From d5bb87910e6cd42f59282959869392e5ab291721 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Mon, 1 Jun 2026 18:57:53 -0400 Subject: [PATCH] Added adoption report and transfer code --- backend/src/app.ts | 124 ++++- backend/src/db/schema.ts | 26 +- backend/src/repositories/birdRepository.ts | 90 +++- backend/src/types.ts | 12 + docs/API_REFERENCE.md | 34 ++ frontend/src/App.tsx | 579 +++++++++++++++++++-- frontend/src/index.css | 38 ++ 7 files changed, 845 insertions(+), 58 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index ba22456..f8c1d65 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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, @@ -258,6 +261,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() @@ -700,6 +704,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, @@ -3314,7 +3320,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; } @@ -3396,7 +3402,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; } @@ -3404,6 +3410,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); @@ -3477,7 +3595,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; } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index cbff7d8..9cd346c 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -326,8 +326,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'); @@ -380,6 +380,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, diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 357c2df..c3733dd 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -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( + `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( `SELECT id, bird_id, weight_grams, recorded_on::text, notes diff --git a/backend/src/types.ts b/backend/src/types.ts index 4d7c16f..f12dd86 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -191,6 +191,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; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 18df41d..68a8ecc 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5a8a820..ff663ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -401,7 +401,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; type PhotoCropState = { @@ -884,6 +884,14 @@ const getBirdGenderSymbol = (bird: Pick) => { return '?'; }; +const escapeReportHtml = (value: string | number | null | undefined) => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const formatDateTime = (value: string | null) => { if (!value) { return 'Never'; @@ -1640,12 +1648,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>({}); + const [creatingAdoptionReportCode, setCreatingAdoptionReportCode] = useState(false); + const [adoptionReportError, setAdoptionReportError] = useState(''); const [deletingBird, setDeletingBird] = useState(false); const [memorializingBird, setMemorializingBird] = useState(false); const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState(''); @@ -1663,6 +1680,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], @@ -4263,6 +4281,358 @@ function App() { } }; + const handleTransferCodeAcceptSubmit = async (event: React.FormEvent) => { + 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 + ? `

${escapeReportHtml(label)}

    ${entries.map((entry) => `
  • ${escapeReportHtml(entry)}
  • `).join('')}
` + : `

${escapeReportHtml(label)}

Not recorded

`; + }; + const weightRows = weights.length + ? weights + .map( + (entry) => + `${escapeReportHtml(formatDate(entry.recordedOn))}${escapeReportHtml(formatWeight(entry.weightGrams))}${escapeReportHtml(entry.notes || '')}`, + ) + .join('') + : 'No weights recorded.'; + const vetVisitRows = vetVisits.length + ? vetVisits + .map( + (visit) => + `${escapeReportHtml(formatDate(visit.visitedOn))}${escapeReportHtml(visit.clinicName)}${escapeReportHtml(visit.reason)}${escapeReportHtml(visit.notes || '')}`, + ) + .join('') + : 'No vet visits recorded.'; + const noteRows = selectedBirdNotes.length + ? selectedBirdNotes + .map( + (note) => + `
${escapeReportHtml(formatDateTime(note.updatedAt))}

${escapeReportHtml(note.body)}

`, + ) + .join('') + : '

No notes recorded.

'; + const chartSvg = + selectedBirdChart.points.length || selectedBirdChart.historicalPoints.length + ? ` + ${selectedBirdChart.yTicks + .map( + (tick) => + ``, + ) + .join('')} + ${selectedBirdChart.historicalPath ? `` : ''} + ${selectedBirdChart.path ? `` : ''} + ${selectedBirdChart.points + .map((point) => `${escapeReportHtml(point.label)}`) + .join('')} + ` + : '

No weight graph available yet.

'; + + 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(` + + + FlockPal Adoption Report - ${escapeReportHtml(selectedBird.name)} + + + +
+ +
+ ${escapeReportHtml(selectedBird.name)} profile photo +

${escapeReportHtml(selectedBird.name)}

+

Adoption Report

+

Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}

+
+
+ + + + +

${escapeReportHtml(transferCode)}

+
+
+
+

Flock Member Info

+
+ ${profileRows.map(([label, value]) => `
${escapeReportHtml(label)}${escapeReportHtml(value)}
`).join('')} +
+ ${detailList('Motivators', selectedBird.motivators)} + ${detailList('Demotivators', selectedBird.demotivators)} +

Weight Graph

+ ${chartSvg} +

Weight History

+ ${weightRows}
DateWeightNotes
+

Veterinary Clinic Info

+
+ ${vetRows.map(([label, value]) => `
${escapeReportHtml(label)}${escapeReportHtml(value)}
`).join('')} +
+

Vet Visit History

+ ${vetVisitRows}
DateClinicReasonNotes
+

Notes

+ ${noteRows} +
+ + `); + 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', @@ -5649,7 +6019,7 @@ function App() { setBirdForm({ ...birdForm, name: event.target.value })} required />