Phase 5 cleanup

This commit is contained in:
Aerilyn Weber 2026-04-26 18:44:59 +09:00
parent 5536acd67d
commit 76a516a417
136 changed files with 6322 additions and 1985 deletions

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import {
getRefillAlerts,
listRefillLists,
@ -26,11 +27,11 @@ const STATUS_LABELS: Record<string, string> = {
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',
const STATUS_PILL: Record<string, string> = {
active: 'mt-pill--ok',
shopping: 'mt-pill--info',
completed: 'mt-pill--ghost',
archived: 'mt-pill--ghost',
};
function formatDate(dateStr: string): string {
@ -93,7 +94,7 @@ function AlertsPanel({
}
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<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">
@ -102,7 +103,8 @@ function AlertsPanel({
<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"
className="mt-field"
style={{ width: 'auto' }}
>
{[3, 5, 7, 10, 14, 30].map((d) => (
<option key={d} value={d}>
@ -114,7 +116,7 @@ function AlertsPanel({
{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"
className="mt-btn mt-btn--primary"
>
Generate Refill List
</button>
@ -131,19 +133,15 @@ function AlertsPanel({
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"
className="mt-field"
/>
<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"
>
<button type="submit" disabled={generating} className="mt-btn mt-btn--primary">
{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"
className="mt-btn mt-btn--ghost"
>
Cancel
</button>
@ -151,7 +149,7 @@ function AlertsPanel({
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -169,9 +167,7 @@ function AlertsPanel({
<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>
<span className="block mt-1 text-xs">Try increasing the threshold to see more.</span>
)}
</div>
) : (
@ -185,10 +181,7 @@ function AlertsPanel({
: 'text-yellow-600';
return (
<div
key={alert.medicineId}
className="rounded-lg border bg-gray-50 p-4"
>
<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">
@ -201,17 +194,14 @@ function AlertsPanel({
<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>
<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>
Suggested:{' '}
<span className="font-medium">{alert.suggestedQuantity} units</span>
</p>
{alert.cheapestOption && (
<p className="text-green-700 font-medium">
@ -260,9 +250,7 @@ function RefillListDetail({
const updated = await updateRefillListItem(householdId, list._id, item._id, {
checked: !item.checked,
actualPrice:
!item.checked && actualPrices[item._id]
? Number(actualPrices[item._id])
: undefined,
!item.checked && actualPrices[item._id] ? Number(actualPrices[item._id]) : undefined,
});
setItems(updated.items);
} catch (err) {
@ -287,7 +275,10 @@ function RefillListDetail({
setError('No checked items to add to cabinet.');
return;
}
if (!confirm(`Add ${checkedCount} checked item${checkedCount !== 1 ? 's' : ''} to your cabinet?`)) return;
if (
!confirm(`Add ${checkedCount} checked item${checkedCount !== 1 ? 's' : ''} to your cabinet?`)
)
return;
setAdding(true);
setError('');
try {
@ -295,7 +286,9 @@ function RefillListDetail({
onUpdated();
setAdding(false);
if (result.addedCount > 0) {
alert(`Added ${result.addedCount} item${result.addedCount !== 1 ? 's' : ''} to your cabinet.`);
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');
@ -307,12 +300,12 @@ function RefillListDetail({
const totalChecked = items.filter((i) => i.checked).length;
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<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']}`}>
<span className={`mt-pill ${STATUS_PILL[list.status] ?? STATUS_PILL['active']}`}>
{STATUS_LABELS[list.status] ?? list.status}
</span>
<span className="text-xs text-gray-400">
@ -325,21 +318,24 @@ function RefillListDetail({
)}
</div>
</div>
<button
onClick={onClose}
className="rounded p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="Close"
>
<button onClick={onClose} className="mt-btn mt-btn--icon" 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" />
<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">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">Dismiss</button>
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
@ -357,24 +353,24 @@ function RefillListDetail({
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"
className="h-4 w-4 rounded border-gray-300"
/>
<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'}`}>
<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 className="text-xs text-gray-400">
est. {item.estimatedPrice.toFixed(2)}
</span>
)}
{item.addedToCabinet && <span className="mt-pill mt-pill--ok">in cabinet</span>}
</div>
{item.notes && <p className="text-xs text-gray-400 mt-0.5">{item.notes}</p>}
</div>
@ -389,7 +385,8 @@ function RefillListDetail({
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"
className="mt-field"
style={{ width: '7rem', fontSize: '0.75rem' }}
/>
</div>
)}
@ -400,18 +397,14 @@ function RefillListDetail({
<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"
>
<button onClick={handleAddToCabinet} disabled={adding} className="mt-btn mt-btn--primary">
{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"
className="mt-btn mt-btn--ghost"
>
Start shopping
</button>
@ -419,16 +412,15 @@ function RefillListDetail({
{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"
className="mt-btn mt-btn--ghost"
>
Mark complete
</button>
)}
{(list.status === RefillListStatus.ACTIVE ||
list.status === RefillListStatus.SHOPPING) && (
{(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"
className="mt-btn mt-btn--ghost"
>
Archive
</button>
@ -458,7 +450,11 @@ function CreateListForm({
setError('');
setSubmitting(true);
try {
await createRefillList(householdId, { name: name.trim(), fromAlerts: false, thresholdDays: 7 });
await createRefillList(householdId, {
name: name.trim(),
fromAlerts: false,
thresholdDays: 7,
});
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create list');
@ -468,13 +464,9 @@ function CreateListForm({
}
return (
<div className="mb-4 rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card mb-4">
<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>
)}
{error && <div className="mt-alert mt-alert--danger mb-3">{error}</div>}
<form onSubmit={handleSubmit} className="flex items-center gap-3">
<input
type="text"
@ -483,20 +475,12 @@ function CreateListForm({
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"
className="mt-field"
/>
<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"
>
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
{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"
>
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
Cancel
</button>
</form>
@ -546,7 +530,8 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
<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"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All statuses</option>
{Object.values(RefillListStatus).map((s) => (
@ -555,10 +540,7 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
</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"
>
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
{showForm ? 'Cancel' : 'New List'}
</button>
</div>
@ -591,9 +573,11 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">Dismiss</button>
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
@ -604,8 +588,10 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
))}
</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 className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
{filterStatus
? `No ${STATUS_LABELS[filterStatus] ?? filterStatus} lists.`
: 'No refill lists yet. Create one above or generate from alerts.'}
</div>
) : (
<div className="space-y-2">
@ -617,18 +603,16 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
<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`}
className={`w-full mt-card text-left transition-colors ${
isSelected ? 'outline outline-2 outline-[var(--brand)]' : ''
}`}
>
<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']}`}
className={`mt-pill ${STATUS_PILL[list.status] ?? STATUS_PILL['active']}`}
>
{STATUS_LABELS[list.status] ?? list.status}
</span>
@ -658,13 +642,9 @@ function RefillsContent({ householdId }: { householdId: string }) {
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">
<AlertsPanel householdId={householdId} onGenerateList={() => setListsKey((k) => k + 1)} />
<div className="mt-card">
<RefillListsPanel key={listsKey} householdId={householdId} />
</div>
</div>
@ -677,32 +657,54 @@ export default function RefillsPage() {
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" />
<>
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
<div style={{ padding: '28px 32px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[1, 2].map((i) => (
<div
key={i}
style={{ height: 96, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
/>
))}
</div>
</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>
<>
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to{' '}
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
create or join a household
</Link>{' '}
before managing refills.
</p>
</div>
</div>
</div>
</>
);
}
return <RefillsContent householdId={householdId} />;
return (
<>
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
<div className="mt-page">
<RefillsContent householdId={householdId} />
</div>
</>
);
}