Finished medication tracking and UI enhancements

This commit is contained in:
Corey Blais
2026-04-19 13:20:02 -04:00
parent 263b98d3d8
commit 872b6c8663
6 changed files with 589 additions and 253 deletions
+59
View File
@@ -30,6 +30,7 @@ import {
import { import {
completePendingBirdTransfersForOwner, completePendingBirdTransfersForOwner,
createBird, createBird,
upsertMedicationAdministrationForBird,
createMedicationForBird, createMedicationForBird,
createPendingBirdTransfer, createPendingBirdTransfer,
findBirdsByBandId, findBirdsByBandId,
@@ -40,6 +41,7 @@ import {
deleteVetVisitForBird, deleteVetVisitForBird,
getBirdById, getBirdById,
listBirds, listBirds,
listMedicationAdministrationsForBird,
listMedicationsForBird, listMedicationsForBird,
listVetVisitsForBird, listVetVisitsForBird,
listWeightsForBird, listWeightsForBird,
@@ -83,6 +85,7 @@ import type {
IntegrationTokenRow, IntegrationTokenRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationRow, MedicationRow,
MedicationAdministrationRow,
ProviderKey, ProviderKey,
RescueVerificationStatus, RescueVerificationStatus,
SubscriptionStatus, SubscriptionStatus,
@@ -238,6 +241,12 @@ const medicationSchema = z
path: ['endDate'], path: ['endDate'],
}); });
const medicationAdministrationSchema = z.object({
administeredOn: dateStringSchema,
status: z.enum(['administered', 'missed']),
notes: z.string().trim().max(500).optional().or(z.literal('')),
});
const integrationTokenCreateSchema = z.object({ const integrationTokenCreateSchema = z.object({
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
scope: integrationTokenScopeSchema.default('read_write'), scope: integrationTokenScopeSchema.default('read_write'),
@@ -441,6 +450,17 @@ const normalizeMedication = (row: MedicationRow) => ({
notes: row.notes, notes: row.notes,
}); });
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
id: row.id,
medicationId: row.medication_id,
birdId: row.bird_id,
administeredOn: row.administered_on,
status: row.status,
notes: row.notes,
createdByUserId: row.created_by_user_id,
createdAt: row.created_at,
});
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({ const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
@@ -2282,6 +2302,45 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW
} }
}); });
app.get('/api/birds/:birdId/medication-administrations', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const administrations = await listMedicationAdministrationsForBird(req.params.birdId, req.auth!.workspace.id);
res.json({ administrations: administrations.map(normalizeMedicationAdministration) });
} catch (error) {
next(error);
}
});
app.post('/api/birds/:birdId/medications/:medicationId/administrations', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = medicationAdministrationSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid medication administration payload', details: parsed.error.flatten() });
return;
}
try {
const administration = await upsertMedicationAdministrationForBird(
req.params.medicationId,
req.params.birdId,
req.auth!.workspace.id,
parsed.data.administeredOn,
parsed.data.status,
emptyToNull(parsed.data.notes),
req.auth!.user.id,
);
if (!administration) {
res.status(404).json({ error: 'Medication not found.' });
return;
}
res.status(201).json({ administration: normalizeMedicationAdministration(administration) });
} catch (error) {
next(error);
}
});
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error); console.error(error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' }); res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
+15
View File
@@ -295,6 +295,18 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CHECK (end_date IS NULL OR end_date >= start_date) CHECK (end_date IS NULL OR end_date >= start_date)
); );
CREATE TABLE IF NOT EXISTS medication_administrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
administered_on DATE NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('administered', 'missed')),
notes VARCHAR(500),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (medication_id, administered_on)
);
CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on
ON weight_records (bird_id, recorded_on DESC); ON weight_records (bird_id, recorded_on DESC);
@@ -304,6 +316,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date
ON medications (bird_id, start_date DESC); ON medications (bird_id, start_date DESC);
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
ON medication_administrations (bird_id, administered_on DESC);
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
+60 -1
View File
@@ -1,5 +1,14 @@
import { db } from '../db/client.js'; import { db } from '../db/client.js';
import type { BirdGender, BirdRow, LostBirdMatchRow, MedicationRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js'; import type {
BirdGender,
BirdRow,
LostBirdMatchRow,
MedicationAdministrationRow,
MedicationRow,
PendingBirdTransferRow,
VetVisitRow,
WeightRow,
} from '../types.js';
const birdSelectFields = ` const birdSelectFields = `
birds.id, birds.id,
@@ -482,3 +491,53 @@ export const deleteMedicationForBird = async (medicationId: string, birdId: stri
return (result.rowCount ?? 0) > 0; return (result.rowCount ?? 0) > 0;
}; };
export const listMedicationAdministrationsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<MedicationAdministrationRow>(
`SELECT id, medication_id, bird_id, administered_on::text, status, notes, created_by_user_id, created_at
FROM medication_administrations
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = medication_administrations.bird_id
AND birds.workspace_id = $2
)
ORDER BY administered_on DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const upsertMedicationAdministrationForBird = async (
medicationId: string,
birdId: string,
workspaceId: number,
administeredOn: string,
status: 'administered' | 'missed',
notes: string | null,
createdByUserId: string | null,
) => {
const result = await db.query<MedicationAdministrationRow>(
`INSERT INTO medication_administrations (medication_id, bird_id, administered_on, status, notes, created_by_user_id)
SELECT $1, $2, $4, $5, $6, $7
WHERE EXISTS (
SELECT 1
FROM medications
JOIN birds ON birds.id = medications.bird_id
WHERE medications.id = $1
AND medications.bird_id = $2
AND birds.workspace_id = $3
)
ON CONFLICT (medication_id, administered_on)
DO UPDATE SET status = EXCLUDED.status,
notes = EXCLUDED.notes,
created_by_user_id = EXCLUDED.created_by_user_id,
created_at = CURRENT_TIMESTAMP
RETURNING id, medication_id, bird_id, administered_on::text, status, notes, created_by_user_id, created_at`,
[medicationId, birdId, workspaceId, administeredOn, status, notes, createdByUserId],
);
return result.rows[0] ?? null;
};
+11
View File
@@ -156,6 +156,17 @@ export type MedicationRow = {
notes: string | null; notes: string | null;
}; };
export type MedicationAdministrationRow = {
id: string;
medication_id: string;
bird_id: string;
administered_on: string;
status: 'administered' | 'missed';
notes: string | null;
created_by_user_id: string | null;
created_at: string;
};
export type AuthContext = { export type AuthContext = {
user: UserRow; user: UserRow;
session: AuthSessionRow; session: AuthSessionRow;
+388 -251
View File
@@ -59,6 +59,16 @@ type Medication = {
notes: string | null; notes: string | null;
}; };
type MedicationAdministration = {
id: string;
medicationId: string;
birdId: string;
administeredOn: string;
status: 'administered' | 'missed';
notes: string | null;
createdAt: string;
};
type Workspace = { type Workspace = {
id: number; id: number;
name: string; name: string;
@@ -981,6 +991,7 @@ function App() {
const [weights, setWeights] = useState<WeightRecord[]>([]); const [weights, setWeights] = useState<WeightRecord[]>([]);
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]); const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
const [medications, setMedications] = useState<Medication[]>([]); const [medications, setMedications] = useState<Medication[]>([]);
const [medicationAdministrations, setMedicationAdministrations] = useState<MedicationAdministration[]>([]);
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({}); const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
const [allBirdVetVisits, setAllBirdVetVisits] = useState<Record<string, VetVisit[]>>({}); const [allBirdVetVisits, setAllBirdVetVisits] = useState<Record<string, VetVisit[]>>({});
const [dismissedAlerts, setDismissedAlerts] = useState<DismissedAlertMap>({}); const [dismissedAlerts, setDismissedAlerts] = useState<DismissedAlertMap>({});
@@ -1048,6 +1059,7 @@ function App() {
const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
const [editingMedicationId, setEditingMedicationId] = useState(''); const [editingMedicationId, setEditingMedicationId] = useState('');
const [deletingMedicationId, setDeletingMedicationId] = useState(''); const [deletingMedicationId, setDeletingMedicationId] = useState('');
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null); const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
@@ -1491,6 +1503,7 @@ function App() {
setWeights([]); setWeights([]);
setVetVisits([]); setVetVisits([]);
setMedications([]); setMedications([]);
setMedicationAdministrations([]);
setAllBirdWeights({}); setAllBirdWeights({});
setAllBirdVetVisits({}); setAllBirdVetVisits({});
setSelectedBirdId(''); setSelectedBirdId('');
@@ -1659,24 +1672,28 @@ function App() {
setWeights([]); setWeights([]);
setVetVisits([]); setVetVisits([]);
setMedications([]); setMedications([]);
setMedicationAdministrations([]);
return; return;
} }
const loadBirdDetail = async () => { const loadBirdDetail = async () => {
try { try {
const [weightsResponse, visitsResponse, medicationsResponse] = await Promise.all([ const [weightsResponse, visitsResponse, medicationsResponse, medicationAdministrationsResponse] = await Promise.all([
apiFetch(`/birds/${selectedBird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken), apiFetch(`/birds/${selectedBird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken),
apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken), apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken),
apiFetch(`/birds/${selectedBird.id}/medications`, authToken), apiFetch(`/birds/${selectedBird.id}/medications`, authToken),
apiFetch(`/birds/${selectedBird.id}/medication-administrations`, authToken),
]); ]);
if (!weightsResponse.ok || !visitsResponse.ok || !medicationsResponse.ok) { if (!weightsResponse.ok || !visitsResponse.ok || !medicationsResponse.ok || !medicationAdministrationsResponse.ok) {
throw new Error('Unable to load flock member details.'); throw new Error('Unable to load flock member details.');
} }
const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {}; const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {};
const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {}; const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {};
const medicationsData = (await readJsonSafely<{ medications?: Medication[] }>(medicationsResponse)) ?? {}; const medicationsData = (await readJsonSafely<{ medications?: Medication[] }>(medicationsResponse)) ?? {};
const medicationAdministrationsData =
(await readJsonSafely<{ administrations?: MedicationAdministration[] }>(medicationAdministrationsResponse)) ?? {};
setWeights(weightsData.weights ?? []); setWeights(weightsData.weights ?? []);
const nextVetVisits = visitsData.vetVisits ?? []; const nextVetVisits = visitsData.vetVisits ?? [];
@@ -1686,10 +1703,12 @@ function App() {
[selectedBird.id]: nextVetVisits, [selectedBird.id]: nextVetVisits,
})); }));
setMedications(medicationsData.medications ?? []); setMedications(medicationsData.medications ?? []);
setMedicationAdministrations(medicationAdministrationsData.administrations ?? []);
setEditingVetVisitId(''); setEditingVetVisitId('');
setDeletingVetVisitId(''); setDeletingVetVisitId('');
setEditingMedicationId(''); setEditingMedicationId('');
setDeletingMedicationId(''); setDeletingMedicationId('');
setSavingMedicationAdministrationId('');
} catch (loadError) { } catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.'); setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
} }
@@ -1914,6 +1933,7 @@ function App() {
setWeights([]); setWeights([]);
setVetVisits([]); setVetVisits([]);
setMedications([]); setMedications([]);
setMedicationAdministrations([]);
setAllBirdVetVisits({}); setAllBirdVetVisits({});
setActivePage('overview'); setActivePage('overview');
} catch (switchError) { } catch (switchError) {
@@ -2618,6 +2638,7 @@ function App() {
} }
setMedications((current) => current.filter((medication) => medication.id !== medicationId)); setMedications((current) => current.filter((medication) => medication.id !== medicationId));
setMedicationAdministrations((current) => current.filter((administration) => administration.medicationId !== medicationId));
if (editingMedicationId === medicationId) { if (editingMedicationId === medicationId) {
handleCancelMedicationEdit(); handleCancelMedicationEdit();
} }
@@ -2628,6 +2649,49 @@ function App() {
} }
}; };
const handleMedicationAdministrationSubmit = async (medicationId: string, status: MedicationAdministration['status']) => {
if (!selectedBird || savingMedicationAdministrationId) {
return;
}
setSavingMedicationAdministrationId(`${medicationId}-${status}`);
setError('');
try {
const administeredOn = new Date().toISOString().slice(0, 10);
const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}/administrations`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ administeredOn, status }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to update medication administration.'));
}
const data = await readJsonSafely<{ administration: MedicationAdministration }>(response);
if (!data?.administration) {
throw new Error('Unable to update medication administration.');
}
setMedicationAdministrations((current) =>
[data.administration, ...current.filter((administration) => administration.id !== data.administration.id)]
.filter(
(administration, index, all) =>
all.findIndex(
(candidate) =>
candidate.medicationId === administration.medicationId && candidate.administeredOn === administration.administeredOn,
) === index,
)
.sort((left, right) => right.administeredOn.localeCompare(left.administeredOn) || right.createdAt.localeCompare(left.createdAt)),
);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to update medication administration.');
} finally {
setSavingMedicationAdministrationId('');
}
};
const handleRemoveBird = async () => { const handleRemoveBird = async () => {
if (!selectedBird || deletingBird) { if (!selectedBird || deletingBird) {
return; return;
@@ -2669,10 +2733,12 @@ function App() {
setWeights([]); setWeights([]);
setVetVisits([]); setVetVisits([]);
setMedications([]); setMedications([]);
setMedicationAdministrations([]);
setEditingVetVisitId(''); setEditingVetVisitId('');
setDeletingVetVisitId(''); setDeletingVetVisitId('');
setEditingMedicationId(''); setEditingMedicationId('');
setDeletingMedicationId(''); setDeletingMedicationId('');
setSavingMedicationAdministrationId('');
if (editingBirdId === selectedBird.id) { if (editingBirdId === selectedBird.id) {
setEditingBirdId(''); setEditingBirdId('');
@@ -3226,6 +3292,93 @@ function App() {
} }
const showWorkspaceSwitcher = authSession.workspaces.length > 1; const showWorkspaceSwitcher = authSession.workspaces.length > 1;
const todayDate = new Date().toISOString().slice(0, 10);
const renderMedicationCard = (medication: Medication, options: { showActions?: boolean; showAdministrationControls?: boolean }) => {
const latestAdministration = medicationAdministrations.find((administration) => administration.medicationId === medication.id);
const todayAdministration = medicationAdministrations.find(
(administration) => administration.medicationId === medication.id && administration.administeredOn === todayDate,
);
const givenActionId = `${medication.id}-administered`;
const missedActionId = `${medication.id}-missed`;
return (
<article key={medication.id} className="vet-visit-card">
<strong>{medication.name}</strong>
<span>
{medication.dosage} {medication.frequency}
{medication.route ? `${medication.route}` : ''}
</span>
<small>
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
</small>
<small>{medication.notes || 'No notes recorded.'}</small>
{latestAdministration ? (
<small>
Last update: {latestAdministration.status === 'administered' ? 'Given' : 'Missed'} on {formatShortDate(latestAdministration.administeredOn)}
</small>
) : null}
{options.showAdministrationControls ? (
<div className="medication-admin-actions">
<small>
Today:{' '}
{todayAdministration
? `${todayAdministration.status === 'administered' ? 'Given' : 'Missed'} on ${formatShortDate(todayAdministration.administeredOn)}`
: 'Not updated yet'}
</small>
<div className="button-row">
<button
className="primary-button"
onClick={() => handleMedicationAdministrationSubmit(medication.id, 'administered')}
type="button"
disabled={Boolean(savingMedicationAdministrationId)}
>
{savingMedicationAdministrationId === givenActionId ? 'Saving...' : 'Given today'}
</button>
<button
className="secondary-button"
onClick={() => handleMedicationAdministrationSubmit(medication.id, 'missed')}
type="button"
disabled={Boolean(savingMedicationAdministrationId)}
>
{savingMedicationAdministrationId === missedActionId ? 'Saving...' : 'Missed today'}
</button>
</div>
</div>
) : null}
{options.showActions ? (
<div className="button-row">
<button className="secondary-button" onClick={() => handleEditMedication(medication)} type="button">
Edit
</button>
{editingMedicationId === medication.id ? (
<button
className="secondary-button"
onClick={() => handleDeleteMedication(medication.id)}
type="button"
disabled={deletingMedicationId === medication.id}
>
{deletingMedicationId === medication.id ? 'Deleting...' : 'Delete'}
</button>
) : null}
</div>
) : null}
</article>
);
};
const renderMedicationList = (options: { showActions?: boolean; showAdministrationControls?: boolean }) =>
medications.length ? (
<>
{activeMedications.length ? <strong>Active medication</strong> : null}
{activeMedications.map((medication) => renderMedicationCard(medication, options))}
{pastMedications.length ? <strong>Past medication</strong> : null}
{pastMedications.map((medication) => renderMedicationCard(medication, options))}
</>
) : (
<article className="vet-visit-card empty-card">
<strong>No medication configured yet</strong>
<small>Add medication here to make it visible on the flock member care page.</small>
</article>
);
return ( return (
<main className="app-shell"> <main className="app-shell">
@@ -3975,158 +4128,17 @@ function App() {
</form> </form>
</section> </section>
<section className="panel inset-panel"> {medications.length ? (
<div className="panel-header"> <section className="panel inset-panel">
<div> <div className="panel-header">
<p className="eyebrow">Medication</p> <div>
<h2>Per-bird medication log</h2> <p className="eyebrow">Medication</p>
<h2>Medication schedule</h2>
</div>
</div> </div>
</div> <div className="recent-list">{renderMedicationList({ showAdministrationControls: true })}</div>
</section>
<form className="form-panel inline-form" onSubmit={handleMedicationSubmit}> ) : null}
<label>
Medication
<input
value={medicationForm.name}
onChange={(event) => setMedicationForm({ ...medicationForm, name: event.target.value })}
placeholder="Meloxicam"
required
/>
</label>
<label>
Dosage
<input
value={medicationForm.dosage}
onChange={(event) => setMedicationForm({ ...medicationForm, dosage: event.target.value })}
placeholder="0.05 mL"
required
/>
</label>
<label>
Frequency
<input
value={medicationForm.frequency}
onChange={(event) => setMedicationForm({ ...medicationForm, frequency: event.target.value })}
placeholder="Every 12 hours"
required
/>
</label>
<label>
Route
<input
value={medicationForm.route}
onChange={(event) => setMedicationForm({ ...medicationForm, route: event.target.value })}
placeholder="Oral"
/>
</label>
<label>
Start date
<input
type="date"
value={medicationForm.startDate}
onChange={(event) => setMedicationForm({ ...medicationForm, startDate: event.target.value })}
required
/>
</label>
<label>
End date
<input
type="date"
value={medicationForm.endDate}
onChange={(event) => setMedicationForm({ ...medicationForm, endDate: event.target.value })}
/>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={medicationForm.notes}
onChange={(event) => setMedicationForm({ ...medicationForm, notes: event.target.value })}
placeholder="Instructions, response, side effects, or prescribing vet"
/>
</label>
<div className="button-row wide-field">
<button className="primary-button" type="submit">
{editingMedicationId ? 'Save medication changes' : 'Save medication'}
</button>
{editingMedicationId ? (
<button className="secondary-button" onClick={handleCancelMedicationEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form>
<div className="recent-list">
{medications.length ? (
<>
{activeMedications.length ? <strong>Active medication</strong> : null}
{activeMedications.map((medication) => (
<article key={medication.id} className="vet-visit-card">
<strong>{medication.name}</strong>
<span>
{medication.dosage} {medication.frequency}
{medication.route ? `${medication.route}` : ''}
</span>
<small>
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
</small>
<small>{medication.notes || 'No notes recorded.'}</small>
<div className="button-row">
<button className="secondary-button" onClick={() => handleEditMedication(medication)} type="button">
Edit
</button>
{editingMedicationId === medication.id ? (
<button
className="secondary-button"
onClick={() => handleDeleteMedication(medication.id)}
type="button"
disabled={deletingMedicationId === medication.id}
>
{deletingMedicationId === medication.id ? 'Deleting...' : 'Delete'}
</button>
) : null}
</div>
</article>
))}
{pastMedications.length ? <strong>Past medication</strong> : null}
{pastMedications.map((medication) => (
<article key={medication.id} className="vet-visit-card">
<strong>{medication.name}</strong>
<span>
{medication.dosage} {medication.frequency}
{medication.route ? `${medication.route}` : ''}
</span>
<small>
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
</small>
<small>{medication.notes || 'No notes recorded.'}</small>
<div className="button-row">
<button className="secondary-button" onClick={() => handleEditMedication(medication)} type="button">
Edit
</button>
{editingMedicationId === medication.id ? (
<button
className="secondary-button"
onClick={() => handleDeleteMedication(medication.id)}
type="button"
disabled={deletingMedicationId === medication.id}
>
{deletingMedicationId === medication.id ? 'Deleting...' : 'Delete'}
</button>
) : null}
</div>
</article>
))}
</>
) : (
<article className="vet-visit-card empty-card">
<strong>No medication logged yet</strong>
<small>Add medication above to track dosage, frequency, dates, and notes for this bird.</small>
</article>
)}
</div>
</section>
<section className="panel inset-panel"> <section className="panel inset-panel">
<div className="panel-header"> <div className="panel-header">
@@ -4136,7 +4148,7 @@ function App() {
</div> </div>
</div> </div>
<form className="form-panel inline-form" onSubmit={handleVetVisitSubmit}> <form className="form-panel inline-form care-entry-form" onSubmit={handleVetVisitSubmit}>
<label> <label>
Visit date Visit date
<input <input
@@ -4171,7 +4183,7 @@ function App() {
placeholder="Exam notes, medications, follow-ups, or restrictions" placeholder="Exam notes, medications, follow-ups, or restrictions"
/> />
</label> </label>
<div className="button-row wide-field"> <div className="button-row care-form-actions">
<button className="primary-button" type="submit"> <button className="primary-button" type="submit">
{editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'} {editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
</button> </button>
@@ -4776,101 +4788,116 @@ function App() {
))} ))}
</div> </div>
<form className="form-panel" onSubmit={handleBirdSubmit}> <form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
<label> <section className="settings-nested-card">
Bird name <div className="settings-nested-header">
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required /> <p className="eyebrow">Identity</p>
</label> <h3>Basic profile</h3>
<label> </div>
Band ID <div className="settings-nested-grid">
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required /> <label>
</label> Bird name
<label className="species-picker-field"> <input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
Species </label>
<div className="species-picker"> <label>
<input Band ID
value={birdForm.species} <input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
onChange={(event) => { </label>
setBirdForm({ ...birdForm, species: event.target.value }); <label className="species-picker-field wide-field">
setSpeciesPickerOpen(true); Species
}} <div className="species-picker">
onFocus={() => setSpeciesPickerOpen(true)} <input
onBlur={() => { value={birdForm.species}
window.setTimeout(() => { onChange={(event) => {
setSpeciesPickerOpen(false); setBirdForm({ ...birdForm, species: event.target.value });
}, 120); setSpeciesPickerOpen(true);
}} }}
placeholder="Start typing a species" onFocus={() => setSpeciesPickerOpen(true)}
autoComplete="off" onBlur={() => {
required window.setTimeout(() => {
/> setSpeciesPickerOpen(false);
{speciesPickerOpen ? ( }, 120);
<div className="species-picker-menu"> }}
{filteredSpeciesOptions.length ? ( placeholder="Start typing a species"
filteredSpeciesOptions.map((speciesOption) => ( autoComplete="off"
<button required
key={speciesOption} />
className={`species-picker-option ${birdForm.species === speciesOption ? 'active' : ''}`} {speciesPickerOpen ? (
onMouseDown={(event) => { <div className="species-picker-menu">
event.preventDefault(); {filteredSpeciesOptions.length ? (
setBirdForm({ ...birdForm, species: speciesOption }); filteredSpeciesOptions.map((speciesOption) => (
setSpeciesPickerOpen(false); <button
}} key={speciesOption}
type="button" className={`species-picker-option ${birdForm.species === speciesOption ? 'active' : ''}`}
> onMouseDown={(event) => {
{speciesOption} event.preventDefault();
</button> setBirdForm({ ...birdForm, species: speciesOption });
)) setSpeciesPickerOpen(false);
) : ( }}
<div className="species-picker-empty">No matching species yet. Keep typing to add a custom entry.</div> type="button"
)} >
{speciesOption}
</button>
))
) : (
<div className="species-picker-empty">No matching species yet. Keep typing to add a custom entry.</div>
)}
</div>
) : null}
</div> </div>
) : null} <small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
</label>
<div className="segmented-field wide-field">
<span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
<button
className={`segmented-option ${birdForm.gender === 'unknown' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'unknown' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'unknown'}
>
<span className="gender-symbol unknown" aria-hidden="true">
?
</span>
Unknown
</button>
<button
className={`segmented-option ${birdForm.gender === 'male' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'male' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'male'}
>
<span className="gender-symbol male" aria-hidden="true">
</span>
Male
</button>
<button
className={`segmented-option ${birdForm.gender === 'female' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'female' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'female'}
>
<span className="gender-symbol female" aria-hidden="true">
</span>
Female
</button>
</div>
<small className="muted">Shown on the bird profile card as a symbol.</small>
</div>
</div> </div>
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small> </section>
</label>
<div className="segmented-field"> <section className="settings-nested-card">
<span>Gender</span> <div className="settings-nested-header">
<div className="segmented-control" role="radiogroup" aria-label="Bird gender"> <p className="eyebrow">Dates</p>
<button <h3>Milestones and reminders</h3>
className={`segmented-option ${birdForm.gender === 'unknown' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'unknown' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'unknown'}
>
<span className="gender-symbol unknown" aria-hidden="true">
?
</span>
Unknown
</button>
<button
className={`segmented-option ${birdForm.gender === 'male' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'male' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'male'}
>
<span className="gender-symbol male" aria-hidden="true">
</span>
Male
</button>
<button
className={`segmented-option ${birdForm.gender === 'female' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'female' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'female'}
>
<span className="gender-symbol female" aria-hidden="true">
</span>
Female
</button>
</div> </div>
<small className="muted">Shown on the bird profile card as a symbol.</small> <div className="settings-nested-grid">
</div>
<label> <label>
DOB DOB
<input <input
@@ -4905,6 +4932,15 @@ function App() {
/> />
<small className="muted">Send a reminder on this bird's gotcha day anniversary.</small> <small className="muted">Send a reminder on this bird's gotcha day anniversary.</small>
</label> </label>
</div>
</section>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Display</p>
<h3>Chart color and photo</h3>
</div>
<div className="settings-nested-grid">
<label> <label>
Graph color Graph color
<input type="color" value={birdForm.chartColor} onChange={(event) => setBirdForm({ ...birdForm, chartColor: event.target.value })} /> <input type="color" value={birdForm.chartColor} onChange={(event) => setBirdForm({ ...birdForm, chartColor: event.target.value })} />
@@ -4914,7 +4950,7 @@ function App() {
<p className="muted">This color will follow this bird across the overview graph and its individual weight trend.</p> <p className="muted">This color will follow this bird across the overview graph and its individual weight trend.</p>
</div> </div>
<div className="photo-editor"> <div className="photo-editor wide-field">
<div className="photo-preview-shell"> <div className="photo-preview-shell">
{photoCrop ? ( {photoCrop ? (
<div <div
@@ -4988,11 +5024,112 @@ function App() {
) : null} ) : null}
</div> </div>
</div> </div>
</div>
</section>
<button className="primary-button" type="submit" disabled={savingBird}> <div className="settings-save-row">
{savingBird ? 'Saving...' : editingBird ? 'Save profile changes' : 'Save bird profile'} <button className="primary-button" type="submit" disabled={savingBird}>
</button> {savingBird ? 'Saving...' : editingBird ? 'Save profile changes' : 'Save bird profile'}
</button>
</div>
</form> </form>
<section className="settings-subsection settings-nested-card">
<div className="panel-header">
<div>
<p className="eyebrow">Medication</p>
<h3>Medication configuration</h3>
<p className="muted">
Add per-bird medications here. The medication section only appears on the flock member page after medication is configured.
</p>
</div>
</div>
{selectedBird ? (
<>
<form className="form-panel inline-form care-entry-form" onSubmit={handleMedicationSubmit}>
<label>
Medication
<input
value={medicationForm.name}
onChange={(event) => setMedicationForm({ ...medicationForm, name: event.target.value })}
placeholder="Meloxicam"
required
/>
</label>
<label>
Dosage
<input
value={medicationForm.dosage}
onChange={(event) => setMedicationForm({ ...medicationForm, dosage: event.target.value })}
placeholder="0.05 mL"
required
/>
</label>
<label>
Frequency
<input
value={medicationForm.frequency}
onChange={(event) => setMedicationForm({ ...medicationForm, frequency: event.target.value })}
placeholder="Every 12 hours"
required
/>
</label>
<label>
Route
<input
value={medicationForm.route}
onChange={(event) => setMedicationForm({ ...medicationForm, route: event.target.value })}
placeholder="Oral"
/>
</label>
<label>
Start date
<input
type="date"
value={medicationForm.startDate}
onChange={(event) => setMedicationForm({ ...medicationForm, startDate: event.target.value })}
required
/>
</label>
<label>
End date
<input
type="date"
value={medicationForm.endDate}
onChange={(event) => setMedicationForm({ ...medicationForm, endDate: event.target.value })}
/>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={medicationForm.notes}
onChange={(event) => setMedicationForm({ ...medicationForm, notes: event.target.value })}
placeholder="Instructions, response, side effects, or prescribing vet"
/>
</label>
<div className="button-row care-form-actions">
<button className="primary-button" type="submit">
{editingMedicationId ? 'Save medication changes' : 'Save medication'}
</button>
{editingMedicationId ? (
<button className="secondary-button" onClick={handleCancelMedicationEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form>
<div className="recent-list">{renderMedicationList({ showActions: true })}</div>
</>
) : (
<article className="vet-visit-card empty-card">
<strong>Select a bird profile first</strong>
<small>Choose an existing bird above before adding medication configuration.</small>
</article>
)}
</section>
</> </>
) : null} ) : null}
</article> </article>
+56 -1
View File
@@ -533,6 +533,45 @@ textarea {
order: 2; order: 2;
} }
.settings-subsection {
display: grid;
gap: 1rem;
}
.settings-nested-stack {
gap: 1rem;
}
.settings-nested-card {
display: grid;
gap: 1rem;
padding: 1rem;
border: 1px solid rgba(53, 129, 98, 0.16);
border-radius: 22px;
background: rgba(255, 252, 246, 0.62);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.settings-nested-header {
display: grid;
gap: 0.15rem;
}
.settings-nested-header h3 {
margin: 0;
}
.settings-nested-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.settings-save-row {
display: flex;
justify-content: flex-start;
}
.flock-member-panel, .flock-member-panel,
.flock-member-sections { .flock-member-sections {
display: grid; display: grid;
@@ -1008,6 +1047,15 @@ textarea {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.care-form-actions {
align-self: start;
margin-top: 0;
}
.care-entry-form {
margin-bottom: 1rem;
}
.legend-card { .legend-card {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center; align-items: center;
@@ -1142,6 +1190,12 @@ textarea {
opacity: 0.82; opacity: 0.82;
} }
.medication-admin-actions {
display: grid;
gap: 0.65rem;
padding-top: 0.35rem;
}
.form-panel { .form-panel {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@@ -1471,7 +1525,8 @@ label {
.chart-footer, .chart-footer,
.inline-form, .inline-form,
.profile-hero, .profile-hero,
.photo-editor { .photo-editor,
.settings-nested-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }