medication tracking started
This commit is contained in:
@@ -30,18 +30,22 @@ import {
|
|||||||
import {
|
import {
|
||||||
completePendingBirdTransfersForOwner,
|
completePendingBirdTransfersForOwner,
|
||||||
createBird,
|
createBird,
|
||||||
|
createMedicationForBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
findBirdsByBandId,
|
findBirdsByBandId,
|
||||||
createVetVisitForBird,
|
createVetVisitForBird,
|
||||||
createWeightForBird,
|
createWeightForBird,
|
||||||
deleteBird,
|
deleteBird,
|
||||||
|
deleteMedicationForBird,
|
||||||
deleteVetVisitForBird,
|
deleteVetVisitForBird,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
listBirds,
|
listBirds,
|
||||||
|
listMedicationsForBird,
|
||||||
listVetVisitsForBird,
|
listVetVisitsForBird,
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
updateBird,
|
updateBird,
|
||||||
|
updateMedicationForBird,
|
||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||||
@@ -78,6 +82,7 @@ import type {
|
|||||||
BirdRow,
|
BirdRow,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
|
MedicationRow,
|
||||||
ProviderKey,
|
ProviderKey,
|
||||||
RescueVerificationStatus,
|
RescueVerificationStatus,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -218,6 +223,21 @@ const vetVisitSchema = z.object({
|
|||||||
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const medicationSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(160),
|
||||||
|
dosage: z.string().trim().min(1).max(160),
|
||||||
|
frequency: z.string().trim().min(1).max(160),
|
||||||
|
route: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
|
startDate: dateStringSchema,
|
||||||
|
endDate: dateStringSchema.optional().or(z.literal('')),
|
||||||
|
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
.refine((value) => !value.endDate || value.endDate >= value.startDate, {
|
||||||
|
message: 'End date must be on or after start date.',
|
||||||
|
path: ['endDate'],
|
||||||
|
});
|
||||||
|
|
||||||
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'),
|
||||||
@@ -409,6 +429,18 @@ const normalizeVetVisit = (row: VetVisitRow) => ({
|
|||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeMedication = (row: MedicationRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
birdId: row.bird_id,
|
||||||
|
name: row.name,
|
||||||
|
dosage: row.dosage,
|
||||||
|
frequency: row.frequency,
|
||||||
|
route: row.route,
|
||||||
|
startDate: row.start_date,
|
||||||
|
endDate: row.end_date,
|
||||||
|
notes: row.notes,
|
||||||
|
});
|
||||||
|
|
||||||
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
|
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
@@ -2147,6 +2179,109 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/birds/:birdId/medications', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const medications = await listMedicationsForBird(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
res.json({ medications: medications.map(normalizeMedication) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = medicationSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid medication payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const medication = await createMedicationForBird(
|
||||||
|
req.params.birdId,
|
||||||
|
parsed.data.name,
|
||||||
|
parsed.data.dosage,
|
||||||
|
parsed.data.frequency,
|
||||||
|
emptyToNull(parsed.data.route),
|
||||||
|
parsed.data.startDate,
|
||||||
|
emptyToNull(parsed.data.endDate),
|
||||||
|
emptyToNull(parsed.data.notes),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ medication: normalizeMedication(medication!) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = medicationSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid medication payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const medication = await updateMedicationForBird(
|
||||||
|
req.params.medicationId,
|
||||||
|
req.params.birdId,
|
||||||
|
parsed.data.name,
|
||||||
|
parsed.data.dosage,
|
||||||
|
parsed.data.frequency,
|
||||||
|
emptyToNull(parsed.data.route),
|
||||||
|
parsed.data.startDate,
|
||||||
|
emptyToNull(parsed.data.endDate),
|
||||||
|
emptyToNull(parsed.data.notes),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!medication) {
|
||||||
|
res.status(404).json({ error: 'Medication not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ medication: normalizeMedication(medication) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await deleteMedicationForBird(req.params.medicationId, req.params.birdId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Medication not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} 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' });
|
||||||
|
|||||||
@@ -281,12 +281,29 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS medications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
dosage VARCHAR(160) NOT NULL,
|
||||||
|
frequency VARCHAR(160) NOT NULL,
|
||||||
|
route VARCHAR(80),
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
notes VARCHAR(1000),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CHECK (end_date IS NULL OR end_date >= start_date)
|
||||||
|
);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on
|
CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on
|
||||||
ON vet_visits (bird_id, visited_on DESC);
|
ON vet_visits (bird_id, visited_on DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date
|
||||||
|
ON medications (bird_id, start_date DESC);
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import type { BirdGender, BirdRow, LostBirdMatchRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js';
|
import type { BirdGender, BirdRow, LostBirdMatchRow, MedicationRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js';
|
||||||
|
|
||||||
const birdSelectFields = `
|
const birdSelectFields = `
|
||||||
birds.id,
|
birds.id,
|
||||||
@@ -403,3 +403,82 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
|
|||||||
|
|
||||||
return (result.rowCount ?? 0) > 0;
|
return (result.rowCount ?? 0) > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
|
const result = await db.query<MedicationRow>(
|
||||||
|
`SELECT id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes
|
||||||
|
FROM medications
|
||||||
|
WHERE bird_id = $1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM birds
|
||||||
|
WHERE birds.id = medications.bird_id
|
||||||
|
AND birds.workspace_id = $2
|
||||||
|
)
|
||||||
|
ORDER BY COALESCE(end_date, '9999-12-31'::date) DESC, start_date DESC, created_at DESC`,
|
||||||
|
[birdId, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMedicationForBird = async (
|
||||||
|
birdId: string,
|
||||||
|
name: string,
|
||||||
|
dosage: string,
|
||||||
|
frequency: string,
|
||||||
|
route: string | null,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string | null,
|
||||||
|
notes: string | null,
|
||||||
|
) => {
|
||||||
|
const result = await db.query<MedicationRow>(
|
||||||
|
`INSERT INTO medications (bird_id, name, dosage, frequency, route, start_date, end_date, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes`,
|
||||||
|
[birdId, name, dosage, frequency, route, startDate, endDate, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateMedicationForBird = async (
|
||||||
|
medicationId: string,
|
||||||
|
birdId: string,
|
||||||
|
name: string,
|
||||||
|
dosage: string,
|
||||||
|
frequency: string,
|
||||||
|
route: string | null,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string | null,
|
||||||
|
notes: string | null,
|
||||||
|
) => {
|
||||||
|
const result = await db.query<MedicationRow>(
|
||||||
|
`UPDATE medications
|
||||||
|
SET name = $3,
|
||||||
|
dosage = $4,
|
||||||
|
frequency = $5,
|
||||||
|
route = $6,
|
||||||
|
start_date = $7,
|
||||||
|
end_date = $8,
|
||||||
|
notes = $9
|
||||||
|
WHERE id = $1
|
||||||
|
AND bird_id = $2
|
||||||
|
RETURNING id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes`,
|
||||||
|
[medicationId, birdId, name, dosage, frequency, route, startDate, endDate, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteMedicationForBird = async (medicationId: string, birdId: string) => {
|
||||||
|
const result = await db.query<{ id: string }>(
|
||||||
|
`DELETE FROM medications
|
||||||
|
WHERE id = $1
|
||||||
|
AND bird_id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[medicationId, birdId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ export type VetVisitRow = {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MedicationRow = {
|
||||||
|
id: string;
|
||||||
|
bird_id: string;
|
||||||
|
name: string;
|
||||||
|
dosage: string;
|
||||||
|
frequency: string;
|
||||||
|
route: string | null;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthContext = {
|
export type AuthContext = {
|
||||||
user: UserRow;
|
user: UserRow;
|
||||||
session: AuthSessionRow;
|
session: AuthSessionRow;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ Endpoints that accept either browser session tokens or integration tokens:
|
|||||||
- `/api/birds`
|
- `/api/birds`
|
||||||
- `/api/birds/:birdId/weights`
|
- `/api/birds/:birdId/weights`
|
||||||
- `/api/birds/:birdId/vet-visits`
|
- `/api/birds/:birdId/vet-visits`
|
||||||
|
- `/api/birds/:birdId/medications`
|
||||||
|
|
||||||
Read-only integration tokens can call read endpoints, but cannot call write endpoints.
|
Read-only integration tokens can call read endpoints, but cannot call write endpoints.
|
||||||
|
|
||||||
@@ -245,6 +246,22 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Medication
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"birdId": "uuid",
|
||||||
|
"name": "Meloxicam",
|
||||||
|
"dosage": "0.05 mL",
|
||||||
|
"frequency": "Every 12 hours",
|
||||||
|
"route": "Oral",
|
||||||
|
"startDate": "2026-04-14",
|
||||||
|
"endDate": null,
|
||||||
|
"notes": "Give with food"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Common Validation Rules
|
## Common Validation Rules
|
||||||
|
|
||||||
- Dates use `YYYY-MM-DD`
|
- Dates use `YYYY-MM-DD`
|
||||||
@@ -885,6 +902,59 @@ Possible errors:
|
|||||||
|
|
||||||
- `404` if the bird does not exist in the active workspace
|
- `404` if the bird does not exist in the active workspace
|
||||||
|
|
||||||
|
### Medications
|
||||||
|
|
||||||
|
#### `GET /api/birds/:birdId/medications`
|
||||||
|
|
||||||
|
Requires auth. Lists medication records for a bird in the active workspace.
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"medications": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/birds/:birdId/medications`
|
||||||
|
|
||||||
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Creates a medication record.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Meloxicam",
|
||||||
|
"dosage": "0.05 mL",
|
||||||
|
"frequency": "Every 12 hours",
|
||||||
|
"route": "Oral",
|
||||||
|
"startDate": "2026-04-14",
|
||||||
|
"endDate": "",
|
||||||
|
"notes": "Give with food"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `201`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"medication": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `PUT /api/birds/:birdId/medications/:medicationId`
|
||||||
|
|
||||||
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Updates a medication record.
|
||||||
|
|
||||||
|
#### `DELETE /api/birds/:birdId/medications/:medicationId`
|
||||||
|
|
||||||
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a medication record.
|
||||||
|
|
||||||
|
Possible errors:
|
||||||
|
|
||||||
|
- `400` if `endDate` is before `startDate`
|
||||||
|
- `404` if the bird or medication does not exist in the active workspace
|
||||||
|
|
||||||
### Integration Tokens
|
### Integration Tokens
|
||||||
|
|
||||||
These endpoints are for browser-session users managing their own automation tokens. They are not accessible with an integration token itself.
|
These endpoints are for browser-session users managing their own automation tokens. They are not accessible with an integration token itself.
|
||||||
|
|||||||
+310
-2
@@ -47,6 +47,18 @@ type VetVisit = {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Medication = {
|
||||||
|
id: string;
|
||||||
|
birdId: string;
|
||||||
|
name: string;
|
||||||
|
dosage: string;
|
||||||
|
frequency: string;
|
||||||
|
route: string | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type Workspace = {
|
type Workspace = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -968,6 +980,7 @@ function App() {
|
|||||||
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
||||||
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 [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>({});
|
||||||
@@ -1011,6 +1024,15 @@ function App() {
|
|||||||
reason: '',
|
reason: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
const [medicationForm, setMedicationForm] = useState({
|
||||||
|
name: '',
|
||||||
|
dosage: '',
|
||||||
|
frequency: '',
|
||||||
|
route: '',
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
endDate: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
const [flockTransferForm, setFlockTransferForm] = useState({
|
const [flockTransferForm, setFlockTransferForm] = useState({
|
||||||
birdId: '',
|
birdId: '',
|
||||||
destinationOwnerEmail: '',
|
destinationOwnerEmail: '',
|
||||||
@@ -1024,6 +1046,8 @@ function App() {
|
|||||||
const [deletingBird, setDeletingBird] = useState(false);
|
const [deletingBird, setDeletingBird] = useState(false);
|
||||||
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
||||||
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||||
|
const [editingMedicationId, setEditingMedicationId] = useState('');
|
||||||
|
const [deletingMedicationId, setDeletingMedicationId] = useState('');
|
||||||
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
||||||
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
||||||
|
|
||||||
@@ -1219,6 +1243,15 @@ function App() {
|
|||||||
const vetVisitDueOverflowCount = Math.max(vetVisitDueBirds.length - 3, 0);
|
const vetVisitDueOverflowCount = Math.max(vetVisitDueBirds.length - 3, 0);
|
||||||
const vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]);
|
const vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]);
|
||||||
|
|
||||||
|
const activeMedications = useMemo(
|
||||||
|
() => medications.filter((medication) => !medication.endDate || parseDateValue(medication.endDate) >= parseDateValue(new Date().toISOString().slice(0, 10))),
|
||||||
|
[medications],
|
||||||
|
);
|
||||||
|
const pastMedications = useMemo(
|
||||||
|
() => medications.filter((medication) => medication.endDate && parseDateValue(medication.endDate) < parseDateValue(new Date().toISOString().slice(0, 10))),
|
||||||
|
[medications],
|
||||||
|
);
|
||||||
|
|
||||||
const filteredSpeciesOptions = useMemo(() => {
|
const filteredSpeciesOptions = useMemo(() => {
|
||||||
const query = birdForm.species.trim().toLowerCase();
|
const query = birdForm.species.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -1457,6 +1490,7 @@ function App() {
|
|||||||
setBirds([]);
|
setBirds([]);
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
|
setMedications([]);
|
||||||
setAllBirdWeights({});
|
setAllBirdWeights({});
|
||||||
setAllBirdVetVisits({});
|
setAllBirdVetVisits({});
|
||||||
setSelectedBirdId('');
|
setSelectedBirdId('');
|
||||||
@@ -1624,22 +1658,25 @@ function App() {
|
|||||||
if (!selectedBird?.id) {
|
if (!selectedBird?.id) {
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
|
setMedications([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadBirdDetail = async () => {
|
const loadBirdDetail = async () => {
|
||||||
try {
|
try {
|
||||||
const [weightsResponse, visitsResponse] = await Promise.all([
|
const [weightsResponse, visitsResponse, medicationsResponse] = 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),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!weightsResponse.ok || !visitsResponse.ok) {
|
if (!weightsResponse.ok || !visitsResponse.ok || !medicationsResponse.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)) ?? {};
|
||||||
|
|
||||||
setWeights(weightsData.weights ?? []);
|
setWeights(weightsData.weights ?? []);
|
||||||
const nextVetVisits = visitsData.vetVisits ?? [];
|
const nextVetVisits = visitsData.vetVisits ?? [];
|
||||||
@@ -1648,8 +1685,11 @@ function App() {
|
|||||||
...current,
|
...current,
|
||||||
[selectedBird.id]: nextVetVisits,
|
[selectedBird.id]: nextVetVisits,
|
||||||
}));
|
}));
|
||||||
|
setMedications(medicationsData.medications ?? []);
|
||||||
setEditingVetVisitId('');
|
setEditingVetVisitId('');
|
||||||
setDeletingVetVisitId('');
|
setDeletingVetVisitId('');
|
||||||
|
setEditingMedicationId('');
|
||||||
|
setDeletingMedicationId('');
|
||||||
} 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.');
|
||||||
}
|
}
|
||||||
@@ -1873,6 +1913,7 @@ function App() {
|
|||||||
setEditingBirdId('');
|
setEditingBirdId('');
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
|
setMedications([]);
|
||||||
setAllBirdVetVisits({});
|
setAllBirdVetVisits({});
|
||||||
setActivePage('overview');
|
setActivePage('overview');
|
||||||
} catch (switchError) {
|
} catch (switchError) {
|
||||||
@@ -2477,6 +2518,116 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMedicationSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedBird) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isEditingMedication = Boolean(editingMedicationId);
|
||||||
|
const response = await apiFetch(
|
||||||
|
isEditingMedication ? `/birds/${selectedBird.id}/medications/${editingMedicationId}` : `/birds/${selectedBird.id}/medications`,
|
||||||
|
authToken,
|
||||||
|
{
|
||||||
|
method: isEditingMedication ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(medicationForm),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, `Unable to ${isEditingMedication ? 'update' : 'save'} medication.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readJsonSafely<{ medication: Medication }>(response);
|
||||||
|
if (!data?.medication) {
|
||||||
|
throw new Error(`Unable to ${isEditingMedication ? 'update' : 'save'} medication.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMedications((current) =>
|
||||||
|
(isEditingMedication
|
||||||
|
? current.map((medication) => (medication.id === data.medication.id ? data.medication : medication))
|
||||||
|
: [data.medication, ...current]
|
||||||
|
).sort((left, right) => {
|
||||||
|
const leftEnd = left.endDate ?? '9999-12-31';
|
||||||
|
const rightEnd = right.endDate ?? '9999-12-31';
|
||||||
|
return rightEnd.localeCompare(leftEnd) || right.startDate.localeCompare(left.startDate);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setMedicationForm({
|
||||||
|
name: '',
|
||||||
|
dosage: '',
|
||||||
|
frequency: '',
|
||||||
|
route: '',
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
endDate: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
setEditingMedicationId('');
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : 'Unable to save medication.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditMedication = (medication: Medication) => {
|
||||||
|
setEditingMedicationId(medication.id);
|
||||||
|
setMedicationForm({
|
||||||
|
name: medication.name,
|
||||||
|
dosage: medication.dosage,
|
||||||
|
frequency: medication.frequency,
|
||||||
|
route: medication.route ?? '',
|
||||||
|
startDate: medication.startDate,
|
||||||
|
endDate: medication.endDate ?? '',
|
||||||
|
notes: medication.notes ?? '',
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelMedicationEdit = () => {
|
||||||
|
setEditingMedicationId('');
|
||||||
|
setMedicationForm({
|
||||||
|
name: '',
|
||||||
|
dosage: '',
|
||||||
|
frequency: '',
|
||||||
|
route: '',
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
endDate: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteMedication = async (medicationId: string) => {
|
||||||
|
if (!selectedBird || deletingMedicationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingMedicationId(medicationId);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}`, authToken, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to remove medication.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setMedications((current) => current.filter((medication) => medication.id !== medicationId));
|
||||||
|
if (editingMedicationId === medicationId) {
|
||||||
|
handleCancelMedicationEdit();
|
||||||
|
}
|
||||||
|
} catch (removeError) {
|
||||||
|
setError(removeError instanceof Error ? removeError.message : 'Unable to remove medication.');
|
||||||
|
} finally {
|
||||||
|
setDeletingMedicationId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveBird = async () => {
|
const handleRemoveBird = async () => {
|
||||||
if (!selectedBird || deletingBird) {
|
if (!selectedBird || deletingBird) {
|
||||||
return;
|
return;
|
||||||
@@ -2517,8 +2668,11 @@ function App() {
|
|||||||
setSelectedBirdId('');
|
setSelectedBirdId('');
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
|
setMedications([]);
|
||||||
setEditingVetVisitId('');
|
setEditingVetVisitId('');
|
||||||
setDeletingVetVisitId('');
|
setDeletingVetVisitId('');
|
||||||
|
setEditingMedicationId('');
|
||||||
|
setDeletingMedicationId('');
|
||||||
|
|
||||||
if (editingBirdId === selectedBird.id) {
|
if (editingBirdId === selectedBird.id) {
|
||||||
setEditingBirdId('');
|
setEditingBirdId('');
|
||||||
@@ -2591,6 +2745,7 @@ function App() {
|
|||||||
});
|
});
|
||||||
setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
||||||
setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
||||||
|
setMedications((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
||||||
if (selectedBird?.id === flockTransferForm.birdId) {
|
if (selectedBird?.id === flockTransferForm.birdId) {
|
||||||
setSelectedBirdId('');
|
setSelectedBirdId('');
|
||||||
}
|
}
|
||||||
@@ -3820,6 +3975,159 @@ function App() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="panel inset-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Medication</p>
|
||||||
|
<h2>Per-bird medication log</h2>
|
||||||
|
</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>
|
||||||
|
|
||||||
<section className="panel inset-panel">
|
<section className="panel inset-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user