diff --git a/docs/admin-backend-spec.md b/docs/admin-backend-spec.md index 82d4d7e..d01fd78 100644 --- a/docs/admin-backend-spec.md +++ b/docs/admin-backend-spec.md @@ -36,6 +36,8 @@ | 2026-08-06 | Table filters: >3 filters open a modal with Apply/Cancel + active chips (`TableToolbar` declarative API) | | 2026-08-06 | i18n: en/am parity (~666 keys), Noto Sans Ethiopic; locale-aware `formatters`; Gregorian + Amharic month names | | 2026-08-06 | New `ModuleKey`: `pos` (pharmacist, stock_manager, operations, super_admin). Items gated by existing `stock` | +| 2026-08-07 | **Doctor** actor: hospital-affiliated clinicians; Admin roster under organisations; create Rx & orders; guest patients | +| 2026-08-07 | **Guest / unauthenticated orders**: `customerType` registered\|guest; staff/doctor authenticated, end customer optional | ## Conventions @@ -53,6 +55,15 @@ Pharmacist (and optionally stock) sessions include `branchId`. +### External actors (not Admin staff roles) + +| Actor | Auth | Affiliation | Can | +| --- | --- | --- | --- | +| **Doctor** | Separate doctor JWT (or shared IdP with `actor=doctor`) — **not** a pharmacy `StaffRole` | Must belong to ≥1 **hospital** or **clinic** organisation | Create prescriptions; create orders (including for **guest** patients without platform accounts) | +| End customer (guest) | None required for admin/doctor-assisted checkout | — | Is the subject of an order/Rx; may later claim/merge into a registered CRM profile | + +Doctors **cannot** manage platform catalogue, team RBAC, finance settlements, or other hospitals’ rosters. AuthZ is scoped to their hospital org(s). + --- ## Auth & me @@ -93,18 +104,49 @@ UI: `/profile` (every authenticated role; not permission-gated), topbar “My pr | Method | Path | Roles | Description | | --- | --- | --- | --- | -| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `q` | -| POST | `/pharmacy/orders` | pharmacist, operations, super_admin | Staff-created order (call centre / walk-in) | -| GET | `/pharmacy/orders/:id` | pharmacist, operations, super_admin | Detail: header + line items + payment + fulfillment | +| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `customerType`, `q` | +| POST | `/pharmacy/orders` | pharmacist, operations, super_admin, **doctor** | Staff- or doctor-created order (call centre / walk-in / clinical). Supports **guest** customers | +| GET | `/pharmacy/orders/:id` | pharmacist, operations, super_admin, doctor (own) | Detail: header + line items + payment + fulfillment + customer type | | GET | `/pharmacy/orders/:id/timeline` | pharmacist, operations, super_admin | Ordered status/audit events for the detail timeline | | POST | `/orders/:id/confirm` | pharmacist, super_admin | Confirm stock | | POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` | | PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle | -Detail response adds `items[] { sku, name, qty, unitEtb }`, `timeline[] { at, title, detail, tone }`, and resolved `rider`. +### Guest / unauthenticated end-customer orders + +The **authenticated** principal is always staff or a doctor. The **end customer** may be unauthenticated: + +| Field | Notes | +| --- | --- | +| `customerType` | `registered` \| `guest` (required on create) | +| `customerId` | Required when `registered`; **omit** when `guest` | +| `customerName` | Display name (guest walk-in / phone caller / patient) | +| `customerPhone` | **Required for guests**; recommended for registered (denormalised) | +| `notes` | Optional staff/doctor free text | +| `doctorId` / `orgId` | Optional; set when a Doctor actor initiates the order | + +**Limits (proposed):** guest orders must include a valid phone; controlled SKUs still require an approved Rx; no loyalty earn by default for guests unless a later claim/merge attaches the order to a CRM profile. Doctors may place guest orders for patients without accounts, scoped to their hospital. + +**Create body sketch:** + +```json +{ + "customerType": "guest", + "customerName": "Walk-in · Aster", + "customerPhone": "+251911880011", + "notes": "Caregiver collecting", + "branchId": "uuid", + "fulfillment": "pickup", + "channel": "doctor", + "doctorId": "uuid", + "lines": [{ "sku": "PCM-500", "qty": 20 }] +} +``` + +Detail response adds `items[] { sku, name, qty, unitEtb }`, `timeline[] { at, title, detail, tone }`, resolved `rider`, and optional `doctor` / `organisation`. Mock: `mocks/data.orders`, `mocks/data.orderLines`, `mocks/data.orderTimeline` -UI: `/orders/:id` +UI: `/orders/new` (Guest customer vs Registered customer toggle), `/orders/:id` --- @@ -113,14 +155,45 @@ UI: `/orders/:id` | Method | Path | Roles | Description | | --- | --- | --- | --- | | GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue; filters: `status`, `controlled`, `q` | -| POST | `/pharmacy/prescriptions` | pharmacist, super_admin | Staff Rx intake | +| POST | `/pharmacy/prescriptions` | pharmacist, super_admin, **doctor** | Staff Rx intake or doctor-authored Rx; body includes `doctorId` when attributed | | POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` | | POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time | | GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report | -| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin | Detail: items, controlled flags, review trail | +| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin, doctor (own) | Detail: items, controlled flags, review trail, linked doctor | | GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight | -UI: `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section. +UI: `/prescriptions/new` (doctor select), `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section. + +--- + +## Doctors (hospital-affiliated actors) + +Doctors are **not** floating platform users. Onboarding is via a **hospital** or **clinic** organisation (`orgType`). Finance / super_admin manage the roster in Admin; doctors authenticate separately to create clinical artefacts. + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| GET | `/admin/doctors` | finance, super_admin; pharmacist (read for attribution) | Filters: `orgId`, `status`, `q` | +| POST | `/admin/doctors` | finance, super_admin | Invite/create `{ orgId, name, specialty, licenseNumber, phone, email, branchId?, sendInvite? }` — **rejects** if org is not hospital/clinic | +| GET | `/admin/doctors/:id` | finance, super_admin; pharmacist (read) | Profile + recent Rx/orders | +| PATCH | `/admin/doctors/:id` | finance, super_admin | Update profile / deactivate | +| POST | `/admin/doctors/:id/invite` | finance, super_admin | Provision or re-send linked login | +| GET | `/admin/organisations/:id/doctors` | finance, super_admin | Doctors for one hospital org | +| POST | `/auth/doctor/login` | — | Doctor email/password or invite token → doctor JWT | +| GET | `/doctor/me` | doctor | Principal + hospital memberships | +| POST | `/doctor/prescriptions` | doctor | Create Rx (patient may be guest name-only) | +| POST | `/doctor/orders` | doctor | Create order for registered or **guest** patient; hospital-scoped | + +**AuthZ notes** + +- Doctor tokens are scoped to their `orgId`(s); cannot read other hospitals’ doctors or orgs +- Cannot mutate catalogue, stock master, finance, team, or audit +- Pharmacist verification remains the gate for controlled dispensing +- Admin UI module: existing `organisations` permission (finance + super_admin) + +**Fields:** `id`, `name`, `specialty`, `licenseNumber`, `phone`, `email`, `status` (active\|inactive), `orgId`, `orgName`, `branchId?`, `hasLinkedAccount`, `createdAt`, `updatedAt`, `avatarUrl?` + +Mock: `mocks/data.doctors` +UI: `/doctors`, `/doctors/new?orgId=`, `/doctors/:id`; hospital org detail → Add doctor --- @@ -235,22 +308,25 @@ Mock: `mocks/data.procurementRequests` · UI: `/procurement/:id` | GET/PUT | `/admin/finance/gateways` | finance, super_admin | Enable/disable Chapa, ArifPay, Telebirr, M-Pesa | | POST | `/admin/finance/refunds` | finance, super_admin | Refund handling | | GET | `/admin/finance/loyalty-liability` | finance, super_admin | Outstanding Birr liability (points × redemption rate; must reconcile with Loyalty KPI) | -| GET | `/admin/organisations` | finance, super_admin | List | -| POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (active) | +| GET | `/admin/organisations` | finance, super_admin | List; filter `orgType`, `status`, `source` | +| POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (active); body includes `orgType` | | GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue | | POST | `/admin/organisations/:id/approve` | finance, super_admin | Activate credit | | POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject | | GET | `/admin/organisations/:id/statements` | finance, super_admin | Consolidated invoices | | GET | `/admin/finance/settlements/:id` | finance, super_admin | Batch detail: `txnCount`, `feesEtb`, net, unmatched slips | | POST | `/admin/finance/settlements/:id/reconcile` | finance, super_admin | Re-run reconciliation | -| GET | `/admin/organisations/:id` | finance, super_admin | Detail: credit profile + utilization | +| GET | `/admin/organisations/:id` | finance, super_admin | Detail: credit profile + utilization + `orgType` | | GET | `/admin/organisations/:id/members` | finance, super_admin | HR roster `{ name, dept, plan }` | +| GET | `/admin/organisations/:id/doctors` | finance, super_admin | Hospital/clinic doctor roster (empty for other types) | | GET | `/admin/organisations/:id/invoices` | finance, super_admin | `{ id, period, amountEtb, status }` | | PATCH | `/admin/organisations/:id/credit` | finance, super_admin | `{ creditLimitEtb }` (review-gated) | +`orgType`: `corporate` · `hospital` · `clinic` · `ngo` · `other`. Doctor invitation is only valid for `hospital` / `clinic`. + **Cross-repo:** B2B portal `POST /company/register` creates `status=pending`. Admin approve activates credit. -UI: `/organisations/:id` (Approve primary; Reject in overflow + credit-section footer), `/finance` (tabs: Settlements / Trends / Operations + gateways), `/finance/settlements/:id`, `/finance/gateways` +UI: `/organisations/:id` (Approve primary; Reject in overflow + credit-section footer; **Add doctor** on hospital/clinic), `/doctors`, `/finance` (tabs: Settlements / Trends / Operations + gateways), `/finance/settlements/:id`, `/finance/gateways` --- @@ -423,3 +499,7 @@ or admin `POST /admin/users/:id/avatar`. 5. Tickets-app vendor: POS integration is pluggable — confirm production partner API once chosen 6. Invoice OCR provider for `/pos/invoices/extract` (Admin currently simulates extraction) 7. Catalogue merge queue ownership: Stock Manager vs dedicated data-steward role +8. Doctor identity provider: dedicated doctor realm vs B2B portal users with `role=doctor` under hospital orgs +9. Guest order claim: can a later registered customer attach historical guest orders by phone OTP? Retention / masking of guest PII? +10. Guest order ceilings: max ETB / controlled-item rules without registered identity — confirm with compliance +11. Multi-hospital doctors: allow one clinician affiliation to multiple hospital orgs? diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index c897741..f2afc98 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -21,6 +21,9 @@ import { SettlementDetailPage } from '@/pages/finance/SettlementDetailPage' import { OrganisationsPage } from '@/pages/organisations/OrganisationsPage' import { CreateOrganisationPage } from '@/pages/organisations/CreateOrganisationPage' import { OrganisationDetailPage } from '@/pages/organisations/OrganisationDetailPage' +import { DoctorsPage } from '@/pages/doctors/DoctorsPage' +import { CreateDoctorPage } from '@/pages/doctors/CreateDoctorPage' +import { DoctorDetailPage } from '@/pages/doctors/DoctorDetailPage' import { MarketingPage } from '@/pages/marketing/MarketingPage' import { CrmPage } from '@/pages/marketing/CrmPage' import { CustomerDetailPage } from '@/pages/marketing/CustomerDetailPage' @@ -88,6 +91,9 @@ export function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/ui/status-badge.tsx b/src/components/ui/status-badge.tsx index c2753e9..5b64d55 100644 --- a/src/components/ui/status-badge.tsx +++ b/src/components/ui/status-badge.tsx @@ -9,6 +9,7 @@ export type StatusDomain = | 'order' | 'prescription' | 'organisation' + | 'doctor' | 'procurement' | 'settlement' | 'stock' @@ -57,6 +58,15 @@ const STATUS_MAP: Record> = { rejected: { label: 'Rejected', variant: 'danger' }, admin: { label: 'Added by admin', variant: 'muted' }, self_register: { label: 'Self-registered', variant: 'secondary' }, + corporate: { label: 'Corporate', variant: 'muted' }, + hospital: { label: 'Hospital', variant: 'default' }, + clinic: { label: 'Clinic', variant: 'secondary' }, + ngo: { label: 'NGO', variant: 'secondary' }, + other: { label: 'Other', variant: 'muted' }, + }, + doctor: { + active: { label: 'Active', variant: 'success' }, + inactive: { label: 'Inactive', variant: 'muted' }, }, procurement: { open: { label: 'Open', variant: 'secondary' }, diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 2f64cae..d035de1 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -53,6 +53,7 @@ import { Settings, Shield, ShoppingBag, + Stethoscope, Truck, UserCircle, Users, @@ -270,7 +271,19 @@ export const navigationRegistry: NavEntry[] = [ module: 'organisations', icon: Building2, group: 'finance', - keywords: ['b2b', 'corporate', 'credit', 'company', 'institution', 'ngo', 'clinic', 'contract'], + keywords: ['b2b', 'corporate', 'credit', 'company', 'institution', 'ngo', 'clinic', 'contract', 'hospital'], + kind: 'page', + showInSidebar: true, + }, + { + id: 'doctors', + title: 'Doctors', + description: 'Hospital-affiliated clinicians who create prescriptions and orders', + path: '/doctors', + module: 'organisations', + icon: Stethoscope, + group: 'finance', + keywords: ['doctor', 'clinician', 'prescriber', 'physician', 'hospital', 'rx author', 'medical license'], kind: 'page', showInSidebar: true, }, @@ -484,7 +497,18 @@ export const navigationRegistry: NavEntry[] = [ module: 'organisations', icon: Building2, group: 'actions', - keywords: ['create organisation', 'add company', 'new b2b', 'onboard corporate'], + keywords: ['create organisation', 'add company', 'new b2b', 'onboard corporate', 'hospital'], + kind: 'action', + }, + { + id: 'action-new-doctor', + title: 'Add doctor', + description: 'Invite a clinician from a hospital organisation', + path: '/doctors/new', + module: 'organisations', + icon: Stethoscope, + group: 'actions', + keywords: ['create doctor', 'invite doctor', 'add physician', 'prescriber', 'hospital clinician'], kind: 'action', }, { diff --git a/src/layouts/CreatePageLayout.tsx b/src/layouts/CreatePageLayout.tsx index b580fa6..051fa07 100644 --- a/src/layouts/CreatePageLayout.tsx +++ b/src/layouts/CreatePageLayout.tsx @@ -24,6 +24,7 @@ export function CreatePageLayout({ children, footer, className, + cardTitle = 'Details', }: { title: string subtitle?: string @@ -32,28 +33,42 @@ export function CreatePageLayout({ children: ReactNode footer?: ReactNode className?: string + cardTitle?: string }) { return (
-
- -
-

{title}

- {subtitle ?

{subtitle}

: null} -
+
+
+ +
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+
+
- Details + {cardTitle} {subtitle ? {subtitle} : null} {children} - {footer ?
{footer}
: null} + {footer ? ( +
+ {footer} +
+ ) : null}
) } diff --git a/src/layouts/DetailPageLayout.tsx b/src/layouts/DetailPageLayout.tsx index 7a54422..5dc9aeb 100644 --- a/src/layouts/DetailPageLayout.tsx +++ b/src/layouts/DetailPageLayout.tsx @@ -27,8 +27,13 @@ export function DetailPageLayout({ }) { return (
-
-
{actions ?
{actions}
: null}
-
+ {sidebar ? (
{children} diff --git a/src/mocks/catalog.ts b/src/mocks/catalog.ts index 87957ba..394cee6 100644 --- a/src/mocks/catalog.ts +++ b/src/mocks/catalog.ts @@ -69,6 +69,41 @@ export type CatalogBarcode = { label?: string } +/** + * Unit of measure conversion — ERPNext-style. + * `conversionFactor` is how many **stock (base) units** equal one of this UOM. + * The stock UOM always has factor `1`. + */ +export type ItemUom = { + uom: string + conversionFactor: number + isStockUom?: boolean + isPurchaseUom?: boolean + isSalesUom?: boolean +} + +export const commonUoms = [ + 'tablet', + 'capsule', + 'ml', + 'g', + 'strip', + 'blister', + 'box', + 'bottle', + 'pack', + 'tube', + 'vial', + 'ampoule', + 'inhaler', + 'kit', + 'carton', + 'case', + 'dozen', + 'actuation', + 'device', +] as const + export type DraftPackVariant = { id: string packSize: string @@ -107,9 +142,15 @@ export type MedicationItem = { controlledSchedule?: ControlledSchedule | '' packSize: string + /** Stock / base unit of measure — qty on warehouse rows is always in this unit. */ baseUnit: string sellByUnit: string packUnit: string + /** + * Allowed units with conversion into `baseUnit`. + * Prefer `resolveItemUoms(item)` when reading so older rows still resolve. + */ + uoms: ItemUom[] /** Parent item id when this row is a pack-size variant. */ parentItemId?: string @@ -130,8 +171,23 @@ export type MedicationItem = { shortDescription: string dosageGuidance: string sideEffects: string + /** Structured mild / severe lists for the mobile PDP. Fallbacks parse `sideEffects`. */ + sideEffectsMild?: string[] + sideEffectsSevere?: string[] warnings: string storageInstructions: string + /** Mobile “Storage” cards — title + body. */ + storageNotes?: { title: string; body: string }[] + /** Mobile “Directions of use” stepper. */ + directions?: { title: string; body: string }[] + /** Pharmacist tip callout on the mobile product page. */ + pharmacistTip?: string + /** Short use-case chip above the name (e.g. Pain & Fever). */ + useCase?: string + /** Shelf / pack label shown as `product.unit` on mobile (e.g. "16 tablets"). */ + storefrontUnit?: string + /** Compare-at (strike-through) price on mobile. */ + compareAtPriceEtb?: number alternativeItemIds: string[] defaultReorderPoint: number @@ -274,7 +330,176 @@ export function slugifyItem(brand: string, generic: string): string { return base || 'item' } -export const catalogItems: MedicationItem[] = [ +/** + * Build a sensible default UOM table from pack size / sell-by / pack unit labels. + * Parses patterns like "10 capsules/strip" and "box (10 strips)". + */ +export function buildDefaultUoms(item: { + baseUnit: string + sellByUnit: string + packUnit: string + packSize?: string +}): ItemUom[] { + const base = (item.baseUnit || 'unit').trim() || 'unit' + const sellBy = (item.sellByUnit || base).trim() || base + const pack = (item.packUnit || sellBy).trim() || sellBy + const packSize = item.packSize ?? '' + + const perSellMatch = packSize.match(/(\d+(?:\.\d+)?)\s*[\w.-]+\/\s*([\w.-]+)/i) + let sellFactor = 1 + if (sellBy.toLowerCase() === base.toLowerCase()) { + sellFactor = 1 + } else if (perSellMatch && perSellMatch[2].toLowerCase().startsWith(sellBy.toLowerCase().slice(0, 4))) { + sellFactor = Number(perSellMatch[1]) || 10 + } else if (perSellMatch) { + sellFactor = Number(perSellMatch[1]) || 10 + } else { + sellFactor = 10 + } + + const packOfSellMatch = pack.match(/(\d+(?:\.\d+)?)\s*(strip|pack|bottle|blister|tube)/i) + let packFactor = sellFactor + if (pack.toLowerCase() === base.toLowerCase()) { + packFactor = 1 + } else if (pack.toLowerCase() === sellBy.toLowerCase()) { + packFactor = sellFactor + } else if (packOfSellMatch) { + packFactor = sellFactor * (Number(packOfSellMatch[1]) || 10) + } else { + packFactor = sellFactor * 10 + } + + const rows: ItemUom[] = [ + { uom: base, conversionFactor: 1, isStockUom: true, isPurchaseUom: sellBy === base, isSalesUom: sellBy === base }, + ] + + if (sellBy.toLowerCase() !== base.toLowerCase()) { + rows.push({ + uom: sellBy, + conversionFactor: sellFactor, + isPurchaseUom: true, + isSalesUom: true, + }) + } + + if ( + pack.toLowerCase() !== base.toLowerCase() && + pack.toLowerCase() !== sellBy.toLowerCase() + ) { + rows.push({ + uom: pack.replace(/\s*\(.*\)$/, '').trim() || pack, + conversionFactor: packFactor, + isPurchaseUom: true, + }) + } + + return rows +} + +export function resolveItemUoms(item: Pick & { + uoms?: ItemUom[] +}): ItemUom[] { + if (item.uoms && item.uoms.length > 0) return item.uoms + return buildDefaultUoms(item) +} + +/** Convert a quantity in `fromUom` into stock (base) units. */ +export function toStockQty( + qty: number, + fromUom: string, + item: Pick, +): number { + const rows = resolveItemUoms(item) + const row = rows.find((u) => u.uom.toLowerCase() === fromUom.toLowerCase()) + const factor = row?.conversionFactor ?? 1 + return Math.round(qty * factor * 1000) / 1000 +} + +/** Convert stock units into another UOM. */ +export function fromStockQty( + stockQty: number, + toUom: string, + item: Pick, +): number { + const rows = resolveItemUoms(item) + const row = rows.find((u) => u.uom.toLowerCase() === toUom.toLowerCase()) + const factor = row?.conversionFactor || 1 + return Math.round((stockQty / factor) * 1000) / 1000 +} + +export function stockUomOf(item: Pick): string { + const stock = resolveItemUoms(item).find((u) => u.isStockUom) + return stock?.uom ?? item.baseUnit +} + +function splitList(value?: string): string[] { + if (!value?.trim()) return [] + return value + .split(/[,;|]/) + .map((part) => part.trim()) + .filter(Boolean) +} + +/** + * Shape consumed by the Gishen mobile product detail screen + * (`Gishen-Mob/app/(root)/(screens)/product/[id].tsx`). + */ +export function storefrontPdpPreview(item: MedicationItem) { + const mild = + item.sideEffectsMild && item.sideEffectsMild.length > 0 + ? item.sideEffectsMild + : splitList(item.sideEffects) + const severe = item.sideEffectsSevere ?? [] + const directions = + item.directions && item.directions.length > 0 + ? item.directions + : item.dosageGuidance + ? [ + { title: 'How to take', body: item.dosageGuidance }, + { + title: 'Complete the course', + body: item.warnings || 'Follow clinician advice and complete the prescribed course.', + }, + ] + : [] + const storage = + item.storageNotes && item.storageNotes.length > 0 + ? item.storageNotes + : item.storageInstructions + ? [{ title: 'Storage', body: item.storageInstructions }] + : [] + + return { + name: item.brandName || item.genericName, + brand: item.brandName || item.manufacturer, + slug: item.seoSlug, + sku: item.sku, + unit: item.storefrontUnit || item.packSize || `${item.sellByUnit}`, + priceEtb: item.sellingPriceEtb, + compareAtEtb: item.compareAtPriceEtb, + image: item.thumbnailUrl || item.images[0], + images: item.images, + description: item.shortDescription, + requiresPrescription: item.prescriptionRequired, + badge: item.badges[0], + useCase: item.useCase || item.therapeuticClass, + form: item.dosageForm, + genericName: item.genericName, + brandNames: item.brandName || item.genericName, + drugClass: item.therapeuticClass, + dosages: item.ingredients.map((i) => i.strength).filter(Boolean), + sideEffectsMild: mild, + sideEffectsSevere: severe, + storage, + directions, + pharmacistTip: item.pharmacistTip || item.warnings || 'Ask a Gishen pharmacist if you are unsure.', + inStockHint: 'Derived from linked warehouse qty on publish', + } +} + +type SeedItem = Omit & { uoms?: ItemUom[] } + +const catalogSeed: SeedItem[] = [ { id: 'item-amx-500', sku: 'AMX-500', @@ -312,8 +537,33 @@ export const catalogItems: MedicationItem[] = [ shortDescription: 'Broad-spectrum penicillin antibiotic for bacterial infections.', dosageGuidance: 'Adults: 500mg every 8 hours. Children: per kg as directed by clinician.', sideEffects: 'Nausea, diarrhoea, rash. Stop if severe allergic reaction.', + sideEffectsMild: ['Nausea', 'Diarrhoea', 'Mild rash'], + sideEffectsSevere: ['Severe allergic reaction', 'Difficulty breathing', 'Swelling of face or throat'], warnings: 'Do not use if penicillin-allergic. Complete the full course.', storageInstructions: 'Store below 25°C in a dry place.', + storageNotes: [ + { title: 'Temperature', body: 'Store below 25°C in a dry place.' }, + { title: 'Light & moisture', body: 'Keep blister strips in the original carton, away from humidity.' }, + { title: 'Children', body: 'Keep out of reach of children.' }, + ], + directions: [ + { + title: 'Confirm with your pharmacist', + body: 'Tell us about any penicillin allergy before starting this course.', + }, + { + title: 'Take as directed', + body: 'Adults typically take 500mg every 8 hours with water. Do not skip doses.', + }, + { + title: 'Complete the course', + body: 'Finish all capsules even if you feel better, unless your clinician advises otherwise.', + }, + ], + pharmacistTip: 'Tell your pharmacist about any penicillin allergy before use.', + useCase: 'Antibiotic', + storefrontUnit: '10 capsules/strip', + compareAtPriceEtb: 24, alternativeItemIds: ['item-azi-250'], defaultReorderPoint: 40, defaultExpiryAlertDays: 90, @@ -435,8 +685,33 @@ export const catalogItems: MedicationItem[] = [ shortDescription: 'Pain relief and fever reducer for adults and children over 6.', dosageGuidance: 'Adults: 1–2 tablets every 4–6 hours. Max 8 tablets/day.', sideEffects: 'Rare rash or liver issues with overdose.', + sideEffectsMild: ['Nausea', 'Sleepiness', 'Stomach upset'], + sideEffectsSevere: ['Liver damage', 'Rash', 'Breathing issues'], warnings: 'Do not exceed labeled dose. Avoid with other paracetamol products.', storageInstructions: 'Cool, dry place. Keep out of reach of children.', + storageNotes: [ + { title: 'Temperature', body: 'Store at room temperature, between 20°C and 25°C.' }, + { title: 'Light & moisture', body: 'Keep in a dry place, away from direct sunlight.' }, + { title: 'Children', body: 'Keep out of reach of children to prevent accidental ingestion.' }, + ], + directions: [ + { + title: 'Check the dose', + body: 'Adults: 1–2 tablets every 4–6 hours. Do not exceed 8 tablets in 24 hours.', + }, + { + title: 'Avoid doubling up', + body: 'Do not take with other products that contain paracetamol.', + }, + { + title: 'Track your response', + body: 'Contact a pharmacist if pain or fever lasts more than 3 days.', + }, + ], + pharmacistTip: 'Do not exceed 4g of paracetamol in 24 hours across all products.', + useCase: 'Pain & Fever', + storefrontUnit: '10 tablets/strip', + compareAtPriceEtb: 12, alternativeItemIds: ['item-ibu-400'], defaultReorderPoint: 60, defaultExpiryAlertDays: 60, @@ -1034,6 +1309,24 @@ export const catalogItems: MedicationItem[] = [ }, ] +/** Seed items get a default multi-UOM table; override here when factors differ from heuristics. */ +export const catalogItems: MedicationItem[] = catalogSeed.map((item) => { + const uoms = item.uoms?.length ? item.uoms : buildDefaultUoms(item) + return { ...item, uoms, baseUnit: uoms.find((u) => u.isStockUom)?.uom ?? item.baseUnit } +}) + +/** Amoxil: explicit 1 capsule · 10 / strip · 100 / box */ +{ + const amx = catalogItems.find((i) => i.id === 'item-amx-500') + if (amx) { + amx.uoms = [ + { uom: 'capsule', conversionFactor: 1, isStockUom: true }, + { uom: 'strip', conversionFactor: 10, isPurchaseUom: true, isSalesUom: true }, + { uom: 'box', conversionFactor: 100, isPurchaseUom: true }, + ] + } +} + export const itemChangeHistory: ItemChangeEvent[] = [ { id: 'ich-1', diff --git a/src/mocks/data.ts b/src/mocks/data.ts index d8f3c78..7e74176 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -1,5 +1,14 @@ import { portraitFor } from '@/lib/avatar' -import type { Branch, FaqArticle, ModuleKey, Order, Organisation, Prescription, StaffRole } from '@/types' +import type { + Branch, + Doctor, + FaqArticle, + ModuleKey, + Order, + Organisation, + Prescription, + StaffRole, +} from '@/types' export const branches: Branch[] = [ { id: 'br-bole', name: 'Bole Branch', zone: 'Bole', phone: '+251 930 099 850' }, @@ -397,13 +406,69 @@ export let orders: Order[] = [ { id: 'ord-5046', customerName: 'Abebe Kebede', branchId: 'br-bole', fulfillment: 'delivery', status: 'delivered', totalEtb: 612, channel: 'web', zone: 'Bole', address: 'Bole Atlas', assignedRiderId: 'rd-1', createdAt: '2026-07-10T14:20:00Z', paid: true, rxApproved: true }, { id: 'ord-5047', customerName: 'Fikru Assefa', branchId: 'br-cmc', fulfillment: 'delivery', status: 'delivered', totalEtb: 371, channel: 'mobile', zone: 'CMC', address: 'CMC Michael', assignedRiderId: 'rd-2', createdAt: '2026-07-09T11:45:00Z', paid: true }, { id: 'ord-5048', customerName: 'Guest · 0921223344', branchId: 'br-bole', fulfillment: 'pickup', status: 'delivered', totalEtb: 300, channel: 'web', createdAt: '2026-07-08T10:10:00Z', paid: true }, + { + id: 'ord-5049', + customerName: 'Walk-in patient · Aster', + customerType: 'guest', + customerPhone: '+251911880011', + notes: 'Pediatric syrup — caregiver collecting; no app account', + branchId: 'br-piassa', + fulfillment: 'pickup', + status: 'confirmed', + totalEtb: 185, + channel: 'doctor', + createdAt: '2026-08-06T11:05:00Z', + paid: false, + rxApproved: true, + doctorId: 'doc-4', + orgId: 'org-4', + }, + { + id: 'ord-5050', + customerName: 'Guest · 0917556677', + customerType: 'guest', + customerPhone: '+251917556677', + notes: 'Phone order — call back if stock substitute needed', + branchId: 'br-bole', + fulfillment: 'delivery', + status: 'pending', + totalEtb: 640, + channel: 'web', + zone: 'Bole', + address: 'Bole Atlas gate 3', + createdAt: '2026-08-06T11:40:00Z', + paid: false, + }, + { + id: 'ord-5051', + customerName: 'Clinic referral · Melaku', + customerType: 'guest', + customerPhone: '+251911770033', + notes: 'Doctor-initiated for outpatient without Gishen account', + branchId: 'br-bole', + fulfillment: 'delivery', + status: 'confirmed', + totalEtb: 920, + channel: 'doctor', + zone: 'Bole', + address: 'Kazanchis UN ECA road', + createdAt: '2026-08-06T12:10:00Z', + paid: true, + rxApproved: true, + doctorId: 'doc-1', + orgId: 'org-3', + }, ] export let organisations: Organisation[] = [ { id: 'org-1', name: 'Horizon Bank', + orgType: 'corporate', tin: '0001234567', + vatNumber: 'VAT-ET-0001234567', + businessLicense: 'BL-AA/48291/2014', + commercialRegistration: 'CR/015842/2014', status: 'active', creditLimitEtb: 500000, usedEtb: 124000, @@ -416,7 +481,11 @@ export let organisations: Organisation[] = [ { id: 'org-2', name: 'Green NGO Ethiopia', + orgType: 'ngo', tin: '0009876543', + vatNumber: '', + businessLicense: 'BL-AA/99102/2025', + commercialRegistration: '', status: 'pending', creditLimitEtb: 100000, usedEtb: 0, @@ -424,8 +493,139 @@ export let organisations: Organisation[] = [ source: 'self_register', createdAt: '2026-08-05T14:00:00Z', }, + { + id: 'org-3', + name: "St. Paul's Hospital MMC", + orgType: 'hospital', + tin: '0005544332', + vatNumber: 'VAT-ET-0005544332', + businessLicense: 'BL-AA/12044/2007', + commercialRegistration: 'CR/008812/2007', + status: 'active', + creditLimitEtb: 750000, + usedEtb: 186000, + billingContact: 'pharmacy@sphmmc.et', + source: 'admin', + createdAt: '2026-03-12T00:00:00Z', + reviewedBy: 'Yonas Finance', + reviewedAt: '2026-03-14T10:15:00Z', + }, + { + id: 'org-4', + name: 'Tikur Anbessa Specialized Hospital', + orgType: 'hospital', + tin: '0007788990', + vatNumber: 'VAT-ET-0007788990', + businessLicense: 'BL-AA/00418/1999', + commercialRegistration: 'CR/001102/1999', + status: 'active', + creditLimitEtb: 900000, + usedEtb: 412000, + billingContact: 'procurement@tash.et', + source: 'admin', + createdAt: '2026-02-20T00:00:00Z', + reviewedBy: 'Yonas Finance', + reviewedAt: '2026-02-22T08:50:00Z', + }, ] +/** Clinical actors invited from hospital / clinic orgs — not pharmacy staff. */ +export let doctors: Doctor[] = [ + { + id: 'doc-1', + name: 'Dr. Selam', + specialty: 'Internal medicine', + licenseNumber: 'MD-ET-10482', + phone: '+251911400101', + email: 'selam.bekele@sphmmc.et', + status: 'active', + orgId: 'org-3', + orgName: "St. Paul's Hospital MMC", + branchId: 'br-bole', + branchName: 'Bole Branch', + hasLinkedAccount: true, + createdAt: '2026-04-01T09:00:00Z', + updatedAt: '2026-08-01T12:00:00Z', + avatarUrl: portraitFor('Dr. Selam'), + }, + { + id: 'doc-2', + name: 'Dr. Yohannes', + specialty: 'Endocrinology', + licenseNumber: 'MD-ET-11820', + phone: '+251911400202', + email: 'yohannes.tesfaye@sphmmc.et', + status: 'active', + orgId: 'org-3', + orgName: "St. Paul's Hospital MMC", + branchId: 'br-cmc', + branchName: 'CMC Branch', + hasLinkedAccount: true, + createdAt: '2026-04-08T10:00:00Z', + updatedAt: '2026-07-22T09:30:00Z', + avatarUrl: portraitFor('Dr. Yohannes'), + }, + { + id: 'doc-3', + name: 'Dr. Helen', + specialty: 'Orthopedics', + licenseNumber: 'MD-ET-09751', + phone: '+251911400303', + email: 'helen.mengistu@tash.et', + status: 'active', + orgId: 'org-4', + orgName: 'Tikur Anbessa Specialized Hospital', + branchId: 'br-cmc', + branchName: 'CMC Branch', + hasLinkedAccount: true, + createdAt: '2026-03-15T11:00:00Z', + updatedAt: '2026-08-05T16:00:00Z', + avatarUrl: portraitFor('Dr. Helen'), + }, + { + id: 'doc-4', + name: 'Dr. Kalkidan', + specialty: 'Pediatrics', + licenseNumber: 'MD-ET-13208', + phone: '+251911400404', + email: 'kalkidan.asefa@tash.et', + status: 'active', + orgId: 'org-4', + orgName: 'Tikur Anbessa Specialized Hospital', + branchId: 'br-piassa', + branchName: 'Piassa Branch', + hasLinkedAccount: false, + createdAt: '2026-05-20T08:00:00Z', + updatedAt: '2026-06-10T14:00:00Z', + avatarUrl: portraitFor('Dr. Kalkidan'), + }, + { + id: 'doc-5', + name: 'Dr. Bemnet', + specialty: 'General practice', + licenseNumber: 'MD-ET-14055', + phone: '+251911400505', + email: 'bemnet.hailu@sphmmc.et', + status: 'inactive', + orgId: 'org-3', + orgName: "St. Paul's Hospital MMC", + hasLinkedAccount: false, + createdAt: '2026-06-01T09:00:00Z', + updatedAt: '2026-07-01T09:00:00Z', + avatarUrl: portraitFor('Dr. Bemnet'), + }, +] + +/** Wire Rx rows to Doctor actors by denormalised prescriber name (mock convenience). */ +const doctorByPrescriber = Object.fromEntries(doctors.map((d) => [d.name, d])) +for (const rx of prescriptions) { + const doc = rx.prescriber ? doctorByPrescriber[rx.prescriber] : undefined + if (doc) { + rx.doctorId = doc.id + rx.orgId = doc.orgId + } +} + /** Canonical product categories — keep `category` on every stock row in sync with this list. */ export const productCategories = [ 'Antibiotics', @@ -506,6 +706,27 @@ export const customers: Customer[] = [ { id: 'c6', name: 'Dawit Lemma', phone: '+251966778899', tier: 'Bronze', points: 260, branch: 'Piassa', joinedAt: '2026-08-04T00:00:00Z', orders: 2, spendEtb: 670, lastOrderAt: '2026-08-06T10:40:00Z' }, ] +/** Backfill guest vs registered on seed orders (append-only enrichment). */ +const customerByName = Object.fromEntries(customers.map((c) => [c.name, c])) +for (const order of orders) { + if (order.customerType) continue + const guestMatch = /^Guest\s*[·•-]\s*(.+)$/i.exec(order.customerName) + if (guestMatch) { + order.customerType = 'guest' + const digits = guestMatch[1].replace(/\D/g, '') + order.customerPhone = order.customerPhone ?? (digits ? `+251${digits.replace(/^0/, '')}` : undefined) + continue + } + const registered = customerByName[order.customerName] + if (registered) { + order.customerType = 'registered' + order.customerId = registered.id + order.customerPhone = order.customerPhone ?? registered.phone + } else { + order.customerType = 'guest' + } +} + export let faqs: FaqArticle[] = [ { id: 'faq-1', @@ -1243,6 +1464,148 @@ export const stockMovements: Record = { @@ -1298,6 +1669,14 @@ export const orgInvoices: Record = { @@ -2978,6 +3357,13 @@ export { findCatalogItem, itemVariants, skuToItemId, + buildDefaultUoms, + resolveItemUoms, + toStockQty, + fromStockQty, + stockUomOf, + storefrontPdpPreview, + commonUoms, } from './catalog' export type { MedicationItem, @@ -2996,6 +3382,7 @@ export type { CatalogBarcode, DraftPackVariant, FieldGroupOwnership, + ItemUom, } from './catalog' import { skuToItemId as _skuToItemId } from './catalog' diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx index 6315fd7..9ff6ec1 100644 --- a/src/pages/dashboard/DashboardPage.tsx +++ b/src/pages/dashboard/DashboardPage.tsx @@ -50,7 +50,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { EmptyState } from '@/components/ui/empty-state' import { statusLabel } from '@/components/ui/status-badge' import { useAuthStore } from '@/zustand/authStore' -import { canAccess, roleModules } from '@/config/permissions' +import { canAccess } from '@/config/permissions' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { auditLog, branches, @@ -102,16 +103,7 @@ const auditModule: Record = { security: 'audit', } -const QUICK_LINKS: { module: ModuleKey; to: string; label: string }[] = [ - { module: 'prescriptions', to: '/prescriptions', label: 'Rx queue' }, - { module: 'orders', to: '/orders', label: 'Orders' }, - { module: 'dispatch', to: '/dispatch', label: 'Dispatch' }, - { module: 'stock', to: '/stock', label: 'Stock' }, - { module: 'procurement', to: '/procurement', label: 'Procurement' }, - { module: 'organisations', to: '/organisations', label: 'Organisations' }, - { module: 'finance', to: '/finance', label: 'Finance' }, - { module: 'analytics', to: '/analytics', label: 'Analytics' }, -] +const PRIMARY_KPI_COUNT = 4 type Kpi = { label: string @@ -580,7 +572,8 @@ export function DashboardPage() { })) }, [role]) - const quickLinks = role ? QUICK_LINKS.filter((l) => roleModules[role]?.includes(l.module)).slice(0, 4) : [] + const primaryKpis = kpis.slice(0, PRIMARY_KPI_COUNT) + const moreKpis = kpis.slice(PRIMARY_KPI_COUNT) const showSales = can('orders') || can('analytics') || can('finance') const showProducts = can('stock') && popularProducts.length > 0 @@ -591,27 +584,10 @@ export function DashboardPage() { const showRxFunnel = can('prescriptions') const branchLinksEnabled = can('settings') - return ( - -
-

- Signed in as {user?.name} - {role ? ` · ${statusLabel('role', role)}` : null} - {user?.branchName ? ` · scoped to ${user.branchName}` : ' · platform-wide view'} -

- {quickLinks.length > 0 ? ( -
- {quickLinks.map((link) => ( - - ))} -
- ) : null} -
- + function renderKpiGrid(items: Kpi[]) { + return (
- {kpis.map((kpi) => ( + {items.map((kpi) => ( ))}
+ ) + } + + return ( + +
+

+ Signed in as {user?.name} + {role ? ` · ${statusLabel('role', role)}` : null} + {user?.branchName ? ` · scoped to ${user.branchName}` : ' · platform-wide view'} +

+
+ + {moreKpis.length > 0 ? ( + + + Overview + More metrics + + {renderKpiGrid(primaryKpis)} + {renderKpiGrid(moreKpis)} + + ) : ( + renderKpiGrid(primaryKpis) + )} {showSales ? ( organisations.filter((o) => HOSPITAL_TYPES.has(o.orgType) && o.status === 'active'), + [], + ) + const defaultOrg = params.get('orgId') ?? hospitalOrgs[0]?.id ?? '' + + const [form, setForm] = useState({ + name: '', + specialty: '', + licenseNumber: '', + phone: '', + email: '', + orgId: defaultOrg, + branchId: '', + inviteAccount: true, + }) + + const set = + (key: keyof typeof form) => + (e: ChangeEvent) => + setForm((prev) => ({ ...prev, [key]: e.target.value })) + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + const org = organisations.find((o) => o.id === form.orgId) + if (!org || !HOSPITAL_TYPES.has(org.orgType)) { + toast.error('Doctors must belong to a hospital or clinic organisation') + return + } + const branch = branches.find((b) => b.id === form.branchId) + const name = form.name.trim().startsWith('Dr.') ? form.name.trim() : `Dr. ${form.name.trim()}` + const now = new Date().toISOString() + const doctor: Doctor = { + id: `doc-${Date.now().toString().slice(-6)}`, + name, + specialty: form.specialty.trim(), + licenseNumber: form.licenseNumber.trim(), + phone: form.phone.trim(), + email: form.email.trim(), + status: 'active', + orgId: org.id, + orgName: org.name, + branchId: branch?.id, + branchName: branch?.name, + hasLinkedAccount: form.inviteAccount, + createdAt: now, + updatedAt: now, + avatarUrl: portraitFor(name), + } + doctors.unshift(doctor) + toast.success( + form.inviteAccount + ? 'Doctor added — invite queued for linked account' + : 'Doctor added to hospital roster (no login yet)', + ) + navigate(`/doctors/${doctor.id}`) + } + + return ( + + + + + } + > +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + {hospitalOrgs.map((o) => ( + + ))} + +
+
+ + +
+
+ + +
+
+ + + + {branches.map((b) => ( + + ))} + +
+ +
+
+ ) +} diff --git a/src/pages/doctors/DoctorDetailPage.tsx b/src/pages/doctors/DoctorDetailPage.tsx new file mode 100644 index 0000000..457b3c5 --- /dev/null +++ b/src/pages/doctors/DoctorDetailPage.tsx @@ -0,0 +1,182 @@ +import { Link, useParams } from 'react-router-dom' +import { toast } from 'sonner' +import { FileHeart, Mail, ShoppingBag, ShieldOff } from 'lucide-react' +import { DetailPageLayout } from '@/layouts/DetailPageLayout' +import { DetailSection } from '@/components/detail/ActivityTimeline' +import { KeyValueGrid } from '@/components/detail/KeyValueGrid' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { PersonAvatar } from '@/components/ui/avatar' +import { StatusBadge } from '@/components/ui/status-badge' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { doctors, orders, prescriptions } from '@/mocks/data' +import { formatDate, formatDateTime, formatEtb } from '@/lib/utils' + +export function DoctorDetailPage() { + const { id } = useParams() + const doctor = doctors.find((d) => d.id === id) + + if (!doctor) { + return ( + + + + ) + } + + const rxRows = prescriptions.filter((p) => p.doctorId === doctor.id).slice(0, 8) + const orderRows = orders.filter((o) => o.doctorId === doctor.id).slice(0, 8) + + return ( + + + + {doctor.hasLinkedAccount ? 'Account linked' : 'No login yet'} + + } + actions={ + <> + + + + + + } + > +
+ + + {doctor.orgName} + + ), + }, + { label: 'Phone', value: doctor.phone }, + { label: 'Email', value: doctor.email }, + { label: 'Preferred branch', value: doctor.branchName ?? '—' }, + { label: 'Linked account', value: doctor.hasLinkedAccount ? 'Yes' : 'Invite pending' }, + { label: 'Added', value: formatDate(doctor.createdAt) }, + { label: 'Updated', value: formatDateTime(doctor.updatedAt) }, + ]} + /> + + +
+ + + + + ID + Patient + Status + + + + {rxRows.length === 0 ? ( + + + No prescriptions yet + + + ) : ( + rxRows.map((rx) => ( + + + + {rx.id} + + + {rx.customerName} + + + + + )) + )} + +
+
+ + + + + + ID + Customer + Total + + + + {orderRows.length === 0 ? ( + + + No orders yet + + + ) : ( + orderRows.map((o) => ( + + + + {o.id} + + + + {o.customerName} + {o.customerType === 'guest' ? ( + Guest + ) : null} + + {formatEtb(o.totalEtb)} + + )) + )} + +
+
+
+
+
+ ) +} diff --git a/src/pages/doctors/DoctorsPage.tsx b/src/pages/doctors/DoctorsPage.tsx new file mode 100644 index 0000000..7124c02 --- /dev/null +++ b/src/pages/doctors/DoctorsPage.tsx @@ -0,0 +1,178 @@ +import { useMemo, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { Eye, Plus, Stethoscope, UserCheck, UserX, Building2 } from 'lucide-react' +import { PageShell } from '@/layouts/PageShell' +import { StatCard } from '@/components/dashboard/StatCard' +import { Card, CardContent } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { StatusBadge } from '@/components/ui/status-badge' +import { PersonAvatar } from '@/components/ui/avatar' +import { TableToolbar, type TableFilter } from '@/components/table/TableToolbar' +import { RowActions } from '@/components/table/RowActions' +import { doctors as seed, organisations } from '@/mocks/data' +import type { Doctor } from '@/types' + +const HOSPITAL_TYPES = new Set(['hospital', 'clinic']) + +export function DoctorsPage() { + const [params] = useSearchParams() + const orgFilterParam = params.get('orgId') ?? 'all' + const [rows] = useState([...seed]) + const [q, setQ] = useState('') + const [status, setStatus] = useState('all') + const [orgId, setOrgId] = useState(orgFilterParam) + + const hospitalOrgs = useMemo( + () => organisations.filter((o) => HOSPITAL_TYPES.has(o.orgType) && o.status !== 'rejected'), + [], + ) + + const kpis = useMemo(() => { + const scoped = orgId === 'all' ? rows : rows.filter((d) => d.orgId === orgId) + return { + total: scoped.length, + active: scoped.filter((d) => d.status === 'active').length, + inactive: scoped.filter((d) => d.status === 'inactive').length, + linked: scoped.filter((d) => d.hasLinkedAccount).length, + hospitals: hospitalOrgs.length, + } + }, [rows, orgId, hospitalOrgs.length]) + + const filtered = useMemo(() => { + return rows.filter((d) => { + if (orgId !== 'all' && d.orgId !== orgId) return false + if (status !== 'all' && d.status !== status) return false + if (q) { + const needle = q.toLowerCase() + if ( + !d.name.toLowerCase().includes(needle) && + !d.specialty.toLowerCase().includes(needle) && + !d.licenseNumber.toLowerCase().includes(needle) && + !d.orgName.toLowerCase().includes(needle) && + !d.email.toLowerCase().includes(needle) + ) { + return false + } + } + return true + }) + }, [rows, q, status, orgId]) + + const filters: TableFilter[] = [ + { + id: 'status', + label: 'Status', + value: status, + onChange: setStatus, + options: [ + { value: 'all', label: 'All statuses' }, + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + ], + }, + { + id: 'org', + label: 'Hospital', + value: orgId, + onChange: setOrgId, + options: [ + { value: 'all', label: 'All hospitals' }, + ...hospitalOrgs.map((o) => ({ value: o.id, label: o.name })), + ], + }, + ] + + const newHref = orgId !== 'all' ? `/doctors/new?orgId=${orgId}` : '/doctors/new' + + return ( + +
+ + + + +
+ + + { + setQ('') + setStatus('all') + setOrgId('all') + }} + actions={ + + } + /> + + + + + Doctor + Specialty + Hospital + License + Account + Status + Actions + + + + {filtered.length === 0 ? ( + + + No doctors match these filters + + + ) : null} + {filtered.map((d) => ( + + + + + {d.name} + + + {d.specialty} + + + {d.orgName} + + + {d.licenseNumber} + + + {d.hasLinkedAccount ? 'Linked' : 'Invite pending'} + + + + + + + , to: `/doctors/${d.id}` }, + ]} + /> + + + ))} + +
+
+
+
+ ) +} diff --git a/src/pages/items/CreateItemPage.tsx b/src/pages/items/CreateItemPage.tsx index d97d44e..b5494d9 100644 --- a/src/pages/items/CreateItemPage.tsx +++ b/src/pages/items/CreateItemPage.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, type FormEvent, type ReactNode } from 'react' import { Link, useNavigate } from 'react-router-dom' import { toast } from 'sonner' -import { AlertTriangle, Plus, Trash2 } from 'lucide-react' +import { AlertTriangle, Check, ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react' import { CreatePageLayout, createFieldFull, @@ -14,7 +14,6 @@ import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { SelectNative } from '@/components/ui/select-native' import { Badge } from '@/components/ui/badge' -import { Separator } from '@/components/ui/separator' import { cn } from '@/lib/utils' import { useAuthStore } from '@/zustand/authStore' import { @@ -32,14 +31,54 @@ import { scoreMatchKey, slugifyItem, therapeuticClasses, + buildDefaultUoms, type ActiveIngredient, type CatalogBarcode, type ControlledSchedule, + type ItemUom, type MedicationItem, type MerchandisingBadge, type ProductType, } from '@/mocks/data' +const STEPS = [ + { + id: 'core', + title: 'Core identification', + description: 'SKU, names, manufacturer and barcodes', + }, + { + id: 'classification', + title: 'Classification', + description: 'Product type, ingredients and dosage form', + }, + { + id: 'packaging', + title: 'Packaging & units', + description: 'Pack size and units of measurement', + }, + { + id: 'pricing', + title: 'Pricing', + description: 'Cost, shelf price and VAT', + }, + { + id: 'regulatory', + title: 'Regulatory', + description: 'EFDA, Rx gates and age rules', + }, + { + id: 'content', + title: 'Content', + description: 'Display guidance for storefront and staff', + }, + { + id: 'defaults', + title: 'Defaults & review', + description: 'Reorder, badges, eligibility — then save', + }, +] as const + type FormState = { genericName: string brandName: string @@ -66,8 +105,18 @@ type FormState = { shortDescription: string dosageGuidance: string sideEffects: string + sideEffectsMild: string + sideEffectsSevere: string warnings: string storageInstructions: string + pharmacistTip: string + useCase: string + storefrontUnit: string + compareAtPriceEtb: string + direction1Title: string + direction1Body: string + direction2Title: string + direction2Body: string defaultReorderPoint: string defaultExpiryAlertDays: string badges: MerchandisingBadge[] @@ -82,9 +131,18 @@ const emptyBarcode = (): CatalogBarcode => ({ value: '', label: '' }) export function CreateItemPage() { const navigate = useNavigate() const user = useAuthStore((s) => s.user) + const [step, setStep] = useState(0) const [sku] = useState(() => nextItemSku()) const [ingredients, setIngredients] = useState([emptyIngredient()]) const [barcodes, setBarcodes] = useState([emptyBarcode()]) + const [uoms, setUoms] = useState(() => + buildDefaultUoms({ + baseUnit: 'tablet', + sellByUnit: 'strip', + packUnit: 'box', + packSize: '10 tablets/strip', + }), + ) const [form, setForm] = useState({ genericName: '', brandName: '', @@ -111,8 +169,18 @@ export function CreateItemPage() { shortDescription: '', dosageGuidance: '', sideEffects: '', + sideEffectsMild: '', + sideEffectsSevere: '', warnings: '', storageInstructions: 'Store below 25°C in a dry place.', + pharmacistTip: '', + useCase: '', + storefrontUnit: '', + compareAtPriceEtb: '', + direction1Title: 'How to take', + direction1Body: '', + direction2Title: 'Complete the course', + direction2Body: '', defaultReorderPoint: '20', defaultExpiryAlertDays: '90', badges: [], @@ -124,6 +192,8 @@ export function CreateItemPage() { const isRx = form.productType === 'Rx' || form.productType === 'Controlled' const isControlled = form.productType === 'Controlled' const promoLocked = isRx + const isLastStep = step === STEPS.length - 1 + const currentStep = STEPS[step] const matchKey = useMemo( () => @@ -167,24 +237,93 @@ export function CreateItemPage() { }) } + const validateStep = (index: number) => { + switch (index) { + case 0: + if (!form.genericName.trim()) { + toast.error('Generic name / INN is required') + return false + } + return true + case 1: { + const cleanIngredients = ingredients.filter((i) => i.name.trim()) + if (cleanIngredients.length === 0) { + toast.error('Add at least one active ingredient') + return false + } + if (isControlled && !form.controlledSchedule) { + toast.error('Controlled schedule is required for controlled substances') + return false + } + return true + } + case 2: { + if (!form.baseUnit.trim()) { + toast.error('Stock / base unit is required') + return false + } + if (uoms.every((u) => !u.uom.trim())) { + toast.error('Add at least one unit of measure') + return false + } + if (!uoms.some((u) => u.isStockUom)) { + toast.error('Mark one UOM as the stock unit') + return false + } + return true + } + case 3: + if (!form.sellingPriceEtb || Number(form.sellingPriceEtb) < 0) { + toast.error('Selling price is required') + return false + } + return true + default: + return true + } + } + + const goNext = () => { + if (!validateStep(step)) return + setStep((s) => Math.min(s + 1, STEPS.length - 1)) + } + + const goBack = () => setStep((s) => Math.max(s - 1, 0)) + const onSubmit = (e: FormEvent) => { e.preventDefault() - if (!form.genericName.trim()) { - toast.error('Generic name / INN is required') + if (!isLastStep) { + goNext() return } - if (isControlled && !form.controlledSchedule) { - toast.error('Controlled schedule is required for controlled substances') - return + for (let i = 0; i < STEPS.length; i += 1) { + if (!validateStep(i)) { + setStep(i) + return + } } const cleanIngredients = ingredients.filter((i) => i.name.trim()) - if (cleanIngredients.length === 0) { - toast.error('Add at least one active ingredient') - return - } const cleanBarcodes = barcodes.filter((b) => b.value.trim()) const now = new Date().toISOString() const actor = user?.name ?? 'Admin' + const stockUnit = uoms.find((u) => u.isStockUom)?.uom || form.baseUnit + const mild = form.sideEffectsMild + .split(/[,;]/) + .map((s) => s.trim()) + .filter(Boolean) + const severe = form.sideEffectsSevere + .split(/[,;]/) + .map((s) => s.trim()) + .filter(Boolean) + const directions = [ + form.direction1Title.trim() && form.direction1Body.trim() + ? { title: form.direction1Title.trim(), body: form.direction1Body.trim() } + : null, + form.direction2Title.trim() && form.direction2Body.trim() + ? { title: form.direction2Title.trim(), body: form.direction2Body.trim() } + : null, + ].filter(Boolean) as { title: string; body: string }[] + const item: MedicationItem = { id: `item-${sku.toLowerCase()}`, sku, @@ -201,11 +340,25 @@ export function CreateItemPage() { routeOfAdministration: form.routeOfAdministration || undefined, controlledSchedule: form.controlledSchedule || '', packSize: form.packSize, - baseUnit: form.baseUnit, + baseUnit: stockUnit, sellByUnit: form.sellByUnit, packUnit: form.packUnit, + uoms: (uoms.length + ? uoms + : buildDefaultUoms({ + baseUnit: stockUnit, + sellByUnit: form.sellByUnit, + packUnit: form.packUnit, + packSize: form.packSize, + }) + ).map((u) => + u.isStockUom || u.uom === stockUnit + ? { ...u, uom: stockUnit, conversionFactor: 1, isStockUom: true } + : u, + ), costPriceEtb: Number(form.costPriceEtb) || 0, sellingPriceEtb: Number(form.sellingPriceEtb) || 0, + compareAtPriceEtb: form.compareAtPriceEtb ? Number(form.compareAtPriceEtb) : undefined, currency: 'ETB', sellingPriceOwner: 'Platform', vatApplicable: form.vatApplicable, @@ -217,10 +370,24 @@ export function CreateItemPage() { advertisingRestricted: promoLocked ? true : form.advertisingRestricted, ageRestriction: form.ageRestriction, shortDescription: form.shortDescription, - dosageGuidance: form.dosageGuidance, - sideEffects: form.sideEffects, + dosageGuidance: form.dosageGuidance || form.direction1Body, + sideEffects: form.sideEffects || mild.join(', '), + sideEffectsMild: mild, + sideEffectsSevere: severe, warnings: form.warnings, storageInstructions: form.storageInstructions, + storageNotes: form.storageInstructions + ? [{ title: 'Storage', body: form.storageInstructions }] + : undefined, + directions: + directions.length > 0 + ? directions + : form.dosageGuidance + ? [{ title: 'How to take', body: form.dosageGuidance }] + : undefined, + pharmacistTip: form.pharmacistTip || undefined, + useCase: form.useCase || form.therapeuticClass, + storefrontUnit: form.storefrontUnit || form.packSize || undefined, alternativeItemIds: [], defaultReorderPoint: Number(form.defaultReorderPoint) || 20, defaultExpiryAlertDays: Number(form.defaultExpiryAlertDays) || 90, @@ -260,7 +427,7 @@ export function CreateItemPage() { return ( navigate('/items')}> Cancel - + {step > 0 ? ( + + ) : null} + {isLastStep ? ( + + ) : ( + + )} } > -
- {nearDuplicates.length > 0 ? ( + + + + {nearDuplicates.length > 0 && step <= 1 ? (
@@ -292,8 +528,9 @@ export function CreateItemPage() {
) : null} + {step === 0 ? (
- +
@@ -369,11 +606,11 @@ export function CreateItemPage() { ))}
+ ) : null} - - + {step === 1 ? (
- +
@@ -485,11 +722,11 @@ export function CreateItemPage() {

) : null}
+ ) : null} - - + {step === 2 ? (
- +
@@ -500,8 +737,18 @@ export function CreateItemPage() { />
- - setField('baseUnit', e.target.value)} /> + + { + const next = e.target.value + setField('baseUnit', next) + setUoms((prev) => + prev.map((u) => (u.isStockUom ? { ...u, uom: next, conversionFactor: 1 } : u)), + ) + }} + placeholder="tablet" + />
@@ -512,12 +759,153 @@ export function CreateItemPage() { setField('packUnit', e.target.value)} />
+ +
+
+
+

Units of measurement

+

+ Conversion factor = how many stock ({form.baseUnit || 'base'}) units equal one of this UOM. + Stock and warehouse qty always stay in the stock UOM. +

+
+
+ + +
+
+
+ {uoms.map((row, index) => ( +
+ { + const next = e.target.value + setUoms((prev) => + prev.map((u, i) => (i === index ? { ...u, uom: next } : u)), + ) + if (row.isStockUom) setField('baseUnit', next) + }} + /> + + setUoms((prev) => + prev.map((u, i) => + i === index + ? { ...u, conversionFactor: Number(e.target.value) || 1 } + : u, + ), + ) + } + /> +
+ + + +
+ +
+ ))} +
+
+ ) : null} - - + {step === 3 ? (
- +
@@ -538,6 +926,17 @@ export function CreateItemPage() { onChange={(e) => setField('sellingPriceEtb', e.target.value)} />
+
+ + setField('compareAtPriceEtb', e.target.value)} + placeholder="Optional strike-through price" + /> +

Shown crossed out on the mobile PDP

+
+ ) : null} - - + {step === 4 ? (
- +
@@ -642,11 +1041,11 @@ export function CreateItemPage() {
+ ) : null} - - + {step === 5 ? (
- +
@@ -654,6 +1053,24 @@ export function CreateItemPage() { rows={2} value={form.shortDescription} onChange={(e) => setField('shortDescription', e.target.value)} + placeholder="Storefront summary shown on the mobile product page" + /> +
+
+ + setField('useCase', e.target.value)} + placeholder="e.g. Pain & Fever" + /> +

Chip above the product name on mobile

+
+
+ + setField('storefrontUnit', e.target.value)} + placeholder='e.g. "16 tablets" or "10 capsules/strip"' />
@@ -665,11 +1082,30 @@ export function CreateItemPage() { />
- +