Phase 5 cleanup
This commit is contained in:
parent
5536acd67d
commit
76a516a417
136 changed files with 6322 additions and 1985 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue