Pharmacists can flag and resolve Rx risk/ops issues on the queue and detail pages; master and admin backend sheets document the flags contract (Mob still TBD). Co-authored-by: Cursor <cursoragent@cursor.com>
747 lines
47 KiB
Markdown
747 lines
47 KiB
Markdown
# 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.
|
||
|
||
**Entity schemas:** [`schemas.md`](./schemas.md) — TypeScript + field tables for Money, Staff, Organisation/Commercial, Doctor, Prescription, Order, Catalogue Item, Stock, Customer, Settlement, Dispatch, envelopes.
|
||
|
||
## Changelog
|
||
|
||
| Date | Change |
|
||
| --- | --- |
|
||
| 2026-08-10 | **Rx prescription flags:** `flags[]` on Rx; add/resolve endpoints; list filters `flagged` / `flagCode`; Admin queue badges + detail Flags section; optional initial flag on intake |
|
||
| 2026-08-08 | **Entity schemas pack (platform):** [`schemas.md`](./schemas.md) covers shared + Admin + B2B + Ecom/backend DTOs; linked from Conventions + master |
|
||
| 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 } }` |
|
||
|
||
### Entity schemas (setup)
|
||
|
||
Full **platform** schemas (shared primitives, Admin ops, B2B portal, Ecom/retail, events) live in **[`schemas.md`](./schemas.md)**. Keep that file in sync with Admin mocks/types, B2B `docs/backend/entities/`, and Ecom `docs/backend.md`.
|
||
|
||
| Area | Schema anchors |
|
||
| --- | --- |
|
||
| Shared Money / envelopes / events | [§1–2](./schemas.md#1-shared-primitives) |
|
||
| Principals (Staff, Doctor, B2B session, Customer) | [§3](./schemas.md#3-principals--actors) |
|
||
| Org · Commercial · Branch · Doctor | [§4](./schemas.md#4-admin--organisations--doctors) |
|
||
| Rx · Order | [§5](./schemas.md#5-admin--prescriptions--orders) |
|
||
| Catalogue · Stock · Procurement | [§6](./schemas.md#6-admin--catalogue-stock--procurement) |
|
||
| POS · Loyalty · Marketing · FAQ | [§7](./schemas.md#7-admin--pos-crm-loyalty--marketing) |
|
||
| Settlements · Dispatch · Admin imports · Audit | [§8](./schemas.md#8-admin--finance-dispatch-imports--audit) |
|
||
| B2B registration · Member · Package · Dept | [§9](./schemas.md#9-b2b-portal-schemas) |
|
||
| B2B Finance · tenant Migration · Rx views | [§10](./schemas.md#10-b2b-finance-migration--rx-views) |
|
||
| Ecom catalog · order create · entitlement · KYC · payments | [§11](./schemas.md#11-ecom--retail-schemas) |
|
||
| Path-prefix ownership map | [§12](./schemas.md#12-backend-ownership--path-prefixes) |
|
||
|
||
#### Quick reference — Money & Org (canonical)
|
||
|
||
```ts
|
||
interface Money { amount: string; currency: 'ETB' }
|
||
|
||
type OrgStatus = 'pending_activation' | 'active' | 'suspended' | 'closed' | 'rejected'
|
||
|
||
interface CommercialTerms {
|
||
credit_limit: Money
|
||
credit_used: Money
|
||
payment_terms_days: number
|
||
price_list_id?: string
|
||
contract_start?: string
|
||
contract_end?: string
|
||
activated_at?: string
|
||
activated_by?: string
|
||
}
|
||
```
|
||
|
||
#### Quick reference — Prescription line dosing
|
||
|
||
```ts
|
||
type DosingFrequency = 'QD' | 'BID' | 'TID' | 'QID' | 'QXH' | 'custom'
|
||
|
||
interface PrescriptionItem {
|
||
name: string
|
||
qty: number
|
||
controlled?: boolean
|
||
frequency?: DosingFrequency
|
||
intervalHours?: number
|
||
times?: string[] // HH:mm — Mob reminder seed
|
||
}
|
||
```
|
||
|
||
## 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`, `flagged` (`open`\|`none`), `flagCode`, `q` |
|
||
| POST | `/pharmacy/prescriptions` | pharmacist, super_admin, **doctor** | Staff Rx intake or doctor-authored Rx; body includes `doctorId` when attributed; optional `flags[]` seed on create |
|
||
| 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), `flags[]`, controlled line flags, review trail, linked doctor |
|
||
| POST | `/pharmacy/prescriptions/:id/flags` | pharmacist, super_admin | Add flag `{ code, severity?, note }` → opens review marker (defaults severity from code) |
|
||
| POST | `/pharmacy/prescriptions/:id/flags/:flagId/resolve` | pharmacist, super_admin | `{ resolutionNote }` → `status=resolved`, stamps `resolvedBy` / `resolvedAt` |
|
||
| GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight |
|
||
|
||
UI: `/prescriptions` (open-flag count badge + Filters: Flags / Flag type), `/prescriptions/new` (doctor select + per-line dosing + optional initial flag), `/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; **Flags** section supports add + resolve with history. Medications table shows interval/frequency and dose times.
|
||
|
||
### Prescription flags (review markers)
|
||
|
||
Operational / clinical **review flags** on an Rx header — distinct from line-item `controlled` and workflow `status`. Staff add flags during intake or review; open flags surface on the queue until resolved. Resolved flags remain for audit.
|
||
|
||
| Field | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `id` | string | Flag id |
|
||
| `code` | enum | `controlled_substance` \| `interaction_concern` \| `incomplete_rx` \| `fraud_suspicion` \| `stock_shortage` \| `needs_clarification` \| `allergy_concern` \| `dosing_concern` \| `other` |
|
||
| `severity` | `info` \| `warning` \| `critical` | Defaults from `code` when omitted on create |
|
||
| `note` | string | Why this Rx needs attention |
|
||
| `createdBy` | string | Staff display name (or id + denorm) |
|
||
| `createdAt` | ISO datetime | |
|
||
| `status` | `open` \| `resolved` | |
|
||
| `resolvedBy?` | string | Set on resolve |
|
||
| `resolvedAt?` | ISO datetime | Set on resolve |
|
||
| `resolutionNote?` | string | How it was cleared |
|
||
|
||
Rx list may expose derived helpers: `openFlagCount`, highest open severity (for badge colour).
|
||
Helper (Admin): `src/lib/prescriptionFlags.ts`. Mock seeds: `rx-1001`–`1003`, `rx-1005`, `rx-1008`.
|
||
|
||
### 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?
|