Implement regimens

This commit is contained in:
Aerilyn Weber 2026-03-28 18:25:49 +09:00
parent 1f66fab30f
commit 9f416903ef
66 changed files with 9130 additions and 189 deletions

View 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' : ''} &bull;{' '}
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} &rarr; {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>
);
}

View 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} &mdash; {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' : ''} &bull;{' '}
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} &bull;{' '}
{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>
);
}

View 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 &amp; 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);

View 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>
);
}

View 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}` : ''}`,
);
}

View 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`,
{},
);
}

View 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}`);
}