From fce0149b1a3e78881b5f7d3401665fed084f56fa Mon Sep 17 00:00:00 2001
From: kirukib
Date: Fri, 7 Aug 2026 00:03:21 +0300
Subject: [PATCH] Ship full Admin console polish and update backend spec.
Add medication Items catalogue, counter POS/QR purchase, profile/login,
detail pages, loyalty ledger, Cmd+K nav, KPI strips/charts, avatars, and
i18n; document the new APIs and open questions for the shared backend team.
Co-authored-by: Cursor
---
.gitignore | 1 +
README.md | 6 +
docs/admin-backend-spec.md | 232 +-
package-lock.json | 351 ++
package.json | 5 +
src/app/AppRoutes.tsx | 43 +-
src/components/charts/ChartCard.tsx | 32 +
src/components/charts/chartTheme.ts | 63 +
src/components/dashboard/StatCard.tsx | 19 +-
src/components/detail/ActivityTimeline.tsx | 78 +
src/components/detail/ApprovalStepper.tsx | 83 +
src/components/detail/KeyValueGrid.tsx | 31 +
src/components/sidebar/Sidebar.tsx | 215 +-
src/components/table/RowActions.tsx | 89 +-
src/components/table/TableFilterModal.tsx | 264 ++
src/components/table/TableToolbar.tsx | 113 +-
src/components/topbar/CommandPalette.tsx | 172 +
src/components/topbar/Topbar.tsx | 20 +-
src/components/ui/avatar.tsx | 209 ++
src/components/ui/command.tsx | 149 +
src/components/ui/dropdown-menu.tsx | 12 +
src/components/ui/empty-state.tsx | 75 +
src/components/ui/icon-tile.tsx | 54 +
src/components/ui/progress.tsx | 45 +
src/components/ui/separator.tsx | 21 +
src/components/ui/skeleton.tsx | 5 +
src/components/ui/status-badge.tsx | 136 +
src/config/navigation.ts | 557 ++++
src/config/permissions.ts | 25 +-
src/i18n/config.ts | 3 +
src/i18n/i18next.d.ts | 9 +
src/index.css | 5 +
src/layouts/CreatePageLayout.tsx | 13 +-
src/layouts/DetailPageLayout.tsx | 59 +
src/layouts/PageShell.tsx | 2 +-
src/lib/avatar.ts | 146 +
src/lib/formatters.ts | 94 +
src/lib/utils.ts | 96 +
src/locales/am/translation.json | 836 ++++-
src/locales/en/translation.json | 822 ++++-
src/mocks/catalog.ts | 1130 +++++++
src/mocks/charts.ts | 326 ++
src/mocks/data.ts | 2869 ++++++++++++++++-
src/pages/analytics/AnalyticsPage.tsx | 204 +-
src/pages/audit/AuditEventDetailPage.tsx | 296 ++
src/pages/audit/AuditPage.tsx | 305 +-
src/pages/auth/LoginPage.tsx | 367 ++-
src/pages/dashboard/DashboardPage.tsx | 931 +++++-
src/pages/dispatch/DispatchPage.tsx | 151 +-
src/pages/dispatch/RiderDetailPage.tsx | 303 ++
src/pages/faq/FaqDetailPage.tsx | 140 +
src/pages/faq/FaqPage.tsx | 90 +-
src/pages/finance/FinancePage.tsx | 305 +-
src/pages/finance/GatewaySettings.tsx | 50 +
src/pages/finance/PaymentGatewaysPage.tsx | 47 +-
src/pages/finance/SettlementDetailPage.tsx | 69 +
src/pages/inbox/InboxPage.tsx | 29 -
src/pages/items/CreateItemPage.tsx | 776 +++++
src/pages/items/ItemDetailPage.tsx | 263 ++
src/pages/items/ItemsPage.tsx | 248 ++
src/pages/marketing/CampaignDetailPage.tsx | 380 +++
src/pages/marketing/CampaignsPage.tsx | 422 ++-
src/pages/marketing/CrmPage.tsx | 161 +-
src/pages/marketing/CustomerDetailPage.tsx | 118 +
src/pages/marketing/LoyaltyPage.tsx | 605 +++-
src/pages/marketing/MarketingPage.tsx | 351 +-
src/pages/marketing/SegmentDetailPage.tsx | 320 ++
.../migrations/MigrationJobDetailPage.tsx | 316 ++
src/pages/migrations/MigrationsPage.tsx | 549 +++-
src/pages/orders/CreateOrderPage.tsx | 16 +-
src/pages/orders/OrderDetailPage.tsx | 157 +
src/pages/orders/OrdersPage.tsx | 273 +-
.../organisations/CreateOrganisationPage.tsx | 9 +-
.../organisations/OrganisationDetailPage.tsx | 312 ++
src/pages/organisations/OrganisationsPage.tsx | 224 +-
src/pages/pos/CustomerCodesPanel.tsx | 85 +
src/pages/pos/InvoiceScanPanel.tsx | 614 ++++
src/pages/pos/PosPage.tsx | 50 +
src/pages/pos/QrImage.tsx | 59 +
src/pages/pos/QuickPurchasePanel.tsx | 873 +++++
src/pages/pos/ScanPad.tsx | 198 ++
src/pages/pos/TicketQueuePanel.tsx | 227 ++
src/pages/pos/commitPurchase.ts | 155 +
src/pages/pos/posEngine.ts | 270 ++
src/pages/pos/useQrScanner.ts | 165 +
.../prescriptions/CreatePrescriptionPage.tsx | 14 +-
.../prescriptions/PrescriptionDetailPage.tsx | 329 ++
src/pages/prescriptions/PrescriptionsPage.tsx | 269 +-
.../CreateProcurementRequestPage.tsx | 7 +-
.../procurement/ProcurementDetailPage.tsx | 121 +
src/pages/procurement/ProcurementPage.tsx | 615 +++-
src/pages/profile/ProfilePage.tsx | 745 +++++
src/pages/settings/BranchDetailPage.tsx | 267 ++
src/pages/settings/SettingsPage.tsx | 579 +++-
src/pages/stock/CreateStockPage.tsx | 135 +-
src/pages/stock/StockDetailPage.tsx | 122 +
src/pages/stock/StockPage.tsx | 204 +-
src/pages/team/CreateTeamMemberPage.tsx | 10 +-
src/pages/team/TeamMemberDetailPage.tsx | 109 +
src/pages/team/TeamPage.tsx | 101 +-
src/types/index.ts | 16 +-
src/zustand/authStore.ts | 9 +-
tailwind.config.js | 4 +-
103 files changed, 23323 insertions(+), 1331 deletions(-)
create mode 100644 src/components/charts/ChartCard.tsx
create mode 100644 src/components/charts/chartTheme.ts
create mode 100644 src/components/detail/ActivityTimeline.tsx
create mode 100644 src/components/detail/ApprovalStepper.tsx
create mode 100644 src/components/detail/KeyValueGrid.tsx
create mode 100644 src/components/table/TableFilterModal.tsx
create mode 100644 src/components/topbar/CommandPalette.tsx
create mode 100644 src/components/ui/avatar.tsx
create mode 100644 src/components/ui/command.tsx
create mode 100644 src/components/ui/empty-state.tsx
create mode 100644 src/components/ui/icon-tile.tsx
create mode 100644 src/components/ui/progress.tsx
create mode 100644 src/components/ui/separator.tsx
create mode 100644 src/components/ui/skeleton.tsx
create mode 100644 src/components/ui/status-badge.tsx
create mode 100644 src/config/navigation.ts
create mode 100644 src/i18n/i18next.d.ts
create mode 100644 src/layouts/DetailPageLayout.tsx
create mode 100644 src/lib/avatar.ts
create mode 100644 src/lib/formatters.ts
create mode 100644 src/mocks/catalog.ts
create mode 100644 src/mocks/charts.ts
create mode 100644 src/pages/audit/AuditEventDetailPage.tsx
create mode 100644 src/pages/dispatch/RiderDetailPage.tsx
create mode 100644 src/pages/faq/FaqDetailPage.tsx
create mode 100644 src/pages/finance/GatewaySettings.tsx
create mode 100644 src/pages/finance/SettlementDetailPage.tsx
delete mode 100644 src/pages/inbox/InboxPage.tsx
create mode 100644 src/pages/items/CreateItemPage.tsx
create mode 100644 src/pages/items/ItemDetailPage.tsx
create mode 100644 src/pages/items/ItemsPage.tsx
create mode 100644 src/pages/marketing/CampaignDetailPage.tsx
create mode 100644 src/pages/marketing/CustomerDetailPage.tsx
create mode 100644 src/pages/marketing/SegmentDetailPage.tsx
create mode 100644 src/pages/migrations/MigrationJobDetailPage.tsx
create mode 100644 src/pages/orders/OrderDetailPage.tsx
create mode 100644 src/pages/organisations/OrganisationDetailPage.tsx
create mode 100644 src/pages/pos/CustomerCodesPanel.tsx
create mode 100644 src/pages/pos/InvoiceScanPanel.tsx
create mode 100644 src/pages/pos/PosPage.tsx
create mode 100644 src/pages/pos/QrImage.tsx
create mode 100644 src/pages/pos/QuickPurchasePanel.tsx
create mode 100644 src/pages/pos/ScanPad.tsx
create mode 100644 src/pages/pos/TicketQueuePanel.tsx
create mode 100644 src/pages/pos/commitPurchase.ts
create mode 100644 src/pages/pos/posEngine.ts
create mode 100644 src/pages/pos/useQrScanner.ts
create mode 100644 src/pages/prescriptions/PrescriptionDetailPage.tsx
create mode 100644 src/pages/procurement/ProcurementDetailPage.tsx
create mode 100644 src/pages/profile/ProfilePage.tsx
create mode 100644 src/pages/settings/BranchDetailPage.tsx
create mode 100644 src/pages/stock/StockDetailPage.tsx
create mode 100644 src/pages/team/TeamMemberDetailPage.tsx
diff --git a/.gitignore b/.gitignore
index ecfaa4a..0a96e80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ dist-ssr
*.sln
*.sw?
.vercel
+.avcheck/
.env
.env.local
.env.*.local
diff --git a/README.md b/README.md
index 365ab43..48c7275 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,12 @@ Demo password for all accounts: `demo123`
| marketing@gishen.et | Marketing Manager |
| ops@gishen.et | Operations |
+## Navigation registry
+
+`src/config/navigation.ts` is the single source of truth for every navigable page and quick action. The sidebar and the topbar command palette (**⌘K / Ctrl+K**) both read from it, and results are filtered by the signed-in role.
+
+**Adding a page? Register it there** alongside its route in `src/app/AppRoutes.tsx` — otherwise it will not show up in the sidebar or in search.
+
## Docs
Living backend contract: [`docs/admin-backend-spec.md`](docs/admin-backend-spec.md) — updated whenever modules are finalized.
diff --git a/docs/admin-backend-spec.md b/docs/admin-backend-spec.md
index 3835bfd..82d4d7e 100644
--- a/docs/admin-backend-spec.md
+++ b/docs/admin-backend-spec.md
@@ -17,11 +17,25 @@
| 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/inbox/audit/settings + FAQ bilingual |
+| 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
@@ -48,8 +62,9 @@ Pharmacist (and optionally stock) sessions include `branchId`.
| 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 |
-| PATCH | `/users/me` | staff | `{ preferredLocale?: "en"\|"am", name? }` |
+| 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:**
@@ -63,26 +78,33 @@ Pharmacist (and optionally stock) sessions include `branchId`.
"email": "pharmacist@gishen.et",
"role": "pharmacist",
"branchId": "uuid",
- "preferredLocale": "en"
+ "preferredLocale": "en",
+ "avatarUrl": "https://cdn.example/avatars/hana.jpg"
}
}
```
-Mock key: `authStore.login` → `/auth/staff/login`
+Mock key: `authStore.login` → `/auth/staff/login`
+UI: `/profile` (every authenticated role; not permission-gated), topbar “My profile”
---
-## Orders (branch inbox)
+## 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 |
-Mock: `mocks/data.orders`
+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`
---
@@ -95,8 +117,49 @@ Mock: `mocks/data.orders`
| 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
@@ -104,7 +167,7 @@ Mock: `mocks/data.orders`
| 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 | Add stock line |
+| 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 |
@@ -114,6 +177,34 @@ Mock: `mocks/data.orders`
| 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)
---
@@ -124,6 +215,14 @@ Mock: `mocks/data.orders`
| 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`
---
@@ -135,28 +234,69 @@ Mock: `mocks/data.orders`
| 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 | Monthly Birr liability |
+| 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 |
+| 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
@@ -168,10 +308,14 @@ Mock: `mocks/data.orders`
| 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)
@@ -181,20 +325,30 @@ Mock: `mocks/data.orders`
| 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, inbox, audit, settings
+## Team, audit, settings
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
-| GET/POST | `/admin/users` | super_admin | Staff + role + branch; list supports `role`, `branchScoped`, `q` |
-| GET | `/admin/inbox/threads` | super_admin | Chatwoot/Telegram/WhatsApp attached to customer |
-| GET | `/admin/audit` | super_admin | Audit log |
+| 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`
---
@@ -208,10 +362,56 @@ Fields: `title_en`, `title_am`, `body_en`, `body_am`, `category`, `published`.
| 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
@@ -219,3 +419,7 @@ Entity templates: `stock`, `catalog`, `hr_members`, `branches`, `riders`, `gener
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
diff --git a/package-lock.json b/package-lock.json
index 30f19c9..041bae2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "gishen-admin",
"version": "0.0.0",
"dependencies": {
+ "@fontsource/noto-sans-ethiopic": "^5.3.0",
"@fontsource/plus-jakarta-sans": "^5.3.0",
"@fontsource/sora": "^5.3.0",
"@radix-ui/react-avatar": "^1.2.6",
@@ -23,8 +24,11 @@
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
"i18next": "^26.3.6",
+ "jsqr": "^1.4.0",
"lucide-react": "^1.29.0",
+ "qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
@@ -36,6 +40,7 @@
},
"devDependencies": {
"@types/node": "^24.13.3",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
@@ -107,6 +112,15 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
+ "node_modules/@fontsource/noto-sans-ethiopic": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/noto-sans-ethiopic/-/noto-sans-ethiopic-5.3.0.tgz",
+ "integrity": "sha512-ivXPkx5Grhse+0GEyedQysDI5vSMUIeDNBhev6OIcSX6WyAPtvutU/CSWqf2wv67POnvQ0QaQ8Aq36vy9WfZpw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@fontsource/plus-jakarta-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/plus-jakarta-sans/-/plus-jakarta-sans-5.3.0.tgz",
@@ -1754,6 +1768,16 @@
"undici-types": "~7.18.0"
}
},
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -1818,6 +1842,30 @@
"node": ">= 6.0.0"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -2012,6 +2060,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2093,6 +2150,17 @@
"url": "https://polar.sh/cva"
}
},
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2102,6 +2170,40 @@
"node": ">=6"
}
},
+ "node_modules/cmdk": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
+ "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-id": "^1.1.0",
+ "@radix-ui/react-primitive": "^2.0.2"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2295,6 +2397,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -2333,6 +2444,12 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2361,6 +2478,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -2504,6 +2627,19 @@
"node": ">=8"
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -2578,6 +2714,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2796,6 +2941,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2829,6 +2983,12 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/jsqr": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
+ "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
+ "license": "Apache-2.0"
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -3122,6 +3282,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/lucide-react": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.29.0.tgz",
@@ -3324,6 +3496,51 @@
}
}
},
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3371,6 +3588,15 @@
"node": ">= 6"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -3543,6 +3769,23 @@
"node": ">=10"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3830,6 +4073,21 @@
"redux": "^5.0.0"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
@@ -3932,6 +4190,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -3958,6 +4222,32 @@
"node": ">=0.10.0"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -4325,6 +4615,67 @@
}
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
diff --git a/package.json b/package.json
index d1cc8df..b72f701 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@fontsource/noto-sans-ethiopic": "^5.3.0",
"@fontsource/plus-jakarta-sans": "^5.3.0",
"@fontsource/sora": "^5.3.0",
"@radix-ui/react-avatar": "^1.2.6",
@@ -25,8 +26,11 @@
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
"i18next": "^26.3.6",
+ "jsqr": "^1.4.0",
"lucide-react": "^1.29.0",
+ "qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
@@ -38,6 +42,7 @@
},
"devDependencies": {
"@types/node": "^24.13.3",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx
index a8a2a65..c897741 100644
--- a/src/app/AppRoutes.tsx
+++ b/src/app/AppRoutes.tsx
@@ -5,29 +5,48 @@ import { LoginPage } from '@/pages/auth/LoginPage'
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
import { OrdersPage } from '@/pages/orders/OrdersPage'
import { CreateOrderPage } from '@/pages/orders/CreateOrderPage'
+import { OrderDetailPage } from '@/pages/orders/OrderDetailPage'
import { PrescriptionsPage } from '@/pages/prescriptions/PrescriptionsPage'
import { CreatePrescriptionPage } from '@/pages/prescriptions/CreatePrescriptionPage'
+import { PrescriptionDetailPage } from '@/pages/prescriptions/PrescriptionDetailPage'
import { StockPage } from '@/pages/stock/StockPage'
import { CreateStockPage } from '@/pages/stock/CreateStockPage'
+import { StockDetailPage } from '@/pages/stock/StockDetailPage'
import { ProcurementPage } from '@/pages/procurement/ProcurementPage'
import { CreateProcurementRequestPage } from '@/pages/procurement/CreateProcurementRequestPage'
+import { ProcurementDetailPage } from '@/pages/procurement/ProcurementDetailPage'
import { FinancePage } from '@/pages/finance/FinancePage'
import { PaymentGatewaysPage } from '@/pages/finance/PaymentGatewaysPage'
+import { SettlementDetailPage } from '@/pages/finance/SettlementDetailPage'
import { OrganisationsPage } from '@/pages/organisations/OrganisationsPage'
import { CreateOrganisationPage } from '@/pages/organisations/CreateOrganisationPage'
+import { OrganisationDetailPage } from '@/pages/organisations/OrganisationDetailPage'
import { MarketingPage } from '@/pages/marketing/MarketingPage'
import { CrmPage } from '@/pages/marketing/CrmPage'
+import { CustomerDetailPage } from '@/pages/marketing/CustomerDetailPage'
import { LoyaltyPage } from '@/pages/marketing/LoyaltyPage'
import { CampaignsPage } from '@/pages/marketing/CampaignsPage'
+import { CampaignDetailPage } from '@/pages/marketing/CampaignDetailPage'
+import { SegmentDetailPage } from '@/pages/marketing/SegmentDetailPage'
import { DispatchPage } from '@/pages/dispatch/DispatchPage'
+import { RiderDetailPage } from '@/pages/dispatch/RiderDetailPage'
import { TeamPage } from '@/pages/team/TeamPage'
import { CreateTeamMemberPage } from '@/pages/team/CreateTeamMemberPage'
-import { InboxPage } from '@/pages/inbox/InboxPage'
+import { TeamMemberDetailPage } from '@/pages/team/TeamMemberDetailPage'
import { AuditPage } from '@/pages/audit/AuditPage'
+import { AuditEventDetailPage } from '@/pages/audit/AuditEventDetailPage'
import { FaqPage } from '@/pages/faq/FaqPage'
+import { FaqDetailPage } from '@/pages/faq/FaqDetailPage'
import { SettingsPage } from '@/pages/settings/SettingsPage'
+import { BranchDetailPage } from '@/pages/settings/BranchDetailPage'
import { MigrationsPage } from '@/pages/migrations/MigrationsPage'
+import { MigrationJobDetailPage } from '@/pages/migrations/MigrationJobDetailPage'
import { AnalyticsPage } from '@/pages/analytics/AnalyticsPage'
+import { ProfilePage } from '@/pages/profile/ProfilePage'
+import { PosPage } from '@/pages/pos/PosPage'
+import { ItemsPage } from '@/pages/items/ItemsPage'
+import { CreateItemPage } from '@/pages/items/CreateItemPage'
+import { ItemDetailPage } from '@/pages/items/ItemDetailPage'
import { useAuthStore } from '@/zustand/authStore'
import { canAccess } from '@/config/permissions'
import type { ModuleKey } from '@/types'
@@ -45,31 +64,51 @@ export function AppRoutes() {
} />
}>
} />
+ {/* Available to every signed-in role — not a permission-gated module. */}
+ } />
+ } />
+ } />
+ } />
+ } />
} />
} />
+ } />
} />
} />
+ } />
} />
} />
+ } />
} />
} />
+ } />
} />
} />
+ } />
} />
} />
+ } />
} />
+ } />
} />
+ } />
} />
} />
+ } />
} />
+ } />
} />
} />
+ } />
} />
+ } />
} />
} />
- } />
+ } />
} />
+ } />
} />
+ } />
} />
diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx
new file mode 100644
index 0000000..99aad79
--- /dev/null
+++ b/src/components/charts/ChartCard.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from 'react'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { cn } from '@/lib/utils'
+
+export function ChartCard({
+ title,
+ description,
+ action,
+ children,
+ className,
+ height = 'h-64',
+}: {
+ title: string
+ description?: string
+ action?: ReactNode
+ children: ReactNode
+ className?: string
+ height?: string
+}) {
+ return (
+
+
+
+ {title}
+ {description ? {description} : null}
+
+ {action}
+
+ {children}
+
+ )
+}
diff --git a/src/components/charts/chartTheme.ts b/src/components/charts/chartTheme.ts
new file mode 100644
index 0000000..c919c4b
--- /dev/null
+++ b/src/components/charts/chartTheme.ts
@@ -0,0 +1,63 @@
+import * as React from 'react'
+
+export const CHART_COLORS = {
+ brand: '#A8C73A',
+ brandDeep: '#8FAD2F',
+ orange: '#E28A1A',
+ forest: '#141414',
+ neutral: '#9A9E90',
+ danger: '#DC2626',
+}
+
+export const axisProps = {
+ tickLine: false,
+ axisLine: false,
+ fontSize: 11,
+ tick: { fill: CHART_COLORS.neutral },
+}
+
+export const gridProps = {
+ vertical: false,
+ stroke: '#E2E5D8',
+ strokeDasharray: '4 4',
+}
+
+export const chartMargin = { left: 4, right: 8, top: 8, bottom: 0 }
+
+export const tooltipProps = {
+ cursor: { fill: 'rgba(168,199,58,0.06)' },
+ contentStyle: {
+ borderRadius: 14,
+ border: '1px solid #E2E5D8',
+ boxShadow: '0 8px 24px rgba(0,0,0,0.06)',
+ fontSize: 12,
+ fontFamily: '"Plus Jakarta Sans", sans-serif',
+ padding: '8px 12px',
+ },
+}
+
+/** Gradient ids: brandFill, orangeFill, forestFill — reference as fill="url(#brandFill)". */
+export const GRADIENT_IDS = {
+ brand: 'brandFill',
+ orange: 'orangeFill',
+ forest: 'forestFill',
+}
+
+function verticalFade(id: string, color: string): React.JSX.Element {
+ return React.createElement(
+ 'linearGradient',
+ { id, key: id, x1: '0', y1: '0', x2: '0', y2: '1' },
+ React.createElement('stop', { offset: '0%', stopColor: color, stopOpacity: 0.28 }),
+ React.createElement('stop', { offset: '100%', stopColor: color, stopOpacity: 0 }),
+ )
+}
+
+export function GradientDefs(): React.JSX.Element {
+ return React.createElement(
+ 'defs',
+ null,
+ verticalFade(GRADIENT_IDS.brand, CHART_COLORS.brand),
+ verticalFade(GRADIENT_IDS.orange, CHART_COLORS.orange),
+ verticalFade(GRADIENT_IDS.forest, CHART_COLORS.forest),
+ )
+}
diff --git a/src/components/dashboard/StatCard.tsx b/src/components/dashboard/StatCard.tsx
index 2c1f07b..7ee84cf 100644
--- a/src/components/dashboard/StatCard.tsx
+++ b/src/components/dashboard/StatCard.tsx
@@ -1,4 +1,5 @@
import type { LucideIcon } from 'lucide-react'
+import { Link } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card'
@@ -8,12 +9,15 @@ export function StatCard({
hint,
icon: Icon,
tone = 'brand',
+ to,
}: {
label: string
value: string | number
hint?: string
icon: LucideIcon
tone?: 'brand' | 'orange' | 'forest' | 'muted'
+ /** When set the whole card becomes a drill-down link to a filtered list or detail route. */
+ to?: string
}) {
const tones = {
brand: 'bg-brand-100 text-brand-700',
@@ -21,8 +25,8 @@ export function StatCard({
forest: 'bg-forest/10 text-forest',
muted: 'bg-muted text-muted-foreground',
}
- return (
-
+ const card = (
+
@@ -35,4 +39,15 @@ export function StatCard({
)
+
+ if (!to) return card
+
+ return (
+
+ {card}
+
+ )
}
diff --git a/src/components/detail/ActivityTimeline.tsx b/src/components/detail/ActivityTimeline.tsx
new file mode 100644
index 0000000..96d5567
--- /dev/null
+++ b/src/components/detail/ActivityTimeline.tsx
@@ -0,0 +1,78 @@
+import type { ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+
+export type TimelineEvent = {
+ id: string
+ at: string
+ title: string
+ detail?: string
+ tone?: 'default' | 'success' | 'warning' | 'danger'
+}
+
+const toneDot: Record
, string> = {
+ default: 'bg-brand-500',
+ success: 'bg-emerald-500',
+ warning: 'bg-orange',
+ danger: 'bg-destructive',
+}
+
+export function ActivityTimeline({
+ events,
+ empty = 'No activity yet',
+ className,
+}: {
+ events: TimelineEvent[]
+ empty?: string
+ className?: string
+}) {
+ if (events.length === 0) {
+ return {empty}
+ }
+
+ return (
+
+ {events.map((ev) => (
+ -
+
+
{ev.title}
+ {ev.detail ? {ev.detail}
: null}
+
+ {new Date(ev.at).toLocaleString()}
+
+
+ ))}
+
+ )
+}
+
+export function DetailSection({
+ title,
+ description,
+ action,
+ children,
+ className,
+}: {
+ title: string
+ description?: string
+ action?: ReactNode
+ children: ReactNode
+ className?: string
+}) {
+ return (
+
+
+
+
{title}
+ {description ?
{description}
: null}
+
+ {action}
+
+ {children}
+
+ )
+}
diff --git a/src/components/detail/ApprovalStepper.tsx b/src/components/detail/ApprovalStepper.tsx
new file mode 100644
index 0000000..345fa73
--- /dev/null
+++ b/src/components/detail/ApprovalStepper.tsx
@@ -0,0 +1,83 @@
+import { Check, X } from 'lucide-react'
+import { cn, formatDateTime } from '@/lib/utils'
+
+export type ApprovalStepState = 'done' | 'current' | 'upcoming' | 'failed'
+
+export type ApprovalStep = {
+ key: string
+ label: string
+ state: ApprovalStepState
+ /** Who moved the record into this stage. */
+ actor?: string
+ at?: string
+ hint?: string
+}
+
+const markerClasses: Record = {
+ done: 'border-brand-500 bg-brand-500 text-white',
+ current: 'border-orange bg-orange/10 text-orange-deep',
+ upcoming: 'border-grayScale-200 bg-grayScale-100 text-grayScale-400',
+ failed: 'border-destructive bg-destructive text-white',
+}
+
+const labelClasses: Record = {
+ done: 'text-foreground',
+ current: 'text-orange-deep',
+ upcoming: 'text-grayScale-400',
+ failed: 'text-destructive',
+}
+
+/**
+ * Horizontal review progress for records that move through an approval
+ * workflow. Stacks to a vertical list below `sm`.
+ */
+export function ApprovalStepper({ steps, className }: { steps: ApprovalStep[]; className?: string }) {
+ return (
+
+ {steps.map((step, index) => {
+ const isLast = index === steps.length - 1
+ const connectorDone = step.state === 'done'
+
+ return (
+ -
+
+
+ {step.state === 'done' ? (
+
+ ) : step.state === 'failed' ? (
+
+ ) : (
+ index + 1
+ )}
+
+ {!isLast ? (
+
+ ) : null}
+
+
+
{step.label}
+ {step.actor ? (
+
{step.actor}
+ ) : null}
+ {step.at ? (
+
{formatDateTime(step.at)}
+ ) : null}
+ {step.hint ?
{step.hint}
: null}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/detail/KeyValueGrid.tsx b/src/components/detail/KeyValueGrid.tsx
new file mode 100644
index 0000000..539e77b
--- /dev/null
+++ b/src/components/detail/KeyValueGrid.tsx
@@ -0,0 +1,31 @@
+import type { ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+
+export function KeyValueGrid({
+ items,
+ className,
+ columns = 2,
+}: {
+ items: { label: string; value: ReactNode }[]
+ className?: string
+ columns?: 1 | 2 | 3
+}) {
+ return (
+
+ {items.map((item) => (
+
+
- {item.label}
+ - {item.value ?? '—'}
+
+ ))}
+
+ )
+}
diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx
index 6abdc9d..1799881 100644
--- a/src/components/sidebar/Sidebar.tsx
+++ b/src/components/sidebar/Sidebar.tsx
@@ -1,55 +1,37 @@
-import { NavLink } from 'react-router-dom'
+import { useEffect, useMemo, useState } from 'react'
+import { NavLink, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
-import {
- LayoutDashboard,
- ShoppingBag,
- FileHeart,
- Warehouse,
- PackageSearch,
- Wallet,
- Building2,
- Megaphone,
- Users,
- Gift,
- Send,
- Truck,
- Shield,
- Inbox,
- ScrollText,
- HelpCircle,
- Settings,
- Database,
- BarChart3,
- ChevronLeft,
- ChevronRight,
-} from 'lucide-react'
+import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'
import { useAuthStore } from '@/zustand/authStore'
-import { canAccess } from '@/config/permissions'
-import type { ModuleKey } from '@/types'
+import { sidebarSections, type NavEntry, type NavGroupId } from '@/config/navigation'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
-const navItems: { module: ModuleKey; to: string; icon: typeof LayoutDashboard; labelKey: string }[] = [
- { module: 'dashboard', to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
- { module: 'orders', to: '/orders', icon: ShoppingBag, labelKey: 'nav.orders' },
- { module: 'prescriptions', to: '/prescriptions', icon: FileHeart, labelKey: 'nav.prescriptions' },
- { module: 'stock', to: '/stock', icon: Warehouse, labelKey: 'nav.stock' },
- { module: 'procurement', to: '/procurement', icon: PackageSearch, labelKey: 'nav.procurement' },
- { module: 'finance', to: '/finance', icon: Wallet, labelKey: 'nav.finance' },
- { module: 'organisations', to: '/organisations', icon: Building2, labelKey: 'nav.organisations' },
- { module: 'marketing', to: '/marketing', icon: Megaphone, labelKey: 'nav.marketing' },
- { module: 'crm', to: '/crm', icon: Users, labelKey: 'nav.crm' },
- { module: 'loyalty', to: '/loyalty', icon: Gift, labelKey: 'nav.loyalty' },
- { module: 'campaigns', to: '/campaigns', icon: Send, labelKey: 'nav.campaigns' },
- { module: 'dispatch', to: '/dispatch', icon: Truck, labelKey: 'nav.dispatch' },
- { module: 'analytics', to: '/analytics', icon: BarChart3, labelKey: 'nav.analytics' },
- { module: 'migrations', to: '/migrations', icon: Database, labelKey: 'nav.migrations' },
- { module: 'faq', to: '/faq', icon: HelpCircle, labelKey: 'nav.faq' },
- { module: 'team', to: '/team', icon: Shield, labelKey: 'nav.team' },
- { module: 'inbox', to: '/inbox', icon: Inbox, labelKey: 'nav.inbox' },
- { module: 'audit', to: '/audit', icon: ScrollText, labelKey: 'nav.audit' },
- { module: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
-]
+const COLLAPSED_GROUPS_KEY = 'gishen_sidebar_groups'
+
+function readCollapsedGroups(): NavGroupId[] {
+ try {
+ const raw = localStorage.getItem(COLLAPSED_GROUPS_KEY)
+ const parsed = raw ? (JSON.parse(raw) as unknown) : null
+ return Array.isArray(parsed) ? (parsed.filter((id) => typeof id === 'string') as NavGroupId[]) : []
+ } catch {
+ return []
+ }
+}
+
+function persistCollapsedGroups(ids: NavGroupId[]) {
+ try {
+ localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify(ids))
+ } catch {
+ // Collapse state is a convenience only; ignore storage failures.
+ }
+}
+
+/** Mirrors NavLink's default matching so we can find the active section. */
+function isPathActive(path: string, pathname: string) {
+ if (path === '/') return pathname === '/'
+ return pathname === path || pathname.startsWith(`${path}/`)
+}
export function Sidebar({
isOpen,
@@ -65,7 +47,73 @@ export function Sidebar({
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const role = user?.role ?? 'pharmacist'
- const items = navItems.filter((item) => canAccess(role, item.module))
+ const { pathname } = useLocation()
+
+ const sections = useMemo(() => sidebarSections(role), [role])
+ const [collapsedGroups, setCollapsedGroups] = useState(readCollapsedGroups)
+
+ const activeGroupId = useMemo(
+ () => sections.find((s) => s.items.some((item) => isPathActive(item.path, pathname)))?.group.id,
+ [sections, pathname],
+ )
+
+ // Reveal the section you just navigated into, without fighting a manual
+ // collapse of the section you are already in (activeGroupId is unchanged then).
+ useEffect(() => {
+ if (!activeGroupId) return
+ setCollapsedGroups((prev) => {
+ if (!prev.includes(activeGroupId)) return prev
+ const next = prev.filter((id) => id !== activeGroupId)
+ persistCollapsedGroups(next)
+ return next
+ })
+ }, [activeGroupId])
+
+ function toggleGroup(id: NavGroupId) {
+ setCollapsedGroups((prev) => {
+ const next = prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]
+ persistCollapsedGroups(next)
+ return next
+ })
+ }
+
+ function renderItem(item: NavEntry) {
+ const Icon = item.icon
+ const label = item.labelKey ? t(item.labelKey, { defaultValue: item.title }) : item.title
+ return (
+
+ cn(
+ 'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ isActive
+ ? 'bg-brand-100/70 text-forest'
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
+ isCollapsed && 'justify-center px-0',
+ )
+ }
+ >
+ {({ isActive }) => (
+ <>
+
+
+
+ {!isCollapsed && {label}}
+ {isActive && !isCollapsed && }
+ >
+ )}
+
+ )
+ }
return (
<>
@@ -91,42 +139,45 @@ export function Sidebar({
)}
-
+
+
+
@@ -45,11 +51,21 @@ export function Topbar({ onSidebarToggle }: { onSidebarToggle: () => void }) {
-
+ navigate('/profile')}>
+
+ My profile
+
+
{
logout()
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..07de1a0
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,209 @@
+import * as React from 'react'
+import * as AvatarPrimitive from '@radix-ui/react-avatar'
+import { resolveAvatarSrc } from '@/lib/avatar'
+import { cn } from '@/lib/utils'
+
+export const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+export const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+export const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+const avatarSizes = {
+ sm: 'h-8 w-8 text-[11px]',
+ md: 'h-10 w-10 text-xs',
+ lg: 'h-14 w-14 text-sm',
+ xl: 'h-20 w-20 text-lg',
+} as const
+
+export type AvatarSize = keyof typeof avatarSizes
+
+/** Latin, Ethiopic, digits — anything that reads as a real name character. */
+const NAME_CHAR = /[\p{L}\p{N}]/u
+
+/**
+ * Initials are taken by code point, not by UTF-16 unit, so Ethiopic syllables
+ * and other multi-unit characters are never split in half.
+ */
+export function getInitials(name: string): string {
+ const words = (name ?? '')
+ .split(/\s+/)
+ .map((word) => Array.from(word).filter((char) => NAME_CHAR.test(char)))
+ .filter((chars) => chars.length > 0)
+
+ if (words.length === 0) return '?'
+
+ const first = words[0][0]
+ const last = words.length > 1 ? words[words.length - 1][0] : ''
+ return (first + last).toLocaleUpperCase()
+}
+
+export function InitialsAvatar({
+ name,
+ src,
+ size = 'md',
+ className,
+ title,
+}: {
+ name: string
+ src?: string
+ size?: AvatarSize
+ className?: string
+ title?: string
+}) {
+ return (
+
+ {src ? : null}
+ {getInitials(name)}
+
+ )
+}
+
+/** Presence tones for the rider status dot. */
+const presenceTone = {
+ online: 'bg-brand-500',
+ away: 'bg-orange',
+ offline: 'bg-grayScale-400',
+} as const
+
+export type AvatarPresence = keyof typeof presenceTone
+
+const presenceSizes: Record = {
+ sm: 'h-2 w-2',
+ md: 'h-2.5 w-2.5',
+ lg: 'h-3.5 w-3.5',
+ xl: 'h-5 w-5',
+}
+
+/**
+ * A person's avatar anywhere their name is shown. `avatarUrl` wins; `seed`
+ * generates a stable portrait for people who exist only as a name string; with
+ * neither, brand-tinted initials carry through. See `@/lib/avatar`.
+ */
+export function PersonAvatar({
+ name,
+ avatarUrl,
+ seed,
+ seeded = false,
+ size = 'md',
+ presence,
+ presenceLabel,
+ title,
+ className,
+}: {
+ name: string
+ avatarUrl?: string
+ /** Stable key to generate a portrait from when there is no `avatarUrl`. */
+ seed?: string
+ /** Shorthand for `seed={name}`. */
+ seeded?: boolean
+ size?: AvatarSize
+ presence?: AvatarPresence
+ /** Already-translated text for the presence dot, read by screen readers. */
+ presenceLabel?: string
+ title?: string
+ className?: string
+}) {
+ const src = resolveAvatarSrc({ avatarUrl, seed: seed ?? (seeded ? name : undefined) })
+ const avatar =
+
+ if (!presence) return avatar
+
+ return (
+
+ {avatar}
+
+ {presenceLabel ? {presenceLabel} : null}
+
+ )
+}
+
+export type StackedPerson = { id?: string; name: string; avatarUrl?: string }
+
+/**
+ * An overlapping stack for groups of people — a branch's staff, a campaign's
+ * owners — with a `+N` bubble once the list runs long. Names stay available to
+ * screen readers so the images are never the only thing identifying anyone.
+ */
+export function AvatarStack({
+ people,
+ size = 'sm',
+ max = 4,
+ seeded = false,
+ className,
+}: {
+ people: StackedPerson[]
+ size?: AvatarSize
+ max?: number
+ /** Generate portraits for people with no `avatarUrl`, seeded from their name. */
+ seeded?: boolean
+ className?: string
+}) {
+ const shown = people.slice(0, max)
+ const overflow = people.slice(max)
+
+ if (people.length === 0) return —
+
+ return (
+
+ {shown.map((person, index) => (
+ 0 && '-ml-2')}
+ />
+ ))}
+ {overflow.length > 0 ? (
+ person.name).join(', ')}
+ className={cn(
+ 'relative -ml-2 flex shrink-0 items-center justify-center rounded-full bg-grayScale-200 font-semibold text-grayScale-600 ring-2 ring-background',
+ avatarSizes[size],
+ )}
+ >
+ +{overflow.length}
+
+ ) : null}
+ {people.map((person) => person.name).join(', ')}
+
+ )
+}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
new file mode 100644
index 0000000..d9d74c2
--- /dev/null
+++ b/src/components/ui/command.tsx
@@ -0,0 +1,149 @@
+import * as React from 'react'
+import * as DialogPrimitive from '@radix-ui/react-dialog'
+import { Command as CommandPrimitive } from 'cmdk'
+import { Search } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+export const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Command.displayName = CommandPrimitive.displayName
+
+export function CommandDialog({
+ open,
+ onOpenChange,
+ label = 'Search pages and actions',
+ className,
+ shouldFilter = true,
+ children,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ label?: string
+ className?: string
+ /** Set false when the consumer supplies an already-filtered item list. */
+ shouldFilter?: boolean
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
+ {label}
+
+ Type to search, use the arrow keys to move and Enter to open.
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+CommandInput.displayName = CommandPrimitive.Input.displayName
+
+export const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandList.displayName = CommandPrimitive.List.displayName
+
+export const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName
+
+export const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandGroup.displayName = CommandPrimitive.Group.displayName
+
+export const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName
+
+export const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandItem.displayName = CommandPrimitive.Item.displayName
+
+export function CommandShortcut({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ )
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
index c1eed96..086a42b 100644
--- a/src/components/ui/dropdown-menu.tsx
+++ b/src/components/ui/dropdown-menu.tsx
@@ -36,3 +36,15 @@ export const DropdownMenuItem = React.forwardRef<
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+export const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx
new file mode 100644
index 0000000..da74836
--- /dev/null
+++ b/src/components/ui/empty-state.tsx
@@ -0,0 +1,75 @@
+import type { ReactNode } from 'react'
+import type { LucideIcon } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+export type EmptyStateSize = 'sm' | 'md' | 'lg'
+export type EmptyStateVariant = 'inline' | 'dashed' | 'page'
+
+const containerClasses: Record = {
+ inline: 'py-8',
+ dashed: 'rounded-xl border border-dashed border-border py-10',
+ page: 'py-14',
+}
+
+const sizeSpacing: Record = {
+ sm: 'gap-1.5 px-4',
+ md: 'gap-2 px-6',
+ lg: 'gap-3 px-8',
+}
+
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+ size = 'md',
+ variant = 'inline',
+ className,
+}: {
+ icon?: LucideIcon
+ title: string
+ description?: string
+ action?: ReactNode
+ size?: EmptyStateSize
+ variant?: EmptyStateVariant
+ className?: string
+}) {
+ const isPage = variant === 'page'
+
+ return (
+
+ {Icon ? (
+ isPage ? (
+
+ ) : (
+
+ )
+ ) : null}
+
+
+ {title}
+
+
+ {description ? (
+
{description}
+ ) : null}
+
+ {action ?
{action}
: null}
+
+ )
+}
diff --git a/src/components/ui/icon-tile.tsx b/src/components/ui/icon-tile.tsx
new file mode 100644
index 0000000..428d7c4
--- /dev/null
+++ b/src/components/ui/icon-tile.tsx
@@ -0,0 +1,54 @@
+import type { LucideIcon } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+export type IconTone = 'brand' | 'orange' | 'forest' | 'muted' | 'danger'
+
+/** Shared by IconTile, DetailSection and ChartCard so tinted surfaces stay identical. */
+export const toneClasses: Record = {
+ brand: 'bg-gradient-to-br from-brand-50 to-brand-100 text-brand-700 ring-brand-200',
+ orange: 'bg-orange/10 text-orange-deep ring-orange/20',
+ forest: 'bg-forest/[0.06] text-forest ring-forest/10',
+ muted: 'bg-grayScale-100 text-grayScale-500 ring-grayScale-200',
+ danger: 'bg-red-50 text-red-700 ring-red-200',
+}
+
+const sizeClasses = {
+ sm: 'h-9 w-9 rounded-lg',
+ md: 'h-12 w-12 rounded-xl',
+ lg: 'h-14 w-14 rounded-2xl',
+} as const
+
+const iconSizes = {
+ sm: 'h-4 w-4',
+ md: 'h-5 w-5',
+ lg: 'h-6 w-6',
+} as const
+
+export type IconTileSize = keyof typeof sizeClasses
+
+export function IconTile({
+ icon: Icon,
+ size = 'md',
+ tone = 'brand',
+ gradient = false,
+ className,
+}: {
+ icon: LucideIcon
+ size?: IconTileSize
+ tone?: IconTone
+ gradient?: boolean
+ className?: string
+}) {
+ return (
+
+
+
+ )
+}
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..b2308b6
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,45 @@
+import { cn } from '@/lib/utils'
+
+export type ProgressTone = 'brand' | 'orange' | 'danger' | 'forest'
+
+const fillClasses: Record = {
+ brand: 'bg-brand-500',
+ orange: 'bg-orange',
+ danger: 'bg-destructive',
+ forest: 'bg-forest',
+}
+
+export function Progress({
+ value,
+ tone = 'brand',
+ size = 'md',
+ className,
+}: {
+ value: number
+ tone?: ProgressTone
+ size?: 'sm' | 'md'
+ className?: string
+}) {
+ const safe = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0
+ // Utilisation past 90% always reads as danger, whatever the caller asked for.
+ const resolvedTone: ProgressTone = safe > 90 ? 'danger' : tone
+
+ return (
+
+ )
+}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..d9fb149
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react'
+import * as SeparatorPrimitive from '@radix-ui/react-separator'
+import { cn } from '@/lib/utils'
+
+export const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
+
+))
+Separator.displayName = SeparatorPrimitive.Root.displayName
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..a00ca79
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,5 @@
+import { cn } from '@/lib/utils'
+
+export function Skeleton({ className }: { className?: string }) {
+ return
+}
diff --git a/src/components/ui/status-badge.tsx b/src/components/ui/status-badge.tsx
new file mode 100644
index 0000000..c2753e9
--- /dev/null
+++ b/src/components/ui/status-badge.tsx
@@ -0,0 +1,136 @@
+import { Badge } from '@/components/ui/badge'
+import { cn, humanize } from '@/lib/utils'
+import type { StaffRole } from '@/types'
+
+/** Mirrors the variants declared in badge.tsx. */
+export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger' | 'muted'
+
+export type StatusDomain =
+ | 'order'
+ | 'prescription'
+ | 'organisation'
+ | 'procurement'
+ | 'settlement'
+ | 'stock'
+ | 'invoice'
+ | 'faq'
+ | 'role'
+ | 'tier'
+
+type StatusEntry = { label: string; variant: BadgeVariant }
+
+const roleStatuses: Record = {
+ pharmacist: { label: 'Pharmacist', variant: 'secondary' },
+ stock_manager: { label: 'Stock manager', variant: 'secondary' },
+ procurement: { label: 'Procurement', variant: 'secondary' },
+ finance: { label: 'Finance', variant: 'secondary' },
+ marketing_manager: { label: 'Marketing manager', variant: 'secondary' },
+ operations: { label: 'Operations', variant: 'secondary' },
+ super_admin: { label: 'Super admin', variant: 'default' },
+}
+
+/**
+ * The single source of truth for status wording and colour across the console.
+ * Keys are the raw values as they arrive from the API, lower-cased.
+ */
+const STATUS_MAP: Record> = {
+ order: {
+ placed: { label: 'Placed', variant: 'secondary' },
+ pending: { label: 'Pending', variant: 'warning' },
+ confirmed: { label: 'Confirmed', variant: 'default' },
+ picking: { label: 'Picking', variant: 'default' },
+ out_for_delivery: { label: 'Out for delivery', variant: 'warning' },
+ delivered: { label: 'Delivered', variant: 'success' },
+ cancelled: { label: 'Cancelled', variant: 'danger' },
+ },
+ prescription: {
+ submitted: { label: 'Submitted', variant: 'secondary' },
+ under_review: { label: 'Under review', variant: 'warning' },
+ approved: { label: 'Approved', variant: 'success' },
+ dispensed: { label: 'Dispensed', variant: 'success' },
+ queried: { label: 'Queried', variant: 'warning' },
+ rejected: { label: 'Rejected', variant: 'danger' },
+ },
+ organisation: {
+ pending: { label: 'Pending', variant: 'warning' },
+ active: { label: 'Active', variant: 'success' },
+ rejected: { label: 'Rejected', variant: 'danger' },
+ admin: { label: 'Added by admin', variant: 'muted' },
+ self_register: { label: 'Self-registered', variant: 'secondary' },
+ },
+ procurement: {
+ open: { label: 'Open', variant: 'secondary' },
+ ordered: { label: 'Ordered', variant: 'default' },
+ received: { label: 'Received', variant: 'success' },
+ high: { label: 'High', variant: 'danger' },
+ medium: { label: 'Medium', variant: 'warning' },
+ low: { label: 'Low', variant: 'muted' },
+ },
+ settlement: {
+ reconciled: { label: 'Reconciled', variant: 'success' },
+ matched: { label: 'Matched', variant: 'success' },
+ pending: { label: 'Pending', variant: 'warning' },
+ unmatched: { label: 'Unmatched', variant: 'danger' },
+ refunded: { label: 'Refunded', variant: 'muted' },
+ },
+ stock: {
+ sale: { label: 'Sale', variant: 'secondary' },
+ receive: { label: 'Receive', variant: 'success' },
+ adjust: { label: 'Adjust', variant: 'warning' },
+ reserve: { label: 'Reserve', variant: 'default' },
+ low_stock: { label: 'Low stock', variant: 'warning' },
+ near_expiry: { label: 'Near expiry', variant: 'warning' },
+ erp_mismatch: { label: 'ERP mismatch', variant: 'danger' },
+ healthy: { label: 'Healthy', variant: 'success' },
+ },
+ invoice: {
+ paid: { label: 'Paid', variant: 'success' },
+ open: { label: 'Open', variant: 'secondary' },
+ overdue: { label: 'Overdue', variant: 'danger' },
+ },
+ faq: {
+ published: { label: 'Published', variant: 'success' },
+ draft: { label: 'Draft', variant: 'muted' },
+ },
+ role: roleStatuses,
+ tier: {
+ gold: { label: 'Gold', variant: 'warning' },
+ silver: { label: 'Silver', variant: 'secondary' },
+ bronze: { label: 'Bronze', variant: 'muted' },
+ },
+}
+
+function lookup(domain: StatusDomain, status: string): StatusEntry | undefined {
+ return STATUS_MAP[domain][(status ?? '').trim().toLowerCase()]
+}
+
+export function statusLabel(domain: StatusDomain, status: string): string {
+ return lookup(domain, status)?.label ?? humanize(status ?? '')
+}
+
+export function statusVariant(domain: StatusDomain, status: string): BadgeVariant {
+ return lookup(domain, status)?.variant ?? 'muted'
+}
+
+const DOT_VARIANTS: BadgeVariant[] = ['success', 'warning', 'danger']
+
+export function StatusBadge({
+ domain,
+ status,
+ className,
+}: {
+ domain: StatusDomain
+ status: string
+ className?: string
+}) {
+ const variant = statusVariant(domain, status)
+
+ return (
+
+ {DOT_VARIANTS.includes(variant) ? (
+
+ ) : null}
+ {statusLabel(domain, status)}
+
+ )
+}
diff --git a/src/config/navigation.ts b/src/config/navigation.ts
new file mode 100644
index 0000000..2f64cae
--- /dev/null
+++ b/src/config/navigation.ts
@@ -0,0 +1,557 @@
+/**
+ * Central page directory for the Gishen Admin console.
+ *
+ * SINGLE SOURCE OF TRUTH. Every navigable destination lives here once and is
+ * consumed by both the grouped sidebar (`src/components/sidebar/Sidebar.tsx`)
+ * and the Cmd+K command palette (`src/components/topbar/CommandPalette.tsx`).
+ *
+ * WHEN YOU ADD A PAGE OR A FEATURE, ADD IT HERE.
+ * A route registered in `src/app/AppRoutes.tsx` but missing from this file is
+ * invisible to search and to the sidebar; an entry here whose `path` has no
+ * route is a broken link, so keep the two in sync.
+ *
+ * Field reference:
+ * id Stable unique key (also the React list key).
+ * title Display name shown in the sidebar and as the result title.
+ * labelKey Optional i18next key; when present it wins over `title`.
+ * description One short line rendered under the title in search results.
+ * path Router path, must match a route in AppRoutes.tsx exactly.
+ * module ModuleKey used for RBAC filtering via `canAccess`.
+ * icon lucide-react icon component.
+ * group NavGroupId — the operational domain this page belongs to.
+ * Drives the sidebar section headers AND the palette headings,
+ * so the two never drift apart. Labels live in `navGroups`.
+ * keywords Synonyms so a page is findable by the words users actually
+ * type (e.g. Dispatch also matches "rider", "delivery").
+ * kind 'page' for destinations, 'action' for create/jump-to flows.
+ * showInSidebar True only for the top-level module pages in the sidebar.
+ *
+ * Entries are listed in the order they render. Within a group, registry order
+ * is display order; the groups themselves follow `navGroups`. Only top-level
+ * module pages carry `showInSidebar: true` — sub-pages and create flows stay
+ * palette-only so the sidebar keeps one row per module.
+ *
+ * Entity detail routes (`orders/:id`, `stock/:sku`, …) are intentionally NOT
+ * registered: they need a record id, so they cannot be reached from a static
+ * directory. Register the list page instead and let users drill in from there.
+ */
+import {
+ BarChart3,
+ Building2,
+ CreditCard,
+ Database,
+ FileHeart,
+ Gift,
+ HelpCircle,
+ LayoutDashboard,
+ Megaphone,
+ PackageSearch,
+ Pill,
+ ScanLine,
+ ScrollText,
+ Send,
+ Settings,
+ Shield,
+ ShoppingBag,
+ Truck,
+ UserCircle,
+ Users,
+ Wallet,
+ Warehouse,
+ type LucideIcon,
+} from 'lucide-react'
+import { canAccess } from '@/config/permissions'
+import type { ModuleKey, StaffRole } from '@/types'
+
+export type NavGroupId =
+ | 'actions'
+ | 'overview'
+ | 'operations'
+ | 'inventory'
+ | 'finance'
+ | 'growth'
+ | 'admin'
+ | 'support'
+
+export interface NavGroupMeta {
+ id: NavGroupId
+ /** Section header text, shown in the sidebar and as a palette heading. */
+ label: string
+}
+
+/**
+ * Group definitions in render order. `actions` is palette-only — the sidebar
+ * skips it so create flows do not clutter the module list.
+ *
+ * `support` exists as its own group because every role can read the FAQ. Filed
+ * under 'growth' it would render a lone "Customers & growth → Knowledge / FAQ"
+ * header for the five roles that have no growth modules at all.
+ */
+export const navGroups: NavGroupMeta[] = [
+ { id: 'actions', label: 'Actions' },
+ { id: 'overview', label: 'Overview' },
+ { id: 'operations', label: 'Daily operations' },
+ { id: 'inventory', label: 'Inventory & supply' },
+ { id: 'finance', label: 'Finance & accounts' },
+ { id: 'growth', label: 'Customers & growth' },
+ { id: 'admin', label: 'Administration' },
+ { id: 'support', label: 'Support' },
+]
+
+export interface NavEntry {
+ id: string
+ title: string
+ labelKey?: string
+ description: string
+ path: string
+ module: ModuleKey
+ icon: LucideIcon
+ group: NavGroupId
+ keywords: string[]
+ kind: 'page' | 'action'
+ showInSidebar?: boolean
+}
+
+export const navigationRegistry: NavEntry[] = [
+ // ── Overview ──────────────────────────────────────────────────────────────
+ {
+ id: 'dashboard',
+ title: 'Dashboard',
+ labelKey: 'nav.dashboard',
+ description: 'Daily KPIs, alerts and branch activity',
+ path: '/',
+ module: 'dashboard',
+ icon: LayoutDashboard,
+ group: 'overview',
+ keywords: ['home', 'overview', 'kpi', 'summary', 'start', 'today'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'analytics',
+ title: 'Analytics',
+ labelKey: 'nav.analytics',
+ description: 'Trends, cohorts and exportable reports',
+ path: '/analytics',
+ module: 'analytics',
+ icon: BarChart3,
+ group: 'overview',
+ keywords: ['reports', 'charts', 'insights', 'metrics', 'bi', 'statistics', 'trends'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+
+ // ── Daily operations ──────────────────────────────────────────────────────
+ {
+ id: 'orders',
+ title: 'Orders',
+ labelKey: 'nav.orders',
+ description: 'Retail and B2B orders, fulfilment status',
+ path: '/orders',
+ module: 'orders',
+ icon: ShoppingBag,
+ group: 'operations',
+ keywords: ['sales', 'basket', 'cart', 'checkout', 'fulfilment', 'invoice', 'customer order'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'prescriptions',
+ title: 'Prescriptions',
+ labelKey: 'nav.prescriptions',
+ description: 'Uploaded Rx queue, verification and dispensing',
+ path: '/prescriptions',
+ module: 'prescriptions',
+ icon: FileHeart,
+ group: 'operations',
+ keywords: ['rx', 'script', 'doctor', 'dispense', 'verify', 'pharmacist', 'medication'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'dispatch',
+ title: 'Dispatch',
+ labelKey: 'nav.dispatch',
+ description: 'Rider assignment and live delivery board',
+ path: '/dispatch',
+ module: 'dispatch',
+ icon: Truck,
+ group: 'operations',
+ keywords: ['rider', 'delivery', 'trip', 'courier', 'logistics', 'route', 'driver', 'shipping'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'pos',
+ title: 'Scan & purchase',
+ labelKey: 'nav.pos',
+ description: 'Counter QR scan, quick purchase and invoice association',
+ path: '/pos',
+ module: 'pos',
+ icon: ScanLine,
+ group: 'operations',
+ keywords: ['pos', 'scan', 'qr', 'counter', 'till', 'barcode', 'ticket', 'invoice scan', 'loyalty code'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+
+ // ── Inventory & supply ────────────────────────────────────────────────────
+ {
+ id: 'items',
+ title: 'Medication items',
+ labelKey: 'nav.items',
+ description: 'Master product catalogue — identity, pricing and regulatory flags',
+ path: '/items',
+ module: 'stock',
+ icon: Pill,
+ group: 'inventory',
+ keywords: ['item', 'catalog', 'catalogue', 'product master', 'inn', 'generic', 'brand', 'efda', 'medication'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'stock',
+ title: 'Stock & ERP',
+ labelKey: 'nav.stock',
+ description: 'Inventory levels, batches, expiry and reorder points',
+ path: '/stock',
+ module: 'stock',
+ icon: Warehouse,
+ group: 'inventory',
+ keywords: ['inventory', 'sku', 'batch', 'expiry', 'warehouse', 'erp', 'products', 'medicines'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'procurement',
+ title: 'Procurement',
+ labelKey: 'nav.procurement',
+ description: 'Purchase requests, suppliers and approvals',
+ path: '/procurement',
+ module: 'procurement',
+ icon: PackageSearch,
+ group: 'inventory',
+ keywords: ['purchase', 'po', 'supplier', 'vendor', 'request', 'buying', 'reorder'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+
+ // ── Finance & accounts ────────────────────────────────────────────────────
+ {
+ id: 'finance',
+ title: 'Finance',
+ labelKey: 'nav.finance',
+ description: 'Revenue, settlements and payment gateways',
+ path: '/finance',
+ module: 'finance',
+ icon: Wallet,
+ group: 'finance',
+ keywords: ['revenue', 'payments', 'settlement', 'payout', 'reconciliation', 'accounting', 'cash'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'finance-gateways',
+ title: 'Payment Gateways',
+ description: 'Enable or disable Telebirr, CBE, cards and cash',
+ path: '/finance/gateways',
+ module: 'finance',
+ icon: CreditCard,
+ group: 'finance',
+ keywords: ['telebirr', 'cbe', 'chapa', 'card', 'psp', 'checkout', 'payment method', 'cash on delivery'],
+ kind: 'page',
+ },
+ {
+ id: 'organisations',
+ title: 'B2B Organisations',
+ labelKey: 'nav.organisations',
+ description: 'Corporate accounts, credit terms and approvals',
+ path: '/organisations',
+ module: 'organisations',
+ icon: Building2,
+ group: 'finance',
+ keywords: ['b2b', 'corporate', 'credit', 'company', 'institution', 'ngo', 'clinic', 'contract'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+
+ // ── Customers & growth ────────────────────────────────────────────────────
+ {
+ id: 'crm',
+ title: 'CRM',
+ labelKey: 'nav.crm',
+ description: 'Customer profiles, segments and lifetime value',
+ path: '/crm',
+ module: 'crm',
+ icon: Users,
+ group: 'growth',
+ keywords: ['customers', 'contacts', 'segment', 'patients', 'ltv', 'audience', 'people'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'marketing',
+ title: 'Marketing',
+ labelKey: 'nav.marketing',
+ description: 'Promotions, banners and channel performance',
+ path: '/marketing',
+ module: 'marketing',
+ icon: Megaphone,
+ group: 'growth',
+ keywords: ['promo', 'promotion', 'discount', 'banner', 'coupon', 'growth', 'ads'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'campaigns',
+ title: 'Campaigns',
+ labelKey: 'nav.campaigns',
+ description: 'SMS, Telegram and push broadcasts',
+ path: '/campaigns',
+ module: 'campaigns',
+ icon: Send,
+ group: 'growth',
+ keywords: ['sms', 'telegram', 'push', 'broadcast', 'blast', 'notification', 'email'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'loyalty',
+ title: 'Loyalty',
+ labelKey: 'nav.loyalty',
+ description: 'Points, tiers and reward redemptions',
+ path: '/loyalty',
+ module: 'loyalty',
+ icon: Gift,
+ group: 'growth',
+ keywords: ['points', 'rewards', 'tier', 'redeem', 'referral', 'membership'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ // ── Administration ────────────────────────────────────────────────────────
+ {
+ id: 'profile',
+ title: 'My profile',
+ labelKey: 'nav.profile',
+ description: 'Your account, sessions and notification preferences',
+ path: '/profile',
+ module: 'dashboard',
+ icon: UserCircle,
+ group: 'admin',
+ keywords: ['account', 'me', 'preferences', 'password', 'avatar', 'notifications', 'settings'],
+ kind: 'page',
+ },
+ {
+ id: 'team',
+ title: 'Team & Roles',
+ labelKey: 'nav.team',
+ description: 'Staff accounts, roles and branch assignment',
+ path: '/team',
+ module: 'team',
+ icon: Shield,
+ group: 'admin',
+ keywords: ['staff', 'users', 'roles', 'permissions', 'rbac', 'employees', 'access'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'audit',
+ title: 'Audit Log',
+ labelKey: 'nav.audit',
+ description: 'Who changed what, and when',
+ path: '/audit',
+ module: 'audit',
+ icon: ScrollText,
+ group: 'admin',
+ keywords: ['log', 'history', 'trail', 'activity', 'compliance', 'events', 'security'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'migrations',
+ title: 'Data Migration',
+ labelKey: 'nav.migrations',
+ description: 'Legacy import jobs and validation errors',
+ path: '/migrations',
+ module: 'migrations',
+ icon: Database,
+ group: 'admin',
+ keywords: ['import', 'csv', 'excel', 'upload', 'legacy', 'etl', 'jobs', 'sync'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+ {
+ id: 'settings',
+ title: 'Settings',
+ labelKey: 'nav.settings',
+ description: 'Branches, delivery zones and platform preferences',
+ path: '/settings',
+ module: 'settings',
+ icon: Settings,
+ group: 'admin',
+ keywords: ['configuration', 'preferences', 'branches', 'zones', 'options', 'admin', 'setup'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+
+ // ── Support ───────────────────────────────────────────────────────────────
+ {
+ id: 'faq',
+ title: 'Knowledge / FAQ',
+ labelKey: 'nav.faq',
+ description: 'Bilingual help articles for staff and customers',
+ path: '/faq',
+ module: 'faq',
+ icon: HelpCircle,
+ group: 'support',
+ keywords: ['help', 'knowledge base', 'articles', 'support', 'questions', 'docs', 'guide'],
+ kind: 'page',
+ showInSidebar: true,
+ },
+
+ // ── Actions (palette only) — create flows ─────────────────────────────────
+ {
+ id: 'action-scan-purchase',
+ title: 'Scan to purchase',
+ description: 'Open the counter QR scanner and start a quick purchase',
+ path: '/pos',
+ module: 'pos',
+ icon: ScanLine,
+ group: 'actions',
+ keywords: ['scan', 'qr', 'pos', 'counter', 'quick purchase', 'till'],
+ kind: 'action',
+ },
+ {
+ id: 'action-new-order',
+ title: 'New order',
+ description: 'Create an order for a walk-in, retail or B2B customer',
+ path: '/orders/new',
+ module: 'orders',
+ icon: ShoppingBag,
+ group: 'actions',
+ keywords: ['create order', 'add order', 'place order', 'sell', 'new sale'],
+ kind: 'action',
+ },
+ {
+ id: 'action-new-prescription',
+ title: 'New prescription',
+ description: 'Log an Rx and attach it to a patient',
+ path: '/prescriptions/new',
+ module: 'prescriptions',
+ icon: FileHeart,
+ group: 'actions',
+ keywords: ['create prescription', 'add rx', 'new rx', 'upload prescription'],
+ kind: 'action',
+ },
+ {
+ id: 'action-new-item',
+ title: 'New medication item',
+ description: 'Create a master catalogue product before receiving stock',
+ path: '/items/new',
+ module: 'stock',
+ icon: Pill,
+ group: 'actions',
+ keywords: ['create item', 'add product', 'new medicine', 'catalogue', 'master data'],
+ kind: 'action',
+ },
+ {
+ id: 'action-new-stock',
+ title: 'New stock line',
+ description: 'Receive a batch of an existing catalogue item at a branch',
+ path: '/stock/new',
+ module: 'stock',
+ icon: Warehouse,
+ group: 'actions',
+ keywords: ['create stock', 'add product', 'new sku', 'receive stock', 'batch'],
+ kind: 'action',
+ },
+ {
+ id: 'action-new-procurement',
+ title: 'New procurement request',
+ description: 'Raise a purchase request for a supplier',
+ path: '/procurement/new',
+ module: 'procurement',
+ icon: PackageSearch,
+ group: 'actions',
+ keywords: ['create purchase', 'add po', 'new request', 'restock', 'order from supplier'],
+ kind: 'action',
+ },
+ {
+ id: 'action-new-organisation',
+ title: 'New organisation',
+ description: 'Onboard a B2B account with credit terms',
+ path: '/organisations/new',
+ module: 'organisations',
+ icon: Building2,
+ group: 'actions',
+ keywords: ['create organisation', 'add company', 'new b2b', 'onboard corporate'],
+ kind: 'action',
+ },
+ {
+ id: 'action-new-team-member',
+ title: 'New team member',
+ description: 'Invite staff and assign a role',
+ path: '/team/new',
+ module: 'team',
+ icon: Shield,
+ group: 'actions',
+ keywords: ['create user', 'add staff', 'invite', 'new employee', 'new account'],
+ kind: 'action',
+ },
+]
+
+/** Top-level module pages, in render order, ungrouped. */
+export const sidebarEntries = navigationRegistry.filter((entry) => entry.showInSidebar)
+
+/** Everything the given role is allowed to reach. */
+export function accessibleEntries(role: StaffRole) {
+ return navigationRegistry.filter((entry) => canAccess(role, entry.module))
+}
+
+function haystack(entry: NavEntry) {
+ return [entry.title, entry.description, ...entry.keywords].join(' ').toLowerCase()
+}
+
+/**
+ * Substring search over title, description and keywords, permission-filtered.
+ * An empty query returns everything the role can reach so the caller can pick
+ * its own default set.
+ */
+export function searchNavigation(query: string, role: StaffRole) {
+ const entries = accessibleEntries(role)
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
+ if (terms.length === 0) return entries
+ return entries.filter((entry) => {
+ const text = haystack(entry)
+ return terms.every((term) => text.includes(term))
+ })
+}
+
+export interface NavSection {
+ group: NavGroupMeta
+ items: NavEntry[]
+}
+
+/** Buckets entries into sections in `navGroups` order, dropping empty ones. */
+export function groupEntries(entries: NavEntry[]): NavSection[] {
+ return navGroups
+ .map((group) => ({ group, items: entries.filter((entry) => entry.group === group.id) }))
+ .filter((section) => section.items.length > 0)
+}
+
+/**
+ * Grouped sidebar navigation for a role. Skips the palette-only `actions`
+ * group and drops any section the role cannot access a single child of, so a
+ * header never renders above an empty list.
+ */
+export function sidebarSections(role: StaffRole): NavSection[] {
+ return navGroups
+ .filter((group) => group.id !== 'actions')
+ .map((group) => ({
+ group,
+ items: navigationRegistry.filter(
+ (entry) => entry.showInSidebar && entry.group === group.id && canAccess(role, entry.module),
+ ),
+ }))
+ .filter((section) => section.items.length > 0)
+}
diff --git a/src/config/permissions.ts b/src/config/permissions.ts
index 508bc1b..408b07b 100644
--- a/src/config/permissions.ts
+++ b/src/config/permissions.ts
@@ -1,8 +1,10 @@
+import { portraitFor } from '@/lib/avatar'
import type { ModuleKey, StaffRole } from '@/types'
const ALL: ModuleKey[] = [
'dashboard',
'orders',
+ 'pos',
'prescriptions',
'stock',
'procurement',
@@ -14,7 +16,6 @@ const ALL: ModuleKey[] = [
'campaigns',
'dispatch',
'team',
- 'inbox',
'audit',
'faq',
'faq_write',
@@ -24,8 +25,8 @@ const ALL: ModuleKey[] = [
]
export const roleModules: Record = {
- pharmacist: ['dashboard', 'orders', 'prescriptions', 'stock', 'faq'],
- stock_manager: ['dashboard', 'stock', 'procurement', 'migrations', 'faq'],
+ pharmacist: ['dashboard', 'orders', 'pos', 'prescriptions', 'stock', 'faq'],
+ stock_manager: ['dashboard', 'pos', 'stock', 'procurement', 'migrations', 'faq'],
procurement: ['dashboard', 'procurement', 'stock', 'faq'],
finance: ['dashboard', 'finance', 'organisations', 'migrations', 'analytics', 'faq'],
marketing_manager: ['dashboard', 'marketing', 'crm', 'loyalty', 'campaigns', 'analytics', 'faq', 'faq_write'],
@@ -38,55 +39,73 @@ export function canAccess(role: StaffRole, module: ModuleKey) {
}
export const demoAccounts: {
+ id: string
email: string
password: string
role: StaffRole
name: string
branchId?: string
branchName?: string
+ /** Profile photo. A real uploaded URL drops in here unchanged. */
+ avatarUrl?: string
}[] = [
{
+ id: 'tm-pharmacist',
email: 'pharmacist@gishen.et',
password: 'demo123',
role: 'pharmacist',
name: 'Hana Pharmacist',
branchId: 'br-bole',
branchName: 'Bole Branch',
+ avatarUrl: portraitFor('Hana Pharmacist'),
},
{
+ id: 'tm-stock',
email: 'stock@gishen.et',
password: 'demo123',
role: 'stock_manager',
name: 'Dawit Stock',
+ avatarUrl: portraitFor('Dawit Stock'),
},
{
+ id: 'tm-procurement',
email: 'procurement@gishen.et',
password: 'demo123',
role: 'procurement',
name: 'Sara Procurement',
+ avatarUrl: portraitFor('Sara Procurement'),
},
{
+ id: 'tm-finance',
email: 'finance@gishen.et',
password: 'demo123',
role: 'finance',
name: 'Yonas Finance',
+ avatarUrl: portraitFor('Yonas Finance'),
},
{
+ id: 'tm-marketing',
email: 'marketing@gishen.et',
password: 'demo123',
role: 'marketing_manager',
name: 'Lidya Marketing',
+ avatarUrl: portraitFor('Lidya Marketing'),
},
{
+ id: 'tm-ops',
email: 'ops@gishen.et',
password: 'demo123',
role: 'operations',
name: 'Abel Operations',
+ avatarUrl: portraitFor('Abel Operations'),
},
{
+ id: 'tm-admin',
email: 'admin@gishen.et',
password: 'demo123',
role: 'super_admin',
+ // No photo on purpose — the platform owner account is a shared system
+ // identity, so it falls through to initials.
name: 'Super Admin',
},
]
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
index 0bb2ce6..689c01e 100644
--- a/src/i18n/config.ts
+++ b/src/i18n/config.ts
@@ -12,6 +12,9 @@ void i18n.use(initReactI18next).init({
},
lng: saved,
fallbackLng: 'en',
+ // Guards against a stale or hand-edited localStorage value setting lng to garbage
+ supportedLngs: ['en', 'am'],
+ nonExplicitSupportedLngs: true,
interpolation: { escapeValue: false },
})
diff --git a/src/i18n/i18next.d.ts b/src/i18n/i18next.d.ts
new file mode 100644
index 0000000..c20c0c4
--- /dev/null
+++ b/src/i18n/i18next.d.ts
@@ -0,0 +1,9 @@
+import 'i18next'
+import type en from '@/locales/en/translation.json'
+
+declare module 'i18next' {
+ interface CustomTypeOptions {
+ defaultNS: 'translation'
+ resources: { translation: typeof en }
+ }
+}
diff --git a/src/index.css b/src/index.css
index b8e211a..df1f054 100644
--- a/src/index.css
+++ b/src/index.css
@@ -4,6 +4,11 @@
@import "@fontsource/plus-jakarta-sans/700.css";
@import "@fontsource/sora/600.css";
@import "@fontsource/sora/700.css";
+/* Ethiopic (U+1200–137F) coverage for Amharic — neither brand font ships it */
+@import "@fontsource/noto-sans-ethiopic/400.css";
+@import "@fontsource/noto-sans-ethiopic/500.css";
+@import "@fontsource/noto-sans-ethiopic/600.css";
+@import "@fontsource/noto-sans-ethiopic/700.css";
@tailwind base;
@tailwind components;
diff --git a/src/layouts/CreatePageLayout.tsx b/src/layouts/CreatePageLayout.tsx
index 2f18f42..b580fa6 100644
--- a/src/layouts/CreatePageLayout.tsx
+++ b/src/layouts/CreatePageLayout.tsx
@@ -5,6 +5,17 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
+/**
+ * Shared field grid for create forms. Pages own the