This repository has been archived on 2026-08-11. You can view files and clone it, but cannot push or open issues or pull requests.
Gishen-Admin/docs/admin-backend-spec.md
Kirubel-Kibru-Yaltopia 8616c03645 Align org activation and Money with B2B platform contracts
Use pending_activation, commercial Money terms, and activate flows so Admin master/spec and mocks match the shared organisation lifecycle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 00:29:44 +03:00

669 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Gishen Admin — Backend Spec Sheet
**Repo:** Gishen-Admin
**Audience:** Shared backend team (consolidate with Ecom / Mobile / B2B sheets later)
**Base URL (TBD):** `https://api.gishen.example/v1`
**Auth:** Bearer JWT for staff roles
**Mocks:** Admin SPA uses in-memory mocks until `VITE_USE_MOCKS=false` and `VITE_API_BASE_URL` point here.
**Cross-app master:** [`GISHEN-MASTER-SPEC.md`](./GISHEN-MASTER-SPEC.md) — product surface map, shared domain objects, auth matrix, and divergence notes vs Mob / Ecom / B2B. Refresh the master (and this sheet) before pushes; see its Update rule.
## Changelog
| Date | Change |
| --- | --- |
| 2026-08-08 | **B2B org alignment:** `pending_activation`, `POST .../activate` + `CommercialTerms`/`Money`, events `org.activated`/`org.suspended`; B2B register path `POST /v1/org/register` |
| 2026-08-07 | Pointer to cross-app [`GISHEN-MASTER-SPEC.md`](./GISHEN-MASTER-SPEC.md) + pre-push refresh rule |
| 2026-08-06 | Scaffold: conventions, roles, auth, empty module sections |
| 2026-08-06 | Shell + RBAC + preferredLocale |
| 2026-08-06 | Pharmacist: prescriptions, branch orders |
| 2026-08-06 | Stock + Procurement |
| 2026-08-06 | Finance, orgs create + pending self-reg approval |
| 2026-08-06 | Marketing CRM / loyalty / campaigns |
| 2026-08-06 | Dispatch board + Telegram bot events |
| 2026-08-06 | Super Admin team/audit/settings + FAQ bilingual |
| 2026-08-06 | Data migration import jobs |
| 2026-08-06 | Deployed Admin SPA: https://gishen-admin.vercel.app |
| 2026-08-06 | List UX: filters + create pages for orders, Rx, orgs, stock, team, procurement; `POST` create sketches below |
| 2026-08-06 | Finance: gateway enable/disable moved to `/admin/finance/gateways` UI route `/finance/gateways` |
| 2026-08-06 | Detail pages for orders, Rx, stock, orgs, procurement, team, CRM, FAQ, settlements; `GET /:id` + sub-resources below |
| 2026-08-06 | Dashboard / analytics / marketing / dispatch chart widgets; KPI series endpoints below |
| 2026-08-06 | Removed Service Inbox module (pages, route, nav, permissions, mocks, i18n) |
| 2026-08-06 | Topbar command palette (Cmd+K) + central page directory; register new pages in src/config/navigation.ts |
| 2026-08-06 | Sidebar consolidated into grouped, collapsible domain sections driven by the same navigation registry |
| 2026-08-06 | Staff profile page `/profile` + Yimaru-style login with demo-account picker; `avatarUrl` on staff/customers/riders |
| 2026-08-06 | Medication **Item** master catalogue (`/items`) separate from Stock; create stock picks existing item |
| 2026-08-06 | Counter **POS / scan-to-purchase** (`/pos`): customer QR, invoice association scan, tickets-app integration queue |
| 2026-08-06 | Loyalty ledger + earning activities tabs; Finance/Procurement KPI tab shells; module list KPI strips |
| 2026-08-06 | Detail pages: campaign, segment, rider, migration job, audit event, branch; approve UX (primary + overflow reject) |
| 2026-08-06 | Dashboard charts: popular products, category mix, sales trend, channel, branch, Rx funnel; "today" KPIs date-scoped |
| 2026-08-06 | Table filters: >3 filters open a modal with Apply/Cancel + active chips (`TableToolbar` declarative API) |
| 2026-08-06 | i18n: en/am parity (~666 keys), Noto Sans Ethiopic; locale-aware `formatters`; Gregorian + Amharic month names |
| 2026-08-06 | New `ModuleKey`: `pos` (pharmacist, stock_manager, operations, super_admin). Items gated by existing `stock` |
| 2026-08-07 | **Doctor** actor: hospital-affiliated clinicians; Admin roster under organisations; create Rx & orders; guest patients |
| 2026-08-07 | **Guest / unauthenticated orders**: `customerType` registered\|guest; staff/doctor authenticated, end customer optional |
| 2026-08-07 | Rx medication schedule: `frequency`, `intervalHours`, `times[]` on prescription line items; Admin create/detail + `dosingSchedule` helpers |
| 2026-08-07 | Catalogue **multi-UOM**: `uoms[]` with `conversionFactor` into stock base; `toStockQty` / `fromStockQty` |
| 2026-08-07 | Catalogue **storefront / mobile PDP** fields + Admin detail preview (`storefrontPdpPreview`) |
| 2026-08-07 | Auth providers: Google, Email, Phone (OTP), Telegram — staff login UI + endpoint sketches |
| 2026-08-07 | **Push batch:** multi-provider staff login + Rx dosing schedule (Admin UI/mocks) + spec alignment for doctor/guest orders, multi-UOM, storefront PDP; open questions 8–17 |
## Conventions
| Item | Convention |
| --- | --- |
| Content type | `application/json` (multipart for imports / uploads) |
| IDs | UUID v4 |
| Money | **`Money`:** `{ "amount": "1250.00", "currency": "ETB" }` (decimal string, major units). Admin SPA may also keep numeric major-ETB helpers for UI demos — sync with `commercial` on activate. |
| Errors | `{ "error": { "code": string, "message": string, "details"?: object } }` |
| Pagination | `?page=&limit=` → `{ data, meta: { page, limit, total } }` |
## Staff roles
`pharmacist` · `stock_manager` · `procurement` · `finance` · `marketing_manager` · `operations` · `super_admin`
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
Staff Admin authenticates with **Bearer JWT**. Login supports multiple identity providers; all paths mint (or exchange into) the same staff access/refresh pair and `GET /auth/me` principal.
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| POST | `/auth/staff/login` | — | Email/password → tokens + user |
| POST | `/auth/staff/oauth/google` | — | Exchange Google ID token (GIS / OAuth) → staff tokens + user |
| POST | `/auth/staff/phone/start` | — | `{ phone }` → start OTP (rate-limited); `{ challengeId, expiresIn }` |
| POST | `/auth/staff/phone/verify` | — | `{ challengeId, code }` → tokens + user when phone is linked to a staff account |
| POST | `/auth/staff/telegram` | — | Telegram Login Widget payload (hash-verified) → tokens + user |
| POST | `/auth/refresh` | refresh | Rotate access |
| POST | `/auth/logout` | staff | Revoke |
| GET | `/auth/me` | staff | Current principal, roles, branchId, avatarUrl, `authProvider` |
| PATCH | `/users/me` | staff | `{ preferredLocale?: "en"\|"am", name?, phone?, avatarUrl? }` |
| POST | `/users/me/avatar` | staff | Multipart image upload → `{ avatarUrl }` |
### Auth providers
| Provider | Flow | Staff Admin | Doctor / external |
| --- | --- | --- | --- |
| **Email** | `POST /auth/staff/login` with password (magic-link optional later — not in Admin mock) | Primary for pharmacy staff | Doctor uses `POST /auth/doctor/login` (email/invite); same IdP may be shared later with `actor=doctor` |
| **Google** | Browser Google Identity Services → ID token → `POST /auth/staff/oauth/google` | Allowed when staff email is in Google Workspace / allow-listed domain | TBD for doctors (hospital Google vs separate realm) |
| **Phone (OTP)** | `phone/start` → SMS/WhatsApp OTP → `phone/verify` | Staff accounts with verified `phone` on user profile | Useful for guest order claim / patient flows (out of Admin staff session); doctor phone login TBD |
| **Telegram** | [Telegram Login Widget](https://core.telegram.org/widgets/login) on Admin SPA → server verifies `hash` with bot token → `POST /auth/staff/telegram` | Optional for staff who linked Telegram id; also used for **rider** dispatch identity (see Dispatch) | Not primary for doctor clinical auth |
**Login response sketch:**
```json
{
"accessToken": "...",
"refreshToken": "...",
"user": {
"id": "uuid",
"name": "Hana Pharmacist",
"email": "pharmacist@gishen.et",
"role": "pharmacist",
"branchId": "uuid",
"preferredLocale": "en",
"avatarUrl": "https://cdn.example/avatars/hana.jpg",
"authProvider": "email"
}
}
```
`authProvider` values: `email` | `google` | `phone` | `telegram`.
**Google body sketch:** `{ "idToken": "..." }`
**Phone start:** `{ "phone": "+251911000000" }`
**Phone verify:** `{ "challengeId": "uuid", "code": "123456" }`
**Telegram body:** widget fields (`id`, `first_name`, `username`, `auth_date`, `hash`, …) as returned by the Login Widget.
Mock keys:
- `authStore.login` → `/auth/staff/login`
- `authStore.loginWithProvider('google'|'phone'|'telegram')` → oauth / phone verify / telegram (Admin SPA mock signs in as demo pharmacist unless overridden)
UI: `/login` (provider buttons + email form + phone OTP step + Telegram CTA + demo picker), `/profile` (every authenticated role; not permission-gated), topbar “My profile”
Env placeholders:
| Side | Vars |
| --- | --- |
| Client (Admin SPA / `.env.example`) | `VITE_GOOGLE_CLIENT_ID`, `VITE_TELEGRAM_BOT_USERNAME` — empty = mock provider UX |
| Server only (never ship to Vite) | Google token audience / Workspace allow-list; `TELEGRAM_BOT_TOKEN` (Login Widget hash verify); SMS/OTP gateway credentials (AfricasTalking / Twilio / …) |
Typed on client as `ImportMetaEnv` in `src/vite-env.d.ts`.
---
## Orders (branch queue)
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `customerType`, `q` |
| POST | `/pharmacy/orders` | pharmacist, operations, super_admin, **doctor** | Staff- or doctor-created order (call centre / walk-in / clinical). Supports **guest** customers |
| GET | `/pharmacy/orders/:id` | pharmacist, operations, super_admin, doctor (own) | Detail: header + line items + payment + fulfillment + customer type |
| GET | `/pharmacy/orders/:id/timeline` | pharmacist, operations, super_admin | Ordered status/audit events for the detail timeline |
| POST | `/orders/:id/confirm` | pharmacist, super_admin | Confirm stock |
| POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` |
| PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle |
### Guest / unauthenticated end-customer orders
The **authenticated** principal is always staff or a doctor. The **end customer** may be unauthenticated:
| Field | Notes |
| --- | --- |
| `customerType` | `registered` \| `guest` (required on create) |
| `customerId` | Required when `registered`; **omit** when `guest` |
| `customerName` | Display name (guest walk-in / phone caller / patient) |
| `customerPhone` | **Required for guests**; recommended for registered (denormalised) |
| `notes` | Optional staff/doctor free text |
| `doctorId` / `orgId` | Optional; set when a Doctor actor initiates the order |
**Limits (proposed):** guest orders must include a valid phone; controlled SKUs still require an approved Rx; no loyalty earn by default for guests unless a later claim/merge attaches the order to a CRM profile. Doctors may place guest orders for patients without accounts, scoped to their hospital.
**Create body sketch:**
```json
{
"customerType": "guest",
"customerName": "Walk-in · Aster",
"customerPhone": "+251911880011",
"notes": "Caregiver collecting",
"branchId": "uuid",
"fulfillment": "pickup",
"channel": "doctor",
"doctorId": "uuid",
"lines": [{ "sku": "PCM-500", "qty": 20 }]
}
```
Detail response adds `items[] { sku, name, qty, unitEtb }`, `timeline[] { at, title, detail, tone }`, resolved `rider`, and optional `doctor` / `organisation`.
Mock: `mocks/data.orders`, `mocks/data.orderLines`, `mocks/data.orderTimeline`
UI: `/orders/new` (Guest customer vs Registered customer toggle), `/orders/:id`
---
## Prescriptions
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue; filters: `status`, `controlled`, `q` |
| POST | `/pharmacy/prescriptions` | pharmacist, super_admin, **doctor** | Staff Rx intake or doctor-authored Rx; body includes `doctorId` when attributed |
| POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` |
| POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time |
| GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report |
| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin, doctor (own) | Detail: items (incl. dosing schedule), controlled flags, review trail, linked doctor |
| GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight |
UI: `/prescriptions/new` (doctor select + per-line dosing schedule), `/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. Medications table shows interval/frequency and dose times.
### Prescription medication line items (schedule)
Each `items[]` entry on create / detail carries dosing instructions used by pharmacist counsel and **mobile dose reminders**. Clock `times` use the same shape as mobile `Reminder.times` (`HH:mm` strings, 24h). Backend should persist the pharmacist’s final `times` list (and `intervalHours` for QXH) so the patient app can seed reminders without re-deriving.
| Field | Type | Notes |
| --- | --- | --- |
| `name` | string | Medicine label / strength as entered |
| `qty` | number | Dispense quantity |
| `controlled?` | boolean | Controlled-substance flag |
| `frequency?` | `QD` \| `BID` \| `TID` \| `QID` \| `QXH` \| `custom` | Once / 2× / 3× / 4× daily, every N hours, or custom clock list |
| `intervalHours?` | number | **Required** when `frequency === "QXH"` (hours between doses, ≥ 1) |
| `times?` | string[] | Clock times `HH:mm` (24h), e.g. `["08:00","14:00","20:00"]`. Admin intake requires ≥ 1 time |
**Frequency → default suggested times** (Admin `defaultTimesForFrequency`; pharmacist may add/remove slots):
| Frequency | Default `times` |
| --- | --- |
| `QD` | `08:00` |
| `BID` | `08:00`, `20:00` |
| `TID` | `08:00`, `14:00`, `20:00` |
| `QID` | `08:00`, `12:00`, `16:00`, `20:00` |
| `QXH` | Generated from `intervalHours` starting 08:00 until midnight (default interval 8h if omitted in UI) |
| `custom` | Seed `08:00`; pharmacist edits freely |
**Create body sketch:** `POST /pharmacy/prescriptions` / `POST /doctor/prescriptions`:
```json
{
"customerName": "Guest patient · no account",
"branchId": "uuid",
"doctorId": "uuid",
"orgId": "uuid",
"items": [
{
"name": "Amoxicillin 500mg",
"qty": 21,
"controlled": false,
"frequency": "TID",
"times": ["08:00", "14:00", "20:00"]
},
{
"name": "Paracetamol 500mg",
"qty": 20,
"frequency": "QXH",
"intervalHours": 6,
"times": ["08:00", "14:00", "20:00"]
}
]
}
```
Rx header also stores denormalised `prescriber` (doctor display name). Patient on Admin intake may be a **guest name** (no CRM `customerId` required).
Helper (Admin): `src/lib/dosingSchedule.ts` — `DOSING_FREQUENCY_OPTIONS`, `defaultTimesForFrequency`, `formatFrequencyLabel`, `formatDoseTimes`.
Mock: `mocks/data.prescriptions` · Types: `PrescriptionItem`, `DosingFrequency`
UI: create sets schedule per line; detail medications table shows frequency label + dose times.
---
## 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
---
## Medication items (catalogue)
Master product identity. **Stock rows reference an Item** via `itemId`; batch/lot, manufacture date, expiry, and branch qty live on Stock only. Do not recreate a product on every goods receipt.
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/admin/catalog/items` | stock_manager, pharmacist (read), procurement (read), super_admin | Filters: `productType`, `efdaStatus`, `mergeStatus`, `therapeuticClass`, `q` |
| POST | `/admin/catalog/items` | stock_manager, super_admin | Create item; server assigns immutable internal SKU |
| GET | `/admin/catalog/items/:id` | stock_manager, pharmacist (read), procurement (read), super_admin | Full record + ownership tags + audit |
| PATCH | `/admin/catalog/items/:id` | stock_manager, super_admin | Update (SKU immutable); field-group ownership may reject ERP-owned writes from Platform |
| GET | `/admin/catalog/items/:id/history` | stock_manager, super_admin | Change log |
| POST | `/admin/catalog/items/match` | stock_manager, super_admin | Duplicate assist body `{ genericName, strength, dosageForm, packSize, manufacturer }` → `{ candidates: [{ id, confidence, matchKey }] }` |
| POST | `/admin/catalog/items/:id/merge` | stock_manager, super_admin | `{ candidateId, status: approved\|rejected }` — reversible 30d |
| POST | `/admin/catalog/items/:id/merge/reverse` | stock_manager, super_admin | Undo approved merge within 30 days |
| GET | `/admin/catalog/items/:id/variants` | same read | Pack-size children linked to parent |
| POST | `/admin/catalog/items/:id/variants` | stock_manager, super_admin | Attach pack variant `{ packSize, barcode, sellingPriceEtb }` |
| POST | `/admin/catalog/items/:id/media` | stock_manager, super_admin | Multipart images (min 1); badges blocked for Rx/Controlled |
**Field groups (create/detail):**
1. Core identification — SKU (immutable), generic/INN, brand, manufacturer, country, EFDA number, barcodes[]
2. Classification — product type (`OTC`\|`Rx`\|`Controlled`\|`Medical device`\|`Beauty`\|`Wellness`), therapeutic class, activeIngredients[], dosage form, route, controlled schedule
3. Packaging — pack size, `baseUnit` (stock UOM), `sellByUnit`, `packUnit`, **`uoms[]` multi-UOM table**, parent/variant links
4. Pricing — cost (ERP-owned), selling price (+ owner ERP\|Platform), currency ETB, VAT, promo eligibility (**auto-off for Rx/Controlled**, no override), B2B price-list ref, optional `compareAtPriceEtb`
5. Regulatory — Rx-required, EFDA status, controlled flag+schedule, advertising-restriction (auto from type), age restriction
6. Content — short description, dosage guidance, side effects (free text + structured mild/severe), warnings, storage (+ structured `storageNotes[]`), `directions[]`, `pharmacistTip`, `useCase`, similar alternatives[]
7. Storefront / mobile PDP — fields below; Admin detail shows a live preview shaped for Gishen mobile
8. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock)
9. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description
10. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps
11. Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at
12. Relationships — B2B-eligible category tag; loyalty earn/redeem eligibility (**auto-excluded for Rx/Controlled**)
### Multi-UOM (ERPNext-style)
Warehouse quantity is **always** counted in the stock / base UOM (`baseUnit`). Additional purchase/sales units convert via `conversionFactor` = how many **stock units** equal one of that UOM. The stock UOM row always has `conversionFactor: 1`.
| Field | Type | Notes |
| --- | --- | --- |
| `uoms[]` | `ItemUom[]` | Allowed units for the item |
| `uoms[].uom` | string | e.g. `tablet`, `strip`, `box` |
| `uoms[].conversionFactor` | number | Stock qty per 1 of this UOM |
| `uoms[].isStockUom?` | boolean | Exactly one true; matches `baseUnit` |
| `uoms[].isPurchaseUom?` | boolean | Used on PO / receive |
| `uoms[].isSalesUom?` | boolean | Used on sales / POS lines |
**Helpers (Admin mock / shared):** `resolveItemUoms` (falls back to `buildDefaultUoms` from pack labels when `uoms` empty), `toStockQty(qty, fromUom, item)`, `fromStockQty(stockQty, toUom, item)`, `stockUomOf(item)`.
**API expectation:** `POST`/`PATCH` `/admin/catalog/items` accept `uoms`; stock receive / POS / order lines may submit qty in a sales/purchase UOM — server converts with `toStockQty` before decrementing inventory. Reject unknown UOM labels.
Example: `1 strip = 10 tablet` → selling 2 strips deducts `20` stock units.
### Storefront / mobile PDP fields
Aligned with Gishen mobile product detail (`product/[id]`). Admin create/edit captures these; detail page previews via `storefrontPdpPreview(item)`.
| Field | Notes |
| --- | --- |
| `storefrontUnit?` | Shelf / pack label shown as mobile `product.unit` (e.g. `10 capsules/strip`); falls back to `packSize` / `sellByUnit` |
| `useCase?` | Chip above name (e.g. Pain & Fever); falls back to therapeutic class |
| `compareAtPriceEtb?` | Strike-through compare-at price |
| `sideEffectsMild?` / `sideEffectsSevere?` | Structured lists; mild falls back to parsing free-text `sideEffects` |
| `directions?` | `{ title, body }[]` stepper; falls back from `dosageGuidance` + `warnings` |
| `storageNotes?` | `{ title, body }[]` cards; falls back from `storageInstructions` |
| `pharmacistTip?` | Callout; falls back to `warnings` |
Preview mapping also exposes name, brand, slug, sku, price, images, Rx flag, badge, form, generic, drug class, ingredient strengths.
Mock: `mocks/catalog.ts` (`catalogItems`, `itemChangeHistory`, `ItemUom`, …)
UI: `/items`, `/items/new` (multi-UOM editor + PDP fields), `/items/:id` (UOM table + Storefront / Mobile PDP section)
Stock create: `POST /pharmacy/inventory` prefers `{ itemId, branchId, batch, … }` over free-typed product fields. UI deep-link `?item=`
---
## Stock & ERP
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/pharmacy/inventory` | stock_manager, pharmacist (read), procurement (read), super_admin | Per-branch stock; filters: `branchId`, `q`, `alert=low\|mismatch` |
| POST | `/pharmacy/inventory` | stock_manager, super_admin | Receive stock against catalogue item: `{ itemId, branchId, batch, manufacturedAt?, expiresAt, qty, uom?, reorderPoint? }` — `qty` may be in a purchase UOM; server converts via item `uoms` → stock base. Must not invent a new Item |
| GET | `/integrations/erp/sync-status` | stock_manager, super_admin | Last sync |
| GET | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP |
| POST | `/admin/stock/discrepancies/:id/resolve` | stock_manager, super_admin | One-click resolve |
| GET/PUT | `/admin/stock/field-ownership` | stock_manager, super_admin | ERP vs platform field owners |
| POST | `/admin/stock/entry/suggest` | stock_manager, super_admin | Fuzzy duplicate suggestions body `{ query }` |
| GET | `/admin/stock/merge-proposals` | stock_manager, super_admin | Merge queue |
| POST | `/admin/stock/merge-proposals/:id/approve` | stock_manager, super_admin | Human approve; reversible 30d |
| GET | `/admin/stock/alerts` | stock_manager, super_admin | Low stock / expiry watchlist |
| POST | `/admin/stock/transfers` | stock_manager, super_admin | Create/approve transfer (owner) |
| GET | `/pharmacy/inventory/:sku` | stock_manager, pharmacist (read), super_admin | Detail: batch, expiry, ERP delta, linked `itemId` |
| GET | `/pharmacy/inventory/:sku/movements` | stock_manager, super_admin | Ledger: `{ at, type, qty, note }` (sale/receive/adjust/reserve) |
UI: `/stock/:sku` · create UI deep-link `?item=`
---
## Counter POS / scan-to-purchase
Staff module (`ModuleKey: pos`) for walk-in counter sales identified by customer QR. Roles: `pharmacist`, `stock_manager`, `operations`, `super_admin`.
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| POST | `/pos/resolve` | pos roles | Resolve scanned code → customer. Body `{ code }` accepts QR payload (`gishen-customer:{id}`), loyalty URL, bare `c*`, or phone digits |
| GET | `/pos/customers/:id/context` | pos roles | Name, phone, tier, points balance, last order — shown before money moves |
| POST | `/pos/purchases` | pos roles | Commit quick purchase `{ customerId, branchId, lines: [{ sku, qty }], channel?: "pos" }` → creates order, decrements stock, awards loyalty ledger entry. **Rejects / gates Controlled & Rx-only SKUs** without a valid dispensed prescription |
| GET | `/pos/earn-preview` | pos roles | `{ subtotalEtb, pointsEarn }` using active loyalty rule + tier multiplier |
| POST | `/pos/invoices/extract` | pos roles | Multipart invoice image/PDF → assisted OCR `{ lines[], confidence, warnings[] }` (operator must confirm) |
| POST | `/pos/invoices/associate` | pos roles | `{ lines[], association: { type: procurement\|supplier\|branch, id }, note? }` — purchase association against existing record |
| GET | `/pos/tickets` | pos roles | Inbound queue from external tickets / till app (`source`, `status`, payload) |
| POST | `/pos/tickets/pull` | pos roles | Pull pending from configured integration (mockable when disconnected) |
| POST | `/pos/tickets/:id/accept` | pos roles | Materialise ticket into POS purchase draft |
| POST | `/pos/tickets/:id/dismiss` | pos roles | Discard |
| GET/PUT | `/pos/integrations/tickets` | finance\|super_admin | Connection status + credentials for tickets app |
Manual code entry and a demo “simulate scan” path must work when camera/`getUserMedia` is unavailable. Camera streams must stop on unmount.
Mock: `mocks/data` POS tickets / earn rules · UI: `/pos` (tabs: Quick purchase, Invoice scan, Ticket queue, Customer codes)
---
## Procurement
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/admin/procurement/reports` | procurement, stock_manager, super_admin | Dead-stock / fast-mover |
| GET | `/admin/procurement/reorder-recommendations` | procurement, super_admin | From velocity |
| POST | `/admin/stock/transfers/request` | procurement, super_admin | Request only; Stock Manager approves |
| GET | `/admin/procurement/requests` | procurement, stock_manager, super_admin | Reorder requests; filters `status`, `priority`, `branchId` |
| GET | `/admin/procurement/requests/:id` | procurement, stock_manager, super_admin | Detail + workflow trail |
| POST | `/admin/procurement/requests/:id/order` | procurement, super_admin | Raise PO → `status=ordered` |
| POST | `/admin/procurement/requests/:id/receive` | procurement, stock_manager, super_admin | Goods received → `status=received`, posts stock movement |
Request shape: `{ id, sku, name, qty, branchId, status: open\|ordered\|received, priority: low\|medium\|high, requestedBy, notes, createdAt }`
Mock: `mocks/data.procurementRequests` · UI: `/procurement/:id`
---
## Finance & organisations
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/admin/finance/settlements` | finance, super_admin | Daily by channel |
| GET | `/admin/finance/reconciliations` | finance, super_admin | Matched to orders |
| GET/PUT | `/admin/finance/gateways` | finance, super_admin | Enable/disable Chapa, ArifPay, Telebirr, M-Pesa |
| POST | `/admin/finance/refunds` | finance, super_admin | Refund handling |
| GET | `/admin/finance/loyalty-liability` | finance, super_admin | Outstanding Birr liability (points × redemption rate; must reconcile with Loyalty KPI) |
| GET | `/admin/organisations` | finance, super_admin | List; filter `orgType`, `status`, `source` |
| POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (`active` + `commercial`); body includes `orgType` |
| GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue (`status=pending_activation`) |
| GET | `/admin/organisations/registration-requests` | finance, super_admin | CRM leads from `org.registration_requested` |
| POST | `/admin/organisations/:id/activate` | finance, super_admin | Body: `commercial` (`credit_limit` Money, payment terms, price list, contract dates). Emits `org.activated` |
| POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject registration |
| POST | `/admin/organisations/:id/suspend` | finance, super_admin | Emit `org.suspended` — B2B blocks mutations |
| GET | `/admin/organisations/:id/statements` | finance, super_admin | Consolidated invoices (`pdf_url` for B2B Finance) |
| GET | `/admin/finance/settlements/:id` | finance, super_admin | Batch detail: `txnCount`, `feesEtb`, net, unmatched slips |
| POST | `/admin/finance/settlements/:id/reconcile` | finance, super_admin | Re-run reconciliation |
| GET | `/admin/organisations/:id` | finance, super_admin | Detail: `commercial` + utilization + `orgType` |
| GET | `/admin/organisations/:id/members` | finance, super_admin | HR roster `{ name, dept, plan }` |
| GET | `/admin/organisations/:id/doctors` | finance, super_admin | Hospital/clinic doctor roster (empty for other types) |
| GET | `/admin/organisations/:id/invoices` | finance, super_admin | `{ id, period, amountEtb, status }` |
| PATCH | `/admin/organisations/:id/commercial` | finance, super_admin | Update commercial terms while active (review-gated) |
`orgType`: `corporate` · `hospital` · `clinic` · `ngo` · `other`. Doctor invitation is only valid for `hospital` / `clinic`.
**Org status (canonical):** `pending_activation` → `active` → `suspended` \| `closed` (plus `rejected` for denied registrations).
**Activate body sketch:**
```json
{
"commercial": {
"credit_limit": { "amount": "2000000.00", "currency": "ETB" },
"payment_terms_days": 30,
"price_list_id": "pl_corporate_2026",
"contract_start": "2026-04-01",
"contract_end": "2027-03-31"
}
}
```
**Cross-repo:** B2B `POST /v1/org/register` creates `org_status=pending_activation` and emits `org.registration_submitted`. Admin activate emits `org.activated` with commercial payload matching B2B `organisation.md`. (Legacy sketch `POST /company/register` / bare `pending` / `/approve` superseded.)
UI: `/organisations/:id` (Activate primary; Reject in overflow + credit-section footer; **Add doctor** on hospital/clinic), `/doctors`, `/finance` (tabs: Settlements / Trends / Operations + gateways), `/finance/settlements/:id`, `/finance/gateways`
---
## Marketing
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/admin/crm/customers` | marketing_manager, super_admin | Read profiles; include `avatarUrl` |
| GET | `/admin/crm/segments` | marketing_manager, super_admin | Self-updating segments |
| GET | `/admin/crm/segments/:id` | marketing_manager, super_admin | Segment detail + members + engagement series |
| GET/POST | `/admin/campaigns` | marketing_manager, super_admin | Compose/schedule SMS/Telegram/push |
| GET | `/admin/campaigns/:id` | marketing_manager, super_admin | Campaign detail: recipients, channel mix, send timeline |
| GET/PUT | `/admin/loyalty/config` | marketing_manager, super_admin | Earn/tiers/referral bonus |
| GET | `/admin/loyalty/ledger` | marketing_manager, finance, super_admin | Points movements; filters `activity`, `direction`, `branchId`, `customerId`, `q` |
| GET | `/admin/loyalty/customers/summary` | marketing_manager, finance, super_admin | Per-customer earned / redeemed / balance / tier |
| GET | `/admin/loyalty/activities` | marketing_manager, super_admin | Earning rule catalogue |
| PATCH | `/admin/loyalty/activities/:id` | marketing_manager, super_admin | Pause/activate / edit points |
| GET | `/admin/crm/customers/:id` | marketing_manager, super_admin | Detail: tier, points, recent orders, touch history, avatar |
| POST | `/admin/crm/customers/:id/points` | marketing_manager, super_admin | `{ delta, reason }` manual loyalty adjustment |
| GET | `/admin/crm/customers/:id/qr` | marketing_manager, pharmacist, pos roles, super_admin | Loyalty QR payload + printable image (PNG/SVG) |
| GET | `/admin/kpi/summary` | marketing_manager, finance, super_admin | Scoped KPIs |
### Chart / KPI series
Dashboard, Analytics, Marketing, Dispatch, Finance, and detail pages read series from these (currently `mocks/charts.ts`):
| Method | Path | Returns |
| --- | --- | --- |
| GET | `/admin/kpi/sales?range=7d\|30d` | `[{ day, sales, orders }]` — sales trend + order overlay |
| GET | `/admin/kpi/popular-products?range=` | `[{ sku, name, units, revenueEtb }]` — ranked; deep-link `/stock/:sku` |
| GET | `/admin/kpi/category-mix` | `[{ category, value }]` — share by therapeutic / product category |
| GET | `/admin/kpi/channel-mix` | `[{ name, value }]` — order share by channel |
| GET | `/admin/kpi/branch-performance` | `[{ branch, revenue, orders, rx }]` |
| GET | `/admin/kpi/rx-funnel` | `{ submitted, underReview, approved, rejected }` |
| GET | `/admin/kpi/settlement-trend` | `[{ day, chapa, telebirr, cod }]` — stacked settlement volume |
| GET | `/admin/kpi/dispatch-load` | `[{ hour, trips }]` |
| GET | `/admin/kpi/stock-movement?sku=` | `[{ week, in, out }]` |
| GET | `/admin/kpi/org-spend?orgId=` | `[{ month, spend }]` |
| GET | `/admin/kpi/customer-points?customerId=` | `[{ month, points }]` |
| GET | `/admin/kpi/loyalty-issued-redeemed` | `[{ month, earned, redeemed }]` |
| GET | `/admin/kpi/needs-attention` | Action queue: pending Rx, low stock, unassigned deliveries, pending orgs |
**Dashboard rule:** any “today” metric must filter on `createdAt`/`submittedAt` calendar day in the staff timezone. Branch-scoped roles receive branch-scoped aggregates only.
UI: `/crm/:id`, `/marketing/:id` (segment), `/campaigns/:id`, `/loyalty`
---
## Dispatch & Telegram bot
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/dispatch/board` | operations, super_admin | Paid + Rx-approved queue |
| POST | `/dispatch/trips` | operations, super_admin | Batch fulfilments |
| POST | `/dispatch/trips/:id/assign` | operations, super_admin | `{ riderId }` → emits Telegram manifest |
| POST | `/dispatch/bot/webhook` | service | Bot status + PoD callbacks |
| GET | `/dispatch/intake` | operations, super_admin | Partner/POS webhook monitor |
| GET | `/dispatch/riders` | operations, super_admin | Roster with `avatarUrl`, on-shift flag |
| POST | `/dispatch/riders` | operations, super_admin | Link Telegram account |
| GET | `/dispatch/riders/:id` | operations, super_admin | Rider detail: stats, trips, on-time series |
**Events:** `trip.assigned` → bot push; `trip.status_updated` → board + customer tracking.
UI: `/dispatch`, `/dispatch/riders/:id`
---
## FAQ (bilingual)
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/admin/faq` | all staff | Published articles |
| POST | `/admin/faq` | marketing_manager, super_admin | Create |
| PUT | `/admin/faq/:id` | marketing_manager, super_admin | Update / publish |
| GET | `/admin/faq/:id` | all staff | Article detail (drafts visible to `faq_write`) |
| DELETE | `/admin/faq/:id` | super_admin | Delete |
Fields: `title_en`, `title_am`, `body_en`, `body_am`, `category`, `published`.
UI: `/faq/:id` — inline bilingual editor for `faq_write`, read-only otherwise.
---
## Team, audit, settings
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET/POST | `/admin/users` | super_admin | Staff + role + branch; list supports `role`, `branchScoped`, `q`; include `avatarUrl` |
| GET | `/admin/users/:id` | super_admin | Staff detail: role, branch scope, module access, sessions, avatar |
| POST | `/admin/users/:id/reset-password` | super_admin | Email reset link |
| POST | `/admin/users/:id/disable` | super_admin | Deactivate account |
| GET | `/admin/audit` | super_admin | Audit log; filters `domain`, `category`, `outcome`, `q`. `target` deep-links to entity detail |
| GET | `/admin/audit/:id` | super_admin | Event detail: actor, before/after, IP, UA, security flag |
| GET/PUT | `/admin/settings` | super_admin | Retention, access controls |
| GET | `/admin/branches` | super_admin, operations | Branch list |
| GET | `/admin/branches/:id` | super_admin, operations | Branch profile, staff, order series |
UI: `/team/:id`, `/audit/:id`, `/settings/branches/:id`
---
## Data migration / imports
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/admin/imports/templates` | stock_manager, finance, operations, super_admin | Entity schemas |
| POST | `/admin/imports` | same | Multipart file upload |
| POST | `/admin/imports/:id/map` | same | Column mapping (+ save template) |
| POST | `/admin/imports/:id/validate` | same | Dry-run row errors |
| POST | `/admin/imports/:id/commit` | same | Upsert commit |
| GET | `/admin/imports` | same | Job history |
| GET | `/admin/imports/:id` | same | Job detail + error rows |
| CRUD | `/admin/imports/mapping-templates` | same | Saved presets |
Entity templates: `stock`, `catalog`, `hr_members`, `branches`, `riders`, `generic`.
UI: `/migrations`, `/migrations/:id`
---
## Navigation & page directory (frontend convention)
`src/config/navigation.ts` is the **single source of truth** for every navigable
destination in the Admin SPA. It feeds both the sidebar and the Cmd+K / Ctrl+K
command palette in the topbar.
**When you add a page or feature, register it there.** A route added to
`src/app/AppRoutes.tsx` but missing from the registry is unreachable by search;
an entry whose `path` has no matching route produces a broken result.
Each entry carries `id`, `title` (+ optional `labelKey` for i18next),
`description`, `path`, `module` (`ModuleKey`, used for RBAC via `canAccess`),
`icon`, `group` (`NavGroupId`), `keywords` (search synonyms), `kind`
(`page` | `action`) and `showInSidebar`.
`group` is the operational domain — Overview, Daily operations, Inventory &
supply, Finance & accounts, Customers & growth, Administration, Support, plus a
palette-only Actions group. It drives the sidebar section headers *and* the
palette headings from one field, so the two cannot drift. Labels live in
`navGroups`; `sidebarSections(role)` returns the grouped, permission-filtered
sidebar and drops any section whose children the role cannot access.
Palette results are permission-filtered with the same `roleModules` map the
route guards use, so no backend change is needed — but any new module key added
server-side must also exist in `ModuleKey` and `roleModules`.
Current SPA destinations of note: `/pos`, `/items`, `/doctors` (orgs module —
hospital/clinic roster), `/profile` (palette + topbar; not a gated module),
Payment Gateways `/finance/gateways` (palette-only).
Entity detail routes (`orders/:id`, `stock/:sku`, `items/:id`, …) are
deliberately not registered: they require a record id. Register the list page
instead.
**List filters (UI):** toolbars with more than three filters collapse into a
modal with staged Apply/Cancel and removable active chips. Search stays inline.
Backend continues to accept flat query params; no change required.
**Avatars:** staff, customers, and riders expose optional `avatarUrl`. Clients
resolve `avatarUrl` → generated placeholder → initials. Upload: `POST /users/me/avatar`
or admin `POST /admin/users/:id/avatar`.
---
## Open questions
1. ~~Money units~~ → **Resolved default:** `Money` decimal-string major ETB (B2B). Close Ecom open decision to match.
2. Driver channel assumption: Telegram bot (flag if SMS/WhatsApp preferred)
3. Checkout entitlement-split: shared component across Ecom/Mobile/B2B (out of Admin UI)
4. Amharic dates: Gregorian with Amharic month names (current) vs Ethiopian calendar (`am-ET-u-ca-ethiopic`) — year differs (~2018 E.C. for 2026 G.C.); need product decision
5. Tickets-app vendor: POS integration is pluggable — confirm production partner API once chosen
6. Invoice OCR provider for `/pos/invoices/extract` (Admin currently simulates extraction)
7. Catalogue merge queue ownership: Stock Manager vs dedicated data-steward role
8. Doctor identity provider: dedicated doctor realm vs B2B portal users with `role=doctor` under hospital orgs
9. Guest order claim: can a later registered customer attach historical guest orders by phone OTP? Retention / masking of guest PII?
10. Guest order ceilings: max ETB / controlled-item rules without registered identity — confirm with compliance
11. Multi-hospital doctors: allow one clinician affiliation to multiple hospital orgs?
12. Multi-UOM source of truth: Platform-editable vs ERP-synced conversion factors — which field-group ownership wins on conflict?
13. Mobile reminder sync: push dose `times` on Rx approve vs on first dispense / patient claim?
14. Google OAuth: production Web client ID + allowed staff email domains / Workspace org for Admin
15. Telegram Login Widget: production bot username and whether staff must pre-link Telegram id in Admin before first login
16. OTP provider for staff phone login: AfricasTalking vs Twilio vs local SMS gateway; shared with guest-order claim OTP?
17. Should doctor login reuse Google / phone / Telegram providers or stay email+invite only?