Finished medication tracking and UI enhancements
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
import {
|
||||
completePendingBirdTransfersForOwner,
|
||||
createBird,
|
||||
upsertMedicationAdministrationForBird,
|
||||
createMedicationForBird,
|
||||
createPendingBirdTransfer,
|
||||
findBirdsByBandId,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
deleteVetVisitForBird,
|
||||
getBirdById,
|
||||
listBirds,
|
||||
listMedicationAdministrationsForBird,
|
||||
listMedicationsForBird,
|
||||
listVetVisitsForBird,
|
||||
listWeightsForBird,
|
||||
@@ -83,6 +85,7 @@ import type {
|
||||
IntegrationTokenRow,
|
||||
LostBirdMatchRow,
|
||||
MedicationRow,
|
||||
MedicationAdministrationRow,
|
||||
ProviderKey,
|
||||
RescueVerificationStatus,
|
||||
SubscriptionStatus,
|
||||
@@ -238,6 +241,12 @@ const medicationSchema = z
|
||||
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({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
scope: integrationTokenScopeSchema.default('read_write'),
|
||||
@@ -441,6 +450,17 @@ const normalizeMedication = (row: MedicationRow) => ({
|
||||
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) => ({
|
||||
id: row.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) => {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
|
||||
|
||||
@@ -295,6 +295,18 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
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
|
||||
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
|
||||
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 $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
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 = `
|
||||
birds.id,
|
||||
@@ -482,3 +491,53 @@ export const deleteMedicationForBird = async (medicationId: string, birdId: stri
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -156,6 +156,17 @@ export type MedicationRow = {
|
||||
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 = {
|
||||
user: UserRow;
|
||||
session: AuthSessionRow;
|
||||
|
||||
+388
-251
@@ -59,6 +59,16 @@ type Medication = {
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type MedicationAdministration = {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
birdId: string;
|
||||
administeredOn: string;
|
||||
status: 'administered' | 'missed';
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Workspace = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -981,6 +991,7 @@ function App() {
|
||||
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
||||
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
|
||||
const [medications, setMedications] = useState<Medication[]>([]);
|
||||
const [medicationAdministrations, setMedicationAdministrations] = useState<MedicationAdministration[]>([]);
|
||||
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
|
||||
const [allBirdVetVisits, setAllBirdVetVisits] = useState<Record<string, VetVisit[]>>({});
|
||||
const [dismissedAlerts, setDismissedAlerts] = useState<DismissedAlertMap>({});
|
||||
@@ -1048,6 +1059,7 @@ function App() {
|
||||
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||
const [editingMedicationId, setEditingMedicationId] = useState('');
|
||||
const [deletingMedicationId, setDeletingMedicationId] = useState('');
|
||||
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
|
||||
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
||||
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
||||
|
||||
@@ -1491,6 +1503,7 @@ function App() {
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
setMedications([]);
|
||||
setMedicationAdministrations([]);
|
||||
setAllBirdWeights({});
|
||||
setAllBirdVetVisits({});
|
||||
setSelectedBirdId('');
|
||||
@@ -1659,24 +1672,28 @@ function App() {
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
setMedications([]);
|
||||
setMedicationAdministrations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadBirdDetail = async () => {
|
||||
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}/vet-visits`, 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.');
|
||||
}
|
||||
|
||||
const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {};
|
||||
const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {};
|
||||
const medicationsData = (await readJsonSafely<{ medications?: Medication[] }>(medicationsResponse)) ?? {};
|
||||
const medicationAdministrationsData =
|
||||
(await readJsonSafely<{ administrations?: MedicationAdministration[] }>(medicationAdministrationsResponse)) ?? {};
|
||||
|
||||
setWeights(weightsData.weights ?? []);
|
||||
const nextVetVisits = visitsData.vetVisits ?? [];
|
||||
@@ -1686,10 +1703,12 @@ function App() {
|
||||
[selectedBird.id]: nextVetVisits,
|
||||
}));
|
||||
setMedications(medicationsData.medications ?? []);
|
||||
setMedicationAdministrations(medicationAdministrationsData.administrations ?? []);
|
||||
setEditingVetVisitId('');
|
||||
setDeletingVetVisitId('');
|
||||
setEditingMedicationId('');
|
||||
setDeletingMedicationId('');
|
||||
setSavingMedicationAdministrationId('');
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
|
||||
}
|
||||
@@ -1914,6 +1933,7 @@ function App() {
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
setMedications([]);
|
||||
setMedicationAdministrations([]);
|
||||
setAllBirdVetVisits({});
|
||||
setActivePage('overview');
|
||||
} catch (switchError) {
|
||||
@@ -2618,6 +2638,7 @@ function App() {
|
||||
}
|
||||
|
||||
setMedications((current) => current.filter((medication) => medication.id !== medicationId));
|
||||
setMedicationAdministrations((current) => current.filter((administration) => administration.medicationId !== medicationId));
|
||||
if (editingMedicationId === medicationId) {
|
||||
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 () => {
|
||||
if (!selectedBird || deletingBird) {
|
||||
return;
|
||||
@@ -2669,10 +2733,12 @@ function App() {
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
setMedications([]);
|
||||
setMedicationAdministrations([]);
|
||||
setEditingVetVisitId('');
|
||||
setDeletingVetVisitId('');
|
||||
setEditingMedicationId('');
|
||||
setDeletingMedicationId('');
|
||||
setSavingMedicationAdministrationId('');
|
||||
|
||||
if (editingBirdId === selectedBird.id) {
|
||||
setEditingBirdId('');
|
||||
@@ -3226,6 +3292,93 @@ function App() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="app-shell">
|
||||
@@ -3975,158 +4128,17 @@ function App() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Medication</p>
|
||||
<h2>Per-bird medication log</h2>
|
||||
{medications.length ? (
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Medication</p>
|
||||
<h2>Medication schedule</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="form-panel inline-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 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>
|
||||
<div className="recent-list">{renderMedicationList({ showAdministrationControls: true })}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
@@ -4136,7 +4148,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="form-panel inline-form" onSubmit={handleVetVisitSubmit}>
|
||||
<form className="form-panel inline-form care-entry-form" onSubmit={handleVetVisitSubmit}>
|
||||
<label>
|
||||
Visit date
|
||||
<input
|
||||
@@ -4171,7 +4183,7 @@ function App() {
|
||||
placeholder="Exam notes, medications, follow-ups, or restrictions"
|
||||
/>
|
||||
</label>
|
||||
<div className="button-row wide-field">
|
||||
<div className="button-row care-form-actions">
|
||||
<button className="primary-button" type="submit">
|
||||
{editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
|
||||
</button>
|
||||
@@ -4776,101 +4788,116 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="form-panel" onSubmit={handleBirdSubmit}>
|
||||
<label>
|
||||
Bird name
|
||||
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Band ID
|
||||
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
|
||||
</label>
|
||||
<label className="species-picker-field">
|
||||
Species
|
||||
<div className="species-picker">
|
||||
<input
|
||||
value={birdForm.species}
|
||||
onChange={(event) => {
|
||||
setBirdForm({ ...birdForm, species: event.target.value });
|
||||
setSpeciesPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setSpeciesPickerOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setSpeciesPickerOpen(false);
|
||||
}, 120);
|
||||
}}
|
||||
placeholder="Start typing a species"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
{speciesPickerOpen ? (
|
||||
<div className="species-picker-menu">
|
||||
{filteredSpeciesOptions.length ? (
|
||||
filteredSpeciesOptions.map((speciesOption) => (
|
||||
<button
|
||||
key={speciesOption}
|
||||
className={`species-picker-option ${birdForm.species === speciesOption ? 'active' : ''}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setBirdForm({ ...birdForm, species: speciesOption });
|
||||
setSpeciesPickerOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{speciesOption}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="species-picker-empty">No matching species yet. Keep typing to add a custom entry.</div>
|
||||
)}
|
||||
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
|
||||
<section className="settings-nested-card">
|
||||
<div className="settings-nested-header">
|
||||
<p className="eyebrow">Identity</p>
|
||||
<h3>Basic profile</h3>
|
||||
</div>
|
||||
<div className="settings-nested-grid">
|
||||
<label>
|
||||
Bird name
|
||||
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Band ID
|
||||
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
|
||||
</label>
|
||||
<label className="species-picker-field wide-field">
|
||||
Species
|
||||
<div className="species-picker">
|
||||
<input
|
||||
value={birdForm.species}
|
||||
onChange={(event) => {
|
||||
setBirdForm({ ...birdForm, species: event.target.value });
|
||||
setSpeciesPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setSpeciesPickerOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setSpeciesPickerOpen(false);
|
||||
}, 120);
|
||||
}}
|
||||
placeholder="Start typing a species"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
{speciesPickerOpen ? (
|
||||
<div className="species-picker-menu">
|
||||
{filteredSpeciesOptions.length ? (
|
||||
filteredSpeciesOptions.map((speciesOption) => (
|
||||
<button
|
||||
key={speciesOption}
|
||||
className={`species-picker-option ${birdForm.species === speciesOption ? 'active' : ''}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setBirdForm({ ...birdForm, species: speciesOption });
|
||||
setSpeciesPickerOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{speciesOption}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="species-picker-empty">No matching species yet. Keep typing to add a custom entry.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
|
||||
</label>
|
||||
<div className="segmented-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>
|
||||
</section>
|
||||
|
||||
<section className="settings-nested-card">
|
||||
<div className="settings-nested-header">
|
||||
<p className="eyebrow">Dates</p>
|
||||
<h3>Milestones and reminders</h3>
|
||||
</div>
|
||||
<small className="muted">Shown on the bird profile card as a symbol.</small>
|
||||
</div>
|
||||
<div className="settings-nested-grid">
|
||||
<label>
|
||||
DOB
|
||||
<input
|
||||
@@ -4905,6 +4932,15 @@ function App() {
|
||||
/>
|
||||
<small className="muted">Send a reminder on this bird's gotcha day anniversary.</small>
|
||||
</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>
|
||||
Graph color
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="photo-editor">
|
||||
<div className="photo-editor wide-field">
|
||||
<div className="photo-preview-shell">
|
||||
{photoCrop ? (
|
||||
<div
|
||||
@@ -4988,11 +5024,112 @@ function App() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button className="primary-button" type="submit" disabled={savingBird}>
|
||||
{savingBird ? 'Saving...' : editingBird ? 'Save profile changes' : 'Save bird profile'}
|
||||
</button>
|
||||
<div className="settings-save-row">
|
||||
<button className="primary-button" type="submit" disabled={savingBird}>
|
||||
{savingBird ? 'Saving...' : editingBird ? 'Save profile changes' : 'Save bird profile'}
|
||||
</button>
|
||||
</div>
|
||||
</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}
|
||||
</article>
|
||||
|
||||
+56
-1
@@ -533,6 +533,45 @@ textarea {
|
||||
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-sections {
|
||||
display: grid;
|
||||
@@ -1008,6 +1047,15 @@ textarea {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.care-form-actions {
|
||||
align-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.care-entry-form {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.legend-card {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
@@ -1142,6 +1190,12 @@ textarea {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.medication-admin-actions {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -1471,7 +1525,8 @@ label {
|
||||
.chart-footer,
|
||||
.inline-form,
|
||||
.profile-hero,
|
||||
.photo-editor {
|
||||
.photo-editor,
|
||||
.settings-nested-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user