Finished medication tracking and UI enhancements
This commit is contained in:
@@ -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' });
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user