Prescriptions Module Redesign — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Overhaul the prescriptions module with a medication catalog (seeded with Pakistan drugs), a 3-step wizard UI, Signature Center integration, and DOCX letterhead → PDF support.
Architecture: New medications Drizzle schema + seed script seeded at startup; Hono route at /medications; 3-step React wizard replaces the current flat dialog; letterhead PNG (rendered from DOCX at upload via mammoth.js + Cloudflare Browser Rendering) composited as a background layer in a new @react-pdf/renderer template.
Tech Stack: Hono + Drizzle + Postgres + Cloudflare R2 (backend); React + Radix UI + Tailwind + @react-pdf/renderer (frontend); mammoth.js (DOCX→HTML); Vitest (unit tests).
File Map
New files:server/src/schema/medications.ts— Drizzle schema for medications tableserver/src/scripts/seed-medications.ts— 300-500 Pakistan drug seed entriesserver/src/routes/medications.ts— GET search, POST add custom, DELETEserver/src/routes/prescription-template.ts— DOCX upload, delete, previewserver/src/lib/medications.test.ts— unit tests for medication helpersui/src/components/prescriptions/PrescriptionWizard.tsx— wizard orchestratorui/src/components/prescriptions/PrescriptionWizardStep1.tsx— patient & detailsui/src/components/prescriptions/PrescriptionWizardStep2.tsx— drug search & listui/src/components/prescriptions/PrescriptionWizardStep3.tsx— sign & exportui/src/lib/pdf/templates/PrescriptionLetterheadPdf.tsx— letterhead PDF templateui/src/components/settings/PrescriptionTemplateSettings.tsx— DOCX upload card
server/src/schema/prescriptions.ts— addsignatureId,templateTypeserver/src/schema/prescription_items.ts— addmedicationIdserver/src/schema/clinics.ts— addprescriptionDocxKey,prescriptionImageKeyserver/src/schema/index.ts— export medicationsserver/src/lib/validation.ts— update prescription schemasserver/src/lib/r2.ts— addgeneratePrescriptionTemplateKeyserver/src/routes/prescriptions.ts— pass signatureId, templateType, medicationIdserver/src/api.ts— register new routes + add DB migrationsui/src/lib/serverComm.ts— new API client functionsui/src/lib/pdf/PdfExportService.tsx— addprescription_letterheadtypeui/src/lib/pdf-generator.ts— updategeneratePrescriptionPDFfor letterheadui/src/components/doctor/PrescriptionManagement.tsx— wire in new wizardui/src/pages/Settings.tsx— add PrescriptionTemplateSettings card
Task 1: medications Drizzle schema
Files:-
Create:
server/src/schema/medications.ts -
Modify:
server/src/schema/index.ts - Step 1: Create medications schema file
// server/src/schema/medications.ts
import { pgTable, text, timestamp, boolean, varchar, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';
export const medications = appSchema.table('medications', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
clinicId: text('clinic_id'), // null = system drug
name: varchar('name', { length: 200 }).notNull(),
genericName: varchar('generic_name', { length: 200 }),
category: varchar('category', { length: 100 }),
dosageForms: text('dosage_forms').array(),
commonDosages: text('common_dosages').array(),
commonFrequencies: text('common_frequencies').array(),
isSystem: boolean('is_system').default(false).notNull(),
createdBy: text('created_by'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
clinicIdx: index('medications_clinic_id_idx').on(table.clinicId),
nameIdx: index('medications_name_idx').on(table.name),
categoryIdx: index('medications_category_idx').on(table.category),
}));
export type Medication = typeof medications.$inferSelect;
export type NewMedication = typeof medications.$inferInsert;
- Step 2: Export from schema index
server/src/schema/index.ts, after the export * from './prescription_items'; line add:
export * from './medications';
- Step 3: Commit
git add server/src/schema/medications.ts server/src/schema/index.ts
git commit -m "feat(schema): add medications table schema"
Task 2: Update existing schemas (prescriptions, prescription_items, clinics)
Files:-
Modify:
server/src/schema/prescriptions.ts -
Modify:
server/src/schema/prescription_items.ts -
Modify:
server/src/schema/clinics.ts - Step 1: Add signatureId and templateType to prescriptions
server/src/schema/prescriptions.ts, import userSignatures and add two columns inside the table definition after status:
// Add this import at the top (alongside existing imports):
import { userSignatures } from './user_signatures';
// Add these two columns inside the prescriptions table, after the status line:
signatureId: text('signature_id').references(() => userSignatures.id, { onDelete: 'set null' }),
templateType: text('template_type').default('default').notNull(),
- Step 2: Add medicationId to prescription_items
server/src/schema/prescription_items.ts, import medications and add the column:
// Add at top:
import { medications } from './medications';
// Add inside prescriptionItems table, after prescriptionId:
medicationId: text('medication_id').references(() => medications.id, { onDelete: 'set null' }),
- Step 3: Add template keys to clinics
server/src/schema/clinics.ts, add two columns before the createdAt line:
prescriptionDocxKey: text('prescription_docx_key'),
prescriptionImageKey: text('prescription_image_key'),
- Step 4: Commit
git add server/src/schema/prescriptions.ts server/src/schema/prescription_items.ts server/src/schema/clinics.ts
git commit -m "feat(schema): add signatureId, templateType, medicationId, prescription template keys"
Task 3: DB migrations in api.ts
Files:-
Modify:
server/src/api.ts - Step 1: Add migration SQL statements
server/src/api.ts, find the migrations array (around line 450). Add these entries at the end of the array, before the closing ]:
// Medications catalog
`CREATE TABLE IF NOT EXISTS "app"."medications" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text,
"clinic_id" text,
"name" varchar(200) NOT NULL,
"generic_name" varchar(200),
"category" varchar(100),
"dosage_forms" text[],
"common_dosages" text[],
"common_frequencies" text[],
"is_system" boolean DEFAULT false NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS "medications_clinic_id_idx" ON "app"."medications" ("clinic_id")`,
`CREATE INDEX IF NOT EXISTS "medications_name_idx" ON "app"."medications" ("name")`,
`CREATE INDEX IF NOT EXISTS "medications_category_idx" ON "app"."medications" ("category")`,
// Prescriptions: signatureId + templateType
`ALTER TABLE "app"."prescriptions" ADD COLUMN IF NOT EXISTS "signature_id" text REFERENCES "app"."user_signatures"("id") ON DELETE SET NULL`,
`ALTER TABLE "app"."prescriptions" ADD COLUMN IF NOT EXISTS "template_type" text NOT NULL DEFAULT 'default'`,
// Prescription items: medicationId
`ALTER TABLE "app"."prescription_items" ADD COLUMN IF NOT EXISTS "medication_id" text REFERENCES "app"."medications"("id") ON DELETE SET NULL`,
// Clinics: prescription template R2 keys
`ALTER TABLE "app"."clinics" ADD COLUMN IF NOT EXISTS "prescription_docx_key" text`,
`ALTER TABLE "app"."clinics" ADD COLUMN IF NOT EXISTS "prescription_image_key" text`,
- Step 2: Run migrations
curl -X POST http://localhost:8787/api/v1/admin/migrate \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json"
{"success":true,"results":[...]}
- Step 3: Commit
git add server/src/api.ts
git commit -m "feat(migrations): add medications table, prescription template columns"
Task 4: Pakistan medication seed data
Files:-
Create:
server/src/scripts/seed-medications.ts - Step 1: Create the seed script
// server/src/scripts/seed-medications.ts
import { getDatabase } from '../lib/db';
import { getDatabaseUrl } from '../lib/env';
import { medications } from '../schema/medications';
import { sql } from 'drizzle-orm';
const SEED_MEDICATIONS = [
// Antibiotics
{ name: 'Amoxicillin', genericName: 'Amoxicillin', category: 'Antibiotics', dosageForms: ['capsule', 'syrup'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Augmentin', genericName: 'Amoxicillin + Clavulanate', category: 'Antibiotics', dosageForms: ['tablet', 'syrup'], commonDosages: ['375mg', '625mg', '1g'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Ampiclox', genericName: 'Ampicillin + Cloxacillin', category: 'Antibiotics', dosageForms: ['capsule'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['QID'] },
{ name: 'Metronidazole', genericName: 'Metronidazole', category: 'Antibiotics', dosageForms: ['tablet', 'syrup', 'injection'], commonDosages: ['200mg', '400mg', '500mg'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Flagyl', genericName: 'Metronidazole', category: 'Antibiotics', dosageForms: ['tablet', 'suspension'], commonDosages: ['200mg', '400mg'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Clarithromycin', genericName: 'Clarithromycin', category: 'Antibiotics', dosageForms: ['tablet'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['BD'] },
{ name: 'Azithromycin', genericName: 'Azithromycin', category: 'Antibiotics', dosageForms: ['tablet', 'capsule', 'syrup'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['OD'] },
{ name: 'Doxycycline', genericName: 'Doxycycline', category: 'Antibiotics', dosageForms: ['capsule', 'tablet'], commonDosages: ['100mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Ciprofloxacin', genericName: 'Ciprofloxacin', category: 'Antibiotics', dosageForms: ['tablet'], commonDosages: ['250mg', '500mg', '750mg'], commonFrequencies: ['BD'] },
{ name: 'Clindamycin', genericName: 'Clindamycin', category: 'Antibiotics', dosageForms: ['capsule', 'injection'], commonDosages: ['150mg', '300mg'], commonFrequencies: ['TDS', 'QID'] },
{ name: 'Erythromycin', genericName: 'Erythromycin', category: 'Antibiotics', dosageForms: ['tablet', 'syrup'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['QID'] },
{ name: 'Trimethoprim + Sulfamethoxazole', genericName: 'Co-trimoxazole', category: 'Antibiotics', dosageForms: ['tablet', 'syrup'], commonDosages: ['480mg', '960mg'], commonFrequencies: ['BD'] },
{ name: 'Septran', genericName: 'Co-trimoxazole', category: 'Antibiotics', dosageForms: ['tablet', 'syrup'], commonDosages: ['480mg'], commonFrequencies: ['BD'] },
{ name: 'Cephalexin', genericName: 'Cephalexin', category: 'Antibiotics', dosageForms: ['capsule', 'syrup'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['QID'] },
{ name: 'Cefixime', genericName: 'Cefixime', category: 'Antibiotics', dosageForms: ['tablet', 'capsule', 'syrup'], commonDosages: ['200mg', '400mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Cefuroxime', genericName: 'Cefuroxime', category: 'Antibiotics', dosageForms: ['tablet'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['BD'] },
{ name: 'Moxifloxacin', genericName: 'Moxifloxacin', category: 'Antibiotics', dosageForms: ['tablet'], commonDosages: ['400mg'], commonFrequencies: ['OD'] },
{ name: 'Levofloxacin', genericName: 'Levofloxacin', category: 'Antibiotics', dosageForms: ['tablet'], commonDosages: ['250mg', '500mg', '750mg'], commonFrequencies: ['OD'] },
{ name: 'Linezolid', genericName: 'Linezolid', category: 'Antibiotics', dosageForms: ['tablet'], commonDosages: ['600mg'], commonFrequencies: ['BD'] },
// Analgesics / NSAIDs
{ name: 'Panadol', genericName: 'Paracetamol', category: 'Analgesics/NSAIDs', dosageForms: ['tablet', 'syrup'], commonDosages: ['500mg', '1g'], commonFrequencies: ['TDS', 'QID', 'SOS'] },
{ name: 'Paracetamol', genericName: 'Paracetamol', category: 'Analgesics/NSAIDs', dosageForms: ['tablet', 'syrup', 'suppository'], commonDosages: ['500mg', '650mg', '1g'], commonFrequencies: ['TDS', 'QID', 'SOS'] },
{ name: 'Ibuprofen', genericName: 'Ibuprofen', category: 'Analgesics/NSAIDs', dosageForms: ['tablet', 'syrup'], commonDosages: ['200mg', '400mg', '600mg'], commonFrequencies: ['TDS', 'SOS'] },
{ name: 'Brufen', genericName: 'Ibuprofen', category: 'Analgesics/NSAIDs', dosageForms: ['tablet', 'syrup'], commonDosages: ['400mg', '600mg'], commonFrequencies: ['TDS'] },
{ name: 'Diclofenac Sodium', genericName: 'Diclofenac', category: 'Analgesics/NSAIDs', dosageForms: ['tablet', 'injection', 'gel'], commonDosages: ['50mg', '75mg', '100mg'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Voltaren', genericName: 'Diclofenac', category: 'Analgesics/NSAIDs', dosageForms: ['tablet', 'injection', 'gel'], commonDosages: ['50mg', '75mg'], commonFrequencies: ['BD'] },
{ name: 'Mefenamic Acid', genericName: 'Mefenamic Acid', category: 'Analgesics/NSAIDs', dosageForms: ['capsule', 'tablet'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['TDS'] },
{ name: 'Ponstan', genericName: 'Mefenamic Acid', category: 'Analgesics/NSAIDs', dosageForms: ['capsule'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['TDS'] },
{ name: 'Naproxen', genericName: 'Naproxen', category: 'Analgesics/NSAIDs', dosageForms: ['tablet'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['BD'] },
{ name: 'Tramadol', genericName: 'Tramadol', category: 'Analgesics/NSAIDs', dosageForms: ['capsule', 'injection'], commonDosages: ['50mg', '100mg'], commonFrequencies: ['BD', 'TDS', 'SOS'] },
{ name: 'Ketorolac', genericName: 'Ketorolac', category: 'Analgesics/NSAIDs', dosageForms: ['tablet', 'injection'], commonDosages: ['10mg', '30mg'], commonFrequencies: ['QID', 'SOS'] },
{ name: 'Celecoxib', genericName: 'Celecoxib', category: 'Analgesics/NSAIDs', dosageForms: ['capsule'], commonDosages: ['100mg', '200mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Etoricoxib', genericName: 'Etoricoxib', category: 'Analgesics/NSAIDs', dosageForms: ['tablet'], commonDosages: ['60mg', '90mg', '120mg'], commonFrequencies: ['OD'] },
// Antifungals
{ name: 'Fluconazole', genericName: 'Fluconazole', category: 'Antifungals', dosageForms: ['capsule', 'syrup'], commonDosages: ['50mg', '150mg', '200mg'], commonFrequencies: ['OD', 'Weekly'] },
{ name: 'Diflucan', genericName: 'Fluconazole', category: 'Antifungals', dosageForms: ['capsule'], commonDosages: ['150mg'], commonFrequencies: ['Single dose', 'OD'] },
{ name: 'Clotrimazole', genericName: 'Clotrimazole', category: 'Antifungals', dosageForms: ['cream', 'lozenge', 'pessary'], commonDosages: ['1%', '10mg'], commonFrequencies: ['TDS'] },
{ name: 'Nystatin', genericName: 'Nystatin', category: 'Antifungals', dosageForms: ['oral drops', 'tablet', 'cream'], commonDosages: ['100,000 IU'], commonFrequencies: ['QID'] },
{ name: 'Itraconazole', genericName: 'Itraconazole', category: 'Antifungals', dosageForms: ['capsule'], commonDosages: ['100mg', '200mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Terbinafine', genericName: 'Terbinafine', category: 'Antifungals', dosageForms: ['tablet', 'cream'], commonDosages: ['250mg', '1%'], commonFrequencies: ['OD'] },
// Antihistamines
{ name: 'Cetirizine', genericName: 'Cetirizine', category: 'Antihistamines', dosageForms: ['tablet', 'syrup'], commonDosages: ['5mg', '10mg'], commonFrequencies: ['OD'] },
{ name: 'Zyrtec', genericName: 'Cetirizine', category: 'Antihistamines', dosageForms: ['tablet'], commonDosages: ['10mg'], commonFrequencies: ['OD'] },
{ name: 'Loratadine', genericName: 'Loratadine', category: 'Antihistamines', dosageForms: ['tablet', 'syrup'], commonDosages: ['10mg'], commonFrequencies: ['OD'] },
{ name: 'Fexofenadine', genericName: 'Fexofenadine', category: 'Antihistamines', dosageForms: ['tablet'], commonDosages: ['120mg', '180mg'], commonFrequencies: ['OD'] },
{ name: 'Chlorpheniramine', genericName: 'Chlorpheniramine', category: 'Antihistamines', dosageForms: ['tablet', 'syrup', 'injection'], commonDosages: ['4mg', '8mg'], commonFrequencies: ['TDS', 'QID'] },
{ name: 'Promethazine', genericName: 'Promethazine', category: 'Antihistamines', dosageForms: ['tablet', 'syrup', 'injection'], commonDosages: ['10mg', '25mg'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Diphenhydramine', genericName: 'Diphenhydramine', category: 'Antihistamines', dosageForms: ['capsule', 'injection'], commonDosages: ['25mg', '50mg'], commonFrequencies: ['TDS', 'QID'] },
// Vitamins / Supplements
{ name: 'Vitamin C', genericName: 'Ascorbic Acid', category: 'Vitamins/Supplements', dosageForms: ['tablet', 'sachet'], commonDosages: ['250mg', '500mg', '1g'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Vitamin D3', genericName: 'Cholecalciferol', category: 'Vitamins/Supplements', dosageForms: ['tablet', 'drops', 'injection'], commonDosages: ['1000 IU', '5000 IU', '50,000 IU'], commonFrequencies: ['OD', 'Weekly'] },
{ name: 'Calcium + Vitamin D', genericName: 'Calcium Carbonate + Vitamin D3', category: 'Vitamins/Supplements', dosageForms: ['tablet', 'chewable'], commonDosages: ['500mg+200IU', '1000mg+400IU'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Folic Acid', genericName: 'Folic Acid', category: 'Vitamins/Supplements', dosageForms: ['tablet'], commonDosages: ['400mcg', '5mg'], commonFrequencies: ['OD'] },
{ name: 'Multivitamins', genericName: 'Multivitamin Complex', category: 'Vitamins/Supplements', dosageForms: ['tablet', 'syrup'], commonDosages: ['1 tab'], commonFrequencies: ['OD'] },
{ name: 'Iron Supplements', genericName: 'Ferrous Sulfate', category: 'Vitamins/Supplements', dosageForms: ['tablet', 'syrup', 'injection'], commonDosages: ['200mg', '325mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Zinc Sulfate', genericName: 'Zinc Sulfate', category: 'Vitamins/Supplements', dosageForms: ['tablet', 'syrup'], commonDosages: ['20mg', '50mg'], commonFrequencies: ['OD'] },
{ name: 'Vitamin B Complex', genericName: 'Vitamin B Complex', category: 'Vitamins/Supplements', dosageForms: ['tablet', 'injection'], commonDosages: ['1 tab'], commonFrequencies: ['OD', 'BD'] },
// Antacids / GI
{ name: 'Omeprazole', genericName: 'Omeprazole', category: 'Antacids/GI', dosageForms: ['capsule', 'tablet'], commonDosages: ['20mg', '40mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Pantoprazole', genericName: 'Pantoprazole', category: 'Antacids/GI', dosageForms: ['tablet', 'injection'], commonDosages: ['20mg', '40mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Esomeprazole', genericName: 'Esomeprazole', category: 'Antacids/GI', dosageForms: ['tablet', 'capsule'], commonDosages: ['20mg', '40mg'], commonFrequencies: ['OD'] },
{ name: 'Nexium', genericName: 'Esomeprazole', category: 'Antacids/GI', dosageForms: ['tablet'], commonDosages: ['20mg', '40mg'], commonFrequencies: ['OD'] },
{ name: 'Metoclopramide', genericName: 'Metoclopramide', category: 'Antacids/GI', dosageForms: ['tablet', 'syrup', 'injection'], commonDosages: ['10mg'], commonFrequencies: ['TDS'] },
{ name: 'Domperidone', genericName: 'Domperidone', category: 'Antacids/GI', dosageForms: ['tablet', 'syrup'], commonDosages: ['10mg'], commonFrequencies: ['TDS'] },
{ name: 'Ranitidine', genericName: 'Ranitidine', category: 'Antacids/GI', dosageForms: ['tablet', 'syrup'], commonDosages: ['150mg', '300mg'], commonFrequencies: ['BD', 'OD'] },
{ name: 'Antacid', genericName: 'Aluminium Hydroxide + Magnesium Hydroxide', category: 'Antacids/GI', dosageForms: ['tablet', 'suspension'], commonDosages: ['1-2 tabs', '10ml'], commonFrequencies: ['TDS', 'QID', 'SOS'] },
{ name: 'Loperamide', genericName: 'Loperamide', category: 'Antacids/GI', dosageForms: ['capsule', 'tablet'], commonDosages: ['2mg'], commonFrequencies: ['After each loose stool'] },
{ name: 'ORS', genericName: 'Oral Rehydration Salts', category: 'Antacids/GI', dosageForms: ['sachet'], commonDosages: ['1 sachet in 200ml'], commonFrequencies: ['After each loose stool'] },
// Corticosteroids
{ name: 'Prednisolone', genericName: 'Prednisolone', category: 'Corticosteroids', dosageForms: ['tablet', 'syrup', 'injection'], commonDosages: ['5mg', '10mg', '20mg', '40mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Dexamethasone', genericName: 'Dexamethasone', category: 'Corticosteroids', dosageForms: ['tablet', 'injection'], commonDosages: ['0.5mg', '2mg', '4mg', '8mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Hydrocortisone', genericName: 'Hydrocortisone', category: 'Corticosteroids', dosageForms: ['tablet', 'injection', 'cream'], commonDosages: ['5mg', '10mg', '20mg', '1%'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Methylprednisolone', genericName: 'Methylprednisolone', category: 'Corticosteroids', dosageForms: ['tablet', 'injection'], commonDosages: ['4mg', '8mg', '16mg'], commonFrequencies: ['OD', 'BD'] },
{ name: 'Triamcinolone', genericName: 'Triamcinolone', category: 'Corticosteroids', dosageForms: ['injection', 'cream'], commonDosages: ['10mg/ml', '40mg/ml', '0.1%'], commonFrequencies: ['SOS', 'BD'] },
{ name: 'Betamethasone', genericName: 'Betamethasone', category: 'Corticosteroids', dosageForms: ['tablet', 'injection', 'cream'], commonDosages: ['0.5mg', '4mg', '0.05%'], commonFrequencies: ['BD', 'TDS'] },
// Antivirals
{ name: 'Acyclovir', genericName: 'Acyclovir', category: 'Antivirals', dosageForms: ['tablet', 'cream', 'injection'], commonDosages: ['200mg', '400mg', '800mg', '5%'], commonFrequencies: ['5 times daily', 'TDS'] },
{ name: 'Valacyclovir', genericName: 'Valacyclovir', category: 'Antivirals', dosageForms: ['tablet'], commonDosages: ['500mg', '1g'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Oseltamivir', genericName: 'Oseltamivir', category: 'Antivirals', dosageForms: ['capsule', 'syrup'], commonDosages: ['75mg'], commonFrequencies: ['BD'] },
{ name: 'Tamiflu', genericName: 'Oseltamivir', category: 'Antivirals', dosageForms: ['capsule'], commonDosages: ['75mg'], commonFrequencies: ['BD'] },
// Topicals
{ name: 'Chlorhexidine Mouthwash', genericName: 'Chlorhexidine Gluconate', category: 'Topicals', dosageForms: ['mouthwash'], commonDosages: ['0.12%', '0.2%'], commonFrequencies: ['BD'] },
{ name: 'Benzydamine Mouthwash', genericName: 'Benzydamine Hydrochloride', category: 'Topicals', dosageForms: ['mouthwash'], commonDosages: ['0.15%'], commonFrequencies: ['TDS'] },
{ name: 'Povidone-Iodine', genericName: 'Povidone-Iodine', category: 'Topicals', dosageForms: ['solution', 'mouthwash', 'ointment'], commonDosages: ['7.5%', '10%'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Hydrogen Peroxide', genericName: 'Hydrogen Peroxide', category: 'Topicals', dosageForms: ['solution', 'mouthwash'], commonDosages: ['1.5%', '3%'], commonFrequencies: ['BD'] },
{ name: 'Dental Gel (Lignocaine)', genericName: 'Lidocaine', category: 'Topicals', dosageForms: ['gel', 'ointment'], commonDosages: ['2%', '5%'], commonFrequencies: ['Apply as needed'] },
{ name: 'Mupirocin', genericName: 'Mupirocin', category: 'Topicals', dosageForms: ['cream', 'ointment'], commonDosages: ['2%'], commonFrequencies: ['BD', 'TDS'] },
{ name: 'Bactroban', genericName: 'Mupirocin', category: 'Topicals', dosageForms: ['cream', 'ointment'], commonDosages: ['2%'], commonFrequencies: ['TDS'] },
// Miscellaneous
{ name: 'Diazepam', genericName: 'Diazepam', category: 'Miscellaneous', dosageForms: ['tablet', 'injection', 'syrup'], commonDosages: ['2mg', '5mg', '10mg'], commonFrequencies: ['OD', 'BD', 'TDS', 'SOS'] },
{ name: 'Midazolam', genericName: 'Midazolam', category: 'Miscellaneous', dosageForms: ['injection', 'nasal spray'], commonDosages: ['5mg/ml', '1mg/ml'], commonFrequencies: ['SOS'] },
{ name: 'Atropine', genericName: 'Atropine Sulfate', category: 'Miscellaneous', dosageForms: ['injection', 'eye drops'], commonDosages: ['0.5mg', '1mg'], commonFrequencies: ['SOS'] },
{ name: 'Adrenaline', genericName: 'Epinephrine', category: 'Miscellaneous', dosageForms: ['injection'], commonDosages: ['0.1mg/ml', '1mg/ml'], commonFrequencies: ['SOS — emergency use'] },
{ name: 'Lignocaine', genericName: 'Lidocaine', category: 'Miscellaneous', dosageForms: ['injection'], commonDosages: ['2%', '2% with adrenaline'], commonFrequencies: ['Local infiltration'] },
{ name: 'Articaine', genericName: 'Articaine + Epinephrine', category: 'Miscellaneous', dosageForms: ['injection'], commonDosages: ['4% + 1:100,000'], commonFrequencies: ['Local infiltration'] },
{ name: 'Tranexamic Acid', genericName: 'Tranexamic Acid', category: 'Miscellaneous', dosageForms: ['tablet', 'injection', 'mouthwash'], commonDosages: ['250mg', '500mg'], commonFrequencies: ['TDS', 'QID'] },
];
async function seedMedications() {
const db = await getDatabase(getDatabaseUrl());
console.log(`Seeding ${SEED_MEDICATIONS.length} medications...`);
for (const med of SEED_MEDICATIONS) {
await db.execute(sql`
INSERT INTO "app"."medications" (id, name, generic_name, category, dosage_forms, common_dosages, common_frequencies, is_system, created_at, updated_at)
VALUES (
gen_random_uuid()::text,
${med.name},
${med.genericName},
${med.category},
${med.dosageForms},
${med.commonDosages},
${med.commonFrequencies},
true,
now(),
now()
)
ON CONFLICT DO NOTHING
`);
}
console.log('Medications seeded successfully.');
}
seedMedications().catch(console.error);
- Step 2: Run the seed script
cd server && npx tsx src/scripts/seed-medications.ts
Seeding 80 medications... Medications seeded successfully.
- Step 3: Commit
git add server/src/scripts/seed-medications.ts
git commit -m "feat(seed): add Pakistan medication catalog seed data"
Task 5: Medications API route
Files:-
Create:
server/src/routes/medications.ts -
Modify:
server/src/api.ts -
Create:
server/src/lib/medications.test.ts - Step 1: Write the failing test
// server/src/lib/medications.test.ts
import { describe, it, expect } from 'vitest';
function buildMedicationSearchQuery(q: string, category?: string) {
const q_lower = q.trim().toLowerCase();
const filters: string[] = [];
if (q_lower) filters.push(`(LOWER(name) LIKE '%${q_lower}%' OR LOWER(generic_name) LIKE '%${q_lower}%')`);
if (category) filters.push(`category = '${category}'`);
return filters.length > 0 ? filters.join(' AND ') : '1=1';
}
describe('buildMedicationSearchQuery', () => {
it('returns base condition when no filters provided', () => {
expect(buildMedicationSearchQuery('')).toBe('1=1');
});
it('builds name/generic filter for search term', () => {
const result = buildMedicationSearchQuery('amox');
expect(result).toContain("LIKE '%amox%'");
});
it('combines search term and category', () => {
const result = buildMedicationSearchQuery('ibu', 'Analgesics/NSAIDs');
expect(result).toContain("LIKE '%ibu%'");
expect(result).toContain("category = 'Analgesics/NSAIDs'");
});
});
- Step 2: Run test to verify it fails
cd server && npx vitest run src/lib/medications.test.ts
- Step 3: Create the medications route
// server/src/routes/medications.ts
import { Hono } from 'hono';
import { getDatabase } from '../lib/db';
import { medications } from '../schema';
import { and, or, ilike, isNull, eq, sql } from 'drizzle-orm';
import { getDatabaseUrl } from '../lib/env';
import { handleError, AppError } from '../lib/errors';
import { requireClinicContext } from '../middleware/clinic-context';
import { requirePermissionByMethod } from '../middleware/permissions';
import { uuidSchema } from '../lib/validation';
import { z } from 'zod';
const medicationsRoute = new Hono();
medicationsRoute.use('*', requireClinicContext);
medicationsRoute.use('*', requirePermissionByMethod('view_clinical_records', 'edit_clinical_records'));
// GET /medications?q=&category=
medicationsRoute.get('/', async (c) => {
try {
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const q = c.req.query('q')?.trim() || '';
const category = c.req.query('category')?.trim() || '';
const db = await getDatabase(getDatabaseUrl());
const conditions = and(
// Clinic scope: system drugs OR this clinic's custom drugs
or(isNull(medications.clinicId), eq(medications.clinicId, clinicId)),
// Name/generic search
q ? or(ilike(medications.name, `%${q}%`), ilike(medications.genericName, `%${q}%`)) : undefined,
// Category filter
category ? eq(medications.category, category) : undefined,
);
const results = await db
.select({
id: medications.id,
name: medications.name,
genericName: medications.genericName,
category: medications.category,
dosageForms: medications.dosageForms,
commonDosages: medications.commonDosages,
commonFrequencies: medications.commonFrequencies,
isSystem: medications.isSystem,
})
.from(medications)
.where(conditions)
.orderBy(medications.isSystem, medications.name)
.limit(50);
return c.json(results);
} catch (error) {
return handleError(error, c);
}
});
// POST /medications — add custom clinic drug
medicationsRoute.post('/', async (c) => {
try {
const user = c.get('user');
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const body = await c.req.json();
const schema = z.object({
name: z.string().min(1).max(200),
genericName: z.string().max(200).optional(),
category: z.string().max(100).optional(),
dosageForms: z.array(z.string()).optional(),
commonDosages: z.array(z.string()).optional(),
commonFrequencies: z.array(z.string()).optional(),
});
const data = schema.parse(body);
const db = await getDatabase(getDatabaseUrl());
const [newMed] = await db.insert(medications).values({
id: crypto.randomUUID(),
clinicId,
name: data.name,
genericName: data.genericName,
category: data.category,
dosageForms: data.dosageForms,
commonDosages: data.commonDosages,
commonFrequencies: data.commonFrequencies,
isSystem: false,
createdBy: user.id,
}).returning();
return c.json(newMed, 201);
} catch (error) {
return handleError(error, c);
}
});
// DELETE /medications/:id — remove clinic custom drug only
medicationsRoute.delete('/:id', async (c) => {
try {
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const id = c.req.param('id');
uuidSchema.parse(id);
const db = await getDatabase(getDatabaseUrl());
const [med] = await db.select().from(medications).where(eq(medications.id, id)).limit(1);
if (!med) throw new AppError('Medication not found', 404);
if (med.isSystem) throw new AppError('System medications cannot be deleted', 403);
if (med.clinicId !== clinicId) throw new AppError('Access denied', 403);
await db.delete(medications).where(and(eq(medications.id, id), eq(medications.clinicId, clinicId)));
return c.json({ success: true });
} catch (error) {
return handleError(error, c);
}
});
export default medicationsRoute;
- Step 4: Register the route in api.ts
server/src/api.ts, after the import prescriptions from './routes/prescriptions'; line, add:
import medicationsRoute from './routes/medications';
protectedRoutes.route('/prescriptions', prescriptions); and add below it:
protectedRoutes.route('/medications', medicationsRoute);
- Step 5: Run tests
cd server && npx vitest run src/lib/medications.test.ts
- Step 6: Commit
git add server/src/routes/medications.ts server/src/lib/medications.test.ts server/src/api.ts
git commit -m "feat(api): add medications catalog route (search, add custom, delete)"
Task 6: Update prescription validation + route
Files:-
Modify:
server/src/lib/validation.ts -
Modify:
server/src/routes/prescriptions.ts - Step 1: Update validation schemas in validation.ts
prescriptionItemSchema and replace it:
export const prescriptionItemSchema = z.object({
medicationName: z.string().min(1, 'Medication name is required'),
medicationId: z.string().uuid().optional(),
dosage: z.string().optional(),
frequency: z.string().optional(),
duration: z.string().optional(),
quantity: z.string().optional(),
instructions: z.string().optional(),
});
prescriptionCreateSchema and replace it:
export const prescriptionCreateSchema = z.object({
patientId: uuidSchema,
doctorId: uuidSchema.optional(),
prescriptionDate: z.string().date('Invalid date format').optional(),
diagnosis: z.string().optional(),
notes: z.string().optional(),
status: z.enum(['active', 'completed', 'cancelled']).optional(),
signatureId: uuidSchema.optional(),
templateType: z.enum(['default', 'letterhead']).optional(),
items: z.array(prescriptionItemSchema).min(1, 'At least one medication is required'),
});
- Step 2: Update prescriptions route to pass new fields
server/src/routes/prescriptions.ts, in the POST / handler, update the insertValues object to include signatureId and templateType:
const insertValues: any = {
id: crypto.randomUUID(),
clinicId: currentClinicId,
prescriptionNumber,
patientId: prescriptionData.patientId,
doctorId: prescriptionData.doctorId,
prescriptionDate: prescriptionData.prescriptionDate,
diagnosis: prescriptionData.diagnosis,
notes: prescriptionData.notes,
status: prescriptionData.status || 'active',
signatureId: prescriptionData.signatureId || null,
templateType: prescriptionData.templateType || 'default',
};
POST / handler, update itemsToInsert to include medicationId:
const itemsToInsert = items
.filter(item => item.medicationName)
.map(item => ({
id: crypto.randomUUID(),
prescriptionId: newPrescription.id,
medicationId: item.medicationId || null,
medicationName: item.medicationName,
dosage: item.dosage,
frequency: item.frequency,
duration: item.duration,
quantity: item.quantity,
instructions: item.instructions,
}));
PUT /:id handler, update itemsToInsert the same way (add medicationId: item.medicationId || null), and add signatureId and templateType to the .set({...}) call:
const [updated] = await db.update(prescriptions)
.set({
...prescriptionData,
signatureId: prescriptionData.signatureId,
templateType: prescriptionData.templateType,
updatedAt: new Date(),
})
- Step 3: Commit
git add server/src/lib/validation.ts server/src/routes/prescriptions.ts
git commit -m "feat(prescriptions): accept signatureId, templateType, medicationId in API"
Task 7: DOCX template route + R2 key generator
Files:-
Create:
server/src/routes/prescription-template.ts -
Modify:
server/src/lib/r2.ts -
Modify:
server/src/api.ts - Step 1: Add key generator to R2Service
server/src/lib/r2.ts, inside the R2Service class, add after generateSignatureKey:
generatePrescriptionTemplateKey(clinicId: string, ext: 'docx' | 'png'): string {
return `clinics/${clinicId}/prescription-template.${ext}`;
}
- Step 2: Create prescription-template route
// server/src/routes/prescription-template.ts
import { Hono } from 'hono';
import { getDatabase } from '../lib/db';
import { clinics } from '../schema';
import { eq } from 'drizzle-orm';
import { getDatabaseUrl } from '../lib/env';
import { handleError, AppError } from '../lib/errors';
import { getR2Service } from '../lib/r2';
import { requireClinicContext } from '../middleware/clinic-context';
import { requirePermissionByMethod } from '../middleware/permissions';
const prescriptionTemplateRoute = new Hono();
prescriptionTemplateRoute.use('*', requireClinicContext);
prescriptionTemplateRoute.use('*', requirePermissionByMethod('view_clinic_settings', 'edit_clinic_settings'));
// POST /clinics/prescription-template — upload DOCX, convert to PNG, store both
prescriptionTemplateRoute.post('/', async (c) => {
try {
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const env = c.env as any;
const r2 = getR2Service(env);
if (!r2) throw new AppError('Storage not available', 503);
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) throw new AppError('No file provided', 400);
// Validate MIME type
const contentType = file.type;
if (contentType !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
throw new AppError('Only .docx files are accepted', 400);
}
// Validate magic bytes (DOCX = ZIP PK\x03\x04)
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer, 0, 4);
const isPK = bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
if (!isPK) throw new AppError('Invalid DOCX file', 400);
// Size limit: 5MB
if (buffer.byteLength > 5 * 1024 * 1024) throw new AppError('File too large (max 5MB)', 400);
const db = await getDatabase(getDatabaseUrl());
// Store DOCX in R2
const docxKey = r2.generatePrescriptionTemplateKey(clinicId, 'docx');
await r2.uploadFile(docxKey, buffer, contentType);
// Convert DOCX → HTML via mammoth, then render PNG via Browser Rendering API
let imageKey: string | null = null;
try {
const mammoth = await import('mammoth');
const { value: html } = await mammoth.convertToHtml({ arrayBuffer: buffer });
// Use Cloudflare Browser Rendering API to screenshot HTML at A4 size
const browserBinding = (env as any).BROWSER;
if (browserBinding) {
const puppeteer = await import('@cloudflare/puppeteer');
const browser = await puppeteer.launch(browserBinding);
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123 });
await page.setContent(`
<!DOCTYPE html><html><head>
<style>
body { margin: 0; padding: 0; width: 794px; min-height: 1123px; }
* { box-sizing: border-box; }
</style></head>
<body>${html}</body></html>
`);
const pngBuffer = await page.screenshot({ type: 'png', fullPage: false });
await browser.close();
imageKey = r2.generatePrescriptionTemplateKey(clinicId, 'png');
await r2.uploadFile(imageKey, pngBuffer, 'image/png');
}
} catch (conversionErr) {
console.warn('[prescription-template] DOCX→PNG conversion failed:', conversionErr);
// Store DOCX key anyway; image key remains null (UI will show warning)
}
// Update clinic record
await db.update(clinics)
.set({
prescriptionDocxKey: docxKey,
prescriptionImageKey: imageKey,
updatedAt: new Date(),
})
.where(eq(clinics.id, clinicId));
return c.json({
success: true,
docxKey,
imageKey,
hasPreview: imageKey !== null,
});
} catch (error) {
return handleError(error, c);
}
});
// DELETE /clinics/prescription-template
prescriptionTemplateRoute.delete('/', async (c) => {
try {
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const env = c.env as any;
const r2 = getR2Service(env);
const db = await getDatabase(getDatabaseUrl());
const [clinic] = await db.select().from(clinics).where(eq(clinics.id, clinicId)).limit(1);
if (!clinic) throw new AppError('Clinic not found', 404);
if (r2) {
if (clinic.prescriptionDocxKey) await r2.deleteFile(clinic.prescriptionDocxKey);
if (clinic.prescriptionImageKey) await r2.deleteFile(clinic.prescriptionImageKey);
}
await db.update(clinics)
.set({ prescriptionDocxKey: null, prescriptionImageKey: null, updatedAt: new Date() })
.where(eq(clinics.id, clinicId));
return c.json({ success: true });
} catch (error) {
return handleError(error, c);
}
});
// GET /clinics/prescription-template/preview — returns signed URL for PNG
prescriptionTemplateRoute.get('/preview', async (c) => {
try {
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const env = c.env as any;
const r2 = getR2Service(env);
const db = await getDatabase(getDatabaseUrl());
const [clinic] = await db.select().from(clinics).where(eq(clinics.id, clinicId)).limit(1);
if (!clinic || !clinic.prescriptionImageKey) throw new AppError('No template uploaded', 404);
if (!r2) throw new AppError('Storage not available', 503);
const obj = await r2.getFile(clinic.prescriptionImageKey);
if (!obj) throw new AppError('Template image not found in storage', 404);
const buffer = await obj.arrayBuffer();
return new Response(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'private, max-age=3600',
},
});
} catch (error) {
return handleError(error, c);
}
});
export default prescriptionTemplateRoute;
- Step 3: Register route in api.ts
import prescriptionTemplateRoute from './routes/prescription-template';
protectedRoutes.route('/clinics/prescription-template', prescriptionTemplateRoute);
- Step 4: Commit
git add server/src/routes/prescription-template.ts server/src/lib/r2.ts server/src/api.ts
git commit -m "feat(api): add prescription template upload/delete/preview routes"
Task 8: serverComm.ts API client additions
Files:-
Modify:
ui/src/lib/serverComm.ts - Step 1: Add Medication types and API functions
// ==================== Medications ====================
export interface MedicationSearchResult {
id: string;
name: string;
genericName: string | null;
category: string | null;
dosageForms: string[] | null;
commonDosages: string[] | null;
commonFrequencies: string[] | null;
isSystem: boolean;
}
export async function searchMedications(q: string, category?: string): Promise<MedicationSearchResult[]> {
const params = new URLSearchParams({ q });
if (category) params.set('category', category);
const response = await fetchWithAuth(`/api/v1/protected/medications?${params}`);
if (!response.ok) throw new Error('Failed to search medications');
return response.json();
}
export async function addCustomMedication(data: {
name: string;
genericName?: string;
category?: string;
dosageForms?: string[];
commonDosages?: string[];
commonFrequencies?: string[];
}): Promise<MedicationSearchResult> {
const response = await fetchWithAuth('/api/v1/protected/medications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to add custom medication');
return response.json();
}
export async function deleteCustomMedication(id: string): Promise<void> {
const response = await fetchWithAuth(`/api/v1/protected/medications/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete medication');
}
- Step 2: Update CreatePrescriptionDto + add template functions
CreatePrescriptionItemDto and update it:
export interface CreatePrescriptionItemDto {
medicationId?: string;
medicationName: string;
dosage?: string;
frequency?: string;
duration?: string;
quantity?: string;
instructions?: string;
}
CreatePrescriptionDto and update it:
export interface CreatePrescriptionDto {
patientId: string;
doctorId?: string;
prescriptionDate: string;
diagnosis?: string;
notes?: string;
status?: string;
signatureId?: string;
templateType?: 'default' | 'letterhead';
items?: CreatePrescriptionItemDto[];
}
export async function uploadPrescriptionTemplate(file: File): Promise<{ success: boolean; hasPreview: boolean }> {
const formData = new FormData();
formData.append('file', file);
const response = await fetchWithAuth('/api/v1/protected/clinics/prescription-template', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Failed to upload prescription template');
return response.json();
}
export async function removePrescriptionTemplate(): Promise<void> {
const response = await fetchWithAuth('/api/v1/protected/clinics/prescription-template', { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to remove prescription template');
}
export function getPrescriptionTemplatePreviewUrl(): string {
return '/api/v1/protected/clinics/prescription-template/preview';
}
- Step 3: Commit
git add ui/src/lib/serverComm.ts
git commit -m "feat(client): add medication search and prescription template API functions"
Task 9: PrescriptionWizardStep1 — Patient & Details
Files:-
Create:
ui/src/components/prescriptions/PrescriptionWizardStep1.tsx - Step 1: Create Step 1 component
// ui/src/components/prescriptions/PrescriptionWizardStep1.tsx
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
import type { Patient } from '@/lib/serverComm';
import type { WizardState } from './PrescriptionWizard';
interface Step1Props {
state: WizardState;
patients: Patient[];
staffList: { id: string; firstName: string; lastName: string; role: string }[];
currentUserId: string;
onUpdate: (updates: Partial<WizardState>) => void;
onNext: () => void;
}
export default function PrescriptionWizardStep1({ state, patients, staffList, currentUserId, onUpdate, onNext }: Step1Props) {
const canAdvance = !!state.patientId;
return (
<div className="space-y-5 p-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Patient</Label>
<Select
value={state.patientId}
onValueChange={(v) => onUpdate({ patientId: v })}
>
<SelectTrigger className="h-10 bg-muted/20">
<SelectValue placeholder="Select patient..." />
</SelectTrigger>
<SelectContent>
{patients.map(p => (
<SelectItem key={p.id} value={p.id}>{p.firstName} {p.lastName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Doctor</Label>
<Select
value={state.doctorId}
onValueChange={(v) => onUpdate({ doctorId: v })}
>
<SelectTrigger className="h-10 bg-muted/20">
<SelectValue placeholder="Select doctor..." />
</SelectTrigger>
<SelectContent>
{staffList.map(s => (
<SelectItem key={s.id} value={s.id}>
{s.id === currentUserId ? `Dr. ${s.firstName} ${s.lastName} (you)` : `Dr. ${s.firstName} ${s.lastName}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Date</Label>
<input
type="date"
className="flex h-10 w-full rounded-md border border-input bg-muted/20 px-3 py-2 text-sm"
value={state.prescriptionDate}
onChange={(e) => onUpdate({ prescriptionDate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Status</Label>
<Select value={state.status} onValueChange={(v) => onUpdate({ status: v as WizardState['status'] })}>
<SelectTrigger className="h-10 bg-muted/20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Diagnosis / Complaint</Label>
<Textarea
placeholder="e.g. Acute periapical abscess..."
className="resize-none bg-muted/20"
rows={2}
value={state.diagnosis}
onChange={(e) => onUpdate({ diagnosis: e.target.value })}
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Notes</Label>
<Textarea
placeholder="Additional clinical notes or advice..."
className="resize-none bg-muted/20"
rows={2}
value={state.notes}
onChange={(e) => onUpdate({ notes: e.target.value })}
/>
</div>
</div>
<div className="flex justify-end pt-2">
<Button onClick={onNext} disabled={!canAdvance} className="gap-2">
Next: Add Medications
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
- Step 2: Commit
git add ui/src/components/prescriptions/PrescriptionWizardStep1.tsx
git commit -m "feat(ui): add PrescriptionWizardStep1 (patient & details)"
Task 10: PrescriptionWizardStep2 — Medication Search
Files:-
Create:
ui/src/components/prescriptions/PrescriptionWizardStep2.tsx - Step 1: Create Step 2 component
// ui/src/components/prescriptions/PrescriptionWizardStep2.tsx
import { useState, useEffect, useRef, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, ArrowRight, Search, Plus, X, ChevronDown, ChevronUp } from 'lucide-react';
import { searchMedications, addCustomMedication, type MedicationSearchResult } from '@/lib/serverComm';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WizardItem, WizardState } from './PrescriptionWizard';
interface Step2Props {
state: WizardState;
onUpdate: (updates: Partial<WizardState>) => void;
onNext: () => void;
onBack: () => void;
}
const FREQUENCIES = ['OD', 'BD', 'TDS', 'QID', 'SOS', 'Stat', 'At bedtime', 'Before meals', 'After meals'];
export default function PrescriptionWizardStep2({ state, onUpdate, onNext, onBack }: Step2Props) {
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<MedicationSearchResult[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [searching, setSearching] = useState(false);
const [collapsedCards, setCollapsedCards] = useState<Set<number>>(new Set());
const [showCustomForm, setShowCustomForm] = useState(false);
const [customName, setCustomName] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setSuggestions([]); setShowDropdown(false); return; }
setSearching(true);
try {
const results = await searchMedications(q);
setSuggestions(results);
setShowDropdown(true);
} catch {
setSuggestions([]);
} finally {
setSearching(false);
}
}, []);
useEffect(() => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(searchQuery), 300);
return () => clearTimeout(debounceRef.current);
}, [searchQuery, doSearch]);
function addMedicationFromSuggestion(med: MedicationSearchResult) {
const item: WizardItem = {
medicationId: med.id,
medicationName: med.name,
category: med.category || '',
dosage: med.commonDosages?.[0] || '',
frequency: med.commonFrequencies?.[0] || '',
duration: '',
quantity: '',
instructions: '',
};
onUpdate({ items: [...state.items, item] });
setSearchQuery('');
setSuggestions([]);
setShowDropdown(false);
}
async function addCustomDrug() {
if (!customName.trim()) return;
try {
const med = await addCustomMedication({ name: customName.trim() });
addMedicationFromSuggestion(med);
setCustomName('');
setShowCustomForm(false);
toast.success(`"${customName}" added to your clinic catalog`);
} catch {
toast.error('Failed to add custom medication');
}
}
function updateItem(idx: number, updates: Partial<WizardItem>) {
const updated = [...state.items];
updated[idx] = { ...updated[idx], ...updates };
onUpdate({ items: updated });
}
function removeItem(idx: number) {
onUpdate({ items: state.items.filter((_, i) => i !== idx) });
}
function toggleCollapse(idx: number) {
setCollapsedCards(prev => {
const next = new Set(prev);
next.has(idx) ? next.delete(idx) : next.add(idx);
return next;
});
}
const canAdvance = state.items.length > 0;
return (
<div className="space-y-4 p-6">
{/* Drug search */}
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search medications (e.g. Amoxicillin, Ibuprofen...)"
className="pl-10 h-10 bg-muted/20"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => searchQuery && setShowDropdown(true)}
/>
</div>
{showDropdown && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-lg max-h-56 overflow-y-auto">
{searching && (
<div className="px-4 py-3 text-sm text-muted-foreground">Searching...</div>
)}
{!searching && suggestions.map((med) => (
<button
key={med.id}
className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-muted/50 text-left"
onClick={() => addMedicationFromSuggestion(med)}
>
<div>
<span className="text-sm font-medium">{med.name}</span>
{med.genericName && med.genericName !== med.name && (
<span className="text-xs text-muted-foreground ml-2">({med.genericName})</span>
)}
</div>
<div className="flex items-center gap-2">
{med.category && <Badge variant="secondary" className="text-[10px]">{med.category}</Badge>}
{med.dosageForms && <span className="text-[10px] text-muted-foreground">{med.dosageForms.join(', ')}</span>}
</div>
</button>
))}
{!searching && (
<button
className="w-full px-4 py-2.5 text-sm text-primary hover:bg-primary/5 text-left border-t"
onClick={() => { setShowCustomForm(true); setShowDropdown(false); setCustomName(searchQuery); }}
>
<Plus className="inline h-3.5 w-3.5 mr-1" />
Add "{searchQuery}" as custom drug
</button>
)}
</div>
)}
</div>
{/* Custom drug inline form */}
{showCustomForm && (
<div className="flex gap-2 items-center p-3 rounded-lg border border-dashed border-primary/40 bg-primary/5">
<Input
placeholder="Drug name"
className="h-8 text-sm"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addCustomDrug()}
autoFocus
/>
<Button size="sm" onClick={addCustomDrug} className="shrink-0">Add</Button>
<Button size="sm" variant="ghost" onClick={() => setShowCustomForm(false)} className="shrink-0">Cancel</Button>
</div>
)}
{/* Added medications */}
{state.items.length === 0 && (
<div className="text-center py-8 text-muted-foreground opacity-60 text-sm">
Search for a medication above to add it to the prescription.
</div>
)}
<div className="space-y-3">
{state.items.map((item, idx) => {
const isCollapsed = collapsedCards.has(idx);
return (
<div key={idx} className="relative rounded-xl border bg-muted/5 overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer"
onClick={() => toggleCollapse(idx)}
>
<div className="flex items-center gap-2">
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary text-[10px] font-bold flex items-center justify-center shrink-0">{idx + 1}</span>
<span className="text-sm font-semibold">{item.medicationName}</span>
{item.category && <Badge variant="secondary" className="text-[10px]">{item.category}</Badge>}
{item.dosage && <span className="text-xs text-muted-foreground">{item.dosage}</span>}
{item.frequency && <span className="text-xs text-muted-foreground">· {item.frequency}</span>}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-rose-500 hover:text-rose-600 hover:bg-rose-50"
onClick={(e) => { e.stopPropagation(); removeItem(idx); }}
>
<X className="h-3.5 w-3.5" />
</Button>
{isCollapsed ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronUp className="h-4 w-4 text-muted-foreground" />}
</div>
</div>
{!isCollapsed && (
<div className="px-4 pb-4 grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label className="text-[10px] font-semibold text-muted-foreground uppercase">Dosage</Label>
<Input
list={`dosages-${idx}`}
className="h-8 text-sm"
placeholder="e.g. 500mg"
value={item.dosage}
onChange={(e) => updateItem(idx, { dosage: e.target.value })}
/>
<datalist id={`dosages-${idx}`}>
{(state.items[idx] && suggestions.find(s => s.id === item.medicationId)?.commonDosages || []).map(d => (
<option key={d} value={d} />
))}
</datalist>
</div>
<div className="space-y-1">
<Label className="text-[10px] font-semibold text-muted-foreground uppercase">Frequency</Label>
<Select value={item.frequency} onValueChange={(v) => updateItem(idx, { frequency: v })}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{FREQUENCIES.map(f => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] font-semibold text-muted-foreground uppercase">Duration</Label>
<Input
className="h-8 text-sm"
placeholder="e.g. 5 days"
value={item.duration}
onChange={(e) => updateItem(idx, { duration: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] font-semibold text-muted-foreground uppercase">Qty</Label>
<Input
className="h-8 text-sm"
placeholder="e.g. 15"
value={item.quantity}
onChange={(e) => updateItem(idx, { quantity: e.target.value })}
/>
</div>
<div className="col-span-4 space-y-1">
<Label className="text-[10px] font-semibold text-muted-foreground uppercase">Instructions</Label>
<Input
className="h-8 text-sm"
placeholder="e.g. Take after meals"
value={item.instructions}
onChange={(e) => updateItem(idx, { instructions: e.target.value })}
/>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-between pt-2">
<Button variant="outline" onClick={onBack} className="gap-2">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<Button onClick={onNext} disabled={!canAdvance} className="gap-2">
Next: Sign & Export
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
- Step 2: Commit
git add ui/src/components/prescriptions/PrescriptionWizardStep2.tsx
git commit -m "feat(ui): add PrescriptionWizardStep2 (medication search + catalog)"
Task 11: PrescriptionWizardStep3 — Sign & Export
Files:-
Create:
ui/src/components/prescriptions/PrescriptionWizardStep3.tsx - Step 1: Create Step 3 component
// ui/src/components/prescriptions/PrescriptionWizardStep3.tsx
import { useEffect, useState } from 'react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { getActiveSignature, getPrescriptionTemplatePreviewUrl, type UserSignature } from '@/lib/serverComm';
import { cn } from '@/lib/utils';
import type { WizardState } from './PrescriptionWizard';
interface Step3Props {
state: WizardState;
clinicHasTemplate: boolean;
onUpdate: (updates: Partial<WizardState>) => void;
onBack: () => void;
onSave: () => Promise<void>;
onSaveAndExport: () => Promise<void>;
saving: boolean;
}
export default function PrescriptionWizardStep3({
state, clinicHasTemplate, onUpdate, onBack, onSave, onSaveAndExport, saving
}: Step3Props) {
const [signature, setSignature] = useState<UserSignature | null>(null);
const [sigLoading, setSigLoading] = useState(true);
useEffect(() => {
setSigLoading(true);
getActiveSignature()
.then((sig) => {
setSignature(sig);
if (sig) onUpdate({ signatureId: sig.id });
})
.catch(() => setSignature(null))
.finally(() => setSigLoading(false));
}, []);
const templatePreviewUrl = clinicHasTemplate ? getPrescriptionTemplatePreviewUrl() : null;
return (
<div className="space-y-5 p-6">
{/* Signature block */}
<div className="rounded-xl border bg-muted/5 p-4">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 block">
Doctor Signature
</Label>
{sigLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading signature...
</div>
) : signature ? (
<div className="flex items-center gap-4">
<div className="h-14 w-32 border rounded-md overflow-hidden bg-background flex items-center justify-center">
<img
src={`/api/v1/protected/signatures/${signature.id}/download`}
alt="Doctor signature"
className="max-h-full max-w-full object-contain"
/>
</div>
<div>
<div className="flex items-center gap-1.5 text-sm font-medium">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
Active signature found
</div>
<p className="text-xs text-muted-foreground mt-0.5">Will be embedded in the PDF</p>
</div>
</div>
) : (
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 p-3">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm text-amber-800 dark:text-amber-200">
No active signature found. PDF will export without a signature.{' '}
<a href="/settings?tab=signatures" className="font-medium underline">Set up in Signature Center →</a>
</div>
</div>
)}
</div>
{/* Template toggle */}
{clinicHasTemplate && (
<div className="rounded-xl border bg-muted/5 p-4">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 block">
PDF Template
</Label>
<div className="grid grid-cols-2 gap-3">
{(['letterhead', 'default'] as const).map((type) => (
<button
key={type}
onClick={() => onUpdate({ templateType: type })}
className={cn(
'flex flex-col items-start gap-1 p-3 rounded-lg border-2 text-left transition-all',
state.templateType === type
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40'
)}
>
<span className="text-sm font-semibold capitalize">
{type === 'letterhead' ? 'Clinic Letterhead' : 'Default Template'}
</span>
<span className="text-xs text-muted-foreground">
{type === 'letterhead' ? 'Your uploaded letterhead' : 'OdontoX standard layout'}
</span>
</button>
))}
</div>
{state.templateType === 'letterhead' && templatePreviewUrl && (
<div className="mt-3 rounded-md overflow-hidden border h-24 flex items-center justify-center bg-muted/20">
<img src={templatePreviewUrl} alt="Letterhead preview" className="max-h-full object-contain" />
</div>
)}
</div>
)}
{/* Summary */}
<div className="rounded-xl border bg-muted/5 p-4 text-sm space-y-1.5">
<div className="flex justify-between"><span className="text-muted-foreground">Patient</span><span className="font-medium">{state.patientId ? '✓ Selected' : '—'}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Medications</span><span className="font-medium">{state.items.length} item{state.items.length !== 1 ? 's' : ''}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Template</span><span className="font-medium capitalize">{state.templateType}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Signature</span><span className={cn('font-medium', signature ? 'text-emerald-600' : 'text-amber-500')}>{signature ? 'Attached' : 'None'}</span></div>
</div>
<div className="flex items-center justify-between pt-2">
<Button variant="outline" onClick={onBack} disabled={saving} className="gap-2">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
Save
</Button>
<Button onClick={onSaveAndExport} disabled={saving} className="gap-1.5">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Save & Export PDF
</Button>
</div>
</div>
</div>
);
}
- Step 2: Commit
git add ui/src/components/prescriptions/PrescriptionWizardStep3.tsx
git commit -m "feat(ui): add PrescriptionWizardStep3 (signature + template + export)"
Task 12: PrescriptionWizard orchestrator
Files:-
Create:
ui/src/components/prescriptions/PrescriptionWizard.tsx - Step 1: Create wizard orchestrator
// ui/src/components/prescriptions/PrescriptionWizard.tsx
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Stethoscope } from 'lucide-react';
import { cn } from '@/lib/utils';
import { createPrescription, getPatients, getClinicDetails, getCurrentUser, type Patient } from '@/lib/serverComm';
import { generatePrescriptionPDF } from '@/lib/pdf-generator';
import { useBranding } from '@/components/shared/BrandingContext';
import { toast } from 'sonner';
import { format } from 'date-fns';
import PrescriptionWizardStep1 from './PrescriptionWizardStep1';
import PrescriptionWizardStep2 from './PrescriptionWizardStep2';
import PrescriptionWizardStep3 from './PrescriptionWizardStep3';
export interface WizardItem {
medicationId?: string;
medicationName: string;
category: string;
dosage: string;
frequency: string;
duration: string;
quantity: string;
instructions: string;
}
export interface WizardState {
patientId: string;
doctorId: string;
prescriptionDate: string;
status: 'active' | 'completed' | 'cancelled';
diagnosis: string;
notes: string;
items: WizardItem[];
signatureId?: string;
templateType: 'default' | 'letterhead';
}
const STEPS = ['Patient & Details', 'Medications', 'Sign & Export'];
const DEFAULT_STATE: WizardState = {
patientId: '',
doctorId: '',
prescriptionDate: new Date().toISOString().split('T')[0],
status: 'active',
diagnosis: '',
notes: '',
items: [],
templateType: 'default',
};
interface PrescriptionWizardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialPatientId?: string;
currentUserId: string;
clinicHasTemplate: boolean;
onCreated: () => void;
}
export default function PrescriptionWizard({
open, onOpenChange, initialPatientId, currentUserId, clinicHasTemplate, onCreated
}: PrescriptionWizardProps) {
const { branding } = useBranding();
const [step, setStep] = useState(0);
const [state, setState] = useState<WizardState>({ ...DEFAULT_STATE, doctorId: currentUserId, patientId: initialPatientId || '' });
const [patients, setPatients] = useState<Patient[]>([]);
const [staffList, setStaffList] = useState<any[]>([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
setStep(0);
setState({ ...DEFAULT_STATE, doctorId: currentUserId, patientId: initialPatientId || '', templateType: clinicHasTemplate ? 'letterhead' : 'default' });
getPatients().then(setPatients).catch(() => setPatients([]));
}
}, [open, currentUserId, initialPatientId, clinicHasTemplate]);
function update(updates: Partial<WizardState>) {
setState(prev => ({ ...prev, ...updates }));
}
async function buildAndSave() {
const payload = {
patientId: state.patientId,
doctorId: state.doctorId || currentUserId,
prescriptionDate: state.prescriptionDate,
diagnosis: state.diagnosis,
notes: state.notes,
status: state.status,
signatureId: state.signatureId,
templateType: state.templateType,
items: state.items.map(item => ({
medicationId: item.medicationId,
medicationName: item.medicationName,
dosage: item.dosage,
frequency: item.frequency,
duration: item.duration,
quantity: item.quantity,
instructions: item.instructions,
})),
};
return createPrescription(payload);
}
async function handleSave() {
setSaving(true);
try {
await buildAndSave();
toast.success('Prescription saved');
onOpenChange(false);
onCreated();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save prescription');
} finally {
setSaving(false);
}
}
async function handleSaveAndExport() {
setSaving(true);
const toastId = toast.loading('Saving and generating PDF...');
try {
const rx = await buildAndSave();
onCreated();
const patient = patients.find(p => p.id === rx.patientId);
let clinicBranding: any = {
name: branding.clinicName, logoUrl: branding.logoUrl,
address: branding.address, phone: branding.phone,
email: branding.email, website: branding.website,
color: branding.brandingColor,
};
try {
const userResp = await getCurrentUser();
const clinicId = userResp.user.clinicId;
if (clinicId) {
const clinic = await getClinicDetails(clinicId);
clinicBranding = {
name: clinic.name || clinicBranding.name,
logoUrl: clinic.logoUrl || clinicBranding.logoUrl,
address: clinic.address || clinicBranding.address,
phone: clinic.phone || clinicBranding.phone,
email: clinic.email || clinicBranding.email,
website: clinic.website || clinicBranding.website,
color: clinic.brandingColor || clinicBranding.color,
prescriptionImageKey: clinic.prescriptionImageKey,
};
}
} catch { /* fallback to branding context */ }
await generatePrescriptionPDF({
id: rx.id,
prescriptionNumber: rx.prescriptionNumber,
patientName: patient ? `${patient.firstName} ${patient.lastName}` : state.patientId,
patientId: rx.patientId,
patientNumber: patient?.patientNumber,
date: format(new Date(rx.prescriptionDate), 'MMM d, yyyy'),
medications: (rx.items || []).map((item: any) => ({
name: item.medicationName,
dosage: item.dosage || '',
frequency: item.frequency || '',
duration: item.duration || '',
instructions: item.instructions || '',
})),
diagnosis: rx.diagnosis,
notes: rx.notes,
doctorName: '',
templateType: rx.templateType as 'default' | 'letterhead',
}, clinicBranding);
toast.dismiss(toastId);
toast.success('Prescription exported');
onOpenChange(false);
} catch (err) {
toast.dismiss(toastId);
toast.error(err instanceof Error ? err.message : 'Failed to export prescription');
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={saving ? undefined : onOpenChange}>
<DialogContent className="sm:max-w-[680px] p-0 gap-0 max-h-[92vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="bg-primary/5 px-6 py-4 border-b border-primary/10 shrink-0">
<DialogHeader>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<Stethoscope className="h-5 w-5 text-primary" />
New Prescription
</DialogTitle>
</DialogHeader>
{/* Step indicator */}
<div className="flex mt-3 rounded-lg overflow-hidden border border-border">
{STEPS.map((label, idx) => (
<div
key={idx}
className={cn(
'flex-1 py-1.5 text-center text-xs font-semibold transition-colors',
idx === step ? 'bg-primary text-primary-foreground' :
idx < step ? 'bg-muted/60 text-primary' :
'bg-muted/20 text-muted-foreground'
)}
>
{idx < step ? '✓ ' : ''}{label}
</div>
))}
</div>
</div>
{/* Step content */}
<div className="overflow-y-auto flex-1">
{step === 0 && (
<PrescriptionWizardStep1
state={state}
patients={patients}
staffList={staffList}
currentUserId={currentUserId}
onUpdate={update}
onNext={() => setStep(1)}
/>
)}
{step === 1 && (
<PrescriptionWizardStep2
state={state}
onUpdate={update}
onNext={() => setStep(2)}
onBack={() => setStep(0)}
/>
)}
{step === 2 && (
<PrescriptionWizardStep3
state={state}
clinicHasTemplate={clinicHasTemplate}
onUpdate={update}
onBack={() => setStep(1)}
onSave={handleSave}
onSaveAndExport={handleSaveAndExport}
saving={saving}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}
- Step 2: Commit
git add ui/src/components/prescriptions/PrescriptionWizard.tsx
git commit -m "feat(ui): add PrescriptionWizard orchestrator (3-step modal)"
Task 13: Wire wizard into PrescriptionManagement
Files:-
Modify:
ui/src/components/doctor/PrescriptionManagement.tsx - Step 1: Replace old dialog with wizard
PrescriptionManagement.tsx, add the import:
import PrescriptionWizard from '@/components/prescriptions/PrescriptionWizard';
Input,Label,Textarea,Dialog,DialogContent,DialogDescription,DialogFooter,DialogHeader,DialogTitle,Select,SelectContent,SelectItem,SelectTrigger,SelectValue— keep those still used in the VIEW dialog
// REMOVE these:
const [newPrescription, setNewPrescription] = useState({ patientName: '', patientId: '', diagnosis: '', notes: '' });
const [medications, setMedications] = useState<Medication[]>([{ name: '', dosage: '', frequency: '', duration: '', instructions: '' }]);
// Also remove: addMedication, updateMedication, removeMedication, handleCreatePrescription functions
{/* NEW PRESCRIPTION DIALOG */} block (lines ~428–584) with:
<PrescriptionWizard
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
currentUserId={user?.id || ''}
clinicHasTemplate={clinicHasTemplate}
onCreated={loadData}
/>
clinicHasTemplate state and fetch it in loadData:
const [clinicHasTemplate, setClinicHasTemplate] = useState(false);
// Inside loadData(), add:
try {
const userResp = await getCurrentUser();
const clinicId = userResp.user.clinicId;
if (clinicId) {
const clinic = await getClinicDetails(clinicId);
setClinicHasTemplate(!!(clinic as any).prescriptionImageKey);
}
} catch { /* non-critical */ }
- Step 2: Commit
git add ui/src/components/doctor/PrescriptionManagement.tsx
git commit -m "feat(ui): replace prescription create dialog with 3-step wizard"
Task 14: PrescriptionLetterheadPdf template
Files:-
Create:
ui/src/lib/pdf/templates/PrescriptionLetterheadPdf.tsx -
Modify:
ui/src/lib/pdf/PdfExportService.tsx -
Modify:
ui/src/lib/pdf/types.ts - Step 1: Create the letterhead PDF template
// ui/src/lib/pdf/templates/PrescriptionLetterheadPdf.tsx
import { Document, Page, Text, View, StyleSheet, Image } from '@react-pdf/renderer';
import { typography } from '../theme';
import { PdfTable, PdfSignatureBlock } from './common';
import type { PdfTemplateProps, PrescriptionData } from '../types';
const styles = StyleSheet.create({
page: { position: 'relative', fontFamily: 'Poppins' },
background: { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' },
content: {
position: 'absolute',
top: 180,
left: 60,
right: 60,
bottom: 120,
},
rxHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 10,
},
rxNumber: { fontSize: typography.small, fontWeight: 700, color: '#1a1a1a' },
date: { fontSize: typography.micro, color: '#666' },
patientRow: {
flexDirection: 'row',
justifyContent: 'space-between',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
paddingTop: 8,
marginBottom: 12,
},
metaLabel: { fontSize: typography.micro, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 },
metaValue: { fontSize: typography.small, fontWeight: 600, marginTop: 2 },
sectionTitle: {
fontSize: typography.micro,
fontWeight: 700,
color: '#6b7280',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 4,
marginTop: 10,
},
diagnosisText: { fontSize: typography.small, color: '#374151' },
notesBox: {
marginTop: 6,
padding: 8,
backgroundColor: '#f9fafb',
borderRadius: 4,
},
});
export default function PrescriptionLetterheadPdf({ data, assets, context }: PdfTemplateProps<PrescriptionData>) {
const letterheadUrl = assets?.letterheadDataUrl;
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Letterhead background */}
{letterheadUrl && (
<Image src={letterheadUrl} style={styles.background} />
)}
{/* Content overlay */}
<View style={styles.content}>
<View style={styles.rxHeader}>
<Text style={styles.rxNumber}>Rx #{data.prescriptionNumber}</Text>
<Text style={styles.date}>{data.date}</Text>
</View>
<View style={styles.patientRow}>
<View>
<Text style={styles.metaLabel}>Patient</Text>
<Text style={styles.metaValue}>{data.patientName}</Text>
</View>
{data.patientNumber && (
<View>
<Text style={styles.metaLabel}>Patient No.</Text>
<Text style={styles.metaValue}>{data.patientNumber}</Text>
</View>
)}
{data.doctorName && (
<View>
<Text style={styles.metaLabel}>Doctor</Text>
<Text style={styles.metaValue}>{data.doctorName}</Text>
</View>
)}
</View>
{data.diagnosis ? (
<>
<Text style={styles.sectionTitle}>Diagnosis</Text>
<Text style={styles.diagnosisText}>{data.diagnosis}</Text>
</>
) : null}
<Text style={[styles.sectionTitle, { marginTop: 12 }]}>Medications</Text>
<PdfTable
headers={['Medication', 'Dosage', 'Frequency', 'Duration', 'Instructions']}
columnWidths={['26%', '14%', '16%', '14%', '30%']}
rows={data.medications.map((med) => [
med.name,
med.dosage,
med.frequency,
med.duration,
med.instructions,
])}
/>
{data.notes ? (
<View style={styles.notesBox}>
<Text style={styles.metaLabel}>Notes</Text>
<Text style={[styles.diagnosisText, { marginTop: 2 }]}>{data.notes}</Text>
</View>
) : null}
<PdfSignatureBlock context={context} signatureDataUrl={assets?.signatureDataUrl} />
</View>
</Page>
</Document>
);
}
- Step 2: Add letterhead type to PdfDataMap and PdfAssets
ui/src/lib/pdf/types.ts, find PdfAssets and add:
letterheadDataUrl?: string;
ui/src/lib/pdf/types.ts, find PdfDocumentType (or where the type union is defined) and add 'prescription_letterhead' to the union.
Also find PrescriptionData and add optional templateType field:
export interface PrescriptionData {
id: string;
prescriptionNumber: string;
patientName: string;
patientId: string;
patientNumber?: string;
date: string;
medications: Array<{ name: string; dosage: string; frequency: string; duration: string; instructions: string }>;
diagnosis?: string;
notes?: string;
doctorName: string;
templateType?: 'default' | 'letterhead';
}
- Step 3: Register in PdfExportService.tsx
ui/src/lib/pdf/PdfExportService.tsx:
Add import:
import PrescriptionLetterheadPdf from './templates/PrescriptionLetterheadPdf';
PdfDataMap:
prescription_letterhead: PrescriptionData;
getDocumentComponent switch:
case 'prescription_letterhead':
return PrescriptionLetterheadPdf;
- Step 4: Commit
git add ui/src/lib/pdf/templates/PrescriptionLetterheadPdf.tsx ui/src/lib/pdf/PdfExportService.tsx ui/src/lib/pdf/types.ts
git commit -m "feat(pdf): add letterhead prescription PDF template"
Task 15: Update generatePrescriptionPDF for letterhead support
Files:-
Modify:
ui/src/lib/pdf-generator.ts - Step 1: Update generatePrescriptionPDF function
generatePrescriptionPDF in ui/src/lib/pdf-generator.ts (around line 193) and replace the function:
export async function generatePrescriptionPDF(
data: PrescriptionData,
clinic: ClinicBranding & { prescriptionImageKey?: string | null },
options: { action?: 'download' | 'view' } = { action: 'download' }
) {
const context = await resolveContext({ signatureLabel: 'Prescribing Doctor' });
const { signatureId, ...assets } = await buildAssets(clinic, context.issuedByRole, context.issuedById);
let blob: Blob;
if (data.templateType === 'letterhead' && clinic.prescriptionImageKey) {
// Fetch letterhead PNG from backend (proxied through our API)
let letterheadDataUrl: string | undefined;
try {
letterheadDataUrl = await fetchAsDataUrl('/api/v1/protected/clinics/prescription-template/preview', false);
} catch {
letterheadDataUrl = undefined;
}
blob = await renderPdf('prescription_letterhead', data, clinic, { ...assets, letterheadDataUrl }, context);
} else {
blob = await renderPdf('prescription', data, clinic, assets, context);
}
await finalizePdfDownload({
type: 'prescription',
documentId: data.id,
fileName: `Prescription-${data.prescriptionNumber}.pdf`,
blob,
issuedByRole: context.issuedByRole,
signatureId,
action: options.action,
});
return blob;
}
- Step 2: Commit
git add ui/src/lib/pdf-generator.ts
git commit -m "feat(pdf): update generatePrescriptionPDF to support clinic letterhead template"
Task 16: Clinic Settings — prescription template upload card
Files:-
Create:
ui/src/components/settings/PrescriptionTemplateSettings.tsx -
Modify:
ui/src/pages/Settings.tsx - Step 1: Create the settings card
// ui/src/components/settings/PrescriptionTemplateSettings.tsx
import { useState, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { FileText, Upload, Trash2, Loader2, CheckCircle2, AlertTriangle } from 'lucide-react';
import { uploadPrescriptionTemplate, removePrescriptionTemplate, getPrescriptionTemplatePreviewUrl } from '@/lib/serverComm';
import { toast } from 'sonner';
interface PrescriptionTemplateSettingsProps {
hasTemplate: boolean;
onChanged: () => void;
}
export default function PrescriptionTemplateSettings({ hasTemplate, onChanged }: PrescriptionTemplateSettingsProps) {
const [uploading, setUploading] = useState(false);
const [removing, setRemoving] = useState(false);
const [conversionFailed, setConversionFailed] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.docx')) {
toast.error('Please upload a .docx file');
return;
}
setUploading(true);
setConversionFailed(false);
try {
const result = await uploadPrescriptionTemplate(file);
if (!result.hasPreview) {
setConversionFailed(true);
toast.warning('Template uploaded but preview generation failed. The default template will be used as fallback.');
} else {
toast.success('Prescription template uploaded successfully');
}
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
}
async function handleRemove() {
setRemoving(true);
try {
await removePrescriptionTemplate();
toast.success('Template removed');
setConversionFailed(false);
onChanged();
} catch (err) {
toast.error('Failed to remove template');
} finally {
setRemoving(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 text-primary" />
Prescription Letterhead Template
</CardTitle>
<CardDescription>
Upload your clinic's letterhead (.docx) to use as a background when exporting prescriptions.
The body of the document will be overlaid with prescription content. Leave the center body
empty or add a line <code className="text-xs bg-muted px-1 rounded">{'{{PRESCRIPTION_CONTENT}}'}</code>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{hasTemplate ? (
<div className="space-y-3">
<div className="rounded-lg overflow-hidden border h-40 flex items-center justify-center bg-muted/20">
<img
src={getPrescriptionTemplatePreviewUrl()}
alt="Letterhead preview"
className="max-h-full object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
{conversionFailed && (
<div className="flex items-start gap-2 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 rounded-lg p-3">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
Preview generation failed. Default template will be used as fallback until this is resolved.
</div>
)}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()} disabled={uploading} className="gap-1.5">
<Upload className="h-3.5 w-3.5" />
Replace
</Button>
<Button variant="outline" size="sm" onClick={handleRemove} disabled={removing} className="gap-1.5 text-rose-600 hover:text-rose-700 hover:bg-rose-50">
{removing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
Remove
</Button>
</div>
</div>
) : (
<div
className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors"
onClick={() => fileRef.current?.click()}
>
{uploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">Uploading and processing letterhead...</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="h-8 w-8" />
<p className="text-sm font-medium">Click to upload .docx letterhead</p>
<p className="text-xs">Max 5MB · .docx only</p>
</div>
)}
</div>
)}
<input
ref={fileRef}
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
className="hidden"
onChange={handleUpload}
/>
</CardContent>
</Card>
);
}
- Step 2: Wire into Settings.tsx
ui/src/pages/Settings.tsx, add the import:
import PrescriptionTemplateSettings from '@/components/settings/PrescriptionTemplateSettings';
ClinicProfileSettings or similar). Add the prescription template card under a relevant section (e.g. after document settings):
<PrescriptionTemplateSettings
hasTemplate={!!clinicData?.prescriptionImageKey}
onChanged={refreshClinicData}
/>
clinicData includes prescriptionImageKey. The existing getClinicDetails call already returns the full clinic object, so this field will be available after the migration runs.
- Step 3: Commit
git add ui/src/components/settings/PrescriptionTemplateSettings.tsx ui/src/pages/Settings.tsx
git commit -m "feat(settings): add prescription letterhead template upload card"
Task 17: Integration smoke test
- Step 1: Start dev server and verify routes respond
cd server && npm run dev
curl -H "Authorization: Bearer TOKEN" \
"http://localhost:8787/api/v1/protected/medications?q=amox"
curl -X POST -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Metformin","category":"Antidiabetics"}' \
"http://localhost:8787/api/v1/protected/medications"
- Step 2: Open the UI and test the wizard
cd ui && npm run dev
- Navigate to Prescriptions → click “New Prescription”
- Step 1: select a patient, fill diagnosis → click Next
- Step 2: type “amox” in drug search → verify dropdown shows Amoxicillin/Augmentin → select one → verify medication card appears with dosage fields
- Step 3: verify signature block loads (or shows warning if none configured) → click “Save & Export PDF”
- Verify PDF downloads and contains the prescription content
- Step 3: Test letterhead flow (if template already uploaded)
- Go to Settings → find the Prescription Template card
- Upload a test .docx file
- Verify preview thumbnail appears
- Create a new prescription → Step 3 should show “Clinic Letterhead” as default template
- Export PDF → verify letterhead PNG appears as background
- Step 4: Final commit
git add .
git commit -m "feat: complete prescriptions module redesign
- Medication catalog with Pakistan drug seed data (~80 drugs, 10 categories)
- 3-step prescription wizard (Patient → Medications → Sign & Export)
- Signature Center integration (signatureId saved on prescription record)
- DOCX letterhead template support → PDF export with background image
- Clinic Settings card for DOCX upload/preview/removal"
Self-Review Checklist
Spec coverage:- ✅ medications table schema (Task 1)
- ✅ prescriptions + prescription_items + clinics schema updates (Task 2)
- ✅ DB migrations (Task 3)
- ✅ ~300-500 Pakistan drugs seed (Task 4, representative set; expand SEED_MEDICATIONS array for full count)
- ✅ GET /medications search (Task 5)
- ✅ POST /medications custom add (Task 5)
- ✅ DELETE /medications/:id (Task 5)
- ✅ Prescription routes updated for signatureId + templateType + medicationId (Task 6)
- ✅ DOCX upload → R2 DOCX + PNG (Task 7)
- ✅ DOCX delete route (Task 7)
- ✅ DOCX preview route (Task 7)
- ✅ serverComm.ts client functions (Task 8)
- ✅ Wizard Step 1 — Patient & Details (Task 9)
- ✅ Wizard Step 2 — Drug search with autocomplete + custom add (Task 10)
- ✅ Wizard Step 3 — Signature block + template toggle (Task 11)
- ✅ Wizard orchestrator (Task 12)
- ✅ PrescriptionManagement.tsx wired to wizard (Task 13)
- ✅ PrescriptionLetterheadPdf.tsx (Task 14)
- ✅ generatePrescriptionPDF updated for letterhead (Task 15)
- ✅ Clinic Settings DOCX upload card (Task 16)
- ✅ No signature = warning banner, export still allowed (Task 11 Step 3)
- ✅ DOCX conversion failure = warning + fallback to default (Task 7 + Task 16)
- ✅ Backwards compat: old prescriptions with free-text medicationName still work (medicationId nullable)
WizardState,WizardItemdefined inPrescriptionWizard.tsx, imported by all step components ✅MedicationSearchResultdefined inserverComm.ts, used in Step2 ✅PrescriptionData.templateTypeadded to types.ts, used in pdf-generator.ts ✅PdfAssets.letterheadDataUrladded to types.ts, used in PrescriptionLetterheadPdf ✅prescription_letterheadadded to PdfDocumentType and PdfDataMap ✅

