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) => ( +
  1. + +

    {ev.title}

    + {ev.detail ?

    {ev.detail}

    : null} +

    + {new Date(ev.at).toLocaleString()} +

    +
  2. + ))} +
+ ) +} + +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 ( +
  1. +
    + + {step.state === 'done' ? ( + + {!isLast ? ( +
    +
    +

    {step.label}

    + {step.actor ? ( +

    {step.actor}

    + ) : null} + {step.at ? ( +

    {formatDateTime(step.at)}

    + ) : null} + {step.hint ?

    {step.hint}

    : null} +
    +
  2. + ) + })} +
+ ) +} 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({
)} -