Skip to main content

Spec: Medicine Library — Settings Management + CSV Bulk Import

Date: 2026-04-30 Scope: Full-page medicine library in Settings with CSV bulk import, table management, and single-entry add/edit. Prescriptions page search continues to work unchanged.

1. CSV Format

Header row required. Column order is fixed.
name,generic_name,category,dosage_forms,common_dosages,common_frequencies
Amoxicillin 500mg,Amoxicillin,Antibiotic,Capsule|Tablet,500mg|250mg,TDS|BD
Ibuprofen 400mg,Ibuprofen,NSAID,Tablet|Syrup,400mg|200mg,TDS|BD
Metronidazole 400mg,Metronidazole,Antibiotic,Tablet,400mg,TDS
Chlorhexidine Mouthwash,Chlorhexidine,Antiseptic,Mouthwash,10ml|15ml,BD
Augmentin 625mg,"Amoxicillin + Clavulanic Acid",Antibiotic,Tablet,625mg,BD|TDS
Lignocaine 2%,Lignocaine,Local Anesthetic,Cartridge|Vial,1.8ml,SOS
Column rules:
ColumnRequiredDescription
nameYesBrand name + strength. Empty → row is an error.
generic_nameNoINN / generic name.
categoryNoFree text. Suggested values: Antibiotic, NSAID, Analgesic, Antifungal, Corticosteroid, Antiseptic, Local Anesthetic, Vitamin, Antiviral, Other.
dosage_formsNoPipe-separated list: Capsule|Tablet|Syrup
common_dosagesNoPipe-separated list: 500mg|250mg
common_frequenciesNoPipe-separated list: OD|BD|TDS
Standard CSV quoting (double-quotes) handles commas in values. No schema changes required — the existing medications table covers all columns. Duplicate detection: case-insensitive exact match on name against existing clinic-owned and system medications. Duplicates are flagged in preview; the user can force-overwrite them. Batch limit: 1 000 rows per import request (server enforces; UI warns before submit).

2. UI Architecture

2a. Settings card — MedicineLibrarySettings.tsx

Placed in the Settings grid alongside PrescriptionTemplateSettings. Shows:
  • Total count of clinic-owned medicines (fetched on mount).
  • “Manage Library” button → setSearchParams({ section: 'medicines' }).
  • “Import CSV” shortcut button → setSearchParams({ section: 'medicines', import: '1' }).

2b. Full-page routing in SettingsModule.tsx

When searchParams.get('section') === 'medicines', render MedicineLibraryPage instead of the settings grid. Pattern is identical to how inventory uses ?item= params.

2c. URL param contract

URL stateRendered view
?section=medicinesFull-page table
?section=medicines&import=1Table + import drawer open
?section=medicines&med=newTable + Add Medicine dialog
?section=medicines&med=<id>&edit=1Table + Edit Medicine dialog pre-filled
Back arrow clears ?section=medicines (returns to settings grid).

2d. MedicineLibraryPage.tsx

Full-page layout. Components: Header row:
  • Back arrow + page title “Medicine Library”
  • “Import CSV” button → sets ?import=1
  • “Add Medicine” button → sets ?med=new
Filter bar:
  • Search input (debounced 300ms, filters name and generic_name)
  • Category dropdown (all distinct categories in the catalog)
Table columns:
ColumnNotes
NameBold. System entries show a lock icon.
Generic NameMuted if empty.
CategoryBadge.
Dosage FormsComma-joined from array.
Common DosagesComma-joined.
FrequenciesComma-joined.
Source”Clinic” or “System” chip.
ActionsEdit (pencil) + Delete (trash). Both disabled for system entries.
Pagination: 50 rows per page, server-side via ?page= + ?limit=50.

2e. MedicineImportDrawer.tsx

Right-side Sheet (full height). Three states: State 1 — File select:
  • Drag-and-drop zone or file picker (.csv only).
  • “Download sample CSV” link (generates a minimal example in-browser).
  • CSV is parsed entirely in the browser — no upload until user confirms.
State 2 — Preview:
  • Summary bar: 312 valid · 4 duplicates · 2 errors
  • Table of all parsed rows. Each row has a checkbox (checked by default for valid/duplicate, disabled for errors) and a status badge:
    • 🟢 Valid — will be imported.
    • 🟡 Duplicate — name already exists. Pre-unchecked. User can check to overwrite.
    • 🔴 Error — missing name or unparseable. Always skipped.
  • “Import N medicines” confirm button (N = count of checked rows).
  • “Back” link to re-select file.
State 3 — Result:
  • Success: “312 imported, 4 skipped”. Drawer auto-closes after 1.5 s and table refreshes.
  • Partial failure: list of skipped rows with reasons stays visible; user dismisses manually.

2f. MedicineFormDialog.tsx

Dialog for both add and edit. Fields:
FieldInput typeNotes
NameText inputRequired.
Generic NameText inputOptional.
CategoryComboboxFree text + suggested values list.
Dosage FormsTag input (chips)Type + Enter to add. E.g. Tablet, Capsule.
Common DosagesTag inputE.g. 500mg, 250mg.
Common FrequenciesTag inputE.g. BD, TDS. Suggestions: OD, BD, TDS, QID, SOS, Stat.
Save → POST (new) or PUT (edit). On success: close dialog, refresh table, toast.

3. API Changes

3a. New: POST /medications/bulk

Permission: clinical.medications.create Request body:
{
  medications: Array<{
    name: string;                    // required
    genericName?: string;
    category?: string;
    dosageForms?: string[];
    commonDosages?: string[];
    commonFrequencies?: string[];
  }>;
  overwriteDuplicates?: boolean;     // default false
}
Logic:
  1. Validate: reject if medications.length > 1000.
  2. Fetch existing names for this clinic + system (case-insensitive).
  3. For each row:
    • Blank name → add to errors, skip.
    • Name matches existing:
      • overwriteDuplicates = false → add to skipped.
      • overwriteDuplicates = true → upsert (update existing clinic-owned entry; cannot overwrite system entries — add to errors).
  4. Bulk insert valid rows in a single db.insert(medications).values([...]).
  5. Return summary.
Response:
{
  imported: number;
  skipped: number;
  errors: Array<{ index: number; name: string; reason: string }>;
}

3b. New: PUT /medications/:id

Permission: clinical.medications.create Logic:
  • 404 if not found.
  • 403 if isSystem = true or clinicId !== currentClinicId.
  • Update all provided fields.
Request body: same shape as createMedicationSchema (existing Zod schema, reused). Response: updated medication row.

3c. New: GET /medications — pagination support

Extend the existing GET endpoint with ?page= and ?limit= query params (default limit=50, page=1). Return { data: Medication[], total: number } instead of bare array when ?paginated=1 is passed. The existing prescription search (?q=) path is unchanged — no ?paginated = existing behaviour.

3d. serverComm.ts additions

// Paginated list for settings table
getMedicationsPage(page: number, search: string, category: string): Promise<{ data: MedicationSearchResult[], total: number }>

// Bulk import
bulkImportMedications(rows: BulkMedicationRow[], overwrite: boolean): Promise<BulkImportResult>

// Update single
updateMedication(id: string, data: Partial<MedicationInput>): Promise<MedicationSearchResult>
searchMedications() (used by the prescription wizard) is unchanged.

4. Permissions

Uses existing clinical.medications.view (read) and clinical.medications.create (write/delete) permission keys. No new permissions needed. Doctors and admins can access; receptionists cannot (existing guard on the route).

5. Out of scope

  • CSV export of the current catalog (future).
  • Merging / deduplicating across system + clinic entries beyond exact-name matching.
  • Per-medicine usage analytics (how many times prescribed).
  • Auto-mapping CSV columns in any order.
  • Pagination inside the import preview (all rows shown, virtualised if > 500).