Implement stores and refills, improve testing

This commit is contained in:
Aerilyn Weber 2026-04-18 12:36:29 +09:00
parent 9f416903ef
commit 5536acd67d
137 changed files with 21218 additions and 221 deletions

View file

@ -0,0 +1,708 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import {
getRefillAlerts,
listRefillLists,
createRefillList,
updateRefillList,
updateRefillListItem,
addToCabinet,
} from '@/services/refills';
import { RefillListStatus } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type { RefillAlertResponseSchema, RefillListResponseSchema } from '@meshitrack/shared';
type RefillAlert = z.infer<typeof RefillAlertResponseSchema>;
type RefillList = z.infer<typeof RefillListResponseSchema>;
type RefillListItem = RefillList['items'][number];
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
shopping: 'Shopping',
completed: 'Completed',
archived: 'Archived',
};
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-700',
shopping: 'bg-blue-100 text-blue-700',
completed: 'bg-gray-100 text-gray-600',
archived: 'bg-gray-100 text-gray-400',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
// --- Refill alerts panel ---
function AlertsPanel({
householdId,
onGenerateList,
}: {
householdId: string;
onGenerateList: () => void;
}) {
const [alerts, setAlerts] = useState<RefillAlert[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [thresholdDays, setThresholdDays] = useState(7);
const [generating, setGenerating] = useState(false);
const [listName, setListName] = useState('');
const [showGenerateForm, setShowGenerateForm] = useState(false);
const fetchAlerts = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await getRefillAlerts(householdId, { thresholdDays });
setAlerts(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load alerts');
} finally {
setLoading(false);
}
}, [householdId, thresholdDays]);
useEffect(() => {
fetchAlerts();
}, [fetchAlerts]);
async function handleGenerateList(e: React.FormEvent) {
e.preventDefault();
if (!listName.trim()) return;
setGenerating(true);
setError('');
try {
await createRefillList(householdId, {
name: listName.trim(),
fromAlerts: true,
thresholdDays,
});
setShowGenerateForm(false);
setListName('');
onGenerateList();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate list');
} finally {
setGenerating(false);
}
}
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">Refill Alerts</h2>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>Threshold:</span>
<select
value={thresholdDays}
onChange={(e) => setThresholdDays(Number(e.target.value))}
className="rounded-lg border px-2 py-1 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
{[3, 5, 7, 10, 14, 30].map((d) => (
<option key={d} value={d}>
{d} days
</option>
))}
</select>
</div>
{alerts.length > 0 && (
<button
onClick={() => setShowGenerateForm(!showGenerateForm)}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
Generate Refill List
</button>
)}
</div>
</div>
{showGenerateForm && (
<form onSubmit={handleGenerateList} className="mb-4 flex items-center gap-3">
<input
type="text"
required
maxLength={200}
value={listName}
onChange={(e) => setListName(e.target.value)}
placeholder="List name, e.g. Weekly refills"
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
<button
type="submit"
disabled={generating}
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"
>
{generating ? 'Creating...' : 'Create'}
</button>
<button
type="button"
onClick={() => setShowGenerateForm(false)}
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</form>
)}
{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: 2 }).map((_, i) => (
<div key={i} className="animate-pulse h-20 rounded-lg bg-gray-200" />
))}
</div>
) : alerts.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-500">
No medicines running low within {thresholdDays} days.
{thresholdDays < 30 && (
<span className="block mt-1 text-xs">
Try increasing the threshold to see more.
</span>
)}
</div>
) : (
<div className="space-y-3">
{alerts.map((alert) => {
const daysColor =
alert.daysUntilEmpty <= 3
? 'text-red-600 font-bold'
: alert.daysUntilEmpty <= 7
? 'text-red-500 font-semibold'
: 'text-yellow-600';
return (
<div
key={alert.medicineId}
className="rounded-lg border bg-gray-50 p-4"
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h3 className="font-semibold text-gray-900">
{alert.medicineName}
<span className="ml-2 text-sm font-normal text-gray-500">
{alert.medicineStrength} {alert.medicineStrengthUnit}
</span>
</h3>
<div className="flex flex-wrap gap-4 mt-1 text-sm">
<span className={daysColor}>
{alert.daysUntilEmpty} day{alert.daysUntilEmpty !== 1 ? 's' : ''} left
</span>
<span className="text-gray-500">
{alert.currentStock} in cabinet
</span>
<span className="text-gray-500">
{alert.dailyConsumption.toFixed(2)}/day
</span>
</div>
</div>
<div className="text-right text-sm">
<p className="text-gray-600">
Suggested: <span className="font-medium">{alert.suggestedQuantity} units</span>
</p>
{alert.cheapestOption && (
<p className="text-green-700 font-medium">
Best: {alert.cheapestOption.storeName} {' '}
{alert.cheapestOption.price.toFixed(2)}
</p>
)}
{!alert.cheapestOption && alert.lastKnownPrice && (
<p className="text-gray-500">
Last: {alert.lastKnownPrice.storeName} {' '}
{alert.lastKnownPrice.price.toFixed(2)}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// --- Refill list detail ---
function RefillListDetail({
list,
householdId,
onUpdated,
onClose,
}: {
list: RefillList;
householdId: string;
onUpdated: () => void;
onClose: () => void;
}) {
const [items, setItems] = useState<RefillListItem[]>(list.items);
const [actualPrices, setActualPrices] = useState<Record<string, string>>({});
const [adding, setAdding] = useState(false);
const [error, setError] = useState('');
async function handleToggleItem(item: RefillListItem) {
setError('');
try {
const updated = await updateRefillListItem(householdId, list._id, item._id, {
checked: !item.checked,
actualPrice:
!item.checked && actualPrices[item._id]
? Number(actualPrices[item._id])
: undefined,
});
setItems(updated.items);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update item');
}
}
async function handleUpdateStatus(status: RefillListStatus) {
setError('');
try {
await updateRefillList(householdId, list._id, { status });
onUpdated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update status');
}
}
async function handleAddToCabinet() {
const checkedCount = items.filter((i) => i.checked && !i.addedToCabinet).length;
/* v8 ignore next 4 */
if (checkedCount === 0) {
setError('No checked items to add to cabinet.');
return;
}
if (!confirm(`Add ${checkedCount} checked item${checkedCount !== 1 ? 's' : ''} to your cabinet?`)) return;
setAdding(true);
setError('');
try {
const result = await addToCabinet(householdId, list._id);
onUpdated();
setAdding(false);
if (result.addedCount > 0) {
alert(`Added ${result.addedCount} item${result.addedCount !== 1 ? 's' : ''} to your cabinet.`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add to cabinet');
setAdding(false);
}
}
const checkedNotAdded = items.filter((i) => i.checked && !i.addedToCabinet).length;
const totalChecked = items.filter((i) => i.checked).length;
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">{list.name}</h2>
<div className="flex items-center gap-2 mt-1">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[list.status] ?? STATUS_COLORS['active']}`}>
{STATUS_LABELS[list.status] ?? list.status}
</span>
<span className="text-xs text-gray-400">
{totalChecked}/{items.length} checked
</span>
{list.totalEstimatedCost != null && (
<span className="text-xs text-gray-500">
Est. {list.totalEstimatedCost.toFixed(2)}
</span>
)}
</div>
</div>
<button
onClick={onClose}
className="rounded p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="Close"
>
<svg className="h-5 w-5" 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>
{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>
)}
{items.length === 0 ? (
<p className="text-sm text-gray-500 py-4 text-center">No items in this list.</p>
) : (
<div className="space-y-2 mb-4">
{items.map((item) => (
<div
key={item._id}
className={`rounded-lg border p-3 flex items-start gap-3 ${item.addedToCabinet ? 'opacity-50' : ''}`}
>
<input
type="checkbox"
checked={item.checked}
onChange={() => handleToggleItem(item)}
disabled={item.addedToCabinet}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${item.checked ? 'line-through text-gray-400' : 'text-gray-900'}`}>
{item.medicineName}
</span>
<span className="text-xs text-gray-500">
{item.quantity} {item.unit}
</span>
{item.estimatedPrice != null && (
<span className="text-xs text-gray-400">est. {item.estimatedPrice.toFixed(2)}</span>
)}
{item.addedToCabinet && (
<span className="rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs">
in cabinet
</span>
)}
</div>
{item.notes && <p className="text-xs text-gray-400 mt-0.5">{item.notes}</p>}
</div>
{item.checked && !item.addedToCabinet && (
<div className="shrink-0">
<input
type="number"
min={0}
step="any"
value={actualPrices[item._id] ?? ''}
onChange={(e) =>
setActualPrices((prev) => ({ ...prev, [item._id]: e.target.value }))
}
placeholder="Actual price"
className="w-28 rounded-lg border px-2 py-1 text-xs focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
)}
</div>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
{checkedNotAdded > 0 && (
<button
onClick={handleAddToCabinet}
disabled={adding}
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"
>
{adding ? 'Adding...' : `Add ${checkedNotAdded} to Cabinet`}
</button>
)}
{list.status === RefillListStatus.ACTIVE && (
<button
onClick={() => handleUpdateStatus(RefillListStatus.SHOPPING)}
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Start shopping
</button>
)}
{list.status === RefillListStatus.SHOPPING && (
<button
onClick={() => handleUpdateStatus(RefillListStatus.COMPLETED)}
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Mark complete
</button>
)}
{(list.status === RefillListStatus.ACTIVE ||
list.status === RefillListStatus.SHOPPING) && (
<button
onClick={() => handleUpdateStatus(RefillListStatus.ARCHIVED)}
className="rounded-lg border px-3 py-2 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
>
Archive
</button>
)}
</div>
</div>
);
}
// --- Create list form ---
function CreateListForm({
householdId,
onCreated,
onCancel,
}: {
householdId: string;
onCreated: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await createRefillList(householdId, { name: name.trim(), fromAlerts: false, thresholdDays: 7 });
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create list');
} finally {
setSubmitting(false);
}
}
return (
<div className="mb-4 rounded-xl border bg-white p-6 shadow-sm">
<h3 className="text-base font-semibold mb-3">New Refill List</h3>
{error && (
<div className="mb-3 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="flex items-center gap-3">
<input
type="text"
required
maxLength={200}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="List name"
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
<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 ? 'Creating...' : 'Create'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</form>
</div>
);
}
// --- Refill lists panel ---
function RefillListsPanel({ householdId }: { householdId: string }) {
const [lists, setLists] = useState<RefillList[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [showForm, setShowForm] = useState(false);
const [selectedList, setSelectedList] = useState<RefillList | null>(null);
const fetchLists = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await listRefillLists(householdId, {
status: (filterStatus as RefillListStatus) || undefined,
limit: 30,
});
setLists(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load refill lists');
} finally {
setLoading(false);
}
}, [householdId, filterStatus]);
useEffect(() => {
fetchLists();
}, [fetchLists]);
function handleSelectList(list: RefillList) {
setSelectedList((prev) => (prev?._id === list._id ? null : list));
}
return (
<div>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<h2 className="text-lg font-semibold">Refill Lists</h2>
<div className="flex items-center gap-3">
<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(RefillListStatus).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</option>
))}
</select>
<button
onClick={() => setShowForm(!showForm)}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
{showForm ? 'Cancel' : 'New List'}
</button>
</div>
</div>
{showForm && (
<CreateListForm
householdId={householdId}
onCreated={() => {
setShowForm(false);
fetchLists();
}}
onCancel={() => setShowForm(false)}
/>
)}
{selectedList && (
<div className="mb-4">
<RefillListDetail
key={selectedList._id}
list={selectedList}
householdId={householdId}
onUpdated={() => {
fetchLists();
setSelectedList(null);
}}
onClose={() => setSelectedList(null)}
/>
</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 rounded-xl border bg-white p-4 h-16" />
))}
</div>
) : lists.length === 0 ? (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-sm text-gray-500">
{filterStatus ? `No ${STATUS_LABELS[filterStatus] ?? filterStatus} lists.` : 'No refill lists yet. Create one above or generate from alerts.'}
</div>
) : (
<div className="space-y-2">
{lists.map((list) => {
const checkedCount = list.items.filter((i) => i.checked).length;
const isSelected = selectedList?._id === list._id;
return (
<button
key={list._id}
onClick={() => handleSelectList(list)}
className={`w-full rounded-xl border p-4 text-left transition-colors ${
isSelected
? 'bg-primary-50 border-primary-300'
: 'bg-white hover:bg-gray-50'
} shadow-sm`}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="font-medium text-gray-900 truncate">{list.name}</span>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[list.status] ?? STATUS_COLORS['active']}`}
>
{STATUS_LABELS[list.status] ?? list.status}
</span>
</div>
<p className="text-xs text-gray-500">
{list.items.length} item{list.items.length !== 1 ? 's' : ''}
{list.items.length > 0 && `${checkedCount} checked`}
{list.totalEstimatedCost != null &&
` — est. ${list.totalEstimatedCost.toFixed(2)}`}
</p>
</div>
<div className="text-xs text-gray-400 shrink-0">{formatDate(list.createdAt)}</div>
</div>
</button>
);
})}
</div>
)}
</div>
);
}
// --- Main page ---
function RefillsContent({ householdId }: { householdId: string }) {
const [listsKey, setListsKey] = useState(0);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Refills</h1>
<div className="space-y-6">
<AlertsPanel
householdId={householdId}
onGenerateList={() => setListsKey((k) => k + 1)}
/>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<RefillListsPanel key={listsKey} householdId={householdId} />
</div>
</div>
</div>
);
}
export default function RefillsPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Refills</h1>
<div className="animate-pulse space-y-4">
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-60 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Refills</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 refills.
</p>
</div>
</div>
);
}
return <RefillsContent householdId={householdId} />;
}