Implement regimens
This commit is contained in:
parent
1f66fab30f
commit
9f416903ef
66 changed files with 9130 additions and 189 deletions
420
packages/web/src/app/(dashboard)/medicines/ActivityTab.tsx
Normal file
420
packages/web/src/app/(dashboard)/medicines/ActivityTab.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { listCabinetEvents, getSpendingSummary } from '@/services/cabinet-events';
|
||||
import { listMedicines } from '@/services/medicines';
|
||||
import { CabinetEventType } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
CabinetEventResponseSchema,
|
||||
SpendingSummaryResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type CabinetEvent = z.infer<typeof CabinetEventResponseSchema>;
|
||||
type SpendingSummary = z.infer<typeof SpendingSummaryResponseSchema>;
|
||||
|
||||
type MedicineOption = {
|
||||
_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
purchased: 'Purchased',
|
||||
consumed: 'Consumed',
|
||||
adjusted: 'Adjusted',
|
||||
discarded: 'Discarded',
|
||||
restored: 'Restored',
|
||||
deleted: 'Deleted',
|
||||
};
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
purchased: 'bg-green-100 text-green-700',
|
||||
consumed: 'bg-blue-100 text-blue-700',
|
||||
adjusted: 'bg-yellow-100 text-yellow-700',
|
||||
discarded: 'bg-red-100 text-red-700',
|
||||
restored: 'bg-purple-100 text-purple-700',
|
||||
deleted: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function formatQuantityChange(event: CabinetEvent): string {
|
||||
const sign = event.quantity > 0 ? '+' : '';
|
||||
return `${sign}${event.quantity}`;
|
||||
}
|
||||
|
||||
function QuantityBadge({ quantity }: { quantity: number }) {
|
||||
const isPositive = quantity > 0;
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}
|
||||
>
|
||||
{isPositive ? '+' : ''}
|
||||
{quantity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Spending summary ---
|
||||
|
||||
function SpendingSummaryView({
|
||||
householdId,
|
||||
medicines,
|
||||
}: {
|
||||
householdId: string;
|
||||
medicines: MedicineOption[];
|
||||
}) {
|
||||
const [summary, setSummary] = useState<SpendingSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [period, setPeriod] = useState<'month' | 'quarter' | 'year'>('month');
|
||||
const [medicineId, setMedicineId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getSpendingSummary(householdId, {
|
||||
period,
|
||||
medicineId: medicineId || undefined,
|
||||
});
|
||||
setSummary(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load spending summary');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, period, medicineId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
}, [fetchSummary]);
|
||||
|
||||
const PERIOD_LABELS = { month: 'This month', quarter: 'This quarter', year: 'This year' };
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<h2 className="text-lg font-semibold">Spending Summary</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as 'month' | 'quarter' | 'year')}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
{Object.entries(PERIOD_LABELS).map(([v, label]) => (
|
||||
<option key={v} value={v}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={medicineId}
|
||||
onChange={(e) => setMedicineId(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All medicines</option>
|
||||
{medicines.map((m) => (
|
||||
<option key={m._id} value={m._id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-6 w-32 rounded bg-gray-200" />
|
||||
<div className="h-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
) : summary && summary.totalSpent > 0 ? (
|
||||
<div className="space-y-5">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{summary.currency ? `${summary.currency} ` : ''}
|
||||
{summary.totalSpent.toFixed(2)}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">total spent</span>
|
||||
</div>
|
||||
|
||||
{summary.byMedicine.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">By medicine</h3>
|
||||
<div className="space-y-2">
|
||||
{summary.byMedicine.map((item) => (
|
||||
<div
|
||||
key={item.medicineId}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{item.medicineName}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{item.purchaseCount} purchase{item.purchaseCount !== 1 ? 's' : ''} •{' '}
|
||||
avg {summary.currency ? `${summary.currency} ` : ''}
|
||||
{item.avgUnitPrice.toFixed(2)}/unit
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{summary.currency ? `${summary.currency} ` : ''}
|
||||
{item.totalSpent.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.byPeriod.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">By period</h3>
|
||||
<div className="space-y-2">
|
||||
{summary.byPeriod.map((item) => (
|
||||
<div
|
||||
key={item.period}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<span className="text-sm text-gray-700">{item.period}</span>
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{summary.currency ? `${summary.currency} ` : ''}
|
||||
{item.totalSpent.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
No purchase data found for this period.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Event timeline ---
|
||||
|
||||
function EventTimeline({
|
||||
householdId,
|
||||
medicines,
|
||||
}: {
|
||||
householdId: string;
|
||||
medicines: MedicineOption[];
|
||||
}) {
|
||||
const [events, setEvents] = useState<CabinetEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filterEventType, setFilterEventType] = useState('');
|
||||
const [filterMedicineId, setFilterMedicineId] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
const fetchEvents = useCallback(
|
||||
async (append = false) => {
|
||||
if (!append) setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listCabinetEvents(householdId, {
|
||||
eventType: filterEventType || undefined,
|
||||
medicineId: filterMedicineId || undefined,
|
||||
startDate: startDate ? new Date(startDate).toISOString() : undefined,
|
||||
endDate: endDate ? new Date(endDate).toISOString() : undefined,
|
||||
cursor: append ? (cursor ?? undefined) : undefined,
|
||||
limit: 20,
|
||||
});
|
||||
setEvents((prev) => (append ? [...prev, ...result.data] : result.data));
|
||||
setCursor(result.pagination.cursor);
|
||||
setHasMore(result.pagination.hasMore);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[householdId, filterEventType, filterMedicineId, startDate, endDate, cursor],
|
||||
);
|
||||
|
||||
// Refetch from scratch when filters change
|
||||
useEffect(() => {
|
||||
setCursor(null);
|
||||
setEvents([]);
|
||||
fetchEvents(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [householdId, filterEventType, filterMedicineId, startDate, endDate]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Cabinet Activity</h2>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterEventType}
|
||||
onChange={(e) => setFilterEventType(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All event types</option>
|
||||
{Object.values(CabinetEventType).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{EVENT_TYPE_LABELS[t] ?? t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterMedicineId}
|
||||
onChange={(e) => setFilterMedicineId(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All medicines</option>
|
||||
{medicines.map((m) => (
|
||||
<option key={m._id} value={m._id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
title="Start date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
title="End date"
|
||||
/>
|
||||
{(filterEventType || filterMedicineId || startDate || endDate) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterEventType('');
|
||||
setFilterMedicineId('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
}}
|
||||
className="text-sm text-gray-500 underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse h-14 rounded-lg bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<p className="text-sm text-center text-gray-500 py-6">
|
||||
No events found for the selected filters.
|
||||
</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-4 top-0 bottom-0 w-px bg-gray-200" />
|
||||
<div className="space-y-4 pl-10">
|
||||
{events.map((event) => (
|
||||
<div key={event._id} className="relative">
|
||||
{/* Dot */}
|
||||
<div
|
||||
className={`absolute -left-6 top-2 h-3 w-3 rounded-full border-2 border-white ${
|
||||
event.quantity > 0 ? 'bg-green-400' : 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<div className="rounded-lg border bg-gray-50 p-3">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
{EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{event.medicineName}
|
||||
</span>
|
||||
<QuantityBadge quantity={event.quantity} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{event.quantityBefore} → {event.quantityAfter}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{formatDateTime(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{(event.reason || event.notes || event.storeName || event.totalPrice) && (
|
||||
<div className="mt-1 flex flex-wrap gap-3 text-xs text-gray-500">
|
||||
{event.reason && <span>Reason: {event.reason}</span>}
|
||||
{event.storeName && <span>Store: {event.storeName}</span>}
|
||||
{event.totalPrice && (
|
||||
<span>
|
||||
{event.currency ? `${event.currency} ` : ''}
|
||||
{event.totalPrice.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{event.notes && <span>{event.notes}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => fetchEvents(true)}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function ActivityTab({ householdId }: { householdId: string }) {
|
||||
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
listMedicines(householdId, { limit: 100 })
|
||||
.then((r) =>
|
||||
setMedicines(r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name }))),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, [householdId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SpendingSummaryView householdId={householdId} medicines={medicines} />
|
||||
<EventTimeline householdId={householdId} medicines={medicines} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
455
packages/web/src/app/(dashboard)/medicines/OrganizerTab.tsx
Normal file
455
packages/web/src/app/(dashboard)/medicines/OrganizerTab.tsx
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
listFills,
|
||||
previewFill,
|
||||
executeFill,
|
||||
undoFill,
|
||||
} from '@/services/organizer';
|
||||
import { listRegimens } from '@/services/regimens';
|
||||
import { OrganizerFillStatus } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
OrganizerFillResponseSchema,
|
||||
OrganizerPreviewResponseSchema,
|
||||
RegimenResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type OrganizerFill = z.infer<typeof OrganizerFillResponseSchema>;
|
||||
type OrganizerPreview = z.infer<typeof OrganizerPreviewResponseSchema>;
|
||||
type Regimen = z.infer<typeof RegimenResponseSchema>;
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
completed: 'Completed',
|
||||
partial: 'Partial',
|
||||
reversed: 'Reversed',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
partial: 'bg-yellow-100 text-yellow-700',
|
||||
reversed: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
// --- Preview result display ---
|
||||
|
||||
function PreviewResult({
|
||||
preview,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
submitting,
|
||||
allowPartial,
|
||||
onTogglePartial,
|
||||
}: {
|
||||
preview: OrganizerPreview;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
submitting: boolean;
|
||||
allowPartial: boolean;
|
||||
onTogglePartial: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">
|
||||
Preview: {preview.regimenName} — {preview.numberOfDays} day
|
||||
{preview.numberOfDays !== 1 ? 's' : ''}
|
||||
</h3>
|
||||
{preview.hasShortages ? (
|
||||
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-700">
|
||||
Shortages detected
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700">
|
||||
Ready to fill
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{preview.items.map((item) => (
|
||||
<div
|
||||
key={item.medicineId}
|
||||
className={`rounded-lg border p-3 ${item.isShort ? 'border-yellow-300 bg-yellow-50' : 'border-gray-200'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{item.medicineName}</span>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-gray-500">
|
||||
Need: <strong>{item.quantityNeeded}</strong>
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
Available: <strong>{item.quantityAvailable}</strong>
|
||||
</span>
|
||||
{item.isShort && (
|
||||
<span className="text-yellow-700 font-semibold">
|
||||
Short: {item.shortage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{item.cabinetBreakdown.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{item.cabinetBreakdown.map((b, i) => (
|
||||
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
|
||||
{b.quantityToTake} units
|
||||
{b.expirationDate ? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{preview.hasShortages && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allowPartial"
|
||||
checked={allowPartial}
|
||||
onChange={(e) => onTogglePartial(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="allowPartial" className="text-sm text-gray-700">
|
||||
Allow partial fill (fill what is available)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={submitting || (preview.hasShortages && !allowPartial)}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Filling...' : 'Confirm fill'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Fill wizard ---
|
||||
|
||||
function FillWizard({
|
||||
householdId,
|
||||
regimens,
|
||||
onFilled,
|
||||
}: {
|
||||
householdId: string;
|
||||
regimens: Regimen[];
|
||||
onFilled: () => void;
|
||||
}) {
|
||||
const [regimenId, setRegimenId] = useState('');
|
||||
const [numberOfDays, setNumberOfDays] = useState(7);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [allowPartial, setAllowPartial] = useState(false);
|
||||
const [preview, setPreview] = useState<OrganizerPreview | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [filling, setFilling] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const activeRegimens = regimens.filter((r) => r.isActive);
|
||||
|
||||
async function handlePreview(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setPreviewing(true);
|
||||
try {
|
||||
const result = await previewFill(householdId, { regimenId, numberOfDays });
|
||||
setPreview(result);
|
||||
setAllowPartial(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate preview');
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFill() {
|
||||
setError('');
|
||||
setFilling(true);
|
||||
try {
|
||||
await executeFill(householdId, { regimenId, numberOfDays, allowPartial, notes: notes || undefined });
|
||||
setPreview(null);
|
||||
setRegimenId('');
|
||||
setNumberOfDays(7);
|
||||
setNotes('');
|
||||
setAllowPartial(false);
|
||||
onFilled();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fill failed');
|
||||
setPreview(null);
|
||||
} finally {
|
||||
setFilling(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<PreviewResult
|
||||
preview={preview}
|
||||
onConfirm={handleFill}
|
||||
onCancel={() => setPreview(null)}
|
||||
submitting={filling}
|
||||
allowPartial={allowPartial}
|
||||
onTogglePartial={setAllowPartial}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Fill Pill Organizer</h2>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{activeRegimens.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
No active regimens found. Create and activate a regimen before filling.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handlePreview} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Regimen</label>
|
||||
<select
|
||||
required
|
||||
value={regimenId}
|
||||
onChange={(e) => setRegimenId(e.target.value)}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select regimen...</option>
|
||||
{activeRegimens.map((r) => (
|
||||
<option key={r._id} value={r._id}>
|
||||
{r.name} ({r.medications.length} medication{r.medications.length !== 1 ? 's' : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Number of days</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={90}
|
||||
value={numberOfDays}
|
||||
onChange={(e) => setNumberOfDays(Number(e.target.value))}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any notes for this fill"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={previewing}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{previewing ? 'Calculating...' : 'Preview fill'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Fill history list ---
|
||||
|
||||
function FillHistory({
|
||||
householdId,
|
||||
refreshKey,
|
||||
}: {
|
||||
householdId: string;
|
||||
refreshKey: number;
|
||||
}) {
|
||||
const [fills, setFills] = useState<OrganizerFill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
|
||||
const fetchFills = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listFills(householdId, {
|
||||
status: filterStatus || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
setFills(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load fill history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, filterStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFills();
|
||||
}, [fetchFills, refreshKey]);
|
||||
|
||||
async function handleUndo(fillId: string) {
|
||||
if (!confirm('Reverse this fill? Cabinet quantities will be restored.')) return;
|
||||
try {
|
||||
const updated = await undoFill(householdId, fillId);
|
||||
setFills((prev) => prev.map((f) => (f._id === updated._id ? updated : f)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to undo fill');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Fill History</h2>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{Object.values(OrganizerFillStatus).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_LABELS[s] ?? s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse h-16 rounded-lg bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
) : fills.length === 0 ? (
|
||||
<p className="text-sm text-center text-gray-500 py-4">No fills recorded yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{fills.map((fill) => (
|
||||
<div key={fill._id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{fill.regimenName}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[fill.status] ?? STATUS_COLORS['completed']}`}
|
||||
>
|
||||
{STATUS_LABELS[fill.status] ?? fill.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{fill.numberOfDays} day{fill.numberOfDays !== 1 ? 's' : ''} •{' '}
|
||||
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} •{' '}
|
||||
{formatDate(fill.fillDate)}
|
||||
</p>
|
||||
{fill.notes && (
|
||||
<p className="text-xs text-gray-400 mt-1">{fill.notes}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{fill.items.map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
item.wasShort
|
||||
? 'bg-yellow-50 text-yellow-700'
|
||||
: 'bg-blue-50 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{item.medicineName}: {item.quantityTaken}/{item.quantityNeeded}
|
||||
{item.wasShort ? ' (short)' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{fill.status !== OrganizerFillStatus.REVERSED && (
|
||||
<button
|
||||
onClick={() => handleUndo(fill._id)}
|
||||
className="shrink-0 rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function OrganizerTab({ householdId }: { householdId: string }) {
|
||||
const [regimens, setRegimens] = useState<Regimen[]>([]);
|
||||
const [regimensLoading, setRegimensLoading] = useState(true);
|
||||
const [fillRefreshKey, setFillRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
listRegimens(householdId, { limit: 100 })
|
||||
.then((r) => setRegimens(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setRegimensLoading(false));
|
||||
}, [householdId]);
|
||||
|
||||
function handleFilled() {
|
||||
setFillRefreshKey((k) => k + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{regimensLoading ? (
|
||||
<div className="animate-pulse rounded-xl border bg-white p-6 h-40" />
|
||||
) : (
|
||||
<FillWizard
|
||||
householdId={householdId}
|
||||
regimens={regimens}
|
||||
onFilled={handleFilled}
|
||||
/>
|
||||
)}
|
||||
<FillHistory householdId={householdId} refreshKey={fillRefreshKey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
722
packages/web/src/app/(dashboard)/medicines/RegimensTab.tsx
Normal file
722
packages/web/src/app/(dashboard)/medicines/RegimensTab.tsx
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
listRegimens,
|
||||
createRegimen,
|
||||
updateRegimen,
|
||||
deleteRegimen,
|
||||
getBurnRates,
|
||||
} from '@/services/regimens';
|
||||
import { listMedicines } from '@/services/medicines';
|
||||
import {
|
||||
DosageFrequency,
|
||||
TimeOfDay,
|
||||
DosageUnit,
|
||||
MedicineForm,
|
||||
allowedUnitsForForm,
|
||||
defaultUnitForForm,
|
||||
} from '@meshitrack/shared';
|
||||
import type { CreateRegimenInput } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
RegimenResponseSchema,
|
||||
RegimenMedicationInputSchema,
|
||||
BurnRateItemSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type Regimen = z.infer<typeof RegimenResponseSchema>;
|
||||
type MedicationInput = z.infer<typeof RegimenMedicationInputSchema>;
|
||||
type BurnRateItem = z.infer<typeof BurnRateItemSchema>;
|
||||
|
||||
type MedicineOption = {
|
||||
_id: string;
|
||||
name: string;
|
||||
strength: number;
|
||||
strengthUnit: string;
|
||||
form: MedicineForm;
|
||||
};
|
||||
|
||||
const FREQUENCY_LABELS: Record<string, string> = {
|
||||
daily: 'Once daily',
|
||||
twice_daily: 'Twice daily',
|
||||
three_times_daily: 'Three times daily',
|
||||
weekly: 'Weekly',
|
||||
every_other_day: 'Every other day',
|
||||
as_needed: 'As needed',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
const TIME_LABELS: Record<string, string> = {
|
||||
morning: 'Morning',
|
||||
afternoon: 'Afternoon',
|
||||
evening: 'Evening',
|
||||
bedtime: 'Bedtime',
|
||||
};
|
||||
|
||||
const FORM_LABELS: Record<string, string> = {
|
||||
tablet: 'Tablet',
|
||||
capsule: 'Capsule',
|
||||
liquid: 'Liquid',
|
||||
injection: 'Injection',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
// --- Medication sub-form row ---
|
||||
|
||||
function MedicationRow({
|
||||
medication,
|
||||
index,
|
||||
medicines,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
medication: MedicationInput;
|
||||
index: number;
|
||||
medicines: MedicineOption[];
|
||||
onChange: (index: number, updated: MedicationInput) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
const selectedMed = medicines.find((m) => m._id === medication.medicineId);
|
||||
const allowedUnits = selectedMed
|
||||
? allowedUnitsForForm(selectedMed.form)
|
||||
: Object.values(DosageUnit);
|
||||
|
||||
function handleMedicineChange(medicineId: string) {
|
||||
const med = medicines.find((m) => m._id === medicineId);
|
||||
const newUnit = med ? defaultUnitForForm(med.form) : DosageUnit.TABLET;
|
||||
onChange(index, { ...medication, medicineId, dosageUnit: newUnit });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-gray-50 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-600">Medication {index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
title="Remove medication"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Medicine</label>
|
||||
<select
|
||||
required
|
||||
value={medication.medicineId}
|
||||
onChange={(e) => handleMedicineChange(e.target.value)}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select medicine...</option>
|
||||
{medicines.map((m) => (
|
||||
<option key={m._id} value={m._id}>
|
||||
{m.name} {m.strength} {m.strengthUnit} ({FORM_LABELS[m.form] ?? m.form})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Dosage</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={0.01}
|
||||
step="any"
|
||||
value={medication.dosage || ''}
|
||||
onChange={(e) => onChange(index, { ...medication, dosage: Number(e.target.value) })}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Unit</label>
|
||||
<select
|
||||
value={medication.dosageUnit}
|
||||
onChange={(e) =>
|
||||
onChange(index, { ...medication, dosageUnit: e.target.value as DosageUnit })
|
||||
}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
{allowedUnits.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Frequency</label>
|
||||
<select
|
||||
value={medication.frequency}
|
||||
onChange={(e) =>
|
||||
onChange(index, {
|
||||
...medication,
|
||||
frequency: e.target.value as DosageFrequency,
|
||||
customFrequencyPerDay:
|
||||
e.target.value === DosageFrequency.CUSTOM
|
||||
? (medication.customFrequencyPerDay ?? 1)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
{Object.values(DosageFrequency).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{FREQUENCY_LABELS[f] ?? f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{medication.frequency === DosageFrequency.CUSTOM && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Times per day</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
step={1}
|
||||
value={medication.customFrequencyPerDay ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(index, { ...medication, customFrequencyPerDay: Number(e.target.value) })
|
||||
}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Time of day (optional)
|
||||
</label>
|
||||
<select
|
||||
value={medication.timeOfDay ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(index, {
|
||||
...medication,
|
||||
timeOfDay: e.target.value ? (e.target.value as TimeOfDay) : undefined,
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Any time</option>
|
||||
{Object.values(TimeOfDay).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{TIME_LABELS[t] ?? t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Instructions (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={medication.instructions ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(index, { ...medication, instructions: e.target.value || undefined })
|
||||
}
|
||||
placeholder="e.g. Take with food"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Regimen create / edit form ---
|
||||
|
||||
function emptyMedication(): MedicationInput {
|
||||
return {
|
||||
medicineId: '',
|
||||
dosage: 1,
|
||||
dosageUnit: DosageUnit.TABLET,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
};
|
||||
}
|
||||
|
||||
function RegimenForm({
|
||||
householdId,
|
||||
medicines,
|
||||
initial,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
householdId: string;
|
||||
medicines: MedicineOption[];
|
||||
initial?: Regimen;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [isActive, setIsActive] = useState(initial?.isActive ?? true);
|
||||
const [medications, setMedications] = useState<MedicationInput[]>(
|
||||
initial?.medications.map((m) => ({
|
||||
medicineId: m.medicineId,
|
||||
dosage: m.dosage,
|
||||
dosageUnit: m.dosageUnit as DosageUnit,
|
||||
frequency: m.frequency as DosageFrequency,
|
||||
customFrequencyPerDay: m.customFrequencyPerDay,
|
||||
timeOfDay: m.timeOfDay as TimeOfDay | undefined,
|
||||
instructions: m.instructions,
|
||||
})) ?? [emptyMedication()],
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function updateMedication(index: number, updated: MedicationInput) {
|
||||
setMedications((prev) => prev.map((m, i) => (i === index ? updated : m)));
|
||||
}
|
||||
|
||||
function removeMedication(index: number) {
|
||||
setMedications((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function addMedication() {
|
||||
setMedications((prev) => [...prev, emptyMedication()]);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (medications.length === 0) {
|
||||
setError('At least one medication is required.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: CreateRegimenInput = { name: name.trim(), isActive, medications };
|
||||
if (initial) {
|
||||
await updateRegimen(householdId, initial._id, payload);
|
||||
} else {
|
||||
await createRegimen(householdId, payload);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save regimen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Regimen' : 'New Regimen'}</h2>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={200}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Morning routine"
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">Medications</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMedication}
|
||||
className="rounded-lg border border-primary-600 px-3 py-1.5 text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
+ Add medication
|
||||
</button>
|
||||
</div>
|
||||
{medications.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">No medications added yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{medications.map((med, i) => (
|
||||
<MedicationRow
|
||||
key={i}
|
||||
index={i}
|
||||
medication={med}
|
||||
medicines={medicines}
|
||||
onChange={updateMedication}
|
||||
onRemove={removeMedication}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Create regimen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Burn rate table ---
|
||||
|
||||
function BurnRateTable({ burnRates }: { burnRates: BurnRateItem[] }) {
|
||||
if (burnRates.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
No active regimens with cabinet stock to calculate burn rates.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="pb-2 font-medium">Medicine</th>
|
||||
<th className="pb-2 font-medium text-right">Daily use</th>
|
||||
<th className="pb-2 font-medium text-right">In cabinet</th>
|
||||
<th className="pb-2 font-medium text-right">Days left</th>
|
||||
<th className="pb-2 font-medium text-right">Monthly cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{burnRates.map((item) => {
|
||||
const daysLeft = item.daysUntilEmpty;
|
||||
const daysColor =
|
||||
daysLeft === null
|
||||
? 'text-gray-400'
|
||||
: daysLeft <= 7
|
||||
? 'text-red-600 font-semibold'
|
||||
: daysLeft <= 30
|
||||
? 'text-yellow-600'
|
||||
: 'text-green-600';
|
||||
|
||||
return (
|
||||
<tr key={item.medicineId} className="py-2">
|
||||
<td className="py-2 font-medium text-gray-900">{item.medicineName}</td>
|
||||
<td className="py-2 text-right text-gray-600">
|
||||
{item.dailyConsumption.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-600">{item.totalInCabinet}</td>
|
||||
<td className={`py-2 text-right ${daysColor}`}>
|
||||
{daysLeft !== null ? daysLeft : '-'}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-600">
|
||||
{item.projectedMonthlyCost !== null
|
||||
? `${item.currency ?? ''} ${item.projectedMonthlyCost.toFixed(2)}`.trim()
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function RegimensTab({ householdId }: { householdId: string }) {
|
||||
const [regimens, setRegimens] = useState<Regimen[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRegimen, setEditingRegimen] = useState<Regimen | null>(null);
|
||||
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [burnRates, setBurnRates] = useState<BurnRateItem[]>([]);
|
||||
const [showBurnRate, setShowBurnRate] = useState(false);
|
||||
const [burnRateLoading, setBurnRateLoading] = useState(false);
|
||||
|
||||
const fetchRegimens = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query =
|
||||
filterActive === 'active'
|
||||
? { isActive: true }
|
||||
: filterActive === 'inactive'
|
||||
? { isActive: false }
|
||||
: {};
|
||||
const result = await listRegimens(householdId, { ...query, limit: 50 });
|
||||
setRegimens(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load regimens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, filterActive]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegimens();
|
||||
}, [fetchRegimens]);
|
||||
|
||||
// Medicines are needed for the form
|
||||
useEffect(() => {
|
||||
listMedicines(householdId, { limit: 100 })
|
||||
.then((r) => setMedicines(r.data as MedicineOption[]))
|
||||
.catch(() => {});
|
||||
}, [householdId]);
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete regimen "${name}"?`)) return;
|
||||
try {
|
||||
await deleteRegimen(householdId, id);
|
||||
setRegimens((prev) => prev.filter((r) => r._id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(regimen: Regimen) {
|
||||
try {
|
||||
const updated = await updateRegimen(householdId, regimen._id, {
|
||||
isActive: !regimen.isActive,
|
||||
});
|
||||
setRegimens((prev) => prev.map((r) => (r._id === regimen._id ? updated : r)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShowBurnRate() {
|
||||
setShowBurnRate((prev) => !prev);
|
||||
if (!showBurnRate && burnRates.length === 0) {
|
||||
setBurnRateLoading(true);
|
||||
try {
|
||||
const result = await getBurnRates(householdId);
|
||||
setBurnRates(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load burn rates');
|
||||
} finally {
|
||||
setBurnRateLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isFormOpen = showForm || editingRegimen !== null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={filterActive}
|
||||
onChange={(e) => setFilterActive(e.target.value as 'all' | 'active' | 'inactive')}
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="all">All regimens</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleShowBurnRate}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{showBurnRate ? 'Hide burn rate' : 'Burn rate'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingRegimen(null);
|
||||
setShowForm(!showForm);
|
||||
}}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'New Regimen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBurnRate && (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Burn Rate & Spending Projections</h2>
|
||||
{burnRateLoading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-6 w-full rounded bg-gray-200" />
|
||||
<div className="h-6 w-full rounded bg-gray-200" />
|
||||
</div>
|
||||
) : (
|
||||
<BurnRateTable burnRates={burnRates} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && !editingRegimen && (
|
||||
<RegimenForm
|
||||
householdId={householdId}
|
||||
medicines={medicines}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
fetchRegimens();
|
||||
setBurnRates([]);
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingRegimen && (
|
||||
<RegimenForm
|
||||
householdId={householdId}
|
||||
medicines={medicines}
|
||||
initial={editingRegimen}
|
||||
onSaved={() => {
|
||||
setEditingRegimen(null);
|
||||
fetchRegimens();
|
||||
setBurnRates([]);
|
||||
}}
|
||||
onCancel={() => setEditingRegimen(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl border bg-white p-4 h-24" />
|
||||
))}
|
||||
</div>
|
||||
) : regimens.length === 0 ? (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
|
||||
{filterActive !== 'all'
|
||||
? `No ${filterActive} regimens found.`
|
||||
: isFormOpen
|
||||
? null
|
||||
: 'No regimens yet. Create your first medication schedule above.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{regimens.map((regimen) => (
|
||||
<div key={regimen._id} className="rounded-xl border bg-white p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{regimen.name}</h3>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
regimen.isActive
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{regimen.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{regimen.medications.length} medication
|
||||
{regimen.medications.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{regimen.medications.map((med, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700"
|
||||
>
|
||||
{med.medicineName} — {med.dosage} {med.dosageUnit} (
|
||||
{FREQUENCY_LABELS[med.frequency] ?? med.frequency})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-400">Created {formatDate(regimen.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleToggleActive(regimen)}
|
||||
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-gray-50 transition-colors"
|
||||
title={regimen.isActive ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{regimen.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setEditingRegimen(regimen);
|
||||
}}
|
||||
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(regimen._id, regimen.name)}
|
||||
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
packages/web/src/app/(dashboard)/medicines/activity/page.tsx
Normal file
51
packages/web/src/app/(dashboard)/medicines/activity/page.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { ActivityTab } from '../ActivityTab';
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-gray-500">
|
||||
You need to{' '}
|
||||
<Link href="/settings" className="text-primary-600 underline">
|
||||
create or join a household
|
||||
</Link>{' '}
|
||||
before viewing cabinet activity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
Medicines
|
||||
</Link>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold">Cabinet Activity</h1>
|
||||
</div>
|
||||
<ActivityTab householdId={householdId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { OrganizerTab } from '../OrganizerTab';
|
||||
|
||||
export default function OrganizerPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-gray-500">
|
||||
You need to{' '}
|
||||
<Link href="/settings" className="text-primary-600 underline">
|
||||
create or join a household
|
||||
</Link>{' '}
|
||||
before using the pill organizer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
Medicines
|
||||
</Link>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold">Pill Organizer</h1>
|
||||
</div>
|
||||
<OrganizerTab householdId={householdId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -41,6 +41,21 @@ export default function MedicinesPage() {
|
|||
description="Track your medicine inventory, quantities and expiry dates"
|
||||
href="/medicines/cabinet"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Regimens"
|
||||
description="Define daily medication schedules and track dosage frequency"
|
||||
href="/medicines/regimens"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Organizer"
|
||||
description="Fill your pill organizer and track cabinet usage"
|
||||
href="/medicines/organizer"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Activity"
|
||||
description="View cabinet event history and spending summaries"
|
||||
href="/medicines/activity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -73,6 +88,9 @@ function PageSkeleton() {
|
|||
<div className="animate-pulse grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
52
packages/web/src/app/(dashboard)/medicines/regimens/page.tsx
Normal file
52
packages/web/src/app/(dashboard)/medicines/regimens/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { RegimensTab } from '../RegimensTab';
|
||||
|
||||
export default function RegimensPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 w-64 rounded-lg bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<p className="text-gray-500">
|
||||
You need to{' '}
|
||||
<Link href="/settings" className="text-primary-600 underline">
|
||||
create or join a household
|
||||
</Link>{' '}
|
||||
before managing regimens.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
Medicines
|
||||
</Link>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold">Regimens</h1>
|
||||
</div>
|
||||
<RegimensTab householdId={householdId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
packages/web/src/services/cabinet-events.ts
Normal file
60
packages/web/src/services/cabinet-events.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
CabinetEventListResponseSchema,
|
||||
SpendingSummaryResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type CabinetEventListResponse = z.infer<typeof CabinetEventListResponseSchema>;
|
||||
type SpendingSummaryResponse = z.infer<typeof SpendingSummaryResponseSchema>;
|
||||
|
||||
export async function listCabinetEvents(
|
||||
householdId: string,
|
||||
query?: {
|
||||
medicineId?: string;
|
||||
eventType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<CabinetEventListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.medicineId) params.set('medicineId', query.medicineId);
|
||||
if (query?.eventType) params.set('eventType', query.eventType);
|
||||
if (query?.startDate) params.set('startDate', query.startDate);
|
||||
if (query?.endDate) params.set('endDate', query.endDate);
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<CabinetEventListResponse>(
|
||||
`/households/${householdId}/cabinet-events${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getEventsByItem(
|
||||
householdId: string,
|
||||
cabinetItemId: string,
|
||||
query?: { cursor?: string; limit?: number },
|
||||
): Promise<CabinetEventListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<CabinetEventListResponse>(
|
||||
`/households/${householdId}/cabinet-events/by-item/${cabinetItemId}${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSpendingSummary(
|
||||
householdId: string,
|
||||
query?: { period?: string; medicineId?: string },
|
||||
): Promise<SpendingSummaryResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.period) params.set('period', query.period);
|
||||
if (query?.medicineId) params.set('medicineId', query.medicineId);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<SpendingSummaryResponse>(
|
||||
`/households/${householdId}/cabinet-events/spending-summary${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
55
packages/web/src/services/organizer.ts
Normal file
55
packages/web/src/services/organizer.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
OrganizerFillListResponseSchema,
|
||||
OrganizerFillResponseSchema,
|
||||
OrganizerPreviewResponseSchema,
|
||||
OrganizerFillSchema,
|
||||
OrganizerPreviewSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type OrganizerFillListResponse = z.infer<typeof OrganizerFillListResponseSchema>;
|
||||
type OrganizerFillResponse = z.infer<typeof OrganizerFillResponseSchema>;
|
||||
type OrganizerPreviewResponse = z.infer<typeof OrganizerPreviewResponseSchema>;
|
||||
type OrganizerFillInput = z.infer<typeof OrganizerFillSchema>;
|
||||
type OrganizerPreviewInput = z.infer<typeof OrganizerPreviewSchema>;
|
||||
|
||||
export async function listFills(
|
||||
householdId: string,
|
||||
query?: { regimenId?: string; status?: string; cursor?: string; limit?: number },
|
||||
): Promise<OrganizerFillListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.regimenId) params.set('regimenId', query.regimenId);
|
||||
if (query?.status) params.set('status', query.status);
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<OrganizerFillListResponse>(
|
||||
`/households/${householdId}/organizer/fills${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFill(householdId: string, id: string): Promise<OrganizerFillResponse> {
|
||||
return apiClient.get<OrganizerFillResponse>(`/households/${householdId}/organizer/fills/${id}`);
|
||||
}
|
||||
|
||||
export async function previewFill(
|
||||
householdId: string,
|
||||
data: OrganizerPreviewInput,
|
||||
): Promise<OrganizerPreviewResponse> {
|
||||
return apiClient.post<OrganizerPreviewResponse>(`/households/${householdId}/organizer/preview`, data);
|
||||
}
|
||||
|
||||
export async function executeFill(
|
||||
householdId: string,
|
||||
data: OrganizerFillInput,
|
||||
): Promise<OrganizerFillResponse> {
|
||||
return apiClient.post<OrganizerFillResponse>(`/households/${householdId}/organizer/fill`, data);
|
||||
}
|
||||
|
||||
export async function undoFill(householdId: string, fillId: string): Promise<OrganizerFillResponse> {
|
||||
return apiClient.post<OrganizerFillResponse>(
|
||||
`/households/${householdId}/organizer/fills/${fillId}/undo`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
51
packages/web/src/services/regimens.ts
Normal file
51
packages/web/src/services/regimens.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
RegimenResponseSchema,
|
||||
RegimenListResponseSchema,
|
||||
BurnRateResponseSchema,
|
||||
CreateRegimenSchema,
|
||||
UpdateRegimenSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type RegimenResponse = z.infer<typeof RegimenResponseSchema>;
|
||||
type RegimenListResponse = z.infer<typeof RegimenListResponseSchema>;
|
||||
type BurnRateResponse = z.infer<typeof BurnRateResponseSchema>;
|
||||
type CreateRegimenInput = z.infer<typeof CreateRegimenSchema>;
|
||||
type UpdateRegimenInput = z.infer<typeof UpdateRegimenSchema>;
|
||||
|
||||
export async function listRegimens(
|
||||
householdId: string,
|
||||
query?: { isActive?: boolean; cursor?: string; limit?: number },
|
||||
): Promise<RegimenListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.isActive !== undefined) params.set('isActive', String(query.isActive));
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<RegimenListResponse>(`/households/${householdId}/regimens${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getRegimen(householdId: string, id: string): Promise<RegimenResponse> {
|
||||
return apiClient.get<RegimenResponse>(`/households/${householdId}/regimens/${id}`);
|
||||
}
|
||||
|
||||
export async function getBurnRates(householdId: string): Promise<BurnRateResponse> {
|
||||
return apiClient.get<BurnRateResponse>(`/households/${householdId}/regimens/burn-rate`);
|
||||
}
|
||||
|
||||
export async function createRegimen(householdId: string, data: CreateRegimenInput): Promise<RegimenResponse> {
|
||||
return apiClient.post<RegimenResponse>(`/households/${householdId}/regimens`, data);
|
||||
}
|
||||
|
||||
export async function updateRegimen(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdateRegimenInput,
|
||||
): Promise<RegimenResponse> {
|
||||
return apiClient.patch<RegimenResponse>(`/households/${householdId}/regimens/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteRegimen(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/households/${householdId}/regimens/${id}`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue