frontend: pharmacy inventory, lab add-result & analysis charts

- Pharmacy: the sidebar entry now expands into Pharmacy + a new Inventory page
  (searchable medication stock with derived in-stock/low/out availability,
  backed by /api/inventory). Fix the dispensing-queue "Expiring" badge so it
  only flags courses ending within the next 7 days, not ones already elapsed.
- Lab "Add analysis result": the patient picker no longer lists patients until
  you type and supports arrow-key + Enter selection; the test field offers a
  catalog of common analyses with an Advanced option (custom analysis + ref
  range); submitted results now show immediately in a Recent results feed.
- Analysis: add a Live panel (real-time line chart) and replace the flat trend
  sparklines with bklit-ui area + bar charts (installed from the @bklit shadcn
  registry; chart CSS variables wired into the theme for light and dark).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-12 19:22:07 +03:00
parent e2bcb2cfbc
commit 321a6298a4
81 changed files with 11538 additions and 67 deletions
+6
View File
@@ -13,6 +13,7 @@ export const statements = {
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
} as const;
@@ -27,6 +28,7 @@ export const owner = ac.newRole({
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -36,6 +38,7 @@ export const admin = ac.newRole({
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -45,6 +48,7 @@ export const member = ac.newRole({
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -56,6 +60,7 @@ export const doctor = ac.newRole({
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -75,6 +80,7 @@ export const pharmacy = ac.newRole({
patient: ["read"],
appointment: ["read"],
prescription: ["read", "write"],
inventory: ["read", "write"],
task: ["read", "write"],
});
+41 -1
View File
@@ -115,6 +115,7 @@
"prescriptions": "Prescriptions",
"analysis": "Analysis",
"pharmacy": "Pharmacy",
"inventory": "Inventory",
"lab": "Lab",
"notes": "Notes",
"messages": "Messages",
@@ -338,6 +339,30 @@
"completeFailedTitle": "Couldn't update prescription",
"completeFailedBody": "Please try again."
},
"inventory": {
"title": "Inventory",
"subtitle": "Search the pharmacy's medication stock and check availability.",
"searchPlaceholder": "Search medications",
"kpi": {
"total": "Medications in stock",
"low": "Low stock",
"out": "Out of stock"
},
"list": {
"title": "Medications",
"description": "Current stock levels across the pharmacy. Items at or below their reorder point are flagged.",
"empty": "No medications in inventory yet.",
"noMatches": "No medications match your search."
},
"stock": "{{count}} {{unit}}",
"reorderAt": "Reorder at {{count}}",
"location": "Shelf {{location}}",
"availability": {
"in-stock": "In stock",
"low": "Low stock",
"out": "Out of stock"
}
},
"lab": {
"title": "Lab",
"subtitle": "The lab's work queue and analysis results.",
@@ -358,8 +383,13 @@
"testPlaceholder": "e.g. Hemoglobin",
"value": "Value",
"valuePlaceholder": "e.g. 14.2 g/dL",
"valueUnitPlaceholder": "e.g. value in {{unit}}",
"flag": "Flag",
"takenAt": "Date",
"advanced": "Advanced",
"advancedHint": "Record a custom analysis with a reference range.",
"refRange": "Reference range",
"refRangePlaceholder": "e.g. 13.017.0 g/dL",
"cancel": "Cancel",
"submit": "Add result",
"needPatientTitle": "Pick a patient",
@@ -371,6 +401,11 @@
"failedTitle": "Couldn't add the result",
"failedBody": "Please try again."
},
"recent": {
"title": "Recent results",
"description": "Analyses recorded on patient records, newest first.",
"empty": "No results recorded yet."
},
"toast": {
"updateFailedTitle": "Couldn't update the task",
"updateFailedBody": "Please try again."
@@ -472,6 +507,11 @@
"analysis": {
"title": "Analysis",
"subtitle": "Clinic performance at a glance, computed from your clinic's data.",
"live": {
"title": "Live",
"subtitle": "Patients currently in the building, updating in real time.",
"label": "In the building now"
},
"patientVolume": {
"title": "Patient volume",
"description": "New, active and total patients",
@@ -501,7 +541,7 @@
},
"charts": {
"title": "Trends",
"subtitle": "Tap a card for the full breakdown.",
"subtitle": "Patient growth and weekly appointment volume.",
"viewDetails": "Tap for details",
"patientGrowthTitle": "New patients",
"patientGrowthDescription": "New patients per month over the last 6 months",
+66
View File
@@ -0,0 +1,66 @@
import { apiFetch } from "@/lib/api-client";
// A pharmacy stock item. Mirrors the backend `src/types/inventory.ts`. Scoped to
// the active clinic. `expiresAt` is an ISO YYYY-MM-DD date (or null).
export type InventoryItem = {
id: string;
name: string;
form: string;
strength: string;
unit: string;
stockQuantity: number;
reorderThreshold: number;
location: string;
expiresAt: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
};
// The fields the inventory dialog collects.
export type InventoryInput = {
name: string;
form?: string;
strength?: string;
unit?: string;
stockQuantity?: number;
reorderThreshold?: number;
location?: string;
expiresAt?: string | null;
notes?: string | null;
};
export type Availability = "in-stock" | "low" | "out";
// Derived stock status: out at zero, low at/under the reorder threshold,
// otherwise in stock. Computed on the client (not persisted).
export function availabilityOf(item: InventoryItem): Availability {
if (item.stockQuantity <= 0) return "out";
if (item.stockQuantity <= item.reorderThreshold) return "low";
return "in-stock";
}
export function listInventory(): Promise<InventoryItem[]> {
return apiFetch<InventoryItem[]>("/api/inventory");
}
export function createInventory(input: InventoryInput): Promise<InventoryItem> {
return apiFetch<InventoryItem>("/api/inventory", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateInventory(
id: string,
input: InventoryInput,
): Promise<InventoryItem> {
return apiFetch<InventoryItem>(`/api/inventory/${id}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function deleteInventory(id: string): Promise<void> {
return apiFetch<void>(`/api/inventory/${id}`, { method: "DELETE" });
}
+65
View File
@@ -0,0 +1,65 @@
// A curated catalog of common laboratory analyses, grouped by panel, used to
// power the "Test" combobox in the lab add-result dialog. Users can still type
// any analysis not listed here (the field accepts free text) — this is just a
// fast-path for the routine ones, with typical reporting units as hints.
//
// Compiled from common clinical lab panels (CBC, BMP/CMP, lipid, thyroid,
// coagulation, inflammatory markers, iron studies, urinalysis). Sources:
// HealthLabs, LevelUpRN, Fullscript, Frederick Health.
export type LabAnalysis = { name: string; unit?: string; group: string };
export const LAB_ANALYSES: LabAnalysis[] = [
// Complete Blood Count (CBC)
{ name: "Hemoglobin", unit: "g/dL", group: "Complete Blood Count" },
{ name: "Hematocrit", unit: "%", group: "Complete Blood Count" },
{ name: "White Blood Cell Count", unit: "10³/µL", group: "Complete Blood Count" },
{ name: "Red Blood Cell Count", unit: "10⁶/µL", group: "Complete Blood Count" },
{ name: "Platelet Count", unit: "10³/µL", group: "Complete Blood Count" },
{ name: "Mean Corpuscular Volume (MCV)", unit: "fL", group: "Complete Blood Count" },
// Basic / Comprehensive Metabolic Panel
{ name: "Sodium", unit: "mmol/L", group: "Metabolic Panel" },
{ name: "Potassium", unit: "mmol/L", group: "Metabolic Panel" },
{ name: "Chloride", unit: "mmol/L", group: "Metabolic Panel" },
{ name: "Bicarbonate (CO₂)", unit: "mmol/L", group: "Metabolic Panel" },
{ name: "Blood Urea Nitrogen (BUN)", unit: "mg/dL", group: "Metabolic Panel" },
{ name: "Creatinine", unit: "mg/dL", group: "Metabolic Panel" },
{ name: "Glucose", unit: "mg/dL", group: "Metabolic Panel" },
{ name: "Calcium", unit: "mg/dL", group: "Metabolic Panel" },
// Liver Function Tests
{ name: "Alanine Aminotransferase (ALT)", unit: "U/L", group: "Liver Function" },
{ name: "Aspartate Aminotransferase (AST)", unit: "U/L", group: "Liver Function" },
{ name: "Alkaline Phosphatase (ALP)", unit: "U/L", group: "Liver Function" },
{ name: "Total Bilirubin", unit: "mg/dL", group: "Liver Function" },
{ name: "Albumin", unit: "g/dL", group: "Liver Function" },
{ name: "Total Protein", unit: "g/dL", group: "Liver Function" },
// Lipid Panel
{ name: "Total Cholesterol", unit: "mg/dL", group: "Lipid Panel" },
{ name: "LDL Cholesterol", unit: "mg/dL", group: "Lipid Panel" },
{ name: "HDL Cholesterol", unit: "mg/dL", group: "Lipid Panel" },
{ name: "Triglycerides", unit: "mg/dL", group: "Lipid Panel" },
// Thyroid
{ name: "Thyroid Stimulating Hormone (TSH)", unit: "mIU/L", group: "Thyroid" },
{ name: "Free T4", unit: "ng/dL", group: "Thyroid" },
{ name: "Free T3", unit: "pg/mL", group: "Thyroid" },
// Diabetes / Inflammatory
{ name: "Hemoglobin A1c", unit: "%", group: "Diabetes & Inflammation" },
{ name: "C-Reactive Protein (CRP)", unit: "mg/L", group: "Diabetes & Inflammation" },
{ name: "Erythrocyte Sedimentation Rate (ESR)", unit: "mm/hr", group: "Diabetes & Inflammation" },
// Coagulation
{ name: "Prothrombin Time (PT)", unit: "s", group: "Coagulation" },
{ name: "International Normalized Ratio (INR)", group: "Coagulation" },
{ name: "Partial Thromboplastin Time (PTT)", unit: "s", group: "Coagulation" },
// Iron / Vitamins
{ name: "Ferritin", unit: "ng/mL", group: "Iron & Vitamins" },
{ name: "Iron", unit: "µg/dL", group: "Iron & Vitamins" },
{ name: "Vitamin D, 25-Hydroxy", unit: "ng/mL", group: "Iron & Vitamins" },
{ name: "Vitamin B12", unit: "pg/mL", group: "Iron & Vitamins" },
// Urinalysis
{ name: "Urinalysis", group: "Urinalysis" },
{ name: "Urine Protein", unit: "mg/dL", group: "Urinalysis" },
];
// Quick lookup of an analysis's unit by name (for prefilling the value hint).
export const LAB_ANALYSIS_UNITS: Record<string, string> = Object.fromEntries(
LAB_ANALYSES.filter((a) => a.unit).map((a) => [a.name, a.unit as string]),
);
+14
View File
@@ -1,5 +1,6 @@
import {
BarChart3,
Boxes,
CalendarClock,
Cross,
FlaskConical,
@@ -90,6 +91,19 @@ export const navItems: NavItem[] = [
icon: Cross,
link: "/pharmacy",
access: "pharmacy",
subs: [
{
id: "pharmacy-dispensing",
labelKey: "nav.pharmacy",
link: "/pharmacy",
},
{
id: "inventory",
labelKey: "nav.inventory",
icon: Boxes,
link: "/inventory",
},
],
},
{
id: "lab",
+1
View File
@@ -142,6 +142,7 @@ const ROUTE_AREAS: Record<string, AccessArea> = {
"/notes": "clinical",
"/activity": "clinical",
"/pharmacy": "pharmacy",
"/inventory": "pharmacy",
"/lab": "lab",
};