mirror of
https://github.com/temetro/temetro.git
synced 2026-08-25 18:07:44 +00:00
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:
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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.0–17.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",
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -142,6 +142,7 @@ const ROUTE_AREAS: Record<string, AccessArea> = {
|
||||
"/notes": "clinical",
|
||||
"/activity": "clinical",
|
||||
"/pharmacy": "pharmacy",
|
||||
"/inventory": "pharmacy",
|
||||
"/lab": "lab",
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user