Added adoption report and transfer code
This commit is contained in:
+121
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user