Added adoption report and transfer code

This commit is contained in:
Corey Blais
2026-06-01 18:57:53 -04:00
parent d748d2db21
commit 545fae59b2
7 changed files with 845 additions and 58 deletions
+121 -3
View File
@@ -35,6 +35,7 @@ import {
completePendingBirdTransfersForOwner,
createBird,
createBirdMilestoneReminderDelivery,
createBirdTransferCode,
createMedicationForBird,
createPendingBirdTransfer,
findBirdsByBandId,
@@ -45,6 +46,7 @@ import {
deleteVetVisitForBird,
getBirdById,
getBirdByPublicProfileCode,
getOpenBirdTransferCode,
listBirds,
listDueBirdMilestoneReminders,
listMemorializedBirds,
@@ -53,6 +55,7 @@ import {
listVetVisitsForBird,
listWeightsForBird,
memorializeBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace,
updateBird,
updateMemorialReminderPreference,
@@ -244,6 +247,7 @@ const lostBirdReportSchema = z.object({
});
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/);
const birdTransferCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{12,32}$/);
const birdProfileListSchema = z
.string()
.trim()
@@ -646,6 +650,8 @@ const normalizeBird = (row: BirdRow) => ({
latestRecordedOn: row.latest_recorded_on,
});
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
const normalizePublicBirdProfile = (row: BirdRow) => ({
id: row.id,
workspaceId: row.workspace_id,
@@ -3154,7 +3160,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
@@ -3236,7 +3242,7 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
@@ -3244,6 +3250,118 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
}
});
app.post(
'/api/birds/:birdId/transfer-code',
requireAuth,
requireWriteAccess,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant']),
async (req: Request, res: Response, next: NextFunction) => {
try {
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!sourceBird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
if (!ensureBirdWritable(sourceBird, res)) {
return;
}
let transferCode = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
transferCode = await createBirdTransferCode({
code: createBirdTransferCodeValue(),
birdId: sourceBird.id,
sourceWorkspaceId: req.auth!.workspace.id,
requestedByUserId: req.auth!.user.id,
});
break;
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
continue;
}
throw error;
}
}
if (!transferCode) {
throw new Error('Unable to create bird transfer code.');
}
await writeAuditLog(req.auth!, 'bird.transfer_code_created', 'bird', sourceBird.id, sourceBird.name, {
transferCodeId: transferCode.id,
});
res.status(201).json({
transferCode: {
code: transferCode.code,
bird: normalizeBird(sourceBird),
},
});
} catch (error) {
next(error);
}
},
);
app.post(
'/api/bird-transfer-codes/:code/accept',
requireAuth,
requireWriteAccess,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant']),
async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdTransferCodeSchema.safeParse(req.params.code);
if (!parsed.success) {
res.status(404).json({ error: 'Bird transfer code not found.' });
return;
}
try {
const transferCode = await getOpenBirdTransferCode(parsed.data);
if (!transferCode) {
res.status(404).json({ error: 'Bird transfer code not found or already used.' });
return;
}
if (transferCode.source_workspace_id === req.auth!.workspace.id) {
res.status(409).json({ error: 'This bird is already in your active flock.' });
return;
}
const bird = await transferBirdToWorkspace(transferCode.id, transferCode.source_workspace_id, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird is no longer available for transfer.' });
return;
}
await markBirdTransferCodeCompleted(transferCode.transfer_code_id, req.auth!.workspace.id);
await writeAuditLog(req.auth!, 'bird.transfer_code_accepted', 'bird', bird.id, bird.name, {
sourceWorkspaceId: transferCode.source_workspace_id,
sourceWorkspaceName: transferCode.workspace_name,
transferCodeId: transferCode.transfer_code_id,
});
res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
next(error);
}
},
);
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body);
@@ -3317,7 +3435,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
+24 -2
View File
@@ -292,8 +292,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
ON birds (workspace_id, LOWER(tag_id))
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
ON birds (LOWER(BTRIM(tag_id)))
WHERE tag_id IS NOT NULL
AND BTRIM(tag_id) <> ''
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
@@ -346,6 +346,28 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_open_bird
ON bird_transfer_codes (bird_id, created_at DESC)
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_code_open
ON bird_transfer_codes (code)
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+89 -1
View File
@@ -5,6 +5,7 @@ import type {
BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType,
BirdRow,
BirdTransferCodeRow,
LostBirdMatchRow,
MedicationAdministrationRow,
MedicationDoseScheduleItem,
@@ -691,7 +692,7 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
failed += 1;
const message =
typeof error === 'object' && error && 'code' in error && error.code === '23505'
? 'The receiving flock already has a bird using the same band/tag ID.'
? 'That band/tag ID is already in use in FlockPal.'
: error instanceof Error
? error.message
: 'Unable to complete pending bird transfer.';
@@ -702,6 +703,93 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
return { completed, failed };
};
export const createBirdTransferCode = async ({
code,
birdId,
sourceWorkspaceId,
requestedByUserId,
}: {
code: string;
birdId: string;
sourceWorkspaceId: number;
requestedByUserId: string;
}) => {
await db.query(
`UPDATE bird_transfer_codes
SET revoked_at = CURRENT_TIMESTAMP
WHERE bird_id = $1
AND source_workspace_id = $2
AND completed_at IS NULL
AND revoked_at IS NULL`,
[birdId, sourceWorkspaceId],
);
const result = await db.query<BirdTransferCodeRow>(
`INSERT INTO bird_transfer_codes (code, bird_id, source_workspace_id, requested_by_user_id)
VALUES ($1, $2, $3, $4)
RETURNING id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at`,
[code, birdId, sourceWorkspaceId, requestedByUserId],
);
return result.rows[0] ?? null;
};
export const getOpenBirdTransferCode = async (code: string) => {
const result = await db.query<
BirdRow & {
transfer_code_id: string;
code: string;
source_workspace_id: number;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
revoked_at: string | null;
transfer_code_created_at: string;
workspace_name: string;
}
>(
`SELECT
bird_transfer_codes.id AS transfer_code_id,
bird_transfer_codes.code,
bird_transfer_codes.source_workspace_id,
bird_transfer_codes.requested_by_user_id,
bird_transfer_codes.completed_at::text,
bird_transfer_codes.completed_workspace_id,
bird_transfer_codes.revoked_at::text,
bird_transfer_codes.created_at AS transfer_code_created_at,
workspaces.name AS workspace_name,
${birdSelectFields}
FROM bird_transfer_codes
INNER JOIN birds ON birds.id = bird_transfer_codes.bird_id
INNER JOIN workspaces ON workspaces.id = bird_transfer_codes.source_workspace_id
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE bird_transfer_codes.code = $1
AND bird_transfer_codes.completed_at IS NULL
AND bird_transfer_codes.revoked_at IS NULL
AND birds.workspace_id = bird_transfer_codes.source_workspace_id
AND birds.memorialized_at IS NULL`,
[code],
);
return result.rows[0] ?? null;
};
export const markBirdTransferCodeCompleted = async (codeId: string, completedWorkspaceId: number) => {
await db.query(
`UPDATE bird_transfer_codes
SET completed_at = CURRENT_TIMESTAMP,
completed_workspace_id = $2
WHERE id = $1`,
[codeId, completedWorkspaceId],
);
};
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
+12
View File
@@ -162,6 +162,18 @@ export type PendingBirdTransferRow = {
created_at: string;
};
export type BirdTransferCodeRow = {
id: string;
code: string;
bird_id: string;
source_workspace_id: number;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
revoked_at: string | null;
created_at: string;
};
export type WeightRow = {
id: string;
bird_id: string;
+34
View File
@@ -897,6 +897,40 @@ Possible errors:
- `409` if that owner email owns more than one receiving flock
- `409` if the destination flock already has a bird using the same `tagId`
#### `POST /api/birds/:birdId/transfer-code`
Requires a browser session, write access, and role `owner` or `assistant`. Creates a unique transfer code for a bird. Creating a new open code for the same bird revokes earlier unused codes for that bird.
Response `201`:
```json
{
"transferCode": {
"code": "secure-code",
"bird": {}
}
}
```
#### `POST /api/bird-transfer-codes/:code/accept`
Requires a browser session, write access, and role `owner` or `assistant`. Accepts a transfer code into the signed-in user's active flock.
Response `200`:
```json
{
"bird": {},
"sourceWorkspaceName": "Previous Flock",
"workspace": {}
}
```
Possible errors:
- `404` if the code does not exist, was revoked, was already used, or the bird is no longer available
- `409` if the bird is already in the active flock or the active flock already has the same `tagId`
#### `DELETE /api/birds/:birdId`
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
+478 -3
View File
@@ -372,7 +372,7 @@ type WeightDropAlert = {
};
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'audit';
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'audit';
type DismissedAlertMap = Record<string, boolean>;
type PhotoCropState = {
@@ -843,6 +843,14 @@ const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
return '?';
};
const escapeReportHtml = (value: string | number | null | undefined) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatDateTime = (value: string | null) => {
if (!value) {
return 'Never';
@@ -1585,12 +1593,21 @@ function App() {
birdId: '',
destinationOwnerEmail: '',
});
const [transferCodeAcceptForm, setTransferCodeAcceptForm] = useState({
code: '',
});
const [transferringBird, setTransferringBird] = useState(false);
const [acceptingTransferCode, setAcceptingTransferCode] = useState(false);
const [transferError, setTransferError] = useState('');
const [transferCodeError, setTransferCodeError] = useState('');
const [transferNotice, setTransferNotice] = useState<{
message: string;
previewUrl?: string | null;
} | null>(null);
const [transferCodeNotice, setTransferCodeNotice] = useState('');
const [adoptionTransferCodes, setAdoptionTransferCodes] = useState<Record<string, string>>({});
const [creatingAdoptionReportCode, setCreatingAdoptionReportCode] = useState(false);
const [adoptionReportError, setAdoptionReportError] = useState('');
const [deletingBird, setDeletingBird] = useState(false);
const [memorializingBird, setMemorializingBird] = useState(false);
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
@@ -1608,6 +1625,7 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId],
);
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId],
@@ -3949,6 +3967,358 @@ function App() {
}
};
const handleTransferCodeAcceptSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (acceptingTransferCode) {
return;
}
const code = transferCodeAcceptForm.code.trim();
setError('');
setTransferCodeError('');
setTransferCodeNotice('');
setAcceptingTransferCode(true);
try {
const response = await apiFetch(`/bird-transfer-codes/${encodeURIComponent(code)}/accept`, authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to accept bird transfer code.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
sourceWorkspaceName?: string;
}>(response)) ?? {};
if (!data.bird) {
throw new Error('Unable to accept bird transfer code.');
}
setBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== data.bird!.id), data.bird!]));
setSelectedBirdId(data.bird.id);
setTransferCodeAcceptForm({ code: '' });
setTransferCodeNotice(`${data.bird.name} was transferred into ${workspace?.name ?? 'your active flock'}.`);
} catch (submitError) {
const message = submitError instanceof Error ? submitError.message : 'Unable to accept bird transfer code.';
setTransferCodeError(message);
setError(message);
} finally {
setAcceptingTransferCode(false);
}
};
const handleCreateAdoptionTransferCode = async () => {
if (!selectedBird || creatingAdoptionReportCode) {
return null;
}
const existingCode = adoptionTransferCodes[selectedBird.id];
if (existingCode) {
return existingCode;
}
setAdoptionReportError('');
setCreatingAdoptionReportCode(true);
try {
const response = await apiFetch(`/birds/${selectedBird.id}/transfer-code`, authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create adoption transfer code.'));
}
const data =
(await readJsonSafely<{
transferCode?: {
code?: string;
bird?: Bird;
};
}>(response)) ?? {};
const code = data.transferCode?.code;
if (!code) {
throw new Error('Unable to create adoption transfer code.');
}
if (data.transferCode?.bird) {
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === data.transferCode!.bird!.id ? data.transferCode!.bird! : bird))));
}
setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: code }));
return code;
} catch (codeError) {
const message = codeError instanceof Error ? codeError.message : 'Unable to create adoption transfer code.';
setAdoptionReportError(message);
setError(message);
return null;
} finally {
setCreatingAdoptionReportCode(false);
}
};
const openAdoptionReport = (transferCode: string, reportWindow = window.open('', '_blank'), printFriendly = false) => {
if (!selectedBird) {
return;
}
if (!reportWindow) {
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
return;
}
const qr = createQrPath(transferCode);
const toReportAssetUrl = (value: string) =>
value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://') ? value : new URL(value, window.location.origin).toString();
const reportLogoUrl = toReportAssetUrl(flockPalLandingArt);
const reportPhotoUrl = toReportAssetUrl(selectedBird.photoDataUrl || defaultBirdPhoto);
const profileRows = [
['Name', selectedBird.name],
['Species', selectedBird.species],
['Band/tag ID', selectedBird.tagId || 'Not recorded'],
['Sex', getBirdGenderLabel(selectedBird)],
['Hatch day', formatDate(selectedBird.dateOfBirth)],
['Gotcha day', formatDate(selectedBird.gotchaDay)],
['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'],
['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'],
];
const vetRows = [
['Clinic name', selectedBird.vetClinicName || 'Not recorded'],
['Clinic address', selectedBird.vetClinicAddress || 'Not recorded'],
['Account #', selectedBird.vetAccountNumber || 'Not recorded'],
['Dr. name', selectedBird.vetDoctorName || 'Not recorded'],
];
const detailList = (label: string, value: string | null) => {
const entries = parseBirdProfileList(value);
return entries.length
? `<section><h3>${escapeReportHtml(label)}</h3><ul>${entries.map((entry) => `<li>${escapeReportHtml(entry)}</li>`).join('')}</ul></section>`
: `<section><h3>${escapeReportHtml(label)}</h3><p>Not recorded</p></section>`;
};
const weightRows = weights.length
? weights
.map(
(entry) =>
`<tr><td>${escapeReportHtml(formatDate(entry.recordedOn))}</td><td>${escapeReportHtml(formatWeight(entry.weightGrams))}</td><td>${escapeReportHtml(entry.notes || '')}</td></tr>`,
)
.join('')
: '<tr><td colspan="3">No weights recorded.</td></tr>';
const vetVisitRows = vetVisits.length
? vetVisits
.map(
(visit) =>
`<tr><td>${escapeReportHtml(formatDate(visit.visitedOn))}</td><td>${escapeReportHtml(visit.clinicName)}</td><td>${escapeReportHtml(visit.reason)}</td><td>${escapeReportHtml(visit.notes || '')}</td></tr>`,
)
.join('')
: '<tr><td colspan="4">No vet visits recorded.</td></tr>';
const noteRows = selectedBirdNotes.length
? selectedBirdNotes
.map(
(note) =>
`<article class="note"><strong>${escapeReportHtml(formatDateTime(note.updatedAt))}</strong><p>${escapeReportHtml(note.body)}</p></article>`,
)
.join('')
: '<p>No notes recorded.</p>';
const chartSvg =
selectedBirdChart.points.length || selectedBirdChart.historicalPoints.length
? `<svg viewBox="0 0 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}" role="img" aria-label="Weight graph">
${selectedBirdChart.yTicks
.map(
(tick) =>
`<line x1="${MEMBER_CHART_PADDING.left}" y1="${tick.y}" x2="${MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}" y2="${tick.y}" class="grid" />`,
)
.join('')}
${selectedBirdChart.historicalPath ? `<path d="${escapeReportHtml(selectedBirdChart.historicalPath)}" class="historical" />` : ''}
${selectedBirdChart.path ? `<path d="${escapeReportHtml(selectedBirdChart.path)}" class="current" />` : ''}
${selectedBirdChart.points
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="4" class="dot"><title>${escapeReportHtml(point.label)}</title></circle>`)
.join('')}
</svg>`
: '<p>No weight graph available yet.</p>';
const bodyBackground = printFriendly
? 'var(--paper)'
: `radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%),
radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%),
radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%),
radial-gradient(circle at 86% 78%, rgba(43, 118, 92, 0.24), transparent 24%),
radial-gradient(circle at 62% 54%, rgba(48, 114, 160, 0.14), transparent 16%),
linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%)`;
const headerBackground = printFriendly
? '#fff'
: 'linear-gradient(135deg, rgba(252, 244, 228, 0.96), rgba(232, 243, 233, 0.9))';
const panelBackground = printFriendly ? '#fff' : 'var(--panel)';
const backgroundOverlayCss = printFriendly
? ''
: `body::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='420' viewBox='0 0 360 420'%3E%3Cg fill='none' stroke-linecap='round' stroke-width='18' opacity='.5'%3E%3Cg stroke='%235bb3b7' transform='translate(54 42) rotate(-18)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3Cg stroke='%237eb773' transform='translate(204 78) rotate(28)'%3E%3Cpath d='M0 -38v76'/%3E%3Cpath d='M-32 -19l64 38'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(312 54) rotate(-38)'%3E%3Cpath d='M0 -28v56'/%3E%3Cpath d='M-24 -14l48 28'/%3E%3C/g%3E%3Cg stroke='%23898b93' transform='translate(118 172) rotate(42)'%3E%3Cpath d='M0 -30v60'/%3E%3Cpath d='M-26 -15l52 30'/%3E%3C/g%3E%3Cg stroke='%23b9c945' transform='translate(278 208) rotate(-12)'%3E%3Cpath d='M0 -36v72'/%3E%3Cpath d='M-31 -18l62 36'/%3E%3C/g%3E%3Cg stroke='%235bb3b7' transform='translate(52 326) rotate(22)'%3E%3Cpath d='M0 -26v52'/%3E%3Cpath d='M-22 -13l44 26'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(186 352) rotate(-48)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-position: center top;
background-repeat: repeat;
background-size: 360px 420px;
content: "";
inset: 0;
opacity: 0.42;
pointer-events: none;
position: fixed;
z-index: -1;
}`;
reportWindow.document.write(`<!doctype html>
<html>
<head>
<title>FlockPal Adoption Report - ${escapeReportHtml(selectedBird.name)}</title>
<style>
:root {
--ink: #1f2a2a;
--muted: #5d5f59;
--paper: #fffdf9;
--panel: #fbf7ee;
--border: rgba(53, 129, 98, 0.28);
--red: #cb3a35;
--green: #238a5a;
--blue: #2769b3;
--gold: #f0b63f;
}
body {
background: ${bodyBackground};
color: var(--ink);
font-family: Inter, Arial, sans-serif;
line-height: 1.45;
margin: 32px;
min-height: 100vh;
position: relative;
}
${backgroundOverlayCss}
header {
background: ${headerBackground};
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 16px 34px rgba(86, 63, 34, 0.14);
display: grid;
gap: 22px;
grid-template-columns: 210px 1fr 172px;
min-height: 228px;
padding: 18px;
}
h1, h2, h3, p { margin: 0; }
h1 { color: var(--red); font-size: 34px; letter-spacing: 0; }
h2 {
border-bottom: 1px solid var(--border);
color: var(--green);
font-size: 19px;
margin: 28px 0 12px;
padding-bottom: 8px;
}
h3 { color: var(--blue); font-size: 14px; margin: 18px 0 8px; text-transform: uppercase; }
.muted { color: var(--muted); margin-top: 6px; }
.brand-logo {
align-self: center;
height: 210px;
justify-self: start;
object-fit: contain;
width: 210px;
}
.report-title {
align-self: center;
justify-self: center;
text-align: center;
}
.report-title .muted { margin-top: 8px; }
.profile-photo {
aspect-ratio: 1;
background: #fff;
border: 3px solid var(--paper);
border-radius: 18px;
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.16);
height: 132px;
margin: 0 auto 12px;
object-fit: cover;
width: 132px;
}
.qr { align-self: center; justify-self: end; text-align: center; width: 170px; }
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 136px; }
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; }
.grid { stroke: rgba(53, 129, 98, 0.16); }
.current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; }
.historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; }
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
.facts { display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.fact span { color: var(--muted); display: block; font-size: 12px; margin-bottom: 4px; text-transform: uppercase; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.note { border-bottom: 1px solid var(--border); padding: 10px 0; }
.note p { margin-top: 6px; white-space: pre-wrap; }
main { margin-top: 24px; }
@media print {
body { margin: 14mm; }
header { box-shadow: none; break-inside: avoid; }
button { display: none; }
}
</style>
</head>
<body>
<header>
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
<div class="report-title">
<img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo">
<h1>${escapeReportHtml(selectedBird.name)}</h1>
<p class="muted">Adoption Report</p>
<p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p>
</div>
<div class="qr">
<svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR">
<rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect>
<path d="${escapeReportHtml(qr.path)}" fill="#111418"></path>
</svg>
<p class="code">${escapeReportHtml(transferCode)}</p>
</div>
</header>
<main>
<h2>Flock Member Info</h2>
<section class="facts">
${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
</section>
${detailList('Motivators', selectedBird.motivators)}
${detailList('Demotivators', selectedBird.demotivators)}
<h2>Weight Graph</h2>
${chartSvg}
<h2>Weight History</h2>
<table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table>
<h2>Veterinary Clinic Info</h2>
<section class="facts">
${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
</section>
<h2>Vet Visit History</h2>
<table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table>
<h2>Notes</h2>
${noteRows}
</main>
</body>
</html>`);
reportWindow.document.close();
reportWindow.focus();
};
const handleOpenAdoptionReport = async (printFriendly = false) => {
const reportWindow = window.open('', '_blank');
if (!reportWindow) {
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
return;
}
const code = selectedBirdAdoptionTransferCode || (await handleCreateAdoptionTransferCode());
if (code) {
openAdoptionReport(code, reportWindow, printFriendly);
} else {
reportWindow.close();
}
};
const saveWorkspaceSettings = async () => {
const response = await apiFetch('/workspace', authToken, {
method: 'PUT',
@@ -5105,7 +5475,7 @@ function App() {
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID
Band ID, if known
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
@@ -5607,6 +5977,19 @@ function App() {
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('reports')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'reports'}
aria-label="Reports"
title="Reports"
>
<svg className="report-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM320-240h320v-80H320v80Zm0-160h320v-80H320v80Zm-80-400v200-200 640-640Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('audit')}
@@ -6215,6 +6598,51 @@ function App() {
</div>
) : null}
{selectedBirdTab === 'reports' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Reports</p>
<h2>Adoption report</h2>
</div>
</div>
<p className="muted">
Create a print-ready adoption report with profile details, weight history, veterinary clinic info, vet visits,
notes, and a transfer code for accepting {selectedBird.name} into another active flock.
</p>
<div className="detail-grid">
<article className="detail-card">
<span>Transfer code</span>
<strong>{selectedBirdAdoptionTransferCode || 'Not generated'}</strong>
</article>
<article className="detail-card">
<span>Included records</span>
<strong>
{weights.length} weights {vetVisits.length} vet visits {selectedBirdNotes.length} notes
</strong>
</article>
</div>
<div className="button-row">
<button className="secondary-button" onClick={handleCreateAdoptionTransferCode} type="button" disabled={creatingAdoptionReportCode}>
{creatingAdoptionReportCode ? 'Creating code...' : selectedBirdAdoptionTransferCode ? 'Code ready' : 'Create transfer code'}
</button>
<button className="primary-button" onClick={() => handleOpenAdoptionReport(false)} type="button" disabled={creatingAdoptionReportCode}>
{creatingAdoptionReportCode ? 'Preparing report...' : 'Open adoption report'}
</button>
<button className="secondary-button" onClick={() => handleOpenAdoptionReport(true)} type="button" disabled={creatingAdoptionReportCode}>
Print-friendly report
</button>
</div>
{adoptionReportError ? (
<p className="error-banner" role="alert">
{adoptionReportError}
</p>
) : null}
</section>
</div>
) : null}
{selectedBirdTab === 'audit' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
@@ -6979,7 +7407,7 @@ function App() {
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID
Band ID, if known
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
@@ -7651,6 +8079,12 @@ function App() {
Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and
vet visits attached while changing which flock owns it.
</p>
<div className="settings-nested-stack">
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Owner transfer</p>
<h3>Send to a known flock owner</h3>
</div>
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
<label>
Bird to move
@@ -7701,6 +8135,47 @@ function App() {
</article>
) : null}
</form>
</section>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Transfer code</p>
<h3>Accept a bird into this flock</h3>
</div>
<p className="muted">
Enter a transfer code from an adoption or handoff report. The bird will move into your active flock automatically.
</p>
<form className="form-panel" onSubmit={handleTransferCodeAcceptSubmit}>
<label>
Transfer code
<input
value={transferCodeAcceptForm.code}
onChange={(event) => {
setTransferCodeAcceptForm({ code: event.target.value });
setTransferCodeError('');
setTransferCodeNotice('');
}}
placeholder="Paste transfer code"
required
/>
</label>
<button className="primary-button" type="submit" disabled={acceptingTransferCode}>
{acceptingTransferCode ? 'Accepting transfer...' : 'Accept bird transfer'}
</button>
{transferCodeError ? (
<p className="error-banner" role="alert">
{transferCodeError}
</p>
) : null}
{transferCodeNotice ? (
<article className="summary-card" role="status">
<strong>Transfer accepted</strong>
<span>{transferCodeNotice}</span>
</article>
) : null}
</form>
</section>
</div>
</>
) : null}
</article>
+38
View File
@@ -1706,6 +1706,44 @@ label {
accent-color: var(--accent-green);
}
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: min(100%, 420px);
margin: 0.35rem 0 0;
padding: 0.85rem 1rem;
border: 1px solid rgba(53, 129, 98, 0.24);
border-radius: 16px;
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 12px 24px rgba(86, 63, 34, 0.1);
}
.checkbox-row input[type="checkbox"] {
width: 20px;
height: 20px;
flex: 0 0 auto;
margin: 0.1rem 0 0;
padding: 0;
accent-color: var(--accent-green);
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
line-height: 1.3;
}
.checkbox-row strong {
color: var(--ink);
font-size: 0.98rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
}
.primary-button {
border: 0;
border-radius: 18px;