488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
'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 (
|
|
<>
|
|
<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 EmptyState({ message }: { message: string }) {
|
|
return (
|
|
<div
|
|
style={{ padding: '12px 0', fontSize: 13, color: 'var(--ink-muted)', textAlign: 'center' }}
|
|
>
|
|
{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>
|
|
);
|
|
}
|