Add doctor actor, guest orders, and richer catalog/stock Admin flows.

Wire hospital-affiliated doctors and unauthenticated customer checkout into mocks, UI, and the backend spec so clinical and walk-in paths match the product model.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kirukib 2026-08-07 11:25:45 +03:00
parent fce0149b1a
commit dd471c5909
25 changed files with 3651 additions and 409 deletions

View File

@ -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?

View File

@ -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() {
<Route path="organisations" element={<Guard module="organisations"><OrganisationsPage /></Guard>} />
<Route path="organisations/new" element={<Guard module="organisations"><CreateOrganisationPage /></Guard>} />
<Route path="organisations/:id" element={<Guard module="organisations"><OrganisationDetailPage /></Guard>} />
<Route path="doctors" element={<Guard module="organisations"><DoctorsPage /></Guard>} />
<Route path="doctors/new" element={<Guard module="organisations"><CreateDoctorPage /></Guard>} />
<Route path="doctors/:id" element={<Guard module="organisations"><DoctorDetailPage /></Guard>} />
<Route path="marketing" element={<Guard module="marketing"><MarketingPage /></Guard>} />
<Route path="marketing/:id" element={<Guard module="marketing"><SegmentDetailPage /></Guard>} />
<Route path="crm" element={<Guard module="crm"><CrmPage /></Guard>} />

View File

@ -9,6 +9,7 @@ export type StatusDomain =
| 'order'
| 'prescription'
| 'organisation'
| 'doctor'
| 'procurement'
| 'settlement'
| 'stock'
@ -57,6 +58,15 @@ const STATUS_MAP: Record<StatusDomain, Record<string, StatusEntry>> = {
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' },

View File

@ -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',
},
{

View File

@ -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 (
<div className={cn('w-full min-w-0 space-y-5', className)}>
<div>
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-3 text-muted-foreground">
<Link to={backTo}>
<ArrowLeft className="h-4 w-4" />
{backLabel}
</Link>
</Button>
<div className="mb-2 h-1 w-12 rounded-full bg-gradient-to-r from-brand-500 to-orange" />
<h1 className="font-display text-2xl font-bold tracking-tight text-forest">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p> : null}
</div>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-3">
<Button
asChild
variant="outline"
size="sm"
className="h-9 w-fit gap-1.5 bg-background shadow-none"
>
<Link to={backTo}>
<ArrowLeft className="h-4 w-4" />
{backLabel}
</Link>
</Button>
<div>
<div className="mb-2 h-1 w-12 rounded-full bg-gradient-to-r from-brand-500 to-orange" />
<h1 className="font-display text-2xl font-bold tracking-tight text-forest">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p> : null}
</div>
</div>
</header>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Details</CardTitle>
<CardTitle className="text-base">{cardTitle}</CardTitle>
{subtitle ? <CardDescription className="sr-only">{subtitle}</CardDescription> : null}
</CardHeader>
<CardContent className="space-y-5">{children}</CardContent>
</Card>
{footer ? <div className="flex flex-wrap justify-end gap-2">{footer}</div> : null}
{footer ? (
<div className="sticky bottom-0 z-10 -mx-1 flex flex-wrap justify-end gap-2 border-t bg-background/95 px-1 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/80">
{footer}
</div>
) : null}
</div>
)
}

View File

@ -27,8 +27,13 @@ export function DetailPageLayout({
}) {
return (
<div className={cn('w-full min-w-0 space-y-5', className)}>
<div>
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-3 text-muted-foreground">
<header className="space-y-4">
<Button
asChild
variant="outline"
size="sm"
className="h-9 w-fit gap-1.5 bg-background shadow-none"
>
<Link to={backTo}>
<ArrowLeft className="h-4 w-4" />
{backLabel}
@ -45,7 +50,7 @@ export function DetailPageLayout({
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
</div>
</header>
{sidebar ? (
<div className="grid gap-5 [&>*]:min-w-0 lg:grid-cols-[minmax(0,1fr)_22rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
{children}

View File

@ -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<MedicationItem, 'baseUnit' | 'sellByUnit' | 'packUnit' | 'packSize'> & {
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<MedicationItem, 'baseUnit' | 'sellByUnit' | 'packUnit' | 'packSize' | 'uoms'>,
): 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<MedicationItem, 'baseUnit' | 'sellByUnit' | 'packUnit' | 'packSize' | 'uoms'>,
): 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<MedicationItem, 'baseUnit' | 'sellByUnit' | 'packUnit' | 'packSize' | 'uoms'>): 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<MedicationItem, 'uoms'> & { 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',

View File

@ -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<string, { id: string; at: string; type: stri
],
}
/**
* Stock ledger documents — ERPNext-style receipts, deliveries, transfers and
* adjustments. Inventory qty is still owned by `stockRows`; these rows make the
* Transactions tab feel like a real stock book.
*/
export type StockTxnType = 'receipt' | 'delivery' | 'transfer' | 'adjustment'
export type StockTxnStatus = 'draft' | 'submitted' | 'cancelled'
export type StockTransaction = {
id: string
type: StockTxnType
sku: string
name: string
qty: number
fromBranchId?: string
toBranchId?: string
reference?: string
at: string
status: StockTxnStatus
postedBy: string
}
export let stockTransactions: StockTransaction[] = [
{
id: 'stx-901',
type: 'receipt',
sku: 'AMX-500',
name: 'Amoxicillin 500mg',
qty: 100,
toBranchId: 'br-bole',
reference: 'PO-884',
at: '2026-08-05T14:00:00Z',
status: 'submitted',
postedBy: 'Dawit Stock',
},
{
id: 'stx-902',
type: 'delivery',
sku: 'PCM-500',
name: 'Paracetamol 500mg',
qty: 20,
fromBranchId: 'br-bole',
reference: 'ord-5002',
at: '2026-08-06T09:00:00Z',
status: 'submitted',
postedBy: 'Hana Pharmacist',
},
{
id: 'stx-903',
type: 'transfer',
sku: 'VIT-C',
name: 'Vitamin C 1000mg',
qty: 40,
fromBranchId: 'br-bole',
toBranchId: 'br-cmc',
reference: 'TRF-112',
at: '2026-08-04T16:20:00Z',
status: 'submitted',
postedBy: 'Dawit Stock',
},
{
id: 'stx-904',
type: 'receipt',
sku: 'MTF-850',
name: 'Metformin 850mg',
qty: 80,
toBranchId: 'br-cmc',
reference: 'PO-840',
at: '2026-07-28T09:00:00Z',
status: 'submitted',
postedBy: 'Sara Procurement',
},
{
id: 'stx-905',
type: 'delivery',
sku: 'MTF-850',
name: 'Metformin 850mg',
qty: 120,
fromBranchId: 'br-cmc',
reference: 'ord-5003',
at: '2026-08-06T10:00:00Z',
status: 'submitted',
postedBy: 'System',
},
{
id: 'stx-906',
type: 'adjustment',
sku: 'CTZ-10',
name: 'Cetirizine 10mg',
qty: -2,
fromBranchId: 'br-bole',
reference: 'ERP-SYNC',
at: '2026-07-21T11:00:00Z',
status: 'submitted',
postedBy: 'Dawit Stock',
},
{
id: 'stx-907',
type: 'transfer',
sku: 'IBU-400',
name: 'Ibuprofen 400mg',
qty: 50,
fromBranchId: 'br-cmc',
toBranchId: 'br-piassa',
reference: 'TRF-118',
at: '2026-08-06T11:30:00Z',
status: 'draft',
postedBy: 'Dawit Stock',
},
{
id: 'stx-908',
type: 'receipt',
sku: 'AZI-250',
name: 'Azithromycin 250mg',
qty: 60,
toBranchId: 'br-bole',
reference: 'PO-860',
at: '2026-07-18T09:00:00Z',
status: 'cancelled',
postedBy: 'Sara Procurement',
},
]
/** Physical-count reconciliation lines for the Stock Reconciliation tab. */
export type StockReconciliationLine = {
id: string
sku: string
name: string
branchId: string
bookQty: number
countedQty: number
unitEtb: number
}
export const stockReconciliationLines: StockReconciliationLine[] = [
{ id: 'rcl-1', sku: 'AMX-500', name: 'Amoxicillin 500mg', branchId: 'br-bole', bookQty: 140, countedQty: 138, unitEtb: 45 },
{ id: 'rcl-2', sku: 'PCM-500', name: 'Paracetamol 500mg', branchId: 'br-bole', bookQty: 320, countedQty: 320, unitEtb: 8 },
{ id: 'rcl-3', sku: 'VIT-C', name: 'Vitamin C 1000mg', branchId: 'br-bole', bookQty: 200, countedQty: 195, unitEtb: 120 },
{ id: 'rcl-4', sku: 'MTF-850', name: 'Metformin 850mg', branchId: 'br-cmc', bookQty: 80, countedQty: 76, unitEtb: 18 },
{ id: 'rcl-5', sku: 'CTZ-10', name: 'Cetirizine 10mg', branchId: 'br-bole', bookQty: 88, countedQty: 90, unitEtb: 12 },
]
export let procurementRequests = [
{
id: 'pr-301',
@ -1290,6 +1653,14 @@ export const orgMembers: Record<string, { id: string; name: string; dept: string
],
// Placeholder seat on an org still pending KYC — no real person behind it, so no photo.
'org-2': [{ id: 'om-4', name: 'Pending admin', dept: 'Operations', plan: 'Staff' }],
'org-3': [
{ id: 'om-5', name: 'Dr. Selam', dept: 'Internal medicine', plan: 'Clinician', avatarUrl: portraitFor('Dr. Selam') },
{ id: 'om-6', name: 'Pharmacy liaison', dept: 'Hospital pharmacy', plan: 'Staff', avatarUrl: portraitFor('Pharmacy liaison') },
],
'org-4': [
{ id: 'om-7', name: 'Dr. Helen', dept: 'Orthopedics', plan: 'Clinician', avatarUrl: portraitFor('Dr. Helen') },
{ id: 'om-8', name: 'Dr. Kalkidan', dept: 'Pediatrics', plan: 'Clinician', avatarUrl: portraitFor('Dr. Kalkidan') },
],
}
export const orgInvoices: Record<string, { id: string; period: string; amountEtb: number; status: string }[]> = {
@ -1298,6 +1669,14 @@ export const orgInvoices: Record<string, { id: string; period: string; amountEtb
{ id: 'inv-881', period: 'Aug 2026 (MTD)', amountEtb: 32000, status: 'open' },
],
'org-2': [],
'org-3': [
{ id: 'inv-910', period: 'Jul 2026', amountEtb: 62000, status: 'paid' },
{ id: 'inv-911', period: 'Aug 2026 (MTD)', amountEtb: 41000, status: 'open' },
],
'org-4': [
{ id: 'inv-920', period: 'Jul 2026', amountEtb: 98000, status: 'paid' },
{ id: 'inv-921', period: 'Aug 2026 (MTD)', amountEtb: 72000, status: 'open' },
],
}
export const customerOrders: Record<string, string[]> = {
@ -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'

View File

@ -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<AuditCategory, ModuleKey> = {
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 (
<PageShell title={t('dashboard.title')} subtitle={t('dashboard.subtitle')}>
<div className="flex flex-col gap-3 rounded-2xl border bg-card px-4 py-3 shadow-soft sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Signed in as <strong className="text-foreground">{user?.name}</strong>
{role ? ` · ${statusLabel('role', role)}` : null}
{user?.branchName ? ` · scoped to ${user.branchName}` : ' · platform-wide view'}
</p>
{quickLinks.length > 0 ? (
<div className="flex flex-wrap gap-2">
{quickLinks.map((link) => (
<Button key={link.to} asChild size="sm" variant="outline" className="h-9">
<Link to={link.to}>{link.label}</Link>
</Button>
))}
</div>
) : null}
</div>
function renderKpiGrid(items: Kpi[]) {
return (
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{kpis.map((kpi) => (
{items.map((kpi) => (
<StatCard
key={kpi.label}
label={kpi.label}
@ -622,6 +598,31 @@ export function DashboardPage() {
/>
))}
</div>
)
}
return (
<PageShell title={t('dashboard.title')} subtitle={t('dashboard.subtitle')}>
<div className="rounded-2xl border bg-card px-4 py-3 shadow-soft">
<p className="text-sm text-muted-foreground">
Signed in as <strong className="text-foreground">{user?.name}</strong>
{role ? ` · ${statusLabel('role', role)}` : null}
{user?.branchName ? ` · scoped to ${user.branchName}` : ' · platform-wide view'}
</p>
</div>
{moreKpis.length > 0 ? (
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="more">More metrics</TabsTrigger>
</TabsList>
<TabsContent value="overview">{renderKpiGrid(primaryKpis)}</TabsContent>
<TabsContent value="more">{renderKpiGrid(moreKpis)}</TabsContent>
</Tabs>
) : (
renderKpiGrid(primaryKpis)
)}
{showSales ? (
<ChartCard

View File

@ -0,0 +1,181 @@
import { useMemo, useState, type ChangeEvent, type FormEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout, createFieldWide, createFormGrid } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { cn } from '@/lib/utils'
import { portraitFor } from '@/lib/avatar'
import { branches, doctors, organisations } from '@/mocks/data'
import type { Doctor } from '@/types'
const HOSPITAL_TYPES = new Set(['hospital', 'clinic'])
export function CreateDoctorPage() {
const navigate = useNavigate()
const [params] = useSearchParams()
const hospitalOrgs = useMemo(
() => 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<HTMLInputElement | HTMLSelectElement>) =>
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 (
<CreatePageLayout
title="Add doctor"
subtitle="Invite a clinician from a hospital organisation — they can create prescriptions and orders scoped to that hospital"
backTo="/doctors"
backLabel="Back to doctors"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/doctors')}>
Cancel
</Button>
<Button type="submit" form="create-doctor-form">
Add doctor
</Button>
</>
}
>
<form id="create-doctor-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label htmlFor="doc-name">Full name</Label>
<Input
id="doc-name"
required
value={form.name}
onChange={set('name')}
placeholder="Selam Bekele"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-specialty">Specialty</Label>
<Input
id="doc-specialty"
required
value={form.specialty}
onChange={set('specialty')}
placeholder="Internal medicine"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-license">License / registration</Label>
<Input
id="doc-license"
required
value={form.licenseNumber}
onChange={set('licenseNumber')}
placeholder="MD-ET-10482"
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label htmlFor="doc-org">Hospital organisation</Label>
<SelectNative id="doc-org" required value={form.orgId} onChange={set('orgId')}>
<option value="" disabled>
Select hospital…
</option>
{hospitalOrgs.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-phone">Phone</Label>
<Input
id="doc-phone"
required
value={form.phone}
onChange={set('phone')}
placeholder="+2519…"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-email">Email</Label>
<Input
id="doc-email"
required
type="email"
value={form.email}
onChange={set('email')}
placeholder="doctor@hospital.et"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-branch">Preferred Gishen branch (optional)</Label>
<SelectNative id="doc-branch" value={form.branchId} onChange={set('branchId')}>
<option value="">None</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</SelectNative>
</div>
<label className={cn('flex items-center gap-2 text-sm', createFieldWide)}>
<input
type="checkbox"
checked={form.inviteAccount}
onChange={(e) => setForm((prev) => ({ ...prev, inviteAccount: e.target.checked }))}
/>
Send invite to provision a linked doctor login
</label>
</form>
</CreatePageLayout>
)
}

View File

@ -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 (
<DetailPageLayout backTo="/doctors" title="Doctor not found">
<Button asChild>
<Link to="/doctors">Back to doctors</Link>
</Button>
</DetailPageLayout>
)
}
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 (
<DetailPageLayout
backTo="/doctors"
backLabel="Back to doctors"
title={doctor.name}
subtitle={`${doctor.specialty} · ${doctor.orgName}`}
badges={
<>
<PersonAvatar name={doctor.name} avatarUrl={doctor.avatarUrl} size="md" />
<StatusBadge domain="doctor" status={doctor.status} />
<Badge variant="outline">{doctor.hasLinkedAccount ? 'Account linked' : 'No login yet'}</Badge>
</>
}
actions={
<>
<Button asChild size="sm" className="h-9 gap-1.5">
<Link to={`/prescriptions/new?doctorId=${doctor.id}`}>
<FileHeart className="h-3.5 w-3.5" />
New prescription
</Link>
</Button>
<Button asChild size="sm" variant="outline" className="h-9 gap-1.5">
<Link to={`/orders/new?doctorId=${doctor.id}`}>
<ShoppingBag className="h-3.5 w-3.5" />
New order
</Link>
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.message(doctor.hasLinkedAccount ? 'Invite already accepted' : 'Invite re-queued')}
>
<Mail className="h-3.5 w-3.5" />
{doctor.hasLinkedAccount ? 'Resend credentials' : 'Send invite'}
</Button>
<Button
size="sm"
variant="destructive"
className="h-9 gap-1.5"
onClick={() => toast.message('Doctor deactivated (mock)')}
>
<ShieldOff className="h-3.5 w-3.5" />
Deactivate
</Button>
</>
}
>
<div className="space-y-5">
<DetailSection title="Profile" description="Identity and hospital affiliation">
<KeyValueGrid
columns={3}
items={[
{ label: 'Specialty', value: doctor.specialty },
{ label: 'License', value: doctor.licenseNumber },
{
label: 'Hospital',
value: (
<Link to={`/organisations/${doctor.orgId}`} className="text-brand-700 hover:underline">
{doctor.orgName}
</Link>
),
},
{ 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) },
]}
/>
</DetailSection>
<div className="grid gap-5 lg:grid-cols-2">
<DetailSection title="Recent prescriptions" description={`${rxRows.length} attributed to this doctor`}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>ID</TableHead>
<TableHead>Patient</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rxRows.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-muted-foreground">
No prescriptions yet
</TableCell>
</TableRow>
) : (
rxRows.map((rx) => (
<TableRow key={rx.id}>
<TableCell>
<Link to={`/prescriptions/${rx.id}`} className="font-medium text-brand-700 hover:underline">
{rx.id}
</Link>
</TableCell>
<TableCell>{rx.customerName}</TableCell>
<TableCell>
<StatusBadge domain="prescription" status={rx.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
<DetailSection title="Recent orders" description="Including guest patients without accounts">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>ID</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orderRows.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-muted-foreground">
No orders yet
</TableCell>
</TableRow>
) : (
orderRows.map((o) => (
<TableRow key={o.id}>
<TableCell>
<Link to={`/orders/${o.id}`} className="font-medium text-brand-700 hover:underline">
{o.id}
</Link>
</TableCell>
<TableCell>
<span className="block">{o.customerName}</span>
{o.customerType === 'guest' ? (
<span className="text-xs text-muted-foreground">Guest</span>
) : null}
</TableCell>
<TableCell>{formatEtb(o.totalEtb)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
</div>
</div>
</DetailPageLayout>
)
}

View File

@ -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<Doctor[]>([...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 (
<PageShell title="Doctors" subtitle="Clinicians affiliated with hospital organisations — invite, review, attribute Rx & orders">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Doctors" value={kpis.total} hint={`${kpis.active} active`} icon={Stethoscope} tone="brand" />
<StatCard label="Active" value={kpis.active} hint="Can create Rx & orders" icon={UserCheck} tone="forest" />
<StatCard label="Linked accounts" value={kpis.linked} hint="Login provisioned" icon={Building2} tone="muted" />
<StatCard label="Inactive" value={kpis.inactive} hint="Suspended or left org" icon={UserX} tone="orange" />
</div>
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search name, specialty, license, hospital…"
resultCount={filtered.length}
filters={filters}
onClearFilters={() => {
setQ('')
setStatus('all')
setOrgId('all')
}}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to={newHref}>
<Plus className="h-4 w-4" />
Add doctor
</Link>
</Button>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Doctor</TableHead>
<TableHead>Specialty</TableHead>
<TableHead>Hospital</TableHead>
<TableHead>License</TableHead>
<TableHead>Account</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
No doctors match these filters
</TableCell>
</TableRow>
) : null}
{filtered.map((d) => (
<TableRow key={d.id}>
<TableCell className="font-medium">
<Link to={`/doctors/${d.id}`} className="flex items-center gap-2.5 text-brand-700 hover:underline">
<PersonAvatar name={d.name} avatarUrl={d.avatarUrl} size="sm" />
{d.name}
</Link>
</TableCell>
<TableCell>{d.specialty}</TableCell>
<TableCell>
<Link to={`/organisations/${d.orgId}`} className="text-brand-700 hover:underline">
{d.orgName}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">{d.licenseNumber}</TableCell>
<TableCell>
<Badge variant={d.hasLinkedAccount ? 'success' : 'outline'}>
{d.hasLinkedAccount ? 'Linked' : 'Invite pending'}
</Badge>
</TableCell>
<TableCell>
<StatusBadge domain="doctor" status={d.status} />
</TableCell>
<TableCell>
<RowActions
primary={[
{ label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/doctors/${d.id}` },
]}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</PageShell>
)
}

View File

@ -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<ActiveIngredient[]>([emptyIngredient()])
const [barcodes, setBarcodes] = useState<CatalogBarcode[]>([emptyBarcode()])
const [uoms, setUoms] = useState<ItemUom[]>(() =>
buildDefaultUoms({
baseUnit: 'tablet',
sellByUnit: 'strip',
packUnit: 'box',
packSize: '10 tablets/strip',
}),
)
const [form, setForm] = useState<FormState>({
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 (
<CreatePageLayout
title="Create medication item"
subtitle="Master product identity. Add stock batches separately after the item exists."
subtitle={`Step ${step + 1} of ${STEPS.length} · ${currentStep.description}`}
backTo="/items"
backLabel="Back to items"
footer={
@ -268,14 +435,83 @@ export function CreateItemPage() {
<Button type="button" variant="outline" onClick={() => navigate('/items')}>
Cancel
</Button>
<Button type="submit" form="create-item-form">
Save item
</Button>
{step > 0 ? (
<Button type="button" variant="outline" className="gap-1.5" onClick={goBack}>
<ChevronLeft className="h-4 w-4" />
Back
</Button>
) : null}
{isLastStep ? (
<Button type="submit" form="create-item-form" className="gap-1.5">
<Check className="h-4 w-4" />
Save item
</Button>
) : (
<Button type="submit" form="create-item-form" className="gap-1.5">
Continue
<ChevronRight className="h-4 w-4" />
</Button>
)}
</>
}
>
<form id="create-item-form" className="space-y-8" onSubmit={onSubmit}>
{nearDuplicates.length > 0 ? (
<form id="create-item-form" className="space-y-6" onSubmit={onSubmit}>
<nav aria-label="Item creation steps" className="space-y-3">
<ol className="flex flex-wrap gap-2">
{STEPS.map((s, index) => {
const done = index < step
const active = index === step
return (
<li key={s.id}>
<button
type="button"
onClick={() => {
if (index === step) return
if (index < step) {
setStep(index)
return
}
for (let i = step; i < index; i += 1) {
if (!validateStep(i)) return
}
setStep(index)
}}
className={cn(
'flex items-center gap-2 rounded-full border px-3 py-1.5 text-left text-xs transition-colors',
active && 'border-brand-500 bg-brand-100 text-forest',
done && !active && 'border-brand-200 bg-brand-50 text-brand-700',
!active && !done && 'border-border bg-background text-muted-foreground',
)}
>
<span
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold',
active && 'bg-brand-500 text-white',
done && !active && 'bg-brand-500 text-white',
!active && !done && 'bg-muted text-muted-foreground',
)}
>
{done ? <Check className="h-3 w-3" /> : index + 1}
</span>
<span className="hidden font-medium sm:inline">{s.title}</span>
</button>
</li>
)
})}
</ol>
<div>
<p className="font-display text-base font-semibold text-forest">{currentStep.title}</p>
<p className="text-xs text-muted-foreground">{currentStep.description}</p>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-gradient-to-r from-brand-500 to-orange transition-all"
style={{ width: `${((step + 1) / STEPS.length) * 100}%` }}
/>
</div>
</div>
</nav>
{nearDuplicates.length > 0 && step <= 1 ? (
<div className="flex gap-3 rounded-xl border border-orange/30 bg-orange/5 px-4 py-3 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-orange-deep" />
<div className="space-y-1">
@ -292,8 +528,9 @@ export function CreateItemPage() {
</div>
) : null}
{step === 0 ? (
<section className="space-y-4">
<SectionTitle title="1. Core identification" />
<SectionTitle title="Core identification" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Internal SKU</Label>
@ -369,11 +606,11 @@ export function CreateItemPage() {
))}
</Repeater>
</section>
) : null}
<Separator />
{step === 1 ? (
<section className="space-y-4">
<SectionTitle title="2. Classification" />
<SectionTitle title="Classification" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Product type</Label>
@ -485,11 +722,11 @@ export function CreateItemPage() {
</p>
) : null}
</section>
) : null}
<Separator />
{step === 2 ? (
<section className="space-y-4">
<SectionTitle title="3. Packaging & units" />
<SectionTitle title="Packaging & units" />
<div className={createFormGrid}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Pack size</Label>
@ -500,8 +737,18 @@ export function CreateItemPage() {
/>
</div>
<div className="space-y-1.5">
<Label>Base unit</Label>
<Input value={form.baseUnit} onChange={(e) => setField('baseUnit', e.target.value)} />
<Label>Stock / base unit</Label>
<Input
value={form.baseUnit}
onChange={(e) => {
const next = e.target.value
setField('baseUnit', next)
setUoms((prev) =>
prev.map((u) => (u.isStockUom ? { ...u, uom: next, conversionFactor: 1 } : u)),
)
}}
placeholder="tablet"
/>
</div>
<div className="space-y-1.5">
<Label>Sell-by unit</Label>
@ -512,12 +759,153 @@ export function CreateItemPage() {
<Input value={form.packUnit} onChange={(e) => setField('packUnit', e.target.value)} />
</div>
</div>
<div className="space-y-3 rounded-xl border bg-cream/40 p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-forest">Units of measurement</p>
<p className="text-xs text-muted-foreground">
Conversion factor = how many stock ({form.baseUnit || 'base'}) units equal one of this UOM.
Stock and warehouse qty always stay in the stock UOM.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-9"
onClick={() =>
setUoms(
buildDefaultUoms({
baseUnit: form.baseUnit,
sellByUnit: form.sellByUnit,
packUnit: form.packUnit,
packSize: form.packSize,
}),
)
}
>
Rebuild from pack fields
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-1"
onClick={() =>
setUoms((prev) => [
...prev,
{ uom: '', conversionFactor: 1, isPurchaseUom: true },
])
}
>
<Plus className="h-3.5 w-3.5" />
Add UOM
</Button>
</div>
</div>
<div className="space-y-2">
{uoms.map((row, index) => (
<div
key={`uom-${index}`}
className="grid gap-2 rounded-lg border bg-background p-3 sm:grid-cols-[1fr_7rem_auto_auto]"
>
<Input
placeholder="Unit name (e.g. strip)"
value={row.uom}
onChange={(e) => {
const next = e.target.value
setUoms((prev) =>
prev.map((u, i) => (i === index ? { ...u, uom: next } : u)),
)
if (row.isStockUom) setField('baseUnit', next)
}}
/>
<Input
type="number"
min={0.001}
step="any"
title="Conversion into stock units"
value={row.conversionFactor}
onChange={(e) =>
setUoms((prev) =>
prev.map((u, i) =>
i === index
? { ...u, conversionFactor: Number(e.target.value) || 1 }
: u,
),
)
}
/>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<label className="flex items-center gap-1.5">
<input
type="radio"
name="stock-uom"
checked={!!row.isStockUom}
onChange={() => {
setUoms((prev) =>
prev.map((u, i) => ({
...u,
isStockUom: i === index,
conversionFactor: i === index ? 1 : u.conversionFactor,
})),
)
if (row.uom.trim()) setField('baseUnit', row.uom.trim())
}}
/>
Stock
</label>
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={!!row.isPurchaseUom}
onChange={(e) =>
setUoms((prev) =>
prev.map((u, i) =>
i === index ? { ...u, isPurchaseUom: e.target.checked } : u,
),
)
}
/>
Purchase
</label>
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={!!row.isSalesUom}
onChange={(e) =>
setUoms((prev) =>
prev.map((u, i) =>
i === index ? { ...u, isSalesUom: e.target.checked } : u,
),
)
}
/>
Sales
</label>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive"
disabled={uoms.length <= 1 || !!row.isStockUom}
onClick={() => setUoms((prev) => prev.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</section>
) : null}
<Separator />
{step === 3 ? (
<section className="space-y-4">
<SectionTitle title="4. Pricing" />
<SectionTitle title="Pricing" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Cost price (ETB)</Label>
@ -538,6 +926,17 @@ export function CreateItemPage() {
onChange={(e) => setField('sellingPriceEtb', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Compare-at price (ETB)</Label>
<Input
type="number"
min={0}
value={form.compareAtPriceEtb}
onChange={(e) => setField('compareAtPriceEtb', e.target.value)}
placeholder="Optional strike-through price"
/>
<p className="text-[11px] text-muted-foreground">Shown crossed out on the mobile PDP</p>
</div>
<div className="space-y-1.5">
<Label>B2B price list</Label>
<SelectNative
@ -588,11 +987,11 @@ export function CreateItemPage() {
</label>
</div>
</section>
) : null}
<Separator />
{step === 4 ? (
<section className="space-y-4">
<SectionTitle title="5. Regulatory & restrictions" />
<SectionTitle title="Regulatory & restrictions" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>EFDA status</Label>
@ -642,11 +1041,11 @@ export function CreateItemPage() {
</div>
</div>
</section>
) : null}
<Separator />
{step === 5 ? (
<section className="space-y-4">
<SectionTitle title="6. Content & guidance" />
<SectionTitle title="Content & guidance" />
<div className={createFormGrid}>
<div className={cn('space-y-1.5', createFieldFull)}>
<Label>Short description</Label>
@ -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"
/>
</div>
<div className="space-y-1.5">
<Label>Use case</Label>
<Input
value={form.useCase}
onChange={(e) => setField('useCase', e.target.value)}
placeholder="e.g. Pain & Fever"
/>
<p className="text-[11px] text-muted-foreground">Chip above the product name on mobile</p>
</div>
<div className="space-y-1.5">
<Label>Storefront unit / pack label</Label>
<Input
value={form.storefrontUnit}
onChange={(e) => setField('storefrontUnit', e.target.value)}
placeholder='e.g. "16 tablets" or "10 capsules/strip"'
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
@ -665,11 +1082,30 @@ export function CreateItemPage() {
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Side effects</Label>
<Label>Side effects (legacy free text)</Label>
<Textarea
rows={2}
value={form.sideEffects}
onChange={(e) => setField('sideEffects', e.target.value)}
placeholder="Fallback if structured mild/severe lists are empty"
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Mild side effects</Label>
<Textarea
rows={2}
value={form.sideEffectsMild}
onChange={(e) => setField('sideEffectsMild', e.target.value)}
placeholder="Comma-separated, e.g. Nausea, Headache, Stomach upset"
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Severe side effects</Label>
<Textarea
rows={2}
value={form.sideEffectsSevere}
onChange={(e) => setField('sideEffectsSevere', e.target.value)}
placeholder="Comma-separated, e.g. Allergic reaction, Difficulty breathing"
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
@ -684,13 +1120,59 @@ export function CreateItemPage() {
onChange={(e) => setField('storageInstructions', e.target.value)}
/>
</div>
<div className={cn('space-y-1.5', createFieldFull)}>
<Label>Pharmacist tip</Label>
<Textarea
rows={2}
value={form.pharmacistTip}
onChange={(e) => setField('pharmacistTip', e.target.value)}
placeholder="Short safety callout shown on the mobile PDP"
/>
</div>
</div>
<div className="space-y-3 rounded-xl border bg-muted/20 p-4">
<div>
<p className="text-sm font-semibold text-forest">Directions of use</p>
<p className="text-xs text-muted-foreground">
Stepper shown on the mobile product page (title + body per step)
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 rounded-lg border bg-background p-3">
<Label>Step 1 title</Label>
<Input
value={form.direction1Title}
onChange={(e) => setField('direction1Title', e.target.value)}
/>
<Label>Step 1 body</Label>
<Textarea
rows={2}
value={form.direction1Body}
onChange={(e) => setField('direction1Body', e.target.value)}
/>
</div>
<div className="space-y-2 rounded-lg border bg-background p-3">
<Label>Step 2 title</Label>
<Input
value={form.direction2Title}
onChange={(e) => setField('direction2Title', e.target.value)}
/>
<Label>Step 2 body</Label>
<Textarea
rows={2}
value={form.direction2Body}
onChange={(e) => setField('direction2Body', e.target.value)}
/>
</div>
</div>
</div>
</section>
) : null}
<Separator />
{step === 6 ? (
<section className="space-y-4">
<SectionTitle title="7. Inventory defaults & merchandising" />
<SectionTitle title="Inventory defaults & merchandising" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Default reorder point</Label>
@ -742,7 +1224,23 @@ export function CreateItemPage() {
) : null}
</div>
</div>
<div className="rounded-xl border bg-muted/30 px-4 py-3 text-sm">
<p className="font-medium text-forest">Ready to save</p>
<p className="mt-1 text-muted-foreground">
<span className="font-mono text-foreground">{sku}</span>
{' · '}
{form.brandName || form.genericName || 'Untitled'}
{' · '}
{form.productType}
{' · '}
{form.sellingPriceEtb ? `ETB ${form.sellingPriceEtb}` : 'no price'}
{' · '}
stock UOM {uoms.find((u) => u.isStockUom)?.uom || form.baseUnit}
</p>
</div>
</section>
) : null}
</form>
</CreatePageLayout>
)

View File

@ -12,7 +12,9 @@ import {
itemChangeHistory,
itemVariants,
priceLists,
resolveItemUoms,
stockRows,
storefrontPdpPreview,
} from '@/mocks/data'
import { formatDateTime, formatEtb } from '@/lib/utils'
@ -35,6 +37,7 @@ export function ItemDetailPage() {
const history = itemChangeHistory.filter((event) => event.itemId === item.id)
const priceList = priceLists.find((p) => p.id === item.b2bPriceListId)
const onHand = stock.reduce((sum, row) => sum + row.qty, 0)
const pdp = storefrontPdpPreview(item)
return (
<DetailPageLayout
@ -146,7 +149,7 @@ export function ItemDetailPage() {
columns={3}
items={[
{ label: 'Pack size', value: item.packSize },
{ label: 'Base unit', value: item.baseUnit },
{ label: 'Stock UOM', value: item.baseUnit },
{ label: 'Sell-by / pack', value: `${item.sellByUnit} / ${item.packUnit}` },
{ label: 'Cost', value: formatEtb(item.costPriceEtb) },
{ label: 'Selling price', value: formatEtb(item.sellingPriceEtb) },
@ -161,6 +164,43 @@ export function ItemDetailPage() {
/>
</DetailSection>
<DetailSection
title="Units of measurement"
description="Conversion into stock UOM — warehouse qty is always counted in the stock unit"
>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>UOM</TableHead>
<TableHead>Conversion (→ {item.baseUnit})</TableHead>
<TableHead>Roles</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resolveItemUoms(item).map((row) => (
<TableRow key={row.uom}>
<TableCell className="font-medium">{row.uom}</TableCell>
<TableCell>
1 {row.uom} = {row.conversionFactor} {item.baseUnit}
{row.conversionFactor !== 1 ? (
<span className="ml-2 text-xs text-muted-foreground">
(e.g. 2 {row.uom} → {2 * row.conversionFactor} {item.baseUnit})
</span>
) : null}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
{row.isStockUom ? <Badge variant="success">Stock</Badge> : null}
{row.isPurchaseUom ? <Badge variant="outline">Purchase</Badge> : null}
{row.isSalesUom ? <Badge variant="secondary">Sales</Badge> : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DetailSection>
<DetailSection title="Regulatory">
<KeyValueGrid
columns={3}
@ -196,9 +236,161 @@ export function ItemDetailPage() {
/>
</DetailSection>
<DetailSection
title="Storefront / Mobile PDP"
description="How this item appears on the Gishen mobile product detail screen"
>
<div className="space-y-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
{pdp.image ? (
<img
src={pdp.image}
alt={pdp.name}
className="h-28 w-28 shrink-0 rounded-xl border object-cover bg-muted"
/>
) : (
<div className="flex h-28 w-28 shrink-0 items-center justify-center rounded-xl border bg-muted text-xs text-muted-foreground">
No image
</div>
)}
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
{pdp.useCase ? <Badge variant="secondary">{pdp.useCase}</Badge> : null}
{pdp.requiresPrescription ? <Badge variant="warning">Rx</Badge> : null}
{pdp.badge ? <Badge variant="outline" className="capitalize">{pdp.badge}</Badge> : null}
</div>
<p className="font-display text-lg font-semibold text-forest">{pdp.name}</p>
<p className="text-sm text-muted-foreground">
{pdp.brand}
{pdp.unit ? ` · ${pdp.unit}` : ''}
</p>
<p className="text-sm">
<span className="font-semibold text-forest">{formatEtb(pdp.priceEtb)}</span>
{pdp.compareAtEtb != null ? (
<span className="ml-2 text-muted-foreground line-through">
{formatEtb(pdp.compareAtEtb)}
</span>
) : null}
</p>
{pdp.dosages.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{pdp.dosages.map((dose) => (
<Badge key={dose} variant="outline">
{dose}
</Badge>
))}
</div>
) : null}
</div>
</div>
<KeyValueGrid
columns={3}
items={[
{ label: 'Storefront unit', value: pdp.unit || '—' },
{ label: 'Form', value: pdp.form || '—' },
{ label: 'Drug class', value: pdp.drugClass || '—' },
{ label: 'Generic', value: pdp.genericName || '—' },
{ label: 'Brand names', value: pdp.brandNames || '—' },
{ label: 'Compare-at', value: pdp.compareAtEtb != null ? formatEtb(pdp.compareAtEtb) : '—' },
]}
/>
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Description
</p>
<p className="text-sm text-foreground">{pdp.description || '—'}</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border bg-muted/20 p-4">
<p className="mb-2 text-sm font-semibold text-forest">Mild side effects</p>
{pdp.sideEffectsMild.length === 0 ? (
<p className="text-sm text-muted-foreground">—</p>
) : (
<ul className="list-disc space-y-1 pl-4 text-sm">
{pdp.sideEffectsMild.map((se) => (
<li key={se}>{se}</li>
))}
</ul>
)}
</div>
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-4">
<p className="mb-2 text-sm font-semibold text-forest">Severe side effects</p>
{pdp.sideEffectsSevere.length === 0 ? (
<p className="text-sm text-muted-foreground">—</p>
) : (
<ul className="list-disc space-y-1 pl-4 text-sm">
{pdp.sideEffectsSevere.map((se) => (
<li key={se}>{se}</li>
))}
</ul>
)}
</div>
</div>
{pdp.storage.length > 0 ? (
<div>
<p className="mb-2 text-sm font-semibold text-forest">Storage</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{pdp.storage.map((note) => (
<div key={`${note.title}-${note.body}`} className="rounded-xl border bg-cream/40 p-4">
<p className="text-sm font-medium text-forest">{note.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{note.body}</p>
</div>
))}
</div>
</div>
) : null}
{pdp.directions.length > 0 ? (
<div>
<p className="mb-2 text-sm font-semibold text-forest">Directions of use</p>
<ol className="space-y-3">
{pdp.directions.map((step, index) => (
<li key={`${step.title}-${index}`} className="flex gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-500 text-xs font-semibold text-white">
{index + 1}
</span>
<div>
<p className="text-sm font-medium text-forest">{step.title}</p>
<p className="text-sm text-muted-foreground">{step.body}</p>
</div>
</li>
))}
</ol>
</div>
) : null}
<div className="rounded-xl border border-brand-200 bg-brand-50/60 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-brand-700">
Pharmacist tip
</p>
<p className="mt-1 text-sm text-forest">{pdp.pharmacistTip}</p>
</div>
{pdp.images.length > 1 ? (
<div>
<p className="mb-2 text-sm font-semibold text-forest">Gallery</p>
<div className="flex flex-wrap gap-2">
{pdp.images.map((src) => (
<img
key={src}
src={src}
alt=""
className="h-16 w-16 rounded-lg border object-cover bg-muted"
/>
))}
</div>
</div>
) : null}
</div>
</DetailSection>
<DetailSection
title="Stock on hand"
description={`${stock.length} batch line${stock.length === 1 ? '' : 's'} · ${onHand} units`}
description={`${stock.length} batch line${stock.length === 1 ? '' : 's'} · ${onHand} ${item.baseUnit}${onHand === 1 ? '' : 's'} on hand`}
>
{stock.length === 0 ? (
<div className="flex flex-col items-start gap-3 rounded-xl border border-dashed p-6 text-sm text-muted-foreground">

View File

@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMemo, useState, type FormEvent } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import {
CreatePageLayout,
@ -14,48 +14,82 @@ import { SelectNative } from '@/components/ui/select-native'
import { Textarea } from '@/components/ui/textarea'
import { useAuthStore } from '@/zustand/authStore'
import { cn } from '@/lib/utils'
import { branches, orders } from '@/mocks/data'
import type { Order } from '@/types'
import { branches, customers, doctors, orders } from '@/mocks/data'
import type { Order, OrderCustomerType } from '@/types'
export function CreateOrderPage() {
const navigate = useNavigate()
const [params] = useSearchParams()
const user = useAuthStore((s) => s.user)
const doctorParam = params.get('doctorId') ?? ''
const activeDoctors = useMemo(() => doctors.filter((d) => d.status === 'active'), [])
const [form, setForm] = useState({
customerType: 'guest' as OrderCustomerType,
customerId: '',
customerName: '',
phone: '',
notes: '',
branchId: user?.branchId ?? branches[0]?.id ?? '',
fulfillment: 'pickup' as 'pickup' | 'delivery',
channel: 'web',
channel: doctorParam ? 'doctor' : 'web',
totalEtb: '',
notes: '',
address: '',
doctorId: doctorParam,
})
const selectedDoctor = activeDoctors.find((d) => d.id === form.doctorId)
const selectedCustomer = customers.find((c) => c.id === form.customerId)
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const isGuest = form.customerType === 'guest'
const name = isGuest
? form.customerName.trim() || `Guest · ${form.phone.trim()}`
: selectedCustomer?.name ?? form.customerName.trim()
if (!isGuest && !form.customerId) {
toast.error('Select a registered customer or switch to Guest customer')
return
}
if (isGuest && !form.phone.trim()) {
toast.error('Guest orders require a contact phone')
return
}
const phone = isGuest ? form.phone.trim() : selectedCustomer?.phone ?? form.phone.trim()
const order: Order = {
id: `ord-${Date.now().toString().slice(-6)}`,
customerName: form.customerName || `Guest · ${form.phone}`,
customerName: name,
customerType: form.customerType,
customerId: isGuest ? undefined : form.customerId,
customerPhone: phone || undefined,
notes: form.notes.trim() || undefined,
branchId: form.branchId,
fulfillment: form.fulfillment,
status: 'confirmed',
totalEtb: Number(form.totalEtb) || 0,
channel: form.channel,
channel: form.doctorId ? 'doctor' : form.channel,
address: form.fulfillment === 'delivery' ? form.address : undefined,
zone: branches.find((b) => b.id === form.branchId)?.zone,
createdAt: new Date().toISOString(),
paid: false,
rxApproved: false,
rxApproved: Boolean(form.doctorId),
doctorId: form.doctorId || undefined,
orgId: selectedDoctor?.orgId,
}
orders.unshift(order)
toast.success('Staff order created')
navigate('/orders')
toast.success(
isGuest
? 'Guest order created — no customer account required'
: 'Staff order created for registered customer',
)
navigate(`/orders/${order.id}`)
}
return (
<CreatePageLayout
title="Create order"
subtitle="Staff-entered order for call centre / walk-in"
subtitle="Staff or doctor-attributed order — registered customer or guest (walk-in / phone / patient without account)"
backTo="/orders"
footer={
<>
@ -68,90 +102,208 @@ export function CreateOrderPage() {
</>
}
>
<form id="create-order-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Customer name</Label>
<Input
required
value={form.customerName}
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Phone</Label>
<Input
required
placeholder="+2519…"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Total (ETB)</Label>
<Input
required
type="number"
min={0}
value={form.totalEtb}
onChange={(e) => setForm({ ...form, totalEtb: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Branch</Label>
<SelectNative
value={form.branchId}
onChange={(e) => setForm({ ...form, branchId: e.target.value })}
disabled={user?.role === 'pharmacist'}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Fulfillment</Label>
<SelectNative
value={form.fulfillment}
onChange={(e) =>
setForm({ ...form, fulfillment: e.target.value as 'pickup' | 'delivery' })
}
>
<option value="pickup">Pickup</option>
<option value="delivery">Delivery</option>
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Channel</Label>
<SelectNative
value={form.channel}
onChange={(e) => setForm({ ...form, channel: e.target.value })}
>
<option value="web">Web / call</option>
<option value="mobile">Mobile</option>
<option value="telegram">Telegram</option>
<option value="b2b">B2B</option>
</SelectNative>
</div>
{form.fulfillment === 'delivery' ? (
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Delivery address</Label>
<Input
required
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
/>
<form id="create-order-form" className="space-y-6" onSubmit={onSubmit}>
<section className="space-y-3">
<div>
<h2 className="font-display text-sm font-semibold text-forest">Customer</h2>
<p className="text-xs text-muted-foreground">
End customer does not need to be logged in. Use Guest for walk-in, phone, POS, or doctor-assisted patients.
</p>
</div>
) : null}
<div className={cn('space-y-1.5', createFieldFull)}>
<Label>Notes</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional staff notes"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={form.customerType === 'guest' ? 'default' : 'outline'}
className="h-9"
onClick={() => setForm((prev) => ({ ...prev, customerType: 'guest', customerId: '' }))}
>
Guest customer
</Button>
<Button
type="button"
size="sm"
variant={form.customerType === 'registered' ? 'default' : 'outline'}
className="h-9"
onClick={() => setForm((prev) => ({ ...prev, customerType: 'registered' }))}
>
Registered customer
</Button>
</div>
<div className={createFormGrid}>
{form.customerType === 'registered' ? (
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Registered customer</Label>
<SelectNative
required
value={form.customerId}
onChange={(e) => {
const c = customers.find((x) => x.id === e.target.value)
setForm((prev) => ({
...prev,
customerId: e.target.value,
customerName: c?.name ?? '',
phone: c?.phone ?? '',
}))
}}
>
<option value="">Select customer…</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name} · {c.phone}
</option>
))}
</SelectNative>
<p className="text-xs text-muted-foreground">
Prefer guest?{' '}
<button
type="button"
className="text-brand-700 underline"
onClick={() => setForm((prev) => ({ ...prev, customerType: 'guest', customerId: '' }))}
>
Switch to Guest customer
</button>
</p>
</div>
) : (
<>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Guest name</Label>
<Input
required
value={form.customerName}
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
placeholder="Walk-in name or caller"
/>
</div>
<div className="space-y-1.5">
<Label>Contact phone</Label>
<Input
required
placeholder="+2519…"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</div>
</>
)}
{form.customerType === 'registered' ? (
<div className="space-y-1.5">
<Label>Phone (from profile)</Label>
<Input value={form.phone} readOnly className="bg-muted/40" />
</div>
) : null}
<div className={cn('space-y-1.5', createFieldFull)}>
<Label>Notes (optional)</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Allergy, pickup person, call-back, patient without account…"
/>
</div>
</div>
</section>
<section className="space-y-3">
<div>
<h2 className="font-display text-sm font-semibold text-forest">Order details</h2>
<p className="text-xs text-muted-foreground">Fulfilment and attribution</p>
</div>
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Total (ETB)</Label>
<Input
required
type="number"
min={0}
value={form.totalEtb}
onChange={(e) => setForm({ ...form, totalEtb: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Branch</Label>
<SelectNative
value={form.branchId}
onChange={(e) => setForm({ ...form, branchId: e.target.value })}
disabled={user?.role === 'pharmacist'}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Fulfillment</Label>
<SelectNative
value={form.fulfillment}
onChange={(e) =>
setForm({ ...form, fulfillment: e.target.value as 'pickup' | 'delivery' })
}
>
<option value="pickup">Pickup</option>
<option value="delivery">Delivery</option>
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Channel</Label>
<SelectNative
value={form.channel}
onChange={(e) => setForm({ ...form, channel: e.target.value })}
disabled={Boolean(form.doctorId)}
>
<option value="web">Web / call</option>
<option value="mobile">Mobile</option>
<option value="telegram">Telegram</option>
<option value="b2b">B2B</option>
<option value="doctor">Doctor</option>
<option value="pos">POS / counter</option>
</SelectNative>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Attribute to doctor (optional)</Label>
<SelectNative
value={form.doctorId}
onChange={(e) => {
const doc = activeDoctors.find((d) => d.id === e.target.value)
setForm((prev) => ({
...prev,
doctorId: e.target.value,
channel: e.target.value ? 'doctor' : prev.channel === 'doctor' ? 'web' : prev.channel,
branchId: doc?.branchId || prev.branchId,
}))
}}
>
<option value="">None — staff order</option>
{activeDoctors.map((d) => (
<option key={d.id} value={d.id}>
{d.name} · {d.orgName}
</option>
))}
</SelectNative>
{selectedDoctor ? (
<p className="text-xs text-muted-foreground">
Hospital:{' '}
<Link to={`/organisations/${selectedDoctor.orgId}`} className="text-brand-700 underline">
{selectedDoctor.orgName}
</Link>
. Doctor orders may target guest patients without platform accounts.
</p>
) : null}
</div>
{form.fulfillment === 'delivery' ? (
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Delivery address</Label>
<Input
required
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
/>
</div>
) : null}
</div>
</section>
</form>
</CreatePageLayout>
)

View File

@ -8,7 +8,7 @@ import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { branches, orderLines, orders, orderTimeline, riders } from '@/mocks/data'
import { branches, doctors, orderLines, orders, orderTimeline, riders } from '@/mocks/data'
import { formatEtb } from '@/lib/utils'
import type { Order } from '@/types'
@ -31,6 +31,8 @@ export function OrderDetailPage() {
const timeline = orderTimeline[order.id] ?? []
const branch = branches.find((b) => b.id === order.branchId)
const rider = riders.find((r) => r.id === order.assignedRiderId)
const doctor = order.doctorId ? doctors.find((d) => d.id === order.doctorId) : undefined
const customerType = order.customerType ?? (order.customerName.startsWith('Guest') ? 'guest' : 'registered')
const patch = (next: Partial<Order>, message: string) => {
setOrder((prev) => (prev ? { ...prev, ...next } : prev))
@ -51,6 +53,9 @@ export function OrderDetailPage() {
{order.status}
</Badge>
<Badge variant="outline">{order.fulfillment}</Badge>
<Badge variant={customerType === 'guest' ? 'secondary' : 'outline'}>
{customerType === 'guest' ? 'Guest customer' : 'Registered'}
</Badge>
{order.paid ? <Badge variant="secondary">Paid</Badge> : <Badge variant="warning">Unpaid</Badge>}
</>
}
@ -117,6 +122,30 @@ export function OrderDetailPage() {
{ label: 'Channel', value: order.channel },
{ label: 'Total', value: formatEtb(order.totalEtb) },
{ label: 'Rx approved', value: order.rxApproved ? 'Yes' : 'N/A' },
{ label: 'Customer type', value: customerType === 'guest' ? 'Guest (unauthenticated)' : 'Registered' },
{ label: 'Phone', value: order.customerPhone ?? '—' },
{
label: 'CRM customer',
value: order.customerId ? (
<Link to={`/crm/${order.customerId}`} className="text-brand-700 hover:underline">
{order.customerId}
</Link>
) : (
'None — guest checkout'
),
},
{
label: 'Doctor',
value: doctor ? (
<Link to={`/doctors/${doctor.id}`} className="text-brand-700 hover:underline">
{doctor.name}
</Link>
) : (
'—'
),
},
{ label: 'Hospital org', value: doctor?.orgName ?? '—' },
{ label: 'Notes', value: order.notes ?? '—' },
{ label: 'Created', value: new Date(order.createdAt).toLocaleString() },
{ label: 'Branch phone', value: branch?.phone ?? '—' },
{ label: 'Payment', value: order.paid ? 'Captured' : 'Pending' },

View File

@ -305,7 +305,16 @@ export function OrdersPage() {
{o.id}
</Link>
</TableCell>
<TableCell>{o.customerName}</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
<span>{o.customerName}</span>
{(o.customerType ?? (o.customerName.startsWith('Guest') ? 'guest' : undefined)) === 'guest' ? (
<Badge variant="secondary" className="text-[10px]">
Guest
</Badge>
) : null}
</div>
</TableCell>
<TableCell>{branches.find((b) => b.id === o.branchId)?.name}</TableCell>
<TableCell>
<Badge variant="outline">{o.fulfillment}</Badge>

View File

@ -1,34 +1,48 @@
import { useState, type FormEvent } from 'react'
import { useState, type ChangeEvent, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout, createFieldWide, createFormGrid } from '@/layouts/CreatePageLayout'
import { CreatePageLayout, createFieldFull, createFieldWide, createFormGrid } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { organisations } from '@/mocks/data'
import type { Organisation } from '@/types'
import { SelectNative } from '@/components/ui/select-native'
import type { Organisation, OrganisationType } from '@/types'
export function CreateOrganisationPage() {
const navigate = useNavigate()
const [form, setForm] = useState({
name: '',
orgType: 'corporate' as OrganisationType,
tin: '',
vatNumber: '',
businessLicense: '',
commercialRegistration: '',
creditLimitEtb: '100000',
billingContact: '',
paymentTerms: 'Net 30',
})
const set =
(key: keyof typeof form) =>
(e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }))
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const org: Organisation = {
id: `org-${Date.now()}`,
name: form.name,
tin: form.tin,
name: form.name.trim(),
orgType: form.orgType,
tin: form.tin.trim(),
vatNumber: form.vatNumber.trim() || undefined,
businessLicense: form.businessLicense.trim() || undefined,
commercialRegistration: form.commercialRegistration.trim() || undefined,
status: 'active',
creditLimitEtb: Number(form.creditLimitEtb) || 0,
usedEtb: 0,
billingContact: form.billingContact,
billingContact: form.billingContact.trim(),
source: 'admin',
createdAt: new Date().toISOString(),
}
@ -40,7 +54,7 @@ export function CreateOrganisationPage() {
return (
<CreatePageLayout
title="Create organisation"
subtitle="Admin-created orgs become active immediately"
subtitle="Admin-created orgs become active immediately — capture KYC identifiers before opening credit"
backTo="/organisations"
footer={
<>
@ -53,41 +67,114 @@ export function CreateOrganisationPage() {
</>
}
>
<form id="create-org-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Organisation name</Label>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>TIN</Label>
<Input required value={form.tin} onChange={(e) => setForm({ ...form, tin: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Credit limit (ETB)</Label>
<Input
required
type="number"
min={0}
value={form.creditLimitEtb}
onChange={(e) => setForm({ ...form, creditLimitEtb: e.target.value })}
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Billing contact</Label>
<Input
required
type="email"
value={form.billingContact}
onChange={(e) => setForm({ ...form, billingContact: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Payment terms</Label>
<Input
value={form.paymentTerms}
onChange={(e) => setForm({ ...form, paymentTerms: e.target.value })}
/>
</div>
<form id="create-org-form" className="space-y-6" onSubmit={onSubmit}>
<section className="space-y-3">
<div>
<h2 className="font-display text-sm font-semibold text-forest">Account</h2>
<p className="text-xs text-muted-foreground">Who the credit line belongs to</p>
</div>
<div className={createFormGrid}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label htmlFor="org-name">Organisation name</Label>
<Input id="org-name" required value={form.name} onChange={set('name')} placeholder="e.g. Horizon Bank S.C." />
</div>
<div className="space-y-1.5">
<Label htmlFor="org-type">Organisation type</Label>
<SelectNative id="org-type" required value={form.orgType} onChange={set('orgType')}>
<option value="corporate">Corporate</option>
<option value="hospital">Hospital</option>
<option value="clinic">Clinic</option>
<option value="ngo">NGO</option>
<option value="other">Other</option>
</SelectNative>
{form.orgType === 'hospital' || form.orgType === 'clinic' ? (
<p className="text-xs text-muted-foreground">Hospital/clinic orgs can invite doctors to create Rx and orders.</p>
) : null}
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label htmlFor="org-billing">Billing contact</Label>
<Input
id="org-billing"
required
type="email"
value={form.billingContact}
onChange={set('billingContact')}
placeholder="finance@company.et"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="org-credit">Credit limit (ETB)</Label>
<Input
id="org-credit"
required
type="number"
min={0}
value={form.creditLimitEtb}
onChange={set('creditLimitEtb')}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="org-terms">Payment terms</Label>
<Input id="org-terms" value={form.paymentTerms} onChange={set('paymentTerms')} placeholder="Net 30" />
</div>
</div>
</section>
<section className="space-y-3">
<div>
<h2 className="font-display text-sm font-semibold text-forest">Legal & tax identifiers</h2>
<p className="text-xs text-muted-foreground">
Business license, commercial registration, VAT and TIN used for KYC and invoicing. Leave blank if not
on file yet — detail view shows a clear placeholder.
</p>
</div>
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label htmlFor="org-tin">TIN</Label>
<Input
id="org-tin"
required
inputMode="numeric"
pattern="[0-9]{10}"
maxLength={10}
value={form.tin}
onChange={set('tin')}
placeholder="0001234567"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="org-vat">VAT registration</Label>
<Input
id="org-vat"
value={form.vatNumber}
onChange={set('vatNumber')}
placeholder="VAT-ET-0001234567"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="org-bl">Business license</Label>
<Input
id="org-bl"
value={form.businessLicense}
onChange={set('businessLicense')}
placeholder="BL-AA/48291/2014"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="org-cr">Commercial registration</Label>
<Input
id="org-cr"
value={form.commercialRegistration}
onChange={set('commercialRegistration')}
placeholder="CR/015842/2014"
/>
</div>
<p className={cn('text-xs text-muted-foreground', createFieldFull)}>
Placeholders follow common Ethiopian trade formats — replace with the exact numbers from the documents
before activating a large credit limit.
</p>
</div>
</section>
</form>
</CreatePageLayout>
)

View File

@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Check, ClipboardCopy, X, Wallet } from 'lucide-react'
import { Check, ClipboardCopy, X, Wallet, Plus, Stethoscope } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { ApprovalStepper, type ApprovalStep } from '@/components/detail/ApprovalStepper'
@ -16,7 +16,7 @@ import { StatusBadge } from '@/components/ui/status-badge'
import { PersonAvatar } from '@/components/ui/avatar'
import { RowActions, type RowAction } from '@/components/table/RowActions'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { orgInvoices, orgMembers, organisations } from '@/mocks/data'
import { orgInvoices, orgMembers, organisations, doctors } from '@/mocks/data'
import { orgSpendSeries } from '@/mocks/charts'
import { useAuthStore } from '@/zustand/authStore'
import { formatDate, formatDateTime, formatEtb } from '@/lib/utils'
@ -42,6 +42,8 @@ export function OrganisationDetailPage() {
const invoices = orgInvoices[org.id] ?? []
const util = org.creditLimitEtb ? Math.round((org.usedEtb / org.creditLimitEtb) * 100) : 0
const isPending = org.status === 'pending'
const isClinicalOrg = org.orgType === 'hospital' || org.orgType === 'clinic'
const orgDoctors = doctors.filter((d) => d.orgId === org.id)
const setStatus = (status: Organisation['status'], message: string) => {
const patch = {
@ -78,8 +80,29 @@ export function OrganisationDetailPage() {
toast.message(`TIN ${org.tin} copied to clipboard`)
},
},
...(org.vatNumber
? [
{
label: 'Copy VAT number',
icon: <ClipboardCopy className="h-3.5 w-3.5" />,
onClick: () => {
void navigator.clipboard?.writeText(org.vatNumber!)
toast.message(`VAT ${org.vatNumber} copied to clipboard`)
},
},
]
: []),
]
const legalValue = (value?: string) => {
const trimmed = value?.trim()
return trimmed
? trimmed
: (
<span className="text-muted-foreground">Not on file</span>
)
}
const steps = useMemo<ApprovalStep[]>(
() => [
{
@ -95,7 +118,9 @@ export function OrganisationDetailPage() {
state: isPending ? 'current' : 'done',
actor: isPending ? 'Awaiting a finance decision' : org.reviewedBy,
at: isPending ? undefined : org.reviewedAt,
hint: isPending ? 'KYC documents and TIN need verifying' : undefined,
hint: isPending
? 'Verify TIN, VAT, business license and commercial registration'
: undefined,
},
{
key: 'credit',
@ -117,11 +142,20 @@ export function OrganisationDetailPage() {
badges={
<>
<StatusBadge domain="organisation" status={org.status} />
<StatusBadge domain="organisation" status={org.orgType} />
<Badge variant="outline">{org.source === 'self_register' ? 'Self-registered' : 'Added by admin'}</Badge>
</>
}
actions={
<>
{isClinicalOrg ? (
<Button asChild className="h-10 gap-1.5" variant="outline">
<Link to={`/doctors/new?orgId=${org.id}`}>
<Plus className="h-4 w-4" />
Add doctor
</Link>
</Button>
) : null}
{isPending ? (
<Button className="h-10 gap-1.5" onClick={approve}>
<Check className="h-4 w-4" />
@ -171,6 +205,27 @@ export function OrganisationDetailPage() {
</DetailSection>
<div className="grid gap-5 lg:grid-cols-2">
<DetailSection
title="Legal & tax identifiers"
description="Business license, commercial registration, VAT and TIN on file for this organisation"
>
<KeyValueGrid
columns={2}
items={[
{ label: 'TIN', value: org.tin },
{ label: 'VAT registration', value: legalValue(org.vatNumber) },
{ label: 'Business license', value: legalValue(org.businessLicense) },
{ label: 'Commercial registration', value: legalValue(org.commercialRegistration) },
]}
/>
{!org.vatNumber?.trim() || !org.businessLicense?.trim() || !org.commercialRegistration?.trim() ? (
<p className="mt-4 text-xs text-muted-foreground">
Missing documents show as “Not on file”. Finance should collect them before raising large credit
limits.
</p>
) : null}
</DetailSection>
<DetailSection title="Credit profile">
<KeyValueGrid
columns={2}
@ -273,6 +328,71 @@ export function OrganisationDetailPage() {
</Table>
</DetailSection>
{isClinicalOrg ? (
<DetailSection
title="Doctors"
description={`${orgDoctors.length} clinicians affiliated with this ${org.orgType}`}
>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Doctors are invited from hospital organisations and can create prescriptions and orders (including for guest patients).
</p>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm" className="h-9 gap-1.5">
<Link to={`/doctors?orgId=${org.id}`}>
<Stethoscope className="h-3.5 w-3.5" />
View all
</Link>
</Button>
<Button asChild size="sm" className="h-9 gap-1.5">
<Link to={`/doctors/new?orgId=${org.id}`}>
<Plus className="h-3.5 w-3.5" />
Add doctor
</Link>
</Button>
</div>
</div>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Specialty</TableHead>
<TableHead>License</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orgDoctors.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
No doctors yet — add the first clinician for this hospital
</TableCell>
</TableRow>
) : (
orgDoctors.map((d) => (
<TableRow key={d.id}>
<TableCell className="font-medium">
<Link
to={`/doctors/${d.id}`}
className="flex items-center gap-2.5 text-brand-700 hover:underline"
>
<PersonAvatar name={d.name} avatarUrl={d.avatarUrl} size="sm" />
{d.name}
</Link>
</TableCell>
<TableCell>{d.specialty}</TableCell>
<TableCell className="font-mono text-xs">{d.licenseNumber}</TableCell>
<TableCell>
<StatusBadge domain="doctor" status={d.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
) : null}
<DetailSection title="Invoices">
<Table>
<TableHeader>

View File

@ -24,6 +24,7 @@ export function OrganisationsPage() {
const [q, setQ] = useState('')
const [status, setStatus] = useState('all')
const [source, setSource] = useState('all')
const [orgType, setOrgType] = useState('all')
const kpis = useMemo(() => {
const active = orgs.filter((o) => o.status === 'active')
@ -34,6 +35,7 @@ export function OrganisationsPage() {
creditExposure: active.reduce((sum, o) => sum + o.usedEtb, 0),
creditLimit: active.reduce((sum, o) => sum + o.creditLimitEtb, 0),
selfRegister: orgs.filter((o) => o.source === 'self_register').length,
hospitals: orgs.filter((o) => o.orgType === 'hospital' || o.orgType === 'clinic').length,
}
}, [orgs])
@ -41,17 +43,21 @@ export function OrganisationsPage() {
let list = orgs
if (status !== 'all') list = list.filter((o) => o.status === status)
if (source !== 'all') list = list.filter((o) => o.source === source)
if (orgType !== 'all') list = list.filter((o) => o.orgType === orgType)
if (q) {
const needle = q.toLowerCase()
list = list.filter(
(o) =>
o.name.toLowerCase().includes(needle) ||
o.tin.includes(needle) ||
o.vatNumber?.toLowerCase().includes(needle) ||
o.businessLicense?.toLowerCase().includes(needle) ||
o.commercialRegistration?.toLowerCase().includes(needle) ||
o.billingContact.toLowerCase().includes(needle),
)
}
return list
}, [orgs, q, status, source])
}, [orgs, q, status, source, orgType])
const approve = (id: string, next: 'active' | 'rejected') => {
const patch = {
@ -115,6 +121,20 @@ export function OrganisationsPage() {
{ value: 'self_register', label: 'Self-register' },
],
},
{
id: 'orgType',
label: 'Type',
value: orgType,
onChange: setOrgType,
options: [
{ value: 'all', label: 'All types' },
{ value: 'corporate', label: 'Corporate' },
{ value: 'hospital', label: 'Hospital' },
{ value: 'clinic', label: 'Clinic' },
{ value: 'ngo', label: 'NGO' },
{ value: 'other', label: 'Other' },
],
},
]
return (
@ -146,13 +166,14 @@ export function OrganisationsPage() {
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search name, TIN, contact…"
searchPlaceholder="Search name, TIN, VAT, license…"
resultCount={filtered.length}
filters={orgFilters}
onClearFilters={() => {
setQ('')
setStatus('all')
setSource('all')
setOrgType('all')
}}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
@ -168,6 +189,7 @@ export function OrganisationsPage() {
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>TIN</TableHead>
<TableHead>Source</TableHead>
<TableHead>Limit</TableHead>
@ -179,7 +201,7 @@ export function OrganisationsPage() {
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
@ -193,6 +215,9 @@ export function OrganisationsPage() {
{o.name}
</Link>
</TableCell>
<TableCell>
<StatusBadge domain="organisation" status={o.orgType} />
</TableCell>
<TableCell>{o.tin}</TableCell>
<TableCell>
<StatusBadge domain="organisation" status={o.source} />

View File

@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMemo, useState, type FormEvent } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import {
CreatePageLayout,
@ -13,23 +13,33 @@ import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { useAuthStore } from '@/zustand/authStore'
import { cn } from '@/lib/utils'
import { branches, prescriptions } from '@/mocks/data'
import { branches, doctors, prescriptions } from '@/mocks/data'
import type { Prescription } from '@/types'
export function CreatePrescriptionPage() {
const navigate = useNavigate()
const [params] = useSearchParams()
const user = useAuthStore((s) => s.user)
const doctorParam = params.get('doctorId') ?? ''
const activeDoctors = useMemo(() => doctors.filter((d) => d.status === 'active'), [])
const [form, setForm] = useState({
customerName: '',
prescriber: '',
doctorId: doctorParam || activeDoctors[0]?.id || '',
branchId: user?.branchId ?? branches[0]?.id ?? '',
itemName: '',
qty: '1',
controlled: false,
})
const selectedDoctor = activeDoctors.find((d) => d.id === form.doctorId)
const onSubmit = (e: FormEvent) => {
e.preventDefault()
if (!selectedDoctor) {
toast.error('Select a registered doctor as the prescriber')
return
}
const rx: Prescription = {
id: `rx-${Date.now().toString().slice(-6)}`,
customerName: form.customerName,
@ -43,17 +53,19 @@ export function CreatePrescriptionPage() {
},
],
submittedAt: new Date().toISOString(),
prescriber: form.prescriber,
prescriber: selectedDoctor.name,
doctorId: selectedDoctor.id,
orgId: selectedDoctor.orgId,
}
prescriptions.unshift(rx)
toast.success('Prescription intake created')
navigate('/prescriptions')
navigate(`/prescriptions/${rx.id}`)
}
return (
<CreatePageLayout
title="New prescription intake"
subtitle="Staff-entered Rx for walk-in / phone photo"
subtitle="Attribute Rx to a hospital-affiliated doctor — patient does not need a platform account"
backTo="/prescriptions"
footer={
<>
@ -68,20 +80,47 @@ export function CreatePrescriptionPage() {
>
<form id="create-rx-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Customer name</Label>
<Label>Patient / customer name</Label>
<Input
required
value={form.customerName}
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
placeholder="May be a guest without an app account"
/>
</div>
<div className="space-y-1.5">
<Label>Prescriber</Label>
<Input
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Prescribing doctor</Label>
<SelectNative
required
value={form.prescriber}
onChange={(e) => setForm({ ...form, prescriber: e.target.value })}
/>
value={form.doctorId}
onChange={(e) => {
const doc = activeDoctors.find((d) => d.id === e.target.value)
setForm((prev) => ({
...prev,
doctorId: e.target.value,
branchId: user?.branchId || doc?.branchId || prev.branchId,
}))
}}
>
<option value="" disabled>
Select doctor…
</option>
{activeDoctors.map((d) => (
<option key={d.id} value={d.id}>
{d.name} · {d.specialty} · {d.orgName}
</option>
))}
</SelectNative>
{selectedDoctor ? (
<p className="text-xs text-muted-foreground">
Hospital:{' '}
<Link to={`/organisations/${selectedDoctor.orgId}`} className="text-brand-700 underline">
{selectedDoctor.orgName}
</Link>
{' · '}
License {selectedDoctor.licenseNumber}
</p>
) : null}
</div>
<div className="space-y-1.5">
<Label>Branch</Label>

View File

@ -11,7 +11,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { StatusBadge, statusLabel } from '@/components/ui/status-badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { branches, prescriptions } from '@/mocks/data'
import { branches, doctors, prescriptions } from '@/mocks/data'
import { useAuthStore } from '@/zustand/authStore'
import { formatDateTime } from '@/lib/utils'
import type { Prescription } from '@/types'
@ -35,6 +35,7 @@ export function PrescriptionDetailPage() {
}
const branch = branches.find((b) => b.id === rx.branchId)
const doctor = rx.doctorId ? doctors.find((d) => d.id === rx.doctorId) : undefined
const controlled = rx.items.some((i) => i.controlled)
const isOpen = OPEN_STATUSES.includes(rx.status)
@ -282,7 +283,18 @@ export function PrescriptionDetailPage() {
columns={3}
items={[
{ label: 'Branch', value: branch?.name },
{ label: 'Prescriber', value: rx.prescriber ?? '—' },
{
label: 'Prescriber',
value: doctor ? (
<Link to={`/doctors/${doctor.id}`} className="text-brand-700 hover:underline">
{doctor.name}
</Link>
) : (
rx.prescriber ?? '—'
),
},
{ label: 'Hospital', value: doctor?.orgName ?? '—' },
{ label: 'License', value: doctor?.licenseNumber ?? '—' },
{ label: 'Submitted', value: formatDateTime(rx.submittedAt) },
{ label: 'Items', value: String(rx.items.length) },
{ label: 'Controlled items', value: controlled ? 'Yes — stamp required' : 'None' },

View File

@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { cn } from '@/lib/utils'
import { branches, catalogItems, findCatalogItem, productCategories, stockRows } from '@/mocks/data'
import { branches, catalogItems, findCatalogItem, productCategories, resolveItemUoms, stockRows, stockUomOf, toStockQty } from '@/mocks/data'
import type { ProductCategory } from '@/mocks/data'
function categoryForItem(therapeuticClass: string, productType: string): ProductCategory {
@ -50,11 +50,26 @@ export function CreateStockPage() {
const [form, setForm] = useState({
branchId: branches[0]?.id ?? '',
qty: '',
uom: initialItem ? stockUomOf(initialItem) : '',
batch: '',
expiry: '',
unitEtb: initialItem ? String(initialItem.sellingPriceEtb) : '',
})
const itemUoms = useMemo(
() => (selected ? resolveItemUoms(selected) : []),
[selected],
)
const stockQtyPreview = useMemo(() => {
if (!selected || !form.qty) return null
const entered = Number(form.qty)
if (!Number.isFinite(entered)) return null
const uom = form.uom || stockUomOf(selected)
const stockQty = toStockQty(entered, uom, selected)
return { entered, uom, stockQty, stockUom: stockUomOf(selected) }
}, [selected, form.qty, form.uom])
const onItemChange = (nextId: string) => {
setItemId(nextId)
const item = findCatalogItem(nextId)
@ -62,6 +77,7 @@ export function CreateStockPage() {
setForm((prev) => ({
...prev,
unitEtb: String(item.sellingPriceEtb),
uom: stockUomOf(item),
}))
}
}
@ -72,7 +88,8 @@ export function CreateStockPage() {
toast.error('Pick a catalogue item first')
return
}
const qty = Number(form.qty) || 0
const uom = form.uom || stockUomOf(selected)
const qty = toStockQty(Number(form.qty) || 0, uom, selected)
stockRows.unshift({
sku: selected.sku,
name: selected.brandName || selected.genericName,
@ -86,7 +103,9 @@ export function CreateStockPage() {
itemId: selected.id,
reserved: 0,
})
toast.success(`Stock received for ${selected.sku}`)
toast.success(
`Stock received for ${selected.sku}: ${form.qty} ${uom} → ${qty} ${stockUomOf(selected)}`,
)
navigate(`/stock/${selected.sku}`)
}
@ -176,6 +195,37 @@ export function CreateStockPage() {
onChange={(e) => setForm({ ...form, qty: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Unit of measure</Label>
<SelectNative
required
disabled={!selected}
value={form.uom}
onChange={(e) => setForm({ ...form, uom: e.target.value })}
>
{!selected ? <option value="">Select an item first…</option> : null}
{itemUoms.map((u) => (
<option key={u.uom} value={u.uom}>
{u.uom}
{u.isStockUom ? ' (stock)' : ''}
{u.conversionFactor !== 1 ? ` · 1 = ${u.conversionFactor} ${selected?.baseUnit}` : ''}
</option>
))}
</SelectNative>
{stockQtyPreview && stockQtyPreview.uom !== stockQtyPreview.stockUom ? (
<p className="text-[11px] text-muted-foreground">
Will post as{' '}
<strong>
{stockQtyPreview.stockQty} {stockQtyPreview.stockUom}
</strong>{' '}
on the warehouse (converted from {stockQtyPreview.entered} {stockQtyPreview.uom}).
</p>
) : (
<p className="text-[11px] text-muted-foreground">
Warehouse on-hand is always stored in the item&apos;s stock UOM.
</p>
)}
</div>
<div className="space-y-1.5">
<Label>Batch</Label>
<Input required value={form.batch} onChange={(e) => setForm({ ...form, batch: e.target.value })} />

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,8 @@ export interface Branch {
phone: string
}
export type OrganisationType = 'corporate' | 'hospital' | 'clinic' | 'ngo' | 'other'
export interface Prescription {
id: string
customerName: string
@ -55,7 +57,12 @@ export interface Prescription {
status: 'submitted' | 'under_review' | 'approved' | 'queried' | 'rejected'
items: { name: string; qty: number; controlled?: boolean }[]
submittedAt: string
/** Display name of the prescribing clinician (denormalised). */
prescriber?: string
/** Linked Doctor actor when the Rx was authored or attributed in-platform. */
doctorId?: string
/** Hospital org the prescription is associated with (usually the doctor's hospital). */
orgId?: string
/** Set when a pharmacist picks the Rx up off the queue. */
reviewStartedAt?: string
/** Pharmacist who approved, queried or rejected. */
@ -64,9 +71,24 @@ export interface Prescription {
decisionNote?: string
}
/** End-customer on an order — platform account vs walk-in / phone / guest. */
export type OrderCustomerType = 'registered' | 'guest'
export interface Order {
id: string
customerName: string
/**
* Whether the end customer is a logged-in platform user or an unauthenticated
* guest (walk-in, phone, POS, doctor-assisted patient without an account).
* Defaults to `guest` when omitted on legacy mock rows whose name starts with "Guest".
*/
customerType?: OrderCustomerType
/** CRM / platform customer id when `customerType === 'registered'`. */
customerId?: string
/** Contact phone — required for guests; optional denormalised copy for registered. */
customerPhone?: string
/** Free-text staff / doctor notes (allergy, pickup name, etc.). */
notes?: string
branchId: string
fulfillment: 'delivery' | 'pickup'
status: string
@ -78,12 +100,51 @@ export interface Order {
createdAt: string
paid: boolean
rxApproved?: boolean
/** Doctor who initiated / authored the order when placed as a clinical actor. */
doctorId?: string
/** Hospital org that scoped the doctor-initiated order. */
orgId?: string
}
/**
* External clinical actor affiliated with a hospital (or clinic) organisation.
* Not pharmacy staff — doors open from hospital orgs; doctors create orders & Rx
* scoped to their affiliation. Managed in Admin under Organisations / Doctors.
*/
export interface Doctor {
id: string
name: string
specialty: string
/** Medical registration / license number (e.g. FMHACA / MoH). */
licenseNumber: string
phone: string
email: string
status: 'active' | 'inactive'
orgId: string
orgName: string
/** Optional Gishen fulfilment branch the doctor typically routes to. */
branchId?: string
branchName?: string
/** Whether a login principal has been provisioned for this doctor. */
hasLinkedAccount: boolean
createdAt: string
updatedAt: string
avatarUrl?: string
}
export interface Organisation {
id: string
name: string
/** B2B account kind — hospital/clinic unlocks the Doctors roster. */
orgType: OrganisationType
/** Tax Identification Number (10 digits). */
tin: string
/** VAT registration number — often mirrors TIN once VAT-registered. */
vatNumber?: string
/** Trade / business license number issued by the city administration. */
businessLicense?: string
/** Commercial registration number (Ministry of Trade). */
commercialRegistration?: string
status: 'pending' | 'active' | 'rejected'
creditLimitEtb: number
usedEtb: number