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 | 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 | 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-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
|
## Conventions
|
||||||
|
|
||||||
|
|
@ -53,6 +55,15 @@
|
||||||
|
|
||||||
Pharmacist (and optionally stock) sessions include `branchId`.
|
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
|
## Auth & me
|
||||||
|
|
@ -93,18 +104,49 @@ UI: `/profile` (every authenticated role; not permission-gated), topbar “My pr
|
||||||
|
|
||||||
| Method | Path | Roles | Description |
|
| Method | Path | Roles | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `q` |
|
| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `customerType`, `q` |
|
||||||
| POST | `/pharmacy/orders` | pharmacist, operations, super_admin | Staff-created order (call centre / walk-in) |
|
| 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 | Detail: header + line items + payment + fulfillment |
|
| 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 |
|
| 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/confirm` | pharmacist, super_admin | Confirm stock |
|
||||||
| POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` |
|
| POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` |
|
||||||
| PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle |
|
| 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`
|
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 |
|
| Method | Path | Roles | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue; filters: `status`, `controlled`, `q` |
|
| 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 | `/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 |
|
| 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/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 |
|
| 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 |
|
| GET/PUT | `/admin/finance/gateways` | finance, super_admin | Enable/disable Chapa, ArifPay, Telebirr, M-Pesa |
|
||||||
| POST | `/admin/finance/refunds` | finance, super_admin | Refund handling |
|
| 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/finance/loyalty-liability` | finance, super_admin | Outstanding Birr liability (points × redemption rate; must reconcile with Loyalty KPI) |
|
||||||
| GET | `/admin/organisations` | finance, super_admin | List |
|
| GET | `/admin/organisations` | finance, super_admin | List; filter `orgType`, `status`, `source` |
|
||||||
| POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (active) |
|
| POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (active); body includes `orgType` |
|
||||||
| GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue |
|
| GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue |
|
||||||
| POST | `/admin/organisations/:id/approve` | finance, super_admin | Activate credit |
|
| POST | `/admin/organisations/:id/approve` | finance, super_admin | Activate credit |
|
||||||
| POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject |
|
| POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject |
|
||||||
| GET | `/admin/organisations/:id/statements` | finance, super_admin | Consolidated invoices |
|
| 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 |
|
| 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 |
|
| 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/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 }` |
|
| GET | `/admin/organisations/:id/invoices` | finance, super_admin | `{ id, period, amountEtb, status }` |
|
||||||
| PATCH | `/admin/organisations/:id/credit` | finance, super_admin | `{ creditLimitEtb }` (review-gated) |
|
| 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.
|
**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
|
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)
|
6. Invoice OCR provider for `/pos/invoices/extract` (Admin currently simulates extraction)
|
||||||
7. Catalogue merge queue ownership: Stock Manager vs dedicated data-steward role
|
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 { OrganisationsPage } from '@/pages/organisations/OrganisationsPage'
|
||||||
import { CreateOrganisationPage } from '@/pages/organisations/CreateOrganisationPage'
|
import { CreateOrganisationPage } from '@/pages/organisations/CreateOrganisationPage'
|
||||||
import { OrganisationDetailPage } from '@/pages/organisations/OrganisationDetailPage'
|
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 { MarketingPage } from '@/pages/marketing/MarketingPage'
|
||||||
import { CrmPage } from '@/pages/marketing/CrmPage'
|
import { CrmPage } from '@/pages/marketing/CrmPage'
|
||||||
import { CustomerDetailPage } from '@/pages/marketing/CustomerDetailPage'
|
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" element={<Guard module="organisations"><OrganisationsPage /></Guard>} />
|
||||||
<Route path="organisations/new" element={<Guard module="organisations"><CreateOrganisationPage /></Guard>} />
|
<Route path="organisations/new" element={<Guard module="organisations"><CreateOrganisationPage /></Guard>} />
|
||||||
<Route path="organisations/:id" element={<Guard module="organisations"><OrganisationDetailPage /></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" element={<Guard module="marketing"><MarketingPage /></Guard>} />
|
||||||
<Route path="marketing/:id" element={<Guard module="marketing"><SegmentDetailPage /></Guard>} />
|
<Route path="marketing/:id" element={<Guard module="marketing"><SegmentDetailPage /></Guard>} />
|
||||||
<Route path="crm" element={<Guard module="crm"><CrmPage /></Guard>} />
|
<Route path="crm" element={<Guard module="crm"><CrmPage /></Guard>} />
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export type StatusDomain =
|
||||||
| 'order'
|
| 'order'
|
||||||
| 'prescription'
|
| 'prescription'
|
||||||
| 'organisation'
|
| 'organisation'
|
||||||
|
| 'doctor'
|
||||||
| 'procurement'
|
| 'procurement'
|
||||||
| 'settlement'
|
| 'settlement'
|
||||||
| 'stock'
|
| 'stock'
|
||||||
|
|
@ -57,6 +58,15 @@ const STATUS_MAP: Record<StatusDomain, Record<string, StatusEntry>> = {
|
||||||
rejected: { label: 'Rejected', variant: 'danger' },
|
rejected: { label: 'Rejected', variant: 'danger' },
|
||||||
admin: { label: 'Added by admin', variant: 'muted' },
|
admin: { label: 'Added by admin', variant: 'muted' },
|
||||||
self_register: { label: 'Self-registered', variant: 'secondary' },
|
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: {
|
procurement: {
|
||||||
open: { label: 'Open', variant: 'secondary' },
|
open: { label: 'Open', variant: 'secondary' },
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
|
Stethoscope,
|
||||||
Truck,
|
Truck,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -270,7 +271,19 @@ export const navigationRegistry: NavEntry[] = [
|
||||||
module: 'organisations',
|
module: 'organisations',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
group: 'finance',
|
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',
|
kind: 'page',
|
||||||
showInSidebar: true,
|
showInSidebar: true,
|
||||||
},
|
},
|
||||||
|
|
@ -484,7 +497,18 @@ export const navigationRegistry: NavEntry[] = [
|
||||||
module: 'organisations',
|
module: 'organisations',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
group: 'actions',
|
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',
|
kind: 'action',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export function CreatePageLayout({
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
className,
|
className,
|
||||||
|
cardTitle = 'Details',
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
|
@ -32,28 +33,42 @@ export function CreatePageLayout({
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
cardTitle?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full min-w-0 space-y-5', className)}>
|
<div className={cn('w-full min-w-0 space-y-5', className)}>
|
||||||
<div>
|
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-3 text-muted-foreground">
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
<Link to={backTo}>
|
<Button
|
||||||
<ArrowLeft className="h-4 w-4" />
|
asChild
|
||||||
{backLabel}
|
variant="outline"
|
||||||
</Link>
|
size="sm"
|
||||||
</Button>
|
className="h-9 w-fit gap-1.5 bg-background shadow-none"
|
||||||
<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>
|
<Link to={backTo}>
|
||||||
{subtitle ? <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p> : null}
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</div>
|
{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>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base">Details</CardTitle>
|
<CardTitle className="text-base">{cardTitle}</CardTitle>
|
||||||
{subtitle ? <CardDescription className="sr-only">{subtitle}</CardDescription> : null}
|
{subtitle ? <CardDescription className="sr-only">{subtitle}</CardDescription> : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">{children}</CardContent>
|
<CardContent className="space-y-5">{children}</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,13 @@ export function DetailPageLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full min-w-0 space-y-5', className)}>
|
<div className={cn('w-full min-w-0 space-y-5', className)}>
|
||||||
<div>
|
<header className="space-y-4">
|
||||||
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-3 text-muted-foreground">
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-fit gap-1.5 bg-background shadow-none"
|
||||||
|
>
|
||||||
<Link to={backTo}>
|
<Link to={backTo}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{backLabel}
|
{backLabel}
|
||||||
|
|
@ -45,7 +50,7 @@ export function DetailPageLayout({
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
{sidebar ? (
|
{sidebar ? (
|
||||||
<div className="grid gap-5 [&>*]:min-w-0 lg:grid-cols-[minmax(0,1fr)_22rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
|
<div className="grid gap-5 [&>*]:min-w-0 lg:grid-cols-[minmax(0,1fr)_22rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,41 @@ export type CatalogBarcode = {
|
||||||
label?: string
|
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 = {
|
export type DraftPackVariant = {
|
||||||
id: string
|
id: string
|
||||||
packSize: string
|
packSize: string
|
||||||
|
|
@ -107,9 +142,15 @@ export type MedicationItem = {
|
||||||
controlledSchedule?: ControlledSchedule | ''
|
controlledSchedule?: ControlledSchedule | ''
|
||||||
|
|
||||||
packSize: string
|
packSize: string
|
||||||
|
/** Stock / base unit of measure — qty on warehouse rows is always in this unit. */
|
||||||
baseUnit: string
|
baseUnit: string
|
||||||
sellByUnit: string
|
sellByUnit: string
|
||||||
packUnit: 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. */
|
/** Parent item id when this row is a pack-size variant. */
|
||||||
parentItemId?: string
|
parentItemId?: string
|
||||||
|
|
||||||
|
|
@ -130,8 +171,23 @@ export type MedicationItem = {
|
||||||
shortDescription: string
|
shortDescription: string
|
||||||
dosageGuidance: string
|
dosageGuidance: string
|
||||||
sideEffects: string
|
sideEffects: string
|
||||||
|
/** Structured mild / severe lists for the mobile PDP. Fallbacks parse `sideEffects`. */
|
||||||
|
sideEffectsMild?: string[]
|
||||||
|
sideEffectsSevere?: string[]
|
||||||
warnings: string
|
warnings: string
|
||||||
storageInstructions: 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[]
|
alternativeItemIds: string[]
|
||||||
|
|
||||||
defaultReorderPoint: number
|
defaultReorderPoint: number
|
||||||
|
|
@ -274,7 +330,176 @@ export function slugifyItem(brand: string, generic: string): string {
|
||||||
return base || 'item'
|
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',
|
id: 'item-amx-500',
|
||||||
sku: 'AMX-500',
|
sku: 'AMX-500',
|
||||||
|
|
@ -312,8 +537,33 @@ export const catalogItems: MedicationItem[] = [
|
||||||
shortDescription: 'Broad-spectrum penicillin antibiotic for bacterial infections.',
|
shortDescription: 'Broad-spectrum penicillin antibiotic for bacterial infections.',
|
||||||
dosageGuidance: 'Adults: 500mg every 8 hours. Children: per kg as directed by clinician.',
|
dosageGuidance: 'Adults: 500mg every 8 hours. Children: per kg as directed by clinician.',
|
||||||
sideEffects: 'Nausea, diarrhoea, rash. Stop if severe allergic reaction.',
|
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.',
|
warnings: 'Do not use if penicillin-allergic. Complete the full course.',
|
||||||
storageInstructions: 'Store below 25°C in a dry place.',
|
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'],
|
alternativeItemIds: ['item-azi-250'],
|
||||||
defaultReorderPoint: 40,
|
defaultReorderPoint: 40,
|
||||||
defaultExpiryAlertDays: 90,
|
defaultExpiryAlertDays: 90,
|
||||||
|
|
@ -435,8 +685,33 @@ export const catalogItems: MedicationItem[] = [
|
||||||
shortDescription: 'Pain relief and fever reducer for adults and children over 6.',
|
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.',
|
dosageGuidance: 'Adults: 1–2 tablets every 4–6 hours. Max 8 tablets/day.',
|
||||||
sideEffects: 'Rare rash or liver issues with overdose.',
|
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.',
|
warnings: 'Do not exceed labeled dose. Avoid with other paracetamol products.',
|
||||||
storageInstructions: 'Cool, dry place. Keep out of reach of children.',
|
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'],
|
alternativeItemIds: ['item-ibu-400'],
|
||||||
defaultReorderPoint: 60,
|
defaultReorderPoint: 60,
|
||||||
defaultExpiryAlertDays: 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[] = [
|
export const itemChangeHistory: ItemChangeEvent[] = [
|
||||||
{
|
{
|
||||||
id: 'ich-1',
|
id: 'ich-1',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import { portraitFor } from '@/lib/avatar'
|
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[] = [
|
export const branches: Branch[] = [
|
||||||
{ id: 'br-bole', name: 'Bole Branch', zone: 'Bole', phone: '+251 930 099 850' },
|
{ 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-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-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-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[] = [
|
export let organisations: Organisation[] = [
|
||||||
{
|
{
|
||||||
id: 'org-1',
|
id: 'org-1',
|
||||||
name: 'Horizon Bank',
|
name: 'Horizon Bank',
|
||||||
|
orgType: 'corporate',
|
||||||
tin: '0001234567',
|
tin: '0001234567',
|
||||||
|
vatNumber: 'VAT-ET-0001234567',
|
||||||
|
businessLicense: 'BL-AA/48291/2014',
|
||||||
|
commercialRegistration: 'CR/015842/2014',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
creditLimitEtb: 500000,
|
creditLimitEtb: 500000,
|
||||||
usedEtb: 124000,
|
usedEtb: 124000,
|
||||||
|
|
@ -416,7 +481,11 @@ export let organisations: Organisation[] = [
|
||||||
{
|
{
|
||||||
id: 'org-2',
|
id: 'org-2',
|
||||||
name: 'Green NGO Ethiopia',
|
name: 'Green NGO Ethiopia',
|
||||||
|
orgType: 'ngo',
|
||||||
tin: '0009876543',
|
tin: '0009876543',
|
||||||
|
vatNumber: '',
|
||||||
|
businessLicense: 'BL-AA/99102/2025',
|
||||||
|
commercialRegistration: '',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
creditLimitEtb: 100000,
|
creditLimitEtb: 100000,
|
||||||
usedEtb: 0,
|
usedEtb: 0,
|
||||||
|
|
@ -424,8 +493,139 @@ export let organisations: Organisation[] = [
|
||||||
source: 'self_register',
|
source: 'self_register',
|
||||||
createdAt: '2026-08-05T14:00:00Z',
|
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. */
|
/** Canonical product categories — keep `category` on every stock row in sync with this list. */
|
||||||
export const productCategories = [
|
export const productCategories = [
|
||||||
'Antibiotics',
|
'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' },
|
{ 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[] = [
|
export let faqs: FaqArticle[] = [
|
||||||
{
|
{
|
||||||
id: 'faq-1',
|
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 = [
|
export let procurementRequests = [
|
||||||
{
|
{
|
||||||
id: 'pr-301',
|
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.
|
// 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-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 }[]> = {
|
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' },
|
{ id: 'inv-881', period: 'Aug 2026 (MTD)', amountEtb: 32000, status: 'open' },
|
||||||
],
|
],
|
||||||
'org-2': [],
|
'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[]> = {
|
export const customerOrders: Record<string, string[]> = {
|
||||||
|
|
@ -2978,6 +3357,13 @@ export {
|
||||||
findCatalogItem,
|
findCatalogItem,
|
||||||
itemVariants,
|
itemVariants,
|
||||||
skuToItemId,
|
skuToItemId,
|
||||||
|
buildDefaultUoms,
|
||||||
|
resolveItemUoms,
|
||||||
|
toStockQty,
|
||||||
|
fromStockQty,
|
||||||
|
stockUomOf,
|
||||||
|
storefrontPdpPreview,
|
||||||
|
commonUoms,
|
||||||
} from './catalog'
|
} from './catalog'
|
||||||
export type {
|
export type {
|
||||||
MedicationItem,
|
MedicationItem,
|
||||||
|
|
@ -2996,6 +3382,7 @@ export type {
|
||||||
CatalogBarcode,
|
CatalogBarcode,
|
||||||
DraftPackVariant,
|
DraftPackVariant,
|
||||||
FieldGroupOwnership,
|
FieldGroupOwnership,
|
||||||
|
ItemUom,
|
||||||
} from './catalog'
|
} from './catalog'
|
||||||
|
|
||||||
import { skuToItemId as _skuToItemId } 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 { EmptyState } from '@/components/ui/empty-state'
|
||||||
import { statusLabel } from '@/components/ui/status-badge'
|
import { statusLabel } from '@/components/ui/status-badge'
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
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 {
|
import {
|
||||||
auditLog,
|
auditLog,
|
||||||
branches,
|
branches,
|
||||||
|
|
@ -102,16 +103,7 @@ const auditModule: Record<AuditCategory, ModuleKey> = {
|
||||||
security: 'audit',
|
security: 'audit',
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK_LINKS: { module: ModuleKey; to: string; label: string }[] = [
|
const PRIMARY_KPI_COUNT = 4
|
||||||
{ 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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type Kpi = {
|
type Kpi = {
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -580,7 +572,8 @@ export function DashboardPage() {
|
||||||
}))
|
}))
|
||||||
}, [role])
|
}, [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 showSales = can('orders') || can('analytics') || can('finance')
|
||||||
const showProducts = can('stock') && popularProducts.length > 0
|
const showProducts = can('stock') && popularProducts.length > 0
|
||||||
|
|
@ -591,27 +584,10 @@ export function DashboardPage() {
|
||||||
const showRxFunnel = can('prescriptions')
|
const showRxFunnel = can('prescriptions')
|
||||||
const branchLinksEnabled = can('settings')
|
const branchLinksEnabled = can('settings')
|
||||||
|
|
||||||
return (
|
function renderKpiGrid(items: Kpi[]) {
|
||||||
<PageShell title={t('dashboard.title')} subtitle={t('dashboard.subtitle')}>
|
return (
|
||||||
<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">
|
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||||
{kpis.map((kpi) => (
|
{items.map((kpi) => (
|
||||||
<StatCard
|
<StatCard
|
||||||
key={kpi.label}
|
key={kpi.label}
|
||||||
label={kpi.label}
|
label={kpi.label}
|
||||||
|
|
@ -622,6 +598,31 @@ export function DashboardPage() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 ? (
|
{showSales ? (
|
||||||
<ChartCard
|
<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 { useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AlertTriangle, Plus, Trash2 } from 'lucide-react'
|
import { AlertTriangle, Check, ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
CreatePageLayout,
|
CreatePageLayout,
|
||||||
createFieldFull,
|
createFieldFull,
|
||||||
|
|
@ -14,7 +14,6 @@ import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { SelectNative } from '@/components/ui/select-native'
|
import { SelectNative } from '@/components/ui/select-native'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
import { useAuthStore } from '@/zustand/authStore'
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,14 +31,54 @@ import {
|
||||||
scoreMatchKey,
|
scoreMatchKey,
|
||||||
slugifyItem,
|
slugifyItem,
|
||||||
therapeuticClasses,
|
therapeuticClasses,
|
||||||
|
buildDefaultUoms,
|
||||||
type ActiveIngredient,
|
type ActiveIngredient,
|
||||||
type CatalogBarcode,
|
type CatalogBarcode,
|
||||||
type ControlledSchedule,
|
type ControlledSchedule,
|
||||||
|
type ItemUom,
|
||||||
type MedicationItem,
|
type MedicationItem,
|
||||||
type MerchandisingBadge,
|
type MerchandisingBadge,
|
||||||
type ProductType,
|
type ProductType,
|
||||||
} from '@/mocks/data'
|
} 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 = {
|
type FormState = {
|
||||||
genericName: string
|
genericName: string
|
||||||
brandName: string
|
brandName: string
|
||||||
|
|
@ -66,8 +105,18 @@ type FormState = {
|
||||||
shortDescription: string
|
shortDescription: string
|
||||||
dosageGuidance: string
|
dosageGuidance: string
|
||||||
sideEffects: string
|
sideEffects: string
|
||||||
|
sideEffectsMild: string
|
||||||
|
sideEffectsSevere: string
|
||||||
warnings: string
|
warnings: string
|
||||||
storageInstructions: string
|
storageInstructions: string
|
||||||
|
pharmacistTip: string
|
||||||
|
useCase: string
|
||||||
|
storefrontUnit: string
|
||||||
|
compareAtPriceEtb: string
|
||||||
|
direction1Title: string
|
||||||
|
direction1Body: string
|
||||||
|
direction2Title: string
|
||||||
|
direction2Body: string
|
||||||
defaultReorderPoint: string
|
defaultReorderPoint: string
|
||||||
defaultExpiryAlertDays: string
|
defaultExpiryAlertDays: string
|
||||||
badges: MerchandisingBadge[]
|
badges: MerchandisingBadge[]
|
||||||
|
|
@ -82,9 +131,18 @@ const emptyBarcode = (): CatalogBarcode => ({ value: '', label: '' })
|
||||||
export function CreateItemPage() {
|
export function CreateItemPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
const [sku] = useState(() => nextItemSku())
|
const [sku] = useState(() => nextItemSku())
|
||||||
const [ingredients, setIngredients] = useState<ActiveIngredient[]>([emptyIngredient()])
|
const [ingredients, setIngredients] = useState<ActiveIngredient[]>([emptyIngredient()])
|
||||||
const [barcodes, setBarcodes] = useState<CatalogBarcode[]>([emptyBarcode()])
|
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>({
|
const [form, setForm] = useState<FormState>({
|
||||||
genericName: '',
|
genericName: '',
|
||||||
brandName: '',
|
brandName: '',
|
||||||
|
|
@ -111,8 +169,18 @@ export function CreateItemPage() {
|
||||||
shortDescription: '',
|
shortDescription: '',
|
||||||
dosageGuidance: '',
|
dosageGuidance: '',
|
||||||
sideEffects: '',
|
sideEffects: '',
|
||||||
|
sideEffectsMild: '',
|
||||||
|
sideEffectsSevere: '',
|
||||||
warnings: '',
|
warnings: '',
|
||||||
storageInstructions: 'Store below 25°C in a dry place.',
|
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',
|
defaultReorderPoint: '20',
|
||||||
defaultExpiryAlertDays: '90',
|
defaultExpiryAlertDays: '90',
|
||||||
badges: [],
|
badges: [],
|
||||||
|
|
@ -124,6 +192,8 @@ export function CreateItemPage() {
|
||||||
const isRx = form.productType === 'Rx' || form.productType === 'Controlled'
|
const isRx = form.productType === 'Rx' || form.productType === 'Controlled'
|
||||||
const isControlled = form.productType === 'Controlled'
|
const isControlled = form.productType === 'Controlled'
|
||||||
const promoLocked = isRx
|
const promoLocked = isRx
|
||||||
|
const isLastStep = step === STEPS.length - 1
|
||||||
|
const currentStep = STEPS[step]
|
||||||
|
|
||||||
const matchKey = useMemo(
|
const matchKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -167,24 +237,93 @@ export function CreateItemPage() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validateStep = (index: number) => {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
if (!form.genericName.trim()) {
|
||||||
|
toast.error('Generic name / INN is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case 1: {
|
||||||
|
const cleanIngredients = ingredients.filter((i) => i.name.trim())
|
||||||
|
if (cleanIngredients.length === 0) {
|
||||||
|
toast.error('Add at least one active ingredient')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (isControlled && !form.controlledSchedule) {
|
||||||
|
toast.error('Controlled schedule is required for controlled substances')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
if (!form.baseUnit.trim()) {
|
||||||
|
toast.error('Stock / base unit is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (uoms.every((u) => !u.uom.trim())) {
|
||||||
|
toast.error('Add at least one unit of measure')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!uoms.some((u) => u.isStockUom)) {
|
||||||
|
toast.error('Mark one UOM as the stock unit')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
if (!form.sellingPriceEtb || Number(form.sellingPriceEtb) < 0) {
|
||||||
|
toast.error('Selling price is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (!validateStep(step)) return
|
||||||
|
setStep((s) => Math.min(s + 1, STEPS.length - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => setStep((s) => Math.max(s - 1, 0))
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
const onSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.genericName.trim()) {
|
if (!isLastStep) {
|
||||||
toast.error('Generic name / INN is required')
|
goNext()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isControlled && !form.controlledSchedule) {
|
for (let i = 0; i < STEPS.length; i += 1) {
|
||||||
toast.error('Controlled schedule is required for controlled substances')
|
if (!validateStep(i)) {
|
||||||
return
|
setStep(i)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const cleanIngredients = ingredients.filter((i) => i.name.trim())
|
const cleanIngredients = ingredients.filter((i) => i.name.trim())
|
||||||
if (cleanIngredients.length === 0) {
|
|
||||||
toast.error('Add at least one active ingredient')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const cleanBarcodes = barcodes.filter((b) => b.value.trim())
|
const cleanBarcodes = barcodes.filter((b) => b.value.trim())
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const actor = user?.name ?? 'Admin'
|
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 = {
|
const item: MedicationItem = {
|
||||||
id: `item-${sku.toLowerCase()}`,
|
id: `item-${sku.toLowerCase()}`,
|
||||||
sku,
|
sku,
|
||||||
|
|
@ -201,11 +340,25 @@ export function CreateItemPage() {
|
||||||
routeOfAdministration: form.routeOfAdministration || undefined,
|
routeOfAdministration: form.routeOfAdministration || undefined,
|
||||||
controlledSchedule: form.controlledSchedule || '',
|
controlledSchedule: form.controlledSchedule || '',
|
||||||
packSize: form.packSize,
|
packSize: form.packSize,
|
||||||
baseUnit: form.baseUnit,
|
baseUnit: stockUnit,
|
||||||
sellByUnit: form.sellByUnit,
|
sellByUnit: form.sellByUnit,
|
||||||
packUnit: form.packUnit,
|
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,
|
costPriceEtb: Number(form.costPriceEtb) || 0,
|
||||||
sellingPriceEtb: Number(form.sellingPriceEtb) || 0,
|
sellingPriceEtb: Number(form.sellingPriceEtb) || 0,
|
||||||
|
compareAtPriceEtb: form.compareAtPriceEtb ? Number(form.compareAtPriceEtb) : undefined,
|
||||||
currency: 'ETB',
|
currency: 'ETB',
|
||||||
sellingPriceOwner: 'Platform',
|
sellingPriceOwner: 'Platform',
|
||||||
vatApplicable: form.vatApplicable,
|
vatApplicable: form.vatApplicable,
|
||||||
|
|
@ -217,10 +370,24 @@ export function CreateItemPage() {
|
||||||
advertisingRestricted: promoLocked ? true : form.advertisingRestricted,
|
advertisingRestricted: promoLocked ? true : form.advertisingRestricted,
|
||||||
ageRestriction: form.ageRestriction,
|
ageRestriction: form.ageRestriction,
|
||||||
shortDescription: form.shortDescription,
|
shortDescription: form.shortDescription,
|
||||||
dosageGuidance: form.dosageGuidance,
|
dosageGuidance: form.dosageGuidance || form.direction1Body,
|
||||||
sideEffects: form.sideEffects,
|
sideEffects: form.sideEffects || mild.join(', '),
|
||||||
|
sideEffectsMild: mild,
|
||||||
|
sideEffectsSevere: severe,
|
||||||
warnings: form.warnings,
|
warnings: form.warnings,
|
||||||
storageInstructions: form.storageInstructions,
|
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: [],
|
alternativeItemIds: [],
|
||||||
defaultReorderPoint: Number(form.defaultReorderPoint) || 20,
|
defaultReorderPoint: Number(form.defaultReorderPoint) || 20,
|
||||||
defaultExpiryAlertDays: Number(form.defaultExpiryAlertDays) || 90,
|
defaultExpiryAlertDays: Number(form.defaultExpiryAlertDays) || 90,
|
||||||
|
|
@ -260,7 +427,7 @@ export function CreateItemPage() {
|
||||||
return (
|
return (
|
||||||
<CreatePageLayout
|
<CreatePageLayout
|
||||||
title="Create medication item"
|
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"
|
backTo="/items"
|
||||||
backLabel="Back to items"
|
backLabel="Back to items"
|
||||||
footer={
|
footer={
|
||||||
|
|
@ -268,14 +435,83 @@ export function CreateItemPage() {
|
||||||
<Button type="button" variant="outline" onClick={() => navigate('/items')}>
|
<Button type="button" variant="outline" onClick={() => navigate('/items')}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="create-item-form">
|
{step > 0 ? (
|
||||||
Save item
|
<Button type="button" variant="outline" className="gap-1.5" onClick={goBack}>
|
||||||
</Button>
|
<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}>
|
<form id="create-item-form" className="space-y-6" onSubmit={onSubmit}>
|
||||||
{nearDuplicates.length > 0 ? (
|
<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">
|
<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" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-orange-deep" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -292,8 +528,9 @@ export function CreateItemPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{step === 0 ? (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionTitle title="1. Core identification" />
|
<SectionTitle title="Core identification" />
|
||||||
<div className={createFormGrid}>
|
<div className={createFormGrid}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Internal SKU</Label>
|
<Label>Internal SKU</Label>
|
||||||
|
|
@ -369,11 +606,11 @@ export function CreateItemPage() {
|
||||||
))}
|
))}
|
||||||
</Repeater>
|
</Repeater>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Separator />
|
{step === 1 ? (
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionTitle title="2. Classification" />
|
<SectionTitle title="Classification" />
|
||||||
<div className={createFormGrid}>
|
<div className={createFormGrid}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Product type</Label>
|
<Label>Product type</Label>
|
||||||
|
|
@ -485,11 +722,11 @@ export function CreateItemPage() {
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Separator />
|
{step === 2 ? (
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionTitle title="3. Packaging & units" />
|
<SectionTitle title="Packaging & units" />
|
||||||
<div className={createFormGrid}>
|
<div className={createFormGrid}>
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
<Label>Pack size</Label>
|
<Label>Pack size</Label>
|
||||||
|
|
@ -500,8 +737,18 @@ export function CreateItemPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Base unit</Label>
|
<Label>Stock / base unit</Label>
|
||||||
<Input value={form.baseUnit} onChange={(e) => setField('baseUnit', e.target.value)} />
|
<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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Sell-by unit</Label>
|
<Label>Sell-by unit</Label>
|
||||||
|
|
@ -512,12 +759,153 @@ export function CreateItemPage() {
|
||||||
<Input value={form.packUnit} onChange={(e) => setField('packUnit', e.target.value)} />
|
<Input value={form.packUnit} onChange={(e) => setField('packUnit', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Separator />
|
{step === 3 ? (
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionTitle title="4. Pricing" />
|
<SectionTitle title="Pricing" />
|
||||||
<div className={createFormGrid}>
|
<div className={createFormGrid}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Cost price (ETB)</Label>
|
<Label>Cost price (ETB)</Label>
|
||||||
|
|
@ -538,6 +926,17 @@ export function CreateItemPage() {
|
||||||
onChange={(e) => setField('sellingPriceEtb', e.target.value)}
|
onChange={(e) => setField('sellingPriceEtb', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<Label>B2B price list</Label>
|
<Label>B2B price list</Label>
|
||||||
<SelectNative
|
<SelectNative
|
||||||
|
|
@ -588,11 +987,11 @@ export function CreateItemPage() {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Separator />
|
{step === 4 ? (
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionTitle title="5. Regulatory & restrictions" />
|
<SectionTitle title="Regulatory & restrictions" />
|
||||||
<div className={createFormGrid}>
|
<div className={createFormGrid}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>EFDA status</Label>
|
<Label>EFDA status</Label>
|
||||||
|
|
@ -642,11 +1041,11 @@ export function CreateItemPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Separator />
|
{step === 5 ? (
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionTitle title="6. Content & guidance" />
|
<SectionTitle title="Content & guidance" />
|
||||||
<div className={createFormGrid}>
|
<div className={createFormGrid}>
|
||||||
<div className={cn('space-y-1.5', createFieldFull)}>
|
<div className={cn('space-y-1.5', createFieldFull)}>
|
||||||
<Label>Short description</Label>
|
<Label>Short description</Label>
|
||||||
|
|
@ -654,6 +1053,24 @@ export function CreateItemPage() {
|
||||||
rows={2}
|
rows={2}
|
||||||
value={form.shortDescription}
|
value={form.shortDescription}
|
||||||
onChange={(e) => setField('shortDescription', e.target.value)}
|
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>
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
|
|
@ -665,11 +1082,30 @@ export function CreateItemPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
<Label>Side effects</Label>
|
<Label>Side effects (legacy free text)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
value={form.sideEffects}
|
value={form.sideEffects}
|
||||||
onChange={(e) => setField('sideEffects', e.target.value)}
|
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>
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
|
|
@ -684,13 +1120,59 @@ export function CreateItemPage() {
|
||||||
onChange={(e) => setField('storageInstructions', e.target.value)}
|
onChange={(e) => setField('storageInstructions', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Separator />
|
{step === 6 ? (
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionTitle title="7. Inventory defaults & merchandising" />
|
<SectionTitle title="Inventory defaults & merchandising" />
|
||||||
<div className={createFormGrid}>
|
<div className={createFormGrid}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Default reorder point</Label>
|
<Label>Default reorder point</Label>
|
||||||
|
|
@ -742,7 +1224,23 @@ export function CreateItemPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</CreatePageLayout>
|
</CreatePageLayout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
itemChangeHistory,
|
itemChangeHistory,
|
||||||
itemVariants,
|
itemVariants,
|
||||||
priceLists,
|
priceLists,
|
||||||
|
resolveItemUoms,
|
||||||
stockRows,
|
stockRows,
|
||||||
|
storefrontPdpPreview,
|
||||||
} from '@/mocks/data'
|
} from '@/mocks/data'
|
||||||
import { formatDateTime, formatEtb } from '@/lib/utils'
|
import { formatDateTime, formatEtb } from '@/lib/utils'
|
||||||
|
|
||||||
|
|
@ -35,6 +37,7 @@ export function ItemDetailPage() {
|
||||||
const history = itemChangeHistory.filter((event) => event.itemId === item.id)
|
const history = itemChangeHistory.filter((event) => event.itemId === item.id)
|
||||||
const priceList = priceLists.find((p) => p.id === item.b2bPriceListId)
|
const priceList = priceLists.find((p) => p.id === item.b2bPriceListId)
|
||||||
const onHand = stock.reduce((sum, row) => sum + row.qty, 0)
|
const onHand = stock.reduce((sum, row) => sum + row.qty, 0)
|
||||||
|
const pdp = storefrontPdpPreview(item)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPageLayout
|
<DetailPageLayout
|
||||||
|
|
@ -146,7 +149,7 @@ export function ItemDetailPage() {
|
||||||
columns={3}
|
columns={3}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Pack size', value: item.packSize },
|
{ 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: 'Sell-by / pack', value: `${item.sellByUnit} / ${item.packUnit}` },
|
||||||
{ label: 'Cost', value: formatEtb(item.costPriceEtb) },
|
{ label: 'Cost', value: formatEtb(item.costPriceEtb) },
|
||||||
{ label: 'Selling price', value: formatEtb(item.sellingPriceEtb) },
|
{ label: 'Selling price', value: formatEtb(item.sellingPriceEtb) },
|
||||||
|
|
@ -161,6 +164,43 @@ export function ItemDetailPage() {
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</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">
|
<DetailSection title="Regulatory">
|
||||||
<KeyValueGrid
|
<KeyValueGrid
|
||||||
columns={3}
|
columns={3}
|
||||||
|
|
@ -196,9 +236,161 @@ export function ItemDetailPage() {
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</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
|
<DetailSection
|
||||||
title="Stock on hand"
|
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 ? (
|
{stock.length === 0 ? (
|
||||||
<div className="flex flex-col items-start gap-3 rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
|
<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 { useMemo, useState, type FormEvent } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
CreatePageLayout,
|
CreatePageLayout,
|
||||||
|
|
@ -14,48 +14,82 @@ import { SelectNative } from '@/components/ui/select-native'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
import { useAuthStore } from '@/zustand/authStore'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { branches, orders } from '@/mocks/data'
|
import { branches, customers, doctors, orders } from '@/mocks/data'
|
||||||
import type { Order } from '@/types'
|
import type { Order, OrderCustomerType } from '@/types'
|
||||||
|
|
||||||
export function CreateOrderPage() {
|
export function CreateOrderPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [params] = useSearchParams()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const doctorParam = params.get('doctorId') ?? ''
|
||||||
|
const activeDoctors = useMemo(() => doctors.filter((d) => d.status === 'active'), [])
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
|
customerType: 'guest' as OrderCustomerType,
|
||||||
|
customerId: '',
|
||||||
customerName: '',
|
customerName: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
|
notes: '',
|
||||||
branchId: user?.branchId ?? branches[0]?.id ?? '',
|
branchId: user?.branchId ?? branches[0]?.id ?? '',
|
||||||
fulfillment: 'pickup' as 'pickup' | 'delivery',
|
fulfillment: 'pickup' as 'pickup' | 'delivery',
|
||||||
channel: 'web',
|
channel: doctorParam ? 'doctor' : 'web',
|
||||||
totalEtb: '',
|
totalEtb: '',
|
||||||
notes: '',
|
|
||||||
address: '',
|
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) => {
|
const onSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
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 = {
|
const order: Order = {
|
||||||
id: `ord-${Date.now().toString().slice(-6)}`,
|
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,
|
branchId: form.branchId,
|
||||||
fulfillment: form.fulfillment,
|
fulfillment: form.fulfillment,
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
totalEtb: Number(form.totalEtb) || 0,
|
totalEtb: Number(form.totalEtb) || 0,
|
||||||
channel: form.channel,
|
channel: form.doctorId ? 'doctor' : form.channel,
|
||||||
address: form.fulfillment === 'delivery' ? form.address : undefined,
|
address: form.fulfillment === 'delivery' ? form.address : undefined,
|
||||||
zone: branches.find((b) => b.id === form.branchId)?.zone,
|
zone: branches.find((b) => b.id === form.branchId)?.zone,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
paid: false,
|
paid: false,
|
||||||
rxApproved: false,
|
rxApproved: Boolean(form.doctorId),
|
||||||
|
doctorId: form.doctorId || undefined,
|
||||||
|
orgId: selectedDoctor?.orgId,
|
||||||
}
|
}
|
||||||
orders.unshift(order)
|
orders.unshift(order)
|
||||||
toast.success('Staff order created')
|
toast.success(
|
||||||
navigate('/orders')
|
isGuest
|
||||||
|
? 'Guest order created — no customer account required'
|
||||||
|
: 'Staff order created for registered customer',
|
||||||
|
)
|
||||||
|
navigate(`/orders/${order.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreatePageLayout
|
<CreatePageLayout
|
||||||
title="Create order"
|
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"
|
backTo="/orders"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
|
@ -68,90 +102,208 @@ export function CreateOrderPage() {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form id="create-order-form" className={createFormGrid} onSubmit={onSubmit}>
|
<form id="create-order-form" className="space-y-6" onSubmit={onSubmit}>
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
<section className="space-y-3">
|
||||||
<Label>Customer name</Label>
|
<div>
|
||||||
<Input
|
<h2 className="font-display text-sm font-semibold text-forest">Customer</h2>
|
||||||
required
|
<p className="text-xs text-muted-foreground">
|
||||||
value={form.customerName}
|
End customer does not need to be logged in. Use Guest for walk-in, phone, POS, or doctor-assisted patients.
|
||||||
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
|
</p>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Phone</Label>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
placeholder="+2519…"
|
|
||||||
value={form.phone}
|
|
||||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Total (ETB)</Label>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={form.totalEtb}
|
|
||||||
onChange={(e) => setForm({ ...form, totalEtb: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Branch</Label>
|
|
||||||
<SelectNative
|
|
||||||
value={form.branchId}
|
|
||||||
onChange={(e) => setForm({ ...form, branchId: e.target.value })}
|
|
||||||
disabled={user?.role === 'pharmacist'}
|
|
||||||
>
|
|
||||||
{branches.map((b) => (
|
|
||||||
<option key={b.id} value={b.id}>
|
|
||||||
{b.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</SelectNative>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Fulfillment</Label>
|
|
||||||
<SelectNative
|
|
||||||
value={form.fulfillment}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({ ...form, fulfillment: e.target.value as 'pickup' | 'delivery' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="pickup">Pickup</option>
|
|
||||||
<option value="delivery">Delivery</option>
|
|
||||||
</SelectNative>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Channel</Label>
|
|
||||||
<SelectNative
|
|
||||||
value={form.channel}
|
|
||||||
onChange={(e) => setForm({ ...form, channel: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="web">Web / call</option>
|
|
||||||
<option value="mobile">Mobile</option>
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
<option value="b2b">B2B</option>
|
|
||||||
</SelectNative>
|
|
||||||
</div>
|
|
||||||
{form.fulfillment === 'delivery' ? (
|
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
|
||||||
<Label>Delivery address</Label>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
value={form.address}
|
|
||||||
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className={cn('space-y-1.5', createFieldFull)}>
|
<Button
|
||||||
<Label>Notes</Label>
|
type="button"
|
||||||
<Textarea
|
size="sm"
|
||||||
value={form.notes}
|
variant={form.customerType === 'guest' ? 'default' : 'outline'}
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
className="h-9"
|
||||||
placeholder="Optional staff notes"
|
onClick={() => setForm((prev) => ({ ...prev, customerType: 'guest', customerId: '' }))}
|
||||||
/>
|
>
|
||||||
</div>
|
Guest customer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={form.customerType === 'registered' ? 'default' : 'outline'}
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setForm((prev) => ({ ...prev, customerType: 'registered' }))}
|
||||||
|
>
|
||||||
|
Registered customer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={createFormGrid}>
|
||||||
|
{form.customerType === 'registered' ? (
|
||||||
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
|
<Label>Registered customer</Label>
|
||||||
|
<SelectNative
|
||||||
|
required
|
||||||
|
value={form.customerId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const c = customers.find((x) => x.id === e.target.value)
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customerId: e.target.value,
|
||||||
|
customerName: c?.name ?? '',
|
||||||
|
phone: c?.phone ?? '',
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select customer…</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} · {c.phone}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectNative>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Prefer guest?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-brand-700 underline"
|
||||||
|
onClick={() => setForm((prev) => ({ ...prev, customerType: 'guest', customerId: '' }))}
|
||||||
|
>
|
||||||
|
Switch to Guest customer
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
|
<Label>Guest name</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={form.customerName}
|
||||||
|
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
|
||||||
|
placeholder="Walk-in name or caller"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Contact phone</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
placeholder="+2519…"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.customerType === 'registered' ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Phone (from profile)</Label>
|
||||||
|
<Input value={form.phone} readOnly className="bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={cn('space-y-1.5', createFieldFull)}>
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Allergy, pickup person, call-back, patient without account…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-sm font-semibold text-forest">Order details</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">Fulfilment and attribution</p>
|
||||||
|
</div>
|
||||||
|
<div className={createFormGrid}>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Total (ETB)</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.totalEtb}
|
||||||
|
onChange={(e) => setForm({ ...form, totalEtb: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Branch</Label>
|
||||||
|
<SelectNative
|
||||||
|
value={form.branchId}
|
||||||
|
onChange={(e) => setForm({ ...form, branchId: e.target.value })}
|
||||||
|
disabled={user?.role === 'pharmacist'}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectNative>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Fulfillment</Label>
|
||||||
|
<SelectNative
|
||||||
|
value={form.fulfillment}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, fulfillment: e.target.value as 'pickup' | 'delivery' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="pickup">Pickup</option>
|
||||||
|
<option value="delivery">Delivery</option>
|
||||||
|
</SelectNative>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Channel</Label>
|
||||||
|
<SelectNative
|
||||||
|
value={form.channel}
|
||||||
|
onChange={(e) => setForm({ ...form, channel: e.target.value })}
|
||||||
|
disabled={Boolean(form.doctorId)}
|
||||||
|
>
|
||||||
|
<option value="web">Web / call</option>
|
||||||
|
<option value="mobile">Mobile</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="b2b">B2B</option>
|
||||||
|
<option value="doctor">Doctor</option>
|
||||||
|
<option value="pos">POS / counter</option>
|
||||||
|
</SelectNative>
|
||||||
|
</div>
|
||||||
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
|
<Label>Attribute to doctor (optional)</Label>
|
||||||
|
<SelectNative
|
||||||
|
value={form.doctorId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const doc = activeDoctors.find((d) => d.id === e.target.value)
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
doctorId: e.target.value,
|
||||||
|
channel: e.target.value ? 'doctor' : prev.channel === 'doctor' ? 'web' : prev.channel,
|
||||||
|
branchId: doc?.branchId || prev.branchId,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">None — staff order</option>
|
||||||
|
{activeDoctors.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name} · {d.orgName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectNative>
|
||||||
|
{selectedDoctor ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Hospital:{' '}
|
||||||
|
<Link to={`/organisations/${selectedDoctor.orgId}`} className="text-brand-700 underline">
|
||||||
|
{selectedDoctor.orgName}
|
||||||
|
</Link>
|
||||||
|
. Doctor orders may target guest patients without platform accounts.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{form.fulfillment === 'delivery' ? (
|
||||||
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
|
<Label>Delivery address</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</form>
|
</form>
|
||||||
</CreatePageLayout>
|
</CreatePageLayout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
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 { formatEtb } from '@/lib/utils'
|
||||||
import type { Order } from '@/types'
|
import type { Order } from '@/types'
|
||||||
|
|
||||||
|
|
@ -31,6 +31,8 @@ export function OrderDetailPage() {
|
||||||
const timeline = orderTimeline[order.id] ?? []
|
const timeline = orderTimeline[order.id] ?? []
|
||||||
const branch = branches.find((b) => b.id === order.branchId)
|
const branch = branches.find((b) => b.id === order.branchId)
|
||||||
const rider = riders.find((r) => r.id === order.assignedRiderId)
|
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) => {
|
const patch = (next: Partial<Order>, message: string) => {
|
||||||
setOrder((prev) => (prev ? { ...prev, ...next } : prev))
|
setOrder((prev) => (prev ? { ...prev, ...next } : prev))
|
||||||
|
|
@ -51,6 +53,9 @@ export function OrderDetailPage() {
|
||||||
{order.status}
|
{order.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline">{order.fulfillment}</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>}
|
{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: 'Channel', value: order.channel },
|
||||||
{ label: 'Total', value: formatEtb(order.totalEtb) },
|
{ label: 'Total', value: formatEtb(order.totalEtb) },
|
||||||
{ label: 'Rx approved', value: order.rxApproved ? 'Yes' : 'N/A' },
|
{ 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: 'Created', value: new Date(order.createdAt).toLocaleString() },
|
||||||
{ label: 'Branch phone', value: branch?.phone ?? '—' },
|
{ label: 'Branch phone', value: branch?.phone ?? '—' },
|
||||||
{ label: 'Payment', value: order.paid ? 'Captured' : 'Pending' },
|
{ label: 'Payment', value: order.paid ? 'Captured' : 'Pending' },
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,16 @@ export function OrdersPage() {
|
||||||
{o.id}
|
{o.id}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</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>{branches.find((b) => b.id === o.branchId)?.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{o.fulfillment}</Badge>
|
<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 { useNavigate } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { organisations } from '@/mocks/data'
|
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() {
|
export function CreateOrganisationPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
|
orgType: 'corporate' as OrganisationType,
|
||||||
tin: '',
|
tin: '',
|
||||||
|
vatNumber: '',
|
||||||
|
businessLicense: '',
|
||||||
|
commercialRegistration: '',
|
||||||
creditLimitEtb: '100000',
|
creditLimitEtb: '100000',
|
||||||
billingContact: '',
|
billingContact: '',
|
||||||
paymentTerms: 'Net 30',
|
paymentTerms: 'Net 30',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const set =
|
||||||
|
(key: keyof typeof form) =>
|
||||||
|
(e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: e.target.value }))
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
const onSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const org: Organisation = {
|
const org: Organisation = {
|
||||||
id: `org-${Date.now()}`,
|
id: `org-${Date.now()}`,
|
||||||
name: form.name,
|
name: form.name.trim(),
|
||||||
tin: form.tin,
|
orgType: form.orgType,
|
||||||
|
tin: form.tin.trim(),
|
||||||
|
vatNumber: form.vatNumber.trim() || undefined,
|
||||||
|
businessLicense: form.businessLicense.trim() || undefined,
|
||||||
|
commercialRegistration: form.commercialRegistration.trim() || undefined,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
creditLimitEtb: Number(form.creditLimitEtb) || 0,
|
creditLimitEtb: Number(form.creditLimitEtb) || 0,
|
||||||
usedEtb: 0,
|
usedEtb: 0,
|
||||||
billingContact: form.billingContact,
|
billingContact: form.billingContact.trim(),
|
||||||
source: 'admin',
|
source: 'admin',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +54,7 @@ export function CreateOrganisationPage() {
|
||||||
return (
|
return (
|
||||||
<CreatePageLayout
|
<CreatePageLayout
|
||||||
title="Create organisation"
|
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"
|
backTo="/organisations"
|
||||||
footer={
|
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}>
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
<section className="space-y-3">
|
||||||
<Label>Organisation name</Label>
|
<div>
|
||||||
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<h2 className="font-display text-sm font-semibold text-forest">Account</h2>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">Who the credit line belongs to</p>
|
||||||
<div className="space-y-1.5">
|
</div>
|
||||||
<Label>TIN</Label>
|
<div className={createFormGrid}>
|
||||||
<Input required value={form.tin} onChange={(e) => setForm({ ...form, tin: e.target.value })} />
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
</div>
|
<Label htmlFor="org-name">Organisation name</Label>
|
||||||
<div className="space-y-1.5">
|
<Input id="org-name" required value={form.name} onChange={set('name')} placeholder="e.g. Horizon Bank S.C." />
|
||||||
<Label>Credit limit (ETB)</Label>
|
</div>
|
||||||
<Input
|
<div className="space-y-1.5">
|
||||||
required
|
<Label htmlFor="org-type">Organisation type</Label>
|
||||||
type="number"
|
<SelectNative id="org-type" required value={form.orgType} onChange={set('orgType')}>
|
||||||
min={0}
|
<option value="corporate">Corporate</option>
|
||||||
value={form.creditLimitEtb}
|
<option value="hospital">Hospital</option>
|
||||||
onChange={(e) => setForm({ ...form, creditLimitEtb: e.target.value })}
|
<option value="clinic">Clinic</option>
|
||||||
/>
|
<option value="ngo">NGO</option>
|
||||||
</div>
|
<option value="other">Other</option>
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
</SelectNative>
|
||||||
<Label>Billing contact</Label>
|
{form.orgType === 'hospital' || form.orgType === 'clinic' ? (
|
||||||
<Input
|
<p className="text-xs text-muted-foreground">Hospital/clinic orgs can invite doctors to create Rx and orders.</p>
|
||||||
required
|
) : null}
|
||||||
type="email"
|
</div>
|
||||||
value={form.billingContact}
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
onChange={(e) => setForm({ ...form, billingContact: e.target.value })}
|
<Label htmlFor="org-billing">Billing contact</Label>
|
||||||
/>
|
<Input
|
||||||
</div>
|
id="org-billing"
|
||||||
<div className="space-y-1.5">
|
required
|
||||||
<Label>Payment terms</Label>
|
type="email"
|
||||||
<Input
|
value={form.billingContact}
|
||||||
value={form.paymentTerms}
|
onChange={set('billingContact')}
|
||||||
onChange={(e) => setForm({ ...form, paymentTerms: e.target.value })}
|
placeholder="finance@company.et"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="org-credit">Credit limit (ETB)</Label>
|
||||||
|
<Input
|
||||||
|
id="org-credit"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.creditLimitEtb}
|
||||||
|
onChange={set('creditLimitEtb')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="org-terms">Payment terms</Label>
|
||||||
|
<Input id="org-terms" value={form.paymentTerms} onChange={set('paymentTerms')} placeholder="Net 30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-sm font-semibold text-forest">Legal & tax identifiers</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Business license, commercial registration, VAT and TIN used for KYC and invoicing. Leave blank if not
|
||||||
|
on file yet — detail view shows a clear placeholder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={createFormGrid}>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="org-tin">TIN</Label>
|
||||||
|
<Input
|
||||||
|
id="org-tin"
|
||||||
|
required
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]{10}"
|
||||||
|
maxLength={10}
|
||||||
|
value={form.tin}
|
||||||
|
onChange={set('tin')}
|
||||||
|
placeholder="0001234567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="org-vat">VAT registration</Label>
|
||||||
|
<Input
|
||||||
|
id="org-vat"
|
||||||
|
value={form.vatNumber}
|
||||||
|
onChange={set('vatNumber')}
|
||||||
|
placeholder="VAT-ET-0001234567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="org-bl">Business license</Label>
|
||||||
|
<Input
|
||||||
|
id="org-bl"
|
||||||
|
value={form.businessLicense}
|
||||||
|
onChange={set('businessLicense')}
|
||||||
|
placeholder="BL-AA/48291/2014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="org-cr">Commercial registration</Label>
|
||||||
|
<Input
|
||||||
|
id="org-cr"
|
||||||
|
value={form.commercialRegistration}
|
||||||
|
onChange={set('commercialRegistration')}
|
||||||
|
placeholder="CR/015842/2014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className={cn('text-xs text-muted-foreground', createFieldFull)}>
|
||||||
|
Placeholders follow common Ethiopian trade formats — replace with the exact numbers from the documents
|
||||||
|
before activating a large credit limit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</form>
|
</form>
|
||||||
</CreatePageLayout>
|
</CreatePageLayout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
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 { DetailPageLayout } from '@/layouts/DetailPageLayout'
|
||||||
import { DetailSection } from '@/components/detail/ActivityTimeline'
|
import { DetailSection } from '@/components/detail/ActivityTimeline'
|
||||||
import { ApprovalStepper, type ApprovalStep } from '@/components/detail/ApprovalStepper'
|
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 { PersonAvatar } from '@/components/ui/avatar'
|
||||||
import { RowActions, type RowAction } from '@/components/table/RowActions'
|
import { RowActions, type RowAction } from '@/components/table/RowActions'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
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 { orgSpendSeries } from '@/mocks/charts'
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
import { useAuthStore } from '@/zustand/authStore'
|
||||||
import { formatDate, formatDateTime, formatEtb } from '@/lib/utils'
|
import { formatDate, formatDateTime, formatEtb } from '@/lib/utils'
|
||||||
|
|
@ -42,6 +42,8 @@ export function OrganisationDetailPage() {
|
||||||
const invoices = orgInvoices[org.id] ?? []
|
const invoices = orgInvoices[org.id] ?? []
|
||||||
const util = org.creditLimitEtb ? Math.round((org.usedEtb / org.creditLimitEtb) * 100) : 0
|
const util = org.creditLimitEtb ? Math.round((org.usedEtb / org.creditLimitEtb) * 100) : 0
|
||||||
const isPending = org.status === 'pending'
|
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 setStatus = (status: Organisation['status'], message: string) => {
|
||||||
const patch = {
|
const patch = {
|
||||||
|
|
@ -78,8 +80,29 @@ export function OrganisationDetailPage() {
|
||||||
toast.message(`TIN ${org.tin} copied to clipboard`)
|
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[]>(
|
const steps = useMemo<ApprovalStep[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
|
@ -95,7 +118,9 @@ export function OrganisationDetailPage() {
|
||||||
state: isPending ? 'current' : 'done',
|
state: isPending ? 'current' : 'done',
|
||||||
actor: isPending ? 'Awaiting a finance decision' : org.reviewedBy,
|
actor: isPending ? 'Awaiting a finance decision' : org.reviewedBy,
|
||||||
at: isPending ? undefined : org.reviewedAt,
|
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',
|
key: 'credit',
|
||||||
|
|
@ -117,11 +142,20 @@ export function OrganisationDetailPage() {
|
||||||
badges={
|
badges={
|
||||||
<>
|
<>
|
||||||
<StatusBadge domain="organisation" status={org.status} />
|
<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>
|
<Badge variant="outline">{org.source === 'self_register' ? 'Self-registered' : 'Added by admin'}</Badge>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
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 ? (
|
{isPending ? (
|
||||||
<Button className="h-10 gap-1.5" onClick={approve}>
|
<Button className="h-10 gap-1.5" onClick={approve}>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
|
|
@ -171,6 +205,27 @@ export function OrganisationDetailPage() {
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-2">
|
<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">
|
<DetailSection title="Credit profile">
|
||||||
<KeyValueGrid
|
<KeyValueGrid
|
||||||
columns={2}
|
columns={2}
|
||||||
|
|
@ -273,6 +328,71 @@ export function OrganisationDetailPage() {
|
||||||
</Table>
|
</Table>
|
||||||
</DetailSection>
|
</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">
|
<DetailSection title="Invoices">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export function OrganisationsPage() {
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [status, setStatus] = useState('all')
|
const [status, setStatus] = useState('all')
|
||||||
const [source, setSource] = useState('all')
|
const [source, setSource] = useState('all')
|
||||||
|
const [orgType, setOrgType] = useState('all')
|
||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
const active = orgs.filter((o) => o.status === 'active')
|
const active = orgs.filter((o) => o.status === 'active')
|
||||||
|
|
@ -34,6 +35,7 @@ export function OrganisationsPage() {
|
||||||
creditExposure: active.reduce((sum, o) => sum + o.usedEtb, 0),
|
creditExposure: active.reduce((sum, o) => sum + o.usedEtb, 0),
|
||||||
creditLimit: active.reduce((sum, o) => sum + o.creditLimitEtb, 0),
|
creditLimit: active.reduce((sum, o) => sum + o.creditLimitEtb, 0),
|
||||||
selfRegister: orgs.filter((o) => o.source === 'self_register').length,
|
selfRegister: orgs.filter((o) => o.source === 'self_register').length,
|
||||||
|
hospitals: orgs.filter((o) => o.orgType === 'hospital' || o.orgType === 'clinic').length,
|
||||||
}
|
}
|
||||||
}, [orgs])
|
}, [orgs])
|
||||||
|
|
||||||
|
|
@ -41,17 +43,21 @@ export function OrganisationsPage() {
|
||||||
let list = orgs
|
let list = orgs
|
||||||
if (status !== 'all') list = list.filter((o) => o.status === status)
|
if (status !== 'all') list = list.filter((o) => o.status === status)
|
||||||
if (source !== 'all') list = list.filter((o) => o.source === source)
|
if (source !== 'all') list = list.filter((o) => o.source === source)
|
||||||
|
if (orgType !== 'all') list = list.filter((o) => o.orgType === orgType)
|
||||||
if (q) {
|
if (q) {
|
||||||
const needle = q.toLowerCase()
|
const needle = q.toLowerCase()
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
(o) =>
|
(o) =>
|
||||||
o.name.toLowerCase().includes(needle) ||
|
o.name.toLowerCase().includes(needle) ||
|
||||||
o.tin.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),
|
o.billingContact.toLowerCase().includes(needle),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}, [orgs, q, status, source])
|
}, [orgs, q, status, source, orgType])
|
||||||
|
|
||||||
const approve = (id: string, next: 'active' | 'rejected') => {
|
const approve = (id: string, next: 'active' | 'rejected') => {
|
||||||
const patch = {
|
const patch = {
|
||||||
|
|
@ -115,6 +121,20 @@ export function OrganisationsPage() {
|
||||||
{ value: 'self_register', label: 'Self-register' },
|
{ 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 (
|
return (
|
||||||
|
|
@ -146,13 +166,14 @@ export function OrganisationsPage() {
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={q}
|
search={q}
|
||||||
onSearchChange={setQ}
|
onSearchChange={setQ}
|
||||||
searchPlaceholder="Search name, TIN, contact…"
|
searchPlaceholder="Search name, TIN, VAT, license…"
|
||||||
resultCount={filtered.length}
|
resultCount={filtered.length}
|
||||||
filters={orgFilters}
|
filters={orgFilters}
|
||||||
onClearFilters={() => {
|
onClearFilters={() => {
|
||||||
setQ('')
|
setQ('')
|
||||||
setStatus('all')
|
setStatus('all')
|
||||||
setSource('all')
|
setSource('all')
|
||||||
|
setOrgType('all')
|
||||||
}}
|
}}
|
||||||
actions={
|
actions={
|
||||||
<Button asChild className="h-10 gap-2 shadow-none">
|
<Button asChild className="h-10 gap-2 shadow-none">
|
||||||
|
|
@ -168,6 +189,7 @@ export function OrganisationsPage() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
<TableHead>TIN</TableHead>
|
<TableHead>TIN</TableHead>
|
||||||
<TableHead>Source</TableHead>
|
<TableHead>Source</TableHead>
|
||||||
<TableHead>Limit</TableHead>
|
<TableHead>Limit</TableHead>
|
||||||
|
|
@ -179,7 +201,7 @@ export function OrganisationsPage() {
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<TableRow>
|
<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')}
|
{t('common.noResults')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -193,6 +215,9 @@ export function OrganisationsPage() {
|
||||||
{o.name}
|
{o.name}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge domain="organisation" status={o.orgType} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>{o.tin}</TableCell>
|
<TableCell>{o.tin}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge domain="organisation" status={o.source} />
|
<StatusBadge domain="organisation" status={o.source} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, type FormEvent } from 'react'
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
CreatePageLayout,
|
CreatePageLayout,
|
||||||
|
|
@ -13,23 +13,33 @@ import { Label } from '@/components/ui/label'
|
||||||
import { SelectNative } from '@/components/ui/select-native'
|
import { SelectNative } from '@/components/ui/select-native'
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
import { useAuthStore } from '@/zustand/authStore'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { branches, prescriptions } from '@/mocks/data'
|
import { branches, doctors, prescriptions } from '@/mocks/data'
|
||||||
import type { Prescription } from '@/types'
|
import type { Prescription } from '@/types'
|
||||||
|
|
||||||
export function CreatePrescriptionPage() {
|
export function CreatePrescriptionPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [params] = useSearchParams()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const doctorParam = params.get('doctorId') ?? ''
|
||||||
|
const activeDoctors = useMemo(() => doctors.filter((d) => d.status === 'active'), [])
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
customerName: '',
|
customerName: '',
|
||||||
prescriber: '',
|
doctorId: doctorParam || activeDoctors[0]?.id || '',
|
||||||
branchId: user?.branchId ?? branches[0]?.id ?? '',
|
branchId: user?.branchId ?? branches[0]?.id ?? '',
|
||||||
itemName: '',
|
itemName: '',
|
||||||
qty: '1',
|
qty: '1',
|
||||||
controlled: false,
|
controlled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedDoctor = activeDoctors.find((d) => d.id === form.doctorId)
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
const onSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (!selectedDoctor) {
|
||||||
|
toast.error('Select a registered doctor as the prescriber')
|
||||||
|
return
|
||||||
|
}
|
||||||
const rx: Prescription = {
|
const rx: Prescription = {
|
||||||
id: `rx-${Date.now().toString().slice(-6)}`,
|
id: `rx-${Date.now().toString().slice(-6)}`,
|
||||||
customerName: form.customerName,
|
customerName: form.customerName,
|
||||||
|
|
@ -43,17 +53,19 @@ export function CreatePrescriptionPage() {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
submittedAt: new Date().toISOString(),
|
submittedAt: new Date().toISOString(),
|
||||||
prescriber: form.prescriber,
|
prescriber: selectedDoctor.name,
|
||||||
|
doctorId: selectedDoctor.id,
|
||||||
|
orgId: selectedDoctor.orgId,
|
||||||
}
|
}
|
||||||
prescriptions.unshift(rx)
|
prescriptions.unshift(rx)
|
||||||
toast.success('Prescription intake created')
|
toast.success('Prescription intake created')
|
||||||
navigate('/prescriptions')
|
navigate(`/prescriptions/${rx.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreatePageLayout
|
<CreatePageLayout
|
||||||
title="New prescription intake"
|
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"
|
backTo="/prescriptions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
|
@ -68,20 +80,47 @@ export function CreatePrescriptionPage() {
|
||||||
>
|
>
|
||||||
<form id="create-rx-form" className={createFormGrid} onSubmit={onSubmit}>
|
<form id="create-rx-form" className={createFormGrid} onSubmit={onSubmit}>
|
||||||
<div className={cn('space-y-1.5', createFieldWide)}>
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
<Label>Customer name</Label>
|
<Label>Patient / customer name</Label>
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
value={form.customerName}
|
value={form.customerName}
|
||||||
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
|
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
|
||||||
|
placeholder="May be a guest without an app account"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className={cn('space-y-1.5', createFieldWide)}>
|
||||||
<Label>Prescriber</Label>
|
<Label>Prescribing doctor</Label>
|
||||||
<Input
|
<SelectNative
|
||||||
required
|
required
|
||||||
value={form.prescriber}
|
value={form.doctorId}
|
||||||
onChange={(e) => setForm({ ...form, prescriber: e.target.value })}
|
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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Branch</Label>
|
<Label>Branch</Label>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { StatusBadge, statusLabel } from '@/components/ui/status-badge'
|
import { StatusBadge, statusLabel } from '@/components/ui/status-badge'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
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 { useAuthStore } from '@/zustand/authStore'
|
||||||
import { formatDateTime } from '@/lib/utils'
|
import { formatDateTime } from '@/lib/utils'
|
||||||
import type { Prescription } from '@/types'
|
import type { Prescription } from '@/types'
|
||||||
|
|
@ -35,6 +35,7 @@ export function PrescriptionDetailPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = branches.find((b) => b.id === rx.branchId)
|
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 controlled = rx.items.some((i) => i.controlled)
|
||||||
const isOpen = OPEN_STATUSES.includes(rx.status)
|
const isOpen = OPEN_STATUSES.includes(rx.status)
|
||||||
|
|
||||||
|
|
@ -282,7 +283,18 @@ export function PrescriptionDetailPage() {
|
||||||
columns={3}
|
columns={3}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Branch', value: branch?.name },
|
{ 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: 'Submitted', value: formatDateTime(rx.submittedAt) },
|
||||||
{ label: 'Items', value: String(rx.items.length) },
|
{ label: 'Items', value: String(rx.items.length) },
|
||||||
{ label: 'Controlled items', value: controlled ? 'Yes — stamp required' : 'None' },
|
{ 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 { Label } from '@/components/ui/label'
|
||||||
import { SelectNative } from '@/components/ui/select-native'
|
import { SelectNative } from '@/components/ui/select-native'
|
||||||
import { cn } from '@/lib/utils'
|
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'
|
import type { ProductCategory } from '@/mocks/data'
|
||||||
|
|
||||||
function categoryForItem(therapeuticClass: string, productType: string): ProductCategory {
|
function categoryForItem(therapeuticClass: string, productType: string): ProductCategory {
|
||||||
|
|
@ -50,11 +50,26 @@ export function CreateStockPage() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
branchId: branches[0]?.id ?? '',
|
branchId: branches[0]?.id ?? '',
|
||||||
qty: '',
|
qty: '',
|
||||||
|
uom: initialItem ? stockUomOf(initialItem) : '',
|
||||||
batch: '',
|
batch: '',
|
||||||
expiry: '',
|
expiry: '',
|
||||||
unitEtb: initialItem ? String(initialItem.sellingPriceEtb) : '',
|
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) => {
|
const onItemChange = (nextId: string) => {
|
||||||
setItemId(nextId)
|
setItemId(nextId)
|
||||||
const item = findCatalogItem(nextId)
|
const item = findCatalogItem(nextId)
|
||||||
|
|
@ -62,6 +77,7 @@ export function CreateStockPage() {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
unitEtb: String(item.sellingPriceEtb),
|
unitEtb: String(item.sellingPriceEtb),
|
||||||
|
uom: stockUomOf(item),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +88,8 @@ export function CreateStockPage() {
|
||||||
toast.error('Pick a catalogue item first')
|
toast.error('Pick a catalogue item first')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const qty = Number(form.qty) || 0
|
const uom = form.uom || stockUomOf(selected)
|
||||||
|
const qty = toStockQty(Number(form.qty) || 0, uom, selected)
|
||||||
stockRows.unshift({
|
stockRows.unshift({
|
||||||
sku: selected.sku,
|
sku: selected.sku,
|
||||||
name: selected.brandName || selected.genericName,
|
name: selected.brandName || selected.genericName,
|
||||||
|
|
@ -86,7 +103,9 @@ export function CreateStockPage() {
|
||||||
itemId: selected.id,
|
itemId: selected.id,
|
||||||
reserved: 0,
|
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}`)
|
navigate(`/stock/${selected.sku}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,6 +195,37 @@ export function CreateStockPage() {
|
||||||
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<Label>Batch</Label>
|
<Label>Batch</Label>
|
||||||
<Input required value={form.batch} onChange={(e) => setForm({ ...form, batch: e.target.value })} />
|
<Input required value={form.batch} onChange={(e) => setForm({ ...form, batch: e.target.value })} />
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -48,6 +48,8 @@ export interface Branch {
|
||||||
phone: string
|
phone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OrganisationType = 'corporate' | 'hospital' | 'clinic' | 'ngo' | 'other'
|
||||||
|
|
||||||
export interface Prescription {
|
export interface Prescription {
|
||||||
id: string
|
id: string
|
||||||
customerName: string
|
customerName: string
|
||||||
|
|
@ -55,7 +57,12 @@ export interface Prescription {
|
||||||
status: 'submitted' | 'under_review' | 'approved' | 'queried' | 'rejected'
|
status: 'submitted' | 'under_review' | 'approved' | 'queried' | 'rejected'
|
||||||
items: { name: string; qty: number; controlled?: boolean }[]
|
items: { name: string; qty: number; controlled?: boolean }[]
|
||||||
submittedAt: string
|
submittedAt: string
|
||||||
|
/** Display name of the prescribing clinician (denormalised). */
|
||||||
prescriber?: string
|
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. */
|
/** Set when a pharmacist picks the Rx up off the queue. */
|
||||||
reviewStartedAt?: string
|
reviewStartedAt?: string
|
||||||
/** Pharmacist who approved, queried or rejected. */
|
/** Pharmacist who approved, queried or rejected. */
|
||||||
|
|
@ -64,9 +71,24 @@ export interface Prescription {
|
||||||
decisionNote?: string
|
decisionNote?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** End-customer on an order — platform account vs walk-in / phone / guest. */
|
||||||
|
export type OrderCustomerType = 'registered' | 'guest'
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: string
|
id: string
|
||||||
customerName: 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
|
branchId: string
|
||||||
fulfillment: 'delivery' | 'pickup'
|
fulfillment: 'delivery' | 'pickup'
|
||||||
status: string
|
status: string
|
||||||
|
|
@ -78,12 +100,51 @@ export interface Order {
|
||||||
createdAt: string
|
createdAt: string
|
||||||
paid: boolean
|
paid: boolean
|
||||||
rxApproved?: 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 {
|
export interface Organisation {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
/** B2B account kind — hospital/clinic unlocks the Doctors roster. */
|
||||||
|
orgType: OrganisationType
|
||||||
|
/** Tax Identification Number (10 digits). */
|
||||||
tin: string
|
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'
|
status: 'pending' | 'active' | 'rejected'
|
||||||
creditLimitEtb: number
|
creditLimitEtb: number
|
||||||
usedEtb: number
|
usedEtb: number
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user