Implement medicine library and cabinet
This commit is contained in:
parent
db79af06f7
commit
1f66fab30f
72 changed files with 7642 additions and 319 deletions
|
|
@ -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"
|
||||
|
|
|
|||
692
packages/web/src/app/(dashboard)/medicines/CabinetTab.tsx
Normal file
692
packages/web/src/app/(dashboard)/medicines/CabinetTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
358
packages/web/src/app/(dashboard)/medicines/LibraryTab.tsx
Normal file
358
packages/web/src/app/(dashboard)/medicines/LibraryTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1002
packages/web/src/app/(dashboard)/medicines/[id]/page.tsx
Normal file
1002
packages/web/src/app/(dashboard)/medicines/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
41
packages/web/src/app/(dashboard)/medicines/cabinet/page.tsx
Normal file
41
packages/web/src/app/(dashboard)/medicines/cabinet/page.tsx
Normal 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} />;
|
||||
}
|
||||
41
packages/web/src/app/(dashboard)/medicines/library/page.tsx
Normal file
41
packages/web/src/app/(dashboard)/medicines/library/page.tsx
Normal 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} />;
|
||||
}
|
||||
79
packages/web/src/app/(dashboard)/medicines/page.tsx
Normal file
79
packages/web/src/app/(dashboard)/medicines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
8
packages/web/src/components/Providers.tsx
Normal file
8
packages/web/src/components/Providers.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
41
packages/web/src/lib/useApi.ts
Normal file
41
packages/web/src/lib/useApi.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
86
packages/web/src/services/cabinet.ts
Normal file
86
packages/web/src/services/cabinet.ts
Normal 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}`);
|
||||
}
|
||||
28
packages/web/src/services/households.ts
Normal file
28
packages/web/src/services/households.ts
Normal 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 });
|
||||
}
|
||||
96
packages/web/src/services/medicines.ts
Normal file
96
packages/web/src/services/medicines.ts
Normal 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}`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue