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

@ -1,41 +1,488 @@
import Link from 'next/link';
'use client';
import useSWR from 'swr';
import { useApi } from '@/lib/useApi';
import { getCabinetSummary, listCabinetItems } from '@/services/cabinet';
import { listPurchases } from '@/services/purchases';
import { getRefillAlerts } from '@/services/refills';
import { listCabinetEvents } from '@/services/cabinet-events';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Pill } from '@/components/ui/Pill';
import { Icon } from '@/components/ui/Icon';
function now() {
return new Date();
}
function greeting() {
const h = now().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
function formatDate(d: Date) {
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
}
export default function DashboardPage() {
const { householdId, profile, isLoading } = useApi();
const name = profile?.displayName?.split(' ')[0] ?? 'there';
const { data: summary } = useSWR(householdId ? `cabinet-summary-${householdId}` : null, () =>
getCabinetSummary(householdId!),
);
const { data: cabinetItems } = useSWR(householdId ? `cabinet-items-${householdId}` : null, () =>
listCabinetItems(householdId!, { limit: 10 }),
);
const { data: pendingPurchases } = useSWR(
householdId ? `purchases-ordered-${householdId}` : null,
() => listPurchases(householdId!, { status: 'ordered', limit: 5 }),
);
const { data: refillAlerts } = useSWR(householdId ? `refill-alerts-${householdId}` : null, () =>
getRefillAlerts(householdId!, { thresholdDays: 14 }),
);
const { data: recentEvents } = useSWR(householdId ? `cabinet-events-${householdId}` : null, () =>
listCabinetEvents(householdId!, { limit: 5 }),
);
const today = formatDate(now());
if (isLoading) {
return (
<>
<SetPageHeader title="Dashboard" subtitle="Household overview" />
<DashboardSkeleton />
</>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<DashboardCard
title="Medicines"
description="Manage your medicines, products and inventory"
href="/medicines"
/>
<DashboardCard
title="Settings"
description="Manage household and account settings"
href="/settings"
/>
<>
<SetPageHeader title="Dashboard" subtitle="Household overview" />
<div style={{ padding: '28px 32px 56px', maxWidth: 1400, width: '100%' }}>
{/* Hero */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 24,
paddingBottom: 24,
borderBottom: '1px solid var(--border)',
marginBottom: 20,
}}
>
<div>
<div
style={{
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--ink-muted)',
fontWeight: 500,
marginBottom: 6,
}}
>
{today}
</div>
<div
style={{
fontFamily: 'var(--font-display)',
fontSize: 34,
fontWeight: 400,
letterSpacing: '-0.02em',
color: 'var(--ink-strong)',
lineHeight: 1.05,
}}
>
{greeting()}, {name}.
</div>
{summary && (
<div style={{ fontSize: 14, color: 'var(--ink-muted)', marginTop: 8 }}>
{refillAlerts && refillAlerts.data.some((a) => a.daysUntilEmpty <= 7) ? (
<span style={{ color: 'var(--danger)' }}>
Some medicines are critically low check refills.
</span>
) : (
'Your cabinet is in good shape.'
)}
</div>
)}
</div>
{/* Cabinet stats */}
{summary && (
<div style={{ display: 'flex', gap: 16, flexShrink: 0 }}>
<StatBadge label="Total medicines" value={summary.data.length} />
<StatBadge label="Running low" value={refillAlerts?.data.length ?? 0} tone="warn" />
</div>
)}
</div>
{/* Grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(12, 1fr)',
gap: 16,
}}
>
{/* Days of supply */}
<div style={{ gridColumn: 'span 7' }}>
<Card>
<CardHeader
title="Cabinet — days of supply"
subtitle="At current usage"
action={
<Button variant="ghost" size="sm">
Open cabinet <Icon name="arrow" size={12} />
</Button>
}
/>
<div
style={{
padding: '4px 18px 16px',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
{cabinetItems?.data.length ? (
cabinetItems.data.slice(0, 8).map((item) => (
<div
key={item._id}
style={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 12,
alignItems: 'center',
fontSize: 12,
padding: '4px 0',
}}
>
<div
style={{
fontWeight: 500,
color: 'var(--ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.medicineName ?? 'Unknown'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
flex: 1,
height: 6,
background: 'var(--bg-inset)',
borderRadius: 3,
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${Math.min(100, (item.quantity / 100) * 100)}%`,
background: 'var(--brand)',
borderRadius: 3,
}}
/>
</div>
<span
className="num"
style={{
fontSize: 12,
fontWeight: 600,
minWidth: 40,
textAlign: 'right',
}}
>
{item.quantity} {item.unit}
</span>
</div>
</div>
))
) : (
<EmptyState message="No cabinet items yet." />
)}
</div>
</Card>
</div>
{/* Running low */}
<div style={{ gridColumn: 'span 5' }}>
<Card>
<CardHeader
title="Running low"
subtitle={`${refillAlerts?.data.length ?? 0} need attention`}
action={
<Button variant="ghost" size="sm">
Refills
</Button>
}
/>
<div
style={{
padding: '4px 18px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{refillAlerts?.data.length ? (
refillAlerts.data.slice(0, 5).map((alert) => (
<div
key={alert.medicineId}
style={{
display: 'flex',
gap: 10,
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px dashed var(--border)',
}}
>
<div
style={{
width: 22,
height: 22,
borderRadius: 6,
display: 'grid',
placeItems: 'center',
background: 'var(--brand-soft)',
color: 'var(--brand)',
flexShrink: 0,
}}
>
<Icon name="pill" size={12} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: 'var(--ink-strong)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{alert.medicineName}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div
className="num"
style={{
fontSize: 14,
fontWeight: 600,
color: alert.daysUntilEmpty <= 7 ? 'var(--danger)' : 'var(--warn)',
}}
>
{alert.daysUntilEmpty}d
</div>
<div style={{ fontSize: 10, color: 'var(--ink-faint)' }}>left</div>
</div>
</div>
))
) : (
<EmptyState message="No alerts — all stocked." />
)}
</div>
</Card>
</div>
{/* Pending orders */}
<div style={{ gridColumn: 'span 6' }}>
<Card>
<CardHeader
title="Pending orders"
subtitle={`${pendingPurchases?.data.length ?? 0} awaiting arrival`}
action={
<Button variant="ghost" size="sm">
All purchases
</Button>
}
/>
<div
style={{
padding: '4px 18px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{pendingPurchases?.data.length ? (
pendingPurchases.data.map((p) => (
<div
key={p._id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 0',
borderBottom: '1px dashed var(--border)',
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'var(--brand-soft)',
color: 'var(--brand-soft-ink)',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
}}
>
<Icon name="truck" size={14} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: 13 }}>
{p.storeName ?? 'Unknown store'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
{p.items.length} item{p.items.length > 1 ? 's' : ''}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<Pill tone={p.status === 'ordered' ? 'warn' : 'ok'}>{p.status}</Pill>
</div>
</div>
))
) : (
<EmptyState message="No pending orders." />
)}
</div>
</Card>
</div>
{/* Recent activity */}
<div style={{ gridColumn: 'span 6' }}>
<Card>
<CardHeader
title="Recent activity"
subtitle="Cabinet changes"
action={
<Button variant="ghost" size="sm">
See all
</Button>
}
/>
<div style={{ padding: '4px 18px 16px' }}>
{recentEvents?.data.length ? (
recentEvents.data.slice(0, 5).map((event) => (
<div
key={event._id}
style={{
display: 'flex',
gap: 10,
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px dashed var(--border)',
}}
>
<Pill
tone={
event.eventType === 'consumed'
? 'info'
: event.eventType === 'added'
? 'ok'
: 'warn'
}
style={{ minWidth: 76, justifyContent: 'center' }}
>
{event.eventType}
</Pill>
<span style={{ flex: 1, fontSize: 12 }}>
<strong style={{ fontWeight: 500 }}>{event.medicineName}</strong>
</span>
<span
className="mono"
style={{ fontSize: 10, color: 'var(--ink-faint)', flexShrink: 0 }}
>
{new Date(event.createdAt).toLocaleDateString()}
</span>
</div>
))
) : (
<EmptyState message="No recent activity." />
)}
</div>
</Card>
</div>
</div>
</div>
</>
);
}
function StatBadge({
label,
value,
tone,
}: {
label: string;
value: number;
tone?: 'warn' | 'danger';
}) {
const color =
tone === 'danger' ? 'var(--danger)' : tone === 'warn' ? 'var(--warn)' : 'var(--brand)';
return (
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '10px 14px',
textAlign: 'center',
}}
>
<div
className="num"
style={{ fontSize: 24, fontWeight: 600, color, letterSpacing: '-0.02em', lineHeight: 1.15 }}
>
{value}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 2 }}>{label}</div>
</div>
);
}
function DashboardCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
function EmptyState({ message }: { message: string }) {
return (
<Link
href={href}
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
<div
style={{ padding: '12px 0', fontSize: 13, color: 'var(--ink-muted)', textAlign: 'center' }}
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{description}</p>
</Link>
{message}
</div>
);
}
function DashboardSkeleton() {
return (
<div style={{ padding: '28px 32px', maxWidth: 1400 }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
height: 80,
borderRadius: 'var(--r-md)',
background: 'var(--bg-inset)',
marginBottom: 16,
animation: 'pulse 1.5s infinite',
}}
/>
))}
</div>
);
}