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:
parent
fce0149b1a
commit
dd471c5909
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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>} />
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
function renderKpiGrid(items: Kpi[]) {
|
||||
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>
|
||||
|
||||
<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
|
||||
|
|
|
|||
181
src/pages/doctors/CreateDoctorPage.tsx
Normal file
181
src/pages/doctors/CreateDoctorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
src/pages/doctors/DoctorDetailPage.tsx
Normal file
182
src/pages/doctors/DoctorDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
src/pages/doctors/DoctorsPage.tsx
Normal file
178
src/pages/doctors/DoctorsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const validateStep = (index: number) => {
|
||||
switch (index) {
|
||||
case 0:
|
||||
if (!form.genericName.trim()) {
|
||||
toast.error('Generic name / INN is required')
|
||||
return
|
||||
}
|
||||
if (isControlled && !form.controlledSchedule) {
|
||||
toast.error('Controlled schedule is required for controlled substances')
|
||||
return
|
||||
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 (!isLastStep) {
|
||||
goNext()
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < STEPS.length; i += 1) {
|
||||
if (!validateStep(i)) {
|
||||
setStep(i)
|
||||
return
|
||||
}
|
||||
}
|
||||
const cleanIngredients = ingredients.filter((i) => i.name.trim())
|
||||
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">
|
||||
{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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,17 +102,82 @@ export function CreateOrderPage() {
|
|||
</>
|
||||
}
|
||||
>
|
||||
<form id="create-order-form" className={createFormGrid} onSubmit={onSubmit}>
|
||||
<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>
|
||||
<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>Customer name</Label>
|
||||
<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>Phone</Label>
|
||||
<Label>Contact phone</Label>
|
||||
<Input
|
||||
required
|
||||
placeholder="+2519…"
|
||||
|
|
@ -86,6 +185,31 @@ export function CreateOrderPage() {
|
|||
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
|
||||
|
|
@ -127,13 +251,47 @@ export function CreateOrderPage() {
|
|||
<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>
|
||||
|
|
@ -144,14 +302,8 @@ export function CreateOrderPage() {
|
|||
/>
|
||||
</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>
|
||||
</section>
|
||||
</form>
|
||||
</CreatePageLayout>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
<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>Organisation name</Label>
|
||||
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<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>TIN</Label>
|
||||
<Input required value={form.tin} onChange={(e) => setForm({ ...form, tin: e.target.value })} />
|
||||
<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="space-y-1.5">
|
||||
<Label>Credit limit (ETB)</Label>
|
||||
<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={(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 })}
|
||||
onChange={set('creditLimitEtb')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Payment terms</Label>
|
||||
<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
|
||||
value={form.paymentTerms}
|
||||
onChange={(e) => setForm({ ...form, paymentTerms: e.target.value })}
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,7 +80,28 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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'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 })} />
|
||||
|
|
|
|||
|
|
@ -2,43 +2,126 @@ import { useMemo, useState } from 'react'
|
|||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Boxes, CalendarClock, Eye, PackageX, Plus, RefreshCw, TrendingDown, Wallet } from 'lucide-react'
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
CalendarClock,
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
PackageMinus,
|
||||
PackagePlus,
|
||||
PackageX,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
TrendingDown,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
import { PageShell } from '@/layouts/PageShell'
|
||||
import { StatCard } from '@/components/dashboard/StatCard'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { ChartCard } from '@/components/charts/ChartCard'
|
||||
import {
|
||||
CHART_COLORS,
|
||||
GRADIENT_IDS,
|
||||
GradientDefs,
|
||||
axisProps,
|
||||
chartMargin,
|
||||
gridProps,
|
||||
tooltipProps,
|
||||
} from '@/components/charts/chartTheme'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
|
||||
import { RowActions } from '@/components/table/RowActions'
|
||||
import { branches, stockRows } from '@/mocks/data'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { TableToolbar } from '@/components/table/TableToolbar'
|
||||
import type { TableFilter } from '@/components/table/TableToolbar'
|
||||
import { RowActions } from '@/components/table/RowActions'
|
||||
import {
|
||||
branches,
|
||||
stockReconciliationLines,
|
||||
stockRows,
|
||||
stockTransactions,
|
||||
type StockTransaction,
|
||||
type StockTxnType,
|
||||
} from '@/mocks/data'
|
||||
import { stockMovement } from '@/mocks/charts'
|
||||
import { useAuthStore } from '@/zustand/authStore'
|
||||
import { formatEtb } from '@/lib/utils'
|
||||
import { formatDateTime, formatEtb, formatNumber } from '@/lib/utils'
|
||||
|
||||
/** Same threshold the dashboard and procurement use to flag low stock. */
|
||||
const LOW_STOCK_THRESHOLD = 20
|
||||
const NEAR_EXPIRY_MONTHS = 3
|
||||
|
||||
/** Mock expiry is a `YYYY-MM` string, so compare whole months instead of parsing a full date. */
|
||||
const TAB_VALUES = ['inventory', 'transactions', 'reconciliation', 'reports'] as const
|
||||
type StockTab = (typeof TAB_VALUES)[number]
|
||||
|
||||
function monthsUntilExpiry(expiry: string, from: Date) {
|
||||
const [year, month] = expiry.split('-').map(Number)
|
||||
if (!year || !month) return Number.POSITIVE_INFINITY
|
||||
return (year - from.getFullYear()) * 12 + (month - 1 - from.getMonth())
|
||||
}
|
||||
|
||||
function branchName(id?: string) {
|
||||
if (!id) return '—'
|
||||
return branches.find((b) => b.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
const txnTypeLabel: Record<StockTxnType, string> = {
|
||||
receipt: 'Stock receipt',
|
||||
delivery: 'Delivery',
|
||||
transfer: 'Transfer',
|
||||
adjustment: 'Adjustment',
|
||||
}
|
||||
|
||||
const txnTypeVariant = {
|
||||
receipt: 'success',
|
||||
delivery: 'warning',
|
||||
transfer: 'default',
|
||||
adjustment: 'muted',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Stock module shell modelled on ERPNext Stock:
|
||||
* Inventory tracking · transactions · reconciliation · reports & analytics.
|
||||
* Item master lives at `/items`; this page owns warehouse qty and movements.
|
||||
*/
|
||||
export function StockPage() {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [entry, setEntry] = useState('')
|
||||
const [rows, setRows] = useState(stockRows)
|
||||
const [txns, setTxns] = useState(stockTransactions)
|
||||
const [counts, setCounts] = useState(
|
||||
() => Object.fromEntries(stockReconciliationLines.map((l) => [l.id, String(l.countedQty)])),
|
||||
)
|
||||
const [q, setQ] = useState('')
|
||||
const [branchFilter, setBranchFilter] = useState('all')
|
||||
const [txnType, setTxnType] = useState('all')
|
||||
const [txnStatus, setTxnStatus] = useState('all')
|
||||
const [txnQ, setTxnQ] = useState('')
|
||||
const [entry, setEntry] = useState('')
|
||||
|
||||
/** The alert filter lives in the URL so the KPI cards can link into a filtered inventory view. */
|
||||
const alertFilter = searchParams.get('alert') ?? 'all'
|
||||
const tabParam = searchParams.get('tab')
|
||||
const activeTab: StockTab = TAB_VALUES.includes(tabParam as StockTab)
|
||||
? (tabParam as StockTab)
|
||||
: 'inventory'
|
||||
|
||||
const setAlertFilter = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
|
|
@ -47,7 +130,13 @@ export function StockPage() {
|
|||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
|
||||
/** Branch-scoped roles only ever see their own shelves, so the KPIs and the table share one base list. */
|
||||
const setTab = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (value === 'inventory') next.delete('tab')
|
||||
else next.set('tab', value)
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
|
||||
const canPickBranch = !user?.branchId
|
||||
const scoped = useMemo(
|
||||
() => (user?.branchId ? rows.filter((r) => r.branchId === user.branchId) : rows),
|
||||
|
|
@ -56,6 +145,7 @@ export function StockPage() {
|
|||
|
||||
const kpis = useMemo(() => {
|
||||
const now = new Date()
|
||||
const mismatches = scoped.filter((r) => r.qty !== r.erpQty).length
|
||||
return {
|
||||
skus: new Set(scoped.map((r) => r.sku)).size,
|
||||
lines: scoped.length,
|
||||
|
|
@ -64,8 +154,10 @@ export function StockPage() {
|
|||
out: scoped.filter((r) => r.qty === 0).length,
|
||||
expiring: scoped.filter((r) => monthsUntilExpiry(r.expiry, now) <= NEAR_EXPIRY_MONTHS).length,
|
||||
retailValueEtb: scoped.reduce((sum, r) => sum + r.qty * r.unitEtb, 0),
|
||||
mismatches,
|
||||
openTransfers: txns.filter((t) => t.type === 'transfer' && t.status === 'draft').length,
|
||||
}
|
||||
}, [scoped])
|
||||
}, [scoped, txns])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const now = new Date()
|
||||
|
|
@ -84,6 +176,84 @@ export function StockPage() {
|
|||
return list
|
||||
}, [scoped, canPickBranch, q, branchFilter, alertFilter])
|
||||
|
||||
const filteredTxns = useMemo(() => {
|
||||
let list = txns
|
||||
if (user?.branchId) {
|
||||
list = list.filter(
|
||||
(t) => t.fromBranchId === user.branchId || t.toBranchId === user.branchId,
|
||||
)
|
||||
}
|
||||
if (txnType !== 'all') list = list.filter((t) => t.type === txnType)
|
||||
if (txnStatus !== 'all') list = list.filter((t) => t.status === txnStatus)
|
||||
if (txnQ) {
|
||||
const needle = txnQ.toLowerCase()
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.id.toLowerCase().includes(needle) ||
|
||||
t.sku.toLowerCase().includes(needle) ||
|
||||
t.name.toLowerCase().includes(needle) ||
|
||||
(t.reference ?? '').toLowerCase().includes(needle),
|
||||
)
|
||||
}
|
||||
return [...list].sort((a, b) => b.at.localeCompare(a.at))
|
||||
}, [txns, user?.branchId, txnType, txnStatus, txnQ])
|
||||
|
||||
const mismatches = useMemo(
|
||||
() => scoped.filter((r) => r.qty !== r.erpQty),
|
||||
[scoped],
|
||||
)
|
||||
|
||||
const reconLines = useMemo(() => {
|
||||
return stockReconciliationLines
|
||||
.filter((l) => !user?.branchId || l.branchId === user.branchId)
|
||||
.map((l) => {
|
||||
const counted = Number(counts[l.id])
|
||||
const countedQty = Number.isFinite(counted) ? counted : l.countedQty
|
||||
const variance = countedQty - l.bookQty
|
||||
return { ...l, countedQty, variance, varianceEtb: variance * l.unitEtb }
|
||||
})
|
||||
}, [counts, user?.branchId])
|
||||
|
||||
const reconSummary = useMemo(() => {
|
||||
const withVariance = reconLines.filter((l) => l.variance !== 0)
|
||||
return {
|
||||
lines: reconLines.length,
|
||||
variances: withVariance.length,
|
||||
valueEtb: withVariance.reduce((sum, l) => sum + Math.abs(l.varianceEtb), 0),
|
||||
}
|
||||
}, [reconLines])
|
||||
|
||||
const valueByBranch = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const r of scoped) {
|
||||
map.set(r.branchId, (map.get(r.branchId) ?? 0) + r.qty * r.unitEtb)
|
||||
}
|
||||
return [...map.entries()].map(([branchId, value]) => ({
|
||||
branch: branchName(branchId),
|
||||
value,
|
||||
}))
|
||||
}, [scoped])
|
||||
|
||||
const valueByCategory = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const r of scoped) {
|
||||
map.set(r.category, (map.get(r.category) ?? 0) + r.qty * r.unitEtb)
|
||||
}
|
||||
const palette = [
|
||||
CHART_COLORS.brand,
|
||||
CHART_COLORS.orange,
|
||||
CHART_COLORS.forest,
|
||||
'#7C9A2E',
|
||||
'#C97310',
|
||||
'#5B6B3A',
|
||||
'#9BB84A',
|
||||
'#D4A017',
|
||||
]
|
||||
return [...map.entries()]
|
||||
.map(([name, value], i) => ({ name, value, fill: palette[i % palette.length] }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
}, [scoped])
|
||||
|
||||
const duplicates =
|
||||
entry.length > 2
|
||||
? rows.filter(
|
||||
|
|
@ -93,45 +263,135 @@ export function StockPage() {
|
|||
)
|
||||
: []
|
||||
|
||||
const resolve = (sku: string) => {
|
||||
const resolveErp = (sku: string) => {
|
||||
setRows((prev) => prev.map((r) => (r.sku === sku ? { ...r, qty: r.erpQty } : r)))
|
||||
toast.success(`Discrepancy resolved for ${sku}`)
|
||||
toast.success(`Book qty aligned to ERP for ${sku}`)
|
||||
}
|
||||
|
||||
const submitTxn = (txn: StockTransaction) => {
|
||||
setTxns((prev) => prev.map((t) => (t.id === txn.id ? { ...t, status: 'submitted' } : t)))
|
||||
toast.success(`${txnTypeLabel[txn.type]} ${txn.id} submitted`)
|
||||
}
|
||||
|
||||
const postReconciliation = () => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) => {
|
||||
const line = reconLines.find((l) => l.sku === r.sku && l.branchId === r.branchId)
|
||||
return line ? { ...r, qty: line.countedQty } : r
|
||||
}),
|
||||
)
|
||||
toast.success('Stock reconciliation posted — book qty updated from physical count')
|
||||
}
|
||||
|
||||
const createTxnDraft = (type: StockTxnType) => {
|
||||
const sample = scoped[0]
|
||||
if (!sample) {
|
||||
toast.message('No stock lines available to draft against')
|
||||
return
|
||||
}
|
||||
const id = `stx-${Date.now()}`
|
||||
const draft: StockTransaction = {
|
||||
id,
|
||||
type,
|
||||
sku: sample.sku,
|
||||
name: sample.name,
|
||||
qty: type === 'receipt' ? 50 : 10,
|
||||
fromBranchId: type === 'receipt' ? undefined : sample.branchId,
|
||||
toBranchId:
|
||||
type === 'delivery'
|
||||
? undefined
|
||||
: type === 'transfer'
|
||||
? branches.find((b) => b.id !== sample.branchId)?.id
|
||||
: sample.branchId,
|
||||
reference: type === 'receipt' ? 'PO-DRAFT' : type === 'transfer' ? 'TRF-DRAFT' : undefined,
|
||||
at: new Date().toISOString(),
|
||||
status: 'draft',
|
||||
postedBy: user?.name ?? 'Stock Manager',
|
||||
}
|
||||
setTxns((prev) => [draft, ...prev])
|
||||
setTab('transactions')
|
||||
toast.success(`${txnTypeLabel[type]} draft ${id} created`)
|
||||
}
|
||||
|
||||
const inventoryFilters: TableFilter[] = [
|
||||
...(canPickBranch
|
||||
? [
|
||||
{
|
||||
id: 'branch',
|
||||
label: 'Branch',
|
||||
value: branchFilter,
|
||||
onChange: setBranchFilter,
|
||||
options: [
|
||||
{ value: 'all', label: t('common.allBranches') },
|
||||
...branches.map((b) => ({ value: b.id, label: b.name })),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'alert',
|
||||
label: 'Alert',
|
||||
value: alertFilter,
|
||||
onChange: setAlertFilter,
|
||||
options: [
|
||||
{ value: 'all', label: 'All stock' },
|
||||
{ value: 'low', label: 'Low stock' },
|
||||
{ value: 'out', label: 'Out of stock' },
|
||||
{ value: 'expiry', label: 'Expiring soon' },
|
||||
{ value: 'mismatch', label: 'ERP mismatch' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const txnFilters: TableFilter[] = [
|
||||
{
|
||||
id: 'type',
|
||||
label: 'Type',
|
||||
value: txnType,
|
||||
onChange: setTxnType,
|
||||
options: [
|
||||
{ value: 'all', label: 'All types' },
|
||||
{ value: 'receipt', label: 'Receipts' },
|
||||
{ value: 'delivery', label: 'Deliveries' },
|
||||
{ value: 'transfer', label: 'Transfers' },
|
||||
{ value: 'adjustment', label: 'Adjustments' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
value: txnStatus,
|
||||
onChange: setTxnStatus,
|
||||
options: [
|
||||
{ value: 'all', label: 'All statuses' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'submitted', label: 'Submitted' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell title={t('stock.title')} subtitle={t('stock.subtitle')}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<PageShell
|
||||
title={t('stock.title')}
|
||||
subtitle="Track inventory, post receipts/deliveries/transfers, reconcile counts, and review stock analytics — item master lives in Medication items"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="Total SKUs"
|
||||
value={kpis.skus}
|
||||
hint={`${kpis.lines} stock lines · ${kpis.branches} branch${kpis.branches === 1 ? '' : 'es'}`}
|
||||
hint={`${kpis.lines} stock lines · ${kpis.branches} warehouse${kpis.branches === 1 ? '' : 's'}`}
|
||||
icon={Boxes}
|
||||
tone="brand"
|
||||
/>
|
||||
<StatCard
|
||||
label="Low stock"
|
||||
value={kpis.low}
|
||||
hint={`Under ${LOW_STOCK_THRESHOLD} units left`}
|
||||
label="Low / out of stock"
|
||||
value={kpis.low + kpis.out}
|
||||
hint={`${kpis.low} low · ${kpis.out} out`}
|
||||
icon={TrendingDown}
|
||||
tone="orange"
|
||||
to="/stock?alert=low"
|
||||
/>
|
||||
<StatCard
|
||||
label="Out of stock"
|
||||
value={kpis.out}
|
||||
hint="Nothing on the shelf"
|
||||
icon={PackageX}
|
||||
tone="muted"
|
||||
to="/stock?alert=out"
|
||||
/>
|
||||
<StatCard
|
||||
label="Expiring soon"
|
||||
value={kpis.expiring}
|
||||
hint={`Within ${NEAR_EXPIRY_MONTHS} months`}
|
||||
icon={CalendarClock}
|
||||
tone="orange"
|
||||
to="/stock?alert=expiry"
|
||||
/>
|
||||
<StatCard
|
||||
label="Inventory value"
|
||||
value={formatEtb(kpis.retailValueEtb)}
|
||||
|
|
@ -139,64 +399,49 @@ export function StockPage() {
|
|||
icon={Wallet}
|
||||
tone="forest"
|
||||
/>
|
||||
<StatCard
|
||||
label="Open reconciliations"
|
||||
value={kpis.mismatches + kpis.openTransfers}
|
||||
hint={`${kpis.mismatches} ERP gaps · ${kpis.openTransfers} draft transfer${kpis.openTransfers === 1 ? '' : 's'}`}
|
||||
icon={ClipboardCheck}
|
||||
tone="muted"
|
||||
to="/stock?tab=reconciliation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="inventory">
|
||||
<TabsList>
|
||||
<Tabs value={activeTab} onValueChange={setTab}>
|
||||
<TabsList className="h-auto w-full flex-wrap justify-start sm:w-auto">
|
||||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||||
<TabsTrigger value="erp">ERP sync</TabsTrigger>
|
||||
<TabsTrigger value="entry">AI-assisted entry</TabsTrigger>
|
||||
<TabsTrigger value="transfers">Transfers</TabsTrigger>
|
||||
<TabsTrigger value="transactions">Transactions</TabsTrigger>
|
||||
<TabsTrigger value="reconciliation">Reconciliation</TabsTrigger>
|
||||
<TabsTrigger value="reports">Reports</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inventory">
|
||||
|
||||
<TabsContent value="inventory" className="space-y-4">
|
||||
<Card className="overflow-hidden">
|
||||
<TableToolbar
|
||||
search={q}
|
||||
onSearchChange={setQ}
|
||||
searchPlaceholder="Search SKU or name…"
|
||||
resultCount={filtered.length}
|
||||
filters={
|
||||
<>
|
||||
{canPickBranch ? (
|
||||
<FilterSelect
|
||||
label="Branch"
|
||||
value={branchFilter}
|
||||
onChange={setBranchFilter}
|
||||
options={[
|
||||
{ value: 'all', label: t('common.allBranches') },
|
||||
...branches.map((b) => ({ value: b.id, label: b.name })),
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<FilterSelect
|
||||
label="Alert"
|
||||
value={alertFilter}
|
||||
onChange={setAlertFilter}
|
||||
options={[
|
||||
{ value: 'all', label: 'All stock' },
|
||||
{ value: 'low', label: 'Low stock' },
|
||||
{ value: 'out', label: 'Out of stock' },
|
||||
{ value: 'expiry', label: 'Expiring soon' },
|
||||
{ value: 'mismatch', label: 'ERP mismatch' },
|
||||
]}
|
||||
/>
|
||||
<ClearFiltersButton
|
||||
visible={branchFilter !== 'all' || alertFilter !== 'all' || !!q}
|
||||
onClick={() => {
|
||||
filters={inventoryFilters}
|
||||
onClearFilters={() => {
|
||||
setQ('')
|
||||
setBranchFilter('all')
|
||||
setAlertFilter('all')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" className="h-10 gap-2 shadow-none">
|
||||
<Link to="/items">Item master</Link>
|
||||
</Button>
|
||||
<Button asChild className="h-10 gap-2 shadow-none">
|
||||
<Link to="/stock/new">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add stock line
|
||||
Receive stock
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<CardContent className="p-0">
|
||||
|
|
@ -205,7 +450,7 @@ export function StockPage() {
|
|||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Branch</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Expiry</TableHead>
|
||||
|
|
@ -229,7 +474,7 @@ export function StockPage() {
|
|||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{r.name}</TableCell>
|
||||
<TableCell>{branches.find((b) => b.id === r.branchId)?.name}</TableCell>
|
||||
<TableCell>{branchName(r.branchId)}</TableCell>
|
||||
<TableCell>{r.qty}</TableCell>
|
||||
<TableCell>{r.batch}</TableCell>
|
||||
<TableCell>{r.expiry}</TableCell>
|
||||
|
|
@ -238,6 +483,8 @@ export function StockPage() {
|
|||
<Badge variant="danger">out of stock</Badge>
|
||||
) : r.qty < LOW_STOCK_THRESHOLD ? (
|
||||
<Badge variant="warning">low stock</Badge>
|
||||
) : r.qty !== r.erpQty ? (
|
||||
<Badge variant="outline">ERP Δ</Badge>
|
||||
) : (
|
||||
<Badge variant="success">ok</Badge>
|
||||
)}
|
||||
|
|
@ -253,9 +500,168 @@ export function StockPage() {
|
|||
...(r.qty !== r.erpQty
|
||||
? [
|
||||
{
|
||||
label: 'Sync ERP',
|
||||
label: 'Reconcile ERP',
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
onClick: () => resolve(r.sku),
|
||||
onClick: () => resolveErp(r.sku),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="font-display text-base">Duplicate check</CardTitle>
|
||||
<CardDescription>
|
||||
Type a name, barcode, or SKU before receiving — near matches should be reviewed against the item
|
||||
master rather than creating a parallel product.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
placeholder="Name, barcode, or SKU…"
|
||||
value={entry}
|
||||
onChange={(e) => setEntry(e.target.value)}
|
||||
/>
|
||||
{duplicates.map((d) => (
|
||||
<div
|
||||
key={`${d.sku}-${d.branchId}`}
|
||||
className="rounded-xl border border-orange/30 bg-orange/5 p-3 text-sm"
|
||||
>
|
||||
Possible match:{' '}
|
||||
<Link to={`/stock/${d.sku}`} className="font-semibold text-brand-700 hover:underline">
|
||||
{d.name}
|
||||
</Link>{' '}
|
||||
({d.sku} · {branchName(d.branchId)}) — prefer{' '}
|
||||
<Link to="/items" className="font-medium text-brand-700 hover:underline">
|
||||
Medication items
|
||||
</Link>{' '}
|
||||
if this is a catalogue duplicate.
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transactions" className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="h-10 gap-1.5" onClick={() => createTxnDraft('receipt')}>
|
||||
<PackagePlus className="h-4 w-4" />
|
||||
New receipt
|
||||
</Button>
|
||||
<Button variant="outline" className="h-10 gap-1.5" onClick={() => createTxnDraft('delivery')}>
|
||||
<PackageMinus className="h-4 w-4" />
|
||||
New delivery
|
||||
</Button>
|
||||
<Button variant="outline" className="h-10 gap-1.5" onClick={() => createTxnDraft('transfer')}>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
New transfer
|
||||
</Button>
|
||||
<Button asChild variant="ghost" className="h-10 gap-1.5">
|
||||
<Link to="/stock/new">Receive against item</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<TableToolbar
|
||||
search={txnQ}
|
||||
onSearchChange={setTxnQ}
|
||||
searchPlaceholder="Search document, SKU, reference…"
|
||||
resultCount={filteredTxns.length}
|
||||
filters={txnFilters}
|
||||
onClearFilters={() => {
|
||||
setTxnQ('')
|
||||
setTxnType('all')
|
||||
setTxnStatus('all')
|
||||
}}
|
||||
/>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Document</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>From → To</TableHead>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTxns.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
|
||||
No transactions match these filters
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredTxns.map((txn) => (
|
||||
<TableRow key={txn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div>{txn.id}</div>
|
||||
{txn.reference ? (
|
||||
<div className="text-xs text-muted-foreground">{txn.reference}</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={txnTypeVariant[txn.type]}>{txnTypeLabel[txn.type]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link to={`/stock/${txn.sku}`} className="font-medium text-brand-700 hover:underline">
|
||||
{txn.sku}
|
||||
</Link>
|
||||
<div className="text-xs text-muted-foreground">{txn.name}</div>
|
||||
</TableCell>
|
||||
<TableCell>{txn.qty}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{txn.type === 'transfer'
|
||||
? `${branchName(txn.fromBranchId)} → ${branchName(txn.toBranchId)}`
|
||||
: txn.type === 'receipt'
|
||||
? `→ ${branchName(txn.toBranchId)}`
|
||||
: txn.type === 'delivery'
|
||||
? `${branchName(txn.fromBranchId)} → out`
|
||||
: branchName(txn.fromBranchId ?? txn.toBranchId)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm">
|
||||
{formatDateTime(txn.at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
txn.status === 'submitted'
|
||||
? 'success'
|
||||
: txn.status === 'cancelled'
|
||||
? 'danger'
|
||||
: 'muted'
|
||||
}
|
||||
>
|
||||
{txn.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
primary={[
|
||||
{
|
||||
label: 'View item',
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
to: `/stock/${txn.sku}`,
|
||||
},
|
||||
...(txn.status === 'draft'
|
||||
? [
|
||||
{
|
||||
label: 'Submit',
|
||||
icon: <ClipboardCheck className="h-3.5 w-3.5" />,
|
||||
onClick: () => submitTxn(txn),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
@ -270,61 +676,261 @@ export function StockPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="erp">
|
||||
|
||||
<TabsContent value="reconciliation" className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{rows
|
||||
.filter((r) => r.qty !== r.erpQty)
|
||||
.map((r) => (
|
||||
<div key={r.sku} className="flex items-center justify-between rounded-xl border p-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Count lines</CardDescription>
|
||||
<CardTitle className="font-display text-2xl">{reconSummary.lines}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Lines with variance</CardDescription>
|
||||
<CardTitle className="font-display text-2xl">{reconSummary.variances}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Abs. variance value</CardDescription>
|
||||
<CardTitle className="font-display text-2xl">
|
||||
{formatEtb(reconSummary.valueEtb)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="flex flex-col gap-3 border-b sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{r.sku}</p>
|
||||
<CardTitle className="font-display text-base">Physical stock reconciliation</CardTitle>
|
||||
<CardDescription>
|
||||
Enter counted qty per warehouse line. Posting writes the book quantity to match the count
|
||||
(ERPNext-style Stock Reconciliation).
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button className="h-10 gap-1.5" onClick={postReconciliation}>
|
||||
<ClipboardCheck className="h-4 w-4" />
|
||||
Post reconciliation
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead>Book qty</TableHead>
|
||||
<TableHead>Counted qty</TableHead>
|
||||
<TableHead>Variance</TableHead>
|
||||
<TableHead>Value Δ</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reconLines.map((line) => (
|
||||
<TableRow key={line.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{line.sku}</div>
|
||||
<div className="text-xs text-muted-foreground">{line.name}</div>
|
||||
</TableCell>
|
||||
<TableCell>{branchName(line.branchId)}</TableCell>
|
||||
<TableCell>{line.bookQty}</TableCell>
|
||||
<TableCell className="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
className="h-9"
|
||||
value={counts[line.id] ?? String(line.countedQty)}
|
||||
onChange={(e) =>
|
||||
setCounts((prev) => ({ ...prev, [line.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={
|
||||
line.variance === 0
|
||||
? 'text-muted-foreground'
|
||||
: line.variance < 0
|
||||
? 'font-semibold text-destructive'
|
||||
: 'font-semibold text-brand-700'
|
||||
}
|
||||
>
|
||||
{line.variance > 0 ? `+${line.variance}` : line.variance}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{formatEtb(line.varianceEtb)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-display text-base">ERP sync discrepancies</CardTitle>
|
||||
<CardDescription>
|
||||
Platform vs ERP on-hand. Resolve when the ERP figure is the source of truth.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{mismatches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No open ERP discrepancies in scope.</p>
|
||||
) : (
|
||||
mismatches.map((r) => {
|
||||
const delta = r.qty - r.erpQty
|
||||
const pct = r.erpQty ? Math.min(100, Math.round((r.qty / r.erpQty) * 100)) : 0
|
||||
return (
|
||||
<div
|
||||
key={`${r.sku}-${r.branchId}`}
|
||||
className="flex flex-col gap-3 rounded-xl border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{r.sku} · {r.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Platform {r.qty} · ERP {r.erpQty}
|
||||
{branchName(r.branchId)} · Platform {r.qty} · ERP {r.erpQty} · Δ{' '}
|
||||
{delta > 0 ? `+${delta}` : delta}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" className="h-9 gap-1.5" onClick={() => resolve(r.sku)}>
|
||||
<Progress value={pct} />
|
||||
</div>
|
||||
<Button size="sm" className="h-9 gap-1.5" onClick={() => resolveErp(r.sku)}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Resolve to ERP
|
||||
Align to ERP
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{rows.every((r) => r.qty === r.erpQty) && (
|
||||
<p className="text-sm text-muted-foreground">No open discrepancies</p>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="entry">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<Input
|
||||
placeholder="Name, barcode, or SKU…"
|
||||
value={entry}
|
||||
onChange={(e) => setEntry(e.target.value)}
|
||||
|
||||
<TabsContent value="reports" className="space-y-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
title="Stock movement"
|
||||
description="Weekly inflow vs outflow across warehouses"
|
||||
height="h-72"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={stockMovement} margin={chartMargin}>
|
||||
<GradientDefs />
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="week" {...axisProps} />
|
||||
<YAxis {...axisProps} width={44} />
|
||||
<Tooltip {...tooltipProps} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="in"
|
||||
name="In"
|
||||
stroke={CHART_COLORS.brand}
|
||||
fill={`url(#${GRADIENT_IDS.brand})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{duplicates.map((d) => (
|
||||
<div key={d.sku} className="rounded-xl border border-orange/30 bg-orange/5 p-3 text-sm">
|
||||
Possible match: <strong>{d.name}</strong> ({d.sku}) — merge requires human approval
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="out"
|
||||
name="Out"
|
||||
stroke={CHART_COLORS.orange}
|
||||
fill={`url(#${GRADIENT_IDS.orange})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Value by warehouse"
|
||||
description="On-hand inventory at shelf price"
|
||||
height="h-72"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={valueByBranch} margin={chartMargin}>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="branch" {...axisProps} />
|
||||
<YAxis {...axisProps} width={52} tickFormatter={(v) => formatNumber(Number(v))} />
|
||||
<Tooltip {...tooltipProps} formatter={(v) => formatEtb(Number(v))} />
|
||||
<Bar dataKey="value" name="Value" fill={CHART_COLORS.brand} radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Value by category" description="Category mix of on-hand value" height="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={valueByCategory}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={52}
|
||||
outerRadius={82}
|
||||
paddingAngle={3}
|
||||
>
|
||||
{valueByCategory.map((slice) => (
|
||||
<Cell key={slice.name} fill={slice.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip {...tooltipProps} formatter={(v) => formatEtb(Number(v))} />
|
||||
<Legend verticalAlign="bottom" height={56} iconType="circle" iconSize={8} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-display text-base">Watchlist</CardTitle>
|
||||
<CardDescription>Expiring soon and stock-outs that need action this week</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{scoped
|
||||
.filter((r) => {
|
||||
const now = new Date()
|
||||
return r.qty === 0 || monthsUntilExpiry(r.expiry, now) <= NEAR_EXPIRY_MONTHS
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${r.sku}-${r.branchId}-watch`}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/stock/${r.sku}`}
|
||||
className="font-medium text-brand-700 hover:underline"
|
||||
>
|
||||
{r.sku}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{r.name} · {branchName(r.branchId)}
|
||||
</p>
|
||||
</div>
|
||||
{r.qty === 0 ? (
|
||||
<Badge variant="danger" className="shrink-0 gap-1">
|
||||
<PackageX className="h-3 w-3" />
|
||||
Out
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning" className="shrink-0 gap-1">
|
||||
<CalendarClock className="h-3 w-3" />
|
||||
{r.expiry}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{scoped.every((r) => {
|
||||
const now = new Date()
|
||||
return r.qty > 0 && monthsUntilExpiry(r.expiry, now) > NEAR_EXPIRY_MONTHS
|
||||
}) ? (
|
||||
<p className="text-sm text-muted-foreground">Watchlist is clear for this scope.</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="transfers">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stock Manager owns inter-branch transfers. Suggest move of Vitamin C from Bole → CMC.
|
||||
</p>
|
||||
<Button
|
||||
className="h-10"
|
||||
onClick={() => toast.success('Transfer draft created (mock)')}
|
||||
>
|
||||
Create transfer suggestion
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageShell>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Reference in New Issue
Block a user