Added adoption report and transfer code
Deploy / deploy-dev (push) Successful in 2m31s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
Corey Blais
2026-06-01 18:57:53 -04:00
parent 3053e3bef5
commit d5bb87910e
7 changed files with 845 additions and 58 deletions
+121 -3
View File
@@ -35,6 +35,7 @@ import {
completePendingBirdTransfersForOwner, completePendingBirdTransfersForOwner,
createBird, createBird,
createBirdMilestoneReminderDelivery, createBirdMilestoneReminderDelivery,
createBirdTransferCode,
createMedicationForBird, createMedicationForBird,
createPendingBirdTransfer, createPendingBirdTransfer,
findBirdsByBandId, findBirdsByBandId,
@@ -45,6 +46,7 @@ import {
deleteVetVisitForBird, deleteVetVisitForBird,
getBirdById, getBirdById,
getBirdByPublicProfileCode, getBirdByPublicProfileCode,
getOpenBirdTransferCode,
listBirds, listBirds,
listDueBirdMilestoneReminders, listDueBirdMilestoneReminders,
listMemorializedBirds, listMemorializedBirds,
@@ -53,6 +55,7 @@ import {
listVetVisitsForBird, listVetVisitsForBird,
listWeightsForBird, listWeightsForBird,
memorializeBird, memorializeBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace, transferBirdToWorkspace,
updateBird, updateBird,
updateMemorialReminderPreference, updateMemorialReminderPreference,
@@ -258,6 +261,7 @@ const lostBirdReportSchema = z.object({
}); });
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/); 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 const birdProfileListSchema = z
.string() .string()
.trim() .trim()
@@ -700,6 +704,8 @@ const normalizeBird = (row: BirdRow) => ({
latestRecordedOn: row.latest_recorded_on, latestRecordedOn: row.latest_recorded_on,
}); });
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
const normalizePublicBirdProfile = (row: BirdRow) => ({ const normalizePublicBirdProfile = (row: BirdRow) => ({
id: row.id, id: row.id,
workspaceId: row.workspace_id, workspaceId: row.workspace_id,
@@ -3314,7 +3320,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { 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; return;
} }
@@ -3396,7 +3402,7 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) { } catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { 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; 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) => { app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body); const parsed = birdSchema.safeParse(req.body);
@@ -3477,7 +3595,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { 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; return;
} }
+24 -2
View File
@@ -326,8 +326,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
DROP INDEX IF EXISTS idx_birds_workspace_tag_id; DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
ON birds (workspace_id, LOWER(tag_id)) ON birds (LOWER(BTRIM(tag_id)))
WHERE tag_id IS NOT NULL WHERE tag_id IS NOT NULL
AND BTRIM(tag_id) <> '' AND BTRIM(tag_id) <> ''
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none'); 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) ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL; 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 ( CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+89 -1
View File
@@ -5,6 +5,7 @@ import type {
BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType, BirdMilestoneReminderType,
BirdRow, BirdRow,
BirdTransferCodeRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
MedicationDoseScheduleItem, MedicationDoseScheduleItem,
@@ -691,7 +692,7 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
failed += 1; failed += 1;
const message = const message =
typeof error === 'object' && error && 'code' in error && error.code === '23505' 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 instanceof Error
? error.message ? error.message
: 'Unable to complete pending bird transfer.'; : 'Unable to complete pending bird transfer.';
@@ -702,6 +703,93 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
return { completed, failed }; 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) => { export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>( const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes `SELECT id, bird_id, weight_grams, recorded_on::text, notes
+12
View File
@@ -191,6 +191,18 @@ export type PendingBirdTransferRow = {
created_at: string; 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 = { export type WeightRow = {
id: string; id: string;
bird_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 that owner email owns more than one receiving flock
- `409` if the destination flock already has a bird using the same `tagId` - `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` #### `DELETE /api/birds/:birdId`
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird. Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
+527 -52
View File
@@ -401,7 +401,7 @@ type WeightDropAlert = {
}; };
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit'; 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 DismissedAlertMap = Record<string, boolean>;
type PhotoCropState = { type PhotoCropState = {
@@ -884,6 +884,14 @@ const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
return '?'; 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) => { const formatDateTime = (value: string | null) => {
if (!value) { if (!value) {
return 'Never'; return 'Never';
@@ -1640,12 +1648,21 @@ function App() {
birdId: '', birdId: '',
destinationOwnerEmail: '', destinationOwnerEmail: '',
}); });
const [transferCodeAcceptForm, setTransferCodeAcceptForm] = useState({
code: '',
});
const [transferringBird, setTransferringBird] = useState(false); const [transferringBird, setTransferringBird] = useState(false);
const [acceptingTransferCode, setAcceptingTransferCode] = useState(false);
const [transferError, setTransferError] = useState(''); const [transferError, setTransferError] = useState('');
const [transferCodeError, setTransferCodeError] = useState('');
const [transferNotice, setTransferNotice] = useState<{ const [transferNotice, setTransferNotice] = useState<{
message: string; message: string;
previewUrl?: string | null; previewUrl?: string | null;
} | null>(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 [deletingBird, setDeletingBird] = useState(false);
const [memorializingBird, setMemorializingBird] = useState(false); const [memorializingBird, setMemorializingBird] = useState(false);
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState(''); const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
@@ -1663,6 +1680,7 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? null, () => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId], [birds, selectedBirdId],
); );
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
const editingBird = useMemo( const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null, () => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId], [birds, editingBirdId],
@@ -4263,6 +4281,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 saveWorkspaceSettings = async () => {
const response = await apiFetch('/workspace', authToken, { const response = await apiFetch('/workspace', authToken, {
method: 'PUT', method: 'PUT',
@@ -5649,7 +6019,7 @@ function App() {
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required /> <input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label> </label>
<label> <label>
Band ID Band ID, if known
<input <input
value={birdForm.tagId} value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
@@ -6151,6 +6521,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" /> <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> </svg>
</button> </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 <button
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`} className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('audit')} onClick={() => setSelectedBirdTab('audit')}
@@ -6759,6 +7142,51 @@ function App() {
</div> </div>
) : null} ) : 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' ? ( {selectedBirdTab === 'audit' ? (
<div className="flock-member-sections" role="tabpanel"> <div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel"> <section className="panel inset-panel">
@@ -7544,7 +7972,7 @@ function App() {
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required /> <input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label> </label>
<label> <label>
Band ID Band ID, if known
<input <input
value={birdForm.tagId} value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
@@ -8216,56 +8644,103 @@ function App() {
Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and 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. vet visits attached while changing which flock owns it.
</p> </p>
<form className="form-panel" onSubmit={handleFlockTransferSubmit}> <div className="settings-nested-stack">
<label> <section className="settings-nested-card">
Bird to move <div className="settings-nested-header">
<select <p className="eyebrow">Owner transfer</p>
value={flockTransferForm.birdId} <h3>Send to a known flock owner</h3>
onChange={(event) => { </div>
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value }); <form className="form-panel" onSubmit={handleFlockTransferSubmit}>
setTransferError(''); <label>
setTransferNotice(null); Bird to move
}} <select
required value={flockTransferForm.birdId}
> onChange={(event) => {
<option value="">Select a bird from this flock</option> setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
{birds.map((bird) => ( setTransferError('');
<option key={bird.id} value={bird.id}> setTransferNotice(null);
{bird.name} {bird.species} {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'} }}
</option> required
))} >
</select> <option value="">Select a bird from this flock</option>
</label> {birds.map((bird) => (
<label> <option key={bird.id} value={bird.id}>
Receiving flock owner email {bird.name} {bird.species} {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
<input </option>
type="email" ))}
value={flockTransferForm.destinationOwnerEmail} </select>
onChange={(event) => { </label>
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value }); <label>
setTransferError(''); Receiving flock owner email
setTransferNotice(null); <input
}} type="email"
placeholder="owner@example.com" value={flockTransferForm.destinationOwnerEmail}
required onChange={(event) => {
/> setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
</label> setTransferError('');
<button className="primary-button" type="submit" disabled={transferringBird}> setTransferNotice(null);
{transferringBird ? 'Transferring bird...' : 'Transfer bird'} }}
</button> placeholder="owner@example.com"
{transferError ? ( required
<p className="error-banner" role="alert"> />
{transferError} </label>
<button className="primary-button" type="submit" disabled={transferringBird}>
{transferringBird ? 'Transferring bird...' : 'Transfer bird'}
</button>
{transferError ? (
<p className="error-banner" role="alert">
{transferError}
</p>
) : null}
{transferNotice ? (
<article className="summary-card" role="status">
<strong>Pending transfer invite sent</strong>
<span>{transferNotice.message}</span>
{transferNotice.previewUrl ? <a href={transferNotice.previewUrl}>Open invite link</a> : null}
</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> </p>
) : null} <form className="form-panel" onSubmit={handleTransferCodeAcceptSubmit}>
{transferNotice ? ( <label>
<article className="summary-card" role="status"> Transfer code
<strong>Pending transfer invite sent</strong> <input
<span>{transferNotice.message}</span> value={transferCodeAcceptForm.code}
{transferNotice.previewUrl ? <a href={transferNotice.previewUrl}>Open invite link</a> : null} onChange={(event) => {
</article> setTransferCodeAcceptForm({ code: event.target.value });
) : null} setTransferCodeError('');
</form> 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} ) : null}
</article> </article>
+38
View File
@@ -1823,6 +1823,44 @@ label {
accent-color: var(--accent-green); 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 { .primary-button {
border: 0; border: 0;
border-radius: 18px; border-radius: 18px;