MeshiTrack/packages/web/src/app/(dashboard)/refills/page.tsx

711 lines
23 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
2026-04-26 18:44:59 +09:00
import { SetPageHeader } from '@/components/layout/SetPageHeader';
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',
};
2026-04-26 18:44:59 +09:00
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 {
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 (
2026-04-26 18:44:59 +09:00
<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">
<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))}
2026-04-26 18:44:59 +09:00
className="mt-field"
style={{ width: 'auto' }}
>
{[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)}
2026-04-26 18:44:59 +09:00
className="mt-btn mt-btn--primary"
>
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"
2026-04-26 18:44:59 +09:00
className="mt-field"
/>
2026-04-26 18:44:59 +09:00
<button type="submit" disabled={generating} className="mt-btn mt-btn--primary">
{generating ? 'Creating...' : 'Create'}
</button>
<button
type="button"
onClick={() => setShowGenerateForm(false)}
2026-04-26 18:44:59 +09:00
className="mt-btn mt-btn--ghost"
>
Cancel
</button>
</form>
)}
{error && (
2026-04-26 18:44:59 +09:00
<div className="mt-alert mt-alert--danger mb-4">
{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 && (
2026-04-26 18:44:59 +09:00
<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 (
2026-04-26 18:44:59 +09:00
<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>
2026-04-26 18:44:59 +09:00
<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">
2026-04-26 18:44:59 +09:00
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:
2026-04-26 18:44:59 +09:00
!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;
}
2026-04-26 18:44:59 +09:00
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) {
2026-04-26 18:44:59 +09:00
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 (
2026-04-26 18:44:59 +09:00
<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">
2026-04-26 18:44:59 +09:00
<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">
{totalChecked}/{items.length} checked
</span>
{list.totalEstimatedCost != null && (
<span className="text-xs text-gray-500">
Est. {list.totalEstimatedCost.toFixed(2)}
</span>
)}
</div>
</div>
2026-04-26 18:44:59 +09:00
<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">
2026-04-26 18:44:59 +09:00
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{error && (
2026-04-26 18:44:59 +09:00
<div className="mt-alert mt-alert--danger mb-4">
{error}
2026-04-26 18:44:59 +09:00
<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}
2026-04-26 18:44:59 +09:00
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">
2026-04-26 18:44:59 +09:00
<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 && (
2026-04-26 18:44:59 +09:00
<span className="text-xs text-gray-400">
est. {item.estimatedPrice.toFixed(2)}
</span>
)}
2026-04-26 18:44:59 +09:00
{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>
{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"
2026-04-26 18:44:59 +09:00
className="mt-field"
style={{ width: '7rem', fontSize: '0.75rem' }}
/>
</div>
)}
</div>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
{checkedNotAdded > 0 && (
2026-04-26 18:44:59 +09:00
<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)}
2026-04-26 18:44:59 +09:00
className="mt-btn mt-btn--ghost"
>
Start shopping
</button>
)}
{list.status === RefillListStatus.SHOPPING && (
<button
onClick={() => handleUpdateStatus(RefillListStatus.COMPLETED)}
2026-04-26 18:44:59 +09:00
className="mt-btn mt-btn--ghost"
>
Mark complete
</button>
)}
2026-04-26 18:44:59 +09:00
{(list.status === RefillListStatus.ACTIVE || list.status === RefillListStatus.SHOPPING) && (
<button
onClick={() => handleUpdateStatus(RefillListStatus.ARCHIVED)}
2026-04-26 18:44:59 +09:00
className="mt-btn mt-btn--ghost"
>
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 {
2026-04-26 18:44:59 +09:00
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 (
2026-04-26 18:44:59 +09:00
<div className="mt-card mb-4">
<h3 className="text-base font-semibold mb-3">New Refill List</h3>
2026-04-26 18:44:59 +09:00
{error && <div className="mt-alert mt-alert--danger mb-3">{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"
2026-04-26 18:44:59 +09:00
className="mt-field"
/>
2026-04-26 18:44:59 +09:00
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
{submitting ? 'Creating...' : 'Create'}
</button>
2026-04-26 18:44:59 +09:00
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
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)}
2026-04-26 18:44:59 +09:00
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All statuses</option>
{Object.values(RefillListStatus).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</option>
))}
</select>
2026-04-26 18:44:59 +09:00
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
{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 && (
2026-04-26 18:44:59 +09:00
<div className="mt-alert mt-alert--danger mb-4">
{error}
2026-04-26 18:44:59 +09:00
<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 ? (
2026-04-26 18:44:59 +09:00
<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">
{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)}
2026-04-26 18:44:59 +09:00
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
2026-04-26 18:44:59 +09:00
className={`mt-pill ${STATUS_PILL[list.status] ?? STATUS_PILL['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>
<div className="space-y-6">
2026-04-26 18:44:59 +09:00
<AlertsPanel householdId={householdId} onGenerateList={() => setListsKey((k) => k + 1)} />
<div className="mt-card">
<RefillListsPanel key={listsKey} householdId={householdId} />
</div>
</div>
</div>
);
}
export default function RefillsPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
2026-04-26 18:44:59 +09:00
<>
<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>
2026-04-26 18:44:59 +09:00
</>
);
}
if (!householdId) {
return (
2026-04-26 18:44:59 +09:00
<>
<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>
2026-04-26 18:44:59 +09:00
</>
);
}
2026-04-26 18:44:59 +09:00
return (
<>
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
<div className="mt-page">
<RefillsContent householdId={householdId} />
</div>
</>
);
}