Implement medicine library and cabinet

This commit is contained in:
Aerilyn Weber 2026-03-28 08:19:48 +09:00
parent db79af06f7
commit 1f66fab30f
72 changed files with 7642 additions and 319 deletions

View file

@ -1,7 +1,28 @@
import type { NextConfig } from 'next';
import path from 'path';
const sharedSrc = path.resolve(import.meta.dirname, '../shared/src');
const nextConfig: NextConfig = {
transpilePackages: ['@meshitrack/shared'],
turbopack: {},
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.
config.resolve = config.resolve ?? {};
config.resolve.extensionAlias = {
...config.resolve.extensionAlias,
'.js': ['.ts', '.js'],
};
// Ensure the shared source directory is included in the module resolution
config.resolve.alias = {
...config.resolve.alias,
'@meshitrack/shared': sharedSrc,
};
return config;
},
};
export default nextConfig;

View file

@ -6,29 +6,9 @@ export default function DashboardPage() {
<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="Product Library"
description="Manage your food products and nutrition data"
href="/products"
/>
<DashboardCard
title="Recipes"
description="Create and manage recipes with auto-nutrition"
href="/recipes"
/>
<DashboardCard
title="Pantry"
description="Track what's in your fridge and pantry"
href="/pantry"
/>
<DashboardCard
title="Meal Plans"
description="Plan your weekly meals and hit nutrition targets"
href="/meal-plans"
/>
<DashboardCard
title="Shopping Lists"
description="Create shopping lists and track prices"
href="/shopping"
title="Medicines"
description="Manage your medicines, products and inventory"
href="/medicines"
/>
<DashboardCard
title="Settings"

View file

@ -0,0 +1,692 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import {
listCabinetItems,
getCabinetSummary,
createCabinetItem,
adjustCabinetItemQuantity,
deleteCabinetItem,
} from '@/services/cabinet';
import { listMedicines } from '@/services/medicines';
import {
DosageUnit,
CabinetItemStatus,
allowedUnitsForForm,
defaultUnitForForm,
} from '@meshitrack/shared';
import type { MedicineForm, CreateCabinetItemInput } from '@meshitrack/shared';
type CabinetItem = {
_id: string;
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
medicineProductId?: string;
medicineProductBrand?: string;
concentration?: number;
concentrationUnit?: string;
quantity: number;
unit: string;
expirationDate?: string;
status: string;
notes?: string;
};
type SummaryItem = {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
totalQuantity: number;
unit: string;
earliestExpiry: string | null;
itemCount: number;
};
type MedicineOption = {
_id: string;
name: string;
strength: number;
strengthUnit: string;
form: string;
};
const FORM_LABELS: Record<string, string> = {
tablet: 'Tablet',
capsule: 'Capsule',
liquid: 'Liquid',
injection: 'Injection',
other: 'Other',
};
const UNIT_LABELS: Record<string, string> = {
tablet: 'tablets',
capsule: 'capsules',
ml: 'mL',
vial: 'vials',
dose: 'doses',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
depleted: 'Depleted',
expired: 'Expired',
};
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-700',
depleted: 'bg-gray-100 text-gray-600',
expired: 'bg-red-100 text-red-700',
};
function getExpiryColor(expirationDate?: string): string {
if (!expirationDate) return '';
const now = new Date();
const expiry = new Date(expirationDate);
const daysUntil = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntil <= 0) return 'text-red-600 font-semibold';
if (daysUntil <= 7) return 'text-red-500';
if (daysUntil <= 30) return 'text-yellow-600';
return 'text-green-600';
}
function formatDate(dateStr?: string): string {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString();
}
function daysUntilExpiry(dateStr?: string): string {
if (!dateStr) return '';
const now = new Date();
const expiry = new Date(dateStr);
const days = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (days <= 0) return '(expired)';
if (days === 1) return '(1 day)';
return `(${days} days)`;
}
export function CabinetTab({ householdId }: { householdId: string }) {
const [view, setView] = useState<'summary' | 'detail'>('summary');
const [summaryItems, setSummaryItems] = useState<SummaryItem[]>([]);
const [cabinetItems, setCabinetItems] = useState<CabinetItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [filterStatus, setFilterStatus] = useState('');
const [expandedMedicine, setExpandedMedicine] = useState<string | null>(null);
const [expandedItems, setExpandedItems] = useState<CabinetItem[]>([]);
const [expandLoading, setExpandLoading] = useState(false);
const fetchData = useCallback(async () => {
if (!householdId) return;
setLoading(true);
try {
if (view === 'summary') {
const result = await getCabinetSummary(householdId);
setSummaryItems(result.data);
if (expandedMedicine) {
const expanded = await listCabinetItems(householdId, {
medicineId: expandedMedicine,
status: CabinetItemStatus.ACTIVE,
limit: 50,
});
setExpandedItems(expanded.data);
if (expanded.data.length === 0) {
setExpandedMedicine(null);
}
}
} else {
const result = await listCabinetItems(householdId, {
status: (filterStatus as CabinetItemStatus) || undefined,
limit: 50,
});
setCabinetItems(result.data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load cabinet');
} finally {
setLoading(false);
}
}, [householdId, view, filterStatus, expandedMedicine]);
useEffect(() => {
if (householdId) {
fetchData();
}
}, [householdId, fetchData]);
async function handleExpand(medicineId: string) {
if (!householdId) return;
if (expandedMedicine === medicineId) {
setExpandedMedicine(null);
setExpandedItems([]);
return;
}
setExpandedMedicine(medicineId);
setExpandLoading(true);
try {
const result = await listCabinetItems(householdId, {
medicineId,
status: CabinetItemStatus.ACTIVE,
limit: 50,
});
setExpandedItems(result.data);
} catch {
setExpandedItems([]);
} finally {
setExpandLoading(false);
}
}
async function handleAdjust(itemId: string, delta: number) {
if (!householdId) return;
try {
await adjustCabinetItemQuantity(householdId, itemId, { delta });
fetchData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to adjust quantity');
}
}
async function handleDelete(itemId: string) {
if (!householdId || !confirm('Delete this item permanently?')) return;
try {
await deleteCabinetItem(householdId, itemId);
fetchData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
}
return (
<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"
>
{showForm ? 'Cancel' : 'Add to Cabinet'}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{showForm && (
<AddToCabinetForm
householdId={householdId}
onCreated={() => {
setShowForm(false);
fetchData();
}}
onCancel={() => setShowForm(false)}
/>
)}
<div className="mb-4 flex items-center gap-3">
<div className="flex rounded-lg border overflow-hidden">
<button
onClick={() => setView('summary')}
className={`px-4 py-2 text-sm font-medium transition-colors ${view === 'summary' ? 'bg-primary-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}
>
Summary
</button>
<button
onClick={() => setView('detail')}
className={`px-4 py-2 text-sm font-medium transition-colors ${view === 'detail' ? 'bg-primary-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}
>
All Items
</button>
</div>
{view === 'detail' && (
<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"
>
<option value="">All Statuses</option>
{Object.values(CabinetItemStatus).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</option>
))}
</select>
)}
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl border bg-white p-4 h-20" />
))}
</div>
) : view === 'summary' ? (
<SummaryView
items={summaryItems}
expandedMedicine={expandedMedicine}
expandedItems={expandedItems}
expandLoading={expandLoading}
onExpand={handleExpand}
onAdjust={handleAdjust}
onDelete={handleDelete}
/>
) : (
<DetailView items={cabinetItems} onAdjust={handleAdjust} onDelete={handleDelete} />
)}
</div>
);
}
function SummaryView({
items,
expandedMedicine,
expandedItems,
expandLoading,
onExpand,
onAdjust,
onDelete,
}: {
items: SummaryItem[];
expandedMedicine: string | null;
expandedItems: CabinetItem[];
expandLoading: boolean;
onExpand: (id: string) => void;
onAdjust: (id: string, delta: number) => void;
onDelete: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
Your cabinet is empty. Add medicines above.
</div>
);
}
return (
<div className="space-y-3">
{items.map((item) => (
<div key={item.medicineId}>
<button
onClick={() => onExpand(item.medicineId)}
className="w-full rounded-xl border bg-white p-4 shadow-sm text-left hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900">{item.medicineName}</h3>
<p className="text-sm text-gray-500">
{item.medicineStrength} {item.medicineStrengthUnit}{' '}
{FORM_LABELS[item.medicineForm] ?? item.medicineForm}
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-lg font-semibold text-gray-900">
{item.totalQuantity} {UNIT_LABELS[item.unit] ?? item.unit}
</p>
<p className="text-xs text-gray-500">
{item.itemCount} {item.itemCount === 1 ? 'item' : 'items'}
</p>
</div>
{item.earliestExpiry && (
<div className={`text-right text-sm ${getExpiryColor(item.earliestExpiry)}`}>
<p>Exp: {formatDate(item.earliestExpiry)}</p>
<p className="text-xs">{daysUntilExpiry(item.earliestExpiry)}</p>
</div>
)}
<svg
className={`h-5 w-5 text-gray-400 transition-transform ${expandedMedicine === item.medicineId ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</button>
{expandedMedicine === item.medicineId && (
<div className="ml-6 mt-2 space-y-2">
{expandLoading ? (
<div className="animate-pulse rounded-lg border bg-gray-50 p-3 h-14" />
) : expandedItems.length === 0 ? (
<p className="text-sm text-gray-500 p-2">No active items</p>
) : (
expandedItems.map((ci) => (
<CabinetItemCard key={ci._id} item={ci} onAdjust={onAdjust} onDelete={onDelete} />
))
)}
</div>
)}
</div>
))}
</div>
);
}
function DetailView({
items,
onAdjust,
onDelete,
}: {
items: CabinetItem[];
onAdjust: (id: string, delta: number) => void;
onDelete: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
No items match your filter.
</div>
);
}
return (
<div className="space-y-3">
{items.map((item) => (
<CabinetItemCard
key={item._id}
item={item}
showMedicineName
onAdjust={onAdjust}
onDelete={onDelete}
/>
))}
</div>
);
}
function CabinetItemCard({
item,
showMedicineName = false,
onAdjust,
onDelete,
}: {
item: CabinetItem;
showMedicineName?: boolean;
onAdjust: (id: string, delta: number) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="rounded-lg border bg-white p-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
{showMedicineName && <h4 className="font-medium text-gray-900">{item.medicineName}</h4>}
<div className="flex items-center gap-2 text-sm text-gray-600">
{showMedicineName && (
<span>
{item.medicineStrength} {item.medicineStrengthUnit}{' '}
{FORM_LABELS[item.medicineForm] ?? item.medicineForm}
</span>
)}
{item.medicineProductBrand && (
<span className="text-gray-400">({item.medicineProductBrand})</span>
)}
{item.concentration != null && item.concentrationUnit && (
<span className="text-gray-500">
{item.concentration} {item.concentrationUnit}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-sm">
<span className="font-medium">
{item.quantity} {UNIT_LABELS[item.unit] ?? item.unit}
</span>
{item.expirationDate && (
<span className={getExpiryColor(item.expirationDate)}>
Exp: {formatDate(item.expirationDate)} {daysUntilExpiry(item.expirationDate)}
</span>
)}
</div>
{item.notes && <p className="text-xs text-gray-400 mt-1">{item.notes}</p>}
</div>
<div className="flex items-center gap-2 ml-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[item.status] ?? STATUS_COLORS['active']}`}
>
{STATUS_LABELS[item.status] ?? item.status}
</span>
{item.status === 'active' && (
<div className="flex items-center gap-1">
<button
onClick={() => onAdjust(item._id, -1)}
className="rounded border px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 transition-colors"
title="Take 1"
>
-1
</button>
<button
onClick={() => onAdjust(item._id, 1)}
className="rounded border px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 transition-colors"
title="Add 1"
>
+1
</button>
</div>
)}
<button
onClick={() => onDelete(item._id)}
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Delete"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
);
}
function AddToCabinetForm({
householdId,
onCreated,
onCancel,
}: {
householdId: string;
onCreated: () => void;
onCancel: () => void;
}) {
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
const [formData, setFormData] = useState<CreateCabinetItemInput>({
medicineId: '',
quantity: 0,
unit: DosageUnit.TABLET,
});
const [expirationDate, setExpirationDate] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [medicineSearch, setMedicineSearch] = useState('');
useEffect(() => {
async function fetchMedicines() {
try {
const result = await listMedicines(householdId, {
q: medicineSearch || undefined,
limit: 50,
});
setMedicines(result.data);
} catch {
/* empty */
}
}
fetchMedicines();
}, [householdId, medicineSearch]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
if (!formData.medicineId) {
setError('Please select a medicine');
return;
}
setSubmitting(true);
try {
const payload: CreateCabinetItemInput = {
...formData,
quantity: Number(formData.quantity),
};
if (expirationDate) {
payload.expirationDate = new Date(expirationDate).toISOString();
}
if (notes.trim()) {
payload.notes = notes.trim();
}
await createCabinetItem(householdId, payload);
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add item');
} finally {
setSubmitting(false);
}
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Add to Cabinet</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">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"
/>
<select
value={formData.medicineId}
onChange={(e) => {
const selectedMed = medicines.find((m) => m._id === e.target.value);
const medForm = selectedMed?.form as MedicineForm | undefined;
const defUnit = medForm ? defaultUnitForForm(medForm) : DosageUnit.TABLET;
setFormData({ ...formData, medicineId: e.target.value, unit: defUnit });
}}
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"
required
>
<option value="">Select a medicine</option>
{medicines.map((med) => (
<option key={med._id} value={med._id}>
{med.name} ({med.strength} {med.strengthUnit}, {FORM_LABELS[med.form] ?? med.form})
</option>
))}
</select>
{medicines.length === 0 && (
<p className="text-xs text-gray-400 mt-1">
No medicines found.{' '}
<Link href="/medicines" className="text-primary-600 underline">
Add medicines first
</Link>
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
<input
type="number"
required
min={0}
step="any"
value={formData.quantity || ''}
onChange={(e) => setFormData({ ...formData, quantity: Number(e.target.value) })}
placeholder="30"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
<select
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: 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"
>
{(() => {
const selectedMed = medicines.find((m) => m._id === formData.medicineId);
const units = selectedMed
? allowedUnitsForForm(selectedMed.form as MedicineForm)
: Object.values(DosageUnit);
return units.map((u) => (
<option key={u} value={u}>
{UNIT_LABELS[u] ?? u}
</option>
));
})()}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Expiration Date (optional)
</label>
<input
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(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"
/>
</div>
<div>
<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 about this item"
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"
/>
</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"
>
{submitting ? 'Adding...' : 'Add to Cabinet'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,358 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { listMedicines, createMedicine, deleteMedicine } from '@/services/medicines';
import { MedicineForm, StrengthUnit, MedicineCategory } from '@meshitrack/shared';
import type { CreateMedicineInput } from '@meshitrack/shared';
type Medicine = {
_id: string;
name: string;
form: string;
strength: number;
strengthUnit: string;
category: string;
tags: string[];
};
const FORM_LABELS: Record<string, string> = {
tablet: 'Tablet',
capsule: 'Capsule',
liquid: 'Liquid',
injection: 'Injection',
other: 'Other',
};
const CATEGORY_LABELS: Record<string, string> = {
prescription: 'Prescription',
otc: 'OTC',
supplement: 'Supplement',
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',
};
export function LibraryTab({ householdId }: { householdId: string }) {
const [medicines, setMedicines] = useState<Medicine[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [search, setSearch] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [filterForm, setFilterForm] = useState('');
const fetchMedicines = useCallback(async () => {
if (!householdId) return;
setLoading(true);
try {
const result = await listMedicines(householdId, {
q: search || undefined,
category: filterCategory || undefined,
form: filterForm || undefined,
limit: 50,
});
setMedicines(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load medicines');
} finally {
setLoading(false);
}
}, [householdId, search, filterCategory, filterForm]);
useEffect(() => {
if (householdId) {
fetchMedicines();
}
}, [householdId, fetchMedicines]);
async function handleDelete(id: string, name: string) {
if (!householdId || !confirm(`Delete "${name}"?`)) return;
try {
await deleteMedicine(householdId, id);
setMedicines((prev) => prev.filter((m) => m._id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
}
return (
<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"
>
{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">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{showForm && (
<CreateMedicineForm
householdId={householdId}
onCreated={() => {
setShowForm(false);
fetchMedicines();
}}
onCancel={() => setShowForm(false)}
/>
)}
<div className="mb-4 flex flex-wrap items-center gap-3">
<input
type="text"
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"
/>
<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"
>
<option value="">All Categories</option>
{Object.values(MedicineCategory).map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c] ?? c}
</option>
))}
</select>
<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"
>
<option value="">All Forms</option>
{Object.values(MedicineForm).map((f) => (
<option key={f} value={f}>
{FORM_LABELS[f] ?? f}
</option>
))}
</select>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl border bg-white p-4 h-20" />
))}
</div>
) : medicines.length === 0 ? (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
{search || filterCategory || filterForm
? 'No medicines found matching your filters.'
: 'No medicines yet. Add your first one above.'}
</div>
) : (
<div className="space-y-3">
{medicines.map((med) => (
<div
key={med._id}
className="rounded-xl border bg-white p-4 shadow-sm flex items-center justify-between"
>
<Link href={`/medicines/${med._id}`} className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div>
<h3 className="font-medium text-gray-900">{med.name}</h3>
<p className="text-sm text-gray-500">
{med.strength} {med.strengthUnit} {FORM_LABELS[med.form] ?? med.form}
</p>
</div>
</div>
</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']}`}
>
{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"
title="Delete"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
function CreateMedicineForm({
householdId,
onCreated,
onCancel,
}: {
householdId: string;
onCreated: () => void;
onCancel: () => void;
}) {
const [formData, setFormData] = useState<CreateMedicineInput>({
name: '',
form: MedicineForm.TABLET,
strength: 0,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.OTC,
tags: [],
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await createMedicine(householdId, {
...formData,
name: formData.name.trim(),
strength: Number(formData.strength),
});
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create medicine');
} finally {
setSubmitting(false);
}
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<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>
)}
<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>
<input
type="text"
required
maxLength={200}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">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"
>
{Object.values(MedicineForm).map((f) => (
<option key={f} value={f}>
{FORM_LABELS[f] ?? f}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Strength</label>
<input
type="number"
required
min={0.01}
step="any"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">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"
>
{Object.values(StrengthUnit).map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">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"
>
{Object.values(MedicineCategory).map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c] ?? c}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">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"
/>
</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"
>
{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"
>
Cancel
</button>
</div>
</form>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { CabinetTab } from '../CabinetTab';
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" />
</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>
</div>
</div>
);
}
return <CabinetTab householdId={householdId} />;
}

View file

@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { LibraryTab } from '../LibraryTab';
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>
);
}
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>
);
}
return <LibraryTab householdId={householdId} />;
}

View file

@ -0,0 +1,79 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
export default function MedicinesPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return <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>
</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"
/>
</div>
</div>
);
}
function SectionCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
return (
<Link
href={href}
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{description}</p>
</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 className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
</div>
</div>
);
}

View file

@ -1,32 +1,319 @@
'use client';
import { useState } from 'react';
import { useApi } from '@/lib/useApi';
import {
createHousehold,
joinHousehold,
getHousehold,
updateHousehold,
generateInviteCode,
} from '@/services/households';
export default function SettingsPage() {
const { householdId, isLoading, refreshProfile } = useApi();
if (isLoading) {
return <SettingsLoading />;
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Settings</h1>
<div className="max-w-2xl space-y-6">
<section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Household</h2>
<p className="text-sm text-gray-500">
Household management will be available here. Create a household, invite members, or
switch between households.
</p>
</section>
<section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Account</h2>
<p className="text-sm text-gray-500">
Account settings are managed through Keycloak. Click the button below to manage your
profile.
</p>
<a
href={`${process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'http://localhost:8080'}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'meshitrack'}/account`}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Manage Keycloak Account
</a>
</section>
<HouseholdSection householdId={householdId} onHouseholdChanged={() => refreshProfile()} />
<AccountSection />
</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>
</div>
);
}
function HouseholdSection({
householdId,
onHouseholdChanged,
}: {
householdId: string | null;
onHouseholdChanged: () => void;
}) {
const [householdName, setHouseholdName] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [currentHousehold, setCurrentHousehold] = useState<{
name: string;
inviteCode: string;
members: { userId: string; role: string }[];
} | null>(null);
const [loadedHousehold, setLoadedHousehold] = useState(false);
const [editingName, setEditingName] = useState(false);
const [editedName, setEditedName] = useState('');
const [editNameError, setEditNameError] = useState('');
const [savingName, setSavingName] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [regenerateError, setRegenerateError] = useState('');
async function loadHousehold() {
if (!householdId || loadedHousehold) return;
try {
const hh = await getHousehold(householdId);
setCurrentHousehold(hh);
} catch {
// Household may not be accessible yet
}
setLoadedHousehold(true);
}
if (householdId && !loadedHousehold) {
loadHousehold();
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await createHousehold(householdName.trim());
setHouseholdName('');
onHouseholdChanged();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create household');
} finally {
setSubmitting(false);
}
}
async function handleJoin(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await joinHousehold(inviteCode.trim());
setInviteCode('');
onHouseholdChanged();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join household');
} finally {
setSubmitting(false);
}
}
async function handleSaveName() {
if (!householdId || !currentHousehold) return;
const trimmed = editedName.trim();
if (!trimmed) {
setEditNameError('Name cannot be empty');
return;
}
if (trimmed === currentHousehold.name) {
setEditingName(false);
setEditNameError('');
return;
}
setEditNameError('');
setSavingName(true);
try {
const updated = await updateHousehold(householdId, { name: trimmed });
setCurrentHousehold(updated);
setEditingName(false);
} catch (err) {
setEditNameError(err instanceof Error ? err.message : 'Failed to update name');
} finally {
setSavingName(false);
}
}
async function handleRegenerateInviteCode() {
if (!householdId) return;
setRegenerateError('');
setRegenerating(true);
try {
const updated = await generateInviteCode(householdId);
setCurrentHousehold(updated);
} catch (err) {
setRegenerateError(err instanceof Error ? err.message : 'Failed to regenerate invite code');
} finally {
setRegenerating(false);
}
}
if (householdId) {
return (
<section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Household</h2>
{currentHousehold ? (
<div className="space-y-3">
<div>
<span className="text-sm text-gray-500">Name:</span>{' '}
{editingName ? (
<span className="inline-flex items-center gap-2">
<input
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
maxLength={100}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
<button
type="button"
onClick={handleSaveName}
disabled={savingName}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{savingName ? 'Saving...' : 'Save'}
</button>
<button
type="button"
onClick={() => {
setEditingName(false);
setEditNameError('');
}}
disabled={savingName}
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Cancel
</button>
</span>
) : (
<span className="inline-flex items-center gap-2">
<span className="font-medium">{currentHousehold.name}</span>
<button
type="button"
onClick={() => {
setEditedName(currentHousehold.name);
setEditNameError('');
setEditingName(true);
}}
className="rounded-lg border px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
Edit
</button>
</span>
)}
{editNameError && <p className="mt-1 text-xs text-red-600">{editNameError}</p>}
</div>
<div>
<span className="text-sm text-gray-500">Invite Code:</span>{' '}
<span className="inline-flex items-center gap-2">
<code className="rounded bg-gray-100 px-2 py-1 text-sm font-mono">
{currentHousehold.inviteCode}
</code>
<button
type="button"
onClick={handleRegenerateInviteCode}
disabled={regenerating}
className="rounded-lg border px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
{regenerating ? 'Regenerating...' : 'Regenerate'}
</button>
</span>
{regenerateError && <p className="mt-1 text-xs text-red-600">{regenerateError}</p>}
</div>
<div>
<span className="text-sm text-gray-500">Members:</span>{' '}
<span className="font-medium">{currentHousehold.members.length}</span>
</div>
</div>
) : (
<p className="text-sm text-gray-500">Loading household details...</p>
)}
</section>
);
}
return (
<section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Household</h2>
<p className="text-sm text-gray-500 mb-4">
You are not part of any household yet. Create one or join using an invite code.
</p>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<div className="space-y-6">
<form onSubmit={handleCreate} className="space-y-3">
<h3 className="text-sm font-medium text-gray-700">Create a new household</h3>
<div className="flex gap-3">
<input
type="text"
value={householdName}
onChange={(e) => setHouseholdName(e.target.value)}
placeholder="Household name"
required
maxLength={100}
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"
/>
<button
type="submit"
disabled={submitting || !householdName.trim()}
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"
>
Create
</button>
</div>
</form>
<div className="border-t" />
<form onSubmit={handleJoin} className="space-y-3">
<h3 className="text-sm font-medium text-gray-700">Join with invite code</h3>
<div className="flex gap-3">
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Invite code"
required
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"
/>
<button
type="submit"
disabled={submitting || !inviteCode.trim()}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Join
</button>
</div>
</form>
</div>
</section>
);
}
function AccountSection() {
const keycloakUrl = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'http://localhost:8080';
const realm = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'meshitrack';
return (
<section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Account</h2>
<p className="text-sm text-gray-500">
Account settings are managed through Keycloak. Click the button below to manage your
profile.
</p>
<a
href={`${keycloakUrl}/realms/${realm}/account`}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Manage Keycloak Account
</a>
</section>
);
}

View file

@ -1,15 +1,18 @@
import type { Metadata } from 'next';
import { Providers } from '@/components/Providers';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: 'MeshiTrack',
description: 'Nutrition & Pantry Management Platform',
description: 'Medicine & Nutrition Management Platform',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50">{children}</body>
<body className="min-h-screen bg-gray-50">
<Providers>{children}</Providers>
</body>
</html>
);
}

View file

@ -0,0 +1,8 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View file

@ -2,11 +2,7 @@ import Link from 'next/link';
const navItems = [
{ label: 'Dashboard', href: '/dashboard' },
{ label: 'Products', href: '/products' },
{ label: 'Recipes', href: '/recipes' },
{ label: 'Pantry', href: '/pantry' },
{ label: 'Meal Plans', href: '/meal-plans' },
{ label: 'Shopping', href: '/shopping' },
{ label: 'Medicines', href: '/medicines' },
{ label: 'Settings', href: '/settings' },
];

View file

@ -1,25 +1,47 @@
import { auth } from '@/lib/auth';
'use client';
export async function TopBar() {
const session = await auth();
const name = session?.user?.name ?? 'Unknown';
import Link from 'next/link';
import useSWR from 'swr';
import { useApi } from '@/lib/useApi';
import { getHousehold } from '@/services/households';
export function TopBar() {
const { householdId, profile, isLoading } = useApi();
const name = profile?.displayName ?? 'Unknown';
const initial = name.charAt(0).toUpperCase();
const householdId = session?.householdIds?.[0] ?? null;
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>
);
}
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">
{householdId}
{household?.name ?? householdId}
</span>
) : (
<span className="rounded-md border px-3 py-1 text-gray-400">No household</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"
>
No household - Create one
</Link>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{name}</span>
<div className="h-8 w-8 rounded-full bg-primary-200 flex items-center justify-center text-sm font-medium text-primary-800">
<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>
</div>

View file

@ -31,21 +31,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: '/login',
},
callbacks: {
async jwt({ token, account, profile }) {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
// householdIds is injected into the ID token by the Keycloak protocol mapper
token.householdIds = (profile as Record<string, unknown>)?.['householdIds'] as
| string[]
| undefined;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
session.householdIds = (token.householdIds as string[] | undefined) ?? [];
return session;
},
},

View file

@ -0,0 +1,41 @@
'use client';
import { useSession } from 'next-auth/react';
import useSWR from 'swr';
import { apiClient } from '@/services/api-client';
import type { UserResponse } from '@meshitrack/shared';
/**
* Provides authentication state and user profile (including householdIds).
*
* The access token comes from the NextAuth session (Keycloak JWT).
* Household membership is fetched from the API (`GET /users/me`) via SWR
* so it is always fresh -- no stale JWT claims.
*/
export function useApi() {
const { data: session, status } = useSession();
// Set API client token synchronously so SWR fetches have credentials.
if (session?.accessToken) {
apiClient.accessToken = session.accessToken;
}
const shouldFetch = status === 'authenticated' && apiClient.hasToken;
const {
data: profile,
mutate: refreshProfile,
isLoading: profileLoading,
} = useSWR<UserResponse>(shouldFetch ? 'user-profile' : null, () =>
apiClient.get<UserResponse>('/users/me'),
);
return {
householdId: profile?.householdIds?.[0] ?? null,
householdIds: profile?.householdIds ?? [],
isLoading: status === 'loading' || (shouldFetch && profileLoading),
isAuthenticated: status === 'authenticated',
profile: profile ?? null,
refreshProfile,
};
}

View file

@ -7,6 +7,10 @@ class ApiClient {
this._accessToken = token;
}
public get hasToken(): boolean {
return this._accessToken !== null;
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@ -19,15 +23,26 @@ class ApiClient {
return headers;
}
private async handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
let message: string;
try {
const body = await res.json();
message = body.message || `Request failed: ${res.status}`;
} catch {
message = `Request failed: ${res.status} ${res.statusText}`;
}
throw new Error(message);
}
if (res.status === 204) return undefined as T;
return res.json();
}
public async get<T>(url: string): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
headers: this.getHeaders(),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
return this.handleResponse<T>(res);
}
public async post<T>(url: string, body?: unknown): Promise<T> {
@ -36,11 +51,7 @@ class ApiClient {
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
return this.handleResponse<T>(res);
}
public async patch<T>(url: string, body: unknown): Promise<T> {
@ -49,24 +60,19 @@ class ApiClient {
headers: this.getHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
return this.handleResponse<T>(res);
}
public async delete<T = void>(url: string): Promise<T> {
const headers: Record<string, string> = {};
if (this._accessToken) {
headers['Authorization'] = `Bearer ${this._accessToken}`;
}
const res = await fetch(`${BASE_URL}${url}`, {
method: 'DELETE',
headers: this.getHeaders(),
headers,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
return this.handleResponse<T>(res);
}
}

View file

@ -0,0 +1,86 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
CabinetItemResponseSchema,
CabinetItemListResponseSchema,
CabinetSummaryResponseSchema,
CreateCabinetItemInput,
UpdateCabinetItemInput,
AdjustQuantityInput,
} from '@meshitrack/shared';
type CabinetItemResponse = z.infer<typeof CabinetItemResponseSchema>;
type CabinetItemListResponse = z.infer<typeof CabinetItemListResponseSchema>;
type CabinetSummaryResponse = z.infer<typeof CabinetSummaryResponseSchema>;
export async function listCabinetItems(
householdId: string,
query?: {
medicineId?: string;
status?: string;
expiringWithin?: number;
cursor?: string;
limit?: number;
},
): Promise<CabinetItemListResponse> {
const params = new URLSearchParams();
if (query?.medicineId) params.set('medicineId', query.medicineId);
if (query?.status) params.set('status', query.status);
if (query?.expiringWithin) params.set('expiringWithin', String(query.expiringWithin));
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<CabinetItemListResponse>(
`/households/${householdId}/cabinet${qs ? `?${qs}` : ''}`,
);
}
export async function getCabinetItem(
householdId: string,
id: string,
): Promise<CabinetItemResponse> {
return apiClient.get<CabinetItemResponse>(`/households/${householdId}/cabinet/${id}`);
}
export async function getCabinetSummary(householdId: string): Promise<CabinetSummaryResponse> {
return apiClient.get<CabinetSummaryResponse>(`/households/${householdId}/cabinet/summary`);
}
export async function getExpiringSoon(
householdId: string,
days = 30,
): Promise<{ data: CabinetItemResponse[] }> {
return apiClient.get<{ data: CabinetItemResponse[] }>(
`/households/${householdId}/cabinet/expiring-soon?days=${days}`,
);
}
export async function createCabinetItem(
householdId: string,
data: CreateCabinetItemInput,
): Promise<CabinetItemResponse> {
return apiClient.post<CabinetItemResponse>(`/households/${householdId}/cabinet`, data);
}
export async function updateCabinetItem(
householdId: string,
id: string,
data: UpdateCabinetItemInput,
): Promise<CabinetItemResponse> {
return apiClient.patch<CabinetItemResponse>(`/households/${householdId}/cabinet/${id}`, data);
}
export async function adjustCabinetItemQuantity(
householdId: string,
id: string,
data: AdjustQuantityInput,
): Promise<CabinetItemResponse> {
return apiClient.post<CabinetItemResponse>(
`/households/${householdId}/cabinet/${id}/adjust`,
data,
);
}
export async function deleteCabinetItem(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/cabinet/${id}`);
}

View file

@ -0,0 +1,28 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type { HouseholdResponseSchema, UpdateHouseholdInput } from '@meshitrack/shared';
type HouseholdResponse = z.infer<typeof HouseholdResponseSchema>;
export async function createHousehold(name: string): Promise<HouseholdResponse> {
return apiClient.post<HouseholdResponse>('/households', { name });
}
export async function getHousehold(id: string): Promise<HouseholdResponse> {
return apiClient.get<HouseholdResponse>(`/households/${id}`);
}
export async function updateHousehold(
id: string,
data: UpdateHouseholdInput,
): Promise<HouseholdResponse> {
return apiClient.patch<HouseholdResponse>(`/households/${id}`, data);
}
export async function generateInviteCode(id: string): Promise<HouseholdResponse> {
return apiClient.post<HouseholdResponse>(`/households/${id}/invite`);
}
export async function joinHousehold(inviteCode: string): Promise<HouseholdResponse> {
return apiClient.post<HouseholdResponse>('/households/join', { inviteCode });
}

View file

@ -0,0 +1,96 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
MedicineResponseSchema,
MedicineListResponseSchema,
MedicineProductResponseSchema,
MedicineProductListResponseSchema,
CreateMedicineInput,
UpdateMedicineInput,
CreateMedicineProductInput,
UpdateMedicineProductInput,
} from '@meshitrack/shared';
type MedicineResponse = z.infer<typeof MedicineResponseSchema>;
type MedicineListResponse = z.infer<typeof MedicineListResponseSchema>;
type MedicineProductResponse = z.infer<typeof MedicineProductResponseSchema>;
type MedicineProductListResponse = z.infer<typeof MedicineProductListResponseSchema>;
export async function listMedicines(
householdId: string,
query?: { q?: string; category?: string; form?: string; cursor?: string; limit?: number },
): Promise<MedicineListResponse> {
const params = new URLSearchParams();
if (query?.q) params.set('q', query.q);
if (query?.category) params.set('category', query.category);
if (query?.form) params.set('form', query.form);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<MedicineListResponse>(
`/households/${householdId}/medicines${qs ? `?${qs}` : ''}`,
);
}
export async function getMedicine(householdId: string, id: string): Promise<MedicineResponse> {
return apiClient.get<MedicineResponse>(`/households/${householdId}/medicines/${id}`);
}
export async function createMedicine(
householdId: string,
data: CreateMedicineInput,
): Promise<MedicineResponse> {
return apiClient.post<MedicineResponse>(`/households/${householdId}/medicines`, data);
}
export async function updateMedicine(
householdId: string,
id: string,
data: UpdateMedicineInput,
): Promise<MedicineResponse> {
return apiClient.patch<MedicineResponse>(`/households/${householdId}/medicines/${id}`, data);
}
export async function deleteMedicine(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/medicines/${id}`);
}
export async function listMedicineProducts(
householdId: string,
medicineId: string,
query?: { cursor?: string; limit?: number },
): Promise<MedicineProductListResponse> {
const params = new URLSearchParams();
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<MedicineProductListResponse>(
`/households/${householdId}/medicines/${medicineId}/products${qs ? `?${qs}` : ''}`,
);
}
export async function createMedicineProduct(
householdId: string,
medicineId: string,
data: CreateMedicineProductInput,
): Promise<MedicineProductResponse> {
return apiClient.post<MedicineProductResponse>(
`/households/${householdId}/medicines/${medicineId}/products`,
data,
);
}
export async function updateMedicineProduct(
householdId: string,
id: string,
data: UpdateMedicineProductInput,
): Promise<MedicineProductResponse> {
return apiClient.patch<MedicineProductResponse>(
`/households/${householdId}/medicine-products/${id}`,
data,
);
}
export async function deleteMedicineProduct(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/medicine-products/${id}`);
}

View file

@ -15,9 +15,7 @@
"allowJs": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"],
"@meshitrack/shared": ["../shared/src"],
"@meshitrack/shared/*": ["../shared/src/*"]
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],