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.| Column | Required | Description |
|---|---|---|
name | Yes | Brand name + strength. Empty → row is an error. |
generic_name | No | INN / generic name. |
category | No | Free text. Suggested values: Antibiotic, NSAID, Analgesic, Antifungal, Corticosteroid, Antiseptic, Local Anesthetic, Vitamin, Antiviral, Other. |
dosage_forms | No | Pipe-separated list: Capsule|Tablet|Syrup |
common_dosages | No | Pipe-separated list: 500mg|250mg |
common_frequencies | No | Pipe-separated list: OD|BD|TDS |
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 state | Rendered view |
|---|---|
?section=medicines | Full-page table |
?section=medicines&import=1 | Table + import drawer open |
?section=medicines&med=new | Table + Add Medicine dialog |
?section=medicines&med=<id>&edit=1 | Table + Edit Medicine dialog pre-filled |
?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
- Search input (debounced 300ms, filters
nameandgeneric_name) - Category dropdown (all distinct categories in the catalog)
| Column | Notes |
|---|---|
| Name | Bold. System entries show a lock icon. |
| Generic Name | Muted if empty. |
| Category | Badge. |
| Dosage Forms | Comma-joined from array. |
| Common Dosages | Comma-joined. |
| Frequencies | Comma-joined. |
| Source | ”Clinic” or “System” chip. |
| Actions | Edit (pencil) + Delete (trash). Both disabled for system entries. |
?page= + ?limit=50.
2e. MedicineImportDrawer.tsx
Right-side Sheet (full height). Three states:
State 1 — File select:
- Drag-and-drop zone or file picker (
.csvonly). - “Download sample CSV” link (generates a minimal example in-browser).
- CSV is parsed entirely in the browser — no upload until user confirms.
- 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.
- 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:
| Field | Input type | Notes |
|---|---|---|
| Name | Text input | Required. |
| Generic Name | Text input | Optional. |
| Category | Combobox | Free text + suggested values list. |
| Dosage Forms | Tag input (chips) | Type + Enter to add. E.g. Tablet, Capsule. |
| Common Dosages | Tag input | E.g. 500mg, 250mg. |
| Common Frequencies | Tag input | E.g. BD, TDS. Suggestions: OD, BD, TDS, QID, SOS, Stat. |
3. API Changes
3a. New: POST /medications/bulk
Permission: clinical.medications.create
Request body:
- Validate: reject if
medications.length > 1000. - Fetch existing names for this clinic + system (case-insensitive).
- 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).
- Blank
- Bulk insert valid rows in a single
db.insert(medications).values([...]). - Return summary.
3b. New: PUT /medications/:id
Permission: clinical.medications.create
Logic:
- 404 if not found.
- 403 if
isSystem = trueorclinicId !== currentClinicId. - Update all provided fields.
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
searchMedications() (used by the prescription wizard) is unchanged.
4. Permissions
Uses existingclinical.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).

