Phase 5 cleanup
This commit is contained in:
parent
5536acd67d
commit
76a516a417
136 changed files with 6322 additions and 1985 deletions
|
|
@ -26,6 +26,12 @@ export default tseslint.config(
|
|||
settings: {
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }],
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ const nextConfig: NextConfig = {
|
|||
webpack: (config) => {
|
||||
// The shared package source uses ESM `.js` extensions on imports (e.g. `./enums/index.js`).
|
||||
// When Next.js resolves via tsconfig paths to the raw `.ts` source, webpack needs to
|
||||
// know that `.js` imports inside that directory should resolve to `.ts` files.
|
||||
// know that `.js` imports inside that directory should resolve to `.ts`/`.tsx` files.
|
||||
config.resolve = config.resolve ?? {};
|
||||
config.resolve.extensionAlias = {
|
||||
...config.resolve.extensionAlias,
|
||||
'.js': ['.ts', '.js'],
|
||||
'.js': ['.tsx', '.ts', '.jsx', '.js'],
|
||||
};
|
||||
|
||||
// Ensure the shared source directory is included in the module resolution
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import DashboardLayout from '../layout';
|
|||
|
||||
describe('DashboardLayout', () => {
|
||||
it('renders sidebar, topbar and children', () => {
|
||||
render(<DashboardLayout>
|
||||
<div data-testid="child">content</div>
|
||||
</DashboardLayout>);
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div data-testid="child">content</div>
|
||||
</DashboardLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render, container } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import DashboardLoading from '../loading';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('swr', () => ({ default: vi.fn(() => ({ data: undefined })) }));
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
|
|
@ -8,21 +11,29 @@ vi.mock('next/link', () => ({
|
|||
|
||||
import DashboardPage from '../page';
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, profile: undefined });
|
||||
});
|
||||
|
||||
describe(DashboardPage.name, () => {
|
||||
it('renders heading', () => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Medicines card linking to /medicines', () => {
|
||||
it('shows loading skeleton when session loading', () => {
|
||||
render(<DashboardPage />);
|
||||
const link = screen.getByRole('link', { name: /medicines/i });
|
||||
expect(link).toHaveAttribute('href', '/medicines');
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Settings card linking to /settings', () => {
|
||||
it('renders page when household loaded', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Alice' },
|
||||
});
|
||||
render(<DashboardPage />);
|
||||
const link = screen.getByRole('link', { name: /settings/i });
|
||||
expect(link).toHaveAttribute('href', '/settings');
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,31 @@
|
|||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { TopBar } from '@/components/layout/TopBar';
|
||||
import { PageHeaderProvider } from '@/components/layout/PageHeaderContext';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
<PageHeaderProvider>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '248px 1fr',
|
||||
minHeight: '100vh',
|
||||
background: 'var(--bg)',
|
||||
}}
|
||||
>
|
||||
<Sidebar />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<TopBar />
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeaderProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
|
|
@ -28,7 +29,9 @@ vi.mock('@/services/medicines', () => ({
|
|||
listMedicineProducts: mockListMedicineProducts,
|
||||
}));
|
||||
vi.mock('@/services/stores', () => ({ listStores: mockListStores }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
|
||||
import MedicinePricesPage from '../page';
|
||||
|
||||
|
|
@ -74,7 +77,9 @@ describe('MedicinePricesPage', () => {
|
|||
it('loads price history when medicine selected', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockGetPriceHistory.mockResolvedValue({
|
||||
|
|
@ -102,7 +107,9 @@ describe('MedicinePricesPage', () => {
|
|||
await waitFor(() => screen.getByText('Select a medicine'));
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
|
||||
|
||||
await waitFor(() => expect(mockGetPriceHistory).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
|
||||
await waitFor(() =>
|
||||
expect(mockGetPriceHistory).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)),
|
||||
);
|
||||
// Wait for price records to render
|
||||
await waitFor(() => expect(screen.getByText('Walgreens')).toBeInTheDocument());
|
||||
});
|
||||
|
|
@ -116,13 +123,15 @@ describe('MedicinePricesPage', () => {
|
|||
await waitFor(() => screen.getByRole('button', { name: 'Record Price', hidden: false }));
|
||||
|
||||
// The submit button inside the form also has text 'Record Price'
|
||||
const submitBtn = screen.getAllByRole('button', { name: 'Record Price' }).find(
|
||||
(b) => b.getAttribute('type') === 'submit',
|
||||
);
|
||||
const submitBtn = screen
|
||||
.getAllByRole('button', { name: 'Record Price' })
|
||||
.find((b) => b.getAttribute('type') === 'submit');
|
||||
if (submitBtn) {
|
||||
fireEvent.submit(submitBtn.closest('form')!);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Please select a medicine, a product, and a store.')).toBeInTheDocument(),
|
||||
expect(
|
||||
screen.getByText('Please select a medicine, a product, and a store.'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -130,7 +139,9 @@ describe('MedicinePricesPage', () => {
|
|||
it('shows error when price history fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
||||
|
|
@ -146,11 +157,21 @@ describe('MedicinePricesPage', () => {
|
|||
it('records a price successfully', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Walgreens',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
|
|
@ -176,17 +197,28 @@ describe('MedicinePricesPage', () => {
|
|||
fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } });
|
||||
|
||||
// Submit form
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Record Price', hidden: true }).closest('form')!);
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Record Price', hidden: true }).closest('form')!,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockRecordPrice).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })));
|
||||
await waitFor(() =>
|
||||
expect(mockRecordPrice).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ medicineId: 'med-1' }),
|
||||
),
|
||||
);
|
||||
// Form should close after success
|
||||
await waitFor(() => expect(screen.queryByText('Record Price', { selector: 'h2' })).not.toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Record Price', { selector: 'h2' })).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Load more button in price history', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockGetPriceHistory.mockResolvedValue({
|
||||
|
|
@ -223,11 +255,21 @@ describe('MedicinePricesPage', () => {
|
|||
it('shows store filter when medicine selected and changes it', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Walgreens',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
|
|
@ -245,7 +287,9 @@ describe('MedicinePricesPage', () => {
|
|||
it('dismisses price history error', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
|
||||
|
|
@ -263,13 +307,31 @@ describe('MedicinePricesPage', () => {
|
|||
it('shows store comparison table', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCompareStores.mockResolvedValue({
|
||||
data: [
|
||||
{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 12.99, latestPricePerUnit: 0.14, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
|
||||
{ storeId: 'st-2', storeName: 'CVS', latestPrice: 14.99, latestPricePerUnit: 0.17, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
|
||||
{
|
||||
storeId: 'st-1',
|
||||
storeName: 'Walgreens',
|
||||
latestPrice: 12.99,
|
||||
latestPricePerUnit: 0.14,
|
||||
currency: 'USD',
|
||||
date: '2026-01-01T00:00:00.000Z',
|
||||
isInsurancePrice: false,
|
||||
},
|
||||
{
|
||||
storeId: 'st-2',
|
||||
storeName: 'CVS',
|
||||
latestPrice: 14.99,
|
||||
latestPricePerUnit: 0.17,
|
||||
currency: 'USD',
|
||||
date: '2026-01-01T00:00:00.000Z',
|
||||
isInsurancePrice: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -308,7 +370,9 @@ describe('MedicinePricesPage', () => {
|
|||
await userEvent.click(screen.getByText('Record Price'));
|
||||
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'Met' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
|
||||
target: { value: 'Met' },
|
||||
});
|
||||
|
||||
// Notes field (no placeholder, but maxLength 1000)
|
||||
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,8 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import {
|
||||
recordPrice,
|
||||
getPriceHistory,
|
||||
compareStores,
|
||||
} from '@/services/medicine-prices';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { recordPrice, getPriceHistory, compareStores } from '@/services/medicine-prices';
|
||||
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
||||
import { listStores } from '@/services/stores';
|
||||
import { DosageUnit } from '@meshitrack/shared';
|
||||
|
|
@ -120,22 +117,18 @@ function RecordPriceForm({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mt-card mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Record Price</h2>
|
||||
{error && (
|
||||
<div className="mb-4 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-4">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
|
||||
<label className="mt-field-label">Store</label>
|
||||
<select
|
||||
value={storeId}
|
||||
onChange={(e) => setStoreId(e.target.value)}
|
||||
required
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">Select store</option>
|
||||
{stores.map((s) => (
|
||||
|
|
@ -147,7 +140,7 @@ function RecordPriceForm({
|
|||
{stores.length === 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
No stores yet.{' '}
|
||||
<Link href="/stores" className="text-primary-600 underline">
|
||||
<Link href="/stores" className="mt-link">
|
||||
Add a store first
|
||||
</Link>
|
||||
</p>
|
||||
|
|
@ -155,19 +148,19 @@ function RecordPriceForm({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Medicine</label>
|
||||
<label className="mt-field-label">Medicine</label>
|
||||
<input
|
||||
type="text"
|
||||
value={medicineSearch}
|
||||
onChange={(e) => setMedicineSearch(e.target.value)}
|
||||
placeholder="Search medicines..."
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none mb-2"
|
||||
className="mt-field mb-2"
|
||||
/>
|
||||
<select
|
||||
value={medicineId}
|
||||
onChange={(e) => handleMedicineChange(e.target.value)}
|
||||
required
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">Select medicine</option>
|
||||
{filteredMedicines.map((m) => (
|
||||
|
|
@ -179,7 +172,7 @@ function RecordPriceForm({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Product</label>
|
||||
<label className="mt-field-label">Product</label>
|
||||
{productsLoading ? (
|
||||
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
||||
) : (
|
||||
|
|
@ -188,9 +181,11 @@ function RecordPriceForm({
|
|||
onChange={(e) => handleProductChange(e.target.value)}
|
||||
required
|
||||
disabled={!medicineId}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
||||
className="mt-field"
|
||||
>
|
||||
<option value="">{medicineId ? 'Select product' : 'Select a medicine first'}</option>
|
||||
<option value="">
|
||||
{medicineId ? 'Select product' : 'Select a medicine first'}
|
||||
</option>
|
||||
{products.map((p) => (
|
||||
<option key={p._id} value={p._id}>
|
||||
{p.brand ?? 'Generic'} — {p.packageSize} {p.packageUnit}
|
||||
|
|
@ -201,7 +196,7 @@ function RecordPriceForm({
|
|||
{medicineId && !productsLoading && products.length === 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
No products for this medicine.{' '}
|
||||
<Link href={`/medicines/${medicineId}`} className="text-primary-600 underline">
|
||||
<Link href={`/medicines/${medicineId}`} className="mt-link">
|
||||
Add a product first
|
||||
</Link>
|
||||
</p>
|
||||
|
|
@ -210,7 +205,7 @@ function RecordPriceForm({
|
|||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Price</label>
|
||||
<label className="mt-field-label">Price</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
|
|
@ -219,11 +214,11 @@ function RecordPriceForm({
|
|||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="9.99"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Currency</label>
|
||||
<label className="mt-field-label">Currency</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
|
|
@ -231,14 +226,14 @@ function RecordPriceForm({
|
|||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
placeholder="USD"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Package size</label>
|
||||
<label className="mt-field-label">Package size</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
|
|
@ -247,15 +242,15 @@ function RecordPriceForm({
|
|||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
placeholder="90"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
||||
<label className="mt-field-label">Unit</label>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value as DosageUnit)}
|
||||
className="w-full 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"
|
||||
>
|
||||
{Object.values(DosageUnit).map((u) => (
|
||||
<option key={u} value={u}>
|
||||
|
|
@ -267,15 +262,13 @@ function RecordPriceForm({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<label className="mt-field-label">Notes (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -285,7 +278,7 @@ function RecordPriceForm({
|
|||
id="isInsurancePrice"
|
||||
checked={isInsurancePrice}
|
||||
onChange={(e) => setIsInsurancePrice(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="isInsurancePrice" className="text-sm font-medium text-gray-700">
|
||||
Insurance price
|
||||
|
|
@ -294,18 +287,10 @@ function RecordPriceForm({
|
|||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<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 ? 'Recording...' : 'Record Price'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 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>
|
||||
</div>
|
||||
|
|
@ -371,14 +356,15 @@ function PriceHistory({
|
|||
}, [householdId, selectedMedicineId, selectedStoreId]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mt-card">
|
||||
<h2 className="text-lg font-semibold mb-4">Price History</h2>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={selectedMedicineId}
|
||||
onChange={(e) => setSelectedMedicineId(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="">Select a medicine</option>
|
||||
{medicines.map((m) => (
|
||||
|
|
@ -391,7 +377,8 @@ function PriceHistory({
|
|||
<select
|
||||
value={selectedStoreId}
|
||||
onChange={(e) => setSelectedStoreId(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 stores</option>
|
||||
{stores.map((s) => (
|
||||
|
|
@ -404,7 +391,7 @@ function PriceHistory({
|
|||
</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
|
||||
|
|
@ -439,18 +426,15 @@ function PriceHistory({
|
|||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{comparison.map((item, i) => (
|
||||
<tr key={item.storeId} className={i === 0 ? 'text-green-700 font-medium' : ''}>
|
||||
<tr
|
||||
key={item.storeId}
|
||||
className={i === 0 ? 'text-green-700 font-medium' : ''}
|
||||
>
|
||||
<td className="py-2">
|
||||
{item.storeName}
|
||||
{i === 0 && (
|
||||
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs">
|
||||
cheapest
|
||||
</span>
|
||||
)}
|
||||
{i === 0 && <span className="ml-2 mt-pill mt-pill--ok">cheapest</span>}
|
||||
{item.isInsurancePrice && (
|
||||
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
|
||||
insurance
|
||||
</span>
|
||||
<span className="ml-1 mt-pill mt-pill--info">insurance</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
|
|
@ -490,9 +474,7 @@ function PriceHistory({
|
|||
<td className="py-2">
|
||||
{r.storeName}
|
||||
{r.isInsurancePrice && (
|
||||
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
|
||||
ins
|
||||
</span>
|
||||
<span className="ml-1 mt-pill mt-pill--info">ins</span>
|
||||
)}
|
||||
{r.notes && (
|
||||
<span className="ml-1 text-xs text-gray-400"> — {r.notes}</span>
|
||||
|
|
@ -540,18 +522,18 @@ function MedicinePricesContent({ householdId }: { householdId: string }) {
|
|||
const [historyKey, setHistoryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
|
||||
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
|
||||
listMedicines(householdId, { limit: 100 })
|
||||
.then((r) => setMedicines(r.data))
|
||||
.catch(() => {});
|
||||
listStores(householdId, { limit: 100 })
|
||||
.then((r) => setStores(r.data))
|
||||
.catch(() => {});
|
||||
}, [householdId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Medicine Prices</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-primary-600 px-4 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' : 'Record Price'}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -586,33 +568,54 @@ export default function MedicinePricesPage() {
|
|||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
<>
|
||||
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: 64, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicine Prices</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 tracking prices.
|
||||
</p>
|
||||
<>
|
||||
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
|
||||
<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 tracking prices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <MedicinePricesContent householdId={householdId} />;
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
|
||||
<div className="mt-page">
|
||||
<MedicinePricesContent householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ import { listCabinetEvents, getSpendingSummary } from '@/services/cabinet-events
|
|||
import { listMedicines } from '@/services/medicines';
|
||||
import { CabinetEventType } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
CabinetEventResponseSchema,
|
||||
SpendingSummaryResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import type { CabinetEventResponseSchema, SpendingSummaryResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type CabinetEvent = z.infer<typeof CabinetEventResponseSchema>;
|
||||
type SpendingSummary = z.infer<typeof SpendingSummaryResponseSchema>;
|
||||
|
|
@ -27,13 +24,13 @@ const EVENT_TYPE_LABELS: Record<string, string> = {
|
|||
deleted: 'Deleted',
|
||||
};
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
purchased: 'bg-green-100 text-green-700',
|
||||
consumed: 'bg-blue-100 text-blue-700',
|
||||
adjusted: 'bg-yellow-100 text-yellow-700',
|
||||
discarded: 'bg-red-100 text-red-700',
|
||||
restored: 'bg-purple-100 text-purple-700',
|
||||
deleted: 'bg-gray-100 text-gray-600',
|
||||
const EVENT_TYPE_PILL: Record<string, string> = {
|
||||
purchased: 'mt-pill--ok',
|
||||
consumed: 'mt-pill--info',
|
||||
adjusted: 'mt-pill--warn',
|
||||
discarded: 'mt-pill--danger',
|
||||
restored: 'mt-pill--brand',
|
||||
deleted: 'mt-pill--ghost',
|
||||
};
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
|
|
@ -41,7 +38,7 @@ function formatDateTime(dateStr: string): string {
|
|||
}
|
||||
|
||||
/* v8 ignore next 4 */
|
||||
function formatQuantityChange(event: CabinetEvent): string {
|
||||
function _formatQuantityChange(event: CabinetEvent): string {
|
||||
const sign = event.quantity > 0 ? '+' : '';
|
||||
return `${sign}${event.quantity}`;
|
||||
}
|
||||
|
|
@ -49,9 +46,7 @@ function formatQuantityChange(event: CabinetEvent): string {
|
|||
function QuantityBadge({ quantity }: { quantity: number }) {
|
||||
const isPositive = quantity > 0;
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}
|
||||
>
|
||||
<span className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{isPositive ? '+' : ''}
|
||||
{quantity}
|
||||
</span>
|
||||
|
|
@ -96,14 +91,15 @@ function SpendingSummaryView({
|
|||
const PERIOD_LABELS = { month: 'This month', quarter: 'This quarter', year: 'This year' };
|
||||
|
||||
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">Spending Summary</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as 'month' | 'quarter' | 'year')}
|
||||
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' }}
|
||||
>
|
||||
{Object.entries(PERIOD_LABELS).map(([v, label]) => (
|
||||
<option key={v} value={v}>
|
||||
|
|
@ -114,7 +110,8 @@ function SpendingSummaryView({
|
|||
<select
|
||||
value={medicineId}
|
||||
onChange={(e) => setMedicineId(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 medicines</option>
|
||||
{medicines.map((m) => (
|
||||
|
|
@ -126,11 +123,7 @@ function SpendingSummaryView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 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-4">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
|
|
@ -155,9 +148,7 @@ function SpendingSummaryView({
|
|||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{item.medicineName}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.medicineName}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{item.purchaseCount} purchase{item.purchaseCount !== 1 ? 's' : ''} •{' '}
|
||||
avg {summary.currency ? `${summary.currency} ` : ''}
|
||||
|
|
@ -256,14 +247,15 @@ function EventTimeline({
|
|||
}, [householdId, filterEventType, filterMedicineId, startDate, endDate]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mt-card">
|
||||
<h2 className="text-lg font-semibold mb-4">Cabinet Activity</h2>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterEventType}
|
||||
onChange={(e) => setFilterEventType(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 event types</option>
|
||||
{Object.values(CabinetEventType).map((t) => (
|
||||
|
|
@ -275,7 +267,8 @@ function EventTimeline({
|
|||
<select
|
||||
value={filterMedicineId}
|
||||
onChange={(e) => setFilterMedicineId(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 medicines</option>
|
||||
{medicines.map((m) => (
|
||||
|
|
@ -288,14 +281,16 @@ function EventTimeline({
|
|||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(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' }}
|
||||
title="Start date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(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' }}
|
||||
title="End date"
|
||||
/>
|
||||
{(filterEventType || filterMedicineId || startDate || endDate) && (
|
||||
|
|
@ -306,7 +301,7 @@ function EventTimeline({
|
|||
setStartDate('');
|
||||
setEndDate('');
|
||||
}}
|
||||
className="text-sm text-gray-500 underline"
|
||||
className="mt-link text-sm"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
|
|
@ -314,7 +309,7 @@ function EventTimeline({
|
|||
</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
|
||||
|
|
@ -349,7 +344,7 @@ function EventTimeline({
|
|||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${/* v8 ignore next */ EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
|
||||
className={`mt-pill ${/* v8 ignore next */ EVENT_TYPE_PILL[event.eventType] ?? 'mt-pill--ghost'}`}
|
||||
>
|
||||
{/* v8 ignore next */ EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
|
||||
</span>
|
||||
|
|
@ -385,10 +380,7 @@ function EventTimeline({
|
|||
|
||||
{hasMore && (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => fetchEvents(true)}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<button onClick={() => fetchEvents(true)} className="mt-btn mt-btn--ghost">
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -407,7 +399,9 @@ export function ActivityTab({ householdId }: { householdId: string }) {
|
|||
useEffect(() => {
|
||||
listMedicines(householdId, { limit: 100 })
|
||||
.then((r) =>
|
||||
setMedicines(r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name }))),
|
||||
setMedicines(
|
||||
r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name })),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, [householdId]);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -29,11 +29,11 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||
other: 'Other',
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
prescription: 'bg-blue-100 text-blue-700',
|
||||
otc: 'bg-green-100 text-green-700',
|
||||
supplement: 'bg-purple-100 text-purple-700',
|
||||
other: 'bg-gray-100 text-gray-700',
|
||||
const CATEGORY_PILL: Record<string, string> = {
|
||||
prescription: 'mt-pill--info',
|
||||
otc: 'mt-pill--ok',
|
||||
supplement: 'mt-pill--brand',
|
||||
other: 'mt-pill--ghost',
|
||||
};
|
||||
|
||||
export function LibraryTab({ householdId }: { householdId: string }) {
|
||||
|
|
@ -83,16 +83,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
|
|||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div />
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-primary-600 px-4 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' : 'Add Medicine'}
|
||||
</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
|
||||
|
|
@ -117,12 +114,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search medicines..."
|
||||
className="w-full max-w-md 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 max-w-md"
|
||||
/>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(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 Categories</option>
|
||||
{Object.values(MedicineCategory).map((c) => (
|
||||
|
|
@ -134,7 +132,8 @@ export function LibraryTab({ householdId }: { householdId: string }) {
|
|||
<select
|
||||
value={filterForm}
|
||||
onChange={(e) => setFilterForm(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 Forms</option>
|
||||
{Object.values(MedicineForm).map((f) => (
|
||||
|
|
@ -176,13 +175,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
|
|||
</Link>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${CATEGORY_COLORS[med.category] ?? CATEGORY_COLORS['other']}`}
|
||||
className={`mt-pill ${CATEGORY_PILL[med.category] ?? CATEGORY_PILL['other']}`}
|
||||
>
|
||||
{CATEGORY_LABELS[med.category] ?? med.category}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(med._id, med.name)}
|
||||
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="mt-btn mt-btn--danger-icon"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -242,17 +241,13 @@ function CreateMedicineForm({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mt-card mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Add Medicine</h2>
|
||||
{error && (
|
||||
<div className="mb-4 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-4">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<label className="mt-field-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
|
|
@ -260,15 +255,15 @@ function CreateMedicineForm({
|
|||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g. Metformin"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Form</label>
|
||||
<label className="mt-field-label">Form</label>
|
||||
<select
|
||||
value={formData.form}
|
||||
onChange={(e) => setFormData({ ...formData, form: e.target.value as MedicineForm })}
|
||||
className="w-full 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"
|
||||
>
|
||||
{Object.values(MedicineForm).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
|
|
@ -279,7 +274,7 @@ function CreateMedicineForm({
|
|||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Strength</label>
|
||||
<label className="mt-field-label">Strength</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
|
|
@ -288,17 +283,17 @@ function CreateMedicineForm({
|
|||
value={formData.strength || ''}
|
||||
onChange={(e) => setFormData({ ...formData, strength: Number(e.target.value) })}
|
||||
placeholder="500"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
||||
<label className="mt-field-label">Unit</label>
|
||||
<select
|
||||
value={formData.strengthUnit}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, strengthUnit: e.target.value as StrengthUnit })
|
||||
}
|
||||
className="w-full 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"
|
||||
>
|
||||
{Object.values(StrengthUnit).map((u) => (
|
||||
<option key={u} value={u}>
|
||||
|
|
@ -309,13 +304,13 @@ function CreateMedicineForm({
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<label className="mt-field-label">Category</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, category: e.target.value as MedicineCategory })
|
||||
}
|
||||
className="w-full 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"
|
||||
>
|
||||
{Object.values(MedicineCategory).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
|
|
@ -325,30 +320,22 @@ function CreateMedicineForm({
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notes (optional)</label>
|
||||
<label className="mt-field-label">Notes (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={formData.notes ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value || undefined })}
|
||||
placeholder="Any additional notes"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<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 Medicine'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
listFills,
|
||||
previewFill,
|
||||
executeFill,
|
||||
undoFill,
|
||||
} from '@/services/organizer';
|
||||
import { listFills, previewFill, executeFill, undoFill } from '@/services/organizer';
|
||||
import { listRegimens } from '@/services/regimens';
|
||||
import { OrganizerFillStatus } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
|
|
@ -26,12 +21,6 @@ const STATUS_LABELS: Record<string, string> = {
|
|||
reversed: 'Reversed',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
partial: 'bg-yellow-100 text-yellow-700',
|
||||
reversed: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
|
@ -54,20 +43,16 @@ function PreviewResult({
|
|||
onTogglePartial: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm space-y-5">
|
||||
<div className="mt-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">
|
||||
Preview: {preview.regimenName} — {preview.numberOfDays} day
|
||||
{preview.numberOfDays !== 1 ? 's' : ''}
|
||||
</h3>
|
||||
{preview.hasShortages ? (
|
||||
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-700">
|
||||
Shortages detected
|
||||
</span>
|
||||
<span className="mt-pill mt-pill--warn">Shortages detected</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700">
|
||||
Ready to fill
|
||||
</span>
|
||||
<span className="mt-pill mt-pill--ok">Ready to fill</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -87,9 +72,7 @@ function PreviewResult({
|
|||
Available: <strong>{item.quantityAvailable}</strong>
|
||||
</span>
|
||||
{item.isShort && (
|
||||
<span className="text-yellow-700 font-semibold">
|
||||
Short: {item.shortage}
|
||||
</span>
|
||||
<span className="text-yellow-700 font-semibold">Short: {item.shortage}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -98,7 +81,9 @@ function PreviewResult({
|
|||
{item.cabinetBreakdown.map((b, i) => (
|
||||
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
|
||||
{b.quantityToTake} units
|
||||
{b.expirationDate ? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})` : ''}
|
||||
{b.expirationDate
|
||||
? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})`
|
||||
: ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -114,7 +99,7 @@ function PreviewResult({
|
|||
id="allowPartial"
|
||||
checked={allowPartial}
|
||||
onChange={(e) => onTogglePartial(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="allowPartial" className="text-sm text-gray-700">
|
||||
Allow partial fill (fill what is available)
|
||||
|
|
@ -126,14 +111,11 @@ function PreviewResult({
|
|||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={submitting || (preview.hasShortages && !allowPartial)}
|
||||
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"
|
||||
className="mt-btn mt-btn--primary"
|
||||
>
|
||||
{submitting ? 'Filling...' : 'Confirm fill'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<button onClick={onCancel} className="mt-btn mt-btn--ghost">
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -182,7 +164,12 @@ function FillWizard({
|
|||
setError('');
|
||||
setFilling(true);
|
||||
try {
|
||||
await executeFill(householdId, { regimenId, numberOfDays, allowPartial, notes: notes || undefined });
|
||||
await executeFill(householdId, {
|
||||
regimenId,
|
||||
numberOfDays,
|
||||
allowPartial,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
setPreview(null);
|
||||
setRegimenId('');
|
||||
setNumberOfDays(7);
|
||||
|
|
@ -218,13 +205,9 @@ function FillWizard({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mt-card">
|
||||
<h2 className="text-lg font-semibold mb-4">Fill Pill Organizer</h2>
|
||||
{error && (
|
||||
<div className="mb-4 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-4">{error}</div>}
|
||||
{activeRegimens.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
No active regimens found. Create and activate a regimen before filling.
|
||||
|
|
@ -233,23 +216,24 @@ function FillWizard({
|
|||
<form onSubmit={handlePreview} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Regimen</label>
|
||||
<label className="mt-field-label">Regimen</label>
|
||||
<select
|
||||
required
|
||||
value={regimenId}
|
||||
onChange={(e) => setRegimenId(e.target.value)}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">Select regimen...</option>
|
||||
{activeRegimens.map((r) => (
|
||||
<option key={r._id} value={r._id}>
|
||||
{r.name} ({r.medications.length} medication{r.medications.length !== 1 ? 's' : ''})
|
||||
{r.name} ({r.medications.length} medication
|
||||
{r.medications.length !== 1 ? 's' : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Number of days</label>
|
||||
<label className="mt-field-label">Number of days</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
|
|
@ -257,28 +241,22 @@ function FillWizard({
|
|||
max={90}
|
||||
value={numberOfDays}
|
||||
onChange={(e) => setNumberOfDays(Number(e.target.value))}
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<label className="mt-field-label">Notes (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any notes for this fill"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={previewing}
|
||||
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={previewing} className="mt-btn mt-btn--primary">
|
||||
{previewing ? 'Calculating...' : 'Preview fill'}
|
||||
</button>
|
||||
</form>
|
||||
|
|
@ -289,13 +267,7 @@ function FillWizard({
|
|||
|
||||
// --- Fill history list ---
|
||||
|
||||
function FillHistory({
|
||||
householdId,
|
||||
refreshKey,
|
||||
}: {
|
||||
householdId: string;
|
||||
refreshKey: number;
|
||||
}) {
|
||||
function FillHistory({ householdId, refreshKey }: { householdId: string; refreshKey: number }) {
|
||||
const [fills, setFills] = useState<OrganizerFill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
|
@ -331,13 +303,14 @@ function FillHistory({
|
|||
}
|
||||
|
||||
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">
|
||||
<h2 className="text-lg font-semibold">Fill History</h2>
|
||||
<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(OrganizerFillStatus).map((s) => (
|
||||
|
|
@ -349,7 +322,7 @@ function FillHistory({
|
|||
</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
|
||||
|
|
@ -374,7 +347,7 @@ function FillHistory({
|
|||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{fill.regimenName}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[fill.status] ?? STATUS_COLORS['completed']}`}
|
||||
className={`mt-pill ${fill.status === 'completed' ? 'mt-pill--ok' : fill.status === 'partial' ? 'mt-pill--warn' : 'mt-pill--ghost'}`}
|
||||
>
|
||||
{STATUS_LABELS[fill.status] ?? fill.status}
|
||||
</span>
|
||||
|
|
@ -384,18 +357,12 @@ function FillHistory({
|
|||
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} •{' '}
|
||||
{formatDate(fill.fillDate)}
|
||||
</p>
|
||||
{fill.notes && (
|
||||
<p className="text-xs text-gray-400 mt-1">{fill.notes}</p>
|
||||
)}
|
||||
{fill.notes && <p className="text-xs text-gray-400 mt-1">{fill.notes}</p>}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{fill.items.map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
item.wasShort
|
||||
? 'bg-yellow-50 text-yellow-700'
|
||||
: 'bg-blue-50 text-blue-700'
|
||||
}`}
|
||||
className={`mt-pill ${item.wasShort ? 'mt-pill--warn' : 'mt-pill--info'}`}
|
||||
>
|
||||
{item.medicineName}: {item.quantityTaken}/{item.quantityNeeded}
|
||||
{item.wasShort ? ' (short)' : ''}
|
||||
|
|
@ -406,7 +373,7 @@ function FillHistory({
|
|||
{fill.status !== OrganizerFillStatus.REVERSED && (
|
||||
<button
|
||||
onClick={() => handleUndo(fill._id)}
|
||||
className="shrink-0 rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="mt-btn mt-btn--danger-ghost"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
|
|
@ -443,11 +410,7 @@ export function OrganizerTab({ householdId }: { householdId: string }) {
|
|||
{regimensLoading ? (
|
||||
<div className="animate-pulse rounded-xl border bg-white p-6 h-40" />
|
||||
) : (
|
||||
<FillWizard
|
||||
householdId={householdId}
|
||||
regimens={regimens}
|
||||
onFilled={handleFilled}
|
||||
/>
|
||||
<FillWizard householdId={householdId} regimens={regimens} onFilled={handleFilled} />
|
||||
)}
|
||||
<FillHistory householdId={householdId} refreshKey={fillRefreshKey} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
DosageFrequency,
|
||||
TimeOfDay,
|
||||
DosageUnit,
|
||||
MedicineForm,
|
||||
type MedicineForm,
|
||||
allowedUnitsForForm,
|
||||
defaultUnitForForm,
|
||||
} from '@meshitrack/shared';
|
||||
|
|
@ -99,23 +99,28 @@ function MedicationRow({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="mt-btn mt-btn--danger-icon"
|
||||
title="Remove medication"
|
||||
>
|
||||
<svg className="h-4 w-4" 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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Medicine</label>
|
||||
<label className="mt-field-label">Medicine</label>
|
||||
<select
|
||||
required
|
||||
value={medication.medicineId}
|
||||
onChange={(e) => handleMedicineChange(e.target.value)}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">Select medicine...</option>
|
||||
{medicines.map((m) => (
|
||||
|
|
@ -128,7 +133,7 @@ function MedicationRow({
|
|||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Dosage</label>
|
||||
<label className="mt-field-label">Dosage</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
|
|
@ -136,17 +141,17 @@ function MedicationRow({
|
|||
step="any"
|
||||
value={medication.dosage || ''}
|
||||
onChange={(e) => onChange(index, { ...medication, dosage: Number(e.target.value) })}
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Unit</label>
|
||||
<label className="mt-field-label">Unit</label>
|
||||
<select
|
||||
value={medication.dosageUnit}
|
||||
onChange={(e) =>
|
||||
onChange(index, { ...medication, dosageUnit: e.target.value as DosageUnit })
|
||||
}
|
||||
className="w-full 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"
|
||||
>
|
||||
{allowedUnits.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
|
|
@ -158,7 +163,7 @@ function MedicationRow({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Frequency</label>
|
||||
<label className="mt-field-label">Frequency</label>
|
||||
<select
|
||||
value={medication.frequency}
|
||||
onChange={(e) =>
|
||||
|
|
@ -171,7 +176,7 @@ function MedicationRow({
|
|||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full 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"
|
||||
>
|
||||
{Object.values(DosageFrequency).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
|
|
@ -183,7 +188,7 @@ function MedicationRow({
|
|||
|
||||
{medication.frequency === DosageFrequency.CUSTOM && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Times per day</label>
|
||||
<label className="mt-field-label">Times per day</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
|
|
@ -193,15 +198,13 @@ function MedicationRow({
|
|||
onChange={(e) =>
|
||||
onChange(index, { ...medication, customFrequencyPerDay: Number(e.target.value) })
|
||||
}
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Time of day (optional)
|
||||
</label>
|
||||
<label className="mt-field-label">Time of day (optional)</label>
|
||||
<select
|
||||
value={medication.timeOfDay ?? ''}
|
||||
onChange={(e) =>
|
||||
|
|
@ -210,7 +213,7 @@ function MedicationRow({
|
|||
timeOfDay: e.target.value ? (e.target.value as TimeOfDay) : undefined,
|
||||
})
|
||||
}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">Any time</option>
|
||||
{Object.values(TimeOfDay).map((t) => (
|
||||
|
|
@ -222,9 +225,7 @@ function MedicationRow({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Instructions (optional)
|
||||
</label>
|
||||
<label className="mt-field-label">Instructions (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={500}
|
||||
|
|
@ -233,7 +234,7 @@ function MedicationRow({
|
|||
onChange(index, { ...medication, instructions: e.target.value || undefined })
|
||||
}
|
||||
placeholder="e.g. Take with food"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -317,17 +318,13 @@ function RegimenForm({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mt-card mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Regimen' : 'New Regimen'}</h2>
|
||||
{error && (
|
||||
<div className="mb-4 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-4">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<label className="mt-field-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
|
|
@ -335,7 +332,7 @@ function RegimenForm({
|
|||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Morning routine"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
|
|
@ -344,7 +341,7 @@ function RegimenForm({
|
|||
id="isActive"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||
Active
|
||||
|
|
@ -355,11 +352,7 @@ function RegimenForm({
|
|||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">Medications</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMedication}
|
||||
className="rounded-lg border border-primary-600 px-3 py-1.5 text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<button type="button" onClick={addMedication} className="mt-btn mt-btn--ghost">
|
||||
+ Add medication
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -382,18 +375,10 @@ function RegimenForm({
|
|||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<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 ? 'Saving...' : initial ? 'Save changes' : 'Create regimen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 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>
|
||||
</div>
|
||||
|
|
@ -549,16 +534,14 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
<select
|
||||
value={filterActive}
|
||||
onChange={(e) => setFilterActive(e.target.value as 'all' | 'active' | 'inactive')}
|
||||
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">All regimens</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleShowBurnRate}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<button onClick={handleShowBurnRate} className="mt-btn mt-btn--ghost">
|
||||
{showBurnRate ? 'Hide burn rate' : 'Burn rate'}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -567,14 +550,14 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
setEditingRegimen(null);
|
||||
setShowForm(!showForm);
|
||||
}}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
className="mt-btn mt-btn--primary"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'New Regimen'}
|
||||
</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
|
||||
|
|
@ -583,7 +566,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
)}
|
||||
|
||||
{showBurnRate && (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mb-6 mt-card">
|
||||
<h2 className="text-lg font-semibold mb-4">Burn Rate & Spending Projections</h2>
|
||||
{burnRateLoading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
|
|
@ -630,7 +613,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
))}
|
||||
</div>
|
||||
) : regimens.length === 0 ? (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
|
||||
<div className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
|
||||
{filterActive !== 'all'
|
||||
? `No ${filterActive} regimens found.`
|
||||
: isFormOpen
|
||||
|
|
@ -640,17 +623,13 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{regimens.map((regimen) => (
|
||||
<div key={regimen._id} className="rounded-xl border bg-white p-4 shadow-sm">
|
||||
<div key={regimen._id} className="mt-card">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{regimen.name}</h3>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
regimen.isActive
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
className={`mt-pill ${regimen.isActive ? 'mt-pill--ok' : 'mt-pill--ghost'}`}
|
||||
>
|
||||
{regimen.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
|
|
@ -661,21 +640,20 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{regimen.medications.map((med, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700"
|
||||
>
|
||||
<span key={i} className="mt-pill mt-pill--info">
|
||||
{med.medicineName} — {med.dosage} {med.dosageUnit} (
|
||||
{FREQUENCY_LABELS[med.frequency] ?? med.frequency})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-400">Created {formatDate(regimen.createdAt)}</p>
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Created {formatDate(regimen.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleToggleActive(regimen)}
|
||||
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-gray-50 transition-colors"
|
||||
className="mt-btn mt-btn--ghost"
|
||||
title={regimen.isActive ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{regimen.isActive ? 'Deactivate' : 'Activate'}
|
||||
|
|
@ -685,7 +663,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
setShowForm(false);
|
||||
setEditingRegimen(regimen);
|
||||
}}
|
||||
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
|
||||
className="mt-btn mt-btn--icon"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -699,7 +677,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
|
|||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(regimen._id, regimen.name)}
|
||||
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="mt-btn mt-btn--danger-icon"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
import type React from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
|
@ -22,16 +22,21 @@ const {
|
|||
mockUpdateMedicineProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } =
|
||||
vi.hoisted(() => ({
|
||||
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } = vi.hoisted(
|
||||
() => ({
|
||||
mockListCabinetItems: vi.fn(),
|
||||
mockAdjustCabinetItemQuantity: vi.fn(),
|
||||
mockDeleteCabinetItem: vi.fn(),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/navigation', () => ({ useParams: mockUseParams }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => (
|
||||
<a href={props.href}>{props.children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/medicines', () => ({
|
||||
getMedicine: mockGetMedicine,
|
||||
|
|
@ -109,9 +114,7 @@ describe('MedicineDetailPage', () => {
|
|||
it('shows empty products state', async () => {
|
||||
render(<MedicineDetailPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No products yet/)).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText(/No products yet/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('toggles Add Product form', async () => {
|
||||
|
|
@ -146,7 +149,9 @@ describe('MedicineDetailPage', () => {
|
|||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes a product after confirmation', async () => {
|
||||
|
|
@ -250,7 +255,9 @@ describe('MedicineDetailPage', () => {
|
|||
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Glucophage'));
|
||||
fireEvent.change(screen.getByDisplayValue('Glucophage'), { target: { value: 'Glucophage XR' } });
|
||||
fireEvent.change(screen.getByDisplayValue('Glucophage'), {
|
||||
target: { value: 'Glucophage XR' },
|
||||
});
|
||||
fireEvent.submit(screen.getByDisplayValue('Glucophage XR').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
@ -275,9 +282,9 @@ describe('MedicineDetailPage', () => {
|
|||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||
// Change package unit to ml to show concentration fields
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'vial',
|
||||
) as HTMLSelectElement;
|
||||
const unitSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).value === 'vial') as HTMLSelectElement;
|
||||
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('e.g. 100')).toBeInTheDocument());
|
||||
|
|
@ -337,9 +344,9 @@ describe('MedicineDetailPage', () => {
|
|||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '100' } });
|
||||
|
||||
// Change concentration unit
|
||||
const concUnitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.text === '--',
|
||||
) as HTMLSelectElement;
|
||||
const concUnitSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).options[0]?.text === '--') as HTMLSelectElement;
|
||||
if (concUnitSelect) {
|
||||
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
||||
}
|
||||
|
|
@ -372,9 +379,9 @@ describe('MedicineDetailPage', () => {
|
|||
fireEvent.change(screen.getByDisplayValue('60'), { target: { value: '90' } });
|
||||
|
||||
// Change package unit
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'tablet',
|
||||
) as HTMLSelectElement;
|
||||
const unitSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).value === 'tablet') as HTMLSelectElement;
|
||||
fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
||||
|
||||
expect(screen.getByDisplayValue('Glucophage')).toBeInTheDocument();
|
||||
|
|
@ -398,13 +405,17 @@ describe('MedicineDetailPage', () => {
|
|||
fireEvent.change(screen.getByPlaceholderText('e.g. Pfizer'), { target: { value: '' } });
|
||||
|
||||
// Change notes (truthy) then clear (falsy → undefined)
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'Store in fridge' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
||||
target: { value: 'Store in fridge' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
|
||||
// Change unit to ml to show concentration fields
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'vial',
|
||||
) as HTMLSelectElement;
|
||||
const unitSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).value === 'vial') as HTMLSelectElement;
|
||||
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
|
||||
|
|
@ -413,9 +424,9 @@ describe('MedicineDetailPage', () => {
|
|||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '' } });
|
||||
|
||||
// Change concentration unit
|
||||
const concUnitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === '',
|
||||
) as HTMLSelectElement;
|
||||
const concUnitSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).value === '') as HTMLSelectElement;
|
||||
if (concUnitSelect) {
|
||||
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
|
||||
}
|
||||
|
|
@ -544,9 +555,9 @@ describe('MedicineDetailPage', () => {
|
|||
if (nameInput) fireEvent.change(nameInput, { target: { value: 'Metformin XR' } });
|
||||
|
||||
// Change form select
|
||||
const formSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'tablet',
|
||||
) as HTMLSelectElement;
|
||||
const formSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).options[0]?.value === 'tablet') as HTMLSelectElement;
|
||||
if (formSelect) fireEvent.change(formSelect, { target: { value: 'capsule' } });
|
||||
|
||||
// Change strength
|
||||
|
|
@ -554,9 +565,11 @@ describe('MedicineDetailPage', () => {
|
|||
if (strengthInput) fireEvent.change(strengthInput, { target: { value: '250' } });
|
||||
|
||||
// Change category select
|
||||
const catSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
|
||||
) as HTMLSelectElement;
|
||||
const catSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
|
||||
) as HTMLSelectElement;
|
||||
if (catSelect) fireEvent.change(catSelect, { target: { value: 'otc' } });
|
||||
|
||||
// Change notes (truthy value)
|
||||
|
|
@ -607,9 +620,9 @@ describe('MedicineDetailPage', () => {
|
|||
|
||||
await waitFor(() => screen.getByText('Edit Medicine'));
|
||||
// Change strength unit select (the one with 'mg' options)
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'mg',
|
||||
) as HTMLSelectElement;
|
||||
const unitSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).value === 'mg') as HTMLSelectElement;
|
||||
if (unitSelect) fireEvent.change(unitSelect, { target: { value: 'mcg' } });
|
||||
|
||||
expect(screen.getByText('Edit Medicine')).toBeInTheDocument();
|
||||
|
|
@ -643,7 +656,9 @@ describe('MedicineDetailPage', () => {
|
|||
await userEvent.click(screen.getByText('Add Product'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), { target: { value: 'Brand X' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), {
|
||||
target: { value: 'Brand X' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to create product')).toBeInTheDocument());
|
||||
|
|
@ -772,6 +787,8 @@ describe('MedicineDetailPage', () => {
|
|||
await waitFor(() => screen.getAllByTitle('Delete'));
|
||||
await userEvent.click(screen.getAllByTitle('Delete')[0]!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ import {
|
|||
updateMedicine,
|
||||
updateMedicineProduct,
|
||||
} from '@/services/medicines';
|
||||
import {
|
||||
listCabinetItems,
|
||||
adjustCabinetItemQuantity,
|
||||
deleteCabinetItem,
|
||||
} from '@/services/cabinet';
|
||||
import { listCabinetItems, adjustCabinetItemQuantity, deleteCabinetItem } from '@/services/cabinet';
|
||||
import {
|
||||
DosageUnit,
|
||||
ConcentrationUnit,
|
||||
|
|
@ -162,7 +158,9 @@ export default function MedicineDetailPage() {
|
|||
function startEditProduct(product: MedicineProduct) {
|
||||
setEditingProductId(product._id);
|
||||
const validUnits = Object.values(DosageUnit) as string[];
|
||||
const allowedUnits = allowedUnitsForForm((medicine?.form as MedicineForm) ?? MedicineForm.OTHER);
|
||||
const allowedUnits = allowedUnitsForForm(
|
||||
(medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
|
||||
);
|
||||
const storedUnit = product.packageUnit;
|
||||
const packageUnit = validUnits.includes(storedUnit)
|
||||
? (storedUnit as DosageUnit)
|
||||
|
|
@ -650,7 +648,8 @@ export default function MedicineDetailPage() {
|
|||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
>
|
||||
{allowedUnitsForForm(
|
||||
/* v8 ignore next */ (medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
|
||||
/* v8 ignore next */ (medicine?.form as MedicineForm) ??
|
||||
MedicineForm.OTHER,
|
||||
).map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
|
|
|
|||
|
|
@ -45,13 +45,17 @@ describe('ActivityTab', () => {
|
|||
it('fetches spending summary on mount', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.any(Object)));
|
||||
await waitFor(() =>
|
||||
expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.any(Object)),
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches events on mount', async () => {
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledWith('hh1', expect.any(Object)));
|
||||
await waitFor(() =>
|
||||
expect(mockListCabinetEvents).toHaveBeenCalledWith('hh1', expect.any(Object)),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows empty state when no events', async () => {
|
||||
|
|
@ -103,20 +107,18 @@ describe('ActivityTab', () => {
|
|||
|
||||
it('shows spending summary with data', async () => {
|
||||
mockGetSpendingSummary.mockResolvedValue({
|
||||
totalSpent: 125.50,
|
||||
totalSpent: 125.5,
|
||||
currency: 'USD',
|
||||
byMedicine: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
totalSpent: 125.50,
|
||||
totalSpent: 125.5,
|
||||
purchaseCount: 2,
|
||||
avgUnitPrice: 0.69,
|
||||
},
|
||||
],
|
||||
byPeriod: [
|
||||
{ period: '2026-01', totalSpent: 125.50 },
|
||||
],
|
||||
byPeriod: [{ period: '2026-01', totalSpent: 125.5 }],
|
||||
});
|
||||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
|
@ -138,7 +140,9 @@ describe('ActivityTab', () => {
|
|||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(1));
|
||||
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
|
||||
fireEvent.change(screen.getByDisplayValue('All event types'), {
|
||||
target: { value: 'purchased' },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
|
@ -151,7 +155,9 @@ describe('ActivityTab', () => {
|
|||
|
||||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByDisplayValue('All medicines').length).toBeGreaterThan(1));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByDisplayValue('All medicines').length).toBeGreaterThan(1),
|
||||
);
|
||||
const allMedSelects = screen.getAllByDisplayValue('All medicines');
|
||||
// The last select is the cabinet events medicine filter
|
||||
fireEvent.change(allMedSelects[allMedSelects.length - 1]!, { target: { value: 'med-1' } });
|
||||
|
|
@ -163,7 +169,9 @@ describe('ActivityTab', () => {
|
|||
render(<ActivityTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalled());
|
||||
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
|
||||
fireEvent.change(screen.getByDisplayValue('All event types'), {
|
||||
target: { value: 'purchased' },
|
||||
});
|
||||
|
||||
await waitFor(() => screen.getByText('Clear filters'));
|
||||
await userEvent.click(screen.getByText('Clear filters'));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
const {
|
||||
mockListCabinetItems,
|
||||
|
|
@ -28,7 +29,11 @@ vi.mock('@/services/cabinet', () => ({
|
|||
|
||||
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
|
||||
|
||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => (
|
||||
<a href={props.href}>{props.children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CabinetTab } from '../CabinetTab';
|
||||
|
||||
|
|
@ -206,9 +211,7 @@ describe('CabinetTab', () => {
|
|||
// Submit without selecting a medicine - should show error
|
||||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Please select a medicine')).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('Please select a medicine')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('submits AddToCabinetForm successfully', async () => {
|
||||
|
|
@ -241,7 +244,10 @@ describe('CabinetTab', () => {
|
|||
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateCabinetItem).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })),
|
||||
expect(mockCreateCabinetItem).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ medicineId: 'med-1' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -590,7 +596,9 @@ describe('CabinetTab', () => {
|
|||
|
||||
it('shows create error when medicine is selected and create fails', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
|
||||
|
|
@ -623,7 +631,9 @@ describe('CabinetTab', () => {
|
|||
|
||||
it('waits for medicines to load then selects medicine in form', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
|
|
@ -648,7 +658,9 @@ describe('CabinetTab', () => {
|
|||
|
||||
it('shows fallback error when non-Error is thrown during create', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreateCabinetItem.mockRejectedValue('unexpected');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockListMedicines, mockCreateMedicine, mockDeleteMedicine } = vi.hoisted(() => ({
|
||||
mockListMedicines: vi.fn(),
|
||||
|
|
@ -14,7 +15,11 @@ vi.mock('@/services/medicines', () => ({
|
|||
deleteMedicine: mockDeleteMedicine,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => (
|
||||
<a href={props.href}>{props.children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { LibraryTab } from '../LibraryTab';
|
||||
|
||||
|
|
@ -40,7 +45,10 @@ describe('LibraryTab', () => {
|
|||
});
|
||||
|
||||
it('renders medicine list after load', async () => {
|
||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [medicine],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
||||
|
|
@ -98,11 +106,19 @@ describe('LibraryTab', () => {
|
|||
await userEvent.type(screen.getByPlaceholderText('500'), '100');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Medicine' }));
|
||||
|
||||
await waitFor(() => expect(mockCreateMedicine).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Aspirin' })));
|
||||
await waitFor(() =>
|
||||
expect(mockCreateMedicine).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ name: 'Aspirin' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes medicine after confirmation', async () => {
|
||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [medicine],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteMedicine.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
|
|
@ -131,9 +147,13 @@ describe('LibraryTab', () => {
|
|||
// Change category
|
||||
fireEvent.change(screen.getByDisplayValue('OTC'), { target: { value: 'prescription' } });
|
||||
// Change notes (covers truthy branch)
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'test notes' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
||||
target: { value: 'test notes' },
|
||||
});
|
||||
// Clear notes (covers falsy branch → undefined)
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
|
||||
// Verify form is still visible
|
||||
expect(screen.getByPlaceholderText('e.g. Metformin')).toBeInTheDocument();
|
||||
|
|
@ -150,10 +170,14 @@ describe('LibraryTab', () => {
|
|||
await waitFor(() => screen.getByText('Metformin'));
|
||||
|
||||
// Search filter
|
||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'met' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
|
||||
target: { value: 'met' },
|
||||
});
|
||||
|
||||
// Category filter
|
||||
fireEvent.change(screen.getByDisplayValue('All Categories'), { target: { value: 'prescription' } });
|
||||
fireEvent.change(screen.getByDisplayValue('All Categories'), {
|
||||
target: { value: 'prescription' },
|
||||
});
|
||||
|
||||
// Form filter
|
||||
fireEvent.change(screen.getByDisplayValue('All Forms'), { target: { value: 'tablet' } });
|
||||
|
|
@ -162,7 +186,10 @@ describe('LibraryTab', () => {
|
|||
});
|
||||
|
||||
it('shows fallback error when non-Error is thrown on delete', async () => {
|
||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [medicine],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteMedicine.mockRejectedValue('oops');
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
|
|
@ -207,7 +234,10 @@ describe('LibraryTab', () => {
|
|||
});
|
||||
|
||||
it('does not delete medicine if confirmation cancelled', async () => {
|
||||
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [medicine],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<LibraryTab householdId="hh1" />);
|
||||
|
|
|
|||
|
|
@ -50,9 +50,7 @@ describe('OrganizerTab', () => {
|
|||
it('shows no active regimens message when none exist', async () => {
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No active regimens found/)).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText(/No active regimens found/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fill form when active regimens exist', async () => {
|
||||
|
|
@ -69,9 +67,7 @@ describe('OrganizerTab', () => {
|
|||
it('shows empty fill history', async () => {
|
||||
render(<OrganizerTab householdId="hh1" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('No fills recorded yet.')).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('No fills recorded yet.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders fill history entries', async () => {
|
||||
|
|
@ -118,7 +114,10 @@ describe('OrganizerTab', () => {
|
|||
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockPreviewFill).toHaveBeenCalledWith('hh1', expect.objectContaining({ regimenId: 'reg-1' })),
|
||||
expect(mockPreviewFill).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ regimenId: 'reg-1' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -159,7 +158,12 @@ describe('OrganizerTab', () => {
|
|||
|
||||
await userEvent.click(screen.getByText('Confirm fill'));
|
||||
|
||||
await waitFor(() => expect(mockExecuteFill).toHaveBeenCalledWith('hh1', expect.objectContaining({ regimenId: 'reg-1' })));
|
||||
await waitFor(() =>
|
||||
expect(mockExecuteFill).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ regimenId: 'reg-1' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows shortage warning in preview', async () => {
|
||||
|
|
@ -291,15 +295,17 @@ describe('OrganizerTab', () => {
|
|||
status: 'partial',
|
||||
numberOfDays: 7,
|
||||
fillDate: '2026-01-01T00:00:00.000Z',
|
||||
items: [{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityTaken: 3,
|
||||
wasShort: true,
|
||||
shortage: 4,
|
||||
deductions: [],
|
||||
}],
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityTaken: 3,
|
||||
wasShort: true,
|
||||
shortage: 4,
|
||||
deductions: [],
|
||||
},
|
||||
],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
|
|
@ -395,7 +401,11 @@ describe('OrganizerTab', () => {
|
|||
isShort: false,
|
||||
shortage: 0,
|
||||
cabinetBreakdown: [
|
||||
{ cabinetItemId: 'ci-1', quantityToTake: 7, expirationDate: '2027-06-01T00:00:00.000Z' },
|
||||
{
|
||||
cabinetItemId: 'ci-1',
|
||||
quantityToTake: 7,
|
||||
expirationDate: '2027-06-01T00:00:00.000Z',
|
||||
},
|
||||
{ cabinetItemId: 'ci-2', quantityToTake: 3 },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const { mockListRegimens, mockCreateRegimen, mockUpdateRegimen, mockDeleteRegimen, mockGetBurnRates } =
|
||||
vi.hoisted(() => ({
|
||||
mockListRegimens: vi.fn(),
|
||||
mockCreateRegimen: vi.fn(),
|
||||
mockUpdateRegimen: vi.fn(),
|
||||
mockDeleteRegimen: vi.fn(),
|
||||
mockGetBurnRates: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockListRegimens,
|
||||
mockCreateRegimen,
|
||||
mockUpdateRegimen,
|
||||
mockDeleteRegimen,
|
||||
mockGetBurnRates,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListRegimens: vi.fn(),
|
||||
mockCreateRegimen: vi.fn(),
|
||||
mockUpdateRegimen: vi.fn(),
|
||||
mockDeleteRegimen: vi.fn(),
|
||||
mockGetBurnRates: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
|
||||
|
||||
|
|
@ -62,7 +67,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('renders regimen list', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
|
|
@ -122,7 +130,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('deletes regimen after confirmation', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteRegimen.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
|
|
@ -142,12 +153,17 @@ describe('RegimensTab', () => {
|
|||
await waitFor(() => screen.getByText('Burn rate'));
|
||||
await userEvent.click(screen.getByText('Burn rate'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Burn Rate & Spending Projections')).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Burn Rate & Spending Projections')).toBeInTheDocument(),
|
||||
);
|
||||
expect(mockGetBurnRates).toHaveBeenCalledWith('hh1');
|
||||
});
|
||||
|
||||
it('opens edit form for regimen', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
|
|
@ -158,7 +174,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('saves edited regimen', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
|
@ -167,11 +186,17 @@ describe('RegimensTab', () => {
|
|||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('Morning Routine'));
|
||||
fireEvent.change(screen.getByDisplayValue('Morning Routine'), { target: { value: 'Evening Routine' } });
|
||||
fireEvent.change(screen.getByDisplayValue('Morning Routine'), {
|
||||
target: { value: 'Evening Routine' },
|
||||
});
|
||||
fireEvent.submit(screen.getByDisplayValue('Evening Routine').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateRegimen).toHaveBeenCalledWith('hh1', 'reg-1', expect.objectContaining({ name: 'Evening Routine' })),
|
||||
expect(mockUpdateRegimen).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'reg-1',
|
||||
expect.objectContaining({ name: 'Evening Routine' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -196,7 +221,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('shows error when delete fails', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteRegimen.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
|
|
@ -237,7 +265,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('cancels edit form and hides it', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
|
|
@ -251,7 +282,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('filters regimens by active status', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
|
|
@ -288,7 +322,9 @@ describe('RegimensTab', () => {
|
|||
|
||||
it('changes medicine, dosage, and unit in medication row', async () => {
|
||||
mockListMedicines.mockResolvedValue({
|
||||
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
|
||||
data: [
|
||||
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
|
|
@ -300,9 +336,11 @@ describe('RegimensTab', () => {
|
|||
await waitFor(() => screen.getByText('Select medicine...'));
|
||||
|
||||
// Select a medicine in the medication row
|
||||
const medicineSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.text === 'Select medicine...',
|
||||
) as HTMLSelectElement;
|
||||
const medicineSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.text === 'Select medicine...',
|
||||
) as HTMLSelectElement;
|
||||
expect(medicineSelect).toBeDefined();
|
||||
fireEvent.change(medicineSelect!, { target: { value: 'med-1' } });
|
||||
|
||||
|
|
@ -311,9 +349,9 @@ describe('RegimensTab', () => {
|
|||
if (dosageInput) fireEvent.change(dosageInput, { target: { value: '2' } });
|
||||
|
||||
// Change dosage unit
|
||||
const unitSelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).value === 'tablet',
|
||||
) as HTMLSelectElement;
|
||||
const unitSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).value === 'tablet') as HTMLSelectElement;
|
||||
if (unitSelect) fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. Morning routine')).toBeInTheDocument();
|
||||
|
|
@ -342,9 +380,9 @@ describe('RegimensTab', () => {
|
|||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
|
||||
// The frequency select has 'daily' as its first option value
|
||||
const frequencySelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.value === 'daily',
|
||||
) as HTMLSelectElement;
|
||||
const frequencySelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).options[0]?.value === 'daily') as HTMLSelectElement;
|
||||
expect(frequencySelect).toBeDefined();
|
||||
fireEvent.change(frequencySelect!, { target: { value: 'custom' } });
|
||||
|
||||
|
|
@ -368,9 +406,9 @@ describe('RegimensTab', () => {
|
|||
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
|
||||
|
||||
// The time-of-day select has 'Any time' as its first option text
|
||||
const timeOfDaySelect = screen.getAllByRole('combobox').find(
|
||||
(s) => (s as HTMLSelectElement).options[0]?.text === 'Any time',
|
||||
) as HTMLSelectElement;
|
||||
const timeOfDaySelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => (s as HTMLSelectElement).options[0]?.text === 'Any time') as HTMLSelectElement;
|
||||
expect(timeOfDaySelect).toBeDefined();
|
||||
fireEvent.change(timeOfDaySelect!, { target: { value: 'morning' } });
|
||||
|
||||
|
|
@ -389,7 +427,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('shows error when update fails', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRegimen.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
|
@ -401,7 +442,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('toggles active/inactive status', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false });
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
|
@ -452,9 +496,7 @@ describe('RegimensTab', () => {
|
|||
await waitFor(() => screen.getByDisplayValue('All regimens'));
|
||||
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('No active regimens found.')).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('No active regimens found.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows null when form is open and regimens list is empty', async () => {
|
||||
|
|
@ -516,7 +558,10 @@ describe('RegimensTab', () => {
|
|||
});
|
||||
|
||||
it('initializes edit form with existing medications', async () => {
|
||||
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RegimensTab householdId="hh1" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/ActivityTab', () => ({
|
||||
ActivityTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="activity-tab">{householdId}</div>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { ActivityTab } from '../ActivityTab';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Cabinet Activity"
|
||||
subtitle="Spending and cabinet changes"
|
||||
crumbs={['Medicines', 'Activity']}
|
||||
/>
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</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 viewing cabinet activity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Cabinet Activity"
|
||||
subtitle="Spending and cabinet changes"
|
||||
crumbs={['Medicines', 'Activity']}
|
||||
/>
|
||||
<NoHousehold />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
Medicines
|
||||
</Link>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold">Cabinet Activity</h1>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Cabinet Activity"
|
||||
subtitle="Spending and cabinet changes"
|
||||
crumbs={['Medicines', 'Activity']}
|
||||
/>
|
||||
<div className="mt-page">
|
||||
<ActivityTab householdId={householdId} />
|
||||
</div>
|
||||
<ActivityTab householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/CabinetTab', () => ({
|
||||
CabinetTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="cabinet-tab">{householdId}</div>
|
||||
|
|
|
|||
|
|
@ -3,39 +3,77 @@
|
|||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { CabinetTab } from '../CabinetTab';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
|
||||
export default function CabinetPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicine Cabinet</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 w-64 rounded-lg bg-gray-200" />
|
||||
<div className="h-20 rounded-xl bg-gray-200" />
|
||||
<div className="h-20 rounded-xl bg-gray-200" />
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Medicine Cabinet"
|
||||
subtitle="Everything on hand, with days of supply"
|
||||
crumbs={['Medicines', 'Cabinet']}
|
||||
/>
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 64,
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg-inset)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicine Cabinet</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 medicines.
|
||||
</p>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Medicine Cabinet"
|
||||
subtitle="Everything on hand, with days of supply"
|
||||
crumbs={['Medicines', 'Cabinet']}
|
||||
/>
|
||||
<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 medicines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <CabinetTab householdId={householdId} />;
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Medicine Cabinet"
|
||||
subtitle="Everything on hand, with days of supply"
|
||||
crumbs={['Medicines', 'Cabinet']}
|
||||
/>
|
||||
<div className="mt-page">
|
||||
<CabinetTab householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
43
packages/web/src/app/(dashboard)/medicines/helpers.tsx
Normal file
43
packages/web/src/app/(dashboard)/medicines/helpers.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 64,
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg-inset)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoHousehold() {
|
||||
return (
|
||||
<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 medicines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/LibraryTab', () => ({
|
||||
LibraryTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="library-tab">{householdId}</div>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { LibraryTab } from '../LibraryTab';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||
|
||||
export default function LibraryPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicine Library</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 w-64 rounded-lg bg-gray-200" />
|
||||
<div className="h-20 rounded-xl bg-gray-200" />
|
||||
<div className="h-20 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Medicine Library"
|
||||
subtitle="All known medicines"
|
||||
crumbs={['Medicines', 'Library']}
|
||||
/>
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicine Library</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 medicines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Medicine Library"
|
||||
subtitle="All known medicines"
|
||||
crumbs={['Medicines', 'Library']}
|
||||
/>
|
||||
<NoHousehold />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <LibraryTab householdId={householdId} />;
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Medicine Library"
|
||||
subtitle="All known medicines"
|
||||
crumbs={['Medicines', 'Library']}
|
||||
/>
|
||||
<div className="mt-page">
|
||||
<LibraryTab householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/OrganizerTab', () => ({
|
||||
OrganizerTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="organizer-tab">{householdId}</div>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { OrganizerTab } from '../OrganizerTab';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||
|
||||
export default function OrganizerPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
<div className="h-40 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Pill Organizer"
|
||||
subtitle="Fill a week of pills at once"
|
||||
crumbs={['Medicines', 'Organizer']}
|
||||
/>
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Pill Organizer</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 using the pill organizer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Pill Organizer"
|
||||
subtitle="Fill a week of pills at once"
|
||||
crumbs={['Medicines', 'Organizer']}
|
||||
/>
|
||||
<NoHousehold />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
Medicines
|
||||
</Link>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold">Pill Organizer</h1>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Pill Organizer"
|
||||
subtitle="Fill a week of pills at once"
|
||||
crumbs={['Medicines', 'Organizer']}
|
||||
/>
|
||||
<div className="mt-page">
|
||||
<OrganizerTab householdId={householdId} />
|
||||
</div>
|
||||
<OrganizerTab householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,82 +2,122 @@
|
|||
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import type { IconName } from '@/components/ui/Icon';
|
||||
|
||||
export default function MedicinesPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return <PageSkeleton />;
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Medicines" subtitle="All known medicines" />
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Medicines</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 medicines.
|
||||
</p>
|
||||
<>
|
||||
<SetPageHeader title="Medicines" subtitle="All known medicines" />
|
||||
<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 medicines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<SectionCard
|
||||
title="Library"
|
||||
description="Manage your medicines and their products"
|
||||
href="/medicines/library"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Cabinet"
|
||||
description="Track your medicine inventory, quantities and expiry dates"
|
||||
href="/medicines/cabinet"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Regimens"
|
||||
description="Define daily medication schedules and track dosage frequency"
|
||||
href="/medicines/regimens"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Organizer"
|
||||
description="Fill your pill organizer and track cabinet usage"
|
||||
href="/medicines/organizer"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Activity"
|
||||
description="View cabinet event history and spending summaries"
|
||||
href="/medicines/activity"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Stores"
|
||||
description="Manage pharmacies and stores for price tracking"
|
||||
href="/stores"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Prices"
|
||||
description="Track and compare medicine prices across stores"
|
||||
href="/medicine-prices"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Refills"
|
||||
description="Get refill alerts and manage shopping lists"
|
||||
href="/refills"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Purchases"
|
||||
description="Record medicine purchases and track online orders"
|
||||
href="/purchases"
|
||||
/>
|
||||
<>
|
||||
<SetPageHeader title="Medicines" subtitle="All known medicines" />
|
||||
<div style={{ padding: '28px 32px 56px', maxWidth: 1400 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<SectionCard
|
||||
title="Library"
|
||||
description="Manage your medicines and their products"
|
||||
href="/medicines/library"
|
||||
icon="pill"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Cabinet"
|
||||
description="Track your medicine inventory, quantities and expiry dates"
|
||||
href="/medicines/cabinet"
|
||||
icon="cabinet"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Schedule"
|
||||
description="Today's dose log and weekly overview"
|
||||
href="/medicines/schedule"
|
||||
icon="clock"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Regimens"
|
||||
description="Define daily medication schedules"
|
||||
href="/medicines/regimens"
|
||||
icon="list"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Organizer"
|
||||
description="Fill your pill organizer and track cabinet usage"
|
||||
href="/medicines/organizer"
|
||||
icon="calendar"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Activity"
|
||||
description="View cabinet event history and spending summaries"
|
||||
href="/medicines/activity"
|
||||
icon="trend"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Stores"
|
||||
description="Manage pharmacies and stores for price tracking"
|
||||
href="/stores"
|
||||
icon="store"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Prices"
|
||||
description="Track and compare medicine prices across stores"
|
||||
href="/medicine-prices"
|
||||
icon="tag"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Refills"
|
||||
description="Get refill alerts and manage shopping lists"
|
||||
href="/refills"
|
||||
icon="refresh"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Purchases"
|
||||
description="Record medicine purchases and track online orders"
|
||||
href="/purchases"
|
||||
icon="truck"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -85,29 +125,64 @@ function SectionCard({
|
|||
title,
|
||||
description,
|
||||
href,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: IconName;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
padding: 18,
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
textDecoration: 'none',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--brand-soft)',
|
||||
color: 'var(--brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-strong)' }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 2 }}>{description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
|
||||
<div className="animate-pulse grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-gray-200" />
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: 96, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
vi.mock('@/app/(dashboard)/medicines/RegimensTab', () => ({
|
||||
RegimensTab: ({ householdId }: { householdId: string }) => (
|
||||
<div data-testid="regimens-tab">{householdId}</div>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { RegimensTab } from '../RegimensTab';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||
|
||||
export default function RegimensPage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 w-64 rounded-lg bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Regimens"
|
||||
subtitle="Daily medication schedules"
|
||||
crumbs={['Medicines', 'Regimens']}
|
||||
/>
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Regimens</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 regimens.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Regimens"
|
||||
subtitle="Daily medication schedules"
|
||||
crumbs={['Medicines', 'Regimens']}
|
||||
/>
|
||||
<NoHousehold />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
Medicines
|
||||
</Link>
|
||||
<span className="text-gray-400">/</span>
|
||||
<h1 className="text-2xl font-bold">Regimens</h1>
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Regimens"
|
||||
subtitle="Daily medication schedules"
|
||||
crumbs={['Medicines', 'Regimens']}
|
||||
/>
|
||||
<div className="mt-page">
|
||||
<RegimensTab householdId={householdId} />
|
||||
</div>
|
||||
<RegimensTab householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
297
packages/web/src/app/(dashboard)/medicines/schedule/page.tsx
Normal file
297
packages/web/src/app/(dashboard)/medicines/schedule/page.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { listRegimens } from '@/services/regimens';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { PageSkeleton, NoHousehold } from '../helpers';
|
||||
import Link from 'next/link';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { RegimenResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type Regimen = z.infer<typeof RegimenResponseSchema>;
|
||||
type Medication = Regimen['medications'][number];
|
||||
|
||||
const TIME_SLOTS = [
|
||||
{ key: 'morning', label: 'Morning', icon: 'sun' as const },
|
||||
{ key: 'afternoon', label: 'Afternoon', icon: 'sun' as const },
|
||||
{ key: 'evening', label: 'Evening', icon: 'moon' as const },
|
||||
{ key: 'bedtime', label: 'Bedtime', icon: 'moon' as const },
|
||||
{ key: 'any', label: 'Any time', icon: 'clock' as const },
|
||||
] as const;
|
||||
|
||||
const FREQUENCY_LABELS: Record<string, string> = {
|
||||
daily: 'Once daily',
|
||||
twice_daily: 'Twice daily',
|
||||
three_times_daily: 'Three times daily',
|
||||
weekly: 'Weekly',
|
||||
every_other_day: 'Every other day',
|
||||
as_needed: 'As needed',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
type SlotEntry = { regimen: Regimen; medication: Medication };
|
||||
|
||||
function groupByTimeSlot(regimens: Regimen[]): Record<string, SlotEntry[]> {
|
||||
const groups: Record<string, SlotEntry[]> = {
|
||||
morning: [],
|
||||
afternoon: [],
|
||||
evening: [],
|
||||
bedtime: [],
|
||||
any: [],
|
||||
};
|
||||
for (const regimen of regimens) {
|
||||
for (const medication of regimen.medications) {
|
||||
const slot = medication.timeOfDay ?? 'any';
|
||||
if (slot in groups) {
|
||||
groups[slot].push({ regimen, medication });
|
||||
} else {
|
||||
groups.any.push({ regimen, medication });
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function MedicationCard({ regimen, medication }: SlotEntry) {
|
||||
const freqLabel =
|
||||
medication.frequency === 'custom' && medication.customFrequencyPerDay
|
||||
? `${medication.customFrequencyPerDay}x daily`
|
||||
: (FREQUENCY_LABELS[medication.frequency] ?? medication.frequency);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--brand-soft)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
color: 'var(--brand)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name="pill" size={18} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-strong)' }}>
|
||||
{medication.medicineName}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--ink-muted)' }}>
|
||||
{medication.medicineStrength} {medication.medicineStrengthUnit}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-muted)', marginTop: 2 }}>
|
||||
{medication.dosage} {medication.dosageUnit} — {freqLabel}
|
||||
</div>
|
||||
{medication.instructions && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-faint)', marginTop: 2 }}>
|
||||
{medication.instructions}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-faint)', marginTop: 4 }}>
|
||||
<span className="mt-pill mt-pill--ghost">{regimen.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeSlotCard({
|
||||
label,
|
||||
icon,
|
||||
entries,
|
||||
}: {
|
||||
slotKey: string;
|
||||
label: string;
|
||||
icon: 'sun' | 'moon' | 'clock';
|
||||
entries: SlotEntry[];
|
||||
}) {
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<CardHeader
|
||||
title={label}
|
||||
subtitle={`${entries.length} dose${entries.length !== 1 ? 's' : ''}`}
|
||||
action={
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--bg-inset)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
color: 'var(--ink-muted)',
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
{entries.map(({ regimen, medication }, i) => (
|
||||
<MedicationCard
|
||||
key={`${regimen._id}-${medication.medicineId}-${i}`}
|
||||
regimen={regimen}
|
||||
medication={medication}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleContent({ householdId }: { householdId: string }) {
|
||||
const [regimens, setRegimens] = useState<Regimen[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const allRegimens: Regimen[] = [];
|
||||
let cursor: string | null = null;
|
||||
do {
|
||||
const res = await listRegimens(householdId, {
|
||||
isActive: true,
|
||||
limit: 100,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
allRegimens.push(...res.data);
|
||||
cursor = res.pagination.cursor;
|
||||
} while (cursor);
|
||||
if (!cancelled) setRegimens(allRegimens);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load regimens');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [householdId]);
|
||||
|
||||
if (loading) return <PageSkeleton />;
|
||||
|
||||
if (error) {
|
||||
return <div className="mt-alert mt-alert--danger mb-4">{error}</div>;
|
||||
}
|
||||
|
||||
if (regimens.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
padding: '48px 24px',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg-inset)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
color: 'var(--ink-faint)',
|
||||
}}
|
||||
>
|
||||
<Icon name="clock" size={24} />
|
||||
</div>
|
||||
<div style={{ fontSize: 15, color: 'var(--ink-muted)' }}>No active regimens found.</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-faint)' }}>
|
||||
<Link href="/medicines/regimens" className="mt-link">
|
||||
Set up a regimen
|
||||
</Link>{' '}
|
||||
to start tracking your daily schedule.
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupByTimeSlot(regimens);
|
||||
const totalDoses = Object.values(groups).reduce((sum, g) => sum + g.length, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, fontSize: 13, color: 'var(--ink-muted)' }}>
|
||||
{regimens.length} active regimen{regimens.length !== 1 ? 's' : ''} — {totalDoses} dose
|
||||
{totalDoses !== 1 ? 's' : ''} per day
|
||||
</div>
|
||||
{TIME_SLOTS.map(({ key, label, icon }) => (
|
||||
<TimeSlotCard
|
||||
key={key}
|
||||
slotKey={key}
|
||||
label={label}
|
||||
icon={icon}
|
||||
entries={groups[key] ?? []}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchedulePage() {
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Schedule & Log"
|
||||
subtitle="Today and this week"
|
||||
crumbs={['Medicines', 'Schedule']}
|
||||
/>
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Schedule & Log"
|
||||
subtitle="Today and this week"
|
||||
crumbs={['Medicines', 'Schedule']}
|
||||
/>
|
||||
<NoHousehold />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title="Schedule & Log"
|
||||
subtitle="Today and this week"
|
||||
crumbs={['Medicines', 'Schedule']}
|
||||
/>
|
||||
<div style={{ padding: '28px 32px 56px', maxWidth: 900 }}>
|
||||
<ScheduleContent householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
|
|
@ -30,7 +31,9 @@ vi.mock('@/services/medicines', () => ({
|
|||
listMedicines: mockListMedicines,
|
||||
listMedicineProducts: mockListMedicineProducts,
|
||||
}));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
|
||||
import PurchasesPage from '../page';
|
||||
|
||||
|
|
@ -61,9 +64,7 @@ describe('PurchasesPage', () => {
|
|||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
render(<PurchasesPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows Record Purchase button', async () => {
|
||||
|
|
@ -142,7 +143,15 @@ describe('PurchasesPage', () => {
|
|||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Walgreens',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
|
|
@ -162,7 +171,15 @@ describe('PurchasesPage', () => {
|
|||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Walgreens',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
|
|
@ -176,7 +193,9 @@ describe('PurchasesPage', () => {
|
|||
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Add at least one item with a name and quantity.')).toBeInTheDocument(),
|
||||
expect(
|
||||
screen.getByText('Add at least one item with a name and quantity.'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -293,7 +312,15 @@ describe('PurchasesPage', () => {
|
|||
mockListPurchases.mockResolvedValue(emptyResponse);
|
||||
mockListMedicineProducts.mockResolvedValue(emptyResponse);
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Walgreens',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockCreatePurchase.mockResolvedValue({
|
||||
|
|
@ -314,7 +341,9 @@ describe('PurchasesPage', () => {
|
|||
|
||||
await waitFor(() => screen.getByText('Save Purchase'));
|
||||
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), {
|
||||
target: { value: 'Aspirin' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
|
||||
|
||||
|
|
@ -457,7 +486,9 @@ describe('PurchasesPage', () => {
|
|||
await userEvent.click(screen.getByText('Record Purchase'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('Brand / product name'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), {
|
||||
target: { value: 'Aspirin' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('tablet'), { target: { value: 'capsule' } });
|
||||
|
||||
|
|
@ -472,9 +503,7 @@ describe('PurchasesPage', () => {
|
|||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockListMedicineProducts.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' },
|
||||
],
|
||||
data: [{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
listPurchases,
|
||||
createPurchase,
|
||||
|
|
@ -12,10 +13,7 @@ import {
|
|||
import { listStores } from '@/services/stores';
|
||||
import { listMedicines, listMedicineProducts } from '@/services/medicines';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
PurchaseResponseSchema,
|
||||
PurchaseListResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import type { PurchaseResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type PurchaseResponse = z.infer<typeof PurchaseResponseSchema>;
|
||||
|
||||
|
|
@ -75,13 +73,21 @@ function CreatePurchaseForm({
|
|||
]);
|
||||
|
||||
useEffect(() => {
|
||||
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
|
||||
listMedicines(householdId, { limit: 100 })
|
||||
.then((r) => setMedicines(r.data))
|
||||
.catch(() => {});
|
||||
}, [householdId]);
|
||||
|
||||
async function handleMedicineChange(idx: number, medicineId: string) {
|
||||
const updated = items.map((item, i) =>
|
||||
i === idx
|
||||
? { ...item, medicineId, medicineProductId: '', products: [], productsLoading: !!medicineId }
|
||||
? {
|
||||
...item,
|
||||
medicineId,
|
||||
medicineProductId: '',
|
||||
products: [],
|
||||
productsLoading: !!medicineId,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
setItems(updated);
|
||||
|
|
@ -90,7 +96,9 @@ function CreatePurchaseForm({
|
|||
const result = await listMedicineProducts(householdId, medicineId, { limit: 50 });
|
||||
setItems((prev) =>
|
||||
prev.map((item, i) =>
|
||||
i === idx ? { ...item, products: result.data as ProductOption[], productsLoading: false } : item,
|
||||
i === idx
|
||||
? { ...item, products: result.data as ProductOption[], productsLoading: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
|
|
@ -109,7 +117,11 @@ function CreatePurchaseForm({
|
|||
...item,
|
||||
medicineProductId: productId,
|
||||
...(product
|
||||
? { quantity: String(product.packageSize), unit: product.packageUnit, name: product.brand ?? item.name }
|
||||
? {
|
||||
quantity: String(product.packageSize),
|
||||
unit: product.packageUnit,
|
||||
name: product.brand ?? item.name,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
|
|
@ -173,22 +185,18 @@ function CreatePurchaseForm({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mt-card mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Record Purchase</h2>
|
||||
{error && (
|
||||
<div className="mb-4 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-4">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
|
||||
<label className="mt-field-label">Store</label>
|
||||
<select
|
||||
value={storeId}
|
||||
onChange={(e) => setStoreId(e.target.value)}
|
||||
required
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">Select store</option>
|
||||
{stores.map((s) => (
|
||||
|
|
@ -200,7 +208,7 @@ function CreatePurchaseForm({
|
|||
{stores.length === 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
No stores yet.{' '}
|
||||
<Link href="/stores" className="text-primary-600 underline">
|
||||
<Link href="/stores" className="mt-link">
|
||||
Add a store first
|
||||
</Link>
|
||||
</p>
|
||||
|
|
@ -213,7 +221,7 @@ function CreatePurchaseForm({
|
|||
id="isOnline"
|
||||
checked={isOnline}
|
||||
onChange={(e) => setIsOnline(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="isOnline" className="text-sm font-medium text-gray-700">
|
||||
Online order (pending arrival)
|
||||
|
|
@ -222,26 +230,20 @@ function CreatePurchaseForm({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<label className="mt-field-label">Notes (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Items</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="rounded-lg border px-3 py-1 text-xs font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<button type="button" onClick={addItem} className="mt-btn mt-btn--ghost">
|
||||
Add item
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -255,7 +257,7 @@ function CreatePurchaseForm({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(idx)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
className="mt-link text-xs"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
|
@ -264,13 +266,11 @@ function CreatePurchaseForm({
|
|||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Medicine (optional)
|
||||
</label>
|
||||
<label className="mt-field-label">Medicine (optional)</label>
|
||||
<select
|
||||
value={item.medicineId}
|
||||
onChange={(e) => handleMedicineChange(idx, e.target.value)}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">Select medicine</option>
|
||||
{medicines.map((m) => (
|
||||
|
|
@ -282,9 +282,7 @@ function CreatePurchaseForm({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Product (optional)
|
||||
</label>
|
||||
<label className="mt-field-label">Product (optional)</label>
|
||||
{item.productsLoading ? (
|
||||
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
|
||||
) : (
|
||||
|
|
@ -292,7 +290,7 @@ function CreatePurchaseForm({
|
|||
value={item.medicineProductId}
|
||||
onChange={(e) => handleProductChange(idx, e.target.value)}
|
||||
disabled={!item.medicineId}
|
||||
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
||||
className="mt-field"
|
||||
>
|
||||
<option value="">
|
||||
{item.medicineId ? 'Select product' : 'Select medicine first'}
|
||||
|
|
@ -309,9 +307,7 @@ function CreatePurchaseForm({
|
|||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<label className="mt-field-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
|
|
@ -319,20 +315,16 @@ function CreatePurchaseForm({
|
|||
value={item.name}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === idx ? { ...it, name: e.target.value } : it,
|
||||
),
|
||||
prev.map((it, i) => (i === idx ? { ...it, name: e.target.value } : it)),
|
||||
)
|
||||
}
|
||||
placeholder="Brand / product name"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Package size
|
||||
</label>
|
||||
<label className="mt-field-label">Package size</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
|
|
@ -347,36 +339,30 @@ function CreatePurchaseForm({
|
|||
)
|
||||
}
|
||||
placeholder="90"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Unit
|
||||
</label>
|
||||
<label className="mt-field-label">Unit</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={item.unit}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === idx ? { ...it, unit: e.target.value } : it,
|
||||
),
|
||||
prev.map((it, i) => (i === idx ? { ...it, unit: e.target.value } : it)),
|
||||
)
|
||||
}
|
||||
placeholder="tablet"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Price (optional)
|
||||
</label>
|
||||
<label className="mt-field-label">Price (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.01}
|
||||
|
|
@ -390,13 +376,11 @@ function CreatePurchaseForm({
|
|||
)
|
||||
}
|
||||
placeholder="9.99"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Currency
|
||||
</label>
|
||||
<label className="mt-field-label">Currency</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={10}
|
||||
|
|
@ -409,7 +393,7 @@ function CreatePurchaseForm({
|
|||
)
|
||||
}
|
||||
placeholder="USD"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -419,18 +403,10 @@ function CreatePurchaseForm({
|
|||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<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 ? 'Saving...' : 'Save Purchase'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 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>
|
||||
</div>
|
||||
|
|
@ -453,19 +429,13 @@ function PurchaseCard({
|
|||
const isOrdered = purchase.status === 'ordered';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div className={`mt-card ${!isOrdered ? '' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{purchase.storeName}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{formatDate(purchase.purchasedAt)}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
isOrdered
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
<span className={`mt-pill ${isOrdered ? 'mt-pill--warn' : 'mt-pill--ok'}`}>
|
||||
{isOrdered ? 'Pending' : 'Received'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -476,31 +446,24 @@ function PurchaseCard({
|
|||
<span className="text-gray-700">{item.name}</span>
|
||||
<span className="text-gray-500">
|
||||
{item.quantity} {item.unit}
|
||||
{item.actualPrice != null && ` — ${item.actualPrice.toFixed(2)} ${item.currency ?? ''}`}
|
||||
{item.actualPrice != null &&
|
||||
` — ${item.actualPrice.toFixed(2)} ${item.currency ?? ''}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{purchase.notes && (
|
||||
<p className="mt-2 text-xs text-gray-400 italic">{purchase.notes}</p>
|
||||
)}
|
||||
{purchase.notes && <p className="mt-2 text-xs text-gray-400 italic">{purchase.notes}</p>}
|
||||
|
||||
{(isOrdered || onDelete) && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{isOrdered && onReceive && (
|
||||
<button
|
||||
onClick={() => onReceive(purchase._id)}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<button onClick={() => onReceive(purchase._id)} className="mt-btn mt-btn--primary">
|
||||
Mark as received
|
||||
</button>
|
||||
)}
|
||||
{isOrdered && onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(purchase._id)}
|
||||
className="rounded-lg border px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<button onClick={() => onDelete(purchase._id)} className="mt-btn mt-btn--ghost">
|
||||
Cancel order
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -522,7 +485,9 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
|||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
|
||||
listStores(householdId, { limit: 100 })
|
||||
.then((r) => setStores(r.data))
|
||||
.catch(() => {});
|
||||
}, [householdId]);
|
||||
|
||||
const fetchPurchases = useCallback(
|
||||
|
|
@ -534,9 +499,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
|||
cursor: append ? (cursor ?? undefined) : undefined,
|
||||
limit: 20,
|
||||
});
|
||||
setPurchases((prev) =>
|
||||
append ? [...prev, ...result.data] : result.data,
|
||||
);
|
||||
setPurchases((prev) => (append ? [...prev, ...result.data] : result.data));
|
||||
setCursor(result.pagination.cursor);
|
||||
setHasMore(result.pagination.hasMore);
|
||||
} catch (err) {
|
||||
|
|
@ -582,10 +545,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
|||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Purchases</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-primary-600 px-4 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' : 'Record Purchase'}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -604,7 +564,7 @@ function PurchasesContent({ 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
|
||||
|
|
@ -648,7 +608,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
|
|||
)}
|
||||
|
||||
{purchases.length === 0 && (
|
||||
<div className="rounded-xl border bg-white p-10 text-center shadow-sm">
|
||||
<div className="mt-card text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
No purchases recorded yet. Record your first purchase to get started.
|
||||
</p>
|
||||
|
|
@ -676,33 +636,54 @@ export default function PurchasesPage() {
|
|||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Purchases</h1>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
||||
<div className="h-28 rounded-xl bg-gray-200" />
|
||||
<div className="h-28 rounded-xl bg-gray-200" />
|
||||
<>
|
||||
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: 64, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Purchases</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 recording purchases.
|
||||
</p>
|
||||
<>
|
||||
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
|
||||
<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 recording purchases.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <PurchasesContent householdId={householdId} />;
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
|
||||
<div className="mt-page">
|
||||
<PurchasesContent householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type React from 'react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
|
||||
|
|
@ -29,7 +30,9 @@ vi.mock('@/services/refills', () => ({
|
|||
updateRefillListItem: mockUpdateRefillListItem,
|
||||
addToCabinet: mockAddToCabinet,
|
||||
}));
|
||||
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
|
||||
vi.mock('next/link', () => ({
|
||||
default: (props: { href?: string; children: React.ReactNode }) => props.children,
|
||||
}));
|
||||
|
||||
import RefillsPage from '../page';
|
||||
|
||||
|
|
@ -65,17 +68,13 @@ describe('RefillsPage', () => {
|
|||
it('shows empty state for alerts', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<RefillsPage />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No medicines running low/)).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText(/No medicines running low/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows empty state for refill lists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
render(<RefillsPage />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error when alerts fail', async () => {
|
||||
|
|
@ -116,7 +115,10 @@ describe('RefillsPage', () => {
|
|||
fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateRefillList).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Test List' })),
|
||||
expect(mockCreateRefillList).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ name: 'Test List' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -274,7 +276,9 @@ describe('RefillsPage', () => {
|
|||
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
|
||||
target: { value: 'Auto Refills' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!);
|
||||
fireEvent.submit(
|
||||
screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateRefillList).toHaveBeenCalledWith(
|
||||
|
|
@ -293,7 +297,11 @@ describe('RefillsPage', () => {
|
|||
await userEvent.click(screen.getByText('New List'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('List name'));
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[screen.getAllByRole('button', { name: 'Cancel' }).length - 1]!);
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Cancel' })[
|
||||
screen.getAllByRole('button', { name: 'Cancel' }).length - 1
|
||||
]!,
|
||||
);
|
||||
|
||||
expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -382,7 +390,9 @@ describe('RefillsPage', () => {
|
|||
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
|
||||
target: { value: 'Auto Refills' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!);
|
||||
fireEvent.submit(
|
||||
screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Generate failed')).toBeInTheDocument());
|
||||
});
|
||||
|
|
@ -451,7 +461,9 @@ describe('RefillsPage', () => {
|
|||
await waitFor(() => screen.getByText('Aspirin'));
|
||||
await userEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', { checked: true });
|
||||
expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', {
|
||||
checked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a shopping list as complete', async () => {
|
||||
|
|
@ -623,9 +635,7 @@ describe('RefillsPage', () => {
|
|||
await waitFor(() => screen.getByText('Aspirin'));
|
||||
await userEvent.click(screen.getAllByRole('checkbox')[0]!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Failed to update item')).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('Failed to update item')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows fallback error when non-Error thrown on update status', async () => {
|
||||
|
|
@ -653,9 +663,7 @@ describe('RefillsPage', () => {
|
|||
await waitFor(() => screen.getByText('Start shopping'));
|
||||
await userEvent.click(screen.getByText('Start shopping'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Failed to update status')).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('Failed to update status')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows refill alert when present', async () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,19 @@ const { mockUseApi } = vi.hoisted(() => ({
|
|||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockCreateHousehold, mockJoinHousehold, mockGetHousehold, mockUpdateHousehold, mockGenerateInviteCode } =
|
||||
vi.hoisted(() => ({
|
||||
mockCreateHousehold: vi.fn(),
|
||||
mockJoinHousehold: vi.fn(),
|
||||
mockGetHousehold: vi.fn(),
|
||||
mockUpdateHousehold: vi.fn(),
|
||||
mockGenerateInviteCode: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockCreateHousehold,
|
||||
mockJoinHousehold,
|
||||
mockGetHousehold,
|
||||
mockUpdateHousehold,
|
||||
mockGenerateInviteCode,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateHousehold: vi.fn(),
|
||||
mockJoinHousehold: vi.fn(),
|
||||
mockGetHousehold: vi.fn(),
|
||||
mockUpdateHousehold: vi.fn(),
|
||||
mockGenerateInviteCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('@/services/households', () => ({
|
||||
|
|
@ -137,7 +142,9 @@ describe('SettingsPage', () => {
|
|||
await userEvent.type(input, 'Updated Home');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(mockUpdateHousehold).toHaveBeenCalledWith('hh1', { name: 'Updated Home' }));
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateHousehold).toHaveBeenCalledWith('hh1', { name: 'Updated Home' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error when name update fails', async () => {
|
||||
|
|
@ -252,7 +259,9 @@ describe('SettingsPage', () => {
|
|||
await waitFor(() => screen.getByText('My House'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to regenerate invite code')).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Failed to regenerate invite code')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows validation error when saving empty name', async () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import {
|
||||
createHousehold,
|
||||
joinHousehold,
|
||||
|
|
@ -14,26 +15,32 @@ export default function SettingsPage() {
|
|||
const { householdId, isLoading, refreshProfile } = useApi();
|
||||
|
||||
if (isLoading) {
|
||||
return <SettingsLoading />;
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Settings" subtitle="Household and account" />
|
||||
<SettingsLoading />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<HouseholdSection householdId={householdId} onHouseholdChanged={() => refreshProfile()} />
|
||||
<AccountSection />
|
||||
<>
|
||||
<SetPageHeader title="Settings" subtitle="Household and account" />
|
||||
<div style={{ padding: '28px 32px 56px' }}>
|
||||
<div style={{ maxWidth: 640, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<HouseholdSection householdId={householdId} onHouseholdChanged={() => refreshProfile()} />
|
||||
<AccountSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsLoading() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
||||
<div className="max-w-2xl">
|
||||
<div className="animate-pulse rounded-xl border bg-white p-6 shadow-sm h-48" />
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<div style={{ height: 192, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -64,20 +71,23 @@ function HouseholdSection({
|
|||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [regenerateError, setRegenerateError] = useState('');
|
||||
|
||||
async function loadHousehold() {
|
||||
if (!householdId || /* v8 ignore next */ loadedHousehold) return;
|
||||
try {
|
||||
const hh = await getHousehold(householdId);
|
||||
setCurrentHousehold(hh);
|
||||
} catch {
|
||||
// Household may not be accessible yet
|
||||
useEffect(() => {
|
||||
if (!householdId || loadedHousehold) return;
|
||||
let cancelled = false;
|
||||
async function loadHousehold() {
|
||||
try {
|
||||
const hh = await getHousehold(householdId as string);
|
||||
if (!cancelled) setCurrentHousehold(hh);
|
||||
} catch {
|
||||
// Household may not be accessible yet
|
||||
}
|
||||
if (!cancelled) setLoadedHousehold(true);
|
||||
}
|
||||
setLoadedHousehold(true);
|
||||
}
|
||||
|
||||
if (householdId && !loadedHousehold) {
|
||||
loadHousehold();
|
||||
}
|
||||
void loadHousehold();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [householdId, loadedHousehold]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ const { mockUseApi } = vi.hoisted(() => ({
|
|||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListStores, mockCreateStore, mockUpdateStore, mockDeactivateStore } = vi.hoisted(() => ({
|
||||
mockListStores: vi.fn(),
|
||||
mockCreateStore: vi.fn(),
|
||||
mockUpdateStore: vi.fn(),
|
||||
mockDeactivateStore: vi.fn(),
|
||||
}));
|
||||
const { mockListStores, mockCreateStore, mockUpdateStore, mockDeactivateStore } = vi.hoisted(
|
||||
() => ({
|
||||
mockListStores: vi.fn(),
|
||||
mockCreateStore: vi.fn(),
|
||||
mockUpdateStore: vi.fn(),
|
||||
mockDeactivateStore: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
|
|
@ -151,7 +153,15 @@ describe('StoresPage', () => {
|
|||
it('creates a store on form submit', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
mockCreateStore.mockResolvedValue({ _id: 'st-new', name: 'Walmart', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' });
|
||||
mockCreateStore.mockResolvedValue({
|
||||
_id: 'st-new',
|
||||
name: 'Walmart',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
});
|
||||
|
||||
render(<StoresPage />);
|
||||
|
||||
|
|
@ -159,11 +169,16 @@ describe('StoresPage', () => {
|
|||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), {
|
||||
target: { value: 'Walmart' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateStore).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Walmart' })),
|
||||
expect(mockCreateStore).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ name: 'Walmart' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -178,7 +193,9 @@ describe('StoresPage', () => {
|
|||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), {
|
||||
target: { value: 'Walmart' },
|
||||
});
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Store already exists')).toBeInTheDocument());
|
||||
|
|
@ -187,7 +204,17 @@ describe('StoresPage', () => {
|
|||
it('opens edit form for a store', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'CVS',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
|
|
@ -203,7 +230,17 @@ describe('StoresPage', () => {
|
|||
it('saves edited store', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'CVS',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateStore.mockResolvedValue({});
|
||||
|
|
@ -218,7 +255,11 @@ describe('StoresPage', () => {
|
|||
fireEvent.submit(screen.getByDisplayValue('CVS Pharmacy').closest('form')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateStore).toHaveBeenCalledWith('hh1', 'st-1', expect.objectContaining({ name: 'CVS Pharmacy' })),
|
||||
expect(mockUpdateStore).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'st-1',
|
||||
expect.objectContaining({ name: 'CVS Pharmacy' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -237,7 +278,17 @@ describe('StoresPage', () => {
|
|||
it('shows error when deactivate fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'CVS',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeactivateStore.mockRejectedValue(new Error('Deactivate failed'));
|
||||
|
|
@ -261,7 +312,11 @@ describe('StoresPage', () => {
|
|||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[screen.getAllByRole('button', { name: 'Cancel' }).length - 1]!);
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Cancel' })[
|
||||
screen.getAllByRole('button', { name: 'Cancel' }).length - 1
|
||||
]!,
|
||||
);
|
||||
|
||||
expect(screen.queryByPlaceholderText('e.g. Walgreens')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -269,7 +324,17 @@ describe('StoresPage', () => {
|
|||
it('cancels the edit store form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'CVS',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
|
|
@ -288,8 +353,24 @@ describe('StoresPage', () => {
|
|||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'st-1', name: 'Walgreens', tags: ['pharmacy'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{ _id: 'st-2', name: 'Costco', tags: ['supermarket'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Walgreens',
|
||||
tags: ['pharmacy'],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
{
|
||||
_id: 'st-2',
|
||||
name: 'Costco',
|
||||
tags: ['supermarket'],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
|
@ -315,8 +396,12 @@ describe('StoresPage', () => {
|
|||
await userEvent.click(screen.getByText('Add Store'));
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
|
||||
fireEvent.change(screen.getByPlaceholderText('123 Main St'), { target: { value: '456 Oak Ave' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Any notes'), { target: { value: 'Good prices' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('123 Main St'), {
|
||||
target: { value: '456 Oak Ave' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Any notes'), {
|
||||
target: { value: 'Good prices' },
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g. Walgreens')).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -368,8 +453,24 @@ describe('StoresPage', () => {
|
|||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{ _id: 'st-2', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Walgreens',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
{
|
||||
_id: 'st-2',
|
||||
name: 'CVS',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
|
@ -386,7 +487,17 @@ describe('StoresPage', () => {
|
|||
it('toggles isActive in edit form', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
|
||||
data: [
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'CVS',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockUpdateStore.mockResolvedValue({});
|
||||
|
|
@ -406,8 +517,24 @@ describe('StoresPage', () => {
|
|||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListStores.mockResolvedValue({
|
||||
data: [
|
||||
{ _id: 'st-1', name: 'Active Store', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{ _id: 'st-2', name: 'Old Store', tags: [], isActive: false, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
|
||||
{
|
||||
_id: 'st-1',
|
||||
name: 'Active Store',
|
||||
tags: [],
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
{
|
||||
_id: 'st-2',
|
||||
name: 'Old Store',
|
||||
tags: [],
|
||||
isActive: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
createdBy: 'u-1',
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { listStores, createStore, updateStore, deactivateStore } from '@/services/stores';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { StoreResponseSchema } from '@meshitrack/shared';
|
||||
|
|
@ -11,12 +12,12 @@ type Store = z.infer<typeof StoreResponseSchema>;
|
|||
|
||||
const PRESET_TAGS = ['pharmacy', 'grocery', 'online', 'bulk', 'discount'];
|
||||
|
||||
const TAG_COLORS: Record<string, string> = {
|
||||
pharmacy: 'bg-blue-100 text-blue-700',
|
||||
grocery: 'bg-green-100 text-green-700',
|
||||
online: 'bg-purple-100 text-purple-700',
|
||||
bulk: 'bg-orange-100 text-orange-700',
|
||||
discount: 'bg-yellow-100 text-yellow-700',
|
||||
const TAG_PILL: Record<string, string> = {
|
||||
pharmacy: 'mt-pill--info',
|
||||
grocery: 'mt-pill--ok',
|
||||
online: 'mt-pill--brand',
|
||||
bulk: 'mt-pill--warn',
|
||||
discount: 'mt-pill--warn',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
|
|
@ -83,13 +84,9 @@ function StoreForm({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div className="mt-card mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Store' : 'Add Store'}</h2>
|
||||
{error && (
|
||||
<div className="mb-4 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-4">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
|
|
@ -101,7 +98,7 @@ function StoreForm({
|
|||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Walgreens"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -114,7 +111,7 @@ function StoreForm({
|
|||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -126,20 +123,18 @@ function StoreForm({
|
|||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://walgreens.com"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notes (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any notes"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,7 +149,8 @@ function StoreForm({
|
|||
onClick={() => toggleTag(tag)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${
|
||||
tags.includes(tag)
|
||||
? (TAG_COLORS[tag] ?? 'bg-gray-200 text-gray-800') + ' border-transparent'
|
||||
? (TAG_PILL[tag] ? `mt-pill ${TAG_PILL[tag]}` : 'bg-gray-200 text-gray-800') +
|
||||
' border-transparent'
|
||||
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -196,13 +192,10 @@ function StoreForm({
|
|||
}}
|
||||
placeholder="Custom tag..."
|
||||
maxLength={50}
|
||||
className="rounded-lg border px-3 py-1.5 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
|
||||
className="mt-field"
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomTag}
|
||||
className="rounded-lg border px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<button type="button" onClick={addCustomTag} className="mt-btn mt-btn--ghost">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -215,7 +208,7 @@ function StoreForm({
|
|||
id="isActive"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||
Active
|
||||
|
|
@ -224,18 +217,10 @@ function StoreForm({
|
|||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<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 ? 'Saving...' : initial ? 'Save changes' : 'Add Store'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 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>
|
||||
</div>
|
||||
|
|
@ -256,40 +241,29 @@ function StoreCard({
|
|||
onDeactivate: (store: Store) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border bg-white p-4 shadow-sm ${!store.isActive ? 'opacity-60' : ''}`}>
|
||||
<div className={`mt-card ${!store.isActive ? 'opacity-60' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{store.name}</h3>
|
||||
{!store.isActive && (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
{!store.isActive && <span className="mt-pill mt-pill--ghost">Inactive</span>}
|
||||
</div>
|
||||
{store.address && (
|
||||
<p className="text-sm text-gray-500 mb-1">{store.address}</p>
|
||||
)}
|
||||
{store.address && <p className="text-sm text-gray-500 mb-1">{store.address}</p>}
|
||||
{store.url && (
|
||||
<a
|
||||
href={store.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary-600 underline hover:text-primary-700 block mb-1"
|
||||
className="mt-link text-xs block mb-1"
|
||||
>
|
||||
{store.url}
|
||||
</a>
|
||||
)}
|
||||
{store.notes && (
|
||||
<p className="text-xs text-gray-400 mb-1">{store.notes}</p>
|
||||
)}
|
||||
{store.notes && <p className="text-xs text-gray-400 mb-1">{store.notes}</p>}
|
||||
{store.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{store.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${TAG_COLORS[tag] ?? 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
<span key={tag} className={`mt-pill ${TAG_PILL[tag] ?? 'mt-pill--ghost'}`}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
|
|
@ -298,11 +272,7 @@ function StoreCard({
|
|||
<p className="text-xs text-gray-400 mt-2">Added {formatDate(store.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => onEdit(store)}
|
||||
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<button onClick={() => onEdit(store)} className="mt-btn mt-btn--icon" title="Edit">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
|
|
@ -315,7 +285,7 @@ function StoreCard({
|
|||
{store.isActive && (
|
||||
<button
|
||||
onClick={() => onDeactivate(store)}
|
||||
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="mt-btn mt-btn--danger-icon"
|
||||
title="Deactivate"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -387,14 +357,14 @@ function StoresContent({ householdId }: { householdId: string }) {
|
|||
setEditingStore(null);
|
||||
setShowForm(!showForm);
|
||||
}}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
||||
className="mt-btn mt-btn--primary"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'Add Store'}
|
||||
</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
|
||||
|
|
@ -431,12 +401,13 @@ function StoresContent({ householdId }: { householdId: string }) {
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search stores..."
|
||||
className="w-full max-w-xs 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 max-w-xs"
|
||||
/>
|
||||
<select
|
||||
value={filterTag}
|
||||
onChange={(e) => setFilterTag(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 tags</option>
|
||||
{PRESET_TAGS.map((tag) => (
|
||||
|
|
@ -450,7 +421,7 @@ function StoresContent({ householdId }: { householdId: string }) {
|
|||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
Show inactive
|
||||
</label>
|
||||
|
|
@ -463,8 +434,10 @@ function StoresContent({ householdId }: { householdId: string }) {
|
|||
))}
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
|
||||
{search || filterTag ? 'No stores match your filters.' : 'No stores yet. Add your first one above.'}
|
||||
<div className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
|
||||
{search || filterTag
|
||||
? 'No stores match your filters.'
|
||||
: 'No stores yet. Add your first one above.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -490,33 +463,54 @@ export default function StoresPage() {
|
|||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Stores</h1>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 w-48 rounded-lg bg-gray-200" />
|
||||
<div className="h-20 rounded-xl bg-gray-200" />
|
||||
<div className="h-20 rounded-xl bg-gray-200" />
|
||||
<>
|
||||
<SetPageHeader title="Stores" subtitle="Pharmacies and vendors" />
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: 64, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Stores</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 stores.
|
||||
</p>
|
||||
<>
|
||||
<SetPageHeader title="Stores" subtitle="Pharmacies and vendors" />
|
||||
<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 stores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <StoresContent householdId={householdId} />;
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Stores" subtitle="Pharmacies and vendors" />
|
||||
<div className="mt-page">
|
||||
<StoresContent householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,68 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Fraunces, Inter_Tight, JetBrains_Mono } from 'next/font/google';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
const interTight = Inter_Tight({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MeshiTrack',
|
||||
description: 'Medicine & Nutrition Management Platform',
|
||||
};
|
||||
|
||||
// Inline script injected before React hydration to read localStorage and
|
||||
// apply the saved theme/accent without flash of unstyled content.
|
||||
const themeScript = `
|
||||
(function() {
|
||||
try {
|
||||
var t = localStorage.getItem('mt-theme') || 'light';
|
||||
var a = localStorage.getItem('mt-accent') || 'sage';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
var accents = {
|
||||
sage: { brand:'#2f6b4a', deep:'#1e4a32', soft:'#e6efe8', softInk:'#1e4a32' },
|
||||
cobalt: { brand:'#2e5aa8', deep:'#1d3d75', soft:'#e4eaf5', softInk:'#1d3d75' },
|
||||
terracotta: { brand:'#b55438', deep:'#7d3825', soft:'#f6e6de', softInk:'#7d3825' },
|
||||
graphite: { brand:'#2c2c28', deep:'#000000', soft:'#e8e6df', softInk:'#2c2c28' },
|
||||
};
|
||||
var ac = accents[a] || accents.sage;
|
||||
var r = document.documentElement.style;
|
||||
r.setProperty('--brand', ac.brand);
|
||||
r.setProperty('--brand-deep', ac.deep);
|
||||
r.setProperty('--brand-soft', ac.soft);
|
||||
r.setProperty('--brand-soft-ink', ac.softInk);
|
||||
r.setProperty('--viz-1', ac.brand);
|
||||
} catch(e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-gray-50">
|
||||
<html
|
||||
lang="en"
|
||||
className={`${interTight.variable} ${fraunces.variable} ${jetbrainsMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{}
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
90
packages/web/src/components/ThemeProvider.tsx
Normal file
90
packages/web/src/components/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
type Accent = 'sage' | 'cobalt' | 'terracotta' | 'graphite';
|
||||
|
||||
interface AccentTokens {
|
||||
brand: string;
|
||||
deep: string;
|
||||
soft: string;
|
||||
softInk: string;
|
||||
}
|
||||
|
||||
const ACCENTS: Record<Accent, AccentTokens> = {
|
||||
sage: { brand: '#2f6b4a', deep: '#1e4a32', soft: '#e6efe8', softInk: '#1e4a32' },
|
||||
cobalt: { brand: '#2e5aa8', deep: '#1d3d75', soft: '#e4eaf5', softInk: '#1d3d75' },
|
||||
terracotta: { brand: '#b55438', deep: '#7d3825', soft: '#f6e6de', softInk: '#7d3825' },
|
||||
graphite: { brand: '#2c2c28', deep: '#000000', soft: '#e8e6df', softInk: '#2c2c28' },
|
||||
};
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
accent: Accent;
|
||||
setTheme: (t: Theme) => void;
|
||||
setAccent: (a: Accent) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function applyAccent(accent: Accent) {
|
||||
const ac = ACCENTS[accent];
|
||||
const r = document.documentElement.style;
|
||||
r.setProperty('--brand', ac.brand);
|
||||
r.setProperty('--brand-deep', ac.deep);
|
||||
r.setProperty('--brand-soft', ac.soft);
|
||||
r.setProperty('--brand-soft-ink', ac.softInk);
|
||||
r.setProperty('--viz-1', ac.brand);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>('light');
|
||||
const [accent, setAccentState] = useState<Accent>('sage');
|
||||
|
||||
// Hydrate from localStorage on mount (server renders light/sage defaults).
|
||||
useEffect(() => {
|
||||
const savedTheme = (localStorage.getItem('mt-theme') as Theme | null) ?? 'light';
|
||||
const savedAccent = (localStorage.getItem('mt-accent') as Accent | null) ?? 'sage';
|
||||
setThemeState(savedTheme);
|
||||
setAccentState(savedAccent);
|
||||
}, []);
|
||||
|
||||
// Sync theme to DOM + localStorage.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('mt-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// Sync accent to DOM + localStorage.
|
||||
useEffect(() => {
|
||||
applyAccent(accent);
|
||||
localStorage.setItem('mt-accent', accent);
|
||||
}, [accent]);
|
||||
|
||||
function setTheme(t: Theme) {
|
||||
setThemeState(t);
|
||||
}
|
||||
|
||||
function setAccent(a: Accent) {
|
||||
setAccentState(a);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
setThemeState((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, accent, setTheme, setAccent, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
40
packages/web/src/components/layout/PageHeaderContext.tsx
Normal file
40
packages/web/src/components/layout/PageHeaderContext.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageHeader {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
crumbs?: string[];
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
interface PageHeaderContextValue {
|
||||
header: PageHeader;
|
||||
setHeader: (h: PageHeader) => void;
|
||||
}
|
||||
|
||||
const PageHeaderContext = createContext<PageHeaderContextValue | null>(null);
|
||||
|
||||
export function PageHeaderProvider({ children }: { children: ReactNode }) {
|
||||
const [header, setHeader] = useState<PageHeader>({ title: 'MeshiTrack' });
|
||||
return (
|
||||
<PageHeaderContext.Provider value={{ header, setHeader }}>
|
||||
{children}
|
||||
</PageHeaderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePageHeader(): PageHeaderContextValue {
|
||||
const ctx = useContext(PageHeaderContext);
|
||||
// Return a no-op when rendered outside the provider (e.g. in unit tests).
|
||||
if (!ctx) {
|
||||
return {
|
||||
header: { title: '' },
|
||||
|
||||
setHeader: () => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
41
packages/web/src/components/layout/SetPageHeader.tsx
Normal file
41
packages/web/src/components/layout/SetPageHeader.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { usePageHeader } from './PageHeaderContext';
|
||||
|
||||
interface SetPageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
crumbs?: string[];
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call inside a page component to set the TopBar's title/subtitle/crumbs.
|
||||
* Renders a visually-hidden heading for accessibility and tests.
|
||||
*/
|
||||
export function SetPageHeader({ title, subtitle, crumbs, actions }: SetPageHeaderProps) {
|
||||
const { setHeader } = usePageHeader();
|
||||
useEffect(() => {
|
||||
setHeader({ title, subtitle, crumbs, actions });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [title, subtitle, crumbs?.join(',')]);
|
||||
return (
|
||||
<h1
|
||||
aria-hidden="false"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +1,259 @@
|
|||
import Link from 'next/link';
|
||||
'use client';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'Medicines', href: '/medicines' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import type { IconName } from '@/components/ui/Icon';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
icon: IconName;
|
||||
section?: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: 'dashboard' },
|
||||
{
|
||||
id: 'cabinet',
|
||||
label: 'Cabinet',
|
||||
href: '/medicines/cabinet',
|
||||
icon: 'cabinet',
|
||||
section: 'Medicines',
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
label: 'Schedule & Log',
|
||||
href: '/medicines/schedule',
|
||||
icon: 'clock',
|
||||
section: 'Medicines',
|
||||
},
|
||||
{
|
||||
id: 'regimens',
|
||||
label: 'Regimens',
|
||||
href: '/medicines/regimens',
|
||||
icon: 'list',
|
||||
section: 'Medicines',
|
||||
},
|
||||
{
|
||||
id: 'organizer',
|
||||
label: 'Pill Organizer',
|
||||
href: '/medicines/organizer',
|
||||
icon: 'calendar',
|
||||
section: 'Medicines',
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
label: 'Library',
|
||||
href: '/medicines/library',
|
||||
icon: 'pill',
|
||||
section: 'Medicines',
|
||||
},
|
||||
{
|
||||
id: 'refills',
|
||||
label: 'Shopping list',
|
||||
href: '/refills',
|
||||
icon: 'refresh',
|
||||
section: 'Medicines',
|
||||
},
|
||||
{ id: 'purchases', label: 'Purchases', href: '/purchases', icon: 'truck', section: 'Medicines' },
|
||||
{ id: 'prices', label: 'Prices', href: '/medicine-prices', icon: 'tag', section: 'Medicines' },
|
||||
{ id: 'stores', label: 'Stores', href: '/stores', icon: 'store', section: 'Medicines' },
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity & Spend',
|
||||
href: '/medicines/activity',
|
||||
icon: 'trend',
|
||||
section: 'Medicines',
|
||||
},
|
||||
{ id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' },
|
||||
];
|
||||
|
||||
function groupNav(items: NavItem[]): [string, NavItem[]][] {
|
||||
const map = new Map<string, NavItem[]>();
|
||||
for (const item of items) {
|
||||
const key = item.section ?? '__root__';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(item);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { profile } = useApi();
|
||||
const displayName = profile?.displayName ?? 'User';
|
||||
const groups = groupNav(NAV);
|
||||
|
||||
return (
|
||||
<aside className="flex w-64 flex-col border-r bg-white">
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<Link href="/dashboard" className="text-xl font-bold text-primary-700">
|
||||
MeshiTrack
|
||||
</Link>
|
||||
<aside
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: '100vh',
|
||||
width: 248,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
padding: '18px 18px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--brand-soft)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="5" fill="var(--brand)" />
|
||||
<path
|
||||
d="M7 15l3-6 2 4 2-3 3 5"
|
||||
stroke="var(--brand-ink)"
|
||||
strokeWidth="1.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--ink-strong)',
|
||||
}}
|
||||
>
|
||||
MeshiTrack
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto p-4">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
style={{ flex: 1, overflowY: 'auto', padding: '10px 10px 16px' }}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{groups.map(([section, items]) => (
|
||||
<div key={section} style={{ marginBottom: 14 }}>
|
||||
{section !== '__root__' && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--ink-faint)',
|
||||
padding: '10px 10px 6px',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{section}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
fontSize: 13,
|
||||
color: isActive ? 'var(--brand-soft-ink)' : 'var(--ink-muted)',
|
||||
background: isActive ? 'var(--brand-soft)' : 'transparent',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
transition: 'background 0.1s, color 0.1s',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
size={16}
|
||||
style={{ opacity: isActive ? 1 : 0.8, flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge != null && (
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--danger-soft)',
|
||||
color: 'var(--danger)',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 8,
|
||||
minWidth: 18,
|
||||
textAlign: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '12px 14px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<Avatar name={displayName} size={32} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink-strong)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>Household owner</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,130 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import useSWR from 'swr';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { getHousehold } from '@/services/households';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { IconButton } from '@/components/ui/IconButton';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { usePageHeader } from './PageHeaderContext';
|
||||
|
||||
export function TopBar() {
|
||||
const { householdId, profile, isLoading } = useApi();
|
||||
const name = profile?.displayName ?? 'Unknown';
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
const { header } = usePageHeader();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const { data: household } = useSWR(householdId ? `household-${householdId}` : null, () =>
|
||||
getHousehold(householdId!),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
|
||||
<div className="h-6 w-32 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
const { title, subtitle, crumbs, actions } = header;
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
|
||||
<div className="text-sm text-gray-500">
|
||||
{householdId ? (
|
||||
<span className="rounded-md border px-3 py-1 font-medium text-gray-700">
|
||||
{household?.name ?? householdId}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href="/settings"
|
||||
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-1 text-amber-700 hover:bg-amber-100 transition-colors"
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
padding: '22px 32px 18px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 5,
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{crumbs && crumbs.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 11,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
No household - Create one
|
||||
</Link>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{i > 0 && <Icon name="chev" size={12} style={{ opacity: 0.4 }} />}
|
||||
<span style={i === crumbs.length - 1 ? { color: 'var(--ink)' } : undefined}>
|
||||
{c}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 28,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--ink-strong)',
|
||||
margin: 0,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-muted)', marginTop: 4 }}>{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{name}</span>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-200 text-sm font-medium text-primary-800">
|
||||
{initial}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{/* Search */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
padding: '6px 10px',
|
||||
minWidth: 280,
|
||||
color: 'var(--ink-muted)',
|
||||
}}
|
||||
>
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
placeholder="Search medicines, regimens, stores…"
|
||||
style={{
|
||||
border: 0,
|
||||
outline: 0,
|
||||
background: 'transparent',
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
aria-label="Search"
|
||||
/>
|
||||
<kbd
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 10,
|
||||
padding: '2px 5px',
|
||||
background: 'var(--bg-inset)',
|
||||
borderRadius: 4,
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--ink-muted)',
|
||||
}}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<IconButton icon="bell" label="Notifications" dot />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<IconButton
|
||||
icon={theme === 'dark' ? 'sun' : 'moon'}
|
||||
label="Toggle theme"
|
||||
onClick={toggleTheme}
|
||||
/>
|
||||
|
||||
{/* Page actions slot */}
|
||||
{actions}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
27
packages/web/src/components/ui/Avatar.tsx
Normal file
27
packages/web/src/components/ui/Avatar.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
interface AvatarProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Avatar({ name, size = 32 }: AvatarProps) {
|
||||
const initial = (name.charAt(0) ?? '?').toUpperCase();
|
||||
return (
|
||||
<div
|
||||
aria-label={name}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, var(--viz-4), var(--viz-3))',
|
||||
color: 'white',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: size * 0.4,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
packages/web/src/components/ui/Button.tsx
Normal file
42
packages/web/src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type Variant = 'primary' | 'ghost' | 'subtle' | 'danger';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BASE =
|
||||
'inline-flex items-center gap-1.5 rounded-[var(--r-sm)] font-medium transition-all whitespace-nowrap border border-transparent leading-none';
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
primary: 'bg-[var(--brand)] text-[var(--brand-ink)] hover:bg-[var(--brand-deep)]',
|
||||
ghost:
|
||||
'bg-[var(--bg-elev)] text-[var(--ink)] border-[var(--border)] hover:border-[var(--border-strong)] hover:bg-[var(--bg-inset)]',
|
||||
subtle:
|
||||
'text-[var(--ink-muted)] hover:text-[var(--ink)] hover:bg-[var(--bg-inset)] border-transparent',
|
||||
danger:
|
||||
'text-[var(--danger)] border-[var(--danger-soft)] bg-[var(--bg-elev)] hover:bg-[var(--danger-soft)]',
|
||||
};
|
||||
|
||||
const SIZES: Record<Size, string> = {
|
||||
sm: 'text-[11px] px-2.5 py-1',
|
||||
md: 'text-[13px] px-3 py-[7px]',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'ghost',
|
||||
size = 'md',
|
||||
className = '',
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button className={`${BASE} ${VARIANTS[variant]} ${SIZES[size]} ${className}`} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
66
packages/web/src/components/ui/Card.tsx
Normal file
66
packages/web/src/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { ReactNode, CSSProperties } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', style }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action }: CardHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 18px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink-strong)',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 2 }}>{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardBody({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
||||
return <div style={{ padding: '16px 18px', ...style }}>{children}</div>;
|
||||
}
|
||||
233
packages/web/src/components/ui/Icon.tsx
Normal file
233
packages/web/src/components/ui/Icon.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import type { CSSProperties, SVGProps } from 'react';
|
||||
|
||||
export type IconName =
|
||||
| 'dashboard'
|
||||
| 'cabinet'
|
||||
| 'pill'
|
||||
| 'clock'
|
||||
| 'list'
|
||||
| 'calendar'
|
||||
| 'store'
|
||||
| 'tag'
|
||||
| 'truck'
|
||||
| 'refresh'
|
||||
| 'search'
|
||||
| 'plus'
|
||||
| 'check'
|
||||
| 'x'
|
||||
| 'chev'
|
||||
| 'chevDown'
|
||||
| 'alert'
|
||||
| 'bell'
|
||||
| 'filter'
|
||||
| 'settings'
|
||||
| 'sun'
|
||||
| 'moon'
|
||||
| 'home'
|
||||
| 'trash'
|
||||
| 'edit'
|
||||
| 'arrow'
|
||||
| 'vial'
|
||||
| 'capsule'
|
||||
| 'injection'
|
||||
| 'trend'
|
||||
| 'fridge'
|
||||
| 'box'
|
||||
| 'yen'
|
||||
| 'zap';
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const PATHS: Record<IconName, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<>
|
||||
<rect x="3" y="3" width="8" height="10" rx="1.5" />
|
||||
<rect x="13" y="3" width="8" height="6" rx="1.5" />
|
||||
<rect x="3" y="15" width="8" height="6" rx="1.5" />
|
||||
<rect x="13" y="11" width="8" height="10" rx="1.5" />
|
||||
</>
|
||||
),
|
||||
cabinet: (
|
||||
<>
|
||||
<rect x="4" y="3" width="16" height="18" rx="2" />
|
||||
<path d="M4 12h16" />
|
||||
<circle cx="10" cy="7.5" r="0.6" fill="currentColor" />
|
||||
<circle cx="10" cy="16.5" r="0.6" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
pill: (
|
||||
<>
|
||||
<rect x="2" y="9" width="20" height="6" rx="3" transform="rotate(-30 12 12)" />
|
||||
<path d="M7.4 7.8l9 5.2" transform="rotate(-30 12 12)" />
|
||||
</>
|
||||
),
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</>
|
||||
),
|
||||
list: (
|
||||
<>
|
||||
<path d="M8 6h13M8 12h13M8 18h13" />
|
||||
<circle cx="3.5" cy="6" r="0.8" fill="currentColor" />
|
||||
<circle cx="3.5" cy="12" r="0.8" fill="currentColor" />
|
||||
<circle cx="3.5" cy="18" r="0.8" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</>
|
||||
),
|
||||
store: (
|
||||
<>
|
||||
<path d="M3 9l1.5-5h15L21 9M3 9v11h18V9M3 9h18" />
|
||||
<path d="M9 20v-6h6v6" />
|
||||
</>
|
||||
),
|
||||
tag: (
|
||||
<>
|
||||
<path d="M3 12l9-9h8v8l-9 9z" />
|
||||
<circle cx="15.5" cy="8.5" r="1.4" />
|
||||
</>
|
||||
),
|
||||
truck: (
|
||||
<>
|
||||
<rect x="2" y="7" width="12" height="10" rx="1.5" />
|
||||
<path d="M14 10h5l3 4v3h-8" />
|
||||
<circle cx="7" cy="18.5" r="1.8" />
|
||||
<circle cx="17" cy="18.5" r="1.8" />
|
||||
</>
|
||||
),
|
||||
refresh: (
|
||||
<>
|
||||
<path d="M21 12a9 9 0 1 1-3-6.7L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M20 20l-3.5-3.5" />
|
||||
</>
|
||||
),
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
check: <path d="M5 12l5 5L20 6" />,
|
||||
x: <path d="M6 6l12 12M18 6L6 18" />,
|
||||
chev: <path d="M9 6l6 6-6 6" />,
|
||||
chevDown: <path d="M6 9l6 6 6-6" />,
|
||||
alert: (
|
||||
<>
|
||||
<path d="M12 3l10 18H2z" />
|
||||
<path d="M12 10v5M12 18v.5" />
|
||||
</>
|
||||
),
|
||||
bell: (
|
||||
<>
|
||||
<path d="M6 8a6 6 0 1 1 12 0c0 6 2 7 2 7H4s2-1 2-7z" />
|
||||
<path d="M10 19a2 2 0 0 0 4 0" />
|
||||
</>
|
||||
),
|
||||
filter: <path d="M4 5h16l-6 8v6l-4-2v-4z" />,
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M20 12a8 8 0 0 0-.2-1.8l2-1.6-2-3.5-2.4 1a8 8 0 0 0-3-1.8L14 2h-4l-.4 2.4a8 8 0 0 0-3 1.8l-2.4-1-2 3.5 2 1.6A8 8 0 0 0 4 12c0 .6.1 1.2.2 1.8l-2 1.6 2 3.5 2.4-1a8 8 0 0 0 3 1.8L10 22h4l.4-2.4a8 8 0 0 0 3-1.8l2.4 1 2-3.5-2-1.6c.1-.6.2-1.2.2-1.8z" />
|
||||
</>
|
||||
),
|
||||
sun: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5" />
|
||||
</>
|
||||
),
|
||||
moon: <path d="M20 14A8 8 0 0 1 10 4a8 8 0 1 0 10 10z" />,
|
||||
home: (
|
||||
<>
|
||||
<path d="M3 11l9-8 9 8v10H3z" />
|
||||
<path d="M9 21v-7h6v7" />
|
||||
</>
|
||||
),
|
||||
trash: (
|
||||
<>
|
||||
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" />
|
||||
</>
|
||||
),
|
||||
edit: (
|
||||
<>
|
||||
<path d="M4 20l4-1 11-11-3-3L5 16z" />
|
||||
<path d="M14 6l3 3" />
|
||||
</>
|
||||
),
|
||||
arrow: <path d="M5 12h14M13 6l6 6-6 6" />,
|
||||
vial: (
|
||||
<>
|
||||
<path d="M8 3h8M9 3v14a3 3 0 0 0 6 0V3" />
|
||||
<path d="M9 11h6" />
|
||||
</>
|
||||
),
|
||||
capsule: (
|
||||
<>
|
||||
<rect x="3" y="9" width="18" height="6" rx="3" />
|
||||
<path d="M12 9v6" />
|
||||
</>
|
||||
),
|
||||
injection: (
|
||||
<>
|
||||
<path d="M17 3l4 4M15 5l4 4M8 12l7-7 3 3-7 7-3.5.5z" />
|
||||
<path d="M8 12l-5 5M4 16l3 3" />
|
||||
</>
|
||||
),
|
||||
trend: (
|
||||
<>
|
||||
<path d="M3 17l6-6 4 4 8-8" />
|
||||
<path d="M14 7h7v7" />
|
||||
</>
|
||||
),
|
||||
fridge: (
|
||||
<>
|
||||
<rect x="5" y="3" width="14" height="18" rx="2" />
|
||||
<path d="M5 10h14" />
|
||||
<path d="M8 6v2M8 13v4" />
|
||||
</>
|
||||
),
|
||||
box: (
|
||||
<>
|
||||
<path d="M3 7l9-4 9 4v10l-9 4-9-4z" />
|
||||
<path d="M3 7l9 4 9-4M12 11v10" />
|
||||
</>
|
||||
),
|
||||
yen: <path d="M5 4l7 9 7-9M7 13h10M7 17h10M12 13v7" />,
|
||||
zap: <path d="M13 2L4 14h7l-1 8 9-12h-7z" />,
|
||||
};
|
||||
|
||||
const svgProps: Omit<SVGProps<SVGSVGElement>, 'width' | 'height'> = {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.6,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
};
|
||||
|
||||
export function Icon({ name, size = 18, className = '', style }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
{...svgProps}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
style={{ display: 'inline-block', flexShrink: 0, ...style }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{PATHS[name] ?? null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
50
packages/web/src/components/ui/IconButton.tsx
Normal file
50
packages/web/src/components/ui/IconButton.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
import type { IconName } from './Icon';
|
||||
|
||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: IconName;
|
||||
dot?: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function IconButton({ icon, dot = false, label, className = '', ...rest }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={className}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--ink-muted)',
|
||||
position: 'relative',
|
||||
transition: 'all 0.1s',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{dot && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 6,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--danger)',
|
||||
border: '1.5px solid var(--bg-elev)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
29
packages/web/src/components/ui/Pill.tsx
Normal file
29
packages/web/src/components/ui/Pill.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { ReactNode, CSSProperties } from 'react';
|
||||
|
||||
type Tone = 'ok' | 'warn' | 'danger' | 'info' | 'ghost' | 'outline';
|
||||
|
||||
interface PillProps {
|
||||
tone?: Tone;
|
||||
children: ReactNode;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const TONE_STYLES: Record<Tone, string> = {
|
||||
ok: 'bg-[var(--ok-soft)] text-[var(--ok)]',
|
||||
warn: 'bg-[var(--warn-soft)] text-[var(--warn)]',
|
||||
danger: 'bg-[var(--danger-soft)] text-[var(--danger)]',
|
||||
info: 'bg-[var(--info-soft)] text-[var(--info)]',
|
||||
ghost: 'bg-[var(--bg-inset)] text-[var(--ink-muted)]',
|
||||
outline: 'bg-transparent text-[var(--ink-muted)] border border-[var(--border)]',
|
||||
};
|
||||
|
||||
export function Pill({ tone = 'ghost', children, style }: PillProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium leading-relaxed ${TONE_STYLES[tone]}`}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
71
packages/web/src/components/ui/Ring.tsx
Normal file
71
packages/web/src/components/ui/Ring.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
interface RingProps {
|
||||
/** Numerator (taken) */
|
||||
value: number;
|
||||
/** Denominator (total) */
|
||||
total: number;
|
||||
/** Radius of the ring (SVG units) */
|
||||
radius?: number;
|
||||
/** Stroke width */
|
||||
strokeWidth?: number;
|
||||
/** Rendered pixel size */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Ring({ value, total, radius = 52, strokeWidth = 10, size = 120 }: RingProps) {
|
||||
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference * (1 - pct / 100);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 120 120"
|
||||
width={size}
|
||||
height={size}
|
||||
aria-label={`${value} of ${total} doses taken`}
|
||||
>
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--bg-inset)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--brand)"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: 'stroke-dashoffset 0.4s' }}
|
||||
/>
|
||||
<text
|
||||
x="60"
|
||||
y="56"
|
||||
textAnchor="middle"
|
||||
fill="var(--ink-strong)"
|
||||
style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 500 }}
|
||||
>
|
||||
{value}
|
||||
<tspan fill="var(--ink-muted)" fontSize="14">
|
||||
/{total}
|
||||
</tspan>
|
||||
</text>
|
||||
<text
|
||||
x="60"
|
||||
y="74"
|
||||
textAnchor="middle"
|
||||
fill="var(--ink-muted)"
|
||||
fontSize="10"
|
||||
letterSpacing="1"
|
||||
>
|
||||
DOSES TAKEN
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
61
packages/web/src/components/ui/SparkBars.tsx
Normal file
61
packages/web/src/components/ui/SparkBars.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
interface SpendBar {
|
||||
label: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface SparkBarsProps {
|
||||
data: SpendBar[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function SparkBars({ data, height = 140 }: SparkBarsProps) {
|
||||
const max = Math.max(...data.map((d) => d.amount), 1);
|
||||
const svgWidth = 20 + data.length * 56;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${svgWidth} ${height + 20}`}
|
||||
width="100%"
|
||||
height={height + 20}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const barHeight = (d.amount / max) * (height - 20);
|
||||
const x = 20 + i * 56;
|
||||
const isLast = i === data.length - 1;
|
||||
return (
|
||||
<g key={d.label}>
|
||||
<rect
|
||||
x={x}
|
||||
y={height - barHeight}
|
||||
width="36"
|
||||
height={barHeight}
|
||||
rx="3"
|
||||
fill={isLast ? 'var(--brand)' : 'var(--brand-soft)'}
|
||||
stroke={isLast ? 'var(--brand-deep)' : 'none'}
|
||||
/>
|
||||
<text
|
||||
x={x + 18}
|
||||
y={height + 15}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill="var(--ink-faint)"
|
||||
>
|
||||
{d.label}
|
||||
</text>
|
||||
<text
|
||||
x={x + 18}
|
||||
y={height - barHeight - 4}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill={isLast ? 'var(--ink)' : 'var(--ink-muted)'}
|
||||
fontWeight={isLast ? 600 : 400}
|
||||
>
|
||||
{(d.amount / 1000).toFixed(0)}k
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
67
packages/web/src/components/ui/SupplyBar.tsx
Normal file
67
packages/web/src/components/ui/SupplyBar.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
interface SupplyBarProps {
|
||||
days: number;
|
||||
maxDays?: number;
|
||||
}
|
||||
|
||||
type Level = 'ok' | 'low' | 'critical';
|
||||
|
||||
function getLevel(days: number): Level {
|
||||
if (days <= 7) return 'critical';
|
||||
if (days <= 14) return 'low';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
const FILL_COLOR: Record<Level, string> = {
|
||||
ok: 'var(--brand)',
|
||||
low: 'var(--warn)',
|
||||
critical: 'var(--danger)',
|
||||
};
|
||||
|
||||
const NUM_COLOR: Record<Level, string> = {
|
||||
ok: 'var(--ink)',
|
||||
low: 'var(--warn)',
|
||||
critical: 'var(--danger)',
|
||||
};
|
||||
|
||||
export function SupplyBar({ days, maxDays = 60 }: SupplyBarProps) {
|
||||
const pct = Math.min(100, (days / maxDays) * 100);
|
||||
const level = getLevel(days);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
background: 'var(--bg-inset)',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${pct}%`,
|
||||
background: FILL_COLOR[level],
|
||||
borderRadius: 3,
|
||||
transition: 'width 0.3s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="num"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 2,
|
||||
minWidth: 42,
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: NUM_COLOR[level] }}>{days}</span>
|
||||
<span style={{ color: 'var(--ink-muted)', fontSize: 10 }}>d</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
packages/web/src/components/ui/index.ts
Normal file
10
packages/web/src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export { Icon } from './Icon';
|
||||
export type { IconName } from './Icon';
|
||||
export { Card, CardHeader, CardBody } from './Card';
|
||||
export { Button } from './Button';
|
||||
export { Pill } from './Pill';
|
||||
export { SupplyBar } from './SupplyBar';
|
||||
export { Ring } from './Ring';
|
||||
export { SparkBars } from './SparkBars';
|
||||
export { IconButton } from './IconButton';
|
||||
export { Avatar } from './Avatar';
|
||||
|
|
@ -6,8 +6,12 @@ const { mockUseSession, mockUseSWR, mockApiClient } = vi.hoisted(() => ({
|
|||
mockUseSWR: vi.fn(),
|
||||
mockApiClient: {
|
||||
_accessToken: null as string | null,
|
||||
set accessToken(token: string) { this._accessToken = token; },
|
||||
get hasToken() { return this._accessToken !== null; },
|
||||
set accessToken(token: string) {
|
||||
this._accessToken = token;
|
||||
},
|
||||
get hasToken() {
|
||||
return this._accessToken !== null;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import type { apiClient as ApiClientType } from '../api-client';
|
||||
|
||||
// Create a fresh ApiClient for each test by re-importing
|
||||
let apiClient: typeof import('../api-client').apiClient;
|
||||
let apiClient: typeof ApiClientType;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ describe('cabinet-events service', () => {
|
|||
|
||||
it('listCabinetEvents builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listCabinetEvents('hh1', { medicineId: 'med-1', eventType: 'dispense', startDate: '2026-01-01', endDate: '2026-02-01' });
|
||||
await listCabinetEvents('hh1', {
|
||||
medicineId: 'med-1',
|
||||
eventType: 'dispense',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-02-01',
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('medicineId=med-1'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('eventType=dispense'));
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('startDate=2026-01-01'));
|
||||
|
|
|
|||
|
|
@ -12,8 +12,14 @@ vi.mock('../api-client', () => ({
|
|||
}));
|
||||
|
||||
import {
|
||||
listCabinetItems, getCabinetItem, getCabinetSummary, getExpiringSoon,
|
||||
createCabinetItem, updateCabinetItem, adjustCabinetItemQuantity, deleteCabinetItem,
|
||||
listCabinetItems,
|
||||
getCabinetItem,
|
||||
getCabinetSummary,
|
||||
getExpiringSoon,
|
||||
createCabinetItem,
|
||||
updateCabinetItem,
|
||||
adjustCabinetItemQuantity,
|
||||
deleteCabinetItem,
|
||||
} from '../cabinet';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
|
@ -80,7 +86,10 @@ describe('cabinet service', () => {
|
|||
it('adjustCabinetItemQuantity calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'ci-1' });
|
||||
await adjustCabinetItemQuantity('hh1', 'ci-1', { adjustment: -5, reason: 'used' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1/adjust', { adjustment: -5, reason: 'used' });
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/cabinet/ci-1/adjust', {
|
||||
adjustment: -5,
|
||||
reason: 'used',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteCabinetItem calls DELETE', async () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ vi.mock('../api-client', () => ({
|
|||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch },
|
||||
}));
|
||||
|
||||
import { createHousehold, getHousehold, updateHousehold, generateInviteCode, joinHousehold } from '../households';
|
||||
import {
|
||||
createHousehold,
|
||||
getHousehold,
|
||||
updateHousehold,
|
||||
generateInviteCode,
|
||||
joinHousehold,
|
||||
} from '../households';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@ vi.mock('../api-client', () => ({
|
|||
}));
|
||||
|
||||
import {
|
||||
listMedicines, getMedicine, createMedicine, updateMedicine, deleteMedicine,
|
||||
listMedicineProducts, createMedicineProduct, updateMedicineProduct, deleteMedicineProduct,
|
||||
listMedicines,
|
||||
getMedicine,
|
||||
createMedicine,
|
||||
updateMedicine,
|
||||
deleteMedicine,
|
||||
listMedicineProducts,
|
||||
createMedicineProduct,
|
||||
updateMedicineProduct,
|
||||
deleteMedicineProduct,
|
||||
} from '../medicines';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
|
@ -77,13 +84,17 @@ describe('medicines service', () => {
|
|||
it('createMedicineProduct calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'mp-1' });
|
||||
await createMedicineProduct('hh1', 'med-1', { brand: 'Bayer' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/medicines/med-1/products', { brand: 'Bayer' });
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/medicines/med-1/products', {
|
||||
brand: 'Bayer',
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMedicineProduct uses medicine-products path', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'mp-1' });
|
||||
await updateMedicineProduct('hh1', 'mp-1', { brand: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/medicine-products/mp-1', { brand: 'Updated' });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/medicine-products/mp-1', {
|
||||
brand: 'Updated',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteMedicineProduct calls DELETE', async () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ vi.mock('../api-client', () => ({
|
|||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import { listPurchases, getPurchase, createPurchase, updatePurchase, receivePurchase, deletePurchase } from '../purchases';
|
||||
import {
|
||||
listPurchases,
|
||||
getPurchase,
|
||||
createPurchase,
|
||||
updatePurchase,
|
||||
receivePurchase,
|
||||
deletePurchase,
|
||||
} from '../purchases';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,13 @@ vi.mock('../api-client', () => ({
|
|||
}));
|
||||
|
||||
import {
|
||||
getRefillAlerts, listRefillLists, createRefillList, getRefillList,
|
||||
updateRefillList, updateRefillListItem, addToCabinet,
|
||||
getRefillAlerts,
|
||||
listRefillLists,
|
||||
createRefillList,
|
||||
getRefillList,
|
||||
updateRefillList,
|
||||
updateRefillListItem,
|
||||
addToCabinet,
|
||||
} from '../refills';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
|
@ -60,16 +65,17 @@ describe('refills service', () => {
|
|||
it('updateRefillList calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'rl-1' });
|
||||
await updateRefillList('hh1', 'rl-1', { name: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1', { name: 'Updated' });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1', {
|
||||
name: 'Updated',
|
||||
});
|
||||
});
|
||||
|
||||
it('updateRefillListItem calls PATCH with nested path', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'rl-1' });
|
||||
await updateRefillListItem('hh1', 'rl-1', 'item-1', { purchased: true } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/households/hh1/refills/lists/rl-1/items/item-1',
|
||||
{ purchased: true },
|
||||
);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/refills/lists/rl-1/items/item-1', {
|
||||
purchased: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('addToCabinet calls POST with empty body', async () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ vi.mock('../api-client', () => ({
|
|||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import { listRegimens, getRegimen, getBurnRates, createRegimen, updateRegimen, deleteRegimen } from '../regimens';
|
||||
import {
|
||||
listRegimens,
|
||||
getRegimen,
|
||||
getBurnRates,
|
||||
createRegimen,
|
||||
updateRegimen,
|
||||
deleteRegimen,
|
||||
} from '../regimens';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ export async function recordPrice(
|
|||
export async function getPriceHistory(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
query?: { storeId?: string; startDate?: string; endDate?: string; cursor?: string; limit?: number },
|
||||
query?: {
|
||||
storeId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<MedicinePriceHistoryResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.storeId) params.set('storeId', query.storeId);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ export async function previewFill(
|
|||
householdId: string,
|
||||
data: OrganizerPreviewInput,
|
||||
): Promise<OrganizerPreviewResponse> {
|
||||
return apiClient.post<OrganizerPreviewResponse>(`/households/${householdId}/organizer/preview`, data);
|
||||
return apiClient.post<OrganizerPreviewResponse>(
|
||||
`/households/${householdId}/organizer/preview`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeFill(
|
||||
|
|
@ -47,7 +50,10 @@ export async function executeFill(
|
|||
return apiClient.post<OrganizerFillResponse>(`/households/${householdId}/organizer/fill`, data);
|
||||
}
|
||||
|
||||
export async function undoFill(householdId: string, fillId: string): Promise<OrganizerFillResponse> {
|
||||
export async function undoFill(
|
||||
householdId: string,
|
||||
fillId: string,
|
||||
): Promise<OrganizerFillResponse> {
|
||||
return apiClient.post<OrganizerFillResponse>(
|
||||
`/households/${householdId}/organizer/fills/${fillId}/undo`,
|
||||
{},
|
||||
|
|
|
|||
|
|
@ -29,10 +29,7 @@ export async function listPurchases(
|
|||
);
|
||||
}
|
||||
|
||||
export async function getPurchase(
|
||||
householdId: string,
|
||||
id: string,
|
||||
): Promise<PurchaseResponse> {
|
||||
export async function getPurchase(householdId: string, id: string): Promise<PurchaseResponse> {
|
||||
return apiClient.get<PurchaseResponse>(`/households/${householdId}/purchases/${id}`);
|
||||
}
|
||||
|
||||
|
|
@ -48,10 +45,7 @@ export async function updatePurchase(
|
|||
id: string,
|
||||
data: UpdatePurchaseInput,
|
||||
): Promise<PurchaseResponse> {
|
||||
return apiClient.patch<PurchaseResponse>(
|
||||
`/households/${householdId}/purchases/${id}`,
|
||||
data,
|
||||
);
|
||||
return apiClient.patch<PurchaseResponse>(`/households/${householdId}/purchases/${id}`, data);
|
||||
}
|
||||
|
||||
export async function receivePurchase(
|
||||
|
|
|
|||
|
|
@ -49,16 +49,10 @@ export async function createRefillList(
|
|||
householdId: string,
|
||||
data: CreateRefillListInput,
|
||||
): Promise<RefillListResponse> {
|
||||
return apiClient.post<RefillListResponse>(
|
||||
`/households/${householdId}/refills/lists`,
|
||||
data,
|
||||
);
|
||||
return apiClient.post<RefillListResponse>(`/households/${householdId}/refills/lists`, data);
|
||||
}
|
||||
|
||||
export async function getRefillList(
|
||||
householdId: string,
|
||||
id: string,
|
||||
): Promise<RefillListResponse> {
|
||||
export async function getRefillList(householdId: string, id: string): Promise<RefillListResponse> {
|
||||
return apiClient.get<RefillListResponse>(`/households/${householdId}/refills/lists/${id}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ export async function listRegimens(
|
|||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<RegimenListResponse>(`/households/${householdId}/regimens${qs ? `?${qs}` : ''}`);
|
||||
return apiClient.get<RegimenListResponse>(
|
||||
`/households/${householdId}/regimens${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRegimen(householdId: string, id: string): Promise<RegimenResponse> {
|
||||
|
|
@ -34,7 +36,10 @@ export async function getBurnRates(householdId: string): Promise<BurnRateRespons
|
|||
return apiClient.get<BurnRateResponse>(`/households/${householdId}/regimens/burn-rate`);
|
||||
}
|
||||
|
||||
export async function createRegimen(householdId: string, data: CreateRegimenInput): Promise<RegimenResponse> {
|
||||
export async function createRegimen(
|
||||
householdId: string,
|
||||
data: CreateRegimenInput,
|
||||
): Promise<RegimenResponse> {
|
||||
return apiClient.post<RegimenResponse>(`/households/${householdId}/regimens`, data);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,7 @@ export async function listStores(
|
|||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<StoreListResponse>(
|
||||
`/households/${householdId}/stores${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
return apiClient.get<StoreListResponse>(`/households/${householdId}/stores${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getStore(householdId: string, id: string): Promise<StoreResponse> {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,748 @@
|
|||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #f0fdf4;
|
||||
--color-primary-100: #dcfce7;
|
||||
--color-primary-200: #bbf7d0;
|
||||
--color-primary-300: #86efac;
|
||||
--color-primary-400: #4ade80;
|
||||
--color-primary-500: #22c55e;
|
||||
--color-primary-600: #16a34a;
|
||||
--color-primary-700: #15803d;
|
||||
--color-primary-800: #166534;
|
||||
--color-primary-900: #14532d;
|
||||
/* ─── Design tokens ──────────────────────────────────────────────────────────
|
||||
Declared as CSS custom properties so they work in inline styles and SVGs.
|
||||
Tailwind v4 @theme maps them to utility classes (bg-bg, text-ink, etc.)
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Type */
|
||||
--font-sans: 'Inter Tight', -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
||||
--font-display: 'Fraunces', 'Inter Tight', serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
||||
|
||||
/* Neutrals (warm off-white → near-black) */
|
||||
--bg: #f6f4ef;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-inset: #efebe3;
|
||||
--border: #e4dfd4;
|
||||
--border-strong: #cfc8b8;
|
||||
--ink-faint: #a39b89;
|
||||
--ink-muted: #6e6759;
|
||||
--ink: #1d1b17;
|
||||
--ink-strong: #0b0a08;
|
||||
|
||||
/* Brand — sage-leaning green */
|
||||
--brand: #2f6b4a;
|
||||
--brand-deep: #1e4a32;
|
||||
--brand-ink: #ffffff;
|
||||
--brand-soft: #e6efe8;
|
||||
--brand-soft-ink: #1e4a32;
|
||||
|
||||
/* Status */
|
||||
--danger: #b8361a;
|
||||
--danger-soft: #fbe8e0;
|
||||
--warn: #a66a0a;
|
||||
--warn-soft: #f9ecd2;
|
||||
--ok: #3e7a4c;
|
||||
--ok-soft: #e3ede0;
|
||||
--info: #3a5a85;
|
||||
--info-soft: #e1e8f1;
|
||||
|
||||
/* Data viz */
|
||||
--viz-1: #2f6b4a;
|
||||
--viz-2: #a66a0a;
|
||||
--viz-3: #3a5a85;
|
||||
--viz-4: #8a4c6e;
|
||||
--viz-5: #6b6237;
|
||||
--viz-6: #7a3a28;
|
||||
|
||||
/* Shape */
|
||||
--r-xs: 6px;
|
||||
--r-sm: 10px;
|
||||
--r-md: 14px;
|
||||
--r-lg: 20px;
|
||||
--r-xl: 28px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(20, 18, 10, 0.04), 0 0 0 1px rgba(20, 18, 10, 0.04);
|
||||
--shadow-md: 0 4px 16px -6px rgba(20, 18, 10, 0.08), 0 0 0 1px rgba(20, 18, 10, 0.05);
|
||||
--shadow-lg: 0 20px 40px -20px rgba(20, 18, 10, 0.22), 0 0 0 1px rgba(20, 18, 10, 0.06);
|
||||
}
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 255, 255, 255;
|
||||
[data-theme='dark'] {
|
||||
--bg: #141310;
|
||||
--bg-elev: #1c1b17;
|
||||
--bg-inset: #100f0c;
|
||||
--border: #2a2823;
|
||||
--border-strong: #3a372f;
|
||||
--ink-faint: #6a6559;
|
||||
--ink-muted: #9a9386;
|
||||
--ink: #ece8de;
|
||||
--ink-strong: #f8f5ec;
|
||||
|
||||
--brand: #5fa87b;
|
||||
--brand-deep: #8fc9a4;
|
||||
--brand-ink: #0b0a08;
|
||||
--brand-soft: #1e2c23;
|
||||
--brand-soft-ink: #8fc9a4;
|
||||
|
||||
--danger: #e37358;
|
||||
--danger-soft: #2c1a15;
|
||||
--warn: #d6a152;
|
||||
--warn-soft: #2c2217;
|
||||
--ok: #7aba89;
|
||||
--ok-soft: #1a2820;
|
||||
--info: #7ea3cf;
|
||||
--info-soft: #1a2028;
|
||||
|
||||
--viz-1: #5fa87b;
|
||||
--viz-2: #d6a152;
|
||||
--viz-3: #7ea3cf;
|
||||
--viz-4: #c088a5;
|
||||
--viz-5: #b8ac76;
|
||||
--viz-6: #d88a74;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
--shadow-md: 0 4px 16px -6px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
--shadow-lg: 0 20px 40px -20px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* ─── Tailwind v4 theme mappings ─────────────────────────────────────────── */
|
||||
@theme {
|
||||
--font-sans: var(--font-sans);
|
||||
--font-display: var(--font-display);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
--color-bg: var(--bg);
|
||||
--color-bg-elev: var(--bg-elev);
|
||||
--color-bg-inset: var(--bg-inset);
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: var(--border-strong);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-strong: var(--ink-strong);
|
||||
--color-ink-muted: var(--ink-muted);
|
||||
--color-ink-faint: var(--ink-faint);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-deep: var(--brand-deep);
|
||||
--color-brand-soft: var(--brand-soft);
|
||||
--color-brand-soft-ink: var(--brand-soft-ink);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-soft: var(--danger-soft);
|
||||
--color-warn: var(--warn);
|
||||
--color-warn-soft: var(--warn-soft);
|
||||
--color-ok: var(--ok);
|
||||
--color-ok-soft: var(--ok-soft);
|
||||
--color-info: var(--info);
|
||||
--color-info-soft: var(--info-soft);
|
||||
|
||||
--radius-xs: var(--r-xs);
|
||||
--radius-sm: var(--r-sm);
|
||||
--radius-md: var(--r-md);
|
||||
--radius-lg: var(--r-lg);
|
||||
--radius-xl: var(--r-xl);
|
||||
}
|
||||
|
||||
/* ─── Base styles ────────────────────────────────────────────────────────── */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.005em;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--brand);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
|
||||
/* ─── Typography utilities ───────────────────────────────────────────────── */
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.serif {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─── Page content wrapper ───────────────────────────────────────────────── */
|
||||
.mt-page {
|
||||
padding: 28px 32px 56px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ─── Shared primitives (pills, segments) ───────────────────────────────── */
|
||||
.mt-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.01em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.mt-pill--brand {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-soft-ink);
|
||||
}
|
||||
.mt-pill--ok {
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
}
|
||||
.mt-pill--warn {
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
.mt-pill--danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.mt-pill--info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.mt-pill--ghost {
|
||||
background: var(--bg-inset);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.mt-seg {
|
||||
display: inline-flex;
|
||||
background: var(--bg-inset);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 0;
|
||||
}
|
||||
.mt-seg button {
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.mt-seg button.is-active {
|
||||
background: var(--bg-elev);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ─── Cabinet — summary strip ───────────────────────────────────────────── */
|
||||
.cab-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cab-summary__item {
|
||||
background: var(--bg-elev);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.cab-summary__label {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cab-summary__value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink-strong);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.cab-summary__item--danger .cab-summary__value {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cab-summary__item--warn .cab-summary__value {
|
||||
color: var(--warn);
|
||||
}
|
||||
.cab-summary__item--brand .cab-summary__value {
|
||||
color: var(--brand);
|
||||
}
|
||||
.cab-summary__hint {
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ─── Cabinet — filter rail ─────────────────────────────────────────────── */
|
||||
.cab-filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cab-filters__tabs {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
background: var(--bg-elev);
|
||||
padding: 3px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.cab-filters__tab {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.cab-filters__tab:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.cab-filters__tab.is-active {
|
||||
background: var(--ink);
|
||||
color: var(--bg-elev);
|
||||
}
|
||||
.cab-filters__count {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.cab-filters__tab.is-active .cab-filters__count {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--bg-elev);
|
||||
}
|
||||
.cab-filters__tools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.cab-filters__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 6px 10px;
|
||||
color: var(--ink-muted);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.cab-filters__search:focus-within {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.cab-filters__search input {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
width: 130px;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.cab-filters__search input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ─── Cabinet — card grid ───────────────────────────────────────────────── */
|
||||
.cab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.cab-card {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cab-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.cab-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--viz-1);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.cab-card:hover::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.cab-card--lvl-expiring::before {
|
||||
background: var(--warn);
|
||||
opacity: 1;
|
||||
}
|
||||
.cab-card--lvl-critical::before {
|
||||
background: var(--danger);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cab-card--lvl-expiring {
|
||||
border-color: color-mix(in oklab, var(--warn) 30%, var(--border));
|
||||
}
|
||||
.cab-card--lvl-critical {
|
||||
border-color: color-mix(in oklab, var(--danger) 30%, var(--border));
|
||||
}
|
||||
|
||||
.cab-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.cab-card__swatch {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--r-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in oklab, var(--c, var(--viz-1)) 14%, var(--bg-inset));
|
||||
color: var(--c, var(--viz-1));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cab-card__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cab-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-strong);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cab-card__strength {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cab-card__flag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cab-card__qty {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.cab-card__qty-num {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink-strong);
|
||||
line-height: 1;
|
||||
}
|
||||
.cab-card__qty-unit {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.cab-card__foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.cab-card__foot-label {
|
||||
font-size: 10px;
|
||||
color: var(--ink-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cab-card__foot-value {
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cab-card__foot-value--warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* ─── Expiry / days bar ─────────────────────────────────────────────────── */
|
||||
.daysbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.daysbar__track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bg-inset);
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.daysbar__fill {
|
||||
height: 100%;
|
||||
background: var(--ok);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.daysbar--low .daysbar__fill {
|
||||
background: var(--warn);
|
||||
}
|
||||
.daysbar--critical .daysbar__fill {
|
||||
background: var(--danger);
|
||||
}
|
||||
.daysbar__label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
min-width: 46px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.daysbar__num {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.daysbar__unit {
|
||||
font-size: 10px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.daysbar--low .daysbar__num {
|
||||
color: var(--warn);
|
||||
}
|
||||
.daysbar--critical .daysbar__num {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ─── Cabinet — expanded items panel ───────────────────────────────────── */
|
||||
.cab-items {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-elev);
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cab-items__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cab-items__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* ─── Cabinet — detail list view ────────────────────────────────────────── */
|
||||
.cab-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-elev);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cab-list__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cab-list__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.cab-list__row:hover {
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
/* ─── Form field base ───────────────────────────────────────────────────── */
|
||||
.mt-field {
|
||||
width: 100%;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.mt-field:focus {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.mt-field::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.mt-field:disabled {
|
||||
background: var(--bg-inset);
|
||||
color: var(--ink-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mt-field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* ─── Buttons ───────────────────────────────────────────────────────────── */
|
||||
.mt-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.12s;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.mt-btn--primary {
|
||||
background: var(--brand);
|
||||
color: var(--brand-ink);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.mt-btn--primary:hover {
|
||||
background: var(--brand-deep);
|
||||
border-color: var(--brand-deep);
|
||||
}
|
||||
.mt-btn--primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mt-btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--ink-muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.mt-btn--ghost:hover {
|
||||
background: var(--bg-inset);
|
||||
color: var(--ink);
|
||||
}
|
||||
.mt-btn--icon {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
color: var(--ink-muted);
|
||||
border-color: var(--border);
|
||||
border-radius: var(--r-xs);
|
||||
}
|
||||
.mt-btn--icon:hover {
|
||||
background: var(--bg-inset);
|
||||
color: var(--ink);
|
||||
}
|
||||
.mt-btn--danger-icon {
|
||||
padding: 5px;
|
||||
background: transparent;
|
||||
color: var(--ink-faint);
|
||||
border-radius: var(--r-xs);
|
||||
}
|
||||
.mt-btn--danger-icon:hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.mt-btn--danger-ghost {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.mt-btn--danger-ghost:hover {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
/* ─── Cards ──────────────────────────────────────────────────────────────── */
|
||||
.mt-card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ─── Alerts ─────────────────────────────────────────────────────────────── */
|
||||
.mt-alert {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.mt-alert--danger {
|
||||
background: var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ─── Links ──────────────────────────────────────────────────────────────── */
|
||||
.mt-link {
|
||||
color: var(--brand);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.mt-link:hover {
|
||||
color: var(--brand-deep);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue