# 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. ## Changelog | Date | Change | | --- | --- | | 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` | ## Conventions | Item | Convention | | --- | --- | | Content type | `application/json` (multipart for imports / uploads) | | IDs | UUID v4 | | Money | Integer ETB (major units for Admin UI demos; align platform-wide later) | | 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`. --- ## Auth & me | Method | Path | Roles | Description | | --- | --- | --- | --- | | POST | `/auth/staff/login` | — | Email/password → tokens + user | | POST | `/auth/refresh` | refresh | Rotate access | | POST | `/auth/logout` | staff | Revoke | | GET | `/auth/me` | staff | Current principal, roles, branchId, avatarUrl | | PATCH | `/users/me` | staff | `{ preferredLocale?: "en"\|"am", name?, phone?, avatarUrl? }` | | POST | `/users/me/avatar` | staff | Multipart image upload → `{ avatarUrl }` | **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" } } ``` Mock key: `authStore.login` → `/auth/staff/login` UI: `/profile` (every authenticated role; not permission-gated), topbar “My profile” --- ## Orders (branch queue) | Method | Path | Roles | Description | | --- | --- | --- | --- | | GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `q` | | POST | `/pharmacy/orders` | pharmacist, operations, super_admin | Staff-created order (call centre / walk-in) | | GET | `/pharmacy/orders/:id` | pharmacist, operations, super_admin | Detail: header + line items + payment + fulfillment | | GET | `/pharmacy/orders/:id/timeline` | pharmacist, operations, super_admin | Ordered status/audit events for the detail timeline | | POST | `/orders/:id/confirm` | pharmacist, super_admin | Confirm stock | | POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` | | PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle | Detail response adds `items[] { sku, name, qty, unitEtb }`, `timeline[] { at, title, detail, tone }`, and resolved `rider`. Mock: `mocks/data.orders`, `mocks/data.orderLines`, `mocks/data.orderTimeline` UI: `/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 | Staff Rx intake | | POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` | | POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time | | GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report | | GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin | Detail: items, controlled flags, review trail | | GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight | UI: `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section. --- ## 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, base UoM, sell-by vs pack unit, 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 5. Regulatory — Rx-required, EFDA status, controlled flag+schedule, advertising-restriction (auto from type), age restriction 6. Content — descriptions, dosage guidance, side effects, warnings, storage, similar alternatives[] (display text only) 7. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock) 8. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description 9. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps 10. Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at 11. Relationships — B2B-eligible category tag; loyalty earn/redeem eligibility (**auto-excluded for Rx/Controlled**) Mock: `mocks/catalog.ts` (`catalogItems`, `itemChangeHistory`, …) UI: `/items`, `/items/new`, `/items/:id` 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, reorderPoint? }` — 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 | | POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (active) | | GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue | | POST | `/admin/organisations/:id/approve` | finance, super_admin | Activate credit | | POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject | | GET | `/admin/organisations/:id/statements` | finance, super_admin | Consolidated invoices | | GET | `/admin/finance/settlements/:id` | finance, super_admin | Batch detail: `txnCount`, `feesEtb`, net, unmatched slips | | POST | `/admin/finance/settlements/:id/reconcile` | finance, super_admin | Re-run reconciliation | | GET | `/admin/organisations/:id` | finance, super_admin | Detail: credit profile + utilization | | GET | `/admin/organisations/:id/members` | finance, super_admin | HR roster `{ name, dept, plan }` | | GET | `/admin/organisations/:id/invoices` | finance, super_admin | `{ id, period, amountEtb, status }` | | PATCH | `/admin/organisations/:id/credit` | finance, super_admin | `{ creditLimitEtb }` (review-gated) | **Cross-repo:** B2B portal `POST /company/register` creates `status=pending`. Admin approve activates credit. UI: `/organisations/:id` (Approve primary; Reject in overflow + credit-section footer), `/finance` (tabs: Settlements / Trends / Operations + gateways), `/finance/settlements/:id`, `/finance/gateways` --- ## 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`, `/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: ETB major vs cents — keep consistent with Ecom/B2B sheets 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