Added adoption report and transfer code
This commit is contained in:
+527
-52
@@ -372,7 +372,7 @@ type WeightDropAlert = {
|
||||
};
|
||||
|
||||
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
|
||||
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'audit';
|
||||
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'audit';
|
||||
type DismissedAlertMap = Record<string, boolean>;
|
||||
|
||||
type PhotoCropState = {
|
||||
@@ -843,6 +843,14 @@ const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
|
||||
return '?';
|
||||
};
|
||||
|
||||
const escapeReportHtml = (value: string | number | null | undefined) =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const formatDateTime = (value: string | null) => {
|
||||
if (!value) {
|
||||
return 'Never';
|
||||
@@ -1585,12 +1593,21 @@ function App() {
|
||||
birdId: '',
|
||||
destinationOwnerEmail: '',
|
||||
});
|
||||
const [transferCodeAcceptForm, setTransferCodeAcceptForm] = useState({
|
||||
code: '',
|
||||
});
|
||||
const [transferringBird, setTransferringBird] = useState(false);
|
||||
const [acceptingTransferCode, setAcceptingTransferCode] = useState(false);
|
||||
const [transferError, setTransferError] = useState('');
|
||||
const [transferCodeError, setTransferCodeError] = useState('');
|
||||
const [transferNotice, setTransferNotice] = useState<{
|
||||
message: string;
|
||||
previewUrl?: string | null;
|
||||
} | null>(null);
|
||||
const [transferCodeNotice, setTransferCodeNotice] = useState('');
|
||||
const [adoptionTransferCodes, setAdoptionTransferCodes] = useState<Record<string, string>>({});
|
||||
const [creatingAdoptionReportCode, setCreatingAdoptionReportCode] = useState(false);
|
||||
const [adoptionReportError, setAdoptionReportError] = useState('');
|
||||
const [deletingBird, setDeletingBird] = useState(false);
|
||||
const [memorializingBird, setMemorializingBird] = useState(false);
|
||||
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
|
||||
@@ -1608,6 +1625,7 @@ function App() {
|
||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
||||
[birds, selectedBirdId],
|
||||
);
|
||||
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
|
||||
const editingBird = useMemo(
|
||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||
[birds, editingBirdId],
|
||||
@@ -3949,6 +3967,358 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransferCodeAcceptSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (acceptingTransferCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = transferCodeAcceptForm.code.trim();
|
||||
setError('');
|
||||
setTransferCodeError('');
|
||||
setTransferCodeNotice('');
|
||||
setAcceptingTransferCode(true);
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/bird-transfer-codes/${encodeURIComponent(code)}/accept`, authToken, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to accept bird transfer code.'));
|
||||
}
|
||||
|
||||
const data =
|
||||
(await readJsonSafely<{
|
||||
bird?: Bird;
|
||||
sourceWorkspaceName?: string;
|
||||
}>(response)) ?? {};
|
||||
|
||||
if (!data.bird) {
|
||||
throw new Error('Unable to accept bird transfer code.');
|
||||
}
|
||||
|
||||
setBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== data.bird!.id), data.bird!]));
|
||||
setSelectedBirdId(data.bird.id);
|
||||
setTransferCodeAcceptForm({ code: '' });
|
||||
setTransferCodeNotice(`${data.bird.name} was transferred into ${workspace?.name ?? 'your active flock'}.`);
|
||||
} catch (submitError) {
|
||||
const message = submitError instanceof Error ? submitError.message : 'Unable to accept bird transfer code.';
|
||||
setTransferCodeError(message);
|
||||
setError(message);
|
||||
} finally {
|
||||
setAcceptingTransferCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAdoptionTransferCode = async () => {
|
||||
if (!selectedBird || creatingAdoptionReportCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingCode = adoptionTransferCodes[selectedBird.id];
|
||||
if (existingCode) {
|
||||
return existingCode;
|
||||
}
|
||||
|
||||
setAdoptionReportError('');
|
||||
setCreatingAdoptionReportCode(true);
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/birds/${selectedBird.id}/transfer-code`, authToken, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to create adoption transfer code.'));
|
||||
}
|
||||
|
||||
const data =
|
||||
(await readJsonSafely<{
|
||||
transferCode?: {
|
||||
code?: string;
|
||||
bird?: Bird;
|
||||
};
|
||||
}>(response)) ?? {};
|
||||
|
||||
const code = data.transferCode?.code;
|
||||
if (!code) {
|
||||
throw new Error('Unable to create adoption transfer code.');
|
||||
}
|
||||
|
||||
if (data.transferCode?.bird) {
|
||||
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === data.transferCode!.bird!.id ? data.transferCode!.bird! : bird))));
|
||||
}
|
||||
setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: code }));
|
||||
return code;
|
||||
} catch (codeError) {
|
||||
const message = codeError instanceof Error ? codeError.message : 'Unable to create adoption transfer code.';
|
||||
setAdoptionReportError(message);
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setCreatingAdoptionReportCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAdoptionReport = (transferCode: string, reportWindow = window.open('', '_blank'), printFriendly = false) => {
|
||||
if (!selectedBird) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reportWindow) {
|
||||
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const qr = createQrPath(transferCode);
|
||||
const toReportAssetUrl = (value: string) =>
|
||||
value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://') ? value : new URL(value, window.location.origin).toString();
|
||||
const reportLogoUrl = toReportAssetUrl(flockPalLandingArt);
|
||||
const reportPhotoUrl = toReportAssetUrl(selectedBird.photoDataUrl || defaultBirdPhoto);
|
||||
const profileRows = [
|
||||
['Name', selectedBird.name],
|
||||
['Species', selectedBird.species],
|
||||
['Band/tag ID', selectedBird.tagId || 'Not recorded'],
|
||||
['Sex', getBirdGenderLabel(selectedBird)],
|
||||
['Hatch day', formatDate(selectedBird.dateOfBirth)],
|
||||
['Gotcha day', formatDate(selectedBird.gotchaDay)],
|
||||
['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'],
|
||||
['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'],
|
||||
];
|
||||
const vetRows = [
|
||||
['Clinic name', selectedBird.vetClinicName || 'Not recorded'],
|
||||
['Clinic address', selectedBird.vetClinicAddress || 'Not recorded'],
|
||||
['Account #', selectedBird.vetAccountNumber || 'Not recorded'],
|
||||
['Dr. name', selectedBird.vetDoctorName || 'Not recorded'],
|
||||
];
|
||||
const detailList = (label: string, value: string | null) => {
|
||||
const entries = parseBirdProfileList(value);
|
||||
return entries.length
|
||||
? `<section><h3>${escapeReportHtml(label)}</h3><ul>${entries.map((entry) => `<li>${escapeReportHtml(entry)}</li>`).join('')}</ul></section>`
|
||||
: `<section><h3>${escapeReportHtml(label)}</h3><p>Not recorded</p></section>`;
|
||||
};
|
||||
const weightRows = weights.length
|
||||
? weights
|
||||
.map(
|
||||
(entry) =>
|
||||
`<tr><td>${escapeReportHtml(formatDate(entry.recordedOn))}</td><td>${escapeReportHtml(formatWeight(entry.weightGrams))}</td><td>${escapeReportHtml(entry.notes || '')}</td></tr>`,
|
||||
)
|
||||
.join('')
|
||||
: '<tr><td colspan="3">No weights recorded.</td></tr>';
|
||||
const vetVisitRows = vetVisits.length
|
||||
? vetVisits
|
||||
.map(
|
||||
(visit) =>
|
||||
`<tr><td>${escapeReportHtml(formatDate(visit.visitedOn))}</td><td>${escapeReportHtml(visit.clinicName)}</td><td>${escapeReportHtml(visit.reason)}</td><td>${escapeReportHtml(visit.notes || '')}</td></tr>`,
|
||||
)
|
||||
.join('')
|
||||
: '<tr><td colspan="4">No vet visits recorded.</td></tr>';
|
||||
const noteRows = selectedBirdNotes.length
|
||||
? selectedBirdNotes
|
||||
.map(
|
||||
(note) =>
|
||||
`<article class="note"><strong>${escapeReportHtml(formatDateTime(note.updatedAt))}</strong><p>${escapeReportHtml(note.body)}</p></article>`,
|
||||
)
|
||||
.join('')
|
||||
: '<p>No notes recorded.</p>';
|
||||
const chartSvg =
|
||||
selectedBirdChart.points.length || selectedBirdChart.historicalPoints.length
|
||||
? `<svg viewBox="0 0 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}" role="img" aria-label="Weight graph">
|
||||
${selectedBirdChart.yTicks
|
||||
.map(
|
||||
(tick) =>
|
||||
`<line x1="${MEMBER_CHART_PADDING.left}" y1="${tick.y}" x2="${MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}" y2="${tick.y}" class="grid" />`,
|
||||
)
|
||||
.join('')}
|
||||
${selectedBirdChart.historicalPath ? `<path d="${escapeReportHtml(selectedBirdChart.historicalPath)}" class="historical" />` : ''}
|
||||
${selectedBirdChart.path ? `<path d="${escapeReportHtml(selectedBirdChart.path)}" class="current" />` : ''}
|
||||
${selectedBirdChart.points
|
||||
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="4" class="dot"><title>${escapeReportHtml(point.label)}</title></circle>`)
|
||||
.join('')}
|
||||
</svg>`
|
||||
: '<p>No weight graph available yet.</p>';
|
||||
|
||||
const bodyBackground = printFriendly
|
||||
? 'var(--paper)'
|
||||
: `radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%),
|
||||
radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%),
|
||||
radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%),
|
||||
radial-gradient(circle at 86% 78%, rgba(43, 118, 92, 0.24), transparent 24%),
|
||||
radial-gradient(circle at 62% 54%, rgba(48, 114, 160, 0.14), transparent 16%),
|
||||
linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%)`;
|
||||
const headerBackground = printFriendly
|
||||
? '#fff'
|
||||
: 'linear-gradient(135deg, rgba(252, 244, 228, 0.96), rgba(232, 243, 233, 0.9))';
|
||||
const panelBackground = printFriendly ? '#fff' : 'var(--panel)';
|
||||
const backgroundOverlayCss = printFriendly
|
||||
? ''
|
||||
: `body::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='420' viewBox='0 0 360 420'%3E%3Cg fill='none' stroke-linecap='round' stroke-width='18' opacity='.5'%3E%3Cg stroke='%235bb3b7' transform='translate(54 42) rotate(-18)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3Cg stroke='%237eb773' transform='translate(204 78) rotate(28)'%3E%3Cpath d='M0 -38v76'/%3E%3Cpath d='M-32 -19l64 38'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(312 54) rotate(-38)'%3E%3Cpath d='M0 -28v56'/%3E%3Cpath d='M-24 -14l48 28'/%3E%3C/g%3E%3Cg stroke='%23898b93' transform='translate(118 172) rotate(42)'%3E%3Cpath d='M0 -30v60'/%3E%3Cpath d='M-26 -15l52 30'/%3E%3C/g%3E%3Cg stroke='%23b9c945' transform='translate(278 208) rotate(-12)'%3E%3Cpath d='M0 -36v72'/%3E%3Cpath d='M-31 -18l62 36'/%3E%3C/g%3E%3Cg stroke='%235bb3b7' transform='translate(52 326) rotate(22)'%3E%3Cpath d='M0 -26v52'/%3E%3Cpath d='M-22 -13l44 26'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(186 352) rotate(-48)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
background-position: center top;
|
||||
background-repeat: repeat;
|
||||
background-size: 360px 420px;
|
||||
content: "";
|
||||
inset: 0;
|
||||
opacity: 0.42;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
}`;
|
||||
|
||||
reportWindow.document.write(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>FlockPal Adoption Report - ${escapeReportHtml(selectedBird.name)}</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #1f2a2a;
|
||||
--muted: #5d5f59;
|
||||
--paper: #fffdf9;
|
||||
--panel: #fbf7ee;
|
||||
--border: rgba(53, 129, 98, 0.28);
|
||||
--red: #cb3a35;
|
||||
--green: #238a5a;
|
||||
--blue: #2769b3;
|
||||
--gold: #f0b63f;
|
||||
}
|
||||
body {
|
||||
background: ${bodyBackground};
|
||||
color: var(--ink);
|
||||
font-family: Inter, Arial, sans-serif;
|
||||
line-height: 1.45;
|
||||
margin: 32px;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
${backgroundOverlayCss}
|
||||
header {
|
||||
background: ${headerBackground};
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 34px rgba(86, 63, 34, 0.14);
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
grid-template-columns: 210px 1fr 172px;
|
||||
min-height: 228px;
|
||||
padding: 18px;
|
||||
}
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { color: var(--red); font-size: 34px; letter-spacing: 0; }
|
||||
h2 {
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--green);
|
||||
font-size: 19px;
|
||||
margin: 28px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
h3 { color: var(--blue); font-size: 14px; margin: 18px 0 8px; text-transform: uppercase; }
|
||||
.muted { color: var(--muted); margin-top: 6px; }
|
||||
.brand-logo {
|
||||
align-self: center;
|
||||
height: 210px;
|
||||
justify-self: start;
|
||||
object-fit: contain;
|
||||
width: 210px;
|
||||
}
|
||||
.report-title {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
.report-title .muted { margin-top: 8px; }
|
||||
.profile-photo {
|
||||
aspect-ratio: 1;
|
||||
background: #fff;
|
||||
border: 3px solid var(--paper);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.16);
|
||||
height: 132px;
|
||||
margin: 0 auto 12px;
|
||||
object-fit: cover;
|
||||
width: 132px;
|
||||
}
|
||||
.qr { align-self: center; justify-self: end; text-align: center; width: 170px; }
|
||||
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 136px; }
|
||||
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; }
|
||||
.grid { stroke: rgba(53, 129, 98, 0.16); }
|
||||
.current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; }
|
||||
.historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; }
|
||||
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
|
||||
.facts { display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||||
.fact span { color: var(--muted); display: block; font-size: 12px; margin-bottom: 4px; text-transform: uppercase; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
||||
.note { border-bottom: 1px solid var(--border); padding: 10px 0; }
|
||||
.note p { margin-top: 6px; white-space: pre-wrap; }
|
||||
main { margin-top: 24px; }
|
||||
@media print {
|
||||
body { margin: 14mm; }
|
||||
header { box-shadow: none; break-inside: avoid; }
|
||||
button { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
|
||||
<div class="report-title">
|
||||
<img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo">
|
||||
<h1>${escapeReportHtml(selectedBird.name)}</h1>
|
||||
<p class="muted">Adoption Report</p>
|
||||
<p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p>
|
||||
</div>
|
||||
<div class="qr">
|
||||
<svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR">
|
||||
<rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect>
|
||||
<path d="${escapeReportHtml(qr.path)}" fill="#111418"></path>
|
||||
</svg>
|
||||
<p class="code">${escapeReportHtml(transferCode)}</p>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Flock Member Info</h2>
|
||||
<section class="facts">
|
||||
${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
|
||||
</section>
|
||||
${detailList('Motivators', selectedBird.motivators)}
|
||||
${detailList('Demotivators', selectedBird.demotivators)}
|
||||
<h2>Weight Graph</h2>
|
||||
${chartSvg}
|
||||
<h2>Weight History</h2>
|
||||
<table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table>
|
||||
<h2>Veterinary Clinic Info</h2>
|
||||
<section class="facts">
|
||||
${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
|
||||
</section>
|
||||
<h2>Vet Visit History</h2>
|
||||
<table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table>
|
||||
<h2>Notes</h2>
|
||||
${noteRows}
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
reportWindow.document.close();
|
||||
reportWindow.focus();
|
||||
};
|
||||
|
||||
const handleOpenAdoptionReport = async (printFriendly = false) => {
|
||||
const reportWindow = window.open('', '_blank');
|
||||
if (!reportWindow) {
|
||||
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = selectedBirdAdoptionTransferCode || (await handleCreateAdoptionTransferCode());
|
||||
if (code) {
|
||||
openAdoptionReport(code, reportWindow, printFriendly);
|
||||
} else {
|
||||
reportWindow.close();
|
||||
}
|
||||
};
|
||||
|
||||
const saveWorkspaceSettings = async () => {
|
||||
const response = await apiFetch('/workspace', authToken, {
|
||||
method: 'PUT',
|
||||
@@ -5105,7 +5475,7 @@ function App() {
|
||||
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Band ID
|
||||
Band ID, if known
|
||||
<input
|
||||
value={birdForm.tagId}
|
||||
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
|
||||
@@ -5607,6 +5977,19 @@ function App() {
|
||||
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('reports')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedBirdTab === 'reports'}
|
||||
aria-label="Reports"
|
||||
title="Reports"
|
||||
>
|
||||
<svg className="report-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
||||
<path d="M240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM320-240h320v-80H320v80Zm0-160h320v-80H320v80Zm-80-400v200-200 640-640Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('audit')}
|
||||
@@ -6215,6 +6598,51 @@ function App() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedBirdTab === 'reports' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Reports</p>
|
||||
<h2>Adoption report</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Create a print-ready adoption report with profile details, weight history, veterinary clinic info, vet visits,
|
||||
notes, and a transfer code for accepting {selectedBird.name} into another active flock.
|
||||
</p>
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<span>Transfer code</span>
|
||||
<strong>{selectedBirdAdoptionTransferCode || 'Not generated'}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Included records</span>
|
||||
<strong>
|
||||
{weights.length} weights • {vetVisits.length} vet visits • {selectedBirdNotes.length} notes
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button className="secondary-button" onClick={handleCreateAdoptionTransferCode} type="button" disabled={creatingAdoptionReportCode}>
|
||||
{creatingAdoptionReportCode ? 'Creating code...' : selectedBirdAdoptionTransferCode ? 'Code ready' : 'Create transfer code'}
|
||||
</button>
|
||||
<button className="primary-button" onClick={() => handleOpenAdoptionReport(false)} type="button" disabled={creatingAdoptionReportCode}>
|
||||
{creatingAdoptionReportCode ? 'Preparing report...' : 'Open adoption report'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => handleOpenAdoptionReport(true)} type="button" disabled={creatingAdoptionReportCode}>
|
||||
Print-friendly report
|
||||
</button>
|
||||
</div>
|
||||
{adoptionReportError ? (
|
||||
<p className="error-banner" role="alert">
|
||||
{adoptionReportError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedBirdTab === 'audit' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
@@ -6979,7 +7407,7 @@ function App() {
|
||||
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Band ID
|
||||
Band ID, if known
|
||||
<input
|
||||
value={birdForm.tagId}
|
||||
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
|
||||
@@ -7651,56 +8079,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
|
||||
vet visits attached while changing which flock owns it.
|
||||
</p>
|
||||
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
|
||||
<label>
|
||||
Bird to move
|
||||
<select
|
||||
value={flockTransferForm.birdId}
|
||||
onChange={(event) => {
|
||||
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
|
||||
setTransferError('');
|
||||
setTransferNotice(null);
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">Select a bird from this flock</option>
|
||||
{birds.map((bird) => (
|
||||
<option key={bird.id} value={bird.id}>
|
||||
{bird.name} • {bird.species} • {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Receiving flock owner email
|
||||
<input
|
||||
type="email"
|
||||
value={flockTransferForm.destinationOwnerEmail}
|
||||
onChange={(event) => {
|
||||
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
|
||||
setTransferError('');
|
||||
setTransferNotice(null);
|
||||
}}
|
||||
placeholder="owner@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit" disabled={transferringBird}>
|
||||
{transferringBird ? 'Transferring bird...' : 'Transfer bird'}
|
||||
</button>
|
||||
{transferError ? (
|
||||
<p className="error-banner" role="alert">
|
||||
{transferError}
|
||||
<div className="settings-nested-stack">
|
||||
<section className="settings-nested-card">
|
||||
<div className="settings-nested-header">
|
||||
<p className="eyebrow">Owner transfer</p>
|
||||
<h3>Send to a known flock owner</h3>
|
||||
</div>
|
||||
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
|
||||
<label>
|
||||
Bird to move
|
||||
<select
|
||||
value={flockTransferForm.birdId}
|
||||
onChange={(event) => {
|
||||
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
|
||||
setTransferError('');
|
||||
setTransferNotice(null);
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">Select a bird from this flock</option>
|
||||
{birds.map((bird) => (
|
||||
<option key={bird.id} value={bird.id}>
|
||||
{bird.name} • {bird.species} • {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Receiving flock owner email
|
||||
<input
|
||||
type="email"
|
||||
value={flockTransferForm.destinationOwnerEmail}
|
||||
onChange={(event) => {
|
||||
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
|
||||
setTransferError('');
|
||||
setTransferNotice(null);
|
||||
}}
|
||||
placeholder="owner@example.com"
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
) : 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>
|
||||
<form className="form-panel" onSubmit={handleTransferCodeAcceptSubmit}>
|
||||
<label>
|
||||
Transfer code
|
||||
<input
|
||||
value={transferCodeAcceptForm.code}
|
||||
onChange={(event) => {
|
||||
setTransferCodeAcceptForm({ code: event.target.value });
|
||||
setTransferCodeError('');
|
||||
setTransferCodeNotice('');
|
||||
}}
|
||||
placeholder="Paste transfer code"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit" disabled={acceptingTransferCode}>
|
||||
{acceptingTransferCode ? 'Accepting transfer...' : 'Accept bird transfer'}
|
||||
</button>
|
||||
{transferCodeError ? (
|
||||
<p className="error-banner" role="alert">
|
||||
{transferCodeError}
|
||||
</p>
|
||||
) : null}
|
||||
{transferCodeNotice ? (
|
||||
<article className="summary-card" role="status">
|
||||
<strong>Transfer accepted</strong>
|
||||
<span>{transferCodeNotice}</span>
|
||||
</article>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
@@ -1706,6 +1706,44 @@ label {
|
||||
accent-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: min(100%, 420px);
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid rgba(53, 129, 98, 0.24);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
box-shadow: 0 12px 24px rgba(86, 63, 34, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0.1rem 0 0;
|
||||
padding: 0;
|
||||
accent-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.checkbox-row span {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkbox-row strong {
|
||||
color: var(--ink);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.checkbox-row small {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
|
||||
Reference in New Issue
Block a user