359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|