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>
42 KiB
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 — 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 + 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 |
|---|---|---|---|
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 |
|
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 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:
{
"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/loginauthStore.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:
{
"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:
{
"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
organisationspermission (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):
- Core identification — SKU (immutable), generic/INN, brand, manufacturer, country, EFDA number, barcodes[]
- Classification — product type (
OTC|Rx|Controlled|Medical device|Beauty|Wellness), therapeutic class, activeIngredients[], dosage form, route, controlled schedule - Packaging — pack size,
baseUnit(stock UOM),sellByUnit,packUnit,uoms[]multi-UOM table, parent/variant links - 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 - Regulatory — Rx-required, EFDA status, controlled flag+schedule, advertising-restriction (auto from type), age restriction
- Content — short description, dosage guidance, side effects (free text + structured mild/severe), warnings, storage (+ structured
storageNotes[]),directions[],pharmacistTip,useCase, similar alternatives[] - Storefront / mobile PDP — fields below; Admin detail shows a live preview shaped for Gishen mobile
- Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock)
- Media — images[], thumbnail, merchandising badges (disabled for Rx/Controlled), SEO slug/title/description
- Dedupe —
matchKey, confidence, mergeCandidateId, mergeStatus, approver, timestamps - Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at
- 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:
{
"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
Money units→ Resolved default:Moneydecimal-string major ETB (B2B). Close Ecom open decision to match.- Driver channel assumption: Telegram bot (flag if SMS/WhatsApp preferred)
- Checkout entitlement-split: shared component across Ecom/Mobile/B2B (out of Admin UI)
- 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 - Tickets-app vendor: POS integration is pluggable — confirm production partner API once chosen
- Invoice OCR provider for
/pos/invoices/extract(Admin currently simulates extraction) - Catalogue merge queue ownership: Stock Manager vs dedicated data-steward role
- Doctor identity provider: dedicated doctor realm vs B2B portal users with
role=doctorunder hospital orgs - Guest order claim: can a later registered customer attach historical guest orders by phone OTP? Retention / masking of guest PII?
- Guest order ceilings: max ETB / controlled-item rules without registered identity — confirm with compliance
- Multi-hospital doctors: allow one clinician affiliation to multiple hospital orgs?
- Multi-UOM source of truth: Platform-editable vs ERP-synced conversion factors — which field-group ownership wins on conflict?
- Mobile reminder sync: push dose
timeson Rx approve vs on first dispense / patient claim? - Google OAuth: production Web client ID + allowed staff email domains / Workspace org for Admin
- Telegram Login Widget: production bot username and whether staff must pre-link Telegram id in Admin before first login
- OTP provider for staff phone login: AfricasTalking vs Twilio vs local SMS gateway; shared with guest-order claim OTP?
- Should doctor login reuse Google / phone / Telegram providers or stay email+invite only?