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 <cursoragent@cursor.com>
This commit is contained in:
kirukib 2026-08-07 00:03:21 +03:00
parent 81731dc945
commit fce0149b1a
103 changed files with 23323 additions and 1331 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@ dist-ssr
*.sln
*.sw?
.vercel
.avcheck/
.env
.env.local
.env.*.local

View File

@ -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.

View File

@ -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`
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

351
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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() {
<Route path="/login" element={<LoginPage />} />
<Route element={<AppLayout />}>
<Route index element={<Guard module="dashboard"><DashboardPage /></Guard>} />
{/* Available to every signed-in role — not a permission-gated module. */}
<Route path="profile" element={<ProfilePage />} />
<Route path="pos" element={<Guard module="pos"><PosPage /></Guard>} />
<Route path="items" element={<Guard module="stock"><ItemsPage /></Guard>} />
<Route path="items/new" element={<Guard module="stock"><CreateItemPage /></Guard>} />
<Route path="items/:id" element={<Guard module="stock"><ItemDetailPage /></Guard>} />
<Route path="orders" element={<Guard module="orders"><OrdersPage /></Guard>} />
<Route path="orders/new" element={<Guard module="orders"><CreateOrderPage /></Guard>} />
<Route path="orders/:id" element={<Guard module="orders"><OrderDetailPage /></Guard>} />
<Route path="prescriptions" element={<Guard module="prescriptions"><PrescriptionsPage /></Guard>} />
<Route path="prescriptions/new" element={<Guard module="prescriptions"><CreatePrescriptionPage /></Guard>} />
<Route path="prescriptions/:id" element={<Guard module="prescriptions"><PrescriptionDetailPage /></Guard>} />
<Route path="stock" element={<Guard module="stock"><StockPage /></Guard>} />
<Route path="stock/new" element={<Guard module="stock"><CreateStockPage /></Guard>} />
<Route path="stock/:sku" element={<Guard module="stock"><StockDetailPage /></Guard>} />
<Route path="procurement" element={<Guard module="procurement"><ProcurementPage /></Guard>} />
<Route path="procurement/new" element={<Guard module="procurement"><CreateProcurementRequestPage /></Guard>} />
<Route path="procurement/:id" element={<Guard module="procurement"><ProcurementDetailPage /></Guard>} />
<Route path="finance" element={<Guard module="finance"><FinancePage /></Guard>} />
<Route path="finance/gateways" element={<Guard module="finance"><PaymentGatewaysPage /></Guard>} />
<Route path="finance/settlements/:id" element={<Guard module="finance"><SettlementDetailPage /></Guard>} />
<Route path="organisations" element={<Guard module="organisations"><OrganisationsPage /></Guard>} />
<Route path="organisations/new" element={<Guard module="organisations"><CreateOrganisationPage /></Guard>} />
<Route path="organisations/:id" element={<Guard module="organisations"><OrganisationDetailPage /></Guard>} />
<Route path="marketing" element={<Guard module="marketing"><MarketingPage /></Guard>} />
<Route path="marketing/:id" element={<Guard module="marketing"><SegmentDetailPage /></Guard>} />
<Route path="crm" element={<Guard module="crm"><CrmPage /></Guard>} />
<Route path="crm/:id" element={<Guard module="crm"><CustomerDetailPage /></Guard>} />
<Route path="loyalty" element={<Guard module="loyalty"><LoyaltyPage /></Guard>} />
<Route path="campaigns" element={<Guard module="campaigns"><CampaignsPage /></Guard>} />
<Route path="campaigns/:id" element={<Guard module="campaigns"><CampaignDetailPage /></Guard>} />
<Route path="dispatch" element={<Guard module="dispatch"><DispatchPage /></Guard>} />
<Route path="dispatch/riders/:id" element={<Guard module="dispatch"><RiderDetailPage /></Guard>} />
<Route path="analytics" element={<Guard module="analytics"><AnalyticsPage /></Guard>} />
<Route path="migrations" element={<Guard module="migrations"><MigrationsPage /></Guard>} />
<Route path="migrations/:id" element={<Guard module="migrations"><MigrationJobDetailPage /></Guard>} />
<Route path="faq" element={<Guard module="faq"><FaqPage /></Guard>} />
<Route path="faq/:id" element={<Guard module="faq"><FaqDetailPage /></Guard>} />
<Route path="team" element={<Guard module="team"><TeamPage /></Guard>} />
<Route path="team/new" element={<Guard module="team"><CreateTeamMemberPage /></Guard>} />
<Route path="inbox" element={<Guard module="inbox"><InboxPage /></Guard>} />
<Route path="team/:id" element={<Guard module="team"><TeamMemberDetailPage /></Guard>} />
<Route path="audit" element={<Guard module="audit"><AuditPage /></Guard>} />
<Route path="audit/:id" element={<Guard module="audit"><AuditEventDetailPage /></Guard>} />
<Route path="settings" element={<Guard module="settings"><SettingsPage /></Guard>} />
<Route path="settings/branches/:id" element={<Guard module="settings"><BranchDetailPage /></Guard>} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -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 (
<Card className={cn('overflow-hidden', className)}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div>
<CardTitle className="text-base">{title}</CardTitle>
{description ? <CardDescription className="mt-1">{description}</CardDescription> : null}
</div>
{action}
</CardHeader>
<CardContent className={cn(height, 'pt-2')}>{children}</CardContent>
</Card>
)
}

View File

@ -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),
)
}

View File

@ -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 (
<Card>
const card = (
<Card className={cn('h-full', to ? 'transition-shadow hover:shadow-soft' : undefined)}>
<CardContent className="flex items-start gap-4 p-5">
<div className={cn('flex h-11 w-11 items-center justify-center rounded-xl', tones[tone])}>
<Icon className="h-5 w-5" />
@ -35,4 +39,15 @@ export function StatCard({
</CardContent>
</Card>
)
if (!to) return card
return (
<Link
to={to}
className="block rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2"
>
{card}
</Link>
)
}

View File

@ -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<NonNullable<TimelineEvent['tone']>, 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 <p className="text-sm text-muted-foreground">{empty}</p>
}
return (
<ol className={cn('relative space-y-0 border-l border-border pl-4', className)}>
{events.map((ev) => (
<li key={ev.id} className="relative pb-5 last:pb-0">
<span
className={cn(
'absolute -left-[1.3rem] top-1 h-2.5 w-2.5 rounded-full ring-4 ring-background',
toneDot[ev.tone ?? 'default'],
)}
/>
<p className="text-sm font-medium text-foreground">{ev.title}</p>
{ev.detail ? <p className="mt-0.5 text-xs text-muted-foreground">{ev.detail}</p> : null}
<p className="mt-1 text-[11px] text-muted-foreground">
{new Date(ev.at).toLocaleString()}
</p>
</li>
))}
</ol>
)
}
export function DetailSection({
title,
description,
action,
children,
className,
}: {
title: string
description?: string
action?: ReactNode
children: ReactNode
className?: string
}) {
return (
<section className={cn('rounded-2xl border bg-card shadow-soft', className)}>
<div className="flex items-start justify-between gap-3 border-b px-5 py-4">
<div>
<h2 className="font-display text-base font-semibold text-forest">{title}</h2>
{description ? <p className="mt-0.5 text-xs text-muted-foreground">{description}</p> : null}
</div>
{action}
</div>
<div className="p-5">{children}</div>
</section>
)
}

View File

@ -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<ApprovalStepState, string> = {
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<ApprovalStepState, string> = {
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 (
<ol className={cn('flex flex-col gap-4 sm:flex-row sm:gap-0', className)}>
{steps.map((step, index) => {
const isLast = index === steps.length - 1
const connectorDone = step.state === 'done'
return (
<li key={step.key} className="flex min-w-0 gap-3 sm:flex-1 sm:flex-col sm:gap-0">
<div className="flex flex-col items-center sm:w-full sm:flex-row">
<span
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border-2 text-[11px] font-semibold',
markerClasses[step.state],
)}
>
{step.state === 'done' ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : step.state === 'failed' ? (
<X className="h-3.5 w-3.5" aria-hidden="true" />
) : (
index + 1
)}
</span>
{!isLast ? (
<span
className={cn(
'my-1 w-0.5 flex-1 rounded-full sm:my-0 sm:ml-2 sm:h-0.5 sm:w-auto',
connectorDone ? 'bg-brand-300' : 'bg-grayScale-200',
)}
aria-hidden="true"
/>
) : null}
</div>
<div className={cn('min-w-0 pb-1 sm:mt-3 sm:pr-4', isLast && 'sm:pr-0')}>
<p className={cn('text-sm font-semibold', labelClasses[step.state])}>{step.label}</p>
{step.actor ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{step.actor}</p>
) : null}
{step.at ? (
<p className="mt-0.5 text-[11px] text-muted-foreground">{formatDateTime(step.at)}</p>
) : null}
{step.hint ? <p className="mt-0.5 text-[11px] text-grayScale-400">{step.hint}</p> : null}
</div>
</li>
)
})}
</ol>
)
}

View File

@ -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 (
<dl
className={cn(
'grid gap-4',
columns === 1 && 'grid-cols-1',
columns === 2 && 'sm:grid-cols-2',
columns === 3 && 'sm:grid-cols-2 lg:grid-cols-3',
className,
)}
>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{item.label}</dt>
<dd className="text-sm font-medium text-foreground">{item.value ?? '—'}</dd>
</div>
))}
</dl>
)
}

View File

@ -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<NavGroupId[]>(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 (
<NavLink
key={item.id}
to={item.path}
end={item.path === '/'}
onClick={onClose}
title={isCollapsed ? label : undefined}
className={({ isActive }) =>
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 }) => (
<>
<span
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
isActive ? 'bg-brand-500/20 text-forest' : 'bg-muted text-muted-foreground',
)}
>
<Icon className="h-4 w-4" />
</span>
{!isCollapsed && <span className="truncate">{label}</span>}
{isActive && !isCollapsed && <span className="ml-auto h-5 w-1 rounded-full bg-brand-500" />}
</>
)}
</NavLink>
)
}
return (
<>
@ -91,42 +139,45 @@ export function Sidebar({
</div>
)}
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{items.map((item) => {
const Icon = item.icon
<nav aria-label="Main navigation" className="flex-1 space-y-4 overflow-y-auto p-3">
{sections.map((section) => {
const groupCollapsed = collapsedGroups.includes(section.group.id)
const listId = `nav-group-${section.group.id}`
// Mini rail: icons only, sections separated by whitespace alone.
if (isCollapsed) {
return (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={onClose}
className={({ isActive }) =>
cn(
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors',
isActive
? 'bg-brand-100/70 text-forest'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
isCollapsed && 'justify-center px-0',
<div key={section.group.id} className="space-y-1">
{section.items.map(renderItem)}
</div>
)
}
return (
<div key={section.group.id}>
<button
type="button"
onClick={() => toggleGroup(section.group.id)}
aria-expanded={!groupCollapsed}
aria-controls={listId}
className="mb-1 flex w-full items-center gap-1.5 rounded-lg px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-grayScale-400 transition-colors hover:text-grayScale-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{({ isActive }) => (
<>
<span
<span className="truncate">{section.group.label}</span>
{groupCollapsed && section.group.id === activeGroupId && (
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand-500" />
)}
<ChevronDown
aria-hidden
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg',
isActive ? 'bg-brand-500/20 text-forest' : 'bg-muted text-muted-foreground',
'ml-auto h-3.5 w-3.5 shrink-0 transition-transform duration-200',
groupCollapsed && '-rotate-90',
)}
>
<Icon className="h-4 w-4" />
</span>
{!isCollapsed && <span className="truncate">{t(item.labelKey)}</span>}
{isActive && !isCollapsed && (
<span className="ml-auto h-5 w-1 rounded-full bg-brand-500" />
)}
</>
)}
</NavLink>
/>
</button>
<div id={listId} className={cn('space-y-1', groupCollapsed && 'hidden')}>
{section.items.map(renderItem)}
</div>
</div>
)
})}
</nav>

View File

@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { MoreHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@ -11,7 +12,9 @@ import { cn } from '@/lib/utils'
export type RowAction = {
label: string
onClick: () => void
/** Navigation target; takes precedence over onClick */
to?: string
onClick?: () => void
icon?: ReactNode
variant?: 'default' | 'destructive'
disabled?: boolean
@ -30,7 +33,25 @@ export function RowActions({
const overflow = items ?? []
return (
<div className={cn('flex items-center justify-end gap-1', className)}>
{primary?.map((action) => (
{primary?.map((action) => {
const content = (
<>
{action.icon}
<span className="hidden xl:inline">{action.label}</span>
<span className="xl:hidden">{action.icon ? null : action.label}</span>
</>
)
return action.to && !action.disabled ? (
<Button
key={action.label}
asChild
size="sm"
variant={action.variant === 'destructive' ? 'destructive' : 'outline'}
className="h-9 gap-1.5 px-3 shadow-none"
>
<Link to={action.to}>{content}</Link>
</Button>
) : (
<Button
key={action.label}
type="button"
@ -40,11 +61,10 @@ export function RowActions({
disabled={action.disabled}
onClick={action.onClick}
>
{action.icon}
<span className="hidden xl:inline">{action.label}</span>
<span className="xl:hidden">{action.icon ? null : action.label}</span>
{content}
</Button>
))}
)
})}
{overflow.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -59,7 +79,15 @@ export function RowActions({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[11rem]">
{overflow.map((action) => (
{overflow.map((action) =>
action.to && !action.disabled ? (
<DropdownMenuItem key={action.label} asChild className="gap-2">
<Link to={action.to}>
{action.icon}
{action.label}
</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem
key={action.label}
disabled={action.disabled}
@ -72,7 +100,8 @@ export function RowActions({
{action.icon}
{action.label}
</DropdownMenuItem>
))}
),
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}

View File

@ -0,0 +1,264 @@
import { useId, useState } from 'react'
import { SlidersHorizontal, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { cn } from '@/lib/utils'
/** Option value that every filter uses to mean "not filtering on this". */
export const NEUTRAL_FILTER_VALUE = 'all'
/** More than this many filters and the toolbar collapses them into the modal. */
export const MAX_INLINE_FILTERS = 3
export type TableFilterOption = { value: string; label: string }
/**
* Declarative filter definition. The same object drives the inline toolbar
* select and the stacked modal field, so a page never describes a filter twice.
*/
export type TableFilter = {
/** Stable key, unique within one toolbar. */
id: string
/** Inline this is `aria-label`/`title` only; inside the modal it is visible. */
label: string
value: string
onChange: (value: string) => void
options: TableFilterOption[]
/** Value that counts as "not filtering". Defaults to `all`. */
neutralValue?: string
className?: string
}
function isTableFilter(value: unknown): value is TableFilter {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<TableFilter>
return (
typeof candidate.id === 'string' &&
typeof candidate.label === 'string' &&
typeof candidate.value === 'string' &&
typeof candidate.onChange === 'function' &&
Array.isArray(candidate.options)
)
}
/**
* Distinguishes the declarative `filters` array from the legacy `ReactNode`
* children API — `ReactNode` can itself be an array, so check the shape.
*/
export function isTableFilterList(value: unknown): value is TableFilter[] {
return Array.isArray(value) && value.every(isTableFilter)
}
function neutralValueOf(filter: TableFilter) {
return filter.neutralValue ?? NEUTRAL_FILTER_VALUE
}
export function isFilterActive(filter: TableFilter) {
return filter.value !== neutralValueOf(filter)
}
export function activeTableFilters(filters: TableFilter[]) {
return filters.filter(isFilterActive)
}
function optionLabel(filter: TableFilter, value: string) {
return filter.options.find((o) => o.value === value)?.label ?? value
}
export type FilterModalLabels = {
trigger?: string
title?: string
description?: string
apply?: string
cancel?: string
reset?: string
}
/**
* Collapsed filter entry point: a compact trigger carrying the active count,
* plus a dialog whose edits are staged and only committed on Apply.
*/
export function TableFilterModal({
filters,
labels,
className,
}: {
filters: TableFilter[]
labels?: FilterModalLabels
className?: string
}) {
const [open, setOpen] = useState(false)
// Bumped on every open so the draft form remounts with a fresh snapshot,
// which is what makes a cancelled edit impossible to resurrect.
const [openCount, setOpenCount] = useState(0)
const activeCount = activeTableFilters(filters).length
const triggerLabel = labels?.trigger ?? 'Filters'
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (next) setOpenCount((count) => count + 1)
setOpen(next)
}}
>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
className={cn('h-10 gap-2 shadow-none', className)}
aria-label={activeCount ? `${triggerLabel} (${activeCount} active)` : triggerLabel}
>
<SlidersHorizontal className="h-4 w-4" />
{triggerLabel}
{activeCount ? (
<span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-brand-500 px-1.5 text-xs font-semibold text-forest">
{activeCount}
</span>
) : null}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] w-[calc(100vw-2rem)] max-w-lg overflow-y-auto">
<DialogHeader className="pr-8">
<DialogTitle>{labels?.title ?? triggerLabel}</DialogTitle>
<DialogDescription>
{labels?.description ?? 'Set the filters you need, then apply them to the table.'}
</DialogDescription>
</DialogHeader>
<FilterModalForm
key={openCount}
filters={filters}
labels={labels}
onClose={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
)
}
/**
* Mounted only while the dialog is open and keyed on the open count, so its
* draft always starts from the committed values — no stale draft after Cancel.
*/
function FilterModalForm({
filters,
labels,
onClose,
}: {
filters: TableFilter[]
labels?: FilterModalLabels
onClose: () => void
}) {
const fieldId = useId()
const [draft, setDraft] = useState<Record<string, string>>(() =>
Object.fromEntries(filters.map((f) => [f.id, f.value])),
)
const valueOf = (filter: TableFilter) => draft[filter.id] ?? filter.value
const draftHasActive = filters.some((f) => valueOf(f) !== neutralValueOf(f))
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
for (const filter of filters) {
const next = valueOf(filter)
if (next !== filter.value) filter.onChange(next)
}
onClose()
}}
>
<div className="space-y-3">
{filters.map((filter) => (
<div key={filter.id} className="space-y-1.5">
<Label htmlFor={`${fieldId}-${filter.id}`}>{filter.label}</Label>
<SelectNative
id={`${fieldId}-${filter.id}`}
className="h-10"
value={valueOf(filter)}
onChange={(e) => setDraft((prev) => ({ ...prev, [filter.id]: e.target.value }))}
>
{filter.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</SelectNative>
</div>
))}
</div>
<div className="flex flex-col-reverse gap-2 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<Button
type="button"
variant="ghost"
className="h-10 text-muted-foreground"
disabled={!draftHasActive}
onClick={() =>
setDraft(Object.fromEntries(filters.map((f) => [f.id, neutralValueOf(f)])))
}
>
{labels?.reset ?? 'Reset'}
</Button>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" className="h-10" onClick={onClose}>
{labels?.cancel ?? 'Cancel'}
</Button>
<Button type="submit" className="h-10">
{labels?.apply ?? 'Apply filters'}
</Button>
</div>
</div>
</form>
)
}
/**
* Keeps hidden filters honest: every active filter stays visible outside the
* modal and can be removed without opening it.
*/
export function ActiveFilterChips({
filters,
removeLabel = 'Remove filter',
className,
}: {
filters: TableFilter[]
removeLabel?: string
className?: string
}) {
const active = activeTableFilters(filters)
if (active.length === 0) return null
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
{active.map((filter) => (
<span
key={filter.id}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-brand-200 bg-brand-50 py-0.5 pl-2.5 pr-1 text-xs font-medium text-brand-700"
>
<span className="truncate">
{filter.label}: {optionLabel(filter, filter.value)}
</span>
<button
type="button"
className="rounded-full p-0.5 text-brand-700/70 hover:bg-brand-100 hover:text-brand-700"
aria-label={`${removeLabel}: ${filter.label}`}
onClick={() => filter.onChange(neutralValueOf(filter))}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)
}

View File

@ -4,8 +4,27 @@ import { Input } from '@/components/ui/input'
import { SelectNative } from '@/components/ui/select-native'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
ActiveFilterChips,
MAX_INLINE_FILTERS,
TableFilterModal,
activeTableFilters,
isTableFilterList,
} from '@/components/table/TableFilterModal'
import type { FilterModalLabels, TableFilter } from '@/components/table/TableFilterModal'
import { cn } from '@/lib/utils'
export type { TableFilter, TableFilterOption } from '@/components/table/TableFilterModal'
export { MAX_INLINE_FILTERS, NEUTRAL_FILTER_VALUE } from '@/components/table/TableFilterModal'
/**
* Shared table toolbar.
*
* `filters` takes either a declarative `TableFilter[]` (preferred) or arbitrary
* nodes (the original API, still supported so pages can migrate one at a time).
* With the array form the toolbar knows how many filters there are, so it can
* keep a small set inline and collapse a dense set into a modal.
*/
export function TableToolbar({
search,
onSearchChange,
@ -13,16 +32,33 @@ export function TableToolbar({
filters,
actions,
resultCount,
onClearFilters,
clearVisible,
collapseAfter = MAX_INLINE_FILTERS,
filterLabels,
className,
}: {
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
filters?: ReactNode
filters?: ReactNode | TableFilter[]
actions?: ReactNode
resultCount?: number
/** Declarative API only: resets every filter and the search box. */
onClearFilters?: () => void
/** Override for when a page tracks filter state the toolbar cannot see. */
clearVisible?: boolean
/** More than this many filters collapses them into the modal. */
collapseAfter?: number
filterLabels?: FilterModalLabels & { clear?: string; removeChip?: string }
className?: string
}) {
const definitions = isTableFilterList(filters) ? filters : null
const collapsed = definitions !== null && definitions.length > collapseAfter
const activeCount = definitions ? activeTableFilters(definitions).length : 0
const showClear =
onClearFilters !== undefined && (clearVisible ?? (activeCount > 0 || !!search))
return (
<div className={cn('space-y-3 border-b bg-muted/30 px-4 py-4', className)}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
@ -48,7 +84,33 @@ export function TableToolbar({
) : null}
</div>
) : null}
{filters ? <div className="flex flex-wrap items-center gap-2">{filters}</div> : null}
{definitions ? (
<div className="flex flex-wrap items-center gap-2">
{collapsed ? (
<TableFilterModal filters={definitions} labels={filterLabels} />
) : (
definitions.map((filter) => (
<FilterSelect
key={filter.id}
label={filter.label}
value={filter.value}
onChange={filter.onChange}
options={filter.options}
className={filter.className}
/>
))
)}
{onClearFilters ? (
<ClearFiltersButton
visible={showClear}
onClick={onClearFilters}
label={filterLabels?.clear}
/>
) : null}
</div>
) : filters ? (
<div className="flex flex-wrap items-center gap-2">{filters as ReactNode}</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{typeof resultCount === 'number' ? (
@ -59,10 +121,17 @@ export function TableToolbar({
{actions}
</div>
</div>
{collapsed && definitions ? (
<ActiveFilterChips filters={definitions} removeLabel={filterLabels?.removeChip} />
) : null}
</div>
)
}
/**
* Filter dropdown with no visible label — the default option text carries the
* meaning instead, so `label` is used only for assistive tech and hover.
*/
export function FilterSelect({
label,
value,
@ -77,13 +146,12 @@ export function FilterSelect({
className?: string
}) {
return (
<div className={cn('flex items-center gap-1.5', className)}>
<span className="hidden text-xs font-medium text-muted-foreground sm:inline">{label}</span>
<SelectNative
className="h-10 w-auto min-w-[9rem] bg-background"
className={cn('h-10 w-full bg-background sm:w-[11.5rem]', className)}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={label}
title={label}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
@ -91,15 +159,22 @@ export function FilterSelect({
</option>
))}
</SelectNative>
</div>
)
}
export function ClearFiltersButton({ onClick, visible }: { onClick: () => void; visible: boolean }) {
export function ClearFiltersButton({
onClick,
visible,
label = 'Clear filters',
}: {
onClick: () => void
visible: boolean
label?: string
}) {
if (!visible) return null
return (
<Button type="button" variant="ghost" size="sm" className="h-10 text-muted-foreground" onClick={onClick}>
Clear filters
{label}
</Button>
)
}

View File

@ -0,0 +1,172 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Search } from 'lucide-react'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@/components/ui/command'
import { Button } from '@/components/ui/button'
import {
accessibleEntries,
groupEntries,
searchNavigation,
type NavEntry,
} from '@/config/navigation'
import { useAuthStore } from '@/zustand/authStore'
import { cn } from '@/lib/utils'
const RECENTS_KEY = 'gishen_palette_recents'
const MAX_RECENTS = 4
function readRecents(): string[] {
try {
const raw = localStorage.getItem(RECENTS_KEY)
const parsed = raw ? (JSON.parse(raw) as unknown) : null
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch {
return []
}
}
function isMacPlatform() {
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
}
export function CommandPalette() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [recentIds, setRecentIds] = useState<string[]>(() => readRecents())
const navigate = useNavigate()
const { t } = useTranslation()
const role = useAuthStore((s) => s.user?.role) ?? 'pharmacist'
const isMac = useMemo(isMacPlatform, [])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen((prev) => !prev)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const results = useMemo(() => searchNavigation(query, role), [query, role])
const sections = useMemo(() => groupEntries(results), [results])
const accessibleCount = useMemo(() => accessibleEntries(role).length, [role])
const recents = useMemo(() => {
if (query.trim()) return []
return recentIds
.map((id) => results.find((entry) => entry.id === id))
.filter((entry): entry is NavEntry => Boolean(entry))
}, [query, recentIds, results])
const onSelect = useCallback(
(entry: NavEntry) => {
setOpen(false)
const next = [entry.id, ...recentIds.filter((id) => id !== entry.id)].slice(0, MAX_RECENTS)
setRecentIds(next)
try {
localStorage.setItem(RECENTS_KEY, JSON.stringify(next))
} catch {
// Recents are a convenience only; ignore storage failures.
}
navigate(entry.path)
},
[navigate, recentIds],
)
function handleOpenChange(next: boolean) {
setOpen(next)
if (!next) setQuery('')
}
const hint = isMac ? '⌘K' : 'Ctrl K'
function renderItem(entry: NavEntry, valuePrefix = '') {
const Icon = entry.icon
return (
<CommandItem
key={`${valuePrefix}${entry.id}`}
value={`${valuePrefix}${entry.id}`}
onSelect={() => onSelect(entry)}
>
<span
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
entry.kind === 'action' ? 'bg-brand-500/20 text-forest' : 'bg-muted text-muted-foreground',
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-forest">
{entry.labelKey ? t(entry.labelKey, { defaultValue: entry.title }) : entry.title}
</span>
<span className="block truncate text-xs text-muted-foreground">{entry.description}</span>
</span>
</CommandItem>
)
}
return (
<>
{/* Compact input-styled trigger on desktop, icon-only on small screens. */}
<Button
variant="outline"
size="sm"
aria-label={`${t('common.search', { defaultValue: 'Search' })} — ${hint}`}
aria-keyshortcuts="Meta+K Control+K"
onClick={() => setOpen(true)}
className="text-muted-foreground md:w-60 md:justify-start md:font-normal lg:w-72"
>
<Search className="h-4 w-4 shrink-0" />
<span className="hidden md:inline">{t('common.search', { defaultValue: 'Search' })}…</span>
<kbd className="ml-auto hidden rounded border bg-grayScale-100 px-1.5 py-0.5 font-sans text-[10px] font-medium text-muted-foreground md:inline-block">
{hint}
</kbd>
</Button>
<CommandDialog open={open} onOpenChange={handleOpenChange} shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Search pages, modules and actions…"
/>
<CommandList>
<CommandEmpty>
No matches for “{query}”. Try a module name like orders, stock or dispatch.
</CommandEmpty>
{recents.length > 0 && (
<CommandGroup heading="Recent">
{recents.map((entry) => renderItem(entry, 'recent:'))}
</CommandGroup>
)}
{sections.map((section) => (
<CommandGroup key={section.group.id} heading={section.group.label}>
{section.items.map((entry) => renderItem(entry))}
</CommandGroup>
))}
</CommandList>
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-[11px] text-muted-foreground">
<span>
{results.length} of {accessibleCount} available
</span>
<CommandShortcut className="ml-0 tracking-normal">
↑↓ move · ↵ open · esc close
</CommandShortcut>
</div>
</CommandDialog>
</>
)
}

View File

@ -5,10 +5,13 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PersonAvatar } from '@/components/ui/avatar'
import { useAuthStore } from '@/zustand/authStore'
import { useNavigate } from 'react-router-dom'
import { CommandPalette } from '@/components/topbar/CommandPalette'
export function Topbar({ onSidebarToggle }: { onSidebarToggle: () => void }) {
const { t, i18n } = useTranslation()
@ -29,6 +32,9 @@ export function Topbar({ onSidebarToggle }: { onSidebarToggle: () => void }) {
{user?.role.replaceAll('_', ' ')}
</p>
</div>
<div className="ml-1 sm:ml-3">
<CommandPalette />
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
@ -45,11 +51,21 @@ export function Topbar({ onSidebarToggle }: { onSidebarToggle: () => void }) {
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" className="h-10 gap-2 px-2">
{user ? (
<PersonAvatar name={user.name} avatarUrl={user.avatarUrl} size="sm" />
) : (
<User className="h-5 w-5" />
)}
<span className="sr-only">Account menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 h-4 w-4" />
My profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
logout()

View File

@ -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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
export const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full object-cover', className)} {...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
export const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-brand-100 font-semibold text-brand-700',
className,
)}
{...props}
/>
))
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 (
<Avatar className={cn(avatarSizes[size], className)} title={title}>
{src ? <AvatarImage src={src} alt={name} /> : null}
<AvatarFallback delayMs={src ? 300 : 0}>{getInitials(name)}</AvatarFallback>
</Avatar>
)
}
/** 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<AvatarSize, string> = {
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 = <InitialsAvatar name={name} src={src} size={size} className={className} title={title} />
if (!presence) return avatar
return (
<span className="relative inline-flex shrink-0">
{avatar}
<span
className={cn(
'absolute bottom-0 right-0 rounded-full ring-2 ring-background',
presenceSizes[size],
presenceTone[presence],
)}
/>
{presenceLabel ? <span className="sr-only">{presenceLabel}</span> : null}
</span>
)
}
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 <span className="text-sm text-muted-foreground">—</span>
return (
<span className={cn('flex items-center', className)}>
{shown.map((person, index) => (
<PersonAvatar
key={person.id ?? person.name}
name={person.name}
avatarUrl={person.avatarUrl}
seeded={seeded}
size={size}
title={person.name}
className={cn('ring-2 ring-background', index > 0 && '-ml-2')}
/>
))}
{overflow.length > 0 ? (
<span
title={overflow.map((person) => 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}
</span>
) : null}
<span className="sr-only">{people.map((person) => person.name).join(', ')}</span>
</span>
)
}

View File

@ -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<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-2xl bg-popover text-popover-foreground',
className,
)}
{...props}
/>
))
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 (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-forest/40 backdrop-blur-sm" />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-4 z-50 w-[calc(100vw-2rem)] max-w-xl -translate-x-1/2 overflow-hidden rounded-2xl border bg-popover shadow-soft sm:top-[12vh]',
className,
)}
>
<DialogPrimitive.Title className="sr-only">{label}</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Type to search, use the arrow keys to move and Enter to open.
</DialogPrimitive.Description>
<Command
loop
shouldFilter={shouldFilter}
className="[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-item]]:px-3 [&_[cmdk-item]]:py-2.5"
>
{children}
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
export const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center gap-2 border-b px-4" cmdk-input-wrapper="">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-12 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
export const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[min(60vh,420px)] overflow-y-auto overflow-x-hidden py-2', className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
export const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Empty
ref={ref}
className={cn('px-4 py-10 text-center text-sm text-muted-foreground', className)}
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
export const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn('overflow-hidden py-1 text-foreground', className)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
export const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
export const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-3 rounded-xl text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-brand-100/70 data-[selected=true]:text-forest data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
export function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn('ml-auto text-[11px] tracking-widest text-muted-foreground', className)}
{...props}
/>
)
}

View File

@ -36,3 +36,15 @@ export const DropdownMenuItem = React.forwardRef<
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
export const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName

View File

@ -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<EmptyStateVariant, string> = {
inline: 'py-8',
dashed: 'rounded-xl border border-dashed border-border py-10',
page: 'py-14',
}
const sizeSpacing: Record<EmptyStateSize, string> = {
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 (
<div
className={cn(
'flex flex-col items-center justify-center text-center',
containerClasses[variant],
sizeSpacing[size],
className,
)}
>
{Icon ? (
isPage ? (
<div className="mb-2 flex h-16 w-16 items-center justify-center rounded-full bg-brand-50">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-brand-100">
<Icon className="h-5 w-5 text-brand-700" aria-hidden="true" />
</div>
</div>
) : (
<Icon className="mb-1 h-6 w-6 text-grayScale-400" aria-hidden="true" />
)
) : null}
<p
className={cn(
isPage ? 'font-display text-lg font-semibold text-foreground' : 'text-sm font-semibold text-foreground',
)}
>
{title}
</p>
{description ? (
<p className={cn('max-w-sm text-sm text-muted-foreground', isPage && 'max-w-md')}>{description}</p>
) : null}
{action ? <div className={cn('mt-2', isPage && 'mt-3')}>{action}</div> : null}
</div>
)
}

View File

@ -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<IconTone, string> = {
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 (
<div
className={cn(
'inline-flex shrink-0 items-center justify-center ring-1 ring-inset',
sizeClasses[size],
gradient ? 'bg-gradient-to-br from-brand-500 to-orange text-white ring-transparent' : toneClasses[tone],
className,
)}
>
<Icon className={iconSizes[size]} aria-hidden="true" />
</div>
)
}

View File

@ -0,0 +1,45 @@
import { cn } from '@/lib/utils'
export type ProgressTone = 'brand' | 'orange' | 'danger' | 'forest'
const fillClasses: Record<ProgressTone, string> = {
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 (
<div
role="progressbar"
aria-valuenow={Math.round(safe)}
aria-valuemin={0}
aria-valuemax={100}
className={cn(
'w-full overflow-hidden rounded-full bg-grayScale-200',
size === 'sm' ? 'h-1.5' : 'h-2',
className,
)}
>
<div
className={cn('h-full rounded-full transition-all', fillClasses[resolvedTone])}
style={{ width: `${safe}%` }}
/>
</div>
)
}

View File

@ -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<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName

View File

@ -0,0 +1,5 @@
import { cn } from '@/lib/utils'
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-xl bg-grayScale-200/70', className)} />
}

View File

@ -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<StaffRole, StatusEntry> = {
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<StatusDomain, Record<string, StatusEntry>> = {
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 (
<Badge variant={variant} className={cn('gap-1.5', className)}>
{DOT_VARIANTS.includes(variant) ? (
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
) : null}
{statusLabel(domain, status)}
</Badge>
)
}

557
src/config/navigation.ts Normal file
View File

@ -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)
}

View File

@ -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<StaffRole, ModuleKey[]> = {
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',
},
]

View File

@ -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 },
})

9
src/i18n/i18next.d.ts vendored Normal file
View File

@ -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 }
}
}

View File

@ -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;

View File

@ -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 <form> element (its id is
* referenced by the footer submit buttons), so the grid lives here as a class
* string instead of a wrapper to avoid nesting a grid inside a grid.
*/
export const createFormGrid = 'grid gap-5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'
/** Field that reads better at double width (names, addresses). */
export const createFieldWide = 'md:col-span-2'
/** Field that should span every column (textareas, checkbox rows). */
export const createFieldFull = 'md:col-span-2 xl:col-span-3 2xl:col-span-4'
export function CreatePageLayout({
title,
subtitle,
@ -23,7 +34,7 @@ export function CreatePageLayout({
className?: string
}) {
return (
<div className={cn('mx-auto max-w-3xl space-y-5', className)}>
<div className={cn('w-full min-w-0 space-y-5', className)}>
<div>
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-3 text-muted-foreground">
<Link to={backTo}>

View File

@ -0,0 +1,59 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export function DetailPageLayout({
backTo,
backLabel = 'Back to list',
title,
subtitle,
badges,
actions,
children,
sidebar,
className,
}: {
backTo: string
backLabel?: string
title: string
subtitle?: string
badges?: ReactNode
actions?: ReactNode
children: ReactNode
sidebar?: ReactNode
className?: string
}) {
return (
<div className={cn('w-full min-w-0 space-y-5', className)}>
<div>
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-3 text-muted-foreground">
<Link to={backTo}>
<ArrowLeft className="h-4 w-4" />
{backLabel}
</Link>
</Button>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 h-1 w-12 rounded-full bg-gradient-to-r from-brand-500 to-orange" />
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-display text-2xl font-bold tracking-tight text-forest">{title}</h1>
{badges}
</div>
{subtitle ? <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
</div>
{sidebar ? (
<div className="grid gap-5 [&>*]:min-w-0 lg:grid-cols-[minmax(0,1fr)_22rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
{children}
{sidebar}
</div>
) : (
children
)}
</div>
)
}

View File

@ -15,7 +15,7 @@ export function PageShell({
className?: string
}) {
return (
<div className={cn('mx-auto max-w-7xl space-y-5', className)}>
<div className={cn('w-full min-w-0 space-y-5', className)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-2 h-1 w-12 rounded-full bg-gradient-to-r from-brand-500 to-orange" />

146
src/lib/avatar.ts Normal file
View File

@ -0,0 +1,146 @@
/**
* Portrait imagery for every person in the console.
*
* The console is a self-contained demo with no backend and no upload, and it has
* to survive being shown with the network switched off, so a remote placeholder
* service is not an option — it would leave a wall of broken images. Portraits
* are instead drawn locally as inline SVG data URIs: deterministic, so a person
* keeps the same face on every screen, and restricted to the brand palette, so
* the set reads as one illustration family rather than assorted stock photos.
*
* `avatarUrl` on the person mocks holds exactly what a real uploaded photo URL
* would hold, so replacing `portraitFor('…')` with 'https://…' later needs no
* other change anywhere.
*/
/** Background gradient plus the two figure tones. Brand tokens only. */
type PortraitPalette = { bgFrom: string; bgTo: string; body: string; skin: string }
const PALETTES: PortraitPalette[] = [
{ bgFrom: '#F4F8E4', bgTo: '#E8F0C4', body: '#3D4038', skin: '#C5C9B8' },
{ bgFrom: '#E8F0C4', bgTo: '#D4E48A', body: '#141414', skin: '#9A9E90' },
{ bgFrom: '#F6F7F2', bgTo: '#E2E5D8', body: '#768F28', skin: '#D4E48A' },
{ bgFrom: '#D4E48A', bgTo: '#A8C73A', body: '#141414', skin: '#E2E5D8' },
{ bgFrom: '#E2E5D8', bgTo: '#C5C9B8', body: '#5C5F56', skin: '#E8F0C4' },
{ bgFrom: '#F4F8E4', bgTo: '#C0D65A', body: '#3D4038', skin: '#E2E5D8' },
{ bgFrom: '#C0D65A', bgTo: '#8FAD2F', body: '#141414', skin: '#F6F7F2' },
{ bgFrom: '#F6F7F2', bgTo: '#D4E48A', body: '#C97310', skin: '#E2E5D8' },
{ bgFrom: '#E8F0C4', bgTo: '#B4CE45', body: '#5C5F56', skin: '#F6F7F2' },
{ bgFrom: '#F4F8E4', bgTo: '#E2E5D8', body: '#141414', skin: '#C0D65A' },
{ bgFrom: '#E2E5D8', bgTo: '#B4CE45', body: '#141414', skin: '#F4F8E4' },
{ bgFrom: '#F6F7F2', bgTo: '#C0D65A', body: '#5C5F56', skin: '#D4E48A' },
]
/** Hair is drawn behind and in front of the face so each variant changes the silhouette. */
type HairVariant = { back: string; front: string }
const HAIR: HairVariant[] = [
{
back: '<circle cx="40" cy="31" r="15"/>',
front: '<path d="M25.5 32a14.5 14.5 0 0 1 29 0c0-6.4-6-9.8-14.5-9.8S25.5 25.6 25.5 32z"/>',
},
{ back: '<circle cx="40" cy="29.5" r="18"/>', front: '' },
{
back: '<circle cx="40" cy="31" r="15"/><circle cx="40" cy="13.5" r="6"/>',
front: '<path d="M25.5 32a14.5 14.5 0 0 1 29 0c0-6.4-6-9.8-14.5-9.8S25.5 25.6 25.5 32z"/>',
},
{
back: '<path d="M22 34a18 18 0 0 1 36 0v21a3 3 0 0 1-3 3H25a3 3 0 0 1-3-3z"/>',
front: '',
},
{
back: '<circle cx="40" cy="31" r="15"/>',
front: '<path d="M25.5 30.5c1-6.2 7-9.3 14.5-9.3 6.2 0 11.2 2.1 13.2 6.1-4.1-1.1-9.1.8-13.2 2.9-4.1 2.1-9.3 3.2-14.5.3z"/>',
},
]
/** Necklines, cut out of the torso in the skin tone. */
const GARMENTS = ['', '<path d="M34 57l6 9.5 6-9.5z"/>', '<rect x="30" y="57" width="20" height="3.5" rx="1.75"/>']
export const PORTRAIT_VARIANTS = PALETTES.length * HAIR.length * GARMENTS.length * 2
function portraitSvg(variant: number): string {
const palette = PALETTES[variant % PALETTES.length]
const hair = HAIR[Math.floor(variant / PALETTES.length) % HAIR.length]
const garment = GARMENTS[Math.floor(variant / (PALETTES.length * HAIR.length)) % GARMENTS.length]
const halo = Math.floor(variant / (PALETTES.length * HAIR.length * GARMENTS.length)) % 2 === 1
return [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">',
'<defs><linearGradient id="bg" x1="0" y1="0" x2=".35" y2="1">',
`<stop offset="0" stop-color="${palette.bgFrom}"/><stop offset="1" stop-color="${palette.bgTo}"/>`,
'</linearGradient></defs>',
'<rect width="80" height="80" fill="url(#bg)"/>',
halo ? `<circle cx="40" cy="34" r="24" fill="${palette.bgFrom}" opacity=".5"/>` : '',
`<g fill="${palette.body}">${hair.back}</g>`,
`<rect x="35.5" y="43" width="9" height="15" rx="4.5" fill="${palette.skin}"/>`,
`<path d="M12 80c0-14.4 12.5-23 28-23s28 8.6 28 23z" fill="${palette.body}"/>`,
garment ? `<g fill="${palette.skin}">${garment}</g>` : '',
`<circle cx="40" cy="33" r="13.5" fill="${palette.skin}"/>`,
hair.front ? `<g fill="${palette.body}">${hair.front}</g>` : '',
'</svg>',
].join('')
}
const portraitCache = new Map<number, string>()
/** Data URI for one portrait variant. Variants wrap, so any integer is safe. */
export function portrait(variant: number): string {
const index = ((variant % PORTRAIT_VARIANTS) + PORTRAIT_VARIANTS) % PORTRAIT_VARIANTS
const cached = portraitCache.get(index)
if (cached) return cached
const uri = `data:image/svg+xml,${encodeURIComponent(portraitSvg(index))}`
portraitCache.set(index, uri)
return uri
}
/** FNV-1a — small, stable across reloads, and fine for Ethiopic code units. */
function hash(seed: string): number {
let h = 0x811c9dc5
for (let i = 0; i < seed.length; i += 1) {
h ^= seed.charCodeAt(i)
h = Math.imul(h, 0x01000193)
}
return h >>> 0
}
/**
* The portrait for a stable key. Seeding on a person's name means the same face
* shows up whether a screen has the full person record or only their name.
*/
export function portraitFor(seed: string): string {
const h = hash(seed)
const palette = h % PALETTES.length
const hair = (h >>> 5) % HAIR.length
const garment = (h >>> 11) % GARMENTS.length
const halo = (h >>> 17) % 2
return portrait(
palette +
PALETTES.length * hair +
PALETTES.length * HAIR.length * garment +
PALETTES.length * HAIR.length * GARMENTS.length * halo,
)
}
/**
* The one place that decides what a person's avatar shows:
* `avatarUrl` → generated portrait → nothing, which lets initials through.
*
* `seed` is opt-in on purpose. A person record without an `avatarUrl` has no
* photo, and initials say that honestly; a bare name string with no record
* behind it (an audit actor, a prescriber) is best seeded from the name so it
* matches that person wherever else they appear.
*/
export function resolveAvatarSrc({
avatarUrl,
seed,
}: {
avatarUrl?: string
seed?: string
}): string | undefined {
if (avatarUrl) return avatarUrl
if (seed) return portraitFor(seed)
return undefined
}

94
src/lib/formatters.ts Normal file
View File

@ -0,0 +1,94 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
/**
* Locale-aware formatting driven by the ACTIVE APP LANGUAGE, not the browser locale.
* Bare `toLocaleString()` follows the browser, so an Amharic user on a German browser
* would otherwise see German dates and separators.
*/
const EMPTY = '—'
/**
* OPEN PRODUCT DECISION — CALENDAR SYSTEM.
*
* `am-ET` resolves to the Gregorian calendar with Amharic month names. Ethiopia widely
* uses the Ethiopian calendar, which is reachable by appending the `-u-ca-ethiopic`
* locale extension — but that yields a DIFFERENT YEAR (e.g. Gregorian 2026 ≈ Ethiopian
* 2018), so it is not a cosmetic change: it affects every date a user reads or reconciles
* against a paper record.
*
* Until the product owner rules on it, we ship Gregorian-with-Amharic-months. Switching
* to the Ethiopian calendar is a one-line change: return `'am-ET-u-ca-ethiopic'` below.
*/
function resolveLocale(lng: string): string {
return lng.startsWith('am') ? 'am-ET' : 'en-ET'
}
function toDate(value?: string | Date | null): Date | null {
if (value === null || value === undefined || value === '') return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function currencyFormatter(locale: string) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'ETB',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})
}
const DATE_OPTIONS: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'short',
year: 'numeric',
}
const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
...DATE_OPTIONS,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}
/**
* Reactive formatters bound to the active language. This is a hook rather than a plain
* function so the calling component subscribes to `languageChanged` and re-renders when
* the topbar switches locale.
*/
export function useFormatters() {
const { i18n } = useTranslation()
const locale = resolveLocale(i18n.language)
return useMemo(() => {
const currency = currencyFormatter(locale)
const number = new Intl.NumberFormat(locale)
const date = new Intl.DateTimeFormat(locale, DATE_OPTIONS)
const dateTime = new Intl.DateTimeFormat(locale, DATE_TIME_OPTIONS)
return {
locale,
formatEtb: (amount: number) => (Number.isFinite(amount) ? currency.format(amount) : EMPTY),
formatNumber: (value: number) => (Number.isFinite(value) ? number.format(value) : EMPTY),
formatDate: (value?: string | Date | null) => {
const parsed = toDate(value)
return parsed ? date.format(parsed) : EMPTY
},
formatDateTime: (value?: string | Date | null) => {
const parsed = toDate(value)
return parsed ? dateTime.format(parsed) : EMPTY
},
}
}, [locale])
}
/**
* Non-reactive escape hatch for module scope and event handlers, where hooks are illegal.
* Pass the language explicitly (e.g. `i18n.language`) — it will not track locale changes.
*/
export function formatEtbStatic(amount: number, lng: string): string {
if (!Number.isFinite(amount)) return EMPTY
return currencyFormatter(resolveLocale(lng)).format(amount)
}

View File

@ -12,3 +12,99 @@ export function formatEtb(amount: number) {
maximumFractionDigits: 0,
}).format(amount)
}
const EMPTY = '—'
const DATE_LOCALE = 'en-GB'
function toDate(value?: string | Date | null): Date | null {
if (value === null || value === undefined || value === '') return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
/** "6 Aug 2026" */
export function formatDate(value?: string | Date | null): string {
const date = toDate(value)
if (!date) return EMPTY
return date.toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short', year: 'numeric' })
}
/** "10:20" */
export function formatTime(value?: string | Date | null): string {
const date = toDate(value)
if (!date) return EMPTY
return date.toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit', hour12: false })
}
/** "6 Aug 2026, 10:20" */
export function formatDateTime(value?: string | Date | null): string {
const date = toDate(value)
if (!date) return EMPTY
return `${formatDate(date)}, ${formatTime(date)}`
}
const RELATIVE_UNITS: { unit: Intl.RelativeTimeFormatUnit; ms: number }[] = [
{ unit: 'year', ms: 365 * 24 * 60 * 60 * 1000 },
{ unit: 'month', ms: 30 * 24 * 60 * 60 * 1000 },
{ unit: 'week', ms: 7 * 24 * 60 * 60 * 1000 },
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
{ unit: 'hour', ms: 60 * 60 * 1000 },
{ unit: 'minute', ms: 60 * 1000 },
]
/** "2 hours ago", "in 3 days", "just now" */
export function formatRelative(value?: string | Date | null): string {
const date = toDate(value)
if (!date) return EMPTY
const diff = date.getTime() - Date.now()
const abs = Math.abs(diff)
if (abs < 45 * 1000) return 'just now'
const formatter = new Intl.RelativeTimeFormat(DATE_LOCALE, { numeric: 'auto' })
for (const { unit, ms } of RELATIVE_UNITS) {
if (abs >= ms) return formatter.format(Math.round(diff / ms), unit)
}
return formatter.format(Math.round(diff / 1000), 'second')
}
/** "Today" | "Yesterday" | "4 Aug 2026" */
export function formatDayGroup(value: string): string {
const date = toDate(value)
if (!date) return EMPTY
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
const days = Math.round((startOfDay(date) - startOfDay(new Date())) / (24 * 60 * 60 * 1000))
if (days === 0) return 'Today'
if (days === -1) return 'Yesterday'
return formatDate(date)
}
/** "out_for_delivery" → "Out for delivery" */
export function humanize(value: string): string {
if (!value) return ''
const words = value
.replace(/[_-]+/g, ' ')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.trim()
.toLowerCase()
return words.charAt(0).toLocaleUpperCase() + words.slice(1)
}
/** 1200 → "1.2K", 3_400_000 → "3.4M" */
export function formatNumber(n: number): string {
if (!Number.isFinite(n)) return EMPTY
const abs = Math.abs(n)
const sign = n < 0 ? '-' : ''
const compact = (divisor: number, suffix: string) => {
const scaled = abs / divisor
const rounded = scaled < 10 ? Math.round(scaled * 10) / 10 : Math.round(scaled)
return `${sign}${rounded}${suffix}`
}
if (abs < 1000) return `${sign}${Math.round(abs * 10) / 10}`
if (abs < 1_000_000) return compact(1000, 'K')
if (abs < 1_000_000_000) return compact(1_000_000, 'M')
return compact(1_000_000_000, 'B')
}

View File

@ -10,25 +10,35 @@
"password": "የይለፍ ቃል",
"welcome": "እንኳን ደህና መጡ",
"subtitle": "የሙከራ ሚና ይምረጡ ወይም በሰራተኛ መለያ ይግቡ።",
"demoHint": "ለሁሉም መለያዎች የይለፍ ቃል፡ demo123"
"demoHint": "ለሁሉም መለያዎች የይለፍ ቃል፡ demo123",
"demoAccounts": "የሙከራ መለያዎች",
"signedIn": "ገብተዋል",
"signedOut": "ወጥተዋል",
"error": {
"invalidCredentials": "የተሳሳተ ኢሜይል ወይም የይለፍ ቃል",
"emailRequired": "የኢሜይል አድራሻዎን ያስገቡ",
"passwordRequired": "የይለፍ ቃልዎን ያስገቡ"
}
},
"nav": {
"dashboard": "ዳሽቦርድ",
"orders": "ትዕዛዞች",
"prescriptions": "ማዘዣዎች",
"pos": "ቅኝት እና ግዢ",
"items": "የመድሃኒት እቃዎች",
"stock": "ክምችት እና ERP",
"procurement": "ግዢ",
"finance": "ፋይናንስ",
"organisations": "B2B ድርጅቶች",
"marketing": "ማርኬቲንግ",
"crm": "CRM",
"crm": "የደንበኛ ግንኙነት",
"loyalty": "ታማኝነት",
"campaigns": "ዘመቻዎች",
"dispatch": "ዲስፓች",
"profile": "የእኔ መገለጫ",
"team": "ቡድን እና ሚናዎች",
"inbox": "የአገልግሎት ሳጥን",
"audit": "የኦዲት መዝገብ",
"faq": "እውቀት / FAQ",
"faq": "እውቀት / ተደጋጋሚ ጥያቄዎች",
"settings": "ቅንብሮች",
"migrations": "የውሂብ ሽግግር",
"analytics": "ትንተና"
@ -57,93 +67,811 @@
"next": "ቀጣይ",
"back": "ተመለስ",
"commit": "አስገባ",
"validate": "አረጋግጥ",
"preview": "ቅድመ እይታ"
"validate": "ትክክለኛነት ፈትሽ",
"preview": "ቅድመ እይታ",
"searchPlaceholder": "ፈልግ…",
"clearSearch": "ፍለጋን አጽዳ",
"clearFilters": "ማጣሪያዎችን አጽዳ",
"moreActions": "ተጨማሪ እርምጃዎች",
"resultCount_one": "{{count}} ውጤት",
"resultCount_other": "{{count}} ውጤቶች",
"resultCountOf": "ከ{{total}} ውስጥ {{count}}",
"sortBy": "በዚህ አድርድር",
"columns": "አምዶች",
"backToList": "ወደ ዝርዝሩ ተመለስ",
"details": "ዝርዝር",
"summary": "ማጠቃለያ",
"activity": "እንቅስቃሴ",
"noActivity": "እስካሁን እንቅስቃሴ የለም",
"id": "መለያ",
"name": "ስም",
"customer": "ደንበኛ",
"phone": "ስልክ",
"sku": "SKU",
"product": "ምርት",
"qty": "ብዛት",
"total": "ጠቅላላ",
"amount": "መጠን",
"date": "ቀን",
"open": "ክፈት",
"send": "ላክ",
"delete": "አጥፋ",
"enabled": "ነቅቷል",
"disabled": "ተሰናክሏል",
"yes": "አዎ",
"no": "አይ",
"na": "አይመለከትም",
"none": "የለም",
"query": "ጥያቄ",
"add": "ጨምር",
"edit": "አስተካክል",
"email": "ኢሜይል",
"description": "መግለጫ",
"notes": "ማስታወሻዎች",
"publish": "አሳትም",
"reason": "ምክንያት",
"type": "ዓይነት",
"category": "ምድብ",
"channel": "መንገድ",
"source": "ምንጭ",
"tier": "ደረጃ",
"role": "ሚና",
"priority": "ቅድሚያ",
"demoData": "የሙከራ ውሂብ",
"mock": "ሙከራ"
},
"filters": {
"date": {
"all": "ሁሉም ቀኖች",
"today": "ዛሬ",
"last7": "ያለፉት 7 ቀኖች",
"last30": "ያለፉት 30 ቀኖች",
"last90": "ያለፉት 90 ቀኖች"
},
"period": "ጊዜ",
"allStatuses": "ሁሉም ሁኔታዎች",
"allTypes": "ሁሉም ዓይነቶች",
"allChannels": "ሁሉም መንገዶች",
"allSources": "ሁሉም ምንጮች",
"allTiers": "ሁሉም ደረጃዎች",
"allRoles": "ሁሉም ሚናዎች",
"allStaff": "ሁሉም ሰራተኞች",
"allItems": "ሁሉም እቃዎች",
"allStock": "ሁሉም ክምችት",
"allSignals": "ሁሉም ምልክቶች",
"allActors": "ሁሉም ፈጻሚዎች",
"allCategories": "ሁሉም ምድቦች",
"allRiders": "ሁሉም አስረካቢዎች",
"allCatchments": "ሁሉም አካባቢዎች",
"allPriorities": "ሁሉም ቅድሚያዎች",
"allDomains": "ሁሉም ዘርፎች",
"allFulfillment": "ሁሉም የማስረከቢያ ዘዴዎች",
"allBranchScopes": "ሁሉም የቅርንጫፍ ወሰኖች",
"allActivities": "ሁሉም እንቅስቃሴዎች",
"unassigned": "ያልተመደበ",
"assigned": "የተመደበ",
"hasControlled": "ቁጥጥር የሚደረግባቸው ብቻ",
"noControlled": "ቁጥጥር የሚደረግበት እቃ የለም",
"paid": "የተከፈለ",
"unpaid": "ያልተከፈለ",
"rider": "አስረካቢ",
"zone": "ዞን",
"expiry": {
"all": "ሁሉም የጊዜ ገደብ",
"expired": "ጊዜው አልፎበታል",
"in30": "በ30 ቀን ውስጥ ጊዜው የሚያልፍ",
"in90": "በ90 ቀን ውስጥ ጊዜው የሚያልፍ",
"in180": "በ180 ቀን ውስጥ ጊዜው የሚያልፍ"
},
"creditUse": {
"all": "ሁሉም የክሬዲት አጠቃቀም",
"under50": "ከ50% በታች ተጠቅሟል",
"from50to80": "50–80% ተጠቅሟል",
"over80": "ከ80% በላይ ተጠቅሟል"
}
},
"roles": {
"pharmacist": "ፋርማሲስት",
"stock_manager": "የክምችት ሥራ አስኪያጅ",
"procurement": "ግዢ",
"finance": "ፋይናንስ",
"marketing_manager": "የማርኬቲንግ ሥራ አስኪያጅ",
"operations": "ኦፕሬሽን",
"super_admin": "ዋና አስተዳዳሪ"
},
"modules": {
"faq_write": "የእውቀት መሠረት አስተዳደር"
},
"dashboard": {
"title": "ዳሽቦርድ",
"subtitle": "በሚና የተጠናከረ ኦፕሬሽን አጠቃላይ እይታ"
"subtitle": "በሚና የተጠናከረ ኦፕሬሽን አጠቃላይ እይታ",
"stats": {
"todaysOrders": "የዛሬ ትዕዛዞች",
"pendingRx": "በመጠባበቅ ላይ ያሉ ማዘዣዎች",
"unassignedDelivery": "ያልተመደበ ማድረስ",
"awaitingPickup": "ለመውሰድ የሚጠብቁ",
"pendingOrgs": "በመጠባበቅ ላይ ያሉ ድርጅቶች",
"activeCreditOrgs": "ንቁ የክሬዲት ድርጅቶች",
"creditUsed": "የተጠቀመ ክሬዲት (ETB)",
"ordersToday": "የዛሬ ትዕዛዞች ብዛት",
"lowStockSkus": "ክምችታቸው ያነሰ SKU",
"erpMismatches": "ከERP ጋር አለመጣጣሞች",
"trackedSkus": "የሚከታተሉ SKU",
"branches": "ቅርንጫፎች",
"openDispatch": "ክፍት ዲስፓች",
"unassigned": "ያልተመደበ",
"outForDelivery": "ለማድረስ ወጥቷል",
"paidReady": "ተከፍሎ የተዘጋጀ",
"orders": "ትዕዛዞች",
"rxQueue": "የማዘዣ ወረፋ"
},
"charts": {
"salesThisWeek": "የዚህ ሳምንት ሽያጭ",
"salesThisWeekHint": "በሁሉም መንገዶች የገቢ አዝማሚያ (የሙከራ ውሂብ)",
"channelMix": "የመንገዶች ድልድል",
"channelMixHint": "በምንጭ የትዕዛዝ ድርሻ",
"revenueByBranch": "በቅርንጫፍ ገቢ",
"revenueByBranchHint": "ከወር መጀመሪያ እስከ አሁን"
},
"recentActivity": "የቅርብ ጊዜ እንቅስቃሴ",
"needsAttention": "ትኩረት ይፈልጋል",
"quickContext": "አጭር መረጃ",
"signedInAs": "{{name}} ({{role}}) ሆነው ገብተዋል።",
"scopedTo": "ወሰን፡ {{branch}}።",
"platformWide": "የመድረክ አጠቃላይ እይታ።",
"openOrders": "ትዕዛዞችን ክፈት",
"viewAnalytics": "ትንተና ተመልከት",
"reviewPendingOrgs": "በመጠባበቅ ላይ ያሉ ድርጅቶችን ገምግም"
},
"orders": {
"title": "ትዕዛዞች",
"subtitle": "የቅርንጫፍ ገቢ እና ማስረከብ",
"assignRider": "አቅራቢ መድብ"
"subtitle": "የቅርንጫፍ ትዕዛዝ ወረፋ እና ማስረከብ",
"assignRider": "አስረካቢ መድብ",
"new": "አዲስ ትዕዛዝ",
"searchPlaceholder": "በመለያ ወይም በደንበኛ ፈልግ…",
"deliveryPerson": "አስረካቢ",
"selectRider": "አስረካቢ ይምረጡ…",
"paid": "የተከፈለ",
"unpaid": "ያልተከፈለ",
"rxApproved": "ማዘዣው ጸድቋል",
"columns": {
"id": "መለያ",
"customer": "ደንበኛ",
"branch": "ቅርንጫፍ",
"fulfillment": "የማስረከቢያ ዘዴ",
"status": "ሁኔታ",
"total": "ጠቅላላ",
"channel": "የትዕዛዝ መንገድ",
"zone": "ዞን",
"address": "አድራሻ",
"createdAt": "የተፈጠረበት"
},
"status": {
"confirmed": "ተረጋግጧል",
"picking": "በመሰብሰብ ላይ",
"out_for_delivery": "ለማድረስ ወጥቷል",
"cancelled": "ተሰርዟል",
"picked_up": "ተወስዷል",
"en_route": "በመንገድ ላይ",
"arrived": "በአድራሻው ደርሷል",
"delivered": "ተደርሷል",
"failed": "አልተሳካም"
},
"fulfillment": {
"delivery": "ማድረስ",
"pickup": "ከቅርንጫፍ መውሰድ"
},
"channel": {
"web": "ዌብ",
"mobile": "ሞባይል",
"telegram": "ቴሌግራም",
"b2b": "B2B"
},
"toast": {
"confirmed": "ትዕዛዙ ተረጋግጧል",
"cancelled": "ትዕዛዙ ተሰርዟል",
"riderAssigned": "አስረካቢ ተመድቧል",
"created": "የሰራተኛ ትዕዛዝ ተፈጥሯል"
}
},
"prescriptions": {
"title": "ማዘዣዎች",
"subtitle": "የፋርማሲስት ግምገማ ወረፋ",
"approve": "አጽድቅ",
"query": "ጥያቄ",
"reject": "ውድቅ"
"reject": "ውድቅ",
"new": "አዲስ የማዘዣ ምዝገባ",
"searchPlaceholder": "በማዘዣ፣ በደንበኛ ወይም በአዛዥ ሐኪም ፈልግ…",
"controlled": "ቁጥጥር የሚደረግበት",
"controlledOnly": "ቁጥጥር የሚደረግባቸው ብቻ",
"allPrescriptions": "ሁሉም ማዘዣዎች",
"columns": {
"id": "መለያ",
"customer": "ደንበኛ",
"prescriber": "አዛዥ ሐኪም",
"items": "እቃዎች",
"branch": "ቅርንጫፍ",
"status": "ሁኔታ",
"submittedAt": "የገባበት"
},
"status": {
"submitted": "ገብቷል",
"under_review": "በግምገማ ላይ",
"approved": "ጸድቋል",
"queried": "ጥያቄ ቀርቦበታል",
"rejected": "ውድቅ ተደርጓል"
},
"toast": {
"statusChanged": "ማዘዣው {{status}} ተብሎ ተመዝግቧል",
"created": "የማዘዣ ምዝገባ ተፈጥሯል"
}
},
"stock": {
"title": "ክምችት እና ERP",
"subtitle": "የክምችት ማመሳሰል፣ ማስጠንቀቂያዎች እና ውህደቶች"
"subtitle": "የክምችት ማመሳሰል፣ ማስጠንቀቂያዎች እና ውህደቶች",
"addLine": "የክምችት መስመር ጨምር",
"searchPlaceholder": "በSKU ወይም በስም ፈልግ…",
"lowStock": "ክምችት ዝቅተኛ",
"ok": "ደህና",
"erpMismatch": "ከERP ጋር አይጣጣምም",
"alert": "ማስጠንቀቂያ",
"tabs": {
"inventory": "ክምችት",
"erp": "የERP ማመሳሰል",
"entry": "በAI የተደገፈ ምዝገባ",
"transfers": "ዝውውሮች"
},
"columns": {
"sku": "SKU",
"name": "ስም",
"branch": "ቅርንጫፍ",
"qty": "ብዛት",
"batch": "ባች",
"expiry": "የሚያልፍበት ቀን",
"alert": "ማስጠንቀቂያ",
"erpQty": "የERP ብዛት"
},
"erp": {
"platformQty": "መድረክ {{qty}}",
"erpQty": "ERP {{qty}}",
"resolveToErp": "ወደ ERP አስተካክል",
"noDiscrepancies": "ክፍት አለመጣጣም የለም",
"syncQueued": "ለ{{sku}} የERP ማመሳሰል ወረፋ ተይዟል"
},
"entry": {
"placeholder": "ስም፣ ባርኮድ ወይም SKU…",
"possibleMatch": "ሊመሳሰል የሚችል፡ {{name}} ({{sku}}) — ማዋሃድ የሰው ማጽደቅ ይፈልጋል"
},
"transfers": {
"description": "በቅርንጫፎች መካከል የሚደረግ ዝውውር በክምችት ሥራ አስኪያጅ ይመራል። ቫይታሚን C ከBole ወደ CMC እንዲዛወር ይጠቁሙ።",
"create": "የዝውውር ጥቆማ ፍጠር"
},
"toast": {
"discrepancyResolved": "የ{{sku}} አለመጣጣም ተስተካክሏል",
"transferDraft": "የዝውውር ረቂቅ ተፈጥሯል",
"transferCreated": "በቅርንጫፎች መካከል የዝውውር ረቂቅ ተፈጥሯል",
"lineAdded": "የክምችት መስመር ተጨምሯል"
}
},
"procurement": {
"title": "ግዢ",
"subtitle": "የእንደገና ትዕዛዝ እና የማስተላለፍ ጥያቄዎች"
"subtitle": "የእንደገና ትዕዛዝ ጥቆማዎች እና የዝውውር ጥያቄዎች",
"newRequest": "አዲስ የእንደገና ትዕዛዝ ጥያቄ",
"searchRequests": "በጥያቄ፣ በSKU ወይም በምርት ፈልግ…",
"searchRestock": "በSKU ወይም በምርት ፈልግ…",
"supplier": "አቅራቢ",
"nudgeSupplier": "አቅራቢውን አስታውስ",
"viewStock": "ክምችት ተመልከት",
"needsRestocking": "መሙላት ይፈልጋል",
"needsRestockingHint": "ከ{{units}} ዩኒት በታች፣ በ{{months}} ወር ውስጥ ጊዜው የሚያልፍ ወይም ከERP ጋር ያልተመሳሰለ",
"expired": "ጊዜው አልፎበታል",
"monthsLeft": "{{count}} ወር ቀርቷል",
"stats": {
"openRequests": "ክፍት ጥያቄዎች",
"openRequestsHint": "የግዢ ትዕዛዝ የሚጠብቁ",
"awaitingDelivery": "አቅርቦት የሚጠብቁ",
"awaitingDeliveryHint": "ከአቅራቢው ታዞዋል",
"received": "ተቀብሏል",
"receivedHint": "በዚህ ወር የተዘጋ",
"needsRestock": "መሙላት ይፈልጋል",
"needsRestockHint": "ዝቅተኛ ክምችት፣ የጊዜ ገደብ ወይም የERP ልዩነት"
},
"finance": {
"title": "ፋይናንስ",
"subtitle": "ስምምነቶች፣ መገናኛዎች፣ ተመላሾች"
"tabs": {
"requests": "ጥያቄዎች",
"restock": "መሙላት",
"insight": "የአቅራቢ ትንታኔ"
},
"columns": {
"id": "መለያ",
"sku": "SKU",
"product": "ምርት",
"qty": "ብዛት",
"branch": "ቅርንጫፍ",
"priority": "ቅድሚያ",
"status": "ሁኔታ",
"onHand": "በእጅ ያለ",
"erp": "ERP",
"batch": "ባች",
"expiry": "የሚያልፍበት ቀን",
"reason": "ምክንያት"
},
"priority": {
"high": "ከፍተኛ",
"medium": "መካከለኛ",
"low": "ዝቅተኛ"
},
"status": {
"open": "ክፍት",
"ordered": "ታዞዋል",
"received": "ተቀብሏል"
},
"signal": {
"low": "ክምችት ዝቅተኛ",
"expiry": "ጊዜው ሊያልፍ ነው",
"mismatch": "ከERP ጋር አይጣጣምም"
},
"suggested": {
"title": "የተጠቆመ መሙላት",
"description": "በቅርንጫፎች ላይ የAI የእንደገና ትዕዛዝ ምልክቶች (የሙከራ ውሂብ)",
"transfer": "ዝውውር",
"reorder": "እንደገና አዝዝ",
"requestTransfer": "ዝውውር ጠይቅ",
"createRequest": "ጥያቄ ፍጠር",
"orderUnits": "{{count}} አዝዝ",
"interBranchTransfer": "በቅርንጫፎች መካከል ዝውውር",
"units": "{{count}} ዩኒት",
"label": "የተጠቆመ"
},
"charts": {
"stockFlow": "የክምችት ግቤት እና ውጤት",
"stockFlowHint": "በሳምንት የተንቀሳቀሱ ዩኒቶች (የሙከራ ውሂብ)",
"revenueByBranch": "በቅርንጫፍ ገቢ",
"revenueByBranchHint": "የፍላጎት መጠን (የሙከራ ውሂብ)",
"inflow": "ግቤት",
"outflow": "ውጤት",
"unitsValue": "{{count}} ዩኒት"
},
"toast": {
"supplierReminder": "ለ{{id}} ለአቅራቢው ማስታወሻ ተልኳል",
"transferRequest": "የዝውውር ጥያቄ ወደ ክምችት ሥራ አስኪያጅ ተልኳል",
"transferRequestFor": "ለ{{sku}} የዝውውር ጥያቄ ወደ ክምችት ሥራ አስኪያጅ ተልኳል",
"poDraft": "ለ{{count}} ዩኒት የግዢ ትዕዛዝ ረቂቅ",
"reorderDrafted": "ለ{{sku}} የእንደገና ትዕዛዝ ጥያቄ ተዘጋጅቷል",
"requestCreated": "ለ{{sku}} የእንደገና ትዕዛዝ ረቂቅ ተፈጥሯል",
"alternateSupplier": "AI አማራጭ አቅራቢ ጠቁሟል፡ {{name}}"
}
},
"organisations": {
"title": "B2B ድርጅቶች",
"subtitle": "ድርጅት ፍጠር እና ራስ መመዝገቢያዎችን አጽድቅ",
"subtitle": "ድርጅት ፍጠር እና ራሳቸውን የመዘገቡትን አጽድቅ",
"pending": "ማጽደቅ የሚጠብቅ",
"createOrg": "ድርጅት ፍጠር",
"searchPlaceholder": "በስም፣ በTIN ወይም በአድራሻ ፈልግ…",
"creditLimit": "የክሬዲት ጣሪያ",
"creditUsed": "የተጠቀመ ክሬዲት",
"billingContact": "የክፍያ አድራሻ",
"tin": "TIN",
"pendingBanner_one": "{{count}} ራሱን የመዘገበ ድርጅት ማጽደቅ ይጠብቃል። ለመገምገም በሁኔታ “በመጠባበቅ ላይ” አጣራ።",
"pendingBanner_other": "{{count}} ራሳቸውን የመዘገቡ ድርጅቶች ማጽደቅ ይጠብቃሉ። ለመገምገም በሁኔታ “በመጠባበቅ ላይ” አጣራ።",
"columns": {
"name": "ስም",
"tin": "TIN",
"source": "ምንጭ",
"limit": "ጣሪያ",
"used": "የተጠቀመ",
"status": "ሁኔታ",
"createdAt": "የተፈጠረበት"
},
"status": {
"pending": "በመጠባበቅ ላይ",
"createOrg": "ድርጅት ፍጠር"
"active": "ንቁ",
"rejected": "ውድቅ ተደርጓል"
},
"source": {
"admin": "በአስተዳዳሪ የተፈጠረ",
"self_register": "ራሱን የመዘገበ"
},
"toast": {
"approved": "ድርጅቱ ጸድቋል — ክሬዲት ንቁ ሆኗል",
"rejected": "ድርጅቱ ውድቅ ተደርጓል",
"created": "ድርጅቱ ተፈጥሯል — ክሬዲት ንቁ ሆኗል",
"creditLimitSubmitted": "የክሬዲት ጣሪያ ማሻሻያ ለግምገማ ተልኳል"
}
},
"crm": {
"title": "የደንበኛ ግንኙነት",
"subtitle": "የደንበኛ መዝገቦች (ንባብ ብቻ)",
"searchPlaceholder": "በስም ወይም በስልክ ፈልግ…",
"points": "ነጥቦች",
"columns": {
"name": "ስም",
"phone": "ስልክ",
"tier": "ደረጃ",
"points": "ነጥቦች",
"branch": "ቅርንጫፍ"
},
"tier": {
"bronze": "ብሮንዝ",
"silver": "ሲልቨር",
"gold": "ወርቅ"
},
"toast": {
"loyaltyBonus": "{{count}} የታማኝነት ነጥብ ጉርሻ ወረፋ ተይዟል",
"campaignEnrollment": "የዘመቻ ምዝገባ ተቀምጧል",
"messageDraft": "የመልእክት ረቂቅ ተቀምጧል"
}
},
"finance": {
"title": "ፋይናንስ",
"subtitle": "የገንዘብ ማወራረድ፣ የክፍያ መንገዶች፣ ተመላሽ ገንዘብ",
"manageGateways": "የክፍያ መንገዶችን አስተዳድር",
"searchPlaceholder": "በክፍያ መንገድ ወይም በባች ፈልግ…",
"dailySettlements": "የዕለት ማወራረድ",
"processRefund": "ተመላሽ ገንዘብ አስኪድ",
"paymentGateways": "የክፍያ መንገዶች",
"operations": "ኦፕሬሽን",
"operationsHint": "የታማኝነት ግዴታ እና በእጅ የሚደረጉ የፋይናንስ እርምጃዎች",
"loyaltyLiabilityLine": "የታማኝነት ነጥብ ግዴታ ({{month}})",
"gatewayNote": "የክፍያ መንገዶችን ማብራት እና ማጥፋት በ“የክፍያ መንገዶች” ትር እና በራሱ የቅንብር ገጽ ላይ ይገኛል፤ ስለዚህ የዕለት ተዕለት የፋይናንስ ሥራ በማወራረድ እና በተመላሽ ገንዘብ ላይ ያተኩራል።",
"stats": {
"settledToday": "የዛሬ ማወራረድ",
"settledTodayHint": "የተወራረዱ ባቾች",
"pendingReconciliation": "በመጠባበቅ ላይ ያለ ማወራረድ",
"pendingReconciliationHint": "መጣጣም የሚጠብቅ",
"transactions": "ግብይቶች",
"transactionsHint": "በሁሉም መንገዶች",
"loyaltyLiability": "የታማኝነት ግዴታ",
"loyaltyLiabilityHint": "ያልተከፈሉ ነጥቦች"
},
"tabs": {
"overview": "አጠቃላይ እይታ",
"gateways": "የክፍያ መንገዶች"
},
"columns": {
"channel": "የክፍያ መንገድ",
"date": "ቀን",
"volume": "መጠን",
"txns": "ግብይቶች",
"fees": "የአገልግሎት ክፍያ",
"status": "ሁኔታ"
},
"status": {
"reconciled": "ተወራርዷል",
"pending": "በመጠባበቅ ላይ"
},
"charts": {
"settlementVolume": "በክፍያ መንገድ የማወራረድ መጠን",
"settlementVolumeHint": "የዕለት የክፍያ መንገድ ማወራረድ (የሙከራ ውሂብ)",
"channelMix": "የመንገዶች ድልድል",
"channelMixHint": "የተከፈሉ ትዕዛዞች ድርሻ (የሙከራ ውሂብ)"
},
"gateways": {
"title": "የክፍያ መንገድ መቆጣጠሪያ",
"description": "በሙከራው ላይ ለውጦች ወዲያውኑ ተግባራዊ ይሆናሉ። Telebirr እና M-Pesa ከመጀመር በፊት የINSA ፈቃድ ሊፈልጉ ይችላሉ።",
"live": "ንቁ",
"off": "ጠፍቷል",
"enable": "መንገዱን አብራ",
"disable": "መንገዱን አጥፋ",
"aggregator": "አቀናጅ",
"directWallet": "ቀጥተኛ ዋሌት",
"toastEnabled": "{{name}} በርቷል (ኮድ ሳይለቀቅ)",
"toastDisabled": "{{name}} ጠፍቷል (ኮድ ሳይለቀቅ)"
},
"toast": {
"refundQueued": "ተመላሽ ገንዘብ ወረፋ ተይዟል",
"reconciliationRerun": "ማወራረድ እንደገና እንዲሠራ ወረፋ ተይዟል",
"csvExport": "የCSV ወደ ውጭ ማውጣት ተጀምሯል"
}
},
"dispatch": {
"title": "የዲስፓች ሰሌዳ",
"subtitle": "ጉዞዎች፣ አቅራቢዎች እና ቴሌግራም ቦት"
"subtitle": "ጉዞዎች፣ አስረካቢዎች እና የቴሌግራም ቦት ሁኔታ",
"assignRider": "አስረካቢ መድብ",
"liveQueue": "የቀጥታ ወረፋ (የተከፈለ እና ማዘዣው የጸደቀ)",
"map": "ካርታ (ሙከራ)",
"mapOverlay": "ንቁ አስረካቢዎች እና ክፍት አቅርቦቶች",
"telegramBot": "የቴሌግራም ዲስፓች ቦት",
"openIntake": "ክፍት የትዕዛዝ ግቤት",
"openIntakeHint": "ከPOS፣ ERP እና አጋሮች የሚመጡ webhooks ይከታተላል (ሙከራ፡ በመጨረሻው ሰዓት 2 የአጋር ትዕዛዞች)።",
"botReady": "ቦቱ ተዘጋጅቷል · አሽከርካሪዎች በቴሌግራም መጠሪያ ተያይዘዋል",
"tripPushed": "ጉዞ {{id}} → {{rider}}፡ ዝርዝሩ ተልኳል (ማቆሚያዎች፣ በደረሰ ክፍያ)",
"botCallback": "የቦት መልስ {{id}}፡ {{status}}",
"stats": {
"openDeliveries": "ክፍት አቅርቦቶች",
"unassigned": "ያልተመደበ",
"enRoute": "በመንገድ ላይ",
"activeRiders": "ንቁ አስረካቢዎች"
},
"charts": {
"tripsByHour": "በሰዓት ጉዞዎች",
"tripsByHourHint": "የሙከራ የዲስፓች ጫና",
"trips": "ጉዞዎች"
},
"toast": {
"tripAssigned": "ጉዞ ተመድቧል · የቴሌግራም ዲስፓች ቦት ተነግሮታል",
"statusUpdated": "ሰሌዳው እና የደንበኛ ክትትል ተዘምኗል፡ {{status}}"
}
},
"audit": {
"title": "የኦዲት መዝገብ",
"subtitle": "ማመሳሰል፣ ማስተካከያዎች፣ ተሻሪ እርምጃዎች፣ ማጽደቂያዎች",
"exportLog": "መዝገቡን ወደ ውጭ አውጣ",
"searchPlaceholder": "በፈጻሚ፣ በእርምጃ ወይም በዒላማ ፈልግ…",
"columns": {
"when": "መቼ",
"actor": "ፈጻሚ",
"action": "እርምጃ",
"target": "ዒላማ"
},
"domain": {
"label": "ዘርፍ",
"rx": "ማዘዣዎች",
"org": "ድርጅቶች",
"stock": "ክምችት",
"order": "ትዕዛዞች"
},
"toast": {
"exportQueued": "የኦዲት ወደ ውጭ ማውጣት ወረፋ ተይዟል"
}
},
"team": {
"title": "ቡድን እና ሚናዎች",
"subtitle": "የሰራተኛ መለያዎች እና የሚና ፈቃዶች",
"addStaff": "ሰራተኛ ጨምር",
"searchPlaceholder": "በስም ወይም በኢሜይል ፈልግ…",
"resetPassword": "የይለፍ ቃል ዳግም አስጀምር",
"resendInvite": "ግብዣ በድጋሚ ላክ",
"disableAccount": "መለያውን አሰናክል",
"columns": {
"name": "ስም",
"email": "ኢሜይል",
"role": "ሚና",
"branch": "ቅርንጫፍ"
},
"branchScope": {
"label": "የቅርንጫፍ ወሰን",
"scoped": "በቅርንጫፍ የተወሰነ",
"platformWide": "መድረክ አጠቃላይ"
},
"toast": {
"added": "ሰራተኛ ተጨምሯል (የሙከራ የመግቢያ የይለፍ ቃል፡ demo123)",
"passwordReset": "የይለፍ ቃል ዳግም ማስጀመሪያ ኢሜይል ወረፋ ተይዟል",
"inviteResent": "ግብዣ በድጋሚ ተልኳል",
"accountDisabled": "መለያው ተሰናክሏል"
}
},
"faq": {
"title": "የእውቀት መሠረት",
"subtitle": "የጋራ ኦፕሬሽን FAQ",
"new": "አዲስ ጽሑፍ"
"subtitle": "የጋራ የኦፕሬሽን ተደጋጋሚ ጥያቄዎች",
"new": "አዲስ ጽሑፍ",
"publish": "አሳትም",
"unpublish": "ከህትመት አንሳ",
"openArticle": "ጽሑፉን ክፈት",
"searchPlaceholder": "ጽሑፎችን ፈልግ…",
"published": "ታትሟል",
"draft": "ረቂቅ",
"category": {
"general": "አጠቃላይ",
"orders": "ትዕዛዞች",
"prescriptions": "ማዘዣዎች",
"stock": "ክምችት",
"payments": "ክፍያዎች",
"b2b": "B2B"
},
"fields": {
"category": "ምድብ",
"titleEn": "አርዕስት (እንግሊዝኛ)",
"titleAm": "አርዕስት (አማርኛ)",
"bodyEn": "ሙሉ ጽሑፍ (እንግሊዝኛ)",
"bodyAm": "ሙሉ ጽሑፍ (አማርኛ)"
},
"toast": {
"published": "ተደጋጋሚ ጥያቄው ታትሟል",
"saved": "ጽሑፉ ተቀምጧል",
"unpublished": "ጽሑፉ ከህትመት ተነስቷል"
}
},
"migrations": {
"title": "የውሂብ ሽግግር",
"subtitle": "ተለዋዋጭ CSV/Excel ማስገባት ከአምድ ማያያዝ ጋር",
"templates": "የማስገባት አብነቶች",
"mapColumns": "አምዶችን አያይዝ",
"dryRun": "የሙከራ ማረጋገጫ"
"dryRun": "የሙከራ ማረጋገጫ",
"fields": "መስኮች፡ {{list}}",
"uploadTitle": "CSV / Excel ስቀል (በሙከራው የCSV ጽሑፍ ይነበባል)",
"useSample": "የናሙና የክምችት CSV ተጠቀም",
"skip": "— ዝለል —",
"validationPassed": "ማረጋገጫው ተሳክቷል",
"jobLogged": "ሥራው በኦዲት መዝገብ ተመዝግቧል።",
"steps": {
"template": "አብነት",
"upload": "መስቀል",
"map": "ማያያዝ",
"validate": "ማረጋገጫ",
"done": "ተጠናቋል"
},
"team": {
"title": "ቡድን እና ሚናዎች",
"subtitle": "ሰራተኞች እና RBAC"
"errors": {
"unmapped": "ያልተያዙ መስኮች፡ {{list}}",
"emptyRows": "ባዶ ረድፎች ተገኝተዋል"
},
"crm": {
"title": "CRM",
"subtitle": "የደንበኛ መዝገቦች (ንባብ)"
},
"loyalty": {
"title": "የታማኝነት ፕሮግራም",
"subtitle": "ነጥቦች፣ ደረጃዎች፣ ሪፈራል"
},
"campaigns": {
"title": "ዘመቻዎች",
"subtitle": "SMS፣ ቴሌግራም፣ push"
},
"inbox": {
"title": "የአገልግሎት ሳጥን",
"subtitle": "Chatwoot፣ ቴሌግራም፣ WhatsApp"
},
"audit": {
"title": "የኦዲት መዝገብ",
"subtitle": "ማመሳሰል፣ ማስተካከያዎች፣ ማጽደቂያዎች"
},
"settings": {
"title": "ቅንብሮች",
"subtitle": "የመድረክ ምርጫዎች"
"toast": {
"columnsDetected_one": "{{count}} አምድ ተገኝቷል",
"columnsDetected_other": "{{count}} አምዶች ተገኝተዋል",
"fixErrors": "በመጀመሪያ የማረጋገጫ ስህተቶቹን አስተካክል",
"committed": "ማስገባቱ ተፈጽሟል · አብነት {{template}} · {{count}} የናሙና ረድፎች"
}
},
"analytics": {
"title": "ትንተና",
"subtitle": "ሽያጭ እና እድገት KPIs"
"subtitle": "የሽያጭ እና የእድገት ቁልፍ መለኪያዎች",
"exportReport": "ሪፖርት ወደ ውጭ አውጣ",
"stats": {
"revenue7d": "ገቢ (7 ቀን)",
"repeatPurchase": "ተደጋጋሚ ግዢ",
"orders7d": "ትዕዛዞች (7 ቀን)",
"fastestSku": "በፍጥነት የሚሸጥ SKU"
},
"charts": {
"salesVsOrders": "ሽያጭ እና ትዕዛዞች",
"salesVsOrdersHint": "የዕለት ገቢ ከትዕዛዝ መጠን ጋር",
"channelMix": "የመንገዶች ድልድል",
"channelMixHint": "የትዕዛዞች ድርሻ",
"branchPerformance": "የቅርንጫፍ አፈጻጸም",
"branchPerformanceHint": "ገቢ እና የማዘዣ መጠን",
"paymentChannels": "የክፍያ መንገዶች",
"paymentChannelsHint": "የማወራረድ መጠን አዝማሚያ"
},
"series": {
"sales": "ሽያጭ (ETB)",
"orders": "ትዕዛዞች",
"revenue": "ገቢ",
"rx": "ማዘዣ",
"cod": "በደረሰ ክፍያ"
},
"toast": {
"exportQueued": "የትንተና ወደ ውጭ ማውጣት ወረፋ ተይዟል"
}
},
"settings": {
"title": "ቅንብሮች",
"subtitle": "የመድረክ ምርጫዎች",
"platform": "መድረክ",
"dataRetention": "የውሂብ ማቆያ ጊዜ (ቀኖች)",
"apiBaseUrl": "የAPI መሠረት URL",
"toast": {
"saved": "ቅንብሮች ተቀምጠዋል"
}
},
"campaigns": {
"title": "ዘመቻዎች",
"subtitle": "SMS፣ ቴሌግራም እና የግፊት ማሳወቂያ ወደ ደንበኛ ክፍሎች",
"compose": "ዘመቻ አዘጋጅ",
"channel": "የመልእክት መንገድ",
"segment": "የደንበኛ ክፍል",
"message": "መልእክት",
"schedule": "መርሐ ግብር (አማራጭ)",
"send": "ላክ / መርሐ ግብር ያዝ",
"channels": {
"sms": "SMS",
"telegram": "ቴሌግራም",
"push": "የግፊት ማሳወቂያ"
},
"segments": {
"chronic": "ተደጋጋሚ የመድኃኒት ሙሌት",
"lapsed": "የቀዘቀዙ ገዢዎች",
"winback": "ማስመለስ"
},
"toast": {
"queued": "ዘመቻው በ{{channel}} ወደ {{segment}} ወረፋ ተይዟል"
}
},
"loyalty": {
"title": "የታማኝነት ፕሮግራም",
"subtitle": "ነጥብ ማግኘት፣ ደረጃዎች፣ የሪፈራል ጉርሻ",
"awardPoints": "ነጥብ ስጥ",
"searchCustomer": "የደንበኛ ስም ፈልግ…",
"stats": {
"pointsAwarded": "የተሰጡ ነጥቦች (ጠቅላላ)",
"pointsAwardedHint_one": "{{count}} የመዝገብ ግቤት",
"pointsAwardedHint_other": "{{count}} የመዝገብ ግቤቶች",
"awardedIn": "በ{{month}} የተሰጡ",
"awardedInHint": "የአሁኑ የሪፖርት ወር",
"pointsRedeemed": "የተመነዘሩ ነጥቦች",
"pointsRedeemedHint": "ከተሰጡት ነጥቦች {{percent}}%",
"outstandingLiability": "ያልተከፈለ ግዴታ",
"outstandingLiabilityHint": "{{points}} ነጥብ × {{rate}} ETB (የሙከራ ተመን)",
"activeMembers": "ንቁ አባላት",
"activeMembersHint": "በመዝገቡ እንቅስቃሴ ያላቸው"
},
"charts": {
"pointsMovement": "የተሰጡ እና የተመነዘሩ ነጥቦች",
"pointsMovementHint": "የፕሮግራሙ ወርሃዊ እንቅስቃሴ",
"pointsByActivity": "በእንቅስቃሴ ነጥቦች",
"earned": "የተገኘ",
"redeemed": "የተመነዘረ"
},
"tabs": {
"ledger": "የነጥብ መዝገብ",
"members": "በደንበኛ",
"activities": "ነጥብ የሚያስገኙ እንቅስቃሴዎች",
"settings": "የፕሮግራም ቅንብሮች"
},
"columns": {
"customer": "ደንበኛ",
"activity": "እንቅስቃሴ",
"points": "ነጥቦች",
"order": "ትዕዛዝ",
"branch": "ቅርንጫፍ",
"date": "ቀን",
"tier": "ደረጃ",
"earned": "የተገኘ",
"redeemed": "የተመነዘረ",
"balance": "ቀሪ",
"lastActivity": "የመጨረሻ እንቅስቃሴ",
"description": "መግለጫ",
"basis": "መሠረት",
"tierMultiplier": "የደረጃ ማባዢ"
},
"direction": {
"label": "አቅጣጫ",
"all": "የተገኘ እና የተመነዘረ",
"earned": "የተገኘ ብቻ",
"redeemed": "የተመነዘረ ብቻ"
},
"activities": {
"title": "ነጥብ የሚያስገኙ እንቅስቃሴዎች",
"description": "በታማኝነት ሲስተሙ የሚተገበሩ የነጥብ ደንቦች። የደረጃ ማባዢዎች በመሠረታዊው ነጥብ ላይ ይደመራሉ።",
"pause": "አቁም",
"activate": "አንቃ",
"active": "ንቁ",
"paused": "ቆሟል"
},
"settings": {
"title": "ኮድ የማይፈልግ የታማኝነት ውቅር",
"earnRate": "የነጥብ ተመን (በአንድ ETB ነጥብ)",
"referralBonus": "የሪፈራል ጉርሻ (ነጥብ)",
"minRedemption": "አነስተኛ የመመንዘሪያ መጠን",
"tiersNote": "ደረጃዎች፡ ብሮንዝ / ሲልቨር / ወርቅ — መስፈርቶቹ በሙሉ ውቅሩ ላይ ይስተካከላሉ።",
"redemptionNote": "የመመንዘሪያ ዋጋ የሙከራ ቋሚ ነው፡ 1 ነጥብ = {{rate}} ETB፣ ከላይ ላለው የግዴታ አኃዝ ይጠቅማል።"
},
"toast": {
"exportQueued": "የነጥብ መዝገብ ወደ ውጭ ማውጣት ወረፋ ተይዟል",
"manualAward": "በእጅ የነጥብ ስጦታ ተዘጋጅቷል",
"activated": "{{name}} ነቅቷል",
"paused": "{{name}} ቆሟል",
"saved": "የታማኝነት ደንቦች ተቀምጠዋል",
"editing": "የ“{{name}}” ደንብ በማስተካከል ላይ"
}
},
"marketing": {
"title": "ማርኬቲንግ",
"subtitle": "ክፍሎች እና ሪፖርቶች"
"title": "የማርኬቲንግ አጠቃላይ እይታ",
"subtitle": "ክፍሎች እና ሪፖርቶች",
"newCampaign": "አዲስ ዘመቻ",
"selfUpdating": "ራሱን የሚያዘምን",
"viewMembers": "አባላትን ተመልከት",
"target": "አነጣጥር",
"stats": {
"reach": "ሊደረስ የሚችል ተደራሽነት",
"activeSegments": "ንቁ ክፍሎች",
"campaignsSent": "የተላኩ ዘመቻዎች (30 ቀን)",
"openRate": "አማካይ የመክፈት መጠን"
},
"charts": {
"segmentSize": "የክፍል መጠን",
"segmentSizeHint": "በራሱ የሚዘምን ክፍል አባላት",
"reachByChannel": "በመንገድ ተደራሽነት",
"reachByChannelHint": "ዘመቻዎች የሚደርሱበት",
"members": "አባላት"
},
"segments": {
"chronic": "ተደጋጋሚ የመድኃኒት ሙሌት",
"lapsed": "የቀዘቀዙ ገዢዎች",
"highValue": "ከፍተኛ ወጪ ያላቸው ቤተሰቦች",
"institutional": "የተቋም መለያዎች",
"boleCatchment": "የBole አካባቢ"
},
"toast": {
"campaignDraft": "“{{name}}”ን የሚያነጣጥር የዘመቻ ረቂቅ"
}
}
}

View File

@ -10,12 +10,22 @@
"password": "Password",
"welcome": "Welcome back",
"subtitle": "Choose a demo role or sign in with your staff account.",
"demoHint": "Demo password for all accounts: demo123"
"demoHint": "Demo password for all accounts: demo123",
"demoAccounts": "Demo accounts",
"signedIn": "Signed in",
"signedOut": "Signed out",
"error": {
"invalidCredentials": "Invalid credentials",
"emailRequired": "Enter your email address",
"passwordRequired": "Enter your password"
}
},
"nav": {
"dashboard": "Dashboard",
"orders": "Orders",
"prescriptions": "Prescriptions",
"pos": "Scan & purchase",
"items": "Medication items",
"stock": "Stock & ERP",
"procurement": "Procurement",
"finance": "Finance",
@ -25,8 +35,8 @@
"loyalty": "Loyalty",
"campaigns": "Campaigns",
"dispatch": "Dispatch",
"profile": "My profile",
"team": "Team & Roles",
"inbox": "Service Inbox",
"audit": "Audit Log",
"faq": "Knowledge / FAQ",
"settings": "Settings",
@ -58,92 +68,810 @@
"back": "Back",
"commit": "Commit",
"validate": "Validate",
"preview": "Preview"
"preview": "Preview",
"searchPlaceholder": "Search…",
"clearSearch": "Clear search",
"clearFilters": "Clear filters",
"moreActions": "More actions",
"resultCount_one": "{{count}} result",
"resultCount_other": "{{count}} results",
"resultCountOf": "{{count}} of {{total}}",
"sortBy": "Sort by",
"columns": "Columns",
"backToList": "Back to list",
"details": "Details",
"summary": "Summary",
"activity": "Activity",
"noActivity": "No activity yet",
"id": "ID",
"name": "Name",
"customer": "Customer",
"phone": "Phone",
"sku": "SKU",
"product": "Product",
"qty": "Qty",
"total": "Total",
"amount": "Amount",
"date": "Date",
"open": "Open",
"send": "Send",
"delete": "Delete",
"enabled": "Enabled",
"disabled": "Disabled",
"yes": "Yes",
"no": "No",
"na": "N/A",
"none": "None",
"query": "Query",
"add": "Add",
"edit": "Edit",
"email": "Email",
"description": "Description",
"notes": "Notes",
"publish": "Publish",
"reason": "Reason",
"type": "Type",
"category": "Category",
"channel": "Channel",
"source": "Source",
"tier": "Tier",
"role": "Role",
"priority": "Priority",
"demoData": "Demo data",
"mock": "Mock"
},
"filters": {
"date": {
"all": "All dates",
"today": "Today",
"last7": "Last 7 days",
"last30": "Last 30 days",
"last90": "Last 90 days"
},
"period": "Period",
"allStatuses": "All statuses",
"allTypes": "All types",
"allChannels": "All channels",
"allSources": "All sources",
"allTiers": "All tiers",
"allRoles": "All roles",
"allStaff": "All staff",
"allItems": "All items",
"allStock": "All stock",
"allSignals": "All signals",
"allActors": "All actors",
"allCategories": "All categories",
"allRiders": "All delivery people",
"allCatchments": "All catchments",
"allPriorities": "All priorities",
"allDomains": "All domains",
"allFulfillment": "All fulfillment",
"allBranchScopes": "All branch scopes",
"allActivities": "All activities",
"unassigned": "Unassigned",
"assigned": "Assigned",
"hasControlled": "Controlled only",
"noControlled": "No controlled items",
"paid": "Paid",
"unpaid": "Unpaid",
"rider": "Delivery person",
"zone": "Zone",
"expiry": {
"all": "Any expiry",
"expired": "Already expired",
"in30": "Expiring within 30 days",
"in90": "Expiring within 90 days",
"in180": "Expiring within 180 days"
},
"creditUse": {
"all": "Any credit use",
"under50": "Under 50% used",
"from50to80": "50–80% used",
"over80": "Over 80% used"
}
},
"roles": {
"pharmacist": "Pharmacist",
"stock_manager": "Stock manager",
"procurement": "Procurement",
"finance": "Finance",
"marketing_manager": "Marketing manager",
"operations": "Operations",
"super_admin": "Super admin"
},
"modules": {
"faq_write": "Manage knowledge base"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Role-aware operational overview"
"subtitle": "Role-aware operational overview",
"stats": {
"todaysOrders": "Today's orders",
"pendingRx": "Pending Rx",
"unassignedDelivery": "Unassigned delivery",
"awaitingPickup": "Awaiting pickup",
"pendingOrgs": "Pending organisations",
"activeCreditOrgs": "Active credit organisations",
"creditUsed": "Credit used (ETB)",
"ordersToday": "Orders today",
"lowStockSkus": "Low stock SKUs",
"erpMismatches": "ERP mismatches",
"trackedSkus": "Tracked SKUs",
"branches": "Branches",
"openDispatch": "Open dispatch",
"unassigned": "Unassigned",
"outForDelivery": "Out for delivery",
"paidReady": "Paid and ready",
"orders": "Orders",
"rxQueue": "Rx queue"
},
"charts": {
"salesThisWeek": "Sales this week",
"salesThisWeekHint": "Demo revenue trend across all channels",
"channelMix": "Channel mix",
"channelMixHint": "Share of orders by origin",
"revenueByBranch": "Revenue by branch",
"revenueByBranchHint": "Month to date"
},
"recentActivity": "Recent activity",
"needsAttention": "Needs attention",
"quickContext": "Quick context",
"signedInAs": "Signed in as {{name}} ({{role}}).",
"scopedTo": "Scoped to {{branch}}.",
"platformWide": "Platform-wide view.",
"openOrders": "Open orders",
"viewAnalytics": "View analytics",
"reviewPendingOrgs": "Review pending organisations"
},
"orders": {
"title": "Orders",
"subtitle": "Branch inbox and fulfilment",
"assignRider": "Assign delivery person"
"subtitle": "Branch order queue and fulfilment",
"assignRider": "Assign delivery person",
"new": "New order",
"searchPlaceholder": "Search ID or customer…",
"deliveryPerson": "Delivery person",
"selectRider": "Select a delivery person…",
"paid": "Paid",
"unpaid": "Unpaid",
"rxApproved": "Rx approved",
"columns": {
"id": "ID",
"customer": "Customer",
"branch": "Branch",
"fulfillment": "Fulfillment",
"status": "Status",
"total": "Total",
"channel": "Channel",
"zone": "Zone",
"address": "Address",
"createdAt": "Created"
},
"status": {
"confirmed": "Confirmed",
"picking": "Picking",
"out_for_delivery": "Out for delivery",
"cancelled": "Cancelled",
"picked_up": "Picked up",
"en_route": "En route",
"arrived": "Arrived",
"delivered": "Delivered",
"failed": "Failed"
},
"fulfillment": {
"delivery": "Delivery",
"pickup": "Pickup"
},
"channel": {
"web": "Web",
"mobile": "Mobile",
"telegram": "Telegram",
"b2b": "B2B"
},
"toast": {
"confirmed": "Order confirmed",
"cancelled": "Order cancelled",
"riderAssigned": "Delivery person assigned",
"created": "Staff order created"
}
},
"prescriptions": {
"title": "Prescriptions",
"subtitle": "Pharmacist review queue",
"approve": "Approve",
"query": "Query",
"reject": "Reject"
"reject": "Reject",
"new": "New Rx intake",
"searchPlaceholder": "Search Rx, customer, or prescriber…",
"controlled": "Controlled",
"controlledOnly": "Controlled only",
"allPrescriptions": "All prescriptions",
"columns": {
"id": "ID",
"customer": "Customer",
"prescriber": "Prescriber",
"items": "Items",
"branch": "Branch",
"status": "Status",
"submittedAt": "Submitted"
},
"status": {
"submitted": "Submitted",
"under_review": "Under review",
"approved": "Approved",
"queried": "Queried",
"rejected": "Rejected"
},
"toast": {
"statusChanged": "Prescription marked {{status}}",
"created": "Prescription intake created"
}
},
"stock": {
"title": "Stock & ERP",
"subtitle": "Inventory sync, alerts, and merges"
"subtitle": "Inventory sync, alerts, and merges",
"addLine": "Add stock line",
"searchPlaceholder": "Search SKU or name…",
"lowStock": "Low stock",
"ok": "OK",
"erpMismatch": "ERP mismatch",
"alert": "Alert",
"tabs": {
"inventory": "Inventory",
"erp": "ERP sync",
"entry": "AI-assisted entry",
"transfers": "Transfers"
},
"columns": {
"sku": "SKU",
"name": "Name",
"branch": "Branch",
"qty": "Qty",
"batch": "Batch",
"expiry": "Expiry",
"alert": "Alert",
"erpQty": "ERP qty"
},
"erp": {
"platformQty": "Platform {{qty}}",
"erpQty": "ERP {{qty}}",
"resolveToErp": "Resolve to ERP",
"noDiscrepancies": "No open discrepancies",
"syncQueued": "ERP sync queued for {{sku}}"
},
"entry": {
"placeholder": "Name, barcode, or SKU…",
"possibleMatch": "Possible match: {{name}} ({{sku}}) — merging requires human approval"
},
"transfers": {
"description": "The stock manager owns inter-branch transfers. Suggest moving Vitamin C from Bole to CMC.",
"create": "Create transfer suggestion"
},
"toast": {
"discrepancyResolved": "Discrepancy resolved for {{sku}}",
"transferDraft": "Transfer draft created",
"transferCreated": "Inter-branch transfer draft created",
"lineAdded": "Stock line added"
}
},
"procurement": {
"title": "Procurement",
"subtitle": "Reorder recommendations and transfer requests"
"subtitle": "Reorder recommendations and transfer requests",
"newRequest": "New reorder request",
"searchRequests": "Search request, SKU, or product…",
"searchRestock": "Search SKU or product…",
"supplier": "Supplier",
"nudgeSupplier": "Nudge supplier",
"viewStock": "View stock",
"needsRestocking": "Needs restocking",
"needsRestockingHint": "Below {{units}} units, expiring within {{months}} months, or out of sync with ERP",
"expired": "Expired",
"monthsLeft": "{{count}} mo left",
"stats": {
"openRequests": "Open requests",
"openRequestsHint": "Awaiting a purchase order",
"awaitingDelivery": "Awaiting delivery",
"awaitingDeliveryHint": "Ordered from the supplier",
"received": "Received",
"receivedHint": "Closed this month",
"needsRestock": "Needs restocking",
"needsRestockHint": "Low stock, expiry, or ERP gap"
},
"finance": {
"title": "Finance",
"subtitle": "Settlements, gateways, refunds"
"tabs": {
"requests": "Requests",
"restock": "Restock",
"insight": "Supplier insight"
},
"columns": {
"id": "ID",
"sku": "SKU",
"product": "Product",
"qty": "Qty",
"branch": "Branch",
"priority": "Priority",
"status": "Status",
"onHand": "On hand",
"erp": "ERP",
"batch": "Batch",
"expiry": "Expiry",
"reason": "Reason"
},
"priority": {
"high": "High",
"medium": "Medium",
"low": "Low"
},
"status": {
"open": "Open",
"ordered": "Ordered",
"received": "Received"
},
"signal": {
"low": "Low stock",
"expiry": "Near expiry",
"mismatch": "ERP mismatch"
},
"suggested": {
"title": "Suggested restock",
"description": "AI reorder signals across branches (demo data)",
"transfer": "Transfer",
"reorder": "Reorder",
"requestTransfer": "Request transfer",
"createRequest": "Create request",
"orderUnits": "Order {{count}}",
"interBranchTransfer": "inter-branch transfer",
"units": "{{count}} units",
"label": "Suggested"
},
"charts": {
"stockFlow": "Stock inflow vs outflow",
"stockFlowHint": "Units moved per week (demo data)",
"revenueByBranch": "Revenue by branch",
"revenueByBranchHint": "Pull-through demand (demo data)",
"inflow": "Inflow",
"outflow": "Outflow",
"unitsValue": "{{count}} units"
},
"toast": {
"supplierReminder": "Supplier reminder sent for {{id}}",
"transferRequest": "Transfer request sent to the stock manager",
"transferRequestFor": "Transfer request sent to the stock manager for {{sku}}",
"poDraft": "Purchase order draft for {{count}} units",
"reorderDrafted": "Reorder request drafted for {{sku}}",
"requestCreated": "Reorder draft created for {{sku}}",
"alternateSupplier": "AI suggested an alternate supplier: {{name}}"
}
},
"organisations": {
"title": "B2B Organisations",
"subtitle": "Create orgs and approve self-registrations",
"pending": "Pending approval",
"createOrg": "Create organisation"
"createOrg": "Create organisation",
"searchPlaceholder": "Search name, TIN, or contact…",
"creditLimit": "Credit limit",
"creditUsed": "Credit used",
"billingContact": "Billing contact",
"tin": "TIN",
"pendingBanner_one": "{{count}} self-registration awaiting approval. Filter by status Pending to review.",
"pendingBanner_other": "{{count}} self-registrations awaiting approval. Filter by status Pending to review.",
"columns": {
"name": "Name",
"tin": "TIN",
"source": "Source",
"limit": "Limit",
"used": "Used",
"status": "Status",
"createdAt": "Created"
},
"status": {
"pending": "Pending",
"active": "Active",
"rejected": "Rejected"
},
"source": {
"admin": "Admin created",
"self_register": "Self-registered"
},
"toast": {
"approved": "Organisation approved — credit active",
"rejected": "Organisation rejected",
"created": "Organisation created — credit active",
"creditLimitSubmitted": "Credit limit change submitted for review"
}
},
"crm": {
"title": "CRM",
"subtitle": "Customer records (read only)",
"searchPlaceholder": "Search name or phone…",
"points": "Points",
"columns": {
"name": "Name",
"phone": "Phone",
"tier": "Tier",
"points": "Points",
"branch": "Branch"
},
"tier": {
"bronze": "Bronze",
"silver": "Silver",
"gold": "Gold"
},
"toast": {
"loyaltyBonus": "Loyalty bonus of {{count}} points queued",
"campaignEnrollment": "Campaign enrollment saved",
"messageDraft": "Message draft saved"
}
},
"finance": {
"title": "Finance",
"subtitle": "Settlements, gateways, refunds",
"manageGateways": "Manage gateways",
"searchPlaceholder": "Search channel or batch…",
"dailySettlements": "Daily settlements",
"processRefund": "Process refund",
"paymentGateways": "Payment gateways",
"operations": "Operations",
"operationsHint": "Loyalty liability and manual finance actions",
"loyaltyLiabilityLine": "Loyalty points liability ({{month}})",
"gatewayNote": "Gateway enable and disable lives in the Gateways tab and on its own settings page, so day-to-day finance work stays focused on settlements and refunds.",
"stats": {
"settledToday": "Settled today",
"settledTodayHint": "Reconciled batches",
"pendingReconciliation": "Pending reconciliation",
"pendingReconciliationHint": "Awaiting match",
"transactions": "Transactions",
"transactionsHint": "Across all channels",
"loyaltyLiability": "Loyalty liability",
"loyaltyLiabilityHint": "Points outstanding"
},
"tabs": {
"overview": "Overview",
"gateways": "Gateways"
},
"columns": {
"channel": "Channel",
"date": "Date",
"volume": "Volume",
"txns": "Transactions",
"fees": "Fees",
"status": "Status"
},
"status": {
"reconciled": "Reconciled",
"pending": "Pending"
},
"charts": {
"settlementVolume": "Settlement volume by channel",
"settlementVolumeHint": "Daily gateway settlements (demo data)",
"channelMix": "Channel mix",
"channelMixHint": "Share of paid orders (demo data)"
},
"gateways": {
"title": "Channel controls",
"description": "Changes apply immediately in the demo. Telebirr and M-Pesa may require INSA clearance before go-live.",
"live": "Live",
"off": "Off",
"enable": "Enable channel",
"disable": "Disable channel",
"aggregator": "Aggregator",
"directWallet": "Direct wallet",
"toastEnabled": "{{name}} enabled (no code release)",
"toastDisabled": "{{name}} disabled (no code release)"
},
"toast": {
"refundQueued": "Refund queued",
"reconciliationRerun": "Reconciliation re-run queued",
"csvExport": "CSV export started"
}
},
"dispatch": {
"title": "Dispatch Board",
"subtitle": "Trips, riders, and Telegram bot status"
"subtitle": "Trips, delivery people, and Telegram bot status",
"assignRider": "Assign delivery person",
"liveQueue": "Live queue (paid and Rx approved)",
"map": "Map (demo)",
"mapOverlay": "Active delivery people and open deliveries overlay",
"telegramBot": "Telegram dispatch bot",
"openIntake": "Open order intake",
"openIntakeHint": "Watching webhooks from POS, ERP, and partners (demo: 2 partner orders in the last hour).",
"botReady": "Bot ready · drivers linked by Telegram username",
"tripPushed": "Trip {{id}} → {{rider}}: manifest pushed (stops, cash on delivery)",
"botCallback": "Bot callback {{id}}: {{status}}",
"stats": {
"openDeliveries": "Open deliveries",
"unassigned": "Unassigned",
"enRoute": "En route",
"activeRiders": "Active delivery people"
},
"charts": {
"tripsByHour": "Trips by hour",
"tripsByHourHint": "Demo dispatch load",
"trips": "Trips"
},
"toast": {
"tripAssigned": "Trip assigned · Telegram dispatch bot notified",
"statusUpdated": "Board and customer tracking updated: {{status}}"
}
},
"audit": {
"title": "Audit log",
"subtitle": "Sync, adjustments, overrides, approvals",
"exportLog": "Export log",
"searchPlaceholder": "Search actor, action, or target…",
"columns": {
"when": "When",
"actor": "Actor",
"action": "Action",
"target": "Target"
},
"domain": {
"label": "Domain",
"rx": "Prescriptions",
"org": "Organisations",
"stock": "Stock",
"order": "Orders"
},
"toast": {
"exportQueued": "Audit export queued"
}
},
"team": {
"title": "Team & Roles",
"subtitle": "Staff accounts and role permissions",
"addStaff": "Add staff",
"searchPlaceholder": "Search name or email…",
"resetPassword": "Reset password",
"resendInvite": "Resend invite",
"disableAccount": "Disable account",
"columns": {
"name": "Name",
"email": "Email",
"role": "Role",
"branch": "Branch"
},
"branchScope": {
"label": "Branch scope",
"scoped": "Branch-scoped",
"platformWide": "Platform-wide"
},
"toast": {
"added": "Staff member added (demo login password: demo123)",
"passwordReset": "Password reset email queued",
"inviteResent": "Invite re-sent",
"accountDisabled": "Account disabled"
}
},
"faq": {
"title": "Knowledge base",
"subtitle": "Shared operational FAQs",
"new": "New article"
"new": "New article",
"publish": "Publish",
"unpublish": "Unpublish",
"openArticle": "Open article",
"searchPlaceholder": "Search articles…",
"published": "Published",
"draft": "Draft",
"category": {
"general": "General",
"orders": "Orders",
"prescriptions": "Prescriptions",
"stock": "Stock",
"payments": "Payments",
"b2b": "B2B"
},
"fields": {
"category": "Category",
"titleEn": "Title (English)",
"titleAm": "Title (Amharic)",
"bodyEn": "Body (English)",
"bodyAm": "Body (Amharic)"
},
"toast": {
"published": "FAQ published",
"saved": "FAQ article saved",
"unpublished": "Article unpublished"
}
},
"migrations": {
"title": "Data Migration",
"subtitle": "Flexible CSV/Excel import with column mapping",
"templates": "Import templates",
"mapColumns": "Map columns",
"dryRun": "Dry-run validation"
"dryRun": "Dry-run validation",
"fields": "Fields: {{list}}",
"uploadTitle": "Upload CSV / Excel (CSV text parsed in the demo)",
"useSample": "Use sample stock CSV",
"skip": "— skip —",
"validationPassed": "Validation passed",
"jobLogged": "Job logged to the audit trail.",
"steps": {
"template": "Template",
"upload": "Upload",
"map": "Map",
"validate": "Validate",
"done": "Done"
},
"team": {
"title": "Team & Roles",
"subtitle": "Staff users and RBAC"
"errors": {
"unmapped": "Unmapped fields: {{list}}",
"emptyRows": "Empty rows detected"
},
"crm": {
"title": "CRM",
"subtitle": "Customer records (read)"
},
"loyalty": {
"title": "Loyalty programme",
"subtitle": "Earn rates, tiers, referral bonus"
},
"campaigns": {
"title": "Campaigns",
"subtitle": "SMS, Telegram, push to segments"
},
"inbox": {
"title": "Service inbox",
"subtitle": "Chatwoot, Telegram, WhatsApp threads"
},
"audit": {
"title": "Audit log",
"subtitle": "Sync, adjustments, overrides, approvals"
},
"settings": {
"title": "Settings",
"subtitle": "Platform-wide preferences"
"toast": {
"columnsDetected_one": "Detected {{count}} column",
"columnsDetected_other": "Detected {{count}} columns",
"fixErrors": "Fix the validation errors first",
"committed": "Import committed · template {{template}} · {{count}} sample rows"
}
},
"analytics": {
"title": "Analytics",
"subtitle": "Sales and growth KPIs"
"subtitle": "Sales and growth KPIs",
"exportReport": "Export report",
"stats": {
"revenue7d": "Revenue (7d)",
"repeatPurchase": "Repeat purchase",
"orders7d": "Orders (7d)",
"fastestSku": "Fastest SKU"
},
"charts": {
"salesVsOrders": "Sales vs orders",
"salesVsOrdersHint": "Daily revenue with order volume overlay",
"channelMix": "Channel mix",
"channelMixHint": "Share of orders",
"branchPerformance": "Branch performance",
"branchPerformanceHint": "Revenue and Rx volume",
"paymentChannels": "Payment channels",
"paymentChannelsHint": "Settlement volume trend"
},
"series": {
"sales": "Sales (ETB)",
"orders": "Orders",
"revenue": "Revenue",
"rx": "Rx",
"cod": "Cash on delivery"
},
"toast": {
"exportQueued": "Analytics export queued"
}
},
"settings": {
"title": "Settings",
"subtitle": "Platform-wide preferences",
"platform": "Platform",
"dataRetention": "Data retention (days)",
"apiBaseUrl": "API base URL",
"toast": {
"saved": "Settings saved"
}
},
"campaigns": {
"title": "Campaigns",
"subtitle": "SMS, Telegram, and push to segments",
"compose": "Compose campaign",
"channel": "Channel",
"segment": "Segment",
"message": "Message",
"schedule": "Schedule (optional)",
"send": "Send / schedule",
"channels": {
"sms": "SMS",
"telegram": "Telegram",
"push": "Push"
},
"segments": {
"chronic": "Chronic refill",
"lapsed": "Lapsed buyers",
"winback": "Win-back"
},
"toast": {
"queued": "Campaign queued on {{channel}} → {{segment}}"
}
},
"loyalty": {
"title": "Loyalty programme",
"subtitle": "Earn rates, tiers, referral bonus",
"awardPoints": "Award points",
"searchCustomer": "Search customer name…",
"stats": {
"pointsAwarded": "Points awarded (all time)",
"pointsAwardedHint_one": "{{count}} ledger entry",
"pointsAwardedHint_other": "{{count}} ledger entries",
"awardedIn": "Awarded in {{month}}",
"awardedInHint": "Current reporting month",
"pointsRedeemed": "Points redeemed",
"pointsRedeemedHint": "{{percent}}% of points issued",
"outstandingLiability": "Outstanding liability",
"outstandingLiabilityHint": "{{points}} pts × {{rate}} ETB (demo rate)",
"activeMembers": "Active members",
"activeMembersHint": "With ledger activity"
},
"charts": {
"pointsMovement": "Points issued vs redeemed",
"pointsMovementHint": "Programme-wide monthly movement",
"pointsByActivity": "Points by activity",
"earned": "Earned",
"redeemed": "Redeemed"
},
"tabs": {
"ledger": "Points ledger",
"members": "Per customer",
"activities": "Earning activities",
"settings": "Programme settings"
},
"columns": {
"customer": "Customer",
"activity": "Activity",
"points": "Points",
"order": "Order",
"branch": "Branch",
"date": "Date",
"tier": "Tier",
"earned": "Earned",
"redeemed": "Redeemed",
"balance": "Balance",
"lastActivity": "Last activity",
"description": "Description",
"basis": "Basis",
"tierMultiplier": "Tier multiplier"
},
"direction": {
"label": "Direction",
"all": "Earned & redeemed",
"earned": "Earned only",
"redeemed": "Redeemed only"
},
"activities": {
"title": "Which activities award points",
"description": "Earning rules applied by the loyalty engine. Tier multipliers stack on top of the base award.",
"pause": "Pause",
"activate": "Activate",
"active": "Active",
"paused": "Paused"
},
"settings": {
"title": "No-code loyalty config",
"earnRate": "Earn rate (points per ETB)",
"referralBonus": "Referral bonus (points)",
"minRedemption": "Minimum redemption",
"tiersNote": "Tiers: Bronze / Silver / Gold — thresholds editable in the full config.",
"redemptionNote": "Redemption value is a demo constant: 1 point = {{rate}} ETB, used for the liability figure above."
},
"toast": {
"exportQueued": "Points ledger export queued",
"manualAward": "Manual points award drafted",
"activated": "{{name}} activated",
"paused": "{{name}} paused",
"saved": "Loyalty rules saved",
"editing": "Editing the “{{name}}” rule"
}
},
"marketing": {
"title": "Marketing overview",
"subtitle": "Segments and reports"
"subtitle": "Segments and reports",
"newCampaign": "New campaign",
"selfUpdating": "Self-updating",
"viewMembers": "View members",
"target": "Target",
"stats": {
"reach": "Addressable reach",
"activeSegments": "Active segments",
"campaignsSent": "Campaigns sent (30d)",
"openRate": "Avg. open rate"
},
"charts": {
"segmentSize": "Segment size",
"segmentSizeHint": "Members per self-updating segment",
"reachByChannel": "Reach by channel",
"reachByChannelHint": "Where campaigns land",
"members": "Members"
},
"segments": {
"chronic": "Chronic refill",
"lapsed": "Lapsed buyers",
"highValue": "High-value households",
"institutional": "Institutional accounts",
"boleCatchment": "Bole catchment"
},
"toast": {
"campaignDraft": "Campaign draft targeting “{{name}}”"
}
}
}

1130
src/mocks/catalog.ts Normal file

File diff suppressed because it is too large Load Diff

326
src/mocks/charts.ts Normal file
View File

@ -0,0 +1,326 @@
export const weeklySales = [
{ day: 'Mon', sales: 42000, orders: 38 },
{ day: 'Tue', sales: 51000, orders: 45 },
{ day: 'Wed', sales: 48000, orders: 41 },
{ day: 'Thu', sales: 61000, orders: 52 },
{ day: 'Fri', sales: 72000, orders: 61 },
{ day: 'Sat', sales: 88000, orders: 74 },
{ day: 'Sun', sales: 39000, orders: 33 },
]
export const channelMix = [
{ name: 'Web', value: 48, fill: '#A8C73A' },
{ name: 'Mobile', value: 31, fill: '#E28A1A' },
{ name: 'Telegram', value: 14, fill: '#141414' },
{ name: 'B2B', value: 7, fill: '#6B7280' },
]
export const branchRevenue = [
{ branch: 'Bole', revenue: 186000, rx: 42 },
{ branch: 'CMC', revenue: 142000, rx: 31 },
{ branch: 'Piassa', revenue: 98000, rx: 22 },
]
export const settlementTrend = [
{ day: 'Aug 1', chapa: 62000, telebirr: 41000, cod: 12000 },
{ day: 'Aug 2', chapa: 71000, telebirr: 48000, cod: 15000 },
{ day: 'Aug 3', chapa: 68000, telebirr: 45000, cod: 11000 },
{ day: 'Aug 4', chapa: 79000, telebirr: 52000, cod: 18000 },
{ day: 'Aug 5', chapa: 84200, telebirr: 52100, cod: 18300 },
{ day: 'Aug 6', chapa: 76000, telebirr: 49000, cod: 14000 },
]
export const dispatchByHour = [
{ hour: '08', trips: 4 },
{ hour: '09', trips: 7 },
{ hour: '10', trips: 11 },
{ hour: '11', trips: 9 },
{ hour: '12', trips: 6 },
{ hour: '13', trips: 8 },
{ hour: '14', trips: 12 },
{ hour: '15', trips: 10 },
{ hour: '16', trips: 14 },
{ hour: '17', trips: 9 },
]
export const stockMovement = [
{ week: 'W1', in: 420, out: 380 },
{ week: 'W2', in: 510, out: 460 },
{ week: 'W3', in: 390, out: 440 },
{ week: 'W4', in: 560, out: 520 },
]
export const orgSpendSeries = [
{ month: 'Mar', spend: 42000 },
{ month: 'Apr', spend: 51000 },
{ month: 'May', spend: 48000 },
{ month: 'Jun', spend: 62000 },
{ month: 'Jul', spend: 71000 },
{ month: 'Aug', spend: 58000 },
]
export const loyaltyPointsSeries = [
{ month: 'Mar', earned: 18400, redeemed: 6200 },
{ month: 'Apr', earned: 21600, redeemed: 7400 },
{ month: 'May', earned: 24800, redeemed: 9100 },
{ month: 'Jun', earned: 27300, redeemed: 8600 },
{ month: 'Jul', earned: 31900, redeemed: 12400 },
{ month: 'Aug', earned: 14200, redeemed: 4800 },
]
export const customerPointsSeries = [
{ month: 'Mar', points: 180 },
{ month: 'Apr', points: 320 },
{ month: 'May', points: 410 },
{ month: 'Jun', points: 520 },
{ month: 'Jul', points: 680 },
{ month: 'Aug', points: 740 },
]
export const segmentEngagementSeries = [
{ segment: 'Chronic refill', reach: 1280, engaged: 742, rate: 58 },
{ segment: 'Bole catchment', reach: 940, engaged: 423, rate: 45 },
{ segment: 'Lapsed buyers', reach: 640, engaged: 122, rate: 19 },
{ segment: 'New customers', reach: 428, engaged: 231, rate: 54 },
{ segment: 'Rx follow-up', reach: 315, engaged: 208, rate: 66 },
{ segment: 'High-value', reach: 210, engaged: 147, rate: 70 },
{ segment: 'Institutional', reach: 18, engaged: 14, rate: 78 },
]
export const segmentGrowth: Record<string, { month: string; members: number }[]> = {
'seg-chronic': [
{ month: 'Mar', members: 810 },
{ month: 'Apr', members: 902 },
{ month: 'May', members: 1004 },
{ month: 'Jun', members: 1118 },
{ month: 'Jul', members: 1211 },
{ month: 'Aug', members: 1280 },
],
'seg-lapsed': [
{ month: 'Mar', members: 402 },
{ month: 'Apr', members: 468 },
{ month: 'May', members: 531 },
{ month: 'Jun', members: 587 },
{ month: 'Jul', members: 612 },
{ month: 'Aug', members: 640 },
],
'seg-highvalue': [
{ month: 'Mar', members: 132 },
{ month: 'Apr', members: 148 },
{ month: 'May', members: 161 },
{ month: 'Jun', members: 178 },
{ month: 'Jul', members: 196 },
{ month: 'Aug', members: 210 },
],
'seg-institutional': [
{ month: 'Mar', members: 9 },
{ month: 'Apr', members: 11 },
{ month: 'May', members: 12 },
{ month: 'Jun', members: 14 },
{ month: 'Jul', members: 16 },
{ month: 'Aug', members: 18 },
],
'seg-bole': [
{ month: 'Mar', members: 640 },
{ month: 'Apr', members: 702 },
{ month: 'May', members: 768 },
{ month: 'Jun', members: 841 },
{ month: 'Jul', members: 902 },
{ month: 'Aug', members: 940 },
],
'seg-rxfollow': [
{ month: 'Mar', members: 180 },
{ month: 'Apr', members: 214 },
{ month: 'May', members: 246 },
{ month: 'Jun', members: 271 },
{ month: 'Jul', members: 298 },
{ month: 'Aug', members: 315 },
],
'seg-new30': [
{ month: 'Mar', members: 302 },
{ month: 'Apr', members: 341 },
{ month: 'May', members: 366 },
{ month: 'Jun', members: 388 },
{ month: 'Jul', members: 411 },
{ month: 'Aug', members: 428 },
],
}
export const campaignPerformance = [
{ name: 'Aug refill nudge', delivered: 1204, opened: 698, clicked: 241 },
{ name: 'Bole weekend offer', delivered: 902, opened: 388, clicked: 132 },
{ name: 'Win-back 20%', delivered: 618, opened: 141, clicked: 44 },
{ name: 'Gold tier perks', delivered: 206, opened: 148, clicked: 71 },
{ name: 'Rx pickup reminder', delivered: 311, opened: 214, clicked: 96 },
{ name: 'Ramadan wellness', delivered: 1420, opened: 604, clicked: 188 },
{ name: 'Corporate renewal', delivered: 18, opened: 14, clicked: 9 },
{ name: 'Flu season prep', delivered: 874, opened: 402, clicked: 143 },
]
export const campaignChannelBreakdown: Record<string, { name: string; value: number; fill: string }[]> = {
'cmp-201': [
{ name: 'Delivered', value: 1204, fill: '#A8C73A' },
{ name: 'Bounced', value: 76, fill: '#E28A1A' },
],
'cmp-202': [
{ name: 'Delivered', value: 902, fill: '#A8C73A' },
{ name: 'Bounced', value: 38, fill: '#E28A1A' },
],
'cmp-203': [
{ name: 'Delivered', value: 618, fill: '#A8C73A' },
{ name: 'Bounced', value: 22, fill: '#E28A1A' },
],
}
export const campaignDailySends: Record<string, { day: string; delivered: number; opened: number; clicked: number }[]> = {
'cmp-201': [
{ day: 'Day 1', delivered: 802, opened: 401, clicked: 142 },
{ day: 'Day 2', delivered: 402, opened: 208, clicked: 71 },
{ day: 'Day 3', delivered: 0, opened: 62, clicked: 21 },
{ day: 'Day 4', delivered: 0, opened: 27, clicked: 7 },
],
'cmp-202': [
{ day: 'Day 1', delivered: 640, opened: 241, clicked: 88 },
{ day: 'Day 2', delivered: 262, opened: 102, clicked: 32 },
{ day: 'Day 3', delivered: 0, opened: 45, clicked: 12 },
],
}
export const auditActivitySeries = [
{ day: 'Aug 1', events: 4, security: 1 },
{ day: 'Aug 2', events: 6, security: 0 },
{ day: 'Aug 3', events: 3, security: 1 },
{ day: 'Aug 4', events: 7, security: 2 },
{ day: 'Aug 5', events: 8, security: 1 },
{ day: 'Aug 6', events: 12, security: 3 },
]
export const migrationVolumeSeries = [
{ month: 'Mar', imported: 1840, failed: 62 },
{ month: 'Apr', imported: 2410, failed: 88 },
{ month: 'May', imported: 1960, failed: 41 },
{ month: 'Jun', imported: 3120, failed: 104 },
{ month: 'Jul', imported: 2780, failed: 57 },
{ month: 'Aug', imported: 1642, failed: 39 },
]
export const riderOnTimeSeries: Record<string, { day: string; onTime: number; late: number }[]> = {
'rd-1': [
{ day: 'Jul 31', onTime: 9, late: 1 },
{ day: 'Aug 1', onTime: 11, late: 0 },
{ day: 'Aug 2', onTime: 8, late: 2 },
{ day: 'Aug 3', onTime: 12, late: 1 },
{ day: 'Aug 4', onTime: 10, late: 1 },
{ day: 'Aug 5', onTime: 13, late: 0 },
{ day: 'Aug 6', onTime: 6, late: 1 },
],
'rd-2': [
{ day: 'Jul 31', onTime: 7, late: 2 },
{ day: 'Aug 1', onTime: 9, late: 1 },
{ day: 'Aug 2', onTime: 6, late: 3 },
{ day: 'Aug 3', onTime: 10, late: 1 },
{ day: 'Aug 4', onTime: 8, late: 2 },
{ day: 'Aug 5', onTime: 11, late: 1 },
{ day: 'Aug 6', onTime: 5, late: 2 },
],
'rd-3': [
{ day: 'Jul 31', onTime: 10, late: 0 },
{ day: 'Aug 1', onTime: 12, late: 1 },
{ day: 'Aug 2', onTime: 9, late: 1 },
{ day: 'Aug 3', onTime: 11, late: 0 },
{ day: 'Aug 4', onTime: 13, late: 1 },
{ day: 'Aug 5', onTime: 10, late: 2 },
{ day: 'Aug 6', onTime: 7, late: 0 },
],
}
export const branchOrdersSeries: Record<string, { day: string; orders: number; revenue: number }[]> = {
'br-bole': [
{ day: 'Jul 31', orders: 41, revenue: 52000 },
{ day: 'Aug 1', orders: 48, revenue: 61000 },
{ day: 'Aug 2', orders: 55, revenue: 72000 },
{ day: 'Aug 3', orders: 38, revenue: 44000 },
{ day: 'Aug 4', orders: 52, revenue: 66000 },
{ day: 'Aug 5', orders: 61, revenue: 79000 },
{ day: 'Aug 6', orders: 34, revenue: 41000 },
],
'br-cmc': [
{ day: 'Jul 31', orders: 28, revenue: 38000 },
{ day: 'Aug 1', orders: 33, revenue: 45000 },
{ day: 'Aug 2', orders: 39, revenue: 54000 },
{ day: 'Aug 3', orders: 24, revenue: 31000 },
{ day: 'Aug 4', orders: 36, revenue: 49000 },
{ day: 'Aug 5', orders: 42, revenue: 58000 },
{ day: 'Aug 6', orders: 21, revenue: 27000 },
],
'br-piassa': [
{ day: 'Jul 31', orders: 18, revenue: 22000 },
{ day: 'Aug 1', orders: 22, revenue: 27000 },
{ day: 'Aug 2', orders: 26, revenue: 33000 },
{ day: 'Aug 3', orders: 15, revenue: 18000 },
{ day: 'Aug 4', orders: 24, revenue: 30000 },
{ day: 'Aug 5', orders: 29, revenue: 37000 },
{ day: 'Aug 6', orders: 14, revenue: 17000 },
],
}
/**
* Slice colours for the dashboard category donut, keyed by `productCategories`
* in mocks/data.ts. Brand palette only — brand greens, orange, forest, greys.
*/
export const productCategoryColors: Record<string, string> = {
Antibiotics: '#A8C73A',
Analgesics: '#E28A1A',
'Chronic care': '#141414',
'Vitamins & supplements': '#8FAD2F',
Respiratory: '#C97310',
'Maternal & child': '#C0D65A',
Dermatology: '#5C5F56',
'First aid': '#9A9E90',
}
/** Order origin labels and colours, keyed by `Order.channel`. */
export const orderChannels: { key: string; label: string; fill: string }[] = [
{ key: 'web', label: 'Web', fill: '#A8C73A' },
{ key: 'mobile', label: 'Mobile', fill: '#E28A1A' },
{ key: 'telegram', label: 'Telegram', fill: '#141414' },
{ key: 'b2b', label: 'B2B', fill: '#9A9E90' },
]
/**
* Prescription review funnel, widest stage first. `statuses` lists every
* `Prescription.status` that counts as having reached the stage.
*/
export const rxFunnelStages: { key: string; label: string; statuses: string[]; fill: string }[] = [
{
key: 'submitted',
label: 'Submitted',
statuses: ['submitted', 'under_review', 'queried', 'approved', 'rejected'],
fill: '#D4E48A',
},
{
key: 'under_review',
label: 'Under review',
statuses: ['under_review', 'queried', 'approved', 'rejected'],
fill: '#B4CE45',
},
{ key: 'approved', label: 'Approved', statuses: ['approved'], fill: '#768F28' },
]
/**
* Counter QR scans per trading hour, split by how the code reached the system.
* `manual` covers keypad entry — it is deliberately never zero, because the
* counter has to keep serving when a camera or an integration is unavailable.
*/
export const counterScansByHour = [
{ hour: '08', camera: 6, manual: 3, ticket: 2 },
{ hour: '09', camera: 11, manual: 4, ticket: 3 },
{ hour: '10', camera: 14, manual: 5, ticket: 4 },
{ hour: '11', camera: 12, manual: 3, ticket: 2 },
{ hour: '12', camera: 8, manual: 2, ticket: 1 },
{ hour: '13', camera: 9, manual: 4, ticket: 2 },
{ hour: '14', camera: 13, manual: 3, ticket: 3 },
{ hour: '15', camera: 15, manual: 6, ticket: 4 },
{ hour: '16', camera: 17, manual: 5, ticket: 5 },
{ hour: '17', camera: 10, manual: 4, ticket: 2 },
]

File diff suppressed because it is too large Load Diff

View File

@ -1,57 +1,175 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
ComposedChart,
Legend,
Line,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Download, TrendingUp, Receipt, Package, ShoppingBag } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
const data = [
{ day: 'Mon', sales: 42000 },
{ day: 'Tue', sales: 51000 },
{ day: 'Wed', sales: 48000 },
{ day: 'Thu', sales: 61000 },
{ day: 'Fri', sales: 72000 },
{ day: 'Sat', sales: 88000 },
{ day: 'Sun', sales: 39000 },
]
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/zustand/authStore'
import { canAccess } from '@/config/permissions'
import { orderLines } from '@/mocks/data'
import { branchRevenue, channelMix, settlementTrend, weeklySales } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
export function AnalyticsPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const kpis = useMemo(() => {
const revenue = weeklySales.reduce((sum, day) => sum + day.sales, 0)
const orders = weeklySales.reduce((sum, day) => sum + day.orders, 0)
const unitsBySku = new Map<string, { name: string; units: number }>()
for (const lines of Object.values(orderLines)) {
for (const line of lines) {
const current = unitsBySku.get(line.sku)
unitsBySku.set(line.sku, { name: line.name, units: (current?.units ?? 0) + line.qty })
}
}
const [fastestSku, fastest] = Array.from(unitsBySku.entries()).sort((a, b) => b[1].units - a[1].units)[0] ?? [
'—',
{ name: '', units: 0 },
]
return {
revenue,
orders,
averageOrderValue: orders ? revenue / orders : 0,
days: weeklySales.length,
fastestSku,
fastestUnits: fastest.units,
}
}, [])
return (
<PageShell title={t('analytics.title')} subtitle={t('analytics.subtitle')}>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-sm">Repeat purchase</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl font-bold">34%</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Fastest SKU</CardTitle>
</CardHeader>
<CardContent className="font-display text-xl font-bold">Paracetamol 500mg</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Channel mix</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">Web 48% · Mobile 31% · TG 14% · B2B 7%</CardContent>
</Card>
<PageShell
title={t('analytics.title')}
subtitle={t('analytics.subtitle')}
actions={
<Button
variant="outline"
className="h-10 gap-2 shadow-none"
onClick={() => toast.success('Analytics export queued (mock)')}
>
<Download className="h-4 w-4" />
Export report
</Button>
}
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label={t('analytics.stats.revenue7d')}
value={formatEtb(kpis.revenue)}
hint={`Across the last ${kpis.days} days`}
icon={TrendingUp}
tone="brand"
/>
<StatCard
label={t('analytics.stats.orders7d')}
value={kpis.orders.toLocaleString()}
hint={`${Math.round(kpis.orders / kpis.days)} orders a day on average`}
icon={ShoppingBag}
tone="forest"
/>
<StatCard
label="Average order value"
value={formatEtb(kpis.averageOrderValue)}
hint="Revenue divided by orders"
icon={Receipt}
tone="orange"
/>
<StatCard
label={t('analytics.stats.fastestSku')}
value={kpis.fastestSku}
hint={`${kpis.fastestUnits} units in sampled baskets`}
icon={Package}
tone="muted"
to={user && canAccess(user.role, 'stock') ? `/stock/${kpis.fastestSku}` : undefined}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Daily sales by channel (demo)</CardTitle>
</CardHeader>
<CardContent className="h-72">
<div className="grid gap-4 xl:grid-cols-3">
<ChartCard
title="Sales vs orders"
description="Daily revenue with order volume overlay"
className="xl:col-span-2"
height="h-72"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<XAxis dataKey="day" />
<YAxis />
<ComposedChart data={weeklySales}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="sales" fill="#A8C73A" radius={[8, 8, 0, 0]} />
<Legend />
<Bar yAxisId="left" dataKey="sales" fill="#A8C73A" radius={[8, 8, 0, 0]} name="Sales (ETB)" />
<Line yAxisId="right" type="monotone" dataKey="orders" stroke="#E28A1A" strokeWidth={2} name="Orders" />
</ComposedChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Channel mix" description="Share of orders" height="h-72">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={channelMix} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85} paddingAngle={3}>
{channelMix.map((slice) => (
<Cell key={slice.name} fill={slice.fill} />
))}
</Pie>
<Tooltip formatter={(v) => `${v}%`} />
<Legend verticalAlign="bottom" height={24} iconType="circle" />
</PieChart>
</ResponsiveContainer>
</ChartCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard title="Branch performance" description="Revenue and Rx volume">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={branchRevenue}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="branch" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar dataKey="revenue" fill="#A8C73A" radius={[6, 6, 0, 0]} name="Revenue" />
<Bar dataKey="rx" fill="#E28A1A" radius={[6, 6, 0, 0]} name="Rx" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</ChartCard>
<ChartCard title="Payment channels" description="Settlement volume trend">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={settlementTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => formatEtb(Number(v))} />
<Legend />
<Area type="monotone" dataKey="chapa" stackId="1" stroke="#A8C73A" fill="#A8C73A66" name="Chapa" />
<Area type="monotone" dataKey="telebirr" stackId="1" stroke="#E28A1A" fill="#E28A1A66" name="Telebirr" />
<Area type="monotone" dataKey="cod" stackId="1" stroke="#141414" fill="#14141433" name="COD" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
</div>
</PageShell>
)
}

View File

@ -0,0 +1,296 @@
import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ArrowUpRight, Copy, Download } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import type { TimelineEvent } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { auditLog, orders, prescriptions } from '@/mocks/data'
import type { AuditCategory } from '@/mocks/data'
/**
* Maps an audit target id to its detail route, when the entity has one.
* Purged historical records stay unlinked so a link never lands on a dead page.
*/
function targetLink(target: string) {
if (target.startsWith('rx-')) {
return prescriptions.some((p) => p.id === target) ? `/prescriptions/${target}` : null
}
if (target.startsWith('ord-')) {
return orders.some((o) => o.id === target) ? `/orders/${target}` : null
}
if (target.startsWith('org-')) return `/organisations/${target}`
if (target.startsWith('pr-')) return `/procurement/${target}`
if (target.startsWith('mig-')) return `/migrations/${target}`
if (target.startsWith('cmp-')) return `/campaigns/${target}`
if (target.startsWith('seg-')) return `/marketing/${target}`
if (target.startsWith('br-')) return `/settings/branches/${target}`
if (target.startsWith('rd-')) return `/dispatch/riders/${target}`
if (target.startsWith('tm-')) return `/team/${target}`
if (target.startsWith('st-')) return `/finance/settlements/${target}`
if (/^[A-Z]{2,4}-/.test(target)) return `/stock/${target}`
return null
}
const categoryLabels: Record<AuditCategory, string> = {
prescriptions: 'Prescriptions',
orders: 'Orders',
stock: 'Stock',
procurement: 'Procurement',
organisations: 'Organisations',
finance: 'Finance',
marketing: 'Marketing',
migrations: 'Migrations',
settings: 'Settings',
security: 'Security',
}
function humanise(role: string) {
const spaced = role.replace(/_/g, ' ')
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}
export function AuditEventDetailPage() {
const { id } = useParams()
const entry = auditLog.find((e) => e.id === id)
const changedKeys = useMemo(() => {
const before: Record<string, unknown> = entry?.before ?? {}
const after: Record<string, unknown> = entry?.after ?? {}
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]))
return keys.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
}, [entry?.before, entry?.after])
const related = useMemo(() => {
if (!entry) return []
return auditLog
.filter((e) => e.id !== entry.id && (e.target === entry.target || e.actor === entry.actor))
.slice(0, 8)
}, [entry])
const actorTimeline = useMemo<TimelineEvent[]>(() => {
if (!entry) return []
return auditLog
.filter((e) => e.actor === entry.actor)
.slice(0, 6)
.map((e): TimelineEvent => ({
id: e.id,
at: e.at,
title: e.action,
detail: `${e.targetLabel} · ${e.outcome}`,
tone: e.outcome === 'failed' ? 'danger' : e.security ? 'warning' : 'default',
}))
}, [entry])
if (!entry) {
return (
<DetailPageLayout backTo="/audit" title="Audit event not found">
<Button asChild>
<Link to="/audit">Back to audit log</Link>
</Button>
</DetailPageLayout>
)
}
const href = targetLink(entry.target)
const copyId = () => {
try {
void navigator.clipboard?.writeText(entry.id)
toast.success(`Copied ${entry.id}`)
} catch {
toast.error('Clipboard is not available in this browser')
}
}
return (
<DetailPageLayout
backTo="/audit"
backLabel="Back to audit log"
title={entry.action}
subtitle={`${entry.actor} · ${new Date(entry.at).toLocaleString()}`}
badges={
<>
<Badge variant={entry.outcome === 'success' ? 'success' : 'danger'}>{entry.outcome}</Badge>
<Badge variant="secondary">{categoryLabels[entry.category]}</Badge>
{entry.security ? (
<Badge variant={entry.outcome === 'failed' ? 'danger' : 'warning'}>security</Badge>
) : null}
</>
}
actions={
<>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Event ${entry.id} exported (mock)`)}
>
<Download className="h-3.5 w-3.5" />
Export event
</Button>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={copyId}>
<Copy className="h-3.5 w-3.5" />
Copy event id
</Button>
{href ? (
<Button asChild size="sm" className="h-9 gap-1.5">
<Link to={href}>
<ArrowUpRight className="h-3.5 w-3.5" />
Open target
</Link>
</Button>
) : null}
</>
}
sidebar={
<DetailSection title="Actor activity" description={`Recent events by ${entry.actor}`}>
<ActivityTimeline events={actorTimeline} empty="No other activity for this actor" />
</DetailSection>
}
>
<div className="space-y-5">
<DetailSection title="Event record">
<KeyValueGrid
columns={2}
items={[
{ label: 'Event id', value: <span className="font-mono text-xs">{entry.id}</span> },
{ label: 'Timestamp', value: new Date(entry.at).toLocaleString() },
{ label: 'Actor', value: entry.actor },
{ label: 'Actor role', value: humanise(entry.actorRole) },
{
label: 'Action',
value: (
<Badge variant="outline" className="font-mono text-xs">
{entry.action}
</Badge>
),
},
{ label: 'Category', value: categoryLabels[entry.category] },
{
label: 'Outcome',
value: (
<Badge variant={entry.outcome === 'success' ? 'success' : 'danger'}>{entry.outcome}</Badge>
),
},
{
label: 'Target',
value: href ? (
<Link to={href} className="text-brand-700 hover:underline">
{entry.target}
</Link>
) : (
entry.target
),
},
{ label: 'Target label', value: entry.targetLabel },
{ label: 'IP address', value: <span className="font-mono text-xs">{entry.ip}</span> },
{
label: 'User agent',
value: <span className="break-all font-mono text-xs">{entry.userAgent}</span>,
},
]}
/>
{entry.note ? (
<div className="mt-4 rounded-xl border border-orange/30 bg-orange/5 p-3 text-sm text-forest">
{entry.note}
</div>
) : null}
</DetailSection>
<DetailSection title="Before / after" description="State captured around this action">
{changedKeys.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Changed fields
</span>
{changedKeys.map((key) => (
<Badge key={key} variant="warning" className="font-mono text-[11px]">
{key}
</Badge>
))}
</div>
) : (
<p className="mb-4 text-sm text-muted-foreground">No field-level changes were recorded.</p>
)}
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Before</p>
{entry.before ? (
<pre className="overflow-x-auto rounded-xl bg-muted p-4 text-xs">
{JSON.stringify(entry.before, null, 2)}
</pre>
) : (
<p className="text-sm text-muted-foreground">No prior state</p>
)}
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">After</p>
{entry.after ? (
<pre className="overflow-x-auto rounded-xl bg-muted p-4 text-xs">
{JSON.stringify(entry.after, null, 2)}
</pre>
) : (
<p className="text-sm text-muted-foreground">No resulting state</p>
)}
</div>
</div>
</DetailSection>
<DetailSection
title="Related activity"
description="Other events on the same target or by the same actor"
>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Event</TableHead>
<TableHead className="hidden sm:table-cell">When</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead className="hidden lg:table-cell">Target</TableHead>
<TableHead>Outcome</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{related.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
No related activity recorded
</TableCell>
</TableRow>
) : (
related.map((e) => (
<TableRow key={e.id}>
<TableCell>
<Link to={`/audit/${e.id}`} className="font-mono text-xs text-brand-700 hover:underline">
{e.id}
</Link>
</TableCell>
<TableCell className="hidden whitespace-nowrap text-xs sm:table-cell">
{new Date(e.at).toLocaleString()}
</TableCell>
<TableCell>{e.actor}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{e.action}
</Badge>
</TableCell>
<TableCell className="hidden lg:table-cell">{e.target}</TableCell>
<TableCell>
<Badge variant={e.outcome === 'success' ? 'success' : 'danger'}>{e.outcome}</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -1,33 +1,310 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Activity, Copy, Download, Eye, ShieldAlert, Users, XCircle } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { auditLog } from '@/mocks/data'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { auditLog, orders, prescriptions } from '@/mocks/data'
import type { AuditCategory } from '@/mocks/data'
import { auditActivitySeries } from '@/mocks/charts'
/**
* Maps an audit target id to its detail route, when the entity has one.
* Purged historical records stay unlinked so a link never lands on a dead page.
*/
function targetLink(target: string) {
if (target.startsWith('rx-')) {
return prescriptions.some((p) => p.id === target) ? `/prescriptions/${target}` : null
}
if (target.startsWith('ord-')) {
return orders.some((o) => o.id === target) ? `/orders/${target}` : null
}
if (target.startsWith('org-')) return `/organisations/${target}`
if (target.startsWith('pr-')) return `/procurement/${target}`
if (target.startsWith('mig-')) return `/migrations/${target}`
if (target.startsWith('cmp-')) return `/campaigns/${target}`
if (target.startsWith('seg-')) return `/marketing/${target}`
if (target.startsWith('br-')) return `/settings/branches/${target}`
if (target.startsWith('rd-')) return `/dispatch/riders/${target}`
if (target.startsWith('tm-')) return `/team/${target}`
if (target.startsWith('st-')) return `/finance/settlements/${target}`
if (/^[A-Z]{2,4}-/.test(target)) return `/stock/${target}`
return null
}
const categoryLabels: Record<AuditCategory, string> = {
prescriptions: 'Prescriptions',
orders: 'Orders',
stock: 'Stock',
procurement: 'Procurement',
organisations: 'Organisations',
finance: 'Finance',
marketing: 'Marketing',
migrations: 'Migrations',
settings: 'Settings',
security: 'Security',
}
const DAY_MS = 86_400_000
const categoryOptions = [
{ value: 'all', label: 'All categories' },
...(Object.keys(categoryLabels) as AuditCategory[]).map((key) => ({
value: key,
label: categoryLabels[key],
})),
]
const periodOptions = [
{ value: 'all', label: 'All time' },
{ value: 'today', label: 'Today' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
]
export function AuditPage() {
const { t } = useTranslation()
const [q, setQ] = useState('')
const [category, setCategory] = useState('all')
const [actor, setActor] = useState('all')
const [period, setPeriod] = useState('all')
const newestAt = useMemo(
() => auditLog.reduce((max, e) => (e.at > max ? e.at : max), auditLog[0]?.at ?? ''),
[],
)
const actorOptions = useMemo(() => {
const names = Array.from(new Set(auditLog.map((e) => e.actor))).sort((a, b) => a.localeCompare(b))
return [{ value: 'all', label: 'All actors' }, ...names.map((name) => ({ value: name, label: name }))]
}, [])
const stats = useMemo(() => {
const newestDay = new Date(newestAt).toDateString()
return {
today: auditLog.filter((e) => new Date(e.at).toDateString() === newestDay).length,
actors: new Set(auditLog.map((e) => e.actor)).size,
security: auditLog.filter((e) => e.security).length,
failed: auditLog.filter((e) => e.outcome === 'failed').length,
}
}, [newestAt])
const filtered = useMemo(() => {
const newestTime = new Date(newestAt).getTime()
const newestDay = new Date(newestAt).toDateString()
return auditLog.filter((e) => {
if (category !== 'all' && e.category !== category) return false
if (actor !== 'all' && e.actor !== actor) return false
if (period === 'today' && new Date(e.at).toDateString() !== newestDay) return false
if (period === '7d' && new Date(e.at).getTime() < newestTime - 7 * DAY_MS) return false
if (period === '30d' && new Date(e.at).getTime() < newestTime - 30 * DAY_MS) return false
if (q) {
const needle = q.toLowerCase()
if (
!e.actor.toLowerCase().includes(needle) &&
!e.action.toLowerCase().includes(needle) &&
!e.target.toLowerCase().includes(needle) &&
!e.targetLabel.toLowerCase().includes(needle)
) {
return false
}
}
return true
})
}, [q, category, actor, period, newestAt])
const auditFilters: TableFilter[] = [
{
id: 'category',
label: t('common.category'),
value: category,
onChange: setCategory,
options: categoryOptions,
},
{ id: 'actor', label: 'Actor', value: actor, onChange: setActor, options: actorOptions },
{ id: 'period', label: 'Period', value: period, onChange: setPeriod, options: periodOptions },
]
return (
<PageShell title={t('audit.title')} subtitle={t('audit.subtitle')}>
<Card>
<CardContent className="p-4">
<PageShell
title={t('audit.title')}
subtitle={t('audit.subtitle')}
actions={
<Button
variant="outline"
className="h-10 gap-2 shadow-none"
onClick={() => toast.success('Audit export queued (mock)')}
>
<Download className="h-4 w-4" />
Export log
</Button>
}
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Events today"
value={stats.today}
hint={newestAt ? new Date(newestAt).toLocaleDateString() : undefined}
icon={Activity}
tone="brand"
/>
<StatCard label="Unique actors" value={stats.actors} hint="Staff with recorded activity" icon={Users} tone="forest" />
<StatCard
label="Security events"
value={stats.security}
hint="Flagged for review"
icon={ShieldAlert}
tone="orange"
/>
<StatCard label="Failed actions" value={stats.failed} hint="Rejected or blocked" icon={XCircle} tone="muted" />
</div>
<ChartCard title="Activity over time" description="Recorded events and security-flagged events per day">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={auditActivitySeries}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="events"
name="Events"
stroke="#A8C73A"
fill="#A8C73A"
fillOpacity={0.25}
/>
<Area
type="monotone"
dataKey="security"
name="Security"
stroke="#E28A1A"
fill="#E28A1A"
fillOpacity={0.25}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search actor, action, target…"
resultCount={filtered.length}
filters={auditFilters}
onClearFilters={() => {
setQ('')
setCategory('all')
setActor('all')
setPeriod('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableRow className="hover:bg-transparent">
<TableHead>Event</TableHead>
<TableHead className="hidden md:table-cell">When</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
<TableHead className="hidden xl:table-cell">Category</TableHead>
<TableHead className="hidden lg:table-cell">Target</TableHead>
<TableHead>Outcome</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditLog.map((a) => (
<TableRow key={a.id}>
<TableCell className="whitespace-nowrap text-xs">{new Date(a.at).toLocaleString()}</TableCell>
<TableCell>{a.actor}</TableCell>
<TableCell className="font-mono text-xs">{a.action}</TableCell>
<TableCell>{a.target}</TableCell>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
))}
) : (
filtered.map((a) => {
const href = targetLink(a.target)
return (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link to={`/audit/${a.id}`} className="font-mono text-xs text-brand-700 hover:underline">
{a.id}
</Link>
</TableCell>
<TableCell className="hidden whitespace-nowrap text-xs md:table-cell">
{new Date(a.at).toLocaleString()}
</TableCell>
<TableCell>
<p className="font-medium">{a.actor}</p>
<p className="text-xs text-muted-foreground">{a.actorRole.replace(/_/g, ' ')}</p>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{a.action}
</Badge>
</TableCell>
<TableCell className="hidden xl:table-cell">
<Badge variant="secondary">{categoryLabels[a.category]}</Badge>
</TableCell>
<TableCell className="hidden lg:table-cell">
{href ? (
<Link to={href} className="text-brand-700 hover:underline">
{a.target}
</Link>
) : (
<span>{a.target}</span>
)}
<p className="text-xs text-muted-foreground">{a.targetLabel}</p>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1">
<Badge variant={a.outcome === 'success' ? 'success' : 'danger'}>{a.outcome}</Badge>
{a.security ? <Badge variant="warning">security</Badge> : null}
</div>
</TableCell>
<TableCell>
<RowActions
primary={[
{ label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/audit/${a.id}` },
]}
items={[
{
label: 'Copy event id',
icon: <Copy className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Copied ${a.id}`),
},
{
label: 'Export event',
icon: <Download className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Event ${a.id} exported (mock)`),
},
]}
/>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</CardContent>

View File

@ -1,90 +1,351 @@
import { useState, type FormEvent } from 'react'
import { useMemo, useState, type FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
AlertCircle,
ChevronDown,
Eye,
EyeOff,
FlaskConical,
Loader2,
Lock,
ShieldCheck,
Truck,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { InitialsAvatar } from '@/components/ui/avatar'
import { statusLabel } from '@/components/ui/status-badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAuthStore } from '@/zustand/authStore'
import { demoAccounts } from '@/config/permissions'
import { toast } from 'sonner'
import type { StaffRole } from '@/types'
const REMEMBER_KEY = 'gishen_remember_email'
const ROLE_ORDER: StaffRole[] = [
'super_admin',
'pharmacist',
'stock_manager',
'procurement',
'finance',
'marketing_manager',
'operations',
]
const VALUE_POINTS = [
{ icon: ShieldCheck, text: 'Prescription review with a full controlled-substance audit trail' },
{ icon: Truck, text: 'Live dispatch across every Gishen branch and rider' },
{ icon: Lock, text: 'Role-scoped access so each team only sees its own work' },
]
export function LoginPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const login = useAuthStore((s) => s.login)
const loginAsDemo = useAuthStore((s) => s.loginAsDemo)
const token = useAuthStore((s) => s.token) || localStorage.getItem('access_token')
const [email, setEmail] = useState('admin@gishen.et')
const [password, setPassword] = useState('demo123')
const [email, setEmail] = useState(() => localStorage.getItem(REMEMBER_KEY) ?? '')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [remember, setRemember] = useState(() => Boolean(localStorage.getItem(REMEMBER_KEY)))
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({})
const [formError, setFormError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [demoEmail, setDemoEmail] = useState<string | null>(null)
const accountsByRole = useMemo(
() =>
ROLE_ORDER.map((role) => ({
role,
label: statusLabel('role', role),
accounts: demoAccounts.filter((a) => a.role === role),
})).filter((group) => group.accounts.length > 0),
[],
)
if (token) return <Navigate to="/" replace />
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const result = login(email, password)
const signIn = (nextEmail: string, nextPassword: string) => {
const result = login(nextEmail, nextPassword)
if (!result.ok) {
toast.error(result.error)
return
setFormError(result.error ?? t('auth.error.invalidCredentials'))
toast.error(result.error ?? t('auth.error.invalidCredentials'))
return false
}
toast.success('Signed in')
if (remember) localStorage.setItem(REMEMBER_KEY, nextEmail)
else localStorage.removeItem(REMEMBER_KEY)
toast.success(t('auth.signedIn'))
navigate('/')
return true
}
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const errors: { email?: string; password?: string } = {}
if (!email.trim()) errors.email = t('auth.error.emailRequired')
if (!password) errors.password = t('auth.error.passwordRequired')
setFieldErrors(errors)
if (Object.keys(errors).length > 0) return
setFormError(null)
setLoading(true)
window.setTimeout(() => {
const ok = signIn(email.trim(), password)
if (!ok) setLoading(false)
}, 450)
}
const onPickDemoAccount = (account: (typeof demoAccounts)[number]) => {
setEmail(account.email)
setPassword(account.password)
setFieldErrors({})
setFormError(null)
setDemoEmail(account.email)
window.setTimeout(() => {
const ok = signIn(account.email, account.password)
if (!ok) setDemoEmail(null)
}, 450)
}
const busy = loading || demoEmail !== null
return (
<div className="grid min-h-screen lg:grid-cols-2">
<div className="relative hidden overflow-hidden bg-forest lg:flex lg:flex-col lg:justify-between lg:p-12">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(168,199,58,0.35),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(226,138,26,0.25),transparent_40%)]" />
<div className="grid min-h-screen bg-cream lg:grid-cols-[1.05fr_1fr]">
<aside className="relative hidden overflow-hidden bg-forest lg:flex lg:flex-col lg:justify-between lg:p-12">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_18%,rgba(168,199,58,0.38),transparent_46%),radial-gradient(circle_at_82%_72%,rgba(226,138,26,0.24),transparent_42%)]" />
<div
className="absolute inset-0 opacity-[0.35]"
style={{
backgroundImage: 'radial-gradient(rgba(246,247,242,0.10) 1px, transparent 1px)',
backgroundSize: '22px 22px',
}}
aria-hidden="true"
/>
<div className="relative">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-500 font-display text-xl font-bold text-forest">
G
</div>
<h1 className="mt-8 font-display text-4xl font-bold text-white">{t('app.name')}</h1>
<p className="mt-3 max-w-md text-white/75">{t('app.tagline')}</p>
<h1 className="mt-10 font-display text-4xl font-bold leading-tight text-white">{t('app.name')}</h1>
<p className="mt-3 max-w-md text-lg text-white/75">{t('app.tagline')}</p>
<p className="mt-6 max-w-md text-sm leading-relaxed text-white/55">
One console for prescriptions, stock, dispatch and finance — built for the way Ethiopian pharmacies
actually run.
</p>
</div>
<p className="relative text-sm text-white/50">{t('app.poweredBy')}</p>
<ul className="relative mt-10 space-y-4">
{VALUE_POINTS.map(({ icon: Icon, text }) => (
<li key={text} className="flex items-start gap-3 text-sm text-white/70">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/10 text-brand-300">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="max-w-sm leading-relaxed">{text}</span>
</li>
))}
</ul>
<p className="relative text-sm text-white/45">{t('app.poweredBy')}</p>
</aside>
<main className="flex items-center justify-center px-5 py-10 sm:px-8">
<div className="w-full max-w-[26rem] space-y-6">
<div className="flex items-center gap-3 lg:hidden">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-brand-500 font-display text-lg font-bold text-forest">
G
</div>
<div className="flex items-center justify-center bg-cream p-6">
<div className="w-full max-w-md space-y-6">
<span className="font-display text-lg font-bold text-forest">{t('app.name')}</span>
</div>
<div>
<h2 className="font-display text-2xl font-bold text-forest">{t('auth.welcome')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('auth.subtitle')}</p>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-700">Admin console</p>
<h2 className="mt-2 font-display text-3xl font-bold tracking-tight text-forest">{t('auth.welcome')}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{t('auth.subtitle')}</p>
</div>
<form onSubmit={onSubmit} className="space-y-4 rounded-2xl border bg-white p-6 shadow-soft">
{formError ? (
<div
role="alert"
className="flex items-start gap-2.5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span>{formError}</span>
</div>
) : null}
<form onSubmit={onSubmit} noValidate className="space-y-4 rounded-2xl border bg-white p-6 shadow-soft">
<div className="space-y-2">
<Label htmlFor="email">{t('auth.email')}</Label>
<Input id="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="you@gishen.et"
value={email}
aria-invalid={Boolean(fieldErrors.email)}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
onChange={(e) => {
setEmail(e.target.value)
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
}}
className={fieldErrors.email ? 'border-red-400 focus-visible:ring-red-400' : undefined}
/>
{fieldErrors.email ? (
<p id="email-error" className="text-xs text-red-600">
{fieldErrors.email}
</p>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">{t('auth.password')}</Label>
<button
type="button"
className="rounded-sm text-xs font-medium text-brand-700 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() =>
toast.message('Password reset', {
description:
'This console runs on demo data, so there is no reset email. Ask a super admin to set a new password.',
})
}
>
Forgot password?
</button>
</div>
<div className="relative">
<Input
id="password"
type="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button type="submit" className="w-full">
{t('auth.login')}
</Button>
<p className="text-center text-xs text-muted-foreground">{t('auth.demoHint')}</p>
</form>
<div className="grid gap-2 sm:grid-cols-2">
{demoAccounts.map((a) => (
<Button
key={a.email}
variant="outline"
className="justify-start text-left"
onClick={() => {
loginAsDemo(a.email)
navigate('/')
aria-invalid={Boolean(fieldErrors.password)}
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
onChange={(e) => {
setPassword(e.target.value)
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
}}
className={`pr-11 ${fieldErrors.password ? 'border-red-400 focus-visible:ring-red-400' : ''}`}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="truncate text-xs font-semibold">{a.role.replaceAll('_', ' ')}</span>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{fieldErrors.password ? (
<p id="password-error" className="text-xs text-red-600">
{fieldErrors.password}
</p>
) : null}
</div>
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="h-4 w-4 rounded border-input accent-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
Remember me on this device
</label>
<Button type="submit" className="w-full" disabled={busy}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
{loading ? 'Signing in…' : t('auth.login')}
</Button>
</form>
<section
aria-labelledby="demo-heading"
className="rounded-2xl border border-dashed border-brand-300 bg-brand-50/60 p-5"
>
<div className="flex items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-brand-700">
<FlaskConical className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 id="demo-heading" className="font-display text-sm font-semibold text-forest">
{t('auth.demoAccounts')}
</h3>
<Badge variant="warning">Demo only</Badge>
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Pick a test user to fill the form and sign in straight away. {t('auth.demoHint')}
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
disabled={busy}
className="mt-4 w-full justify-between bg-white"
>
<span className="flex min-w-0 items-center gap-2">
{demoEmail ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden="true" />
) : null}
<span className="truncate">
{demoEmail ? `Signing in as ${demoEmail}…` : 'Choose a test user'}
</span>
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-[22rem] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto p-1.5"
>
{accountsByRole.map((group) => (
<div key={group.role} className="pb-1 last:pb-0">
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</p>
{group.accounts.map((account) => (
<DropdownMenuItem
key={account.email}
className="items-start gap-3 py-2.5"
onSelect={() => onPickDemoAccount(account)}
>
<InitialsAvatar name={account.name} src={account.avatarUrl} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-foreground">
{account.name}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{account.branchName ?? 'All branches'} · {account.email}
</span>
</span>
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</section>
<p className="text-center text-xs text-muted-foreground">
{t('app.poweredBy')} · © {new Date().getFullYear()}
</p>
</div>
</div>
</main>
</div>
)
}

View File

@ -1,90 +1,921 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router-dom'
import {
ShoppingBag,
FileHeart,
Truck,
Building2,
AlertTriangle,
Boxes,
Building2,
CalendarClock,
CheckCheck,
FileHeart,
Package,
Receipt,
ShoppingBag,
Truck,
Users,
Wallet,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { useAuthStore } from '@/zustand/authStore'
import { orders, organisations, prescriptions, stockRows } from '@/mocks/data'
import { ChartCard } from '@/components/charts/ChartCard'
import {
CHART_COLORS,
GRADIENT_IDS,
GradientDefs,
axisProps,
chartMargin,
gridProps,
tooltipProps,
} from '@/components/charts/chartTheme'
import { ActivityTimeline } from '@/components/detail/ActivityTimeline'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { EmptyState } from '@/components/ui/empty-state'
import { statusLabel } from '@/components/ui/status-badge'
import { useAuthStore } from '@/zustand/authStore'
import { canAccess, roleModules } from '@/config/permissions'
import {
auditLog,
branches,
customers,
orderLines,
orders,
organisations,
prescriptions,
stockRows,
} from '@/mocks/data'
import type { AuditCategory } from '@/mocks/data'
import { orderChannels, productCategoryColors, rxFunnelStages } from '@/mocks/charts'
import { formatEtb, formatNumber } from '@/lib/utils'
import type { ModuleKey } from '@/types'
const DAY_MS = 24 * 60 * 60 * 1000
const TREND_DAYS = 30
/** Platform-wide reorder threshold — mirrors the setting in settingsActivity. */
const LOW_STOCK_THRESHOLD = 20
const NEAR_EXPIRY_MONTHS = 3
const CLOSED_ORDER_STATUSES = new Set(['delivered', 'cancelled'])
function startOfDay(value: Date | string) {
const d = value instanceof Date ? value : new Date(value)
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
function shortDay(time: number) {
return new Date(time).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
/** Months from `now` until a "YYYY-MM" expiry; negative once expired. */
function monthsUntilExpiry(expiry: string, now: Date) {
const [year, month] = expiry.split('-').map(Number)
if (!year || !month) return Number.POSITIVE_INFINITY
return (year - now.getFullYear()) * 12 + (month - 1 - now.getMonth())
}
const auditModule: Record<AuditCategory, ModuleKey> = {
prescriptions: 'prescriptions',
orders: 'orders',
stock: 'stock',
procurement: 'procurement',
organisations: 'organisations',
finance: 'finance',
marketing: 'marketing',
migrations: 'migrations',
settings: 'settings',
security: 'audit',
}
const QUICK_LINKS: { module: ModuleKey; to: string; label: string }[] = [
{ module: 'prescriptions', to: '/prescriptions', label: 'Rx queue' },
{ module: 'orders', to: '/orders', label: 'Orders' },
{ module: 'dispatch', to: '/dispatch', label: 'Dispatch' },
{ module: 'stock', to: '/stock', label: 'Stock' },
{ module: 'procurement', to: '/procurement', label: 'Procurement' },
{ module: 'organisations', to: '/organisations', label: 'Organisations' },
{ module: 'finance', to: '/finance', label: 'Finance' },
{ module: 'analytics', to: '/analytics', label: 'Analytics' },
]
type Kpi = {
label: string
value: string | number
hint?: string
icon: LucideIcon
tone: 'brand' | 'orange' | 'forest' | 'muted'
}
type AttentionItem = {
id: string
title: string
detail: string
to: string
badge: string
tone: 'warning' | 'danger'
}
/** Clickable category tick so chart labels can deep-link into a detail route. */
function LinkTick({
x,
y,
payload,
onSelect,
anchor = 'end',
dy = 4,
}: {
x?: number
y?: number
payload?: { value?: string | number }
onSelect: (value: string) => void
anchor?: 'end' | 'middle'
dy?: number
}) {
const value = String(payload?.value ?? '')
return (
<text
x={x}
y={y}
dy={dy}
textAnchor={anchor}
role="link"
tabIndex={0}
className="cursor-pointer fill-brand-700 text-[11px] font-semibold hover:underline"
onClick={() => onSelect(value)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onSelect(value)
}}
>
{value}
</text>
)
}
export function DashboardPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const branchId = user?.branchId
const role = user?.role
const branchId = user?.branchId
const stats = useMemo(() => {
const branchOrders = branchId ? orders.filter((o) => o.branchId === branchId) : orders
const branchRx = branchId ? prescriptions.filter((p) => p.branchId === branchId) : prescriptions
const pendingRx = branchRx.filter((p) => p.status === 'under_review' || p.status === 'submitted').length
const unassigned = branchOrders.filter((o) => o.fulfillment === 'delivery' && !o.assignedRiderId).length
const lowStock = stockRows.filter((s) => s.qty < 20).length
const pendingOrgs = organisations.filter((o) => o.status === 'pending').length
const openDispatch = orders.filter((o) => o.paid && o.rxApproved && o.fulfillment === 'delivery').length
const can = (module: ModuleKey) => (role ? canAccess(role, module) : false)
/**
* Every number on this page reads from these three arrays, so a branch-scoped
* role can never fall through to a platform-wide figure.
*/
const { scopedOrders, scopedRx, scopedStock } = useMemo(() => {
return {
scopedOrders: branchId ? orders.filter((o) => o.branchId === branchId) : orders,
scopedRx: branchId ? prescriptions.filter((p) => p.branchId === branchId) : prescriptions,
scopedStock: branchId ? stockRows.filter((s) => s.branchId === branchId) : stockRows,
}
}, [branchId])
const metrics = useMemo(() => {
const now = new Date()
const todayStart = startOfDay(now)
const windowStart = todayStart - (TREND_DAYS - 1) * DAY_MS
const billable = scopedOrders.filter((o) => o.status !== 'cancelled')
const ordersToday = billable.filter((o) => startOfDay(o.createdAt) === todayStart)
const ordersWindow = billable.filter((o) => startOfDay(o.createdAt) >= windowStart)
const revenueToday = ordersToday.reduce((sum, o) => sum + o.totalEtb, 0)
const revenueWindow = ordersWindow.reduce((sum, o) => sum + o.totalEtb, 0)
const pendingRx = scopedRx.filter((p) => p.status === 'submitted' || p.status === 'under_review')
const approvedRx = scopedRx.filter((p) => p.status === 'approved')
const decidedRx = scopedRx.filter((p) => p.status === 'approved' || p.status === 'rejected')
const lowStock = scopedStock.filter((s) => s.qty < LOW_STOCK_THRESHOLD)
const erpMismatch = scopedStock.filter((s) => s.qty !== s.erpQty)
const nearExpiry = scopedStock.filter((s) => monthsUntilExpiry(s.expiry, now) <= NEAR_EXPIRY_MONTHS)
const openOrders = scopedOrders.filter((o) => !CLOSED_ORDER_STATUSES.has(o.status))
const unassigned = openOrders.filter((o) => o.fulfillment === 'delivery' && !o.assignedRiderId)
const outForDelivery = scopedOrders.filter((o) => o.status === 'out_for_delivery')
const awaitingPickup = openOrders.filter((o) => o.fulfillment === 'pickup')
const openDispatch = openOrders.filter((o) => o.fulfillment === 'delivery' && o.paid)
const pendingOrgs = organisations.filter((o) => o.status === 'pending')
const creditUsed = organisations.reduce((sum, o) => sum + o.usedEtb, 0)
const creditLimit = organisations.reduce((sum, o) => sum + o.creditLimitEtb, 0)
return {
ordersToday,
revenueToday,
ordersWindow,
revenueWindow,
avgOrderValue: ordersWindow.length ? Math.round(revenueWindow / ordersWindow.length) : 0,
ordersPerDay: Math.round((ordersWindow.length / TREND_DAYS) * 10) / 10,
pendingRx,
approvedRx,
decidedRx,
approvalRate: decidedRx.length ? Math.round((approvedRx.length / decidedRx.length) * 100) : 0,
controlledPending: pendingRx.filter((p) => p.items.some((i) => i.controlled)).length,
lowStock,
erpMismatch,
nearExpiry,
unassigned,
outForDelivery,
awaitingPickup,
openDispatch,
pendingOrgs,
activeOrgs: organisations.filter((o) => o.status === 'active'),
creditUsed,
creditUtil: creditLimit ? Math.round((creditUsed / creditLimit) * 100) : 0,
}
}, [scopedOrders, scopedRx, scopedStock])
const kpis = useMemo<Kpi[]>(() => {
const revenueToday: Kpi = {
label: 'Revenue today',
value: formatEtb(metrics.revenueToday),
hint: `${metrics.ordersToday.length} paid order${metrics.ordersToday.length === 1 ? '' : 's'} so far`,
icon: Wallet,
tone: 'brand',
}
const ordersToday: Kpi = {
label: "Today's orders",
value: metrics.ordersToday.length,
hint: `${metrics.ordersPerDay}/day over the last ${TREND_DAYS} days`,
icon: ShoppingBag,
tone: 'forest',
}
const avgOrderValue: Kpi = {
label: 'Average order value',
value: formatEtb(metrics.avgOrderValue),
hint: `${metrics.ordersWindow.length} orders in ${TREND_DAYS} days`,
icon: Receipt,
tone: 'orange',
}
const pendingRx: Kpi = {
label: 'Pending prescriptions',
value: metrics.pendingRx.length,
hint: metrics.controlledPending
? `${metrics.controlledPending} contain controlled items`
: 'No controlled items waiting',
icon: FileHeart,
tone: 'orange',
}
const approvalRate: Kpi = {
label: 'Rx approval rate',
value: `${metrics.approvalRate}%`,
hint: `${metrics.approvedRx.length} approved of ${metrics.decidedRx.length} decided`,
icon: CheckCheck,
tone: 'brand',
}
const lowStock: Kpi = {
label: 'Low-stock SKUs',
value: metrics.lowStock.length,
hint: `Below ${LOW_STOCK_THRESHOLD} units on hand`,
icon: AlertTriangle,
tone: 'orange',
}
const unassigned: Kpi = {
label: 'Unassigned deliveries',
value: metrics.unassigned.length,
hint: `${metrics.outForDelivery.length} already out for delivery`,
icon: Truck,
tone: 'muted',
}
const pendingOrgs: Kpi = {
label: 'Pending org approvals',
value: metrics.pendingOrgs.length,
hint: `${formatEtb(metrics.creditUsed)} credit drawn by ${metrics.activeOrgs.length} active accounts`,
icon: Building2,
tone: 'muted',
}
if (role === 'pharmacist') {
return [
{ label: "Today's orders", value: branchOrders.length, icon: ShoppingBag, tone: 'brand' as const },
{ label: 'Pending Rx', value: pendingRx, icon: FileHeart, tone: 'orange' as const },
{ label: 'Unassigned delivery', value: unassigned, icon: Truck, tone: 'forest' as const },
{ label: 'Awaiting pickup', value: branchOrders.filter((o) => o.fulfillment === 'pickup').length, icon: ShoppingBag, tone: 'muted' as const },
]
return [pendingRx, approvalRate, ordersToday, unassigned]
}
if (role === 'finance') {
return [
{ label: 'Pending orgs', value: pendingOrgs, icon: Building2, tone: 'orange' as const },
{ label: 'Active credit orgs', value: organisations.filter((o) => o.status === 'active').length, icon: Wallet, tone: 'brand' as const },
{ label: 'Credit used (ETB)', value: organisations.reduce((s, o) => s + o.usedEtb, 0).toLocaleString(), icon: Wallet, tone: 'forest' as const },
{ label: 'Orders today', value: orders.length, icon: ShoppingBag, tone: 'muted' as const },
revenueToday,
avgOrderValue,
{ ...pendingOrgs, tone: 'orange' as const },
{
label: 'Credit utilisation',
value: `${metrics.creditUtil}%`,
hint: `${formatEtb(metrics.creditUsed)} drawn across all accounts`,
icon: Wallet,
tone: 'muted' as const,
},
]
}
if (role === 'stock_manager' || role === 'procurement') {
return [
{ label: 'Low stock SKUs', value: lowStock, icon: AlertTriangle, tone: 'orange' as const },
{ label: 'ERP mismatches', value: stockRows.filter((s) => s.qty !== s.erpQty).length, icon: AlertTriangle, tone: 'danger' as const },
{ label: 'Tracked SKUs', value: stockRows.length, icon: ShoppingBag, tone: 'brand' as const },
{ label: 'Branches', value: 3, icon: Building2, tone: 'muted' as const },
lowStock,
{
label: 'ERP mismatches',
value: metrics.erpMismatch.length,
hint: 'Platform count differs from ERP',
icon: AlertTriangle,
tone: 'orange' as const,
},
{
label: `Expiring in ${NEAR_EXPIRY_MONTHS}m`,
value: metrics.nearExpiry.length,
hint: 'Batches to sell through or return',
icon: CalendarClock,
tone: 'forest' as const,
},
{
label: 'Tracked SKUs',
value: scopedStock.length,
hint: `Across ${branchId ? 1 : branches.length} branch${branchId ? '' : 'es'}`,
icon: Boxes,
tone: 'muted' as const,
},
]
}
if (role === 'operations') {
return [
{ label: 'Open dispatch', value: openDispatch, icon: Truck, tone: 'brand' as const },
{ label: 'Unassigned', value: orders.filter((o) => o.fulfillment === 'delivery' && !o.assignedRiderId).length, icon: Truck, tone: 'orange' as const },
{ label: 'Out for delivery', value: orders.filter((o) => o.status === 'out_for_delivery').length, icon: Truck, tone: 'forest' as const },
{ label: 'Paid ready', value: orders.filter((o) => o.paid && o.rxApproved).length, icon: ShoppingBag, tone: 'muted' as const },
{ ...unassigned, tone: 'orange' as const },
{
label: 'Out for delivery',
value: metrics.outForDelivery.length,
hint: 'Riders currently on the road',
icon: Truck,
tone: 'brand' as const,
},
{
label: 'Open dispatch',
value: metrics.openDispatch.length,
hint: 'Paid deliveries still to complete',
icon: Package,
tone: 'forest' as const,
},
{ ...ordersToday, tone: 'muted' as const },
]
}
if (role === 'marketing_manager') {
return [
ordersToday,
{ ...revenueToday, tone: 'forest' as const },
avgOrderValue,
{
label: 'Known customers',
value: formatNumber(customers.length),
hint: 'Profiles available for segmentation',
icon: Users,
tone: 'muted' as const,
},
]
}
return [
{ label: 'Orders', value: orders.length, icon: ShoppingBag, tone: 'brand' as const },
{ label: 'Rx queue', value: pendingRx, icon: FileHeart, tone: 'orange' as const },
{ label: 'Open dispatch', value: openDispatch, icon: Truck, tone: 'forest' as const },
{ label: 'Pending orgs', value: pendingOrgs, icon: Building2, tone: 'muted' as const },
revenueToday,
ordersToday,
avgOrderValue,
approvalRate,
pendingRx,
lowStock,
unassigned,
pendingOrgs,
]
}, [branchId, role])
}, [metrics, role, scopedStock.length, branchId])
const salesTrend = useMemo(() => {
const todayStart = startOfDay(new Date())
const buckets = Array.from({ length: TREND_DAYS }, (_, i) => {
const time = todayStart - (TREND_DAYS - 1 - i) * DAY_MS
return { time, day: shortDay(time), sales: 0, orders: 0 }
})
const byDay = new Map(buckets.map((b) => [b.time, b]))
for (const order of scopedOrders) {
if (order.status === 'cancelled') continue
const bucket = byDay.get(startOfDay(order.createdAt))
if (!bucket) continue
bucket.sales += order.totalEtb
bucket.orders += 1
}
return buckets
}, [scopedOrders])
/** Order lines belonging to the current scope, joined to their stock row. */
const scopedLines = useMemo(() => {
const billableIds = new Set(scopedOrders.filter((o) => o.status !== 'cancelled').map((o) => o.id))
const bySku = new Map(stockRows.map((s) => [s.sku, s]))
return Object.entries(orderLines)
.filter(([orderId]) => billableIds.has(orderId))
.flatMap(([, lines]) =>
lines.map((line) => ({
...line,
category: bySku.get(line.sku)?.category ?? 'Uncategorised',
revenue: line.qty * line.unitEtb,
})),
)
}, [scopedOrders])
const popularProducts = useMemo(() => {
const totals = new Map<string, { sku: string; name: string; units: number; revenue: number }>()
for (const line of scopedLines) {
const current = totals.get(line.sku) ?? { sku: line.sku, name: line.name, units: 0, revenue: 0 }
current.units += line.qty
current.revenue += line.revenue
totals.set(line.sku, current)
}
return [...totals.values()].sort((a, b) => b.units - a.units).slice(0, 6)
}, [scopedLines])
const categoryMix = useMemo(() => {
const totals = new Map<string, number>()
for (const line of scopedLines) {
totals.set(line.category, (totals.get(line.category) ?? 0) + line.revenue)
}
const ranked = [...totals.entries()]
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
const top = ranked.slice(0, 6)
const rest = ranked.slice(6).reduce((sum, s) => sum + s.value, 0)
const slices = rest > 0 ? [...top, { name: 'Other', value: rest }] : top
return slices.map((slice) => ({
...slice,
fill: productCategoryColors[slice.name] ?? CHART_COLORS.neutral,
}))
}, [scopedLines])
const channelMix = useMemo(() => {
return orderChannels
.map((channel) => {
const matching = scopedOrders.filter(
(o) => o.channel === channel.key && o.status !== 'cancelled',
)
return {
...channel,
orders: matching.length,
revenue: matching.reduce((sum, o) => sum + o.totalEtb, 0),
}
})
.filter((channel) => channel.orders > 0)
}, [scopedOrders])
const branchPerformance = useMemo(() => {
return branches.map((branch) => {
const matching = orders.filter((o) => o.branchId === branch.id && o.status !== 'cancelled')
return {
id: branch.id,
branch: branch.name.replace(' Branch', ''),
orders: matching.length,
revenue: matching.reduce((sum, o) => sum + o.totalEtb, 0),
}
})
}, [])
const rxFunnel = useMemo(() => {
return rxFunnelStages.map((stage) => ({
stage: stage.label,
count: scopedRx.filter((p) => stage.statuses.includes(p.status)).length,
fill: stage.fill,
}))
}, [scopedRx])
const stockHealth = useMemo(() => {
const now = new Date()
return branches.map((branch) => {
const rows = stockRows.filter((s) => s.branchId === branch.id)
return {
branch: branch.name.replace(' Branch', ''),
low: rows.filter((s) => s.qty < LOW_STOCK_THRESHOLD).length,
expiring: rows.filter((s) => monthsUntilExpiry(s.expiry, now) <= NEAR_EXPIRY_MONTHS).length,
mismatch: rows.filter((s) => s.qty !== s.erpQty).length,
}
})
}, [])
const attention = useMemo<AttentionItem[]>(() => {
const items: AttentionItem[] = []
if (can('prescriptions')) {
for (const rx of metrics.pendingRx.slice(0, 3)) {
items.push({
id: rx.id,
title: rx.id,
detail: `${rx.customerName} · ${rx.items.length} item${rx.items.length === 1 ? '' : 's'}`,
to: `/prescriptions/${rx.id}`,
badge: statusLabel('prescription', rx.status),
tone: rx.items.some((i) => i.controlled) ? 'danger' : 'warning',
})
}
}
if (can('stock')) {
for (const row of metrics.lowStock.slice(0, 3)) {
items.push({
id: `low-${row.sku}`,
title: row.sku,
detail: `${row.name} · ${row.qty} left`,
to: `/stock/${row.sku}`,
badge: 'Low stock',
tone: 'warning',
})
}
for (const row of metrics.erpMismatch.slice(0, 2)) {
items.push({
id: `erp-${row.sku}`,
title: row.sku,
detail: `Platform ${row.qty} · ERP ${row.erpQty}`,
to: `/stock/${row.sku}`,
badge: 'ERP mismatch',
tone: 'danger',
})
}
}
if (can('dispatch') || can('orders')) {
for (const order of metrics.unassigned.slice(0, 3)) {
items.push({
id: `dispatch-${order.id}`,
title: order.id,
detail: `${order.customerName} · ${order.zone ?? 'no zone'}`,
to: can('dispatch') ? '/dispatch' : `/orders/${order.id}`,
badge: 'No rider',
tone: 'warning',
})
}
}
if (can('organisations')) {
for (const org of metrics.pendingOrgs) {
items.push({
id: `org-${org.id}`,
title: org.name,
detail: `TIN ${org.tin} · ${formatEtb(org.creditLimitEtb)} requested`,
to: `/organisations/${org.id}`,
badge: 'Awaiting approval',
tone: 'warning',
})
}
}
return items
}, [metrics, role])
const activity = useMemo(() => {
return auditLog
.filter((event) => can(auditModule[event.category]))
.slice(0, 6)
.map((event) => ({
id: event.id,
at: event.at,
title: event.action,
detail: `${event.actor} · ${event.targetLabel}`,
tone: event.outcome === 'failed' ? ('danger' as const) : ('default' as const),
}))
}, [role])
const quickLinks = role ? QUICK_LINKS.filter((l) => roleModules[role]?.includes(l.module)).slice(0, 4) : []
const showSales = can('orders') || can('analytics') || can('finance')
const showProducts = can('stock') && popularProducts.length > 0
const showCategory = (can('stock') || can('analytics')) && categoryMix.length > 0
const showChannels = (can('orders') || can('analytics')) && channelMix.length > 0
const showBranches = !branchId && (can('orders') || can('analytics') || can('finance'))
const showStockHealth = can('stock') && !branchId
const showRxFunnel = can('prescriptions')
const branchLinksEnabled = can('settings')
return (
<PageShell title={t('dashboard.title')} subtitle={t('dashboard.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((s) => (
<StatCard key={s.label} label={s.label} value={s.value} icon={s.icon} tone={s.tone === 'danger' ? 'orange' : s.tone} />
<div className="flex flex-col gap-3 rounded-2xl border bg-card px-4 py-3 shadow-soft sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Signed in as <strong className="text-foreground">{user?.name}</strong>
{role ? ` · ${statusLabel('role', role)}` : null}
{user?.branchName ? ` · scoped to ${user.branchName}` : ' · platform-wide view'}
</p>
{quickLinks.length > 0 ? (
<div className="flex flex-wrap gap-2">
{quickLinks.map((link) => (
<Button key={link.to} asChild size="sm" variant="outline" className="h-9">
<Link to={link.to}>{link.label}</Link>
</Button>
))}
</div>
) : null}
</div>
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{kpis.map((kpi) => (
<StatCard
key={kpi.label}
label={kpi.label}
value={kpi.value}
hint={kpi.hint}
icon={kpi.icon}
tone={kpi.tone}
/>
))}
</div>
{showSales ? (
<ChartCard
title="Sales trend"
description={`Revenue over the last ${TREND_DAYS} days${branchId ? ' for your branch' : ' across all branches'}`}
height="h-72"
action={
<Badge variant="secondary" className="shrink-0">
{formatEtb(metrics.revenueWindow)}
</Badge>
}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={salesTrend} margin={chartMargin}>
<GradientDefs />
<CartesianGrid {...gridProps} />
<XAxis dataKey="day" {...axisProps} interval={4} />
<YAxis {...axisProps} width={52} tickFormatter={(v) => formatNumber(Number(v))} />
<Tooltip
{...tooltipProps}
formatter={(value, name) =>
name === 'Revenue' ? formatEtb(Number(value)) : String(value)
}
/>
<Area
type="monotone"
dataKey="sales"
name="Revenue"
stroke={CHART_COLORS.brand}
strokeWidth={2}
fill={`url(#${GRADIENT_IDS.brand})`}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
{showProducts ? (
<ChartCard
title="Popular products"
description="Units sold in scope — select a SKU to open its stock record"
height="h-72"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={popularProducts} layout="vertical" margin={{ ...chartMargin, left: 24 }}>
<CartesianGrid {...gridProps} vertical horizontal={false} />
<XAxis type="number" {...axisProps} />
<YAxis
type="category"
dataKey="sku"
width={78}
tickLine={false}
axisLine={false}
tick={<LinkTick onSelect={(sku) => navigate(`/stock/${sku}`)} />}
/>
<Tooltip
{...tooltipProps}
formatter={(value, name) =>
name === 'Revenue' ? formatEtb(Number(value)) : `${value} units`
}
/>
<Bar dataKey="units" name="Units" fill={CHART_COLORS.brand} radius={[0, 6, 6, 0]} barSize={18} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
) : null}
{showCategory ? (
<ChartCard
title="Category mix"
description="Share of revenue by product category"
height="h-72"
>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryMix}
dataKey="value"
nameKey="name"
innerRadius={52}
outerRadius={82}
paddingAngle={3}
>
{categoryMix.map((slice) => (
<Cell key={slice.name} fill={slice.fill} />
))}
</Pie>
<Tooltip {...tooltipProps} formatter={(value) => formatEtb(Number(value))} />
<Legend
verticalAlign="bottom"
height={56}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 11, lineHeight: '18px' }}
/>
</PieChart>
</ResponsiveContainer>
</ChartCard>
) : null}
{showRxFunnel ? (
<ChartCard
title="Prescription funnel"
description="Submitted → under review → approved"
height="h-72"
action={
<Badge variant="secondary" className="shrink-0">
{metrics.approvalRate}% approved
</Badge>
}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rxFunnel} layout="vertical" margin={{ ...chartMargin, left: 24 }}>
<CartesianGrid {...gridProps} vertical horizontal={false} />
<XAxis type="number" {...axisProps} allowDecimals={false} />
<YAxis type="category" dataKey="stage" width={96} {...axisProps} />
<Tooltip {...tooltipProps} formatter={(value) => `${value} prescriptions`} />
<Bar dataKey="count" name="Prescriptions" radius={[0, 6, 6, 0]} barSize={26}>
{rxFunnel.map((stage) => (
<Cell key={stage.stage} fill={stage.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
) : null}
{showChannels ? (
<ChartCard title="Orders by channel" description="Where orders originate" height="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={channelMix} margin={chartMargin}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="label" {...axisProps} />
<YAxis {...axisProps} width={36} allowDecimals={false} />
<Tooltip
{...tooltipProps}
formatter={(value, name) =>
name === 'Revenue' ? formatEtb(Number(value)) : String(value)
}
/>
<Bar dataKey="orders" name="Orders" radius={[6, 6, 0, 0]} barSize={40}>
{channelMix.map((channel) => (
<Cell key={channel.key} fill={channel.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
) : null}
{showBranches ? (
<ChartCard
title="Branch performance"
description={
branchLinksEnabled
? 'Revenue and order count — select a branch to open its profile'
: 'Revenue and order count per branch'
}
height="h-72"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={branchPerformance} margin={chartMargin}>
<CartesianGrid {...gridProps} />
<XAxis
dataKey="branch"
tickLine={false}
axisLine={false}
fontSize={11}
tick={
branchLinksEnabled ? (
<LinkTick
anchor="middle"
dy={12}
onSelect={(branch) => {
const match = branchPerformance.find((b) => b.branch === branch)
if (match) navigate(`/settings/branches/${match.id}`)
}}
/>
) : (
{ fill: CHART_COLORS.neutral }
)
}
/>
<YAxis
yAxisId="revenue"
{...axisProps}
width={52}
tickFormatter={(v) => formatNumber(Number(v))}
/>
<YAxis yAxisId="orders" orientation="right" {...axisProps} width={36} allowDecimals={false} />
<Tooltip
{...tooltipProps}
formatter={(value, name) =>
name === 'Revenue' ? formatEtb(Number(value)) : String(value)
}
/>
<Legend verticalAlign="bottom" height={28} iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
<Bar
yAxisId="revenue"
dataKey="revenue"
name="Revenue"
fill={CHART_COLORS.brand}
radius={[6, 6, 0, 0]}
barSize={26}
/>
<Bar
yAxisId="orders"
dataKey="orders"
name="Orders"
fill={CHART_COLORS.orange}
radius={[6, 6, 0, 0]}
barSize={26}
/>
</BarChart>
</ResponsiveContainer>
</ChartCard>
) : null}
{showStockHealth ? (
<ChartCard
title="Stock health by branch"
description={`Low stock, near expiry and ERP gaps (threshold ${LOW_STOCK_THRESHOLD} units)`}
height="h-72"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stockHealth} margin={chartMargin}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="branch" {...axisProps} />
<YAxis {...axisProps} width={36} allowDecimals={false} />
<Tooltip {...tooltipProps} />
<Legend verticalAlign="bottom" height={28} iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="low" name="Low stock" fill={CHART_COLORS.orange} radius={[6, 6, 0, 0]} barSize={20} />
<Bar dataKey="expiring" name="Near expiry" fill={CHART_COLORS.brand} radius={[6, 6, 0, 0]} barSize={20} />
<Bar dataKey="mismatch" name="ERP gap" fill={CHART_COLORS.forest} radius={[6, 6, 0, 0]} barSize={20} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
) : null}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Quick context</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">Needs attention</CardTitle>
{attention.length > 0 ? (
<Badge variant="warning">{attention.length} open</Badge>
) : null}
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Signed in as <strong className="text-foreground">{user?.name}</strong> ({user?.role}).
{user?.branchName ? ` Scoped to ${user.branchName}.` : ' Platform-wide view.'}
<CardContent className="space-y-2">
{attention.length === 0 ? (
<EmptyState
icon={CheckCheck}
title="Nothing needs you right now"
description="Pending prescriptions, low stock and unassigned deliveries will show up here."
variant="dashed"
/>
) : (
attention.map((item) => (
<Link
key={item.id}
to={item.to}
className="flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-sm transition-colors hover:border-brand-300 hover:bg-brand-50"
>
<div className="min-w-0">
<p className="font-medium text-brand-700">{item.title}</p>
<p className="truncate text-xs text-muted-foreground">{item.detail}</p>
</div>
<Badge variant={item.tone === 'danger' ? 'danger' : 'warning'} className="shrink-0">
{item.badge}
</Badge>
</Link>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">Recent activity</CardTitle>
{can('audit') ? (
<Button asChild size="sm" variant="outline" className="h-9">
<Link to="/audit">Open audit log</Link>
</Button>
) : null}
</CardHeader>
<CardContent>
<ActivityTimeline
events={activity}
empty="No activity recorded for the modules you can access."
/>
</CardContent>
</Card>
</div>
</PageShell>
)
}

View File

@ -1,28 +1,70 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Truck, Clock, CheckCircle2, Bike, Timer } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { SelectNative } from '@/components/ui/select-native'
import { orders as seedOrders, riders } from '@/mocks/data'
import { PersonAvatar } from '@/components/ui/avatar'
import { useAuthStore } from '@/zustand/authStore'
import { orders as seedOrders, riders, riderStats, riderTrips } from '@/mocks/data'
import { dispatchByHour } from '@/mocks/charts'
import type { Order } from '@/types'
/** Orders that have left the counter but are not closed out yet. */
const IN_FLIGHT = ['picked_up', 'en_route', 'out_for_delivery', 'arrived']
const CLOSED = ['delivered', 'cancelled', 'failed']
export function DispatchPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const [queue, setQueue] = useState<Order[]>(
seedOrders.filter((o) => o.paid && (o.rxApproved || o.fulfillment === 'pickup')),
seedOrders.filter(
(o) => o.paid && (o.rxApproved || o.fulfillment === 'pickup') && !CLOSED.includes(o.status),
),
)
const [botLog, setBotLog] = useState<string[]>([
'Bot ready · drivers linked by Telegram username',
])
const openDelivery = useMemo(
() => queue.filter((o) => o.fulfillment === 'delivery'),
[queue],
/** Branch scope for the signed-in role — the board, the rider list and every KPI read from these. */
const scopedRiders = useMemo(
() => (user?.branchId ? riders.filter((r) => r.branchId === user.branchId) : riders),
[user],
)
const openDelivery = useMemo(
() =>
queue.filter(
(o) => o.fulfillment === 'delivery' && (!user?.branchId || o.branchId === user.branchId),
),
[queue, user],
)
const kpis = useMemo(() => {
const trips = scopedRiders.flatMap((r) => riderTrips[r.id] ?? [])
const completed = trips.filter((trip) => trip.status === 'delivered')
const minutes = completed.reduce((sum, trip) => sum + trip.minutes, 0)
const onTime = completed.filter((trip) => trip.onTime).length
const onShift = scopedRiders.filter((r) => riderStats[r.id]?.status === 'on_shift').length
return {
inFlight: openDelivery.filter((o) => IN_FLIGHT.includes(o.status)).length,
unassigned: openDelivery.filter((o) => !o.assignedRiderId).length,
avgMinutes: completed.length ? Math.round(minutes / completed.length) : 0,
completedTrips: completed.length,
onTimeRate: completed.length ? Math.round((onTime / completed.length) * 100) : 0,
onShift,
}
}, [openDelivery, scopedRiders])
const assignTrip = (orderId: string, riderId: string) => {
const rider = riders.find((r) => r.id === riderId)
setQueue((prev) =>
@ -45,17 +87,68 @@ export function DispatchPage() {
return (
<PageShell title={t('dispatch.title')} subtitle={t('dispatch.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<StatCard
label="Active deliveries"
value={kpis.inFlight}
hint={`${openDelivery.length} open on the board`}
icon={Truck}
tone="brand"
/>
<StatCard
label={t('dispatch.stats.unassigned')}
value={kpis.unassigned}
hint="Waiting for a rider"
icon={Clock}
tone="orange"
/>
<StatCard
label="Average delivery time"
value={`${kpis.avgMinutes} min`}
hint={`Across ${kpis.completedTrips} completed trips`}
icon={Timer}
tone="forest"
/>
<StatCard
label="On-time rate"
value={`${kpis.onTimeRate}%`}
hint="Delivered inside the promised window"
icon={CheckCircle2}
tone="muted"
/>
<StatCard
label="Riders on shift"
value={kpis.onShift}
hint={`${scopedRiders.length} on the roster`}
icon={Bike}
tone="brand"
/>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader>
<CardTitle>Live queue (paid + Rx approved)</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{openDelivery.length === 0 ? (
<EmptyState
icon={Truck}
variant="dashed"
title="Nothing waiting to go out"
description="Paid, Rx-approved delivery orders land here as soon as a branch confirms them."
/>
) : null}
{openDelivery.map((o) => (
<div key={o.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="font-semibold">{o.id} · {o.customerName}</p>
<p className="font-semibold">
<Link to={`/orders/${o.id}`} className="text-brand-700 hover:underline">
{o.id}
</Link>{' '}
· {o.customerName}
</p>
<p className="text-xs text-muted-foreground">
{o.zone} · {o.address}
</p>
@ -69,7 +162,7 @@ export function DispatchPage() {
onChange={(e) => assignTrip(o.id, e.target.value)}
>
<option value="">Assign rider</option>
{riders.map((r) => (
{scopedRiders.map((r) => (
<option key={r.id} value={r.id}>
{r.name} ({r.telegramId})
</option>
@ -100,6 +193,17 @@ export function DispatchPage() {
</div>
</CardContent>
</Card>
<ChartCard title="Trips by hour" description="Demo dispatch load" height="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={dispatchByHour}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="hour" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} width={28} />
<Tooltip />
<Bar dataKey="trips" fill="#E28A1A" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<Card>
<CardHeader>
<CardTitle>Telegram Dispatch Bot</CardTitle>
@ -112,6 +216,39 @@ export function DispatchPage() {
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Riders on roster</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{scopedRiders.map((r) => {
const status = riderStats[r.id]?.status ?? 'off_shift'
return (
<Link
key={r.id}
to={`/dispatch/riders/${r.id}`}
className="flex items-center gap-3 rounded-xl border px-3 py-2 transition-colors hover:bg-muted/40"
>
<PersonAvatar
name={r.name}
avatarUrl={r.avatarUrl}
size="sm"
presence={status === 'on_shift' ? 'online' : status === 'on_break' ? 'away' : 'offline'}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-forest">{r.name}</p>
<p className="truncate text-xs text-muted-foreground">
{r.telegramId} · {r.zone}
</p>
</div>
<Badge variant={status === 'on_shift' ? 'success' : status === 'on_break' ? 'warning' : 'muted'}>
{status.replaceAll('_', ' ')}
</Badge>
</Link>
)
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Open order intake</CardTitle>

View File

@ -0,0 +1,303 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Gauge, MessageCircle, PowerOff, Route, Star, Timer, Truck, Wallet } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { PersonAvatar } from '@/components/ui/avatar'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ClearFiltersButton, FilterSelect } from '@/components/table/TableToolbar'
import { auditLog, branches, orders, riderStats, riderTrips, riders } from '@/mocks/data'
import { riderOnTimeSeries } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
import type { TimelineEvent } from '@/components/detail/ActivityTimeline'
import type { RiderTrip } from '@/mocks/data'
const SHIFT_BADGE = {
on_shift: 'success',
on_break: 'warning',
off_shift: 'muted',
} as const
const SHIFT_LABEL = {
on_shift: 'on shift',
on_break: 'on break',
off_shift: 'off shift',
} as const
const TRIP_BADGE = {
delivered: 'success',
en_route: 'default',
returned: 'warning',
failed: 'danger',
} as const
const TRIP_TONE: Record<RiderTrip['status'], TimelineEvent['tone']> = {
delivered: 'success',
en_route: 'default',
returned: 'warning',
failed: 'danger',
}
export function RiderDetailPage() {
const { id } = useParams()
const rider = riders.find((r) => r.id === id)
const stats = rider ? riderStats[rider.id] : undefined
const riderId = rider?.id ?? ''
const [tripStatus, setTripStatus] = useState('all')
const trips = useMemo(() => riderTrips[riderId] ?? [], [riderId])
const series = useMemo(() => riderOnTimeSeries[riderId] ?? [], [riderId])
const filteredTrips = useMemo(
() => (tripStatus === 'all' ? trips : trips.filter((tr) => tr.status === tripStatus)),
[trips, tripStatus],
)
const timeline = useMemo<TimelineEvent[]>(() => {
const fromTrips: TimelineEvent[] = trips.slice(0, 8).map((tr) => ({
id: tr.id,
at: tr.completedAt ?? tr.assignedAt,
title: `${tr.orderId} · ${tr.status.replace('_', ' ')}`,
detail: `${tr.customerName} · ${tr.zone}${tr.codEtb > 0 ? ` · COD ${formatEtb(tr.codEtb)}` : ''}`,
tone: TRIP_TONE[tr.status],
}))
const fromAudit: TimelineEvent[] = auditLog
.filter((e) => e.target === riderId)
.map((e) => ({
id: e.id,
at: e.at,
title: `${e.action} — ${e.actor}`,
detail: e.note ?? e.targetLabel,
tone: e.outcome === 'failed' ? 'danger' : 'default',
}))
return [...fromTrips, ...fromAudit].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
}, [trips, riderId])
if (!rider || !stats) {
return (
<DetailPageLayout backTo="/dispatch" title="Rider not found">
<Button asChild>
<Link to="/dispatch">Back to dispatch</Link>
</Button>
</DetailPageLayout>
)
}
const branchName = branches.find((b) => b.id === rider.branchId)?.name ?? '—'
const onShift = stats.status !== 'off_shift'
return (
<DetailPageLayout
backTo="/dispatch"
backLabel="Back to dispatch"
title={rider.name}
subtitle={`${rider.phone} · ${rider.zone} · ${branchName}`}
badges={
<>
<PersonAvatar
name={rider.name}
avatarUrl={rider.avatarUrl}
size="md"
presence={stats.status === 'on_shift' ? 'online' : stats.status === 'on_break' ? 'away' : 'offline'}
/>
<Badge variant={SHIFT_BADGE[stats.status]}>{SHIFT_LABEL[stats.status]}</Badge>
<Badge variant={stats.telegramLinked ? 'success' : 'warning'}>
{stats.telegramLinked ? 'Telegram linked' : 'Not linked'}
</Badge>
</>
}
actions={
<>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Telegram message drafted to ${rider.telegramId}`)}
>
<MessageCircle className="h-3.5 w-3.5" />
Message on Telegram
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.message(`Trip assignment opened for ${rider.name} (mock)`)}
>
<Truck className="h-3.5 w-3.5" />
Assign trip
</Button>
<Button
size="sm"
className="h-9 gap-1.5"
onClick={() =>
toast.success(onShift ? `${rider.name} shift ended` : `${rider.name} shift started`)
}
>
<PowerOff className="h-3.5 w-3.5" />
{onShift ? 'End shift' : 'Start shift'}
</Button>
</>
}
sidebar={
<DetailSection title="Recent activity" description="Latest trips and dispatch audit events">
<ActivityTimeline events={timeline} empty="No rider activity recorded" />
</DetailSection>
}
>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
<StatCard
label="Trips today"
value={stats.tripsToday}
hint={`${stats.tripsTotal} lifetime`}
icon={Route}
tone="brand"
/>
<StatCard label="On-time rate" value={`${stats.onTimeRate}%`} icon={Gauge} tone="forest" />
<StatCard label="Avg delivery time" value={`${stats.avgMinutes} min`} icon={Timer} tone="muted" />
<StatCard label="COD held" value={formatEtb(stats.codHeldEtb)} icon={Wallet} tone="orange" />
<StatCard label="Rating" value={`${stats.rating} / 5`} hint="Customer feedback" icon={Star} tone="brand" />
</div>
<DetailSection title="Rider profile">
<KeyValueGrid
columns={2}
items={[
{ label: 'Phone', value: <span className="whitespace-nowrap">{rider.phone}</span> },
{
label: 'Telegram id',
value: (
<Badge variant="outline" className="font-mono">
{rider.telegramId}
</Badge>
),
},
{ label: 'Branch', value: branchName },
{ label: 'Zone', value: rider.zone },
{ label: 'Vehicle', value: stats.vehicle },
{ label: 'Plate', value: stats.plate },
{ label: 'Shift', value: <span className="whitespace-nowrap">{stats.shift}</span> },
{ label: 'Joined', value: new Date(stats.joinedAt).toLocaleDateString() },
{ label: 'Lifetime trips', value: stats.tripsTotal },
{ label: 'Rating', value: `${stats.rating} / 5` },
{
label: 'Last seen',
value: <span className="whitespace-nowrap">{new Date(stats.lastSeenAt).toLocaleString()}</span>,
},
]}
/>
</DetailSection>
<ChartCard title="On-time performance" description="Completed trips split by on-time and late">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={series}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} width={28} />
<Tooltip />
<Legend />
<Bar dataKey="onTime" stackId="trips" name="On time" fill="#A8C73A" radius={[0, 0, 0, 0]} />
<Bar dataKey="late" stackId="trips" name="Late" fill="#E28A1A" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<DetailSection
title="Delivery history"
description={`${filteredTrips.length} of ${trips.length} trips`}
action={
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label="Trip status"
value={tripStatus}
onChange={setTripStatus}
options={[
{ value: 'all', label: 'All trips' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'en_route', label: 'En route' },
{ value: 'returned', label: 'Returned' },
{ value: 'failed', label: 'Failed' },
]}
/>
<ClearFiltersButton visible={tripStatus !== 'all'} onClick={() => setTripStatus('all')} />
</div>
}
>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Order</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="hidden xl:table-cell">Zone</TableHead>
<TableHead className="hidden lg:table-cell">Assigned</TableHead>
<TableHead className="hidden lg:table-cell">Completed</TableHead>
<TableHead className="hidden md:table-cell">Duration</TableHead>
<TableHead className="hidden md:table-cell">On time</TableHead>
<TableHead className="hidden xl:table-cell">COD</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTrips.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center text-muted-foreground">
No trips for this filter
</TableCell>
</TableRow>
) : (
filteredTrips.map((tr) => {
const orderExists = orders.some((o) => o.id === tr.orderId)
return (
<TableRow key={tr.id}>
<TableCell className="whitespace-nowrap font-medium">
{orderExists ? (
<Link to={`/orders/${tr.orderId}`} className="text-brand-700 hover:underline">
{tr.orderId}
</Link>
) : (
<span className="text-muted-foreground">{tr.orderId}</span>
)}
</TableCell>
<TableCell>{tr.customerName}</TableCell>
<TableCell className="hidden xl:table-cell">{tr.zone}</TableCell>
<TableCell className="hidden whitespace-nowrap text-muted-foreground lg:table-cell">
{new Date(tr.assignedAt).toLocaleString()}
</TableCell>
<TableCell className="hidden whitespace-nowrap text-muted-foreground lg:table-cell">
{tr.completedAt ? new Date(tr.completedAt).toLocaleString() : '—'}
</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">
{tr.minutes > 0 ? `${tr.minutes} min` : '—'}
</TableCell>
<TableCell className="hidden md:table-cell">
{tr.status === 'en_route' ? (
<span className="text-muted-foreground">—</span>
) : (
<Badge variant={tr.onTime ? 'success' : 'warning'}>{tr.onTime ? 'on time' : 'late'}</Badge>
)}
</TableCell>
<TableCell className="hidden whitespace-nowrap xl:table-cell">
{tr.codEtb > 0 ? formatEtb(tr.codEtb) : '—'}
</TableCell>
<TableCell>
<Badge variant={TRIP_BADGE[tr.status]}>{tr.status.replace('_', ' ')}</Badge>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -0,0 +1,140 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { EyeOff, Save } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { faqs } from '@/mocks/data'
import type { FaqArticle } from '@/types'
import { canAccess } from '@/config/permissions'
import { useAuthStore } from '@/zustand/authStore'
export function FaqDetailPage() {
const { id } = useParams()
const user = useAuthStore((s) => s.user)
const canWrite = user ? canAccess(user.role, 'faq_write') || user.role === 'super_admin' : false
const seed = faqs.find((f) => f.id === id)
const [article, setArticle] = useState<FaqArticle | undefined>(seed ? { ...seed } : undefined)
if (!article) {
return (
<DetailPageLayout backTo="/faq" title="Article not found">
<Button asChild>
<Link to="/faq">Back to FAQ</Link>
</Button>
</DetailPageLayout>
)
}
const save = () => {
const target = faqs.find((f) => f.id === article.id)
if (target) Object.assign(target, article, { updatedAt: new Date().toISOString() })
toast.success('FAQ article saved')
}
const unpublish = () => {
setArticle((prev) => (prev ? { ...prev, published: false } : prev))
const target = faqs.find((f) => f.id === article.id)
if (target) target.published = false
toast.message('Article unpublished')
}
return (
<DetailPageLayout
backTo="/faq"
backLabel="Back to FAQ"
title={article.title_en}
subtitle={article.title_am}
badges={
<>
<Badge variant="outline">{article.category}</Badge>
<Badge variant={article.published ? 'success' : 'secondary'}>
{article.published ? 'Published' : 'Draft'}
</Badge>
</>
}
actions={
canWrite ? (
<>
<Button size="sm" className="h-9 gap-1.5" onClick={save}>
<Save className="h-3.5 w-3.5" />
Save
</Button>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={unpublish}>
<EyeOff className="h-3.5 w-3.5" />
Unpublish
</Button>
</>
) : undefined
}
>
<div className="space-y-5">
<DetailSection title="Metadata">
<KeyValueGrid
columns={3}
items={[
{ label: 'Category', value: article.category },
{ label: 'Updated', value: new Date(article.updatedAt).toLocaleString() },
{ label: 'Visibility', value: article.published ? 'Staff help center' : 'Draft only' },
]}
/>
</DetailSection>
{canWrite ? (
<DetailSection title="Edit copy">
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Title (EN)</Label>
<Input
value={article.title_en}
onChange={(e) => setArticle({ ...article, title_en: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Title (AM)</Label>
<Input
value={article.title_am}
onChange={(e) => setArticle({ ...article, title_am: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Body (EN)</Label>
<Textarea
rows={4}
value={article.body_en}
onChange={(e) => setArticle({ ...article, body_en: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Body (AM)</Label>
<Textarea
rows={4}
value={article.body_am}
onChange={(e) => setArticle({ ...article, body_am: e.target.value })}
/>
</div>
</div>
</DetailSection>
) : (
<DetailSection title="Article">
<div className="space-y-4 text-sm">
<div>
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">English</p>
<p>{article.body_en}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Amharic</p>
<p>{article.body_am}</p>
</div>
</div>
</DetailSection>
)}
</div>
</DetailPageLayout>
)
}

View File

@ -1,7 +1,10 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { BookOpenCheck, Eye, Layers, PenLine, Star } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@ -30,6 +33,28 @@ export function FaqPage() {
const lang = i18n.language === 'am' ? 'am' : 'en'
/** Readers never see drafts, so the KPIs count the same articles the reader is allowed to open. */
const visible = useMemo(
() => (canWrite ? articles : articles.filter((a) => a.published)),
[articles, canWrite],
)
const kpis = useMemo(() => {
const published = visible.filter((a) => a.published)
const mostViewed = published.reduce<(typeof published)[number] | null>(
(top, article) => (!top || article.views > top.views ? article : top),
null,
)
return {
published: published.length,
drafts: visible.length - published.length,
views: published.reduce((sum, a) => sum + a.views, 0),
categories: new Set(visible.map((a) => a.category)).size,
mostViewed,
}
}, [visible])
const filtered = useMemo(() => {
return articles.filter((a) => {
if (!a.published && !canWrite) return false
@ -50,6 +75,7 @@ export function FaqPage() {
...draft,
published: true,
updatedAt: new Date().toISOString(),
views: 0,
}
setArticles((prev) => [article, ...prev])
seed.unshift(article)
@ -59,18 +85,72 @@ export function FaqPage() {
return (
<PageShell title={t('faq.title')} subtitle={t('faq.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Published articles"
value={kpis.published}
hint={`${kpis.categories} categories covered`}
icon={BookOpenCheck}
tone="brand"
/>
{canWrite ? (
<StatCard
label="Drafts"
value={kpis.drafts}
hint="Not visible to staff yet"
icon={PenLine}
tone="orange"
/>
) : (
<StatCard
label="Categories"
value={kpis.categories}
hint="Sections of the knowledge base"
icon={Layers}
tone="orange"
/>
)}
<StatCard
label="Total views"
value={kpis.views.toLocaleString()}
hint="Reads since publication"
icon={Eye}
tone="forest"
/>
<StatCard
label="Most-viewed article"
value={kpis.mostViewed ? kpis.mostViewed.views.toLocaleString() : 0}
hint={kpis.mostViewed ? (lang === 'am' ? kpis.mostViewed.title_am : kpis.mostViewed.title_en) : 'No reads yet'}
icon={Star}
tone="muted"
to={kpis.mostViewed ? `/faq/${kpis.mostViewed.id}` : undefined}
/>
</div>
<Input className="max-w-md" placeholder={t('common.search')} value={q} onChange={(e) => setQ(e.target.value)} />
<div className="grid gap-4 lg:grid-cols-2">
{filtered.map((a) => (
<Card key={a.id}>
<Card key={a.id} className="transition-shadow hover:shadow-soft">
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">{lang === 'am' ? a.title_am : a.title_en}</CardTitle>
<CardTitle className="text-base">
<Link to={`/faq/${a.id}`} className="hover:underline">
{lang === 'am' ? a.title_am : a.title_en}
</Link>
</CardTitle>
<div className="flex shrink-0 items-center gap-1.5">
{a.published ? null : <Badge variant="warning">{t('faq.draft')}</Badge>}
<Badge variant="secondary">{a.category}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="text-sm text-muted-foreground whitespace-pre-wrap">
<CardContent className="space-y-3">
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{lang === 'am' ? a.body_am : a.body_en}
</p>
<Button asChild size="sm" variant="outline" className="h-9">
<Link to={`/faq/${a.id}`}>Open article</Link>
</Button>
</CardContent>
</Card>
))}

View File

@ -1,17 +1,93 @@
import { Link } from 'react-router-dom'
import { useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Settings2 } from 'lucide-react'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Clock3, Eye, Gift, Receipt, Settings2, Wallet } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { settlements } from '@/mocks/data'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { GatewaySettings } from '@/pages/finance/GatewaySettings'
import { settlementsDetailed } from '@/mocks/data'
import { channelMix, settlementTrend } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
const LOYALTY_LIABILITY_ETB = 182400
const settlementStatusOptions = [
{ value: 'all', label: 'All statuses' },
{ value: 'reconciled', label: 'Reconciled' },
{ value: 'pending', label: 'Pending' },
]
export function FinancePage() {
const { t } = useTranslation()
const [searchParams, setSearchParams] = useSearchParams()
const [q, setQ] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const tab = searchParams.get('tab') === 'gateways' ? 'gateways' : 'overview'
const onTabChange = (value: string) => {
const next = new URLSearchParams(searchParams)
if (value === 'overview') next.delete('tab')
else next.set('tab', value)
setSearchParams(next, { replace: true })
}
const totals = useMemo(() => {
const settled = settlementsDetailed
.filter((s) => s.status === 'reconciled')
.reduce((sum, s) => sum + s.volumeEtb, 0)
const pending = settlementsDetailed
.filter((s) => s.status === 'pending')
.reduce((sum, s) => sum + s.volumeEtb, 0)
const txns = settlementsDetailed.reduce((sum, s) => sum + s.txnCount, 0)
return { settled, pending, txns }
}, [])
const filtered = useMemo(() => {
let list = settlementsDetailed
if (statusFilter !== 'all') list = list.filter((s) => s.status === statusFilter)
if (q) {
const needle = q.toLowerCase()
list = list.filter(
(s) => s.channel.toLowerCase().includes(needle) || s.id.toLowerCase().includes(needle),
)
}
return list
}, [q, statusFilter])
const settlementFilters: TableFilter[] = [
{
id: 'status',
label: t('common.status'),
value: statusFilter,
onChange: setStatusFilter,
options: settlementStatusOptions,
},
]
return (
<PageShell
@ -26,47 +102,155 @@ export function FinancePage() {
</Button>
}
>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Settled today"
value={formatEtb(totals.settled)}
hint="Reconciled batches"
icon={Wallet}
tone="brand"
/>
<StatCard
label="Pending reconciliation"
value={formatEtb(totals.pending)}
hint="Awaiting match"
icon={Clock3}
tone="orange"
/>
<StatCard
label="Transactions"
value={totals.txns}
hint="Across all channels"
icon={Receipt}
tone="forest"
/>
<StatCard
label="Loyalty liability"
value={formatEtb(LOYALTY_LIABILITY_ETB)}
hint="Points outstanding (Aug)"
icon={Gift}
tone="muted"
/>
</div>
<Tabs value={tab} onValueChange={onTabChange}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="gateways">Gateways</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="space-y-4">
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>Daily settlements</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search channel or batch…"
resultCount={filtered.length}
filters={settlementFilters}
onClearFilters={() => {
setQ('')
setStatusFilter('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead>Channel</TableHead>
<TableHead>Date</TableHead>
<TableHead>Volume</TableHead>
<TableHead>Status</TableHead>
<TableHead>Txns</TableHead>
<TableHead>Fees</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{settlements.map((s) => (
<TableRow key={s.channel}>
<TableCell>{s.channel}</TableCell>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filtered.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
<Link to={`/finance/settlements/${s.id}`} className="text-brand-700 hover:underline">
{s.channel}
</Link>
</TableCell>
<TableCell>{s.date}</TableCell>
<TableCell>{formatEtb(s.volumeEtb)}</TableCell>
<TableCell>{s.txnCount}</TableCell>
<TableCell>{formatEtb(s.feesEtb)}</TableCell>
<TableCell>
<Badge variant={s.status === 'reconciled' ? 'success' : 'warning'}>{s.status}</Badge>
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: t('common.view'),
icon: <Eye className="h-3.5 w-3.5" />,
to: `/finance/settlements/${s.id}`,
},
]}
/>
</TableCell>
</TableRow>
))}
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Operations</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
<div className="grid gap-4 xl:grid-cols-3">
<ChartCard
title="Settlement volume by channel"
description="Daily gateway settlements (demo data)"
className="xl:col-span-2"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={settlementTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => formatEtb(Number(v))} />
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="chapa" stackId="v" name="Chapa" fill="#A8C73A" />
<Bar dataKey="telebirr" stackId="v" name="Telebirr" fill="#E28A1A" />
<Bar dataKey="cod" stackId="v" name="COD" fill="#141414" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Channel mix" description="Share of paid orders (demo data)">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={channelMix} dataKey="value" nameKey="name" innerRadius={45} outerRadius={75}>
{channelMix.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(v) => `${Number(v)}%`} />
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
</ChartCard>
</div>
<DetailSection title="Operations" description="Loyalty liability and manual finance actions">
<div className="space-y-3">
<div className="rounded-xl border bg-muted/40 p-3 text-sm">
Loyalty points liability (Aug): <strong>{formatEtb(182400)}</strong>
Loyalty points liability (Aug): <strong>{formatEtb(LOYALTY_LIABILITY_ETB)}</strong>
</div>
<p className="text-sm text-muted-foreground">
Gateway enable / disable lives on a dedicated settings page so day-to-day finance work stays focused on
settlements and refunds.
Gateway enable / disable lives in the Gateways tab and on its own settings page, so day-to-day finance
work stays focused on settlements and refunds.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" className="h-10" onClick={() => toast.message('Refund queued (mock)')}>
@ -79,9 +263,14 @@ export function FinancePage() {
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</DetailSection>
</div>
</TabsContent>
<TabsContent value="gateways">
<GatewaySettings />
</TabsContent>
</Tabs>
</PageShell>
)
}

View File

@ -0,0 +1,50 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { SwitchRow } from '@/pages/finance/SwitchRow'
import { cn } from '@/lib/utils'
const initialGateways = [
{ id: 'chapa', label: 'Chapa', enabled: true, type: 'Aggregator' },
{ id: 'arifpay', label: 'ArifPay', enabled: true, type: 'Aggregator' },
{ id: 'telebirr', label: 'Telebirr', enabled: false, type: 'Direct wallet' },
{ id: 'mpesa', label: 'M-Pesa', enabled: false, type: 'Direct wallet' },
]
/** Shared by the Finance page Gateways tab and the standalone /finance/gateways route */
export function GatewaySettings({ className }: { className?: string }) {
const [gw, setGw] = useState(initialGateways)
return (
<Card className={cn('max-w-2xl', className)}>
<CardHeader>
<CardTitle>Channel controls</CardTitle>
<CardDescription>
Changes apply immediately in the demo. Telebirr / M-Pesa may require INSA clearance before go-live.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{gw.map((g) => (
<div key={g.id} className="rounded-xl border p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold">{g.label}</p>
<p className="text-xs text-muted-foreground">{g.type}</p>
</div>
<Badge variant={g.enabled ? 'success' : 'muted'}>{g.enabled ? 'Live' : 'Off'}</Badge>
</div>
<SwitchRow
label={g.enabled ? 'Disable channel' : 'Enable channel'}
enabled={g.enabled}
onToggle={() => {
setGw((prev) => prev.map((x) => (x.id === g.id ? { ...x, enabled: !x.enabled } : x)))
toast.success(`${g.label} ${g.enabled ? 'disabled' : 'enabled'} (no code release)`)
}}
/>
</div>
))}
</CardContent>
</Card>
)
}

View File

@ -1,23 +1,10 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'sonner'
import { ArrowLeft } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { SwitchRow } from '@/pages/finance/SwitchRow'
const initialGateways = [
{ id: 'chapa', label: 'Chapa', enabled: true, type: 'Aggregator' },
{ id: 'arifpay', label: 'ArifPay', enabled: true, type: 'Aggregator' },
{ id: 'telebirr', label: 'Telebirr', enabled: false, type: 'Direct wallet' },
{ id: 'mpesa', label: 'M-Pesa', enabled: false, type: 'Direct wallet' },
]
import { GatewaySettings } from '@/pages/finance/GatewaySettings'
export function PaymentGatewaysPage() {
const [gw, setGw] = useState(initialGateways)
return (
<PageShell
title="Payment gateways"
@ -31,37 +18,7 @@ export function PaymentGatewaysPage() {
</Button>
}
>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Channel controls</CardTitle>
<CardDescription>
Changes apply immediately in the demo. Telebirr / M-Pesa may require INSA clearance before go-live.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{gw.map((g) => (
<div key={g.id} className="rounded-xl border p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold">{g.label}</p>
<p className="text-xs text-muted-foreground">{g.type}</p>
</div>
<Badge variant={g.enabled ? 'success' : 'muted'}>{g.enabled ? 'Live' : 'Off'}</Badge>
</div>
<SwitchRow
label={g.enabled ? 'Disable channel' : 'Enable channel'}
enabled={g.enabled}
onToggle={() => {
setGw((prev) =>
prev.map((x) => (x.id === g.id ? { ...x, enabled: !x.enabled } : x)),
)
toast.success(`${g.label} ${g.enabled ? 'disabled' : 'enabled'} (no code release)`)
}}
/>
</div>
))}
</CardContent>
</Card>
<GatewaySettings />
</PageShell>
)
}

View File

@ -0,0 +1,69 @@
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { FileSpreadsheet, RefreshCw } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { settlementsDetailed } from '@/mocks/data'
import { formatEtb } from '@/lib/utils'
export function SettlementDetailPage() {
const { id } = useParams()
const row = settlementsDetailed.find((s) => s.id === id)
if (!row) {
return (
<DetailPageLayout backTo="/finance" title="Settlement not found">
<Button asChild>
<Link to="/finance">Back to finance</Link>
</Button>
</DetailPageLayout>
)
}
return (
<DetailPageLayout
backTo="/finance"
backLabel="Back to finance"
title={`${row.channel} · ${row.date}`}
subtitle={row.id}
badges={<Badge variant={row.status === 'reconciled' ? 'success' : 'warning'}>{row.status}</Badge>}
actions={
<>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success('Reconciliation re-run queued')}
>
<RefreshCw className="h-3.5 w-3.5" />
Re-reconcile
</Button>
<Button size="sm" className="h-9 gap-1.5" onClick={() => toast.message('CSV export started')}>
<FileSpreadsheet className="h-3.5 w-3.5" />
Export
</Button>
</>
}
>
<DetailSection title="Settlement batch">
<KeyValueGrid
columns={3}
items={[
{ label: 'Channel', value: row.channel },
{ label: 'Date', value: row.date },
{ label: 'Volume', value: formatEtb(row.volumeEtb) },
{ label: 'Transactions', value: String(row.txnCount) },
{ label: 'Fees', value: formatEtb(row.feesEtb) },
{ label: 'Net', value: formatEtb(row.volumeEtb - row.feesEtb) },
{ label: 'Status', value: row.status },
{ label: 'Mismatch', value: row.status === 'pending' ? '2 COD slips unmatched' : 'None' },
{ label: 'Ledger', value: 'GL-OPS-410' },
]}
/>
</DetailSection>
</DetailPageLayout>
)
}

View File

@ -1,29 +0,0 @@
import { useTranslation } from 'react-i18next'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
const threads = [
{ id: 1, channel: 'WhatsApp', customer: 'Abebe Kebede', preview: 'Where is my delivery?', unread: true },
{ id: 2, channel: 'Telegram', customer: 'Marta Hailu', preview: 'Refill reminder received', unread: false },
{ id: 3, channel: 'Chatwoot', customer: 'Guest', preview: 'Need CMC pickup hours', unread: true },
]
export function InboxPage() {
const { t } = useTranslation()
return (
<PageShell title={t('inbox.title')} subtitle={t('inbox.subtitle')}>
<div className="grid gap-3">
{threads.map((th) => (
<Card key={th.id}>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{th.customer}</CardTitle>
<Badge variant={th.unread ? 'warning' : 'secondary'}>{th.channel}</Badge>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">{th.preview}</CardContent>
</Card>
))}
</div>
</PageShell>
)
}

View File

@ -0,0 +1,776 @@
import { useMemo, useState, type FormEvent, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { AlertTriangle, Plus, Trash2 } from 'lucide-react'
import {
CreatePageLayout,
createFieldFull,
createFieldWide,
createFormGrid,
} from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { SelectNative } from '@/components/ui/select-native'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/zustand/authStore'
import {
adminRoutes,
ageRestrictions,
catalogItems,
controlledSchedules,
dosageForms,
efdaStatuses,
merchandisingBadges,
nextItemSku,
normalizeMatchKey,
priceLists,
productTypes,
scoreMatchKey,
slugifyItem,
therapeuticClasses,
type ActiveIngredient,
type CatalogBarcode,
type ControlledSchedule,
type MedicationItem,
type MerchandisingBadge,
type ProductType,
} from '@/mocks/data'
type FormState = {
genericName: string
brandName: string
manufacturer: string
countryOfOrigin: string
efdaRegistrationNumber: string
productType: ProductType
therapeuticClass: string
dosageForm: string
routeOfAdministration: string
controlledSchedule: ControlledSchedule | ''
packSize: string
baseUnit: string
sellByUnit: string
packUnit: string
costPriceEtb: string
sellingPriceEtb: string
vatApplicable: boolean
discountEligible: boolean
b2bPriceListId: string
efdaStatus: (typeof efdaStatuses)[number]
advertisingRestricted: boolean
ageRestriction: (typeof ageRestrictions)[number]
shortDescription: string
dosageGuidance: string
sideEffects: string
warnings: string
storageInstructions: string
defaultReorderPoint: string
defaultExpiryAlertDays: string
badges: MerchandisingBadge[]
b2bEligible: boolean
loyaltyEligible: boolean
status: MedicationItem['status']
}
const emptyIngredient = (): ActiveIngredient => ({ name: '', strength: '' })
const emptyBarcode = (): CatalogBarcode => ({ value: '', label: '' })
export function CreateItemPage() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const [sku] = useState(() => nextItemSku())
const [ingredients, setIngredients] = useState<ActiveIngredient[]>([emptyIngredient()])
const [barcodes, setBarcodes] = useState<CatalogBarcode[]>([emptyBarcode()])
const [form, setForm] = useState<FormState>({
genericName: '',
brandName: '',
manufacturer: '',
countryOfOrigin: 'Ethiopia',
efdaRegistrationNumber: '',
productType: 'OTC',
therapeuticClass: therapeuticClasses[0],
dosageForm: 'Tablet',
routeOfAdministration: 'Oral',
controlledSchedule: '',
packSize: '',
baseUnit: 'tablet',
sellByUnit: 'strip',
packUnit: 'box',
costPriceEtb: '',
sellingPriceEtb: '',
vatApplicable: true,
discountEligible: true,
b2bPriceListId: '',
efdaStatus: 'registered',
advertisingRestricted: false,
ageRestriction: 'none',
shortDescription: '',
dosageGuidance: '',
sideEffects: '',
warnings: '',
storageInstructions: 'Store below 25°C in a dry place.',
defaultReorderPoint: '20',
defaultExpiryAlertDays: '90',
badges: [],
b2bEligible: true,
loyaltyEligible: true,
status: 'active',
})
const isRx = form.productType === 'Rx' || form.productType === 'Controlled'
const isControlled = form.productType === 'Controlled'
const promoLocked = isRx
const matchKey = useMemo(
() =>
normalizeMatchKey({
genericName: form.genericName,
strength: ingredients.map((i) => i.strength).filter(Boolean).join('+'),
dosageForm: form.dosageForm,
packSize: form.packSize,
manufacturer: form.manufacturer,
}),
[form.genericName, form.dosageForm, form.packSize, form.manufacturer, ingredients],
)
const nearDuplicates = useMemo(() => {
if (!form.genericName.trim()) return []
return catalogItems
.map((item) => ({ item, score: scoreMatchKey(matchKey, item.matchKey) }))
.filter((row) => row.score >= 0.6)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
}, [matchKey, form.genericName])
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => {
const next = { ...prev, [key]: value }
if (key === 'productType') {
const type = value as ProductType
if (type === 'Rx' || type === 'Controlled') {
next.discountEligible = false
next.loyaltyEligible = false
next.badges = []
next.advertisingRestricted = true
}
if (type === 'Controlled') {
next.controlledSchedule = next.controlledSchedule || 'II'
} else {
next.controlledSchedule = ''
}
}
return next
})
}
const onSubmit = (e: FormEvent) => {
e.preventDefault()
if (!form.genericName.trim()) {
toast.error('Generic name / INN is required')
return
}
if (isControlled && !form.controlledSchedule) {
toast.error('Controlled schedule is required for controlled substances')
return
}
const cleanIngredients = ingredients.filter((i) => i.name.trim())
if (cleanIngredients.length === 0) {
toast.error('Add at least one active ingredient')
return
}
const cleanBarcodes = barcodes.filter((b) => b.value.trim())
const now = new Date().toISOString()
const actor = user?.name ?? 'Admin'
const item: MedicationItem = {
id: `item-${sku.toLowerCase()}`,
sku,
genericName: form.genericName.trim(),
brandName: form.brandName.trim(),
manufacturer: form.manufacturer.trim(),
countryOfOrigin: form.countryOfOrigin.trim(),
efdaRegistrationNumber: form.efdaRegistrationNumber.trim(),
barcodes: cleanBarcodes,
productType: form.productType,
therapeuticClass: form.therapeuticClass,
ingredients: cleanIngredients,
dosageForm: form.dosageForm,
routeOfAdministration: form.routeOfAdministration || undefined,
controlledSchedule: form.controlledSchedule || '',
packSize: form.packSize,
baseUnit: form.baseUnit,
sellByUnit: form.sellByUnit,
packUnit: form.packUnit,
costPriceEtb: Number(form.costPriceEtb) || 0,
sellingPriceEtb: Number(form.sellingPriceEtb) || 0,
currency: 'ETB',
sellingPriceOwner: 'Platform',
vatApplicable: form.vatApplicable,
discountEligible: promoLocked ? false : form.discountEligible,
b2bPriceListId: form.b2bPriceListId || undefined,
prescriptionRequired: isRx,
efdaStatus: form.efdaStatus,
controlledSubstance: isControlled,
advertisingRestricted: promoLocked ? true : form.advertisingRestricted,
ageRestriction: form.ageRestriction,
shortDescription: form.shortDescription,
dosageGuidance: form.dosageGuidance,
sideEffects: form.sideEffects,
warnings: form.warnings,
storageInstructions: form.storageInstructions,
alternativeItemIds: [],
defaultReorderPoint: Number(form.defaultReorderPoint) || 20,
defaultExpiryAlertDays: Number(form.defaultExpiryAlertDays) || 90,
images: [],
badges: promoLocked ? [] : form.badges,
seoSlug: slugifyItem(form.brandName, form.genericName),
metaTitle: `${form.brandName || form.genericName} | Gishen Pharmacy`,
metaDescription: form.shortDescription.slice(0, 160),
matchKey,
matchConfidence: nearDuplicates[0]?.score,
mergeCandidateId: nearDuplicates[0]?.item.id,
mergeStatus: nearDuplicates[0] && nearDuplicates[0].score >= 0.85 ? 'pending' : undefined,
ownership: {
core: 'Platform',
classification: 'Platform',
packaging: 'Platform',
pricing: 'Platform',
regulatory: 'Platform',
content: 'Platform',
inventory: 'Platform',
media: 'Platform',
relationships: 'Platform',
},
b2bEligible: form.b2bEligible,
loyaltyEligible: promoLocked ? false : form.loyaltyEligible,
createdBy: actor,
createdAt: now,
updatedBy: actor,
updatedAt: now,
status: form.status,
}
catalogItems.unshift(item)
toast.success(`Item ${sku} created`)
navigate(`/items/${item.id}`)
}
return (
<CreatePageLayout
title="Create medication item"
subtitle="Master product identity. Add stock batches separately after the item exists."
backTo="/items"
backLabel="Back to items"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/items')}>
Cancel
</Button>
<Button type="submit" form="create-item-form">
Save item
</Button>
</>
}
>
<form id="create-item-form" className="space-y-8" onSubmit={onSubmit}>
{nearDuplicates.length > 0 ? (
<div className="flex gap-3 rounded-xl border border-orange/30 bg-orange/5 px-4 py-3 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-orange-deep" />
<div className="space-y-1">
<p className="font-medium text-forest">Possible duplicate in the catalogue</p>
{nearDuplicates.map(({ item, score }) => (
<p key={item.id} className="text-muted-foreground">
<Link to={`/items/${item.id}`} className="text-brand-700 hover:underline">
{item.sku} · {item.brandName || item.genericName}
</Link>{' '}
({Math.round(score * 100)}% match)
</p>
))}
</div>
</div>
) : null}
<section className="space-y-4">
<SectionTitle title="1. Core identification" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Internal SKU</Label>
<Input value={sku} readOnly className="font-mono bg-muted/40" />
<p className="text-[11px] text-muted-foreground">Auto-generated and immutable after save</p>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Generic name / INN *</Label>
<Input
required
value={form.genericName}
onChange={(e) => setField('genericName', e.target.value)}
placeholder="e.g. Amoxicillin"
/>
</div>
<div className="space-y-1.5">
<Label>Brand name</Label>
<Input value={form.brandName} onChange={(e) => setField('brandName', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Manufacturer</Label>
<Input value={form.manufacturer} onChange={(e) => setField('manufacturer', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Country of origin</Label>
<Input
value={form.countryOfOrigin}
onChange={(e) => setField('countryOfOrigin', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>EFDA registration number</Label>
<Input
value={form.efdaRegistrationNumber}
onChange={(e) => setField('efdaRegistrationNumber', e.target.value)}
/>
</div>
</div>
<Repeater
title="Barcodes / GTIN"
onAdd={() => setBarcodes((prev) => [...prev, emptyBarcode()])}
>
{barcodes.map((row, index) => (
<div key={index} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
<Input
placeholder="Barcode value"
value={row.value}
onChange={(e) =>
setBarcodes((prev) =>
prev.map((b, i) => (i === index ? { ...b, value: e.target.value } : b)),
)
}
/>
<Input
placeholder="Label (pack size)"
value={row.label ?? ''}
onChange={(e) =>
setBarcodes((prev) =>
prev.map((b, i) => (i === index ? { ...b, label: e.target.value } : b)),
)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={barcodes.length === 1}
onClick={() => setBarcodes((prev) => prev.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</Repeater>
</section>
<Separator />
<section className="space-y-4">
<SectionTitle title="2. Classification" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Product type</Label>
<SelectNative
value={form.productType}
onChange={(e) => setField('productType', e.target.value as ProductType)}
>
{productTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Therapeutic / drug class</Label>
<SelectNative
value={form.therapeuticClass}
onChange={(e) => setField('therapeuticClass', e.target.value)}
>
{therapeuticClasses.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Dosage form</Label>
<SelectNative value={form.dosageForm} onChange={(e) => setField('dosageForm', e.target.value)}>
{dosageForms.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Route of administration</Label>
<SelectNative
value={form.routeOfAdministration}
onChange={(e) => setField('routeOfAdministration', e.target.value)}
>
<option value="">Optional</option>
{adminRoutes.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Controlled schedule{isControlled ? ' *' : ''}</Label>
<SelectNative
value={form.controlledSchedule}
disabled={!isControlled}
required={isControlled}
onChange={(e) => setField('controlledSchedule', e.target.value as ControlledSchedule | '')}
>
<option value="">Not applicable</option>
{controlledSchedules.map((s) => (
<option key={s} value={s}>
Schedule {s}
</option>
))}
</SelectNative>
</div>
</div>
<Repeater
title="Active ingredient(s)"
onAdd={() => setIngredients((prev) => [...prev, emptyIngredient()])}
>
{ingredients.map((row, index) => (
<div key={index} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
<Input
required={index === 0}
placeholder="Ingredient name"
value={row.name}
onChange={(e) =>
setIngredients((prev) =>
prev.map((ing, i) => (i === index ? { ...ing, name: e.target.value } : ing)),
)
}
/>
<Input
placeholder="Strength (e.g. 500mg)"
value={row.strength}
onChange={(e) =>
setIngredients((prev) =>
prev.map((ing, i) => (i === index ? { ...ing, strength: e.target.value } : ing)),
)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={ingredients.length === 1}
onClick={() => setIngredients((prev) => prev.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</Repeater>
{promoLocked ? (
<p className="text-xs text-muted-foreground">
Rx / Controlled items automatically lock promotions, merchandising badges and loyalty eligibility.
</p>
) : null}
</section>
<Separator />
<section className="space-y-4">
<SectionTitle title="3. Packaging & units" />
<div className={createFormGrid}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Pack size</Label>
<Input
value={form.packSize}
onChange={(e) => setField('packSize', e.target.value)}
placeholder='e.g. "10 tablets/strip"'
/>
</div>
<div className="space-y-1.5">
<Label>Base unit</Label>
<Input value={form.baseUnit} onChange={(e) => setField('baseUnit', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Sell-by unit</Label>
<Input value={form.sellByUnit} onChange={(e) => setField('sellByUnit', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Pack unit</Label>
<Input value={form.packUnit} onChange={(e) => setField('packUnit', e.target.value)} />
</div>
</div>
</section>
<Separator />
<section className="space-y-4">
<SectionTitle title="4. Pricing" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Cost price (ETB)</Label>
<Input
type="number"
min={0}
value={form.costPriceEtb}
onChange={(e) => setField('costPriceEtb', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Selling price (ETB)</Label>
<Input
type="number"
min={0}
required
value={form.sellingPriceEtb}
onChange={(e) => setField('sellingPriceEtb', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>B2B price list</Label>
<SelectNative
value={form.b2bPriceListId}
onChange={(e) => setField('b2bPriceListId', e.target.value)}
>
<option value="">None</option>
{priceLists.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</SelectNative>
</div>
<label className="flex items-center gap-2 text-sm pt-7">
<input
type="checkbox"
checked={form.vatApplicable}
onChange={(e) => setField('vatApplicable', e.target.checked)}
/>
VAT applicable
</label>
<label className="flex items-center gap-2 text-sm pt-7">
<input
type="checkbox"
disabled={promoLocked}
checked={promoLocked ? false : form.discountEligible}
onChange={(e) => setField('discountEligible', e.target.checked)}
/>
Discount eligible
</label>
<label className="flex items-center gap-2 text-sm pt-7">
<input
type="checkbox"
checked={form.b2bEligible}
onChange={(e) => setField('b2bEligible', e.target.checked)}
/>
B2B eligible
</label>
<label className="flex items-center gap-2 text-sm pt-7">
<input
type="checkbox"
disabled={promoLocked}
checked={promoLocked ? false : form.loyaltyEligible}
onChange={(e) => setField('loyaltyEligible', e.target.checked)}
/>
Loyalty eligible
</label>
</div>
</section>
<Separator />
<section className="space-y-4">
<SectionTitle title="5. Regulatory & restrictions" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>EFDA status</Label>
<SelectNative
value={form.efdaStatus}
onChange={(e) => setField('efdaStatus', e.target.value as FormState['efdaStatus'])}
>
{efdaStatuses.map((s) => (
<option key={s} value={s}>
{s.replaceAll('_', ' ')}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Age restriction</Label>
<SelectNative
value={form.ageRestriction}
onChange={(e) => setField('ageRestriction', e.target.value as FormState['ageRestriction'])}
>
{ageRestrictions.map((s) => (
<option key={s} value={s}>
{s.replaceAll('_', ' ')}
</option>
))}
</SelectNative>
</div>
<label className="flex items-center gap-2 text-sm pt-7">
<input
type="checkbox"
disabled={promoLocked}
checked={promoLocked ? true : form.advertisingRestricted}
onChange={(e) => setField('advertisingRestricted', e.target.checked)}
/>
Advertising restricted
</label>
<div className="space-y-1.5">
<Label>Status</Label>
<SelectNative
value={form.status}
onChange={(e) => setField('status', e.target.value as MedicationItem['status'])}
>
<option value="active">Active</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</SelectNative>
</div>
</div>
</section>
<Separator />
<section className="space-y-4">
<SectionTitle title="6. Content & guidance" />
<div className={createFormGrid}>
<div className={cn('space-y-1.5', createFieldFull)}>
<Label>Short description</Label>
<Textarea
rows={2}
value={form.shortDescription}
onChange={(e) => setField('shortDescription', e.target.value)}
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Dosage guidance</Label>
<Textarea
rows={2}
value={form.dosageGuidance}
onChange={(e) => setField('dosageGuidance', e.target.value)}
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Side effects</Label>
<Textarea
rows={2}
value={form.sideEffects}
onChange={(e) => setField('sideEffects', e.target.value)}
/>
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Warnings</Label>
<Textarea rows={2} value={form.warnings} onChange={(e) => setField('warnings', e.target.value)} />
</div>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Storage instructions</Label>
<Textarea
rows={2}
value={form.storageInstructions}
onChange={(e) => setField('storageInstructions', e.target.value)}
/>
</div>
</div>
</section>
<Separator />
<section className="space-y-4">
<SectionTitle title="7. Inventory defaults & merchandising" />
<div className={createFormGrid}>
<div className="space-y-1.5">
<Label>Default reorder point</Label>
<Input
type="number"
min={0}
value={form.defaultReorderPoint}
onChange={(e) => setField('defaultReorderPoint', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Expiry alert (days)</Label>
<Input
type="number"
min={0}
value={form.defaultExpiryAlertDays}
onChange={(e) => setField('defaultExpiryAlertDays', e.target.value)}
/>
</div>
<div className={cn('space-y-2', createFieldWide)}>
<Label>Merchandising badges</Label>
<div className="flex flex-wrap gap-2">
{merchandisingBadges.map((badge) => {
const active = form.badges.includes(badge)
return (
<button
key={badge}
type="button"
disabled={promoLocked}
onClick={() =>
setField(
'badges',
active ? form.badges.filter((b) => b !== badge) : [...form.badges, badge],
)
}
className={cn(
'rounded-full border px-3 py-1 text-xs capitalize transition-colors',
active ? 'border-brand-500 bg-brand-100 text-brand-700' : 'bg-background',
promoLocked && 'cursor-not-allowed opacity-50',
)}
>
{badge}
</button>
)
})}
</div>
{promoLocked ? (
<Badge variant="warning">Badges disabled for Rx / Controlled</Badge>
) : null}
</div>
</div>
</section>
</form>
</CreatePageLayout>
)
}
function SectionTitle({ title }: { title: string }) {
return <h2 className="font-display text-base font-semibold text-forest">{title}</h2>
}
function Repeater({
title,
onAdd,
children,
}: {
title: string
onAdd: () => void
children: ReactNode
}) {
return (
<div className="space-y-3 rounded-xl border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{title}</p>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5" onClick={onAdd}>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
<div className="space-y-2">{children}</div>
</div>
)
}

View File

@ -0,0 +1,263 @@
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Archive, Boxes, Pencil, Plus } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
findCatalogItem,
itemChangeHistory,
itemVariants,
priceLists,
stockRows,
} from '@/mocks/data'
import { formatDateTime, formatEtb } from '@/lib/utils'
export function ItemDetailPage() {
const { id } = useParams()
const item = id ? findCatalogItem(id) : undefined
if (!item) {
return (
<DetailPageLayout backTo="/items" title="Item not found">
<Button asChild>
<Link to="/items">Back to items</Link>
</Button>
</DetailPageLayout>
)
}
const stock = stockRows.filter((row) => row.itemId === item.id || row.sku === item.sku)
const variants = itemVariants(item.id)
const history = itemChangeHistory.filter((event) => event.itemId === item.id)
const priceList = priceLists.find((p) => p.id === item.b2bPriceListId)
const onHand = stock.reduce((sum, row) => sum + row.qty, 0)
return (
<DetailPageLayout
backTo="/items"
backLabel="Back to items"
title={item.brandName || item.genericName}
subtitle={`${item.sku} · ${item.genericName} · ${item.dosageForm}`}
badges={
<>
<Badge variant="secondary">{item.productType}</Badge>
{item.prescriptionRequired ? <Badge variant="warning">Rx</Badge> : null}
{item.controlledSubstance ? (
<Badge variant="danger">Schedule {item.controlledSchedule || '?'}</Badge>
) : null}
<Badge variant={item.status === 'active' ? 'success' : 'muted'}>{item.status}</Badge>
</>
}
actions={
<>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.message('Edit form opens here (mock)')}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success('Item archived (mock)')}
>
<Archive className="h-3.5 w-3.5" />
Archive
</Button>
<Button asChild size="sm" className="h-9 gap-1.5">
<Link to={`/stock/new?item=${item.id}`}>
<Plus className="h-3.5 w-3.5" />
Add stock
</Link>
</Button>
</>
}
sidebar={
<DetailSection title="Change history">
<ActivityTimeline
events={history.map((event) => ({
id: event.id,
at: event.at,
title: event.title,
detail: [event.actor, event.detail].filter(Boolean).join(' · '),
tone: event.tone,
}))}
empty="No catalogue changes recorded yet"
/>
</DetailSection>
}
>
<div className="space-y-5">
<DetailSection title="Identification">
<KeyValueGrid
columns={3}
items={[
{ label: 'Internal SKU', value: <span className="font-mono">{item.sku}</span> },
{ label: 'Generic / INN', value: item.genericName },
{ label: 'Brand', value: item.brandName || '—' },
{ label: 'Manufacturer', value: item.manufacturer },
{ label: 'Country of origin', value: item.countryOfOrigin },
{ label: 'EFDA registration', value: item.efdaRegistrationNumber || '—' },
{
label: 'Barcodes',
value:
item.barcodes.length === 0
? '—'
: item.barcodes.map((b) => `${b.value}${b.label ? ` (${b.label})` : ''}`).join(', '),
},
{ label: 'Match key', value: <span className="font-mono text-xs">{item.matchKey}</span> },
{ label: 'Updated', value: `${formatDateTime(item.updatedAt)} · ${item.updatedBy}` },
]}
/>
</DetailSection>
<DetailSection title="Classification">
<KeyValueGrid
columns={3}
items={[
{ label: 'Product type', value: item.productType },
{ label: 'Therapeutic class', value: item.therapeuticClass },
{ label: 'Dosage form', value: item.dosageForm },
{ label: 'Route', value: item.routeOfAdministration || '—' },
{
label: 'Ingredients',
value: item.ingredients.map((i) => `${i.name} ${i.strength}`).join(' + '),
},
{
label: 'Controlled',
value: item.controlledSubstance
? `Yes · Schedule ${item.controlledSchedule}`
: 'No',
},
]}
/>
</DetailSection>
<DetailSection title="Packaging & pricing">
<KeyValueGrid
columns={3}
items={[
{ label: 'Pack size', value: item.packSize },
{ label: 'Base unit', value: item.baseUnit },
{ label: 'Sell-by / pack', value: `${item.sellByUnit} / ${item.packUnit}` },
{ label: 'Cost', value: formatEtb(item.costPriceEtb) },
{ label: 'Selling price', value: formatEtb(item.sellingPriceEtb) },
{ label: 'Price owner', value: item.sellingPriceOwner },
{ label: 'VAT', value: item.vatApplicable ? 'Applicable' : 'Exempt' },
{ label: 'Discount', value: item.discountEligible ? 'Eligible' : 'Locked' },
{ label: 'B2B list', value: priceList?.name ?? '—' },
{ label: 'Loyalty', value: item.loyaltyEligible ? 'Eligible' : 'Excluded' },
{ label: 'Reorder point', value: item.defaultReorderPoint },
{ label: 'Expiry alert', value: `${item.defaultExpiryAlertDays} days` },
]}
/>
</DetailSection>
<DetailSection title="Regulatory">
<KeyValueGrid
columns={3}
items={[
{ label: 'Prescription required', value: item.prescriptionRequired ? 'Yes' : 'No' },
{ label: 'EFDA status', value: item.efdaStatus.replaceAll('_', ' ') },
{ label: 'Advertising', value: item.advertisingRestricted ? 'Restricted' : 'Allowed' },
{ label: 'Age restriction', value: item.ageRestriction.replaceAll('_', ' ') },
{
label: 'Badges',
value: item.badges.length ? item.badges.join(', ') : 'None',
},
{
label: 'Merge',
value: item.mergeStatus
? `${item.mergeStatus}${item.mergeCandidateId ? ` → ${item.mergeCandidateId}` : ''}`
: '—',
},
]}
/>
</DetailSection>
<DetailSection title="Clinical content">
<KeyValueGrid
columns={1}
items={[
{ label: 'Description', value: item.shortDescription || '—' },
{ label: 'Dosage guidance', value: item.dosageGuidance || '—' },
{ label: 'Side effects', value: item.sideEffects || '—' },
{ label: 'Warnings', value: item.warnings || '—' },
{ label: 'Storage', value: item.storageInstructions || '—' },
]}
/>
</DetailSection>
<DetailSection
title="Stock on hand"
description={`${stock.length} batch line${stock.length === 1 ? '' : 's'} · ${onHand} units`}
>
{stock.length === 0 ? (
<div className="flex flex-col items-start gap-3 rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Boxes className="h-4 w-4" />
No stock batches linked yet.
</div>
<Button asChild size="sm" className="h-9 gap-1.5">
<Link to={`/stock/new?item=${item.id}`}>
<Plus className="h-3.5 w-3.5" />
Receive first batch
</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>SKU / Batch</TableHead>
<TableHead>Branch</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Expiry</TableHead>
<TableHead>Unit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stock.map((row) => (
<TableRow key={`${row.sku}-${row.batch}-${row.branchId}`}>
<TableCell>
<Link to={`/stock/${row.sku}`} className="font-medium text-brand-700 hover:underline">
{row.sku}
</Link>
<p className="text-xs text-muted-foreground">{row.batch}</p>
</TableCell>
<TableCell>{row.branchId}</TableCell>
<TableCell>{row.qty}</TableCell>
<TableCell>{row.expiry}</TableCell>
<TableCell>{formatEtb(row.unitEtb)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</DetailSection>
{variants.length > 0 ? (
<DetailSection title="Pack variants" description="Other pack sizes of the same molecule">
<div className="flex flex-wrap gap-2">
{variants.map((variant) => (
<Button key={variant.id} asChild variant="outline" size="sm">
<Link to={`/items/${variant.id}`}>
{variant.sku} · {variant.packSize}
</Link>
</Button>
))}
</div>
</DetailSection>
) : null}
</div>
</DetailPageLayout>
)
}

View File

@ -0,0 +1,248 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { AlertTriangle, Archive, Eye, Pill, Plus, ShieldAlert } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import {
catalogItems,
productTypes,
therapeuticClasses,
type MedicationItem,
type ProductType,
} from '@/mocks/data'
import { formatEtb } from '@/lib/utils'
const statusOptions = [
{ value: 'all', label: 'All statuses' },
{ value: 'active', label: 'Active' },
{ value: 'draft', label: 'Draft' },
{ value: 'archived', label: 'Archived' },
]
export function ItemsPage() {
const [q, setQ] = useState('')
const [productType, setProductType] = useState('all')
const [therapeutic, setTherapeutic] = useState('all')
const [status, setStatus] = useState('all')
const [rxOnly, setRxOnly] = useState('all')
const kpis = useMemo(() => {
const active = catalogItems.filter((i) => i.status === 'active')
return {
total: catalogItems.length,
active: active.length,
rx: catalogItems.filter((i) => i.prescriptionRequired).length,
controlled: catalogItems.filter((i) => i.controlledSubstance).length,
mergePending: catalogItems.filter((i) => i.mergeStatus === 'pending').length,
archived: catalogItems.filter((i) => i.status === 'archived').length,
}
}, [])
const filtered = useMemo(() => {
return catalogItems.filter((item) => {
if (productType !== 'all' && item.productType !== productType) return false
if (therapeutic !== 'all' && item.therapeuticClass !== therapeutic) return false
if (status !== 'all' && item.status !== status) return false
if (rxOnly === 'yes' && !item.prescriptionRequired) return false
if (rxOnly === 'controlled' && !item.controlledSubstance) return false
if (q) {
const needle = q.toLowerCase()
const hay = [
item.sku,
item.genericName,
item.brandName,
item.manufacturer,
item.therapeuticClass,
...item.barcodes.map((b) => b.value),
]
.join(' ')
.toLowerCase()
if (!hay.includes(needle)) return false
}
return true
})
}, [q, productType, therapeutic, status, rxOnly])
const filters: TableFilter[] = [
{
id: 'type',
label: 'Product type',
value: productType,
onChange: setProductType,
options: [
{ value: 'all', label: 'All product types' },
...productTypes.map((t) => ({ value: t, label: t })),
],
},
{
id: 'class',
label: 'Therapeutic class',
value: therapeutic,
onChange: setTherapeutic,
options: [
{ value: 'all', label: 'All classes' },
...therapeuticClasses.map((c) => ({ value: c, label: c })),
],
},
{
id: 'status',
label: 'Status',
value: status,
onChange: setStatus,
options: statusOptions,
},
{
id: 'rx',
label: 'Dispensing',
value: rxOnly,
onChange: setRxOnly,
options: [
{ value: 'all', label: 'All items' },
{ value: 'yes', label: 'Prescription only' },
{ value: 'controlled', label: 'Controlled only' },
],
},
]
return (
<PageShell
title="Medication items"
subtitle="Master catalogue identity — stock batches reference these items, they are not created per receipt."
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/items/new">
<Plus className="h-4 w-4" />
New item
</Link>
</Button>
}
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Catalogue items" value={kpis.total} hint={`${kpis.active} active`} icon={Pill} tone="brand" />
<StatCard
label="Prescription-only"
value={kpis.rx}
hint="Rx flag drives POS gates"
icon={ShieldAlert}
tone="forest"
/>
<StatCard
label="Controlled"
value={kpis.controlled}
hint="Schedule reporting required"
icon={AlertTriangle}
tone="orange"
/>
<StatCard
label="Needs attention"
value={kpis.mergePending + kpis.archived}
hint={`${kpis.mergePending} merge pending · ${kpis.archived} archived`}
icon={Archive}
tone="muted"
/>
</div>
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search SKU, INN, brand, barcode…"
resultCount={filtered.length}
filters={filters}
onClearFilters={() => {
setQ('')
setProductType('all')
setTherapeutic('all')
setStatus('all')
setRxOnly('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>SKU</TableHead>
<TableHead>Product</TableHead>
<TableHead className="hidden md:table-cell">Type</TableHead>
<TableHead className="hidden lg:table-cell">Class</TableHead>
<TableHead className="hidden xl:table-cell">Price</TableHead>
<TableHead>Flags</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
No items match these filters
</TableCell>
</TableRow>
) : (
filtered.map((item) => <ItemRow key={item.id} item={item} />)
)}
</TableBody>
</Table>
</CardContent>
</Card>
</PageShell>
)
}
function ItemRow({ item }: { item: MedicationItem }) {
const strength = item.ingredients.map((i) => i.strength).filter(Boolean).join(' + ')
return (
<TableRow>
<TableCell className="font-mono text-xs font-medium">
<Link to={`/items/${item.id}`} className="text-brand-700 hover:underline">
{item.sku}
</Link>
</TableCell>
<TableCell>
<div className="min-w-0">
<p className="font-medium text-forest">
{item.brandName || item.genericName}
{strength ? <span className="font-normal text-muted-foreground"> · {strength}</span> : null}
</p>
<p className="truncate text-xs text-muted-foreground">
{item.genericName}
{item.brandName && item.brandName !== item.genericName ? ` · ${item.dosageForm}` : ` · ${item.dosageForm}`}
</p>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<TypeBadge type={item.productType} />
</TableCell>
<TableCell className="hidden lg:table-cell text-sm">{item.therapeuticClass}</TableCell>
<TableCell className="hidden xl:table-cell tabular-nums">{formatEtb(item.sellingPriceEtb)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{item.prescriptionRequired ? <Badge variant="warning">Rx</Badge> : null}
{item.controlledSubstance ? <Badge variant="danger">Ctrl</Badge> : null}
{item.mergeStatus === 'pending' ? <Badge variant="outline">Merge</Badge> : null}
{item.status !== 'active' ? <Badge variant="muted">{item.status}</Badge> : null}
</div>
</TableCell>
<TableCell>
<RowActions
primary={[
{ label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/items/${item.id}` },
{ label: 'Add stock', icon: <Plus className="h-3.5 w-3.5" />, to: `/stock/new?item=${item.id}` },
]}
/>
</TableCell>
</TableRow>
)
}
function TypeBadge({ type }: { type: ProductType }) {
const variant =
type === 'Controlled' ? 'danger' : type === 'Rx' ? 'warning' : type === 'OTC' ? 'success' : 'secondary'
return <Badge variant={variant}>{type}</Badge>
}

View File

@ -0,0 +1,380 @@
import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import {
Area,
AreaChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Copy, Download, MousePointerClick, PauseCircle, Send, Sparkles, Users } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { ChartCard } from '@/components/charts/ChartCard'
import { StatCard } from '@/components/dashboard/StatCard'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { campaignRecipients, campaigns } from '@/mocks/data'
import { campaignChannelBreakdown, campaignDailySends } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
import type { Campaign, CampaignRecipient } from '@/mocks/data'
import type { TimelineEvent } from '@/components/detail/ActivityTimeline'
const statusTone: Record<Campaign['status'], 'success' | 'default' | 'warning' | 'muted'> = {
sent: 'success',
sending: 'default',
scheduled: 'warning',
draft: 'muted',
}
const recipientTone: Record<CampaignRecipient['status'], 'success' | 'default' | 'warning' | 'danger'> = {
redeemed: 'success',
clicked: 'default',
opened: 'default',
delivered: 'warning',
bounced: 'danger',
}
export function CampaignDetailPage() {
const { id } = useParams()
const campaign = campaigns.find((c) => c.id === id)
const recipients = useMemo(() => (id ? (campaignRecipients[id] ?? []) : []), [id])
const breakdown = useMemo(() => {
if (!campaign) return []
const preset = id ? campaignChannelBreakdown[id] : undefined
if (preset) return preset
return [
{ name: 'Delivered', value: campaign.delivered, fill: '#A8C73A' },
{ name: 'Bounced', value: campaign.bounced, fill: '#E28A1A' },
].filter((slice) => slice.value > 0)
}, [campaign, id])
const daily = useMemo(() => (id ? (campaignDailySends[id] ?? []) : []), [id])
const funnel = useMemo(() => {
if (!campaign) return null
const pct = (value: number, base: number) => (base > 0 ? Math.round((value / base) * 100) : 0)
return {
deliveryRate: pct(campaign.delivered, campaign.recipients),
openRate: pct(campaign.opened, campaign.delivered),
clickRate: pct(campaign.clicked, campaign.delivered),
redeemRate: pct(campaign.redeemed, campaign.delivered),
}
}, [campaign])
const timeline = useMemo<TimelineEvent[]>(() => {
if (!campaign) return []
const events: TimelineEvent[] = [
{
id: 'created',
at: campaign.createdAt,
title: 'Campaign created',
detail: `${campaign.createdBy} drafted this ${campaign.channel} campaign`,
},
]
if (campaign.scheduledFor) {
events.push({
id: 'scheduled',
at: campaign.scheduledFor,
title: 'Scheduled to send',
detail: `${campaign.recipients.toLocaleString()} recipients queued`,
tone: 'warning',
})
}
if (campaign.sentAt) {
events.push({
id: 'sent',
at: campaign.sentAt,
title: campaign.status === 'sending' ? 'Sending started' : 'Campaign sent',
detail: `${campaign.delivered.toLocaleString()} delivered · ${campaign.bounced.toLocaleString()} bounced`,
tone: campaign.bounced > 0 ? 'warning' : 'success',
})
if (campaign.opened > 0) {
events.push({
id: 'opened',
at: new Date(new Date(campaign.sentAt).getTime() + 45 * 60 * 1000).toISOString(),
title: 'First opens recorded',
detail: `${campaign.opened.toLocaleString()} opens · ${campaign.clicked.toLocaleString()} clicks`,
})
}
if (campaign.redeemed > 0) {
events.push({
id: 'redeemed',
at: new Date(new Date(campaign.sentAt).getTime() + 6 * 60 * 60 * 1000).toISOString(),
title: 'Redemptions attributed',
detail: `${campaign.redeemed.toLocaleString()} orders traced back to this campaign`,
tone: 'success',
})
}
}
return events.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
}, [campaign])
if (!campaign || !funnel) {
return (
<DetailPageLayout backTo="/campaigns" backLabel="Back to campaigns" title="Campaign not found">
<Button asChild>
<Link to="/campaigns">Back to campaigns</Link>
</Button>
</DetailPageLayout>
)
}
const canSend = campaign.status === 'draft' || campaign.status === 'scheduled'
return (
<DetailPageLayout
backTo="/campaigns"
backLabel="Back to campaigns"
title={campaign.name}
subtitle={`${campaign.channel} · ${campaign.segmentName} · created by ${campaign.createdBy}`}
badges={<Badge variant={statusTone[campaign.status]}>{campaign.status}</Badge>}
actions={
<>
{canSend ? (
<Button
size="sm"
className="h-9 gap-1.5"
onClick={() => toast.success(`“${campaign.name}” queued to send now`)}
>
<Send className="h-3.5 w-3.5" />
Send now
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
disabled={campaign.status === 'sent'}
onClick={() => toast.success(`“${campaign.name}” paused`)}
>
<PauseCircle className="h-3.5 w-3.5" />
Pause
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Duplicated “${campaign.name}” as a draft`)}
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Exporting report for “${campaign.name}”`)}
>
<Download className="h-3.5 w-3.5" />
Export report
</Button>
</>
}
sidebar={
<DetailSection title="Lifecycle" description="Derived campaign activity">
<ActivityTimeline events={timeline} empty="Nothing has happened yet" />
</DetailSection>
}
>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<StatCard
label="Recipients"
value={campaign.recipients.toLocaleString()}
hint="Segment size at send"
icon={Users}
tone="brand"
/>
<StatCard
label="Delivered"
value={campaign.delivered.toLocaleString()}
hint={`${funnel.deliveryRate}% of recipients`}
icon={Send}
tone="forest"
/>
<StatCard
label="Opened"
value={campaign.opened.toLocaleString()}
hint={`${funnel.openRate}% of delivered`}
icon={Sparkles}
tone="orange"
/>
<StatCard
label="Clicked"
value={campaign.clicked.toLocaleString()}
hint={`${funnel.clickRate}% of delivered`}
icon={MousePointerClick}
tone="muted"
/>
<StatCard
label="Redeemed"
value={campaign.redeemed.toLocaleString()}
hint={`${funnel.redeemRate}% of delivered`}
icon={Sparkles}
tone="brand"
/>
</div>
<DetailSection title="Campaign configuration" description="What was sent, to whom, and when">
<KeyValueGrid
columns={3}
items={[
{ label: 'Channel', value: <Badge variant="outline">{campaign.channel}</Badge> },
{
label: 'Segment',
value: (
<Link to={`/marketing/${campaign.segmentId}`} className="text-brand-700 hover:underline">
{campaign.segmentName}
</Link>
),
},
{ label: 'Status', value: <Badge variant={statusTone[campaign.status]}>{campaign.status}</Badge> },
{ label: 'Recipients', value: campaign.recipients.toLocaleString() },
{ label: 'Budget', value: formatEtb(campaign.budgetEtb) },
{ label: 'Created by', value: campaign.createdBy },
{ label: 'Created at', value: new Date(campaign.createdAt).toLocaleString() },
{
label: campaign.sentAt ? 'Sent at' : 'Scheduled for',
value: campaign.sentAt
? new Date(campaign.sentAt).toLocaleString()
: campaign.scheduledFor
? new Date(campaign.scheduledFor).toLocaleString()
: '—',
},
{ label: 'Bounced', value: campaign.bounced.toLocaleString() },
]}
/>
<div className="mt-5 rounded-xl border bg-muted/30 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Message</p>
<p className="mt-1 text-sm text-foreground">{campaign.message}</p>
</div>
</DetailSection>
<div className={daily.length > 0 ? 'grid gap-4 xl:grid-cols-2' : 'grid gap-4'}>
<ChartCard title="Delivery breakdown" description="Delivered versus bounced">
{breakdown.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No delivery data yet
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={breakdown}
dataKey="value"
nameKey="name"
innerRadius={45}
outerRadius={75}
paddingAngle={3}
>
{breakdown.map((slice) => (
<Cell key={slice.name} fill={slice.fill} />
))}
</Pie>
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Legend verticalAlign="bottom" height={24} iconType="circle" />
</PieChart>
</ResponsiveContainer>
)}
</ChartCard>
{daily.length > 0 ? (
<ChartCard title="Daily sends" description="Delivered, opened and clicked per day">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={daily} margin={{ left: 4, right: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Legend verticalAlign="top" height={24} iconType="circle" />
<Area
type="monotone"
dataKey="delivered"
name="Delivered"
stroke="#A8C73A"
fill="#A8C73A"
fillOpacity={0.2}
/>
<Area
type="monotone"
dataKey="opened"
name="Opened"
stroke="#E28A1A"
fill="#E28A1A"
fillOpacity={0.2}
/>
<Area
type="monotone"
dataKey="clicked"
name="Clicked"
stroke="#141414"
fill="#141414"
fillOpacity={0.1}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
) : null}
</div>
<DetailSection
title="Recipients"
description={`${recipients.length} sampled from ${campaign.recipients.toLocaleString()} sends`}
>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Phone</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden md:table-cell">Sent at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipients.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No recipient sample available for this campaign
</TableCell>
</TableRow>
) : (
recipients.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
{r.customerId ? (
<Link to={`/crm/${r.customerId}`} className="text-brand-700 hover:underline">
{r.name}
</Link>
) : (
r.name
)}
</TableCell>
<TableCell className="hidden whitespace-nowrap sm:table-cell">{r.phone}</TableCell>
<TableCell>
<Badge variant={recipientTone[r.status]}>{r.status}</Badge>
</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">
{new Date(r.sentAt).toLocaleString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -1,22 +1,321 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Copy, Download, Eye, MailOpen, PauseCircle, Send, Users } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { SelectNative } from '@/components/ui/select-native'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { campaigns, marketingSegments } from '@/mocks/data'
import { campaignPerformance } from '@/mocks/charts'
import type { Campaign, MarketingChannel } from '@/mocks/data'
const statusTone: Record<Campaign['status'], 'success' | 'default' | 'warning' | 'muted'> = {
sent: 'success',
sending: 'default',
scheduled: 'warning',
draft: 'muted',
}
const channelFill: Record<MarketingChannel, string> = {
SMS: '#A8C73A',
Telegram: '#E28A1A',
Push: '#141414',
Email: '#6B8E23',
}
const channelOptions = [
{ value: 'all', label: 'All channels' },
{ value: 'SMS', label: 'SMS' },
{ value: 'Telegram', label: 'Telegram' },
{ value: 'Push', label: 'Push' },
{ value: 'Email', label: 'Email' },
]
const campaignStatusOptions = [
{ value: 'all', label: 'All statuses' },
{ value: 'draft', label: 'Draft' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'sending', label: 'Sending' },
{ value: 'sent', label: 'Sent' },
]
export function CampaignsPage() {
const { t } = useTranslation()
const [channel, setChannel] = useState('sms')
const [segment, setSegment] = useState('chronic')
const [q, setQ] = useState('')
const [channelFilter, setChannelFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [channel, setChannel] = useState<MarketingChannel>('SMS')
const [segment, setSegment] = useState(marketingSegments[0]?.id ?? '')
const [body, setBody] = useState('Your refill is due soon. Tap to reorder from Gishen.')
const [scheduleAt, setScheduleAt] = useState('')
const kpis = useMemo(() => {
const sent = campaigns.filter((c) => c.status === 'sent')
const scheduled = campaigns.filter((c) => c.status === 'scheduled').length
const recipients = campaigns.reduce((sum, c) => sum + c.recipients, 0)
const delivered = sent.reduce((sum, c) => sum + c.delivered, 0)
const opened = sent.reduce((sum, c) => sum + c.opened, 0)
const openRate = delivered > 0 ? Math.round((opened / delivered) * 100) : 0
return { sent: sent.length, scheduled, recipients, openRate }
}, [])
const filtered = useMemo(() => {
let list = campaigns
if (channelFilter !== 'all') list = list.filter((c) => c.channel === channelFilter)
if (statusFilter !== 'all') list = list.filter((c) => c.status === statusFilter)
if (q) {
const needle = q.toLowerCase()
list = list.filter(
(c) => c.name.toLowerCase().includes(needle) || c.segmentName.toLowerCase().includes(needle),
)
}
return list
}, [q, channelFilter, statusFilter])
const redemptionByChannel = useMemo(() => {
const buckets = new Map<MarketingChannel, { delivered: number; redeemed: number }>()
campaigns
.filter((c) => c.delivered > 0)
.forEach((c) => {
const current = buckets.get(c.channel) ?? { delivered: 0, redeemed: 0 }
buckets.set(c.channel, {
delivered: current.delivered + c.delivered,
redeemed: current.redeemed + c.redeemed,
})
})
return Array.from(buckets.entries()).map(([name, v]) => ({
name,
rate: Math.round((v.redeemed / v.delivered) * 1000) / 10,
fill: channelFill[name],
}))
}, [])
const selectedSegment = useMemo(
() => marketingSegments.find((s) => s.id === segment),
[segment],
)
const filtersDirty = channelFilter !== 'all' || statusFilter !== 'all' || !!q
const campaignFilters: TableFilter[] = [
{
id: 'channel',
label: t('common.channel'),
value: channelFilter,
onChange: setChannelFilter,
options: channelOptions,
},
{
id: 'status',
label: t('common.status'),
value: statusFilter,
onChange: setStatusFilter,
options: campaignStatusOptions,
},
]
const segmentLabel = selectedSegment?.name ?? 'no segment'
return (
<PageShell title={t('campaigns.title')} subtitle={t('campaigns.subtitle')}>
<PageShell
title={t('campaigns.title')}
subtitle={t('campaigns.subtitle')}
actions={
<Button asChild variant="outline" className="h-10 gap-2 shadow-none">
<Link to="/marketing">
<Users className="h-4 w-4" />
Manage segments
</Link>
</Button>
}
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Campaigns sent" value={kpis.sent} hint="Lifetime" icon={Send} tone="brand" />
<StatCard label="Scheduled" value={kpis.scheduled} hint="Queued to go out" icon={PauseCircle} tone="orange" />
<StatCard
label="Total recipients"
value={kpis.recipients.toLocaleString()}
hint="Across all campaigns"
icon={Users}
tone="forest"
/>
<StatCard
label="Avg. open rate"
value={`${kpis.openRate}%`}
hint="Opened / delivered on sent"
icon={MailOpen}
tone="muted"
/>
</div>
<Tabs defaultValue="campaigns">
<TabsList>
<TabsTrigger value="campaigns">Campaigns</TabsTrigger>
<TabsTrigger value="performance">Performance</TabsTrigger>
<TabsTrigger value="compose">Compose</TabsTrigger>
</TabsList>
<TabsContent value="campaigns">
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search campaign or segment…"
resultCount={filtered.length}
filters={campaignFilters}
clearVisible={filtersDirty}
onClearFilters={() => {
setQ('')
setChannelFilter('all')
setStatusFilter('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Channel</TableHead>
<TableHead className="hidden lg:table-cell">Target segment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Recipients</TableHead>
<TableHead className="hidden md:table-cell">Sent</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filtered.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">
<Link to={`/campaigns/${c.id}`} className="text-brand-700 hover:underline">
{c.name}
</Link>
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="outline">{c.channel}</Badge>
</TableCell>
<TableCell className="hidden lg:table-cell">
<Link to={`/marketing/${c.segmentId}`} className="text-brand-700 hover:underline">
{c.segmentName}
</Link>
</TableCell>
<TableCell>
<Badge variant={statusTone[c.status]}>{c.status}</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">{c.recipients.toLocaleString()}</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">
{c.sentAt
? new Date(c.sentAt).toLocaleDateString()
: c.scheduledFor
? new Date(c.scheduledFor).toLocaleDateString()
: '—'}
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'View',
icon: <Eye className="h-3.5 w-3.5" />,
to: `/campaigns/${c.id}`,
},
]}
items={[
{
label: 'Duplicate',
icon: <Copy className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Duplicated “${c.name}” as a draft`),
},
{
label: 'Pause',
icon: <PauseCircle className="h-3.5 w-3.5" />,
disabled: c.status === 'sent' || c.status === 'draft',
onClick: () => toast.success(`Paused “${c.name}”`),
},
{
label: 'Export report',
icon: <Download className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Exporting report for “${c.name}”`),
},
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="performance">
<div className="grid gap-4 xl:grid-cols-3">
<ChartCard
title="Delivery funnel by campaign"
description="Delivered, opened and clicked"
className="xl:col-span-2"
height="h-80"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={campaignPerformance} margin={{ left: 4, right: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval={0} height={64} angle={-20} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Legend verticalAlign="top" height={28} iconType="circle" />
<Bar dataKey="delivered" name="Delivered" fill="#A8C73A" radius={[6, 6, 0, 0]} />
<Bar dataKey="opened" name="Opened" fill="#E28A1A" radius={[6, 6, 0, 0]} />
<Bar dataKey="clicked" name="Clicked" fill="#141414" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Redemption rate by channel" description="Redeemed / delivered" height="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={redemptionByChannel} margin={{ left: 4, right: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 12 }} unit="%" />
<Tooltip formatter={(v) => `${v}%`} />
<Bar dataKey="rate" name="Redemption" radius={[6, 6, 0, 0]}>
{redemptionByChannel.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
</TabsContent>
<TabsContent value="compose">
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Compose campaign</CardTitle>
@ -25,32 +324,79 @@ export function CampaignsPage() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label>Channel</Label>
<SelectNative value={channel} onChange={(e) => setChannel(e.target.value)}>
<option value="sms">SMS</option>
<option value="telegram">Telegram</option>
<option value="push">Push</option>
<SelectNative
value={channel}
onChange={(e) => setChannel(e.target.value as MarketingChannel)}
>
<option value="SMS">SMS</option>
<option value="Telegram">Telegram</option>
<option value="Push">Push</option>
<option value="Email">Email</option>
</SelectNative>
</div>
<div className="space-y-1">
<Label>Segment</Label>
<SelectNative value={segment} onChange={(e) => setSegment(e.target.value)}>
<option value="chronic">Chronic refill</option>
<option value="lapsed">Lapsed buyers</option>
<option value="winback">Win-back</option>
{marketingSegments.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</SelectNative>
</div>
</div>
<div className="space-y-1">
<Label>Message</Label>
<Textarea value={body} onChange={(e) => setBody(e.target.value)} />
<p className="text-xs text-muted-foreground">{body.length} characters</p>
</div>
<div className="space-y-1">
<Label>Schedule (optional)</Label>
<Input type="datetime-local" />
<Input
type="datetime-local"
value={scheduleAt}
onChange={(e) => setScheduleAt(e.target.value)}
/>
</div>
<div className="rounded-xl border border-brand-200 bg-brand-50 p-3 text-sm">
{selectedSegment ? (
<>
Reaching{' '}
<strong>{selectedSegment.size.toLocaleString()} recipients</strong> in{' '}
<Link to={`/marketing/${selectedSegment.id}`} className="text-brand-700 hover:underline">
{selectedSegment.name}
</Link>{' '}
via {channel} · {selectedSegment.engagementRate}% typical engagement
</>
) : (
'Select a segment to preview reach.'
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
className="h-10"
onClick={() =>
toast.success(
scheduleAt
? `Campaign scheduled on ${channel} → ${segmentLabel} for ${new Date(scheduleAt).toLocaleString()}`
: `Campaign queued on ${channel} → ${segmentLabel}`,
)
}
>
Send / schedule
</Button>
<Button
variant="outline"
className="h-10"
onClick={() => toast.success(`Draft saved for ${channel} → ${segmentLabel}`)}
>
Save as draft
</Button>
</div>
<Button onClick={() => toast.success(`Campaign queued on ${channel} → ${segment}`)}>Send / schedule</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</PageShell>
)
}

View File

@ -1,22 +1,67 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Eye, Gift, UserPlus, Users, Wallet } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { customers } from '@/mocks/data'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { PersonAvatar } from '@/components/ui/avatar'
import { useAuthStore } from '@/zustand/authStore'
import { canAccess } from '@/config/permissions'
import { branches, customers, loyaltyLedger } from '@/mocks/data'
import { formatEtb } from '@/lib/utils'
const tierOptions = [
{ value: 'all', label: 'All tiers' },
{ value: 'Bronze', label: 'Bronze' },
{ value: 'Silver', label: 'Silver' },
{ value: 'Gold', label: 'Gold' },
]
export function CrmPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const [q, setQ] = useState('')
const [tier, setTier] = useState('all')
const [branch, setBranch] = useState('all')
/**
* Customer records carry the branch zone rather than the branch id, so a branch-scoped
* role is resolved to its zone once and every KPI plus the table reads from `scoped`.
*/
const scopedZone = user?.branchId ? branches.find((b) => b.id === user.branchId)?.zone : undefined
const canPickBranch = !scopedZone
const scoped = useMemo(
() => (scopedZone ? customers.filter((c) => c.branch === scopedZone) : customers),
[scopedZone],
)
const kpis = useMemo(() => {
const month = new Date().toISOString().slice(0, 7)
const spend = scoped.reduce((sum, c) => sum + c.spendEtb, 0)
const loyaltyMemberIds = new Set(loyaltyLedger.map((entry) => entry.customerId))
return {
total: scoped.length,
gold: scoped.filter((c) => c.tier === 'Gold').length,
newThisMonth: scoped.filter((c) => c.joinedAt.startsWith(month)).length,
averageLifetimeValue: scoped.length ? spend / scoped.length : 0,
orders: scoped.reduce((sum, c) => sum + c.orders, 0),
loyaltyMembers: scoped.filter((c) => loyaltyMemberIds.has(c.id)).length,
points: scoped.reduce((sum, c) => sum + c.points, 0),
}
}, [scoped])
const filtered = useMemo(() => {
return customers.filter((c) => {
return scoped.filter((c) => {
if (tier !== 'all' && c.tier !== tier) return false
if (branch !== 'all' && c.branch !== branch) return false
if (canPickBranch && branch !== 'all' && c.branch !== branch) return false
if (q) {
const needle = q.toLowerCase()
if (
@ -28,50 +73,73 @@ export function CrmPage() {
}
return true
})
}, [q, tier, branch])
}, [scoped, canPickBranch, q, tier, branch])
const customerFilters: TableFilter[] = [
{ id: 'tier', label: t('common.tier'), value: tier, onChange: setTier, options: tierOptions },
...(canPickBranch
? [
{
id: 'branch',
label: t('common.branch'),
value: branch,
onChange: setBranch,
options: [
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.zone, label: b.zone })),
],
},
]
: []),
]
return (
<PageShell title={t('crm.title')} subtitle={t('crm.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Total customers"
value={kpis.total}
hint={`${kpis.gold} on the Gold tier`}
icon={Users}
tone="brand"
/>
<StatCard
label="New this month"
value={kpis.newThisMonth}
hint="First registered in the current month"
icon={UserPlus}
tone="forest"
/>
<StatCard
label="Average lifetime value"
value={formatEtb(kpis.averageLifetimeValue)}
hint={`${kpis.orders} orders on record`}
icon={Wallet}
tone="muted"
/>
<StatCard
label="Active loyalty members"
value={kpis.loyaltyMembers}
hint={`${kpis.points.toLocaleString()} points held`}
icon={Gift}
tone="orange"
to={user && canAccess(user.role, 'loyalty') ? '/loyalty' : undefined}
/>
</div>
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search name or phone…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Tier"
value={tier}
onChange={setTier}
options={[
{ value: 'all', label: 'All tiers' },
{ value: 'Bronze', label: 'Bronze' },
{ value: 'Silver', label: 'Silver' },
{ value: 'Gold', label: 'Gold' },
]}
/>
<FilterSelect
label="Branch"
value={branch}
onChange={setBranch}
options={[
{ value: 'all', label: 'All catchments' },
{ value: 'Bole', label: 'Bole' },
{ value: 'CMC', label: 'CMC' },
]}
/>
<ClearFiltersButton
visible={tier !== 'all' || branch !== 'all' || !!q}
onClick={() => {
filters={customerFilters}
onClearFilters={() => {
setQ('')
setTier('all')
setBranch('all')
}}
/>
</>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
@ -81,25 +149,38 @@ export function CrmPage() {
<TableHead>Tier</TableHead>
<TableHead>Points</TableHead>
<TableHead>Branch</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filtered.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="font-medium">
<Link to={`/crm/${c.id}`} className="flex items-center gap-2.5 text-brand-700 hover:underline">
<PersonAvatar name={c.name} avatarUrl={c.avatarUrl} size="sm" />
<span>{c.name}</span>
</Link>
</TableCell>
<TableCell>{c.phone}</TableCell>
<TableCell>
<Badge>{c.tier}</Badge>
</TableCell>
<TableCell>{c.points}</TableCell>
<TableCell>{c.branch}</TableCell>
<TableCell>
<RowActions
primary={[
{ label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/crm/${c.id}` },
]}
/>
</TableCell>
</TableRow>
))
)}

View File

@ -0,0 +1,118 @@
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Gift, MessageSquare, Star } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { ChartCard } from '@/components/charts/ChartCard'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { PersonAvatar } from '@/components/ui/avatar'
import { customerOrders, customers, orders } from '@/mocks/data'
import { customerPointsSeries } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
export function CustomerDetailPage() {
const { id } = useParams()
const customer = customers.find((c) => c.id === id)
if (!customer) {
return (
<DetailPageLayout backTo="/crm" title="Customer not found">
<Button asChild>
<Link to="/crm">Back to CRM</Link>
</Button>
</DetailPageLayout>
)
}
const linkedOrderIds = customerOrders[customer.id] ?? []
const linkedOrders = orders.filter((o) => linkedOrderIds.includes(o.id))
return (
<DetailPageLayout
backTo="/crm"
backLabel="Back to CRM"
title={customer.name}
subtitle={customer.phone}
badges={
<>
<PersonAvatar name={customer.name} avatarUrl={customer.avatarUrl} size="md" />
<Badge variant="secondary">{customer.tier}</Badge>
<Badge variant="outline">{customer.branch}</Badge>
</>
}
actions={
<>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => toast.success('Loyalty bonus +100 queued')}>
<Gift className="h-3.5 w-3.5" />
Award points
</Button>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => toast.message('Campaign enrollment saved')}>
<Star className="h-3.5 w-3.5" />
Add to segment
</Button>
<Button size="sm" className="h-9 gap-1.5" onClick={() => toast.message('Message draft saved')}>
<MessageSquare className="h-3.5 w-3.5" />
Message
</Button>
</>
}
sidebar={
<DetailSection title="Touches">
<ActivityTimeline
events={[
{ id: '1', at: '2026-08-06T08:30:00Z', title: 'Placed order', detail: linkedOrderIds[0] ?? '—', tone: 'success' },
{ id: '2', at: '2026-08-01T12:00:00Z', title: 'Redeemed points', detail: '−200 · OTC basket', tone: 'default' },
{ id: '3', at: '2026-07-20T09:00:00Z', title: 'Joined Gold tier', tone: 'warning' },
]}
/>
</DetailSection>
}
>
<div className="space-y-5">
<DetailSection title="Profile">
<KeyValueGrid
columns={3}
items={[
{ label: 'Tier', value: customer.tier },
{ label: 'Points', value: customer.points.toLocaleString() },
{ label: 'Catchment', value: customer.branch },
{ label: 'Phone', value: customer.phone },
{ label: 'Lifetime orders', value: String(linkedOrders.length || 3) },
{ label: 'Preferred channel', value: 'Mobile' },
]}
/>
</DetailSection>
<ChartCard title="Points balance trend" description="Demo loyalty accrual">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={customerPointsSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Area type="monotone" dataKey="points" stroke="#E28A1A" fill="#E28A1A33" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
<DetailSection title="Recent orders">
{linkedOrders.length === 0 ? (
<p className="text-sm text-muted-foreground">No linked orders in demo set.</p>
) : (
<ul className="space-y-2">
{linkedOrders.map((o) => (
<li key={o.id} className="flex items-center justify-between rounded-xl border px-3 py-2 text-sm">
<Link to={`/orders/${o.id}`} className="font-medium text-brand-700 hover:underline">
{o.id}
</Link>
<span className="text-muted-foreground">{formatEtb(o.totalEtb)}</span>
</li>
))}
</ul>
)}
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -1,20 +1,572 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import {
CalendarPlus,
Coins,
Download,
Gift,
Pause,
Pencil,
Play,
Sparkles,
Users,
Wallet,
} from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { customers, loyaltyActivities, loyaltyActivityLabels, loyaltyLedger } from '@/mocks/data'
import { loyaltyPointsSeries } from '@/mocks/charts'
import { cn, formatEtb } from '@/lib/utils'
/** Demo assumption: one loyalty point redeems for 0.25 ETB of pharmacy credit. */
const ETB_PER_POINT = 0.25
/** Points carried over from the legacy loyalty engine before this ledger window opened. */
const OPENING_POINTS_BALANCE = 727557
const activityFilterOptions = [
{ value: 'all', label: 'All activities' },
...Object.entries(loyaltyActivityLabels).map(([value, label]) => ({ value, label })),
]
const tierOptions = [
{ value: 'all', label: 'All tiers' },
{ value: 'Gold', label: 'Gold' },
{ value: 'Silver', label: 'Silver' },
{ value: 'Bronze', label: 'Bronze' },
]
const directionOptions = [
{ value: 'all', label: 'Earned & redeemed' },
{ value: 'earned', label: 'Earned only' },
{ value: 'redeemed', label: 'Redeemed only' },
]
const ledgerBranches = Array.from(new Set(loyaltyLedger.map((e) => e.branch)))
function tierVariant(tier: string) {
if (tier === 'Gold') return 'warning' as const
if (tier === 'Silver') return 'secondary' as const
return 'muted' as const
}
function SignedPoints({ points }: { points: number }) {
return (
<span
className={cn(
'font-semibold tabular-nums',
points < 0 ? 'text-orange-deep' : 'text-brand-700',
)}
>
{points > 0 ? `+${points.toLocaleString()}` : points.toLocaleString()}
</span>
)
}
export function LoyaltyPage() {
const { t } = useTranslation()
const [earn, setEarn] = useState('1')
const [referral, setReferral] = useState('50')
const [minRedeem, setMinRedeem] = useState('100')
const [ledgerQuery, setLedgerQuery] = useState('')
const [activityFilter, setActivityFilter] = useState('all')
const [directionFilter, setDirectionFilter] = useState('all')
const [branchFilter, setBranchFilter] = useState('all')
const [ledgerTier, setLedgerTier] = useState('all')
const [summaryQuery, setSummaryQuery] = useState('')
const [summaryTier, setSummaryTier] = useState('all')
const [rules, setRules] = useState(loyaltyActivities)
const tierByCustomer = useMemo(
() => new Map(customers.map((c) => [c.id, c.tier])),
[],
)
const reportingMonth = useMemo(
() => loyaltyLedger.reduce((latest, e) => (e.at > latest ? e.at : latest), '').slice(0, 7),
[],
)
const reportingMonthLabel = useMemo(
() =>
new Date(`${reportingMonth}-01T00:00:00Z`).toLocaleDateString(undefined, {
month: 'long',
year: 'numeric',
}),
[reportingMonth],
)
const totals = useMemo(() => {
let earned = 0
let redeemed = 0
let earnedThisMonth = 0
for (const entry of loyaltyLedger) {
if (entry.points >= 0) {
earned += entry.points
if (entry.at.startsWith(reportingMonth)) earnedThisMonth += entry.points
} else {
redeemed += Math.abs(entry.points)
}
}
const outstandingPoints = OPENING_POINTS_BALANCE + earned - redeemed
return {
earned,
redeemed,
earnedThisMonth,
outstandingPoints,
liabilityEtb: outstandingPoints * ETB_PER_POINT,
members: new Set(loyaltyLedger.map((e) => e.customerId)).size,
}
}, [reportingMonth])
const pointsByActivity = useMemo(() => {
const map = new Map<string, number>()
for (const entry of loyaltyLedger) {
if (entry.points <= 0) continue
map.set(entry.activity, (map.get(entry.activity) ?? 0) + entry.points)
}
return Array.from(map.entries())
.map(([activity, points]) => ({ activity, points }))
.sort((a, b) => b.points - a.points)
}, [])
const filteredLedger = useMemo(() => {
const needle = ledgerQuery.trim().toLowerCase()
return loyaltyLedger
.filter((entry) => {
if (activityFilter !== 'all' && entry.activity !== activityFilter) return false
if (directionFilter === 'earned' && entry.points < 0) return false
if (directionFilter === 'redeemed' && entry.points >= 0) return false
if (branchFilter !== 'all' && entry.branch !== branchFilter) return false
if (ledgerTier !== 'all' && tierByCustomer.get(entry.customerId) !== ledgerTier) return false
if (needle && !entry.customerName.toLowerCase().includes(needle)) return false
return true
})
.sort((a, b) => b.at.localeCompare(a.at))
}, [ledgerQuery, activityFilter, directionFilter, branchFilter, ledgerTier, tierByCustomer])
const customerSummary = useMemo(() => {
const rows = new Map<
string,
{ id: string; name: string; tier: string; earned: number; redeemed: number; lastAt: string }
>()
for (const entry of loyaltyLedger) {
const row =
rows.get(entry.customerId) ??
{
id: entry.customerId,
name: entry.customerName,
tier: tierByCustomer.get(entry.customerId) ?? 'Bronze',
earned: 0,
redeemed: 0,
lastAt: entry.at,
}
if (entry.points >= 0) row.earned += entry.points
else row.redeemed += Math.abs(entry.points)
if (entry.at > row.lastAt) row.lastAt = entry.at
rows.set(entry.customerId, row)
}
return Array.from(rows.values())
.map((row) => ({ ...row, balance: row.earned - row.redeemed }))
.sort((a, b) => b.balance - a.balance)
}, [tierByCustomer])
const filteredSummary = useMemo(() => {
const needle = summaryQuery.trim().toLowerCase()
return customerSummary.filter((row) => {
if (summaryTier !== 'all' && row.tier !== summaryTier) return false
if (needle && !row.name.toLowerCase().includes(needle)) return false
return true
})
}, [customerSummary, summaryQuery, summaryTier])
const branchOptions = useMemo(
() => [
{ value: 'all', label: t('common.allBranches') },
...ledgerBranches.map((b) => ({ value: b, label: b })),
],
[t],
)
const ledgerFilters: TableFilter[] = [
{
id: 'activity',
label: 'Activity',
value: activityFilter,
onChange: setActivityFilter,
options: activityFilterOptions,
},
{
id: 'direction',
label: 'Direction',
value: directionFilter,
onChange: setDirectionFilter,
options: directionOptions,
},
{
id: 'branch',
label: t('common.branch'),
value: branchFilter,
onChange: setBranchFilter,
options: branchOptions,
},
{
id: 'tier',
label: t('common.tier'),
value: ledgerTier,
onChange: setLedgerTier,
options: tierOptions,
},
]
const summaryFilters: TableFilter[] = [
{
id: 'tier',
label: t('common.tier'),
value: summaryTier,
onChange: setSummaryTier,
options: tierOptions,
},
]
const toggleRule = (id: string) => {
const rule = rules.find((r) => r.id === id)
if (!rule) return
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, active: !r.active } : r)))
toast.success(`${rule.name} ${rule.active ? 'paused' : 'activated'} (mock)`)
}
return (
<PageShell title={t('loyalty.title')} subtitle={t('loyalty.subtitle')}>
<PageShell
title={t('loyalty.title')}
subtitle={t('loyalty.subtitle')}
actions={
<>
<Button
variant="outline"
className="h-10 gap-2 shadow-none"
onClick={() => toast.success('Points ledger export queued (mock)')}
>
<Download className="h-4 w-4" />
{t('common.export')}
</Button>
<Button className="h-10 gap-2 shadow-none" onClick={() => toast.success('Manual points award drafted (mock)')}>
<Gift className="h-4 w-4" />
Award points
</Button>
</>
}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<StatCard
label="Points awarded (all time)"
value={totals.earned.toLocaleString()}
hint={`${loyaltyLedger.length} ledger entries`}
icon={Sparkles}
tone="brand"
/>
<StatCard
label={`Awarded in ${reportingMonthLabel}`}
value={totals.earnedThisMonth.toLocaleString()}
hint="Current reporting month"
icon={CalendarPlus}
tone="forest"
/>
<StatCard
label="Points redeemed"
value={totals.redeemed.toLocaleString()}
hint={`${Math.round((totals.redeemed / totals.earned) * 100)}% of points issued`}
icon={Coins}
tone="orange"
/>
<StatCard
label="Outstanding liability"
value={formatEtb(totals.liabilityEtb)}
hint={`${totals.outstandingPoints.toLocaleString()} pts × ${ETB_PER_POINT} ETB (demo rate)`}
icon={Wallet}
tone="muted"
/>
<StatCard
label="Active members"
value={totals.members}
hint="With ledger activity"
icon={Users}
tone="brand"
/>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<ChartCard
title="Points issued vs redeemed"
description="Programme-wide monthly movement"
className="xl:col-span-2"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={loyaltyPointsSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="earned" name="Earned" fill="#A8C73A" radius={[4, 4, 0, 0]} />
<Bar dataKey="redeemed" name="Redeemed" fill="#E28A1A" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Points by activity</CardTitle>
</CardHeader>
<CardContent className="space-y-3 pt-2">
{pointsByActivity.map((row) => (
<div key={row.activity} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate">{loyaltyActivityLabels[row.activity] ?? row.activity}</span>
<span className="ml-2 shrink-0 font-medium tabular-nums text-forest">
{row.points.toLocaleString()}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-brand-500"
style={{ width: `${(row.points / pointsByActivity[0].points) * 100}%` }}
/>
</div>
</div>
))}
</CardContent>
</Card>
</div>
<Tabs defaultValue="ledger">
<div className="overflow-x-auto pb-1">
<TabsList>
<TabsTrigger value="ledger">Points ledger</TabsTrigger>
<TabsTrigger value="members">Per customer</TabsTrigger>
<TabsTrigger value="activities">Earning activities</TabsTrigger>
<TabsTrigger value="settings">Programme settings</TabsTrigger>
</TabsList>
</div>
<TabsContent value="ledger">
<Card className="overflow-hidden">
<TableToolbar
search={ledgerQuery}
onSearchChange={setLedgerQuery}
searchPlaceholder="Search customer name…"
resultCount={filteredLedger.length}
filters={ledgerFilters}
onClearFilters={() => {
setLedgerQuery('')
setActivityFilter('all')
setDirectionFilter('all')
setBranchFilter('all')
setLedgerTier('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Customer</TableHead>
<TableHead>Activity</TableHead>
<TableHead className="text-right">Points</TableHead>
<TableHead>Order</TableHead>
<TableHead>{t('common.branch')}</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLedger.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filteredLedger.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-medium">
<Link to={`/crm/${entry.customerId}`} className="text-brand-700 hover:underline">
{entry.customerName}
</Link>
</TableCell>
<TableCell>{loyaltyActivityLabels[entry.activity] ?? entry.activity}</TableCell>
<TableCell className="text-right">
<SignedPoints points={entry.points} />
</TableCell>
<TableCell>
{entry.orderId ? (
<Link to={`/orders/${entry.orderId}`} className="text-brand-700 hover:underline">
{entry.orderId}
</Link>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{entry.branch}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{new Date(entry.at).toLocaleDateString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="members">
<Card className="overflow-hidden">
<TableToolbar
search={summaryQuery}
onSearchChange={setSummaryQuery}
searchPlaceholder="Search customer name…"
resultCount={filteredSummary.length}
filters={summaryFilters}
onClearFilters={() => {
setSummaryQuery('')
setSummaryTier('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Customer</TableHead>
<TableHead>Tier</TableHead>
<TableHead className="text-right">Earned</TableHead>
<TableHead className="text-right">Redeemed</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead>Last activity</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSummary.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filteredSummary.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">
<Link to={`/crm/${row.id}`} className="text-brand-700 hover:underline">
{row.name}
</Link>
</TableCell>
<TableCell>
<Badge variant={tierVariant(row.tier)}>{row.tier}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums text-brand-700">
+{row.earned.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums text-orange-deep">
−{row.redeemed.toLocaleString()}
</TableCell>
<TableCell className="text-right font-semibold tabular-nums text-forest">
{row.balance.toLocaleString()}
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{new Date(row.lastAt).toLocaleDateString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="activities">
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>Which activities award points</CardTitle>
<p className="text-sm text-muted-foreground">
Earning rules applied by the loyalty engine. Tier multipliers stack on top of the base award.
</p>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Activity</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Points</TableHead>
<TableHead>Basis</TableHead>
<TableHead>Tier multiplier</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="whitespace-nowrap font-medium text-forest">{rule.name}</TableCell>
<TableCell className="min-w-[16rem] text-muted-foreground">{rule.description}</TableCell>
<TableCell className="text-right font-semibold tabular-nums text-brand-700">
+{rule.points.toLocaleString()}
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{rule.unit}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{rule.tierMultiplier ?? '—'}
</TableCell>
<TableCell>
{rule.active ? (
<Badge variant="success">active</Badge>
) : (
<Badge variant="muted">paused</Badge>
)}
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: rule.active ? 'Pause' : 'Activate',
icon: rule.active ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
),
onClick: () => toggleRule(rule.id),
},
{
label: 'Edit',
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: () => toast.message(`Editing “${rule.name}” rule (mock)`),
},
]}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card className="max-w-xl">
<CardHeader>
<CardTitle>No-code loyalty config</CardTitle>
@ -33,9 +585,14 @@ export function LoyaltyPage() {
<Input value={minRedeem} onChange={(e) => setMinRedeem(e.target.value)} />
</div>
<p className="text-xs text-muted-foreground">Tiers: Bronze / Silver / Gold — thresholds editable in full config.</p>
<p className="text-xs text-muted-foreground">
Redemption value is a demo constant: 1 point = {ETB_PER_POINT} ETB, used for the liability figure above.
</p>
<Button onClick={() => toast.success('Loyalty rules saved (mock)')}>{t('common.save')}</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</PageShell>
)
}

View File

@ -1,33 +1,346 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Copy, Download, Eye, Layers, Megaphone, Percent, Send, Users } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { campaigns, marketingSegments } from '@/mocks/data'
import { channelMix, segmentEngagementSeries } from '@/mocks/charts'
import type { MarketingSegment } from '@/mocks/data'
const segments = [
{ name: 'Chronic refill', size: 1280 },
{ name: 'Lapsed buyers', size: 640 },
{ name: 'High-value households', size: 210 },
{ name: 'Institutional accounts', size: 18 },
{ name: 'Bole catchment', size: 940 },
const statusTone: Record<MarketingSegment['status'], 'success' | 'warning' | 'muted'> = {
active: 'success',
paused: 'warning',
draft: 'muted',
}
const channelOptions = [
{ value: 'all', label: 'All channels' },
{ value: 'SMS', label: 'SMS' },
{ value: 'Telegram', label: 'Telegram' },
{ value: 'Push', label: 'Push' },
{ value: 'Email', label: 'Email' },
]
const segmentStatusOptions = [
{ value: 'all', label: 'All statuses' },
{ value: 'active', label: 'Active' },
{ value: 'paused', label: 'Paused' },
{ value: 'draft', label: 'Draft' },
]
export function MarketingPage() {
const { t } = useTranslation()
const [q, setQ] = useState('')
const [channelFilter, setChannelFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const kpis = useMemo(() => {
const active = marketingSegments.filter((s) => s.status === 'active')
const reach = active.reduce((sum, s) => sum + s.size, 0)
const running = campaigns.filter((c) => c.status === 'sending' || c.status === 'scheduled').length
const avgEngagement = marketingSegments.length
? Math.round(
marketingSegments.reduce((sum, s) => sum + s.engagementRate, 0) / marketingSegments.length,
)
: 0
return { reach, activeCount: active.length, running, avgEngagement }
}, [])
const filtered = useMemo(() => {
let list = marketingSegments
if (channelFilter !== 'all') list = list.filter((s) => s.channel === channelFilter)
if (statusFilter !== 'all') list = list.filter((s) => s.status === statusFilter)
if (q) {
const needle = q.toLowerCase()
list = list.filter(
(s) => s.name.toLowerCase().includes(needle) || s.criteria.toLowerCase().includes(needle),
)
}
return list
}, [q, channelFilter, statusFilter])
const topSegments = useMemo(
() => [...segmentEngagementSeries].sort((a, b) => b.rate - a.rate).slice(0, 4),
[],
)
const filtersDirty = channelFilter !== 'all' || statusFilter !== 'all' || !!q
const segmentFilters: TableFilter[] = [
{
id: 'channel',
label: t('common.channel'),
value: channelFilter,
onChange: setChannelFilter,
options: channelOptions,
},
{
id: 'status',
label: t('common.status'),
value: statusFilter,
onChange: setStatusFilter,
options: segmentStatusOptions,
},
]
return (
<PageShell title={t('marketing.title')} subtitle={t('marketing.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{segments.map((s) => (
<Card key={s.name}>
<CardHeader>
<CardTitle className="text-base">{s.name}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between pt-0">
<span className="font-display text-2xl font-bold">{s.size.toLocaleString()}</span>
<Badge variant="secondary">self-updating</Badge>
<PageShell
title={t('marketing.title')}
subtitle={t('marketing.subtitle')}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/campaigns">
<Megaphone className="h-4 w-4" />
New campaign
</Link>
</Button>
}
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Reachable customers"
value={kpis.reach.toLocaleString()}
hint="Across active segments"
icon={Users}
tone="brand"
/>
<StatCard
label="Active segments"
value={kpis.activeCount}
hint={`${marketingSegments.length} defined`}
icon={Layers}
tone="orange"
/>
<StatCard
label="Campaigns running"
value={kpis.running}
hint="Sending or scheduled"
icon={Send}
tone="forest"
/>
<StatCard
label="Avg. engagement"
value={`${kpis.avgEngagement}%`}
hint="Opened or clicked"
icon={Percent}
tone="muted"
/>
</div>
<Tabs defaultValue="segments">
<TabsList>
<TabsTrigger value="segments">Segments</TabsTrigger>
<TabsTrigger value="performance">Performance</TabsTrigger>
</TabsList>
<TabsContent value="segments">
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search segment or criteria…"
resultCount={filtered.length}
filters={segmentFilters}
clearVisible={filtersDirty}
onClearFilters={() => {
setQ('')
setChannelFilter('all')
setStatusFilter('all')
}}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/campaigns">
<Send className="h-4 w-4" />
Launch campaign
</Link>
</Button>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead className="hidden lg:table-cell">Criteria</TableHead>
<TableHead>Members</TableHead>
<TableHead className="hidden sm:table-cell">Channel</TableHead>
<TableHead>Engagement</TableHead>
<TableHead className="hidden md:table-cell">Last used</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filtered.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
<Link to={`/marketing/${s.id}`} className="text-brand-700 hover:underline">
{s.name}
</Link>
</TableCell>
<TableCell className="hidden max-w-[22rem] truncate text-muted-foreground lg:table-cell">
{s.criteria}
</TableCell>
<TableCell className="whitespace-nowrap">{s.size.toLocaleString()}</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="outline">{s.channel}</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">{s.engagementRate}%</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">
{s.lastUsedAt ? new Date(s.lastUsedAt).toLocaleDateString() : '—'}
</TableCell>
<TableCell>
<Badge variant={statusTone[s.status]}>{s.status}</Badge>
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'View',
icon: <Eye className="h-3.5 w-3.5" />,
to: `/marketing/${s.id}`,
},
]}
items={[
{
label: 'Launch campaign',
icon: <Send className="h-3.5 w-3.5" />,
to: '/campaigns',
},
{
label: 'Duplicate segment',
icon: <Copy className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Duplicated “${s.name}” as a draft segment`),
},
{
label: 'Export members',
icon: <Download className="h-3.5 w-3.5" />,
onClick: () =>
toast.success(`Exporting ${s.size.toLocaleString()} members from “${s.name}”`),
},
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="performance">
<div className="space-y-4">
<div className="grid gap-4 xl:grid-cols-3">
<ChartCard
title="Reach vs engaged"
description="Last campaign per segment"
className="xl:col-span-2"
height="h-80"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={segmentEngagementSeries} layout="vertical" margin={{ left: 8, right: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis type="number" tick={{ fontSize: 12 }} />
<YAxis type="category" dataKey="segment" tick={{ fontSize: 11 }} width={110} />
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Legend verticalAlign="bottom" height={24} iconType="circle" />
<Bar dataKey="reach" name="Reach" fill="#A8C73A" radius={[0, 6, 6, 0]} />
<Bar dataKey="engaged" name="Engaged" fill="#E28A1A" radius={[0, 6, 6, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Reach by channel" description="Where campaigns land" height="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={channelMix}
dataKey="value"
nameKey="name"
innerRadius={50}
outerRadius={85}
paddingAngle={3}
>
{channelMix.map((slice) => (
<Cell key={slice.name} fill={slice.fill} />
))}
</Pie>
<Tooltip formatter={(v) => `${v}%`} />
<Legend verticalAlign="bottom" height={24} iconType="circle" />
</PieChart>
</ResponsiveContainer>
</ChartCard>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<ChartCard
title="Engagement rate by segment"
description="Percent of reach that opened or clicked"
className="xl:col-span-2"
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={segmentEngagementSeries} margin={{ left: 4, right: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="segment" tick={{ fontSize: 10 }} interval={0} height={56} angle={-20} />
<YAxis tick={{ fontSize: 12 }} unit="%" />
<Tooltip formatter={(v) => `${v}%`} />
<Line type="monotone" dataKey="rate" name="Engagement" stroke="#A8C73A" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</ChartCard>
<Card>
<CardContent className="space-y-3 p-5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Best performing segments
</p>
{topSegments.map((s) => (
<div key={s.segment} className="flex items-center justify-between gap-3 rounded-xl border p-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{s.segment}</p>
<p className="text-xs text-muted-foreground">
{s.engaged.toLocaleString()} of {s.reach.toLocaleString()} engaged
</p>
</div>
<Badge variant="success">{s.rate}%</Badge>
</div>
))}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
</Tabs>
</PageShell>
)
}

View File

@ -0,0 +1,320 @@
import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { CalendarClock, Download, Megaphone, Percent, RefreshCw, Users } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { ChartCard } from '@/components/charts/ChartCard'
import { StatCard } from '@/components/dashboard/StatCard'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { auditLog, campaigns, marketingSegments, segmentMembers } from '@/mocks/data'
import { segmentGrowth } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
import type { Campaign, MarketingSegment } from '@/mocks/data'
import type { TimelineEvent } from '@/components/detail/ActivityTimeline'
const segmentTone: Record<MarketingSegment['status'], 'success' | 'warning' | 'muted'> = {
active: 'success',
paused: 'warning',
draft: 'muted',
}
const campaignTone: Record<Campaign['status'], 'success' | 'default' | 'warning' | 'muted'> = {
sent: 'success',
sending: 'default',
scheduled: 'warning',
draft: 'muted',
}
export function SegmentDetailPage() {
const { id } = useParams()
const segment = marketingSegments.find((s) => s.id === id)
const members = useMemo(() => (id ? (segmentMembers[id] ?? []) : []), [id])
const growth = useMemo(() => (id ? (segmentGrowth[id] ?? []) : []), [id])
const relatedCampaigns = useMemo(
() => (id ? campaigns.filter((c) => c.segmentId === id) : []),
[id],
)
const timeline = useMemo<TimelineEvent[]>(() => {
if (!segment) return []
const events: TimelineEvent[] = [
{
id: `${segment.id}-created`,
at: segment.createdAt,
title: 'Segment created',
detail: `${segment.owner} defined the ${segment.refresh} refresh rule`,
},
]
auditLog
.filter((e) => e.category === 'marketing' && e.target === segment.id)
.forEach((e) => {
events.push({
id: e.id,
at: e.at,
title: e.action.replace('.', ' · '),
detail: `${e.actor} — ${e.targetLabel}`,
tone: e.outcome === 'failed' ? 'danger' : 'default',
})
})
relatedCampaigns.forEach((c) => {
if (!c.sentAt) return
events.push({
id: `${c.id}-sent`,
at: c.sentAt,
title: `Campaign sent · ${c.name}`,
detail: `${c.delivered.toLocaleString()} delivered on ${c.channel}`,
tone: 'success',
})
})
if (segment.lastUsedAt) {
events.push({
id: `${segment.id}-lastused`,
at: segment.lastUsedAt,
title: 'Segment last targeted',
detail: `${segment.engagementRate}% engagement on the last send`,
tone: 'warning',
})
}
return events.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
}, [segment, relatedCampaigns])
if (!segment) {
return (
<DetailPageLayout backTo="/marketing" backLabel="Back to marketing" title="Segment not found">
<Button asChild>
<Link to="/marketing">Back to marketing</Link>
</Button>
</DetailPageLayout>
)
}
return (
<DetailPageLayout
backTo="/marketing"
backLabel="Back to marketing"
title={segment.name}
subtitle={segment.criteria}
badges={
<>
<Badge variant={segmentTone[segment.status]}>{segment.status}</Badge>
<Badge variant="outline">{segment.refresh} refresh</Badge>
</>
}
actions={
<>
<Button asChild size="sm" className="h-9 gap-1.5">
<Link to="/campaigns">
<Megaphone className="h-3.5 w-3.5" />
Launch campaign
</Link>
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Exporting ${segment.size.toLocaleString()} members from “${segment.name}”`)}
>
<Download className="h-3.5 w-3.5" />
Export members
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Recomputing “${segment.name}” membership`)}
>
<RefreshCw className="h-3.5 w-3.5" />
Refresh segment
</Button>
</>
}
sidebar={
<DetailSection title="Activity" description="Audit trail and campaign history">
<ActivityTimeline events={timeline} empty="No segment activity yet" />
</DetailSection>
}
>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Members"
value={segment.size.toLocaleString()}
hint={`${segment.refresh} refresh`}
icon={Users}
tone="brand"
/>
<StatCard
label="Engagement"
value={`${segment.engagementRate}%`}
hint="Opened or clicked"
icon={Percent}
tone="orange"
/>
<StatCard
label="Campaigns using"
value={relatedCampaigns.length}
hint="Drafts included"
icon={Megaphone}
tone="forest"
/>
<StatCard
label="Last used"
value={segment.lastUsedAt ? new Date(segment.lastUsedAt).toLocaleDateString() : '—'}
hint={segment.lastUsedAt ? 'Most recent send' : 'Never targeted'}
icon={CalendarClock}
tone="muted"
/>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<DetailSection title="Segment definition" description="How membership is calculated">
<KeyValueGrid
columns={2}
items={[
{ label: 'Criteria', value: segment.criteria },
{ label: 'Channel', value: <Badge variant="outline">{segment.channel}</Badge> },
{ label: 'Refresh', value: segment.refresh },
{ label: 'Owner', value: segment.owner },
{ label: 'Created', value: new Date(segment.createdAt).toLocaleDateString() },
{
label: 'Last used',
value: segment.lastUsedAt ? new Date(segment.lastUsedAt).toLocaleString() : '—',
},
{ label: 'Status', value: <Badge variant={segmentTone[segment.status]}>{segment.status}</Badge> },
]}
/>
</DetailSection>
<ChartCard title="Members over time" description="Monthly segment size">
{growth.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No growth history for this segment
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={growth} margin={{ left: 4, right: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Area
type="monotone"
dataKey="members"
name="Members"
stroke="#A8C73A"
fill="#A8C73A"
fillOpacity={0.25}
/>
</AreaChart>
</ResponsiveContainer>
)}
</ChartCard>
</div>
<DetailSection title="Members" description={`${members.length} sampled from ${segment.size.toLocaleString()}`}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Phone</TableHead>
<TableHead>Tier</TableHead>
<TableHead className="hidden md:table-cell">Orders</TableHead>
<TableHead>Spend</TableHead>
<TableHead className="hidden md:table-cell">Last order</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
No members in this segment
</TableCell>
</TableRow>
) : (
members.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">
{m.customerId ? (
<Link to={`/crm/${m.customerId}`} className="text-brand-700 hover:underline">
{m.name}
</Link>
) : (
m.name
)}
</TableCell>
<TableCell className="hidden whitespace-nowrap sm:table-cell">{m.phone}</TableCell>
<TableCell>
<Badge variant="secondary">{m.tier}</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">{m.orders}</TableCell>
<TableCell className="whitespace-nowrap">{formatEtb(m.spendEtb)}</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">
{m.lastOrderAt ? new Date(m.lastOrderAt).toLocaleDateString() : '—'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
<DetailSection title="Campaigns using this segment" description="Every send targeted at these members">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Channel</TableHead>
<TableHead>Status</TableHead>
<TableHead>Recipients</TableHead>
<TableHead className="hidden md:table-cell">Sent</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{relatedCampaigns.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
No campaigns have targeted this segment
</TableCell>
</TableRow>
) : (
relatedCampaigns.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">
<Link to={`/campaigns/${c.id}`} className="text-brand-700 hover:underline">
{c.name}
</Link>
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="outline">{c.channel}</Badge>
</TableCell>
<TableCell>
<Badge variant={campaignTone[c.status]}>{c.status}</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">{c.recipients.toLocaleString()}</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">
{c.sentAt
? new Date(c.sentAt).toLocaleDateString()
: c.scheduledFor
? new Date(c.scheduledFor).toLocaleDateString()
: '—'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -0,0 +1,316 @@
import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { AlertTriangle, CheckCircle2, Clock, Download, Layers, Percent, RefreshCw } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import type { TimelineEvent } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { auditLog, importTemplates, migrationJobErrors, migrationJobs } from '@/mocks/data'
import type { MigrationJob } from '@/mocks/data'
const statusBadge: Record<MigrationJob['status'], 'success' | 'warning' | 'danger' | 'default'> = {
completed: 'success',
partial: 'warning',
failed: 'danger',
running: 'default',
}
const statusTone: Record<MigrationJob['status'], TimelineEvent['tone']> = {
completed: 'success',
partial: 'warning',
failed: 'danger',
running: 'default',
}
function formatDuration(seconds: number) {
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
}
export function MigrationJobDetailPage() {
const { id } = useParams()
const job = migrationJobs.find((j) => j.id === id)
const template = useMemo(
() => importTemplates.find((tpl) => tpl.id === job?.templateId),
[job?.templateId],
)
const mappingRows = useMemo<[string, string][]>(
() => (job ? Object.entries(job.mapping) : []),
[job],
)
const unmappedFields = useMemo(
() => (template?.fields ?? []).filter((field) => !(job?.mapping && field in job.mapping)),
[template, job?.mapping],
)
const rowErrors = useMemo(() => (job ? migrationJobErrors[job.id] ?? [] : []), [job])
const relatedAudit = useMemo(() => (job ? auditLog.find((a) => a.target === job.id) : undefined), [job])
const chartData = useMemo(
() => [
{ name: 'Imported', rows: job?.rowsImported ?? 0, fill: '#A8C73A' },
{ name: 'Failed', rows: job?.rowsFailed ?? 0, fill: '#E28A1A' },
],
[job?.rowsImported, job?.rowsFailed],
)
const timeline = useMemo<TimelineEvent[]>(() => {
if (!job) return []
const start = new Date(job.runAt).getTime()
const at = (fraction: number) => new Date(start + job.durationSec * 1000 * fraction).toISOString()
const events: TimelineEvent[] = [
{ id: 'upload', at: at(0), title: 'File uploaded', detail: `${job.filename} · ${job.runBy}` },
{
id: 'map',
at: at(0.2),
title: 'Columns mapped',
detail: `${Object.keys(job.mapping).length} target fields matched to source columns`,
},
{
id: 'validate',
at: at(0.5),
title: 'Dry-run validation',
detail: job.rowsFailed > 0 ? `${job.rowsFailed} rows flagged` : 'No blocking issues found',
tone: job.rowsFailed > 0 ? 'warning' : 'success',
},
]
if (job.status === 'running') {
events.push({ id: 'commit', at: at(0.8), title: 'Import in progress', detail: 'Rows are still being written' })
return events
}
events.push({
id: 'commit',
at: at(0.8),
title: 'Import committed',
detail: `${job.rowsImported.toLocaleString()} of ${job.rowsTotal.toLocaleString()} rows written`,
})
events.push({
id: 'outcome',
at: at(1),
title: `Job ${job.status}`,
detail: `Finished in ${formatDuration(job.durationSec)}`,
tone: statusTone[job.status],
})
return events
}, [job])
if (!job) {
return (
<DetailPageLayout backTo="/migrations" title="Import job not found">
<Button asChild>
<Link to="/migrations">Back to data migration</Link>
</Button>
</DetailPageLayout>
)
}
const successRate = job.rowsTotal ? Math.round((job.rowsImported / job.rowsTotal) * 100) : 0
return (
<DetailPageLayout
backTo="/migrations"
backLabel="Back to data migration"
title={job.id}
subtitle={`${job.templateLabel} · ${job.runBy} · ${new Date(job.runAt).toLocaleString()}`}
badges={<Badge variant={statusBadge[job.status]}>{job.status}</Badge>}
actions={
<>
<Button
size="sm"
className="h-9 gap-1.5"
onClick={() => toast.success(`Re-run queued for ${job.id} (mock)`)}
>
<RefreshCw className="h-3.5 w-3.5" />
Re-run job
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
disabled={job.rowsFailed === 0}
onClick={() => toast.success(`Error report exported for ${job.id} (mock)`)}
>
<AlertTriangle className="h-3.5 w-3.5" />
Download errors
</Button>
<Button
size="sm"
variant="secondary"
className="h-9 gap-1.5"
onClick={() => toast.success(`${job.filename} download started (mock)`)}
>
<Download className="h-3.5 w-3.5" />
Download source
</Button>
</>
}
sidebar={
<div className="space-y-5">
<DetailSection title="Job lifecycle">
<ActivityTimeline events={timeline} />
</DetailSection>
<DetailSection title="Audit trail">
{relatedAudit ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
This job was recorded in the audit log by {relatedAudit.actor}.
</p>
<Badge variant="outline" className="font-mono text-xs">
{relatedAudit.action}
</Badge>
<div>
<Button asChild size="sm" variant="outline" className="h-9">
<Link to={`/audit/${relatedAudit.id}`}>View audit event</Link>
</Button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No audit entry references this job.</p>
)}
</DetailSection>
</div>
}
>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<StatCard label="Rows total" value={job.rowsTotal.toLocaleString()} icon={Layers} tone="brand" />
<StatCard
label="Imported"
value={job.rowsImported.toLocaleString()}
icon={CheckCircle2}
tone="forest"
/>
<StatCard label="Failed" value={job.rowsFailed.toLocaleString()} icon={AlertTriangle} tone="orange" />
<StatCard label="Duration" value={formatDuration(job.durationSec)} icon={Clock} tone="muted" />
<StatCard label="Success rate" value={`${successRate}%`} icon={Percent} tone="brand" />
</div>
<DetailSection title="Job summary">
<KeyValueGrid
columns={3}
items={[
{ label: 'Job id', value: <span className="font-mono text-xs">{job.id}</span> },
{ label: 'Template', value: job.templateLabel },
{ label: 'Filename', value: <span className="break-all font-mono text-xs">{job.filename}</span> },
{ label: 'Run by', value: job.runBy },
{ label: 'Run at', value: new Date(job.runAt).toLocaleString() },
{ label: 'Duration', value: formatDuration(job.durationSec) },
{ label: 'Status', value: <Badge variant={statusBadge[job.status]}>{job.status}</Badge> },
{ label: 'Success rate', value: `${successRate}%` },
]}
/>
</DetailSection>
<div className="grid gap-5 lg:grid-cols-2">
<DetailSection
title="Column mapping used"
description={`${mappingRows.length} of ${template?.fields.length ?? mappingRows.length} template fields mapped`}
>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Target field</TableHead>
<TableHead>Source column</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mappingRows.length === 0 && unmappedFields.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="h-24 text-center text-muted-foreground">
No mapping was recorded for this job
</TableCell>
</TableRow>
) : (
<>
{mappingRows.map(([field, column]) => (
<TableRow key={field}>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{field}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{column}</TableCell>
</TableRow>
))}
{unmappedFields.map((field) => (
<TableRow key={field}>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{field}
</Badge>
</TableCell>
<TableCell>
<Badge variant="warning">not mapped</Badge>
</TableCell>
</TableRow>
))}
</>
)}
</TableBody>
</Table>
</DetailSection>
<ChartCard title="Row outcome" description="Imported vs failed rows in this run">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Bar dataKey="rows" name="Rows" radius={[6, 6, 0, 0]}>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
<DetailSection title="Failed rows" description={`${rowErrors.length} rows rejected by validation`}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Row</TableHead>
<TableHead>Column</TableHead>
<TableHead>Value</TableHead>
<TableHead>Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rowErrors.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
Every row imported cleanly
</TableCell>
</TableRow>
) : (
rowErrors.map((err) => (
<TableRow key={err.id}>
<TableCell className="font-medium">{err.row}</TableCell>
<TableCell className="font-mono text-xs">{err.column}</TableCell>
<TableCell className="font-mono text-xs">{err.value === '' ? '—' : err.value}</TableCell>
<TableCell className="text-muted-foreground">{err.reason}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -1,16 +1,47 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { AlertTriangle, CheckCircle2, Clock, Database, Download, Eye, FileDown, RefreshCw } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { SelectNative } from '@/components/ui/select-native'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { importTemplates } from '@/mocks/data'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { importTemplates, migrationJobs } from '@/mocks/data'
import type { MigrationJob } from '@/mocks/data'
import { migrationVolumeSeries } from '@/mocks/charts'
type Step = 'template' | 'upload' | 'map' | 'validate' | 'done'
const statusBadge: Record<MigrationJob['status'], 'success' | 'warning' | 'danger' | 'default'> = {
completed: 'success',
partial: 'warning',
failed: 'danger',
running: 'default',
}
const jobTemplateOptions = [
{ value: 'all', label: 'All templates' },
...importTemplates.map((tpl) => ({ value: tpl.id, label: tpl.label })),
]
const jobStatusOptions = [
{ value: 'all', label: 'All statuses' },
{ value: 'completed', label: 'Completed' },
{ value: 'partial', label: 'Partial' },
{ value: 'failed', label: 'Failed' },
{ value: 'running', label: 'Running' },
]
export function MigrationsPage() {
const { t } = useTranslation()
const [step, setStep] = useState<Step>('template')
@ -20,11 +51,58 @@ export function MigrationsPage() {
const [errors, setErrors] = useState<string[]>([])
const [previewRows, setPreviewRows] = useState<string[][]>([])
const [jobQuery, setJobQuery] = useState('')
const [jobTemplate, setJobTemplate] = useState('all')
const [jobStatus, setJobStatus] = useState('all')
const template = useMemo(
() => importTemplates.find((x) => x.id === templateId)!,
[templateId],
)
const stats = useMemo(() => {
const imported = migrationJobs.reduce((sum, job) => sum + job.rowsImported, 0)
const failed = migrationJobs.reduce((sum, job) => sum + job.rowsFailed, 0)
const latest = migrationJobs.reduce<MigrationJob | null>(
(newest, job) => (!newest || job.runAt > newest.runAt ? job : newest),
null,
)
return { jobs: migrationJobs.length, imported, failed, latest }
}, [])
const filteredJobs = useMemo(() => {
let list = migrationJobs
if (jobTemplate !== 'all') list = list.filter((job) => job.templateId === jobTemplate)
if (jobStatus !== 'all') list = list.filter((job) => job.status === jobStatus)
if (jobQuery) {
const needle = jobQuery.toLowerCase()
list = list.filter(
(job) =>
job.id.toLowerCase().includes(needle) ||
job.filename.toLowerCase().includes(needle) ||
job.runBy.toLowerCase().includes(needle),
)
}
return list
}, [jobQuery, jobTemplate, jobStatus])
const jobFilters: TableFilter[] = [
{
id: 'template',
label: 'Entity type',
value: jobTemplate,
onChange: setJobTemplate,
options: jobTemplateOptions,
},
{
id: 'status',
label: t('common.status'),
value: jobStatus,
onChange: setJobStatus,
options: jobStatusOptions,
},
]
const onFile = async (file: File | null) => {
if (!file) return
const text = await file.text()
@ -66,6 +144,48 @@ export function MigrationsPage() {
return (
<PageShell title={t('migrations.title')} subtitle={t('migrations.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Jobs run" value={stats.jobs} hint="All-time import runs" icon={Database} tone="brand" />
<StatCard
label="Rows imported"
value={stats.imported.toLocaleString()}
hint="Across every job"
icon={CheckCircle2}
tone="forest"
/>
<StatCard
label="Rows failed"
value={stats.failed.toLocaleString()}
hint="Needs a re-run or a fix"
icon={AlertTriangle}
tone="orange"
/>
<StatCard
label="Last import"
value={
stats.latest
? new Date(stats.latest.runAt).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '—'
}
hint={stats.latest ? `${stats.latest.filename} · ${stats.latest.runBy}` : 'No imports yet'}
icon={Clock}
tone="muted"
/>
</div>
<Tabs defaultValue="wizard">
<TabsList>
<TabsTrigger value="wizard">Import wizard</TabsTrigger>
<TabsTrigger value="history">Job history</TabsTrigger>
<TabsTrigger value="templates">Templates</TabsTrigger>
</TabsList>
<TabsContent value="wizard" className="space-y-5">
<div className="flex flex-wrap gap-2">
{(['template', 'upload', 'map', 'validate', 'done'] as Step[]).map((s) => (
<Badge key={s} variant={step === s ? 'default' : 'outline'}>
@ -205,6 +325,177 @@ export function MigrationsPage() {
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="history" className="space-y-5">
<ChartCard title="Import volume" description="Rows imported vs failed by month">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={migrationVolumeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => Number(v).toLocaleString()} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="imported" name="Imported" fill="#A8C73A" radius={[6, 6, 0, 0]} />
<Bar dataKey="failed" name="Failed" fill="#E28A1A" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<Card className="overflow-hidden">
<TableToolbar
search={jobQuery}
onSearchChange={setJobQuery}
searchPlaceholder="Search job id, filename, run by…"
resultCount={filteredJobs.length}
filters={jobFilters}
onClearFilters={() => {
setJobQuery('')
setJobTemplate('all')
setJobStatus('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Job id</TableHead>
<TableHead>Template</TableHead>
<TableHead className="hidden lg:table-cell">Filename</TableHead>
<TableHead className="hidden md:table-cell">Rows total</TableHead>
<TableHead>Imported</TableHead>
<TableHead>Failed</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden xl:table-cell">Run by</TableHead>
<TableHead className="hidden lg:table-cell">Run at</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredJobs.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filteredJobs.map((job) => (
<TableRow key={job.id}>
<TableCell className="font-medium">
<Link to={`/migrations/${job.id}`} className="text-brand-700 hover:underline">
{job.id}
</Link>
</TableCell>
<TableCell>{job.templateLabel}</TableCell>
<TableCell className="hidden font-mono text-xs lg:table-cell">{job.filename}</TableCell>
<TableCell className="hidden md:table-cell">{job.rowsTotal.toLocaleString()}</TableCell>
<TableCell>{job.rowsImported.toLocaleString()}</TableCell>
<TableCell className={job.rowsFailed > 0 ? 'font-medium text-orange-deep' : ''}>
{job.rowsFailed.toLocaleString()}
</TableCell>
<TableCell>
<Badge variant={statusBadge[job.status]}>{job.status}</Badge>
</TableCell>
<TableCell className="hidden xl:table-cell">{job.runBy}</TableCell>
<TableCell className="hidden whitespace-nowrap text-xs lg:table-cell">
{new Date(job.runAt).toLocaleString()}
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'View',
icon: <Eye className="h-3.5 w-3.5" />,
to: `/migrations/${job.id}`,
},
]}
items={[
{
label: 'Re-run job',
icon: <RefreshCw className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Re-run queued for ${job.id} (mock)`),
},
{
label: 'Download errors',
icon: <AlertTriangle className="h-3.5 w-3.5" />,
disabled: job.rowsFailed === 0,
onClick: () => toast.success(`Error report exported for ${job.id} (mock)`),
},
{
label: 'Download source file',
icon: <Download className="h-3.5 w-3.5" />,
onClick: () => toast.success(`${job.filename} download started (mock)`),
},
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="templates">
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>{t('migrations.templates')}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Template</TableHead>
<TableHead className="hidden sm:table-cell">Id</TableHead>
<TableHead className="hidden md:table-cell">Fields</TableHead>
<TableHead>Column list</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importTemplates.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
importTemplates.map((tpl) => (
<TableRow key={tpl.id}>
<TableCell className="font-medium">{tpl.label}</TableCell>
<TableCell className="hidden font-mono text-xs sm:table-cell">{tpl.id}</TableCell>
<TableCell className="hidden md:table-cell">{tpl.fields.length}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{tpl.fields.map((field) => (
<Badge key={field} variant="outline" className="font-mono text-[11px]">
{field}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'Download template',
icon: <FileDown className="h-3.5 w-3.5" />,
onClick: () => toast.success(`${tpl.label} template downloaded (mock)`),
},
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</PageShell>
)
}

View File

@ -1,13 +1,19 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import {
CreatePageLayout,
createFieldFull,
createFieldWide,
createFormGrid,
} from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Textarea } from '@/components/ui/textarea'
import { useAuthStore } from '@/zustand/authStore'
import { cn } from '@/lib/utils'
import { branches, orders } from '@/mocks/data'
import type { Order } from '@/types'
@ -62,8 +68,8 @@ export function CreateOrderPage() {
</>
}
>
<form id="create-order-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5 sm:col-span-2">
<form id="create-order-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Customer name</Label>
<Input
required
@ -129,7 +135,7 @@ export function CreateOrderPage() {
</SelectNative>
</div>
{form.fulfillment === 'delivery' ? (
<div className="space-y-1.5 sm:col-span-2">
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Delivery address</Label>
<Input
required
@ -138,7 +144,7 @@ export function CreateOrderPage() {
/>
</div>
) : null}
<div className="space-y-1.5 sm:col-span-2">
<div className={cn('space-y-1.5', createFieldFull)}>
<Label>Notes</Label>
<Textarea
value={form.notes}

View File

@ -0,0 +1,157 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { CheckCircle2, Truck, XCircle } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { branches, orderLines, orders, orderTimeline, riders } from '@/mocks/data'
import { formatEtb } from '@/lib/utils'
import type { Order } from '@/types'
export function OrderDetailPage() {
const { id } = useParams()
const seed = orders.find((o) => o.id === id)
const [order, setOrder] = useState<Order | undefined>(seed ? { ...seed } : undefined)
if (!order) {
return (
<DetailPageLayout backTo="/orders" title="Order not found" subtitle="This order may have been removed.">
<Button asChild>
<Link to="/orders">Back to orders</Link>
</Button>
</DetailPageLayout>
)
}
const lines = orderLines[order.id] ?? []
const timeline = orderTimeline[order.id] ?? []
const branch = branches.find((b) => b.id === order.branchId)
const rider = riders.find((r) => r.id === order.assignedRiderId)
const patch = (next: Partial<Order>, message: string) => {
setOrder((prev) => (prev ? { ...prev, ...next } : prev))
const target = orders.find((o) => o.id === order.id)
if (target) Object.assign(target, next)
toast.success(message)
}
return (
<DetailPageLayout
backTo="/orders"
backLabel="Back to orders"
title={order.id}
subtitle={`${order.customerName} · ${branch?.name ?? 'Branch'}`}
badges={
<>
<Badge variant={order.status === 'cancelled' ? 'danger' : order.status === 'out_for_delivery' ? 'warning' : 'success'}>
{order.status}
</Badge>
<Badge variant="outline">{order.fulfillment}</Badge>
{order.paid ? <Badge variant="secondary">Paid</Badge> : <Badge variant="warning">Unpaid</Badge>}
</>
}
actions={
<>
<Button
size="sm"
className="h-9 gap-1.5"
disabled={order.status === 'confirmed' || order.status === 'cancelled'}
onClick={() => patch({ status: 'confirmed' }, 'Order confirmed')}
>
<CheckCircle2 className="h-3.5 w-3.5" />
Confirm
</Button>
{order.fulfillment === 'delivery' ? (
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() =>
patch({ assignedRiderId: 'rd-1', status: 'out_for_delivery' }, 'Rider assigned · Telegram notified')
}
>
<Truck className="h-3.5 w-3.5" />
Assign rider
</Button>
) : null}
<Button
size="sm"
variant="destructive"
className="h-9 gap-1.5"
disabled={order.status === 'cancelled'}
onClick={() => patch({ status: 'cancelled' }, 'Order cancelled')}
>
<XCircle className="h-3.5 w-3.5" />
Cancel
</Button>
</>
}
sidebar={
<div className="space-y-5">
<DetailSection title="Activity">
<ActivityTimeline events={timeline} />
</DetailSection>
<DetailSection title="Fulfillment">
<KeyValueGrid
columns={1}
items={[
{ label: 'Rider', value: rider?.name ?? 'Unassigned' },
{ label: 'Zone', value: order.zone ?? '—' },
{ label: 'Address', value: order.address ?? 'Pickup counter' },
{ label: 'Telegram', value: rider?.telegramId ?? '—' },
]}
/>
</DetailSection>
</div>
}
>
<div className="space-y-5">
<DetailSection title="Summary">
<KeyValueGrid
columns={3}
items={[
{ label: 'Channel', value: order.channel },
{ label: 'Total', value: formatEtb(order.totalEtb) },
{ label: 'Rx approved', value: order.rxApproved ? 'Yes' : 'N/A' },
{ label: 'Created', value: new Date(order.createdAt).toLocaleString() },
{ label: 'Branch phone', value: branch?.phone ?? '—' },
{ label: 'Payment', value: order.paid ? 'Captured' : 'Pending' },
]}
/>
</DetailSection>
<DetailSection title="Line items" description="Picked against branch stock">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>SKU</TableHead>
<TableHead>Product</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Unit</TableHead>
<TableHead className="text-right">Line</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.sku}>
<TableCell>
<Link className="font-medium text-brand-700 hover:underline" to={`/stock/${line.sku}`}>
{line.sku}
</Link>
</TableCell>
<TableCell>{line.name}</TableCell>
<TableCell>{line.qty}</TableCell>
<TableCell>{formatEtb(line.unitEtb)}</TableCell>
<TableCell className="text-right">{formatEtb(line.qty * line.unitEtb)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -1,14 +1,26 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CheckCircle2, Plus, Truck, XCircle } from 'lucide-react'
import {
CheckCircle2,
Clock3,
Eye,
Plus,
Receipt,
ShoppingBag,
Smartphone,
Truck,
Wallet,
XCircle,
} from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { TableToolbar, type TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import {
Dialog,
@ -20,32 +32,82 @@ import {
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { useAuthStore } from '@/zustand/authStore'
import { canAccess } from '@/config/permissions'
import { branches, orders as seedOrders, riders } from '@/mocks/data'
import { formatEtb } from '@/lib/utils'
import { formatEtb, humanize } from '@/lib/utils'
import type { Order } from '@/types'
/** Statuses an order passes through before a rider or the counter takes over. */
const AWAITING_FULFILLMENT = ['pending', 'confirmed', 'picking']
function isToday(iso: string, now: Date) {
const date = new Date(iso)
return (
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
)
}
export function OrdersPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const [searchParams, setSearchParams] = useSearchParams()
const [rows, setRows] = useState<Order[]>([...seedOrders])
const [q, setQ] = useState('')
const [status, setStatus] = useState('all')
const [fulfillment, setFulfillment] = useState('all')
const [branchFilter, setBranchFilter] = useState('all')
const [channel, setChannel] = useState('all')
const [assignTarget, setAssignTarget] = useState<Order | null>(null)
const [riderId, setRiderId] = useState('')
/** Status and channel live in the URL so the KPI cards above can link straight into a filtered view. */
const status = searchParams.get('status') ?? 'all'
const channel = searchParams.get('channel') ?? 'all'
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(searchParams)
if (value === 'all') next.delete(key)
else next.set(key, value)
setSearchParams(next, { replace: true })
}
const canPickBranch = user?.role !== 'pharmacist'
const filtered = useMemo(() => {
let list = rows
/** Branch scope for the signed-in role — every KPI and the table read from this one collection. */
const scoped = useMemo(() => {
if (user?.role === 'pharmacist' && user.branchId) {
list = list.filter((o) => o.branchId === user.branchId)
} else if (branchFilter !== 'all') {
list = list.filter((o) => o.branchId === branchFilter)
return rows.filter((o) => o.branchId === user.branchId)
}
if (status !== 'all') list = list.filter((o) => o.status === status)
return rows
}, [rows, user])
const kpis = useMemo(() => {
const now = new Date()
const today = scoped.filter((o) => isToday(o.createdAt, now))
const paidToday = today.filter((o) => o.paid)
const revenueToday = paidToday.reduce((sum, o) => sum + o.totalEtb, 0)
const byChannel = new Map<string, number>()
for (const order of today) byChannel.set(order.channel, (byChannel.get(order.channel) ?? 0) + 1)
const [topChannel, topChannelCount] = Array.from(byChannel.entries()).sort((a, b) => b[1] - a[1])[0] ?? ['—', 0]
return {
today: today.length,
revenueToday,
paidToday: paidToday.length,
averageOrderValue: paidToday.length ? revenueToday / paidToday.length : 0,
awaiting: scoped.filter((o) => AWAITING_FULFILLMENT.includes(o.status)).length,
unpaidAwaiting: scoped.filter((o) => AWAITING_FULFILLMENT.includes(o.status) && !o.paid).length,
topChannel,
topChannelCount,
}
}, [scoped])
const filtered = useMemo(() => {
let list = scoped
if (canPickBranch && branchFilter !== 'all') list = list.filter((o) => o.branchId === branchFilter)
if (status === 'awaiting') list = list.filter((o) => AWAITING_FULFILLMENT.includes(o.status))
else if (status !== 'all') list = list.filter((o) => o.status === status)
if (fulfillment !== 'all') list = list.filter((o) => o.fulfillment === fulfillment)
if (channel !== 'all') list = list.filter((o) => o.channel === channel)
if (q) {
@ -55,11 +117,69 @@ export function OrdersPage() {
)
}
return list
}, [rows, user, q, status, fulfillment, branchFilter, channel])
}, [scoped, canPickBranch, q, status, fulfillment, branchFilter, channel])
const filtersActive =
status !== 'all' || fulfillment !== 'all' || branchFilter !== 'all' || channel !== 'all' || !!q
const orderFilters: TableFilter[] = [
{
id: 'status',
label: 'Status',
value: status,
onChange: (value) => setParam('status', value),
options: [
{ value: 'all', label: 'All statuses' },
{ value: 'awaiting', label: 'Awaiting fulfillment' },
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'picking', label: 'Picking' },
{ value: 'out_for_delivery', label: 'Out for delivery' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'cancelled', label: 'Cancelled' },
],
},
{
id: 'fulfillment',
label: 'Fulfillment',
value: fulfillment,
onChange: setFulfillment,
options: [
{ value: 'all', label: 'All fulfillment' },
{ value: 'delivery', label: 'Delivery' },
{ value: 'pickup', label: 'Pickup' },
],
},
...(canPickBranch
? [
{
id: 'branch',
label: 'Branch',
value: branchFilter,
onChange: setBranchFilter,
options: [
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.id, label: b.name })),
],
},
]
: []),
{
id: 'channel',
label: 'Channel',
value: channel,
onChange: (value) => setParam('channel', value),
options: [
{ value: 'all', label: 'All channels' },
{ value: 'web', label: 'Web' },
{ value: 'mobile', label: 'Mobile' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'b2b', label: 'B2B' },
{ value: 'pos', label: 'POS / counter' },
],
},
]
const confirm = (orderId: string) => {
setRows((prev) => prev.map((o) => (o.id === orderId ? { ...o, status: 'confirmed' } : o)))
const target = seedOrders.find((o) => o.id === orderId)
@ -93,71 +213,61 @@ export function OrdersPage() {
return (
<PageShell title={t('orders.title')} subtitle={t('orders.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<StatCard
label="Orders today"
value={kpis.today}
hint={`${scoped.length} in the current book`}
icon={ShoppingBag}
tone="brand"
/>
<StatCard
label="Revenue today"
value={formatEtb(kpis.revenueToday)}
hint={`${kpis.paidToday} paid order${kpis.paidToday === 1 ? '' : 's'}`}
icon={Wallet}
tone="forest"
to={user && canAccess(user.role, 'finance') ? '/finance' : undefined}
/>
<StatCard
label="Average order value"
value={formatEtb(kpis.averageOrderValue)}
hint="Paid orders today"
icon={Receipt}
tone="muted"
/>
<StatCard
label="Awaiting fulfillment"
value={kpis.awaiting}
hint={`${kpis.unpaidAwaiting} still unpaid`}
icon={Clock3}
tone="orange"
to="/orders?status=awaiting"
/>
<StatCard
label="Top channel today"
value={humanize(kpis.topChannel)}
hint={`${kpis.topChannelCount} of ${kpis.today} orders`}
icon={Smartphone}
tone="brand"
to={kpis.topChannelCount ? `/orders?channel=${kpis.topChannel}` : undefined}
/>
</div>
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search ID or customer…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Status"
value={status}
onChange={setStatus}
options={[
{ value: 'all', label: 'All statuses' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'picking', label: 'Picking' },
{ value: 'out_for_delivery', label: 'Out for delivery' },
{ value: 'cancelled', label: 'Cancelled' },
]}
/>
<FilterSelect
label="Fulfillment"
value={fulfillment}
onChange={setFulfillment}
options={[
{ value: 'all', label: 'All types' },
{ value: 'delivery', label: 'Delivery' },
{ value: 'pickup', label: 'Pickup' },
]}
/>
{canPickBranch ? (
<FilterSelect
label="Branch"
value={branchFilter}
onChange={setBranchFilter}
options={[
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.id, label: b.name })),
]}
/>
) : null}
<FilterSelect
label="Channel"
value={channel}
onChange={setChannel}
options={[
{ value: 'all', label: 'All channels' },
{ value: 'web', label: 'Web' },
{ value: 'mobile', label: 'Mobile' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'b2b', label: 'B2B' },
]}
/>
<ClearFiltersButton
visible={filtersActive}
onClick={() => {
filters={orderFilters}
clearVisible={filtersActive}
onClearFilters={() => {
setQ('')
setStatus('all')
setFulfillment('all')
setBranchFilter('all')
setChannel('all')
setSearchParams(new URLSearchParams(), { replace: true })
}}
/>
</>
}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/orders/new">
@ -190,7 +300,11 @@ export function OrdersPage() {
) : (
filtered.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-medium">{o.id}</TableCell>
<TableCell className="font-medium">
<Link to={`/orders/${o.id}`} className="text-brand-700 hover:underline">
{o.id}
</Link>
</TableCell>
<TableCell>{o.customerName}</TableCell>
<TableCell>{branches.find((b) => b.id === o.branchId)?.name}</TableCell>
<TableCell>
@ -201,6 +315,8 @@ export function OrdersPage() {
variant={
o.status === 'cancelled'
? 'danger'
: o.status === 'pending'
? 'secondary'
: o.status === 'out_for_delivery'
? 'warning'
: 'success'
@ -213,6 +329,11 @@ export function OrdersPage() {
<TableCell>
<RowActions
primary={[
{
label: 'View',
icon: <Eye className="h-3.5 w-3.5" />,
to: `/orders/${o.id}`,
},
{
label: 'Confirm',
icon: <CheckCircle2 className="h-3.5 w-3.5" />,

View File

@ -1,10 +1,11 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { CreatePageLayout, createFieldWide, createFormGrid } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { organisations } from '@/mocks/data'
import type { Organisation } from '@/types'
@ -52,8 +53,8 @@ export function CreateOrganisationPage() {
</>
}
>
<form id="create-org-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5 sm:col-span-2">
<form id="create-org-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Organisation name</Label>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
@ -71,7 +72,7 @@ export function CreateOrganisationPage() {
onChange={(e) => setForm({ ...form, creditLimitEtb: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Billing contact</Label>
<Input
required

View File

@ -0,0 +1,312 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Check, ClipboardCopy, X, Wallet } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { ApprovalStepper, type ApprovalStep } from '@/components/detail/ApprovalStepper'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { ChartCard } from '@/components/charts/ChartCard'
import { CHART_COLORS, axisProps, chartMargin, gridProps, tooltipProps } from '@/components/charts/chartTheme'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { StatusBadge } from '@/components/ui/status-badge'
import { PersonAvatar } from '@/components/ui/avatar'
import { RowActions, type RowAction } from '@/components/table/RowActions'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { orgInvoices, orgMembers, organisations } from '@/mocks/data'
import { orgSpendSeries } from '@/mocks/charts'
import { useAuthStore } from '@/zustand/authStore'
import { formatDate, formatDateTime, formatEtb } from '@/lib/utils'
import type { Organisation } from '@/types'
export function OrganisationDetailPage() {
const { id } = useParams()
const user = useAuthStore((s) => s.user)
const seed = organisations.find((o) => o.id === id)
const [org, setOrg] = useState<Organisation | undefined>(seed ? { ...seed } : undefined)
if (!org) {
return (
<DetailPageLayout backTo="/organisations" title="Organisation not found">
<Button asChild>
<Link to="/organisations">Back to organisations</Link>
</Button>
</DetailPageLayout>
)
}
const members = orgMembers[org.id] ?? []
const invoices = orgInvoices[org.id] ?? []
const util = org.creditLimitEtb ? Math.round((org.usedEtb / org.creditLimitEtb) * 100) : 0
const isPending = org.status === 'pending'
const setStatus = (status: Organisation['status'], message: string) => {
const patch = {
status,
reviewedBy: user?.name ?? 'Finance',
reviewedAt: new Date().toISOString(),
}
setOrg((prev) => (prev ? { ...prev, ...patch } : prev))
const target = organisations.find((o) => o.id === org.id)
if (target) Object.assign(target, patch)
toast.success(message)
}
const approve = () => setStatus('active', 'Organisation approved')
const reject = () => setStatus('rejected', 'Organisation rejected')
const adjustCredit = () => toast.message('Credit limit change submitted for review')
const overflow: RowAction[] = [
...(isPending
? [
{
label: 'Reject application',
icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive' as const,
onClick: reject,
},
]
: []),
{
label: 'Copy TIN',
icon: <ClipboardCopy className="h-3.5 w-3.5" />,
onClick: () => {
void navigator.clipboard?.writeText(org.tin)
toast.message(`TIN ${org.tin} copied to clipboard`)
},
},
]
const steps = useMemo<ApprovalStep[]>(
() => [
{
key: 'registered',
label: 'Registered',
state: 'done',
actor: org.source === 'self_register' ? 'Self-registered' : 'Created in Admin',
at: org.createdAt,
},
{
key: 'review',
label: 'Finance review',
state: isPending ? 'current' : 'done',
actor: isPending ? 'Awaiting a finance decision' : org.reviewedBy,
at: isPending ? undefined : org.reviewedAt,
hint: isPending ? 'KYC documents and TIN need verifying' : undefined,
},
{
key: 'credit',
label: org.status === 'rejected' ? 'Application rejected' : 'Credit account active',
state: org.status === 'active' ? 'done' : org.status === 'rejected' ? 'failed' : 'upcoming',
actor: org.status === 'active' ? `Limit ${formatEtb(org.creditLimitEtb)}` : undefined,
at: org.status === 'pending' ? undefined : org.reviewedAt,
},
],
[org, isPending],
)
return (
<DetailPageLayout
backTo="/organisations"
backLabel="Back to organisations"
title={org.name}
subtitle={`TIN ${org.tin} · ${org.billingContact}`}
badges={
<>
<StatusBadge domain="organisation" status={org.status} />
<Badge variant="outline">{org.source === 'self_register' ? 'Self-registered' : 'Added by admin'}</Badge>
</>
}
actions={
<>
{isPending ? (
<Button className="h-10 gap-1.5" onClick={approve}>
<Check className="h-4 w-4" />
Approve credit account
</Button>
) : (
<Button variant="outline" className="h-10 gap-1.5" onClick={adjustCredit}>
<Wallet className="h-4 w-4" />
Adjust credit
</Button>
)}
<RowActions items={overflow} />
</>
}
>
<div className="space-y-5">
<DetailSection
title="Approval status"
description="Where this credit account sits in the finance workflow"
>
<ApprovalStepper steps={steps} />
<div
className={
org.status === 'rejected'
? 'mt-5 rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3'
: org.status === 'active'
? 'mt-5 rounded-xl border border-brand-200 bg-brand-50 px-4 py-3'
: 'mt-5 rounded-xl border border-orange/30 bg-orange/5 px-4 py-3'
}
>
<p className="text-sm font-semibold text-forest">
{org.status === 'active'
? 'Credit account is live — staff can order on account'
: org.status === 'rejected'
? 'Application rejected — no credit is available'
: 'Awaiting finance approval'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{org.reviewedBy && org.reviewedAt
? `Decided by ${org.reviewedBy} · ${formatDateTime(org.reviewedAt)}`
: `Registered ${formatDate(org.createdAt)} · no decision recorded yet`}
</p>
{org.decisionNote ? (
<p className="mt-2 text-xs text-grayScale-600">{org.decisionNote}</p>
) : null}
</div>
</DetailSection>
<div className="grid gap-5 lg:grid-cols-2">
<DetailSection title="Credit profile">
<KeyValueGrid
columns={2}
items={[
{ label: 'Credit limit', value: formatEtb(org.creditLimitEtb) },
{ label: 'Used', value: formatEtb(org.usedEtb) },
{ label: 'Available', value: formatEtb(Math.max(0, org.creditLimitEtb - org.usedEtb)) },
{ label: 'Billing contact', value: org.billingContact },
{ label: 'Source', value: org.source === 'self_register' ? 'Self-register' : 'Created in Admin' },
{ label: 'Registered', value: formatDate(org.createdAt) },
]}
/>
<div className="mt-5 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-muted-foreground">Credit utilisation</span>
<span className="font-semibold text-forest">{util}%</span>
</div>
<Progress value={util} />
</div>
<div className="mt-5 flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{isPending
? 'Approving opens the credit line immediately and invoices monthly in arrears.'
: org.status === 'active'
? 'Limit changes are queued for a second finance approval.'
: `Rejected by ${org.reviewedBy ?? 'finance'}.`}
</p>
<div className="flex justify-end gap-2">
{isPending ? (
<>
<Button
type="button"
variant="ghost"
className="h-10 gap-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={reject}
>
<X className="h-4 w-4" />
Reject
</Button>
<Button type="button" className="h-10 gap-1.5" onClick={approve}>
<Check className="h-4 w-4" />
Approve credit
</Button>
</>
) : (
<Button type="button" variant="outline" className="h-10 gap-1.5" onClick={adjustCredit}>
<Wallet className="h-4 w-4" />
Adjust credit
</Button>
)}
</div>
</div>
</DetailSection>
<ChartCard title="Monthly spend" description="Credit drawdown over the last six months">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={orgSpendSeries} margin={chartMargin}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="month" {...axisProps} />
<YAxis {...axisProps} width={44} />
<Tooltip {...tooltipProps} formatter={(v) => formatEtb(Number(v))} />
<Bar dataKey="spend" name="Spend" fill={CHART_COLORS.brand} radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<DetailSection title="Members" description={`${members.length} staff linked to this account`}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Dept</TableHead>
<TableHead>Plan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-muted-foreground">
No members yet
</TableCell>
</TableRow>
) : (
members.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">
<span className="flex items-center gap-2.5">
<PersonAvatar name={m.name} avatarUrl={m.avatarUrl} size="sm" />
{m.name}
</span>
</TableCell>
<TableCell>{m.dept}</TableCell>
<TableCell>
<Badge variant="outline">{m.plan}</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
<DetailSection title="Invoices">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>ID</TableHead>
<TableHead>Period</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
No invoices
</TableCell>
</TableRow>
) : (
invoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.id}</TableCell>
<TableCell>{inv.period}</TableCell>
<TableCell>{formatEtb(inv.amountEtb)}</TableCell>
<TableCell>
<StatusBadge domain="invoice" status={inv.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
</div>
</div>
</DetailPageLayout>
)
}

View File

@ -2,25 +2,41 @@ import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Check, Plus, X } from 'lucide-react'
import { Building2, Check, CreditCard, Eye, Plus, Users, Wallet, X } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { Progress } from '@/components/ui/progress'
import { StatusBadge } from '@/components/ui/status-badge'
import { TableToolbar, type TableFilter } from '@/components/table/TableToolbar'
import { RowActions, type RowAction } from '@/components/table/RowActions'
import { organisations as seed } from '@/mocks/data'
import { useAuthStore } from '@/zustand/authStore'
import { formatEtb } from '@/lib/utils'
import type { Organisation } from '@/types'
export function OrganisationsPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const [orgs, setOrgs] = useState<Organisation[]>([...seed])
const [q, setQ] = useState('')
const [status, setStatus] = useState('all')
const [source, setSource] = useState('all')
const kpis = useMemo(() => {
const active = orgs.filter((o) => o.status === 'active')
return {
total: orgs.length,
pending: orgs.filter((o) => o.status === 'pending').length,
active: active.length,
creditExposure: active.reduce((sum, o) => sum + o.usedEtb, 0),
creditLimit: active.reduce((sum, o) => sum + o.creditLimitEtb, 0),
selfRegister: orgs.filter((o) => o.source === 'self_register').length,
}
}, [orgs])
const filtered = useMemo(() => {
let list = orgs
if (status !== 'all') list = list.filter((o) => o.status === status)
@ -38,20 +54,92 @@ export function OrganisationsPage() {
}, [orgs, q, status, source])
const approve = (id: string, next: 'active' | 'rejected') => {
setOrgs((prev) => prev.map((o) => (o.id === id ? { ...o, status: next } : o)))
const patch = {
status: next,
reviewedBy: user?.name ?? 'Finance',
reviewedAt: new Date().toISOString(),
}
setOrgs((prev) => prev.map((o) => (o.id === id ? { ...o, ...patch } : o)))
const target = seed.find((o) => o.id === id)
if (target) target.status = next
if (target) Object.assign(target, patch)
toast.success(next === 'active' ? 'Organisation approved — credit active' : 'Organisation rejected')
}
const pendingCount = orgs.filter((o) => o.status === 'pending').length
const rowActions = (o: Organisation): { primary: RowAction[]; items: RowAction[] } => ({
primary: [
{ label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/organisations/${o.id}` },
...(o.status === 'pending'
? [
{
label: t('common.approve'),
icon: <Check className="h-3.5 w-3.5" />,
onClick: () => approve(o.id, 'active'),
},
]
: []),
],
items:
o.status === 'pending'
? [
{
label: t('common.reject'),
icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive' as const,
onClick: () => approve(o.id, 'rejected'),
},
]
: [],
})
const orgFilters: TableFilter[] = [
{
id: 'status',
label: 'Status',
value: status,
onChange: setStatus,
options: [
{ value: 'all', label: 'All statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'active', label: 'Active' },
{ value: 'rejected', label: 'Rejected' },
],
},
{
id: 'source',
label: 'Source',
value: source,
onChange: setSource,
options: [
{ value: 'all', label: 'All sources' },
{ value: 'admin', label: 'Admin created' },
{ value: 'self_register', label: 'Self-register' },
],
},
]
return (
<PageShell title={t('organisations.title')} subtitle={t('organisations.subtitle')}>
{pendingCount > 0 ? (
<div className="rounded-2xl border border-orange/30 bg-orange/5 px-4 py-3 text-sm">
<strong>{pendingCount}</strong> self-registration{pendingCount > 1 ? 's' : ''} awaiting approval.
Filter by status <em>Pending</em> to review.
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Organisations" value={kpis.total} hint={`${kpis.active} active`} icon={Building2} tone="brand" />
<StatCard label="Pending review" value={kpis.pending} hint={`${kpis.selfRegister} self-registered`} icon={Users} tone="orange" />
<StatCard label="Credit exposure" value={formatEtb(kpis.creditExposure)} hint="Used across active accounts" icon={Wallet} tone="forest" />
<StatCard label="Credit capacity" value={formatEtb(kpis.creditLimit)} hint="Sum of active limits" icon={CreditCard} tone="muted" />
</div>
{kpis.pending > 0 ? (
<div className="flex flex-col gap-3 rounded-2xl border border-orange/30 bg-orange/5 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<p>
<strong>{kpis.pending}</strong> self-registration{kpis.pending > 1 ? 's' : ''} awaiting a finance
decision.
</p>
<Button
type="button"
variant="outline"
className="h-9 shrink-0 bg-background"
onClick={() => setStatus('pending')}
>
Review pending
</Button>
</div>
) : null}
<Card className="overflow-hidden">
@ -60,39 +148,12 @@ export function OrganisationsPage() {
onSearchChange={setQ}
searchPlaceholder="Search name, TIN, contact…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Status"
value={status}
onChange={setStatus}
options={[
{ value: 'all', label: 'All statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'active', label: 'Active' },
{ value: 'rejected', label: 'Rejected' },
]}
/>
<FilterSelect
label="Source"
value={source}
onChange={setSource}
options={[
{ value: 'all', label: 'All sources' },
{ value: 'admin', label: 'Admin created' },
{ value: 'self_register', label: 'Self-register' },
]}
/>
<ClearFiltersButton
visible={status !== 'all' || source !== 'all' || !!q}
onClick={() => {
filters={orgFilters}
onClearFilters={() => {
setQ('')
setStatus('all')
setSource('all')
}}
/>
</>
}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/organisations/new">
@ -116,49 +177,42 @@ export function OrganisationsPage() {
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-medium">{o.name}</TableCell>
<TableCell>{o.tin}</TableCell>
<TableCell>
<Badge variant="outline">{o.source}</Badge>
</TableCell>
<TableCell>{formatEtb(o.creditLimitEtb)}</TableCell>
<TableCell>{formatEtb(o.usedEtb)}</TableCell>
<TableCell>
<Badge
variant={
o.status === 'active' ? 'success' : o.status === 'pending' ? 'warning' : 'danger'
}
>
{o.status}
</Badge>
</TableCell>
<TableCell>
{o.status === 'pending' ? (
<RowActions
primary={[
{
label: t('common.approve'),
icon: <Check className="h-3.5 w-3.5" />,
onClick: () => approve(o.id, 'active'),
},
]}
items={[
{
label: t('common.reject'),
icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive',
onClick: () => approve(o.id, 'rejected'),
},
]}
/>
) : (
<span className="block pr-3 text-right text-xs text-muted-foreground">—</span>
)}
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
))}
) : null}
{filtered.map((o) => {
const actions = rowActions(o)
return (
<TableRow key={o.id}>
<TableCell className="font-medium">
<Link to={`/organisations/${o.id}`} className="text-brand-700 hover:underline">
{o.name}
</Link>
</TableCell>
<TableCell>{o.tin}</TableCell>
<TableCell>
<StatusBadge domain="organisation" status={o.source} />
</TableCell>
<TableCell>{formatEtb(o.creditLimitEtb)}</TableCell>
<TableCell>
<div className="space-y-1">
<p>{formatEtb(o.usedEtb)}</p>
<Progress value={o.creditLimitEtb ? (o.usedEtb / o.creditLimitEtb) * 100 : 0} className="h-1.5" />
</div>
</TableCell>
<TableCell>
<StatusBadge domain="organisation" status={o.status} />
</TableCell>
<TableCell>
<RowActions primary={actions.primary} items={actions.items} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>

View File

@ -0,0 +1,85 @@
/**
* Printable loyalty QR codes — the payloads the counter scanner actually reads.
*/
import { useMemo, useState } from 'react'
import { Printer } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { PersonAvatar } from '@/components/ui/avatar'
import { customers } from '@/mocks/data'
import { customerQrPayload } from '@/pages/pos/posEngine'
import { QrImage } from '@/pages/pos/QrImage'
import { useAuthStore } from '@/zustand/authStore'
import { branches } from '@/mocks/data'
export function CustomerCodesPanel() {
const user = useAuthStore((s) => s.user)
const [q, setQ] = useState('')
const scopedZone = user?.branchId ? branches.find((b) => b.id === user.branchId)?.zone : undefined
const list = useMemo(() => {
let rows = scopedZone ? customers.filter((c) => c.branch === scopedZone) : customers
if (q) {
const needle = q.toLowerCase()
rows = rows.filter(
(c) =>
c.name.toLowerCase().includes(needle) ||
c.phone.includes(needle) ||
c.id.toLowerCase().includes(needle),
)
}
return rows
}, [scopedZone, q])
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Each code encodes <code className="rounded bg-muted px-1">GISHEN-CUST:&lt;id&gt;</code>. Scan it
on Quick purchase, or type the code when the camera is unavailable.
</p>
<Input
className="h-10 max-w-sm"
placeholder="Search customer…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{list.map((customer) => {
const payload = customerQrPayload(customer.id)
return (
<Card key={customer.id}>
<CardHeader className="flex-row items-center gap-3 space-y-0 pb-2">
<PersonAvatar name={customer.name} avatarUrl={customer.avatarUrl} size="md" />
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base">{customer.name}</CardTitle>
<p className="text-xs text-muted-foreground">{customer.phone}</p>
</div>
<Badge variant="secondary">{customer.tier}</Badge>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3 pt-2">
<QrImage value={payload} size={160} alt={`Loyalty QR for ${customer.name}`} />
<code className="break-all text-center text-[11px] text-muted-foreground">{payload}</code>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-1.5"
onClick={() => window.print()}
>
<Printer className="h-3.5 w-3.5" />
Print
</Button>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,614 @@
/**
* Invoice scan → purchase association.
*
* Scan a supplier invoice's QR, upload a photo, or pick a sample. The reader
* runs an assisted extraction (simulated — there is no OCR service offline)
* and resolves into a pre-filled, editable line-item table. Nothing is written
* until the operator confirms, and the UI says so in as many words: the
* extraction is a machine read, the operator is the authority.
*
* Confirming links the invoice to an existing record — a procurement request,
* a supplier account or a branch stock arrival — so a paper invoice can be
* reconciled against what the console already knows was ordered.
*/
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'sonner'
import {
AlertTriangle,
Building2,
CheckCircle2,
FileScan,
Link2,
PackageSearch,
ScanText,
Trash2,
Truck,
Upload,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { EmptyState } from '@/components/ui/empty-state'
import { IconTile } from '@/components/ui/icon-tile'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { useAuthStore } from '@/zustand/authStore'
import { formatDate, formatEtb, formatRelative } from '@/lib/utils'
import {
branches,
invoiceSuppliers,
procurementRequests,
purchaseAssociations,
scannedInvoiceFixtures,
stockRows,
type PurchaseAssociation,
type PurchaseAssociationKind,
type ScannedInvoice,
type ScannedInvoiceLine,
} from '@/mocks/data'
import { ScanPad } from '@/pages/pos/ScanPad'
const VAT_RATE = 0.15
/** Stages shown while the assisted read runs, in order. */
const STAGES = [
'Capturing the page',
'Detecting the invoice layout',
'Reading line items',
'Matching printed codes to the catalogue',
]
const KIND_META: Record<PurchaseAssociationKind, { label: string; hint: string }> = {
procurement: { label: 'Procurement request', hint: 'Reconcile the delivery against what was ordered' },
supplier: { label: 'Supplier account', hint: 'File under the supplier statement for finance' },
branch: { label: 'Branch stock arrival', hint: 'Goods received at a branch with no open request' },
}
/** Unique catalogue SKUs, so an unmatched line can be mapped by hand. */
function catalogueOptions() {
const seen = new Map<string, string>()
for (const row of stockRows) if (!seen.has(row.sku)) seen.set(row.sku, row.name)
return [...seen.entries()].map(([sku, name]) => ({ sku, name }))
}
function findInvoice(reference: string): ScannedInvoice | undefined {
const needle = reference.trim().toLowerCase().replace(/^gishen-inv[:/-]/, '')
if (!needle) return undefined
return scannedInvoiceFixtures.find(
(invoice) =>
invoice.id.toLowerCase() === needle ||
invoice.invoiceNo.toLowerCase() === needle ||
invoice.supplierName.toLowerCase().includes(needle),
)
}
export function InvoiceScanPanel() {
const user = useAuthStore((s) => s.user)
const [progress, setProgress] = useState<number | null>(null)
const [invoice, setInvoice] = useState<ScannedInvoice | null>(null)
const [lines, setLines] = useState<ScannedInvoiceLine[]>([])
const [origin, setOrigin] = useState<string>('')
const [notFound, setNotFound] = useState<string | null>(null)
const [linkKind, setLinkKind] = useState<PurchaseAssociationKind>('procurement')
const [linkId, setLinkId] = useState('')
const [flagged, setFlagged] = useState(false)
const [associations, setAssociations] = useState<PurchaseAssociation[]>(() => [...purchaseAssociations])
const catalogue = useMemo(catalogueOptions, [])
// Drive the assisted-read progress bar; always cleaned up so a mid-read tab
// change cannot leave a timer running.
useEffect(() => {
if (progress === null || progress >= 100) return
const timer = window.setTimeout(() => setProgress((prev) => Math.min(100, (prev ?? 0) + 8)), 90)
return () => window.clearTimeout(timer)
}, [progress])
const readInvoice = (target: ScannedInvoice, source: string) => {
setNotFound(null)
setInvoice(null)
setLines([])
setOrigin(source)
setProgress(0)
window.setTimeout(() => {
setInvoice(target)
setLines(target.lines.map((line) => ({ ...line })))
setLinkKind(target.suggestedLink?.kind ?? 'procurement')
setLinkId(target.suggestedLink?.id ?? '')
setFlagged(false)
}, 1250)
}
const onCode = (code: string, scanSource: string) => {
const match = findInvoice(code)
if (!match) {
setNotFound(code)
setInvoice(null)
return
}
readInvoice(match, scanSource === 'camera' ? 'Invoice QR scan' : `Reference lookup · ${code}`)
}
const onUpload = (file: File | undefined) => {
if (!file) return
// No OCR offline: the upload seeds a fixture so the flow stays honest about
// being assisted, while the operator still confirms every figure.
const index = file.name.length % scannedInvoiceFixtures.length
readInvoice(scannedInvoiceFixtures[index], `Upload · ${file.name}`)
}
const readSubtotal = lines.reduce((sum, line) => sum + line.qty * line.unitEtb, 0)
const readVat = Math.round(readSubtotal * VAT_RATE)
const readTotal = readSubtotal + readVat
const printedDelta = invoice ? readSubtotal - invoice.subtotalEtb : 0
const unmatched = lines.filter((line) => !line.matchedSku || !line.sku)
const lowConfidence = lines.filter((line) => line.confidence < 0.8)
const linkOptions = useMemo(() => {
if (linkKind === 'procurement') {
return procurementRequests.map((request) => ({
value: request.id,
label: `${request.id} · ${request.name} × ${request.qty}`,
}))
}
if (linkKind === 'supplier') {
return invoiceSuppliers.map((supplier) => ({
value: supplier.id,
label: `${supplier.name} · TIN ${supplier.tin}`,
}))
}
return branches.map((branch) => ({ value: branch.id, label: `${branch.name} · stock arrival` }))
}, [linkKind])
const updateLine = (id: string, patch: Partial<ScannedInvoiceLine>) => {
setLines((prev) => prev.map((line) => (line.id === id ? { ...line, ...patch } : line)))
}
const confirmAssociation = () => {
if (!invoice || !linkId || unmatched.length > 0) return
const association: PurchaseAssociation = {
id: `pas-${Date.now().toString().slice(-4)}`,
invoiceId: invoice.id,
invoiceNo: invoice.invoiceNo,
supplierName: invoice.supplierName,
linkKind,
linkId,
linkLabel: linkOptions.find((option) => option.value === linkId)?.label ?? linkId,
totalEtb: readTotal,
lineCount: lines.length,
createdAt: new Date().toISOString(),
createdBy: user?.name ?? 'Counter staff',
status: flagged ? 'review' : 'linked',
}
purchaseAssociations.unshift(association)
setAssociations((prev) => [association, ...prev])
setInvoice(null)
setLines([])
setProgress(null)
toast.success(`${invoice.invoiceNo} associated`, {
description: `${KIND_META[linkKind].label} · ${association.linkLabel}`,
})
}
const reading = progress !== null && !invoice
return (
<div className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Capture an invoice</CardTitle>
<p className="text-sm text-muted-foreground">
Scan the invoice QR, photograph the page, or open a sample to walk the flow through.
</p>
</CardHeader>
<CardContent className="space-y-4">
<ScanPad
onCode={onCode}
manualLabel="Invoice reference"
manualPlaceholder="EPI-2026-4471 or sinv-2001"
manualHint="Matches on invoice number, internal id or supplier name."
simulateLabel="Open a sample invoice"
simulateOptions={scannedInvoiceFixtures.map((fixture) => ({
label: fixture.invoiceNo,
hint: `${fixture.supplierName} · ${fixture.lines.length} lines`,
value: fixture.id,
}))}
simulateHint="Three fixtures: a clean read, one with an unknown code, one with a soft total mismatch."
/>
<Separator />
<div className="space-y-1.5">
<Label htmlFor="invoice-file" className="flex items-center gap-1.5">
<Upload className="h-3.5 w-3.5 text-grayScale-500" aria-hidden="true" />
Upload a photo or PDF
</Label>
<Input
id="invoice-file"
type="file"
accept="image/*,application/pdf"
className="h-11 cursor-pointer py-2.5"
onChange={(event) => onUpload(event.target.files?.[0])}
/>
<p className="text-xs text-muted-foreground">
Extraction runs on-device in this demo — nothing leaves the browser.
</p>
</div>
{notFound ? (
<div className="rounded-xl border border-orange/40 bg-orange/5 p-3 text-sm">
<p className="font-semibold text-forest">No invoice matched</p>
<p className="mt-0.5 break-all text-xs text-muted-foreground">
<code>{notFound}</code> is not a known invoice reference. Upload the page instead, or open a sample.
</p>
</div>
) : null}
</CardContent>
</Card>
<div className="space-y-4">
{reading ? (
<Card>
<CardContent className="space-y-4 p-5">
<div className="flex items-center gap-3">
<IconTile icon={ScanText} tone="orange" />
<div>
<p className="font-display text-base font-semibold text-forest">Reading invoice…</p>
<p className="text-xs text-muted-foreground">{origin}</p>
</div>
</div>
<Progress value={progress ?? 0} tone="orange" />
<ul className="space-y-1.5 text-sm">
{STAGES.map((stage, index) => {
const threshold = ((index + 1) / STAGES.length) * 100
const done = (progress ?? 0) >= threshold
return (
<li
key={stage}
className={done ? 'flex items-center gap-2 text-forest' : 'flex items-center gap-2 text-muted-foreground'}
>
<CheckCircle2
className={done ? 'h-4 w-4 text-brand-700' : 'h-4 w-4 text-grayScale-300'}
aria-hidden="true"
/>
{stage}
</li>
)
})}
</ul>
</CardContent>
</Card>
) : invoice ? (
<>
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div>
<CardTitle>{invoice.invoiceNo}</CardTitle>
<p className="text-sm text-muted-foreground">
{invoice.supplierName} · issued {formatDate(invoice.invoiceDate)} · {origin}
</p>
</div>
<Badge variant="warning">Assisted read</Badge>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-xl border border-orange/40 bg-orange/5 p-3 text-xs text-muted-foreground">
<strong className="text-forest">Extraction is assisted, not authoritative.</strong> Quantities and
prices below were read off the page. Correct anything wrong — nothing is written until you confirm
the association.
</div>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>SKU</TableHead>
<TableHead>Item read</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Unit</TableHead>
<TableHead>Line</TableHead>
<TableHead className="text-right">Drop</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.id} className="align-top">
<TableCell className="min-w-[168px]">
{line.matchedSku && line.sku ? (
<span className="font-medium text-forest">{line.sku}</span>
) : (
<SelectNative
className="h-9"
value={line.sku}
onChange={(event) =>
updateLine(line.id, {
sku: event.target.value,
matchedSku: !!event.target.value,
name:
catalogue.find((item) => item.sku === event.target.value)?.name ?? line.name,
})
}
aria-label={`Map ${line.name} to a catalogue SKU`}
>
<option value="">Unmatched — pick a SKU</option>
{catalogue.map((item) => (
<option key={item.sku} value={item.sku}>
{item.sku} · {item.name}
</option>
))}
</SelectNative>
)}
</TableCell>
<TableCell>
<p className="text-sm">{line.name}</p>
<div className="mt-1 flex flex-wrap gap-1.5">
<Badge variant={line.confidence < 0.8 ? 'warning' : 'muted'}>
{Math.round(line.confidence * 100)}% confidence
</Badge>
{!line.matchedSku || !line.sku ? <Badge variant="danger">No catalogue match</Badge> : null}
</div>
</TableCell>
<TableCell>
<Input
className="h-9 w-20"
type="number"
min={0}
value={line.qty}
onChange={(event) => updateLine(line.id, { qty: Number(event.target.value) })}
aria-label={`Quantity for ${line.name}`}
/>
</TableCell>
<TableCell>
<Input
className="h-9 w-24"
type="number"
min={0}
value={line.unitEtb}
onChange={(event) => updateLine(line.id, { unitEtb: Number(event.target.value) })}
aria-label={`Unit price for ${line.name}`}
/>
</TableCell>
<TableCell className="whitespace-nowrap font-medium">
{formatEtb(line.qty * line.unitEtb)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setLines((prev) => prev.filter((item) => item.id !== line.id))}
aria-label={`Remove ${line.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5 rounded-xl border bg-cream/60 p-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Sum of read lines</span>
<span className="font-medium">{formatEtb(readSubtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">VAT (15%)</span>
<span className="font-medium">{formatEtb(readVat)}</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="font-semibold text-forest">Total</span>
<span className="font-display font-bold text-forest">{formatEtb(readTotal)}</span>
</div>
</div>
<div className="space-y-2 rounded-xl border p-3 text-xs">
<p className="text-muted-foreground">
Printed subtotal on the page: <strong>{formatEtb(invoice.subtotalEtb)}</strong>
</p>
{printedDelta === 0 ? (
<p className="flex items-center gap-1.5 text-brand-700">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
Read lines reconcile with the printed subtotal.
</p>
) : (
<p className="flex items-start gap-1.5 text-orange-deep">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
Off by {formatEtb(Math.abs(printedDelta))} against the printed subtotal — check quantities
before confirming.
</p>
)}
{lowConfidence.length > 0 ? (
<p className="text-muted-foreground">
{lowConfidence.length} line{lowConfidence.length === 1 ? '' : 's'} read with low confidence.
</p>
) : null}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Associate this purchase</CardTitle>
<p className="text-sm text-muted-foreground">
Attach the paperwork to a record so stock arrivals reconcile against it.
</p>
</CardHeader>
<CardContent className="space-y-4">
{invoice.suggestedLink ? (
<div className="flex items-start gap-2 rounded-xl border border-brand-200 bg-brand-50 p-3 text-xs">
<Link2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand-700" aria-hidden="true" />
<span>
Suggested from the read: <strong>{invoice.suggestedLink.label}</strong>. Change it if the
suggestion is wrong.
</span>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="link-kind">Associate with</Label>
<SelectNative
id="link-kind"
value={linkKind}
onChange={(event) => {
setLinkKind(event.target.value as PurchaseAssociationKind)
setLinkId('')
}}
>
{(Object.keys(KIND_META) as PurchaseAssociationKind[]).map((kind) => (
<option key={kind} value={kind}>
{KIND_META[kind].label}
</option>
))}
</SelectNative>
<p className="text-xs text-muted-foreground">{KIND_META[linkKind].hint}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="link-target">Record</Label>
<SelectNative
id="link-target"
value={linkId}
onChange={(event) => setLinkId(event.target.value)}
>
<option value="">Choose a record…</option>
{linkOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</SelectNative>
</div>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-input accent-brand-500"
checked={flagged}
onChange={(event) => setFlagged(event.target.checked)}
/>
<span>
Flag for finance review
<span className="block text-xs text-muted-foreground">
Use when a price or quantity still looks wrong after your corrections.
</span>
</span>
</label>
{unmatched.length > 0 ? (
<div className="rounded-xl border border-red-200 bg-red-50 p-3 text-xs text-red-700">
{unmatched.length} line{unmatched.length === 1 ? '' : 's'} still have no catalogue SKU. Map or drop
them before confirming.
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button
className="h-11 gap-2 shadow-none"
onClick={confirmAssociation}
disabled={!linkId || unmatched.length > 0 || lines.length === 0}
>
<Link2 className="h-4 w-4" />
Confirm association
</Button>
<Button
variant="outline"
className="h-11"
onClick={() => {
setInvoice(null)
setLines([])
setProgress(null)
}}
>
Discard read
</Button>
</div>
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="p-5">
<EmptyState
icon={FileScan}
variant="dashed"
title="No invoice loaded"
description="Scan an invoice QR, upload the page, or open one of the sample invoices to see the assisted read."
/>
</CardContent>
</Card>
)}
</div>
</div>
<DetailSection
title="Purchase associations"
description="Invoices already linked to a request, a supplier or a branch arrival"
>
{associations.length === 0 ? (
<EmptyState icon={Link2} title="Nothing associated yet" />
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Invoice</TableHead>
<TableHead>Supplier</TableHead>
<TableHead>Linked to</TableHead>
<TableHead>Lines</TableHead>
<TableHead>Total</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{associations.map((association) => (
<TableRow key={association.id}>
<TableCell className="font-medium">{association.invoiceNo}</TableCell>
<TableCell>{association.supplierName}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{association.linkKind === 'procurement' ? (
<PackageSearch className="h-3.5 w-3.5 text-grayScale-500" aria-hidden="true" />
) : association.linkKind === 'supplier' ? (
<Truck className="h-3.5 w-3.5 text-grayScale-500" aria-hidden="true" />
) : (
<Building2 className="h-3.5 w-3.5 text-grayScale-500" aria-hidden="true" />
)}
{association.linkKind === 'procurement' ? (
<Link to={`/procurement/${association.linkId}`} className="text-brand-700 hover:underline">
{association.linkLabel}
</Link>
) : (
<span>{association.linkLabel}</span>
)}
</div>
</TableCell>
<TableCell>{association.lineCount}</TableCell>
<TableCell>{formatEtb(association.totalEtb)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatRelative(association.createdAt)} · {association.createdBy}
</TableCell>
<TableCell>
<Badge variant={association.status === 'linked' ? 'success' : 'warning'}>
{association.status === 'linked' ? 'Linked' : 'Finance review'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</DetailSection>
</div>
)
}

50
src/pages/pos/PosPage.tsx Normal file
View File

@ -0,0 +1,50 @@
/**
* Counter POS / scan console — shell for quick purchase, invoice association,
* inbound tickets, and printable customer loyalty codes.
*/
import { useState } from 'react'
import { PageShell } from '@/layouts/PageShell'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { QuickPurchasePanel } from '@/pages/pos/QuickPurchasePanel'
import { InvoiceScanPanel } from '@/pages/pos/InvoiceScanPanel'
import { TicketQueuePanel } from '@/pages/pos/TicketQueuePanel'
import { CustomerCodesPanel } from '@/pages/pos/CustomerCodesPanel'
import type { PurchaseTicket } from '@/mocks/data'
export function PosPage() {
const [tab, setTab] = useState('purchase')
const [handoff, setHandoff] = useState<PurchaseTicket | null>(null)
return (
<PageShell
title="Scan & quick purchase"
subtitle="Identify a loyalty customer by QR, clear the basket, or reconcile a supplier invoice."
>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="purchase">Quick purchase</TabsTrigger>
<TabsTrigger value="invoice">Invoice scan</TabsTrigger>
<TabsTrigger value="tickets">Ticket queue</TabsTrigger>
<TabsTrigger value="codes">Customer codes</TabsTrigger>
</TabsList>
<TabsContent value="purchase" className="mt-4">
<QuickPurchasePanel handoff={handoff} onHandoffConsumed={() => setHandoff(null)} />
</TabsContent>
<TabsContent value="invoice" className="mt-4">
<InvoiceScanPanel />
</TabsContent>
<TabsContent value="tickets" className="mt-4">
<TicketQueuePanel
onAccept={(ticket) => {
setHandoff(ticket)
setTab('purchase')
}}
/>
</TabsContent>
<TabsContent value="codes" className="mt-4">
<CustomerCodesPanel />
</TabsContent>
</Tabs>
</PageShell>
)
}

59
src/pages/pos/QrImage.tsx Normal file
View File

@ -0,0 +1,59 @@
/**
* Renders a payload as a QR code PNG. Drawn client-side with `qrcode`, in
* forest ink on white so it survives being photocopied onto a loyalty card.
*/
import { useEffect, useState } from 'react'
import QRCode from 'qrcode'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
export function QrImage({
value,
size = 148,
alt,
className,
}: {
value: string
size?: number
alt: string
className?: string
}) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
let active = true
QRCode.toDataURL(value, {
width: size * 2,
margin: 1,
errorCorrectionLevel: 'M',
color: { dark: '#141414ff', light: '#ffffffff' },
})
.then((url) => {
if (active) setSrc(url)
})
.catch(() => {
if (active) setSrc(null)
})
return () => {
active = false
}
}, [value, size])
if (!src) {
return (
<div style={{ width: size, height: size }} className={className}>
<Skeleton className="h-full w-full" />
</div>
)
}
return (
<img
src={src}
alt={alt}
width={size}
height={size}
className={cn('rounded-xl bg-white', className)}
/>
)
}

View File

@ -0,0 +1,873 @@
/**
* Scan to purchase — the counter flow.
*
* Scan a customer's loyalty QR, confirm it is the right person, add what they
* are buying, clear any dispensing gate, confirm. Confirming writes a real
* order, a loyalty award and a stock movement into the mocks, then offers the
* receipt and a reset so the counter loops onto the next customer.
*/
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'sonner'
import {
AlertTriangle,
BadgeCheck,
CheckCircle2,
FileHeart,
Minus,
Package,
Plus,
Printer,
Receipt,
RotateCcw,
ScanLine,
ShieldAlert,
Trash2,
UserPlus,
UserRound,
X,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Separator } from '@/components/ui/separator'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { EmptyState } from '@/components/ui/empty-state'
import { IconTile } from '@/components/ui/icon-tile'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { useAuthStore } from '@/zustand/authStore'
import { formatDateTime, formatEtb, formatRelative } from '@/lib/utils'
import {
auditLog,
branches,
counterScanHistory,
customers,
type CounterScan,
type Customer,
type PurchaseTicket,
type ScanSource,
} from '@/mocks/data'
import type { Order } from '@/types'
import { QrImage } from '@/pages/pos/QrImage'
import { ScanPad } from '@/pages/pos/ScanPad'
import { commitPurchase, shortBranchName } from '@/pages/pos/commitPurchase'
import {
blockingIssues,
cartTotal,
customerQrPayload,
findStock,
gateFor,
isShort,
lineTotal,
pointsForPurchase,
resolveScannedCode,
searchBranchStock,
type CartLine,
} from '@/pages/pos/posEngine'
function tierVariant(tier: string) {
if (tier === 'Gold') return 'warning' as const
if (tier === 'Silver') return 'secondary' as const
return 'muted' as const
}
type CounterReceipt = {
order: Order
lines: CartLine[]
points: number
customer: Customer
}
export function QuickPurchasePanel({
handoff,
onHandoffConsumed,
}: {
/** A ticket sent over from the queue for an operator to clear and confirm. */
handoff?: PurchaseTicket | null
onHandoffConsumed?: () => void
}) {
const user = useAuthStore((s) => s.user)
const role = user?.role
const [branchId, setBranchId] = useState(user?.branchId ?? branches[0]?.id ?? '')
const [customer, setCustomer] = useState<Customer | null>(null)
const [unknownCode, setUnknownCode] = useState<string | null>(null)
const [scanSource, setScanSource] = useState<ScanSource>('manual')
const [lines, setLines] = useState<CartLine[]>([])
const [query, setQuery] = useState('')
const [receipt, setReceipt] = useState<CounterReceipt | null>(null)
const [overrideSku, setOverrideSku] = useState<string | null>(null)
const [overrideReason, setOverrideReason] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [draft, setDraft] = useState({ name: '', phone: '' })
const [history, setHistory] = useState<CounterScan[]>(() => counterScanHistory.slice(0, 6))
const suggestions = useMemo(() => searchBranchStock(branchId, query), [branchId, query])
const total = cartTotal(lines)
const points = customer ? pointsForPurchase(total, customer.tier) : 0
const issues = customer ? blockingIssues(lines, customer.name, role) : []
/** Ticket-queue accept lands here with a pre-filled customer + basket. */
useEffect(() => {
if (!handoff) return
const match =
(handoff.customerId ? customers.find((c) => c.id === handoff.customerId) : undefined) ??
(handoff.phone ? customers.find((c) => c.phone === handoff.phone) : undefined) ??
null
setBranchId(handoff.branchId || branchId)
setReceipt(null)
setUnknownCode(null)
setScanSource('manual')
if (match) {
setCustomer(match)
} else {
setCustomer(null)
setUnknownCode(handoff.phone ?? handoff.externalRef)
}
setLines(
handoff.lines.map((line) => {
const stock = findStock(handoff.branchId || branchId, line.sku)
return {
sku: line.sku,
name: line.name,
qty: line.qty,
unitEtb: line.unitEtb,
available: stock?.qty ?? 0,
}
}),
)
onHandoffConsumed?.()
toast.message(`Ticket ${handoff.externalRef} loaded`, {
description: match
? `${match.name} · confirm the basket`
: 'Resolve the customer, then confirm the basket',
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- consume each handoff once when identity changes
}, [handoff?.id])
const logScan = (entry: CounterScan) => {
counterScanHistory.unshift(entry)
setHistory((prev) => [entry, ...prev].slice(0, 6))
}
const onCode = (code: string, source: ScanSource) => {
const { customer: match } = resolveScannedCode(code)
setScanSource(source)
if (!match) {
setUnknownCode(code)
setCustomer(null)
logScan({
id: `scn-${Date.now()}`,
at: new Date().toISOString(),
code,
customerName: 'Not recognised',
source,
outcome: 'unknown',
branchId,
staff: user?.name ?? 'Counter staff',
})
return
}
setUnknownCode(null)
setCustomer(match)
setLines([])
setReceipt(null)
toast.success(`${match.name} identified`, { description: `${match.tier} tier · ${match.points} points` })
}
const resetCounter = () => {
setCustomer(null)
setUnknownCode(null)
setLines([])
setQuery('')
setReceipt(null)
}
const addLine = (sku: string) => {
const row = findStock(branchId, sku)
if (!row) return
setQuery('')
setLines((prev) => {
const existing = prev.find((line) => line.sku === sku)
if (existing) {
return prev.map((line) => (line.sku === sku ? { ...line, qty: line.qty + 1 } : line))
}
return [
...prev,
{ sku: row.sku, name: row.name, qty: 1, unitEtb: row.unitEtb, available: row.qty },
]
})
}
const setQty = (sku: string, qty: number) => {
setLines((prev) =>
prev.map((line) => (line.sku === sku ? { ...line, qty: Math.max(1, qty) } : line)),
)
}
const removeLine = (sku: string) => setLines((prev) => prev.filter((line) => line.sku !== sku))
const saveOverride = (event: FormEvent) => {
event.preventDefault()
const reason = overrideReason.trim()
if (!overrideSku || !reason) return
setLines((prev) =>
prev.map((line) => (line.sku === overrideSku ? { ...line, overrideReason: reason } : line)),
)
auditLog.unshift({
id: `a-${Date.now().toString().slice(-4)}`,
at: new Date().toISOString(),
actor: user?.name ?? 'Counter staff',
actorRole: role ?? 'pharmacist',
action: 'pos.emergency_supply',
category: 'prescriptions',
target: overrideSku,
targetLabel: `${customer?.name ?? 'Walk-in'} · ${overrideSku}`,
ip: '196.189.44.12',
userAgent: navigator.userAgent,
outcome: 'success',
security: true,
note: reason,
})
toast.success('Emergency supply recorded', { description: 'Logged to the security review queue' })
setOverrideSku(null)
setOverrideReason('')
}
const createCustomer = (event: FormEvent) => {
event.preventDefault()
const name = draft.name.trim()
const phone = draft.phone.trim()
if (!name || !phone) return
const created: Customer = {
id: `c${customers.length + 1}`,
name,
phone,
tier: 'Bronze',
points: 0,
branch: shortBranchName(branchId),
joinedAt: new Date().toISOString(),
orders: 0,
spendEtb: 0,
lastOrderAt: null,
}
customers.push(created)
setCustomer(created)
setUnknownCode(null)
setLines([])
setCreateOpen(false)
setDraft({ name: '', phone: '' })
toast.success(`${name} enrolled`, { description: 'A loyalty QR code is ready in Customer codes' })
}
const confirmPurchase = () => {
if (!customer || lines.length === 0 || issues.length > 0) return
const { order, points: awarded, scan } = commitPurchase({
customer,
branchId,
lines,
role,
source: scanSource,
staffName: user?.name ?? 'Counter staff',
})
setHistory((prev) => [scan, ...prev].slice(0, 6))
setReceipt({ order, lines, points: awarded, customer })
setLines([])
toast.success(`${order.id} confirmed`, {
description: `${formatEtb(total)} · ${awarded} points awarded to ${customer.name}`,
})
}
// ── Receipt ───────────────────────────────────────────────────────────────
if (receipt) {
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card>
<CardHeader className="flex-row items-center gap-3">
<IconTile icon={CheckCircle2} tone="brand" />
<div>
<CardTitle>Purchase confirmed</CardTitle>
<p className="text-sm text-muted-foreground">
{receipt.order.id} · {formatDateTime(receipt.order.createdAt)}
</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-xl border bg-cream/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="font-display text-base font-semibold text-forest">{receipt.customer.name}</p>
<p className="text-xs text-muted-foreground">
{receipt.customer.phone} · {shortBranchName(receipt.order.branchId)}
</p>
</div>
<div className="text-right">
<p className="font-display text-xl font-bold text-forest">{formatEtb(receipt.order.totalEtb)}</p>
<p className="text-xs text-brand-700">+{receipt.points} loyalty points</p>
</div>
</div>
</div>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>SKU</TableHead>
<TableHead>Item</TableHead>
<TableHead>Qty</TableHead>
<TableHead className="text-right">Line total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{receipt.lines.map((line) => (
<TableRow key={line.sku}>
<TableCell className="font-medium">{line.sku}</TableCell>
<TableCell>{line.name}</TableCell>
<TableCell>{line.qty}</TableCell>
<TableCell className="text-right">{formatEtb(lineTotal(line))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex flex-wrap gap-2">
<Button className="h-10 gap-2 shadow-none" onClick={resetCounter}>
<ScanLine className="h-4 w-4" />
Next customer
</Button>
<Button variant="outline" className="h-10 gap-2" onClick={() => window.print()}>
<Printer className="h-4 w-4" />
Print receipt
</Button>
<Button asChild variant="secondary" className="h-10 gap-2">
<Link to={`/orders/${receipt.order.id}`}>
<Receipt className="h-4 w-4" />
Open order
</Link>
</Button>
</div>
</CardContent>
</Card>
<DetailSection title="What was written" description="Every mock this confirmation touched">
<ul className="space-y-2 text-sm">
<li className="flex gap-2">
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
<span>
Order <strong>{receipt.order.id}</strong> appended, paid, channel <strong>counter</strong>
</span>
</li>
<li className="flex gap-2">
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
<span>
Loyalty ledger credited <strong>+{receipt.points}</strong> points ({receipt.customer.tier} multiplier
applied)
</span>
</li>
<li className="flex gap-2">
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
<span>
Stock decremented at {shortBranchName(receipt.order.branchId)} with a sale movement per line
</span>
</li>
<li className="flex gap-2">
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
<span>Scan logged to the counter history below</span>
</li>
</ul>
</DetailSection>
</div>
)
}
// ── Scan / basket ─────────────────────────────────────────────────────────
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Identify the customer</CardTitle>
<p className="text-sm text-muted-foreground">
Scan their loyalty QR. No camera? Type the code or phone number, or simulate a scan.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="pos-branch">Selling branch</Label>
<SelectNative
id="pos-branch"
value={branchId}
onChange={(event) => {
setBranchId(event.target.value)
setLines([])
}}
disabled={role === 'pharmacist' && !!user?.branchId}
>
{branches.map((branch) => (
<option key={branch.id} value={branch.id}>
{branch.name}
</option>
))}
</SelectNative>
</div>
<Separator />
{customer ? (
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-xl border border-brand-200 bg-brand-50 p-4">
<IconTile icon={UserRound} tone="brand" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-display text-base font-semibold text-forest">{customer.name}</p>
<Badge variant={tierVariant(customer.tier)}>{customer.tier}</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{customer.phone} · {customer.id} · Home branch {customer.branch}
</p>
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div>
<dt className="text-muted-foreground">Points balance</dt>
<dd className="font-semibold text-forest">{customer.points.toLocaleString()}</dd>
</div>
<div>
<dt className="text-muted-foreground">Last order</dt>
<dd className="font-semibold text-forest">
{customer.lastOrderAt ? formatRelative(customer.lastOrderAt) : 'First visit'}
</dd>
</div>
</dl>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={resetCounter}
aria-label="Clear customer and scan again"
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Resolved from a {scanSource} scan. Confirm the name and phone with the customer before taking payment.
</p>
</div>
) : unknownCode ? (
<div className="rounded-xl border border-orange/40 bg-orange/5 p-4">
<div className="flex items-start gap-3">
<IconTile icon={AlertTriangle} tone="orange" size="sm" />
<div className="min-w-0">
<p className="text-sm font-semibold text-forest">Code not recognised</p>
<p className="mt-0.5 break-all text-xs text-muted-foreground">
<code>{unknownCode}</code> matches no customer record.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
className="h-9 gap-1.5 px-3 text-xs shadow-none"
onClick={() => {
setDraft({
name: '',
phone: /^\+?\d[\d\s-]{7,}$/.test(unknownCode) ? unknownCode : '',
})
setCreateOpen(true)
}}
>
<UserPlus className="h-3.5 w-3.5" />
Enrol this customer
</Button>
<Button
variant="outline"
className="h-9 px-3 text-xs"
onClick={() => setUnknownCode(null)}
>
Scan again
</Button>
</div>
</div>
</div>
</div>
) : (
<ScanPad
onCode={onCode}
manualLabel="Keypad entry"
manualPlaceholder="c1, GISHEN-CUST:c1 or +2519…"
manualHint="Accepts the printed code, a loyalty URL, a customer id or a phone number."
simulateOptions={customers.slice(0, 4).map((c) => ({
label: c.name.split(' ')[0],
hint: `${c.tier} · ${c.points} points`,
value: customerQrPayload(c.id),
}))}
simulateHint="No camera needed — picks a demo customer as if their code had been read."
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent counter scans</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{history.map((entry) => (
<div key={entry.id} className="flex items-center justify-between gap-3 rounded-xl border p-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-forest">{entry.customerName}</p>
<p className="text-xs text-muted-foreground">
{formatRelative(entry.at)} · {entry.source}
{entry.orderId ? ` · ${entry.orderId}` : ''}
</p>
</div>
<Badge variant={entry.outcome === 'resolved' ? 'success' : 'warning'}>{entry.outcome}</Badge>
</div>
))}
</CardContent>
</Card>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>What are they buying?</CardTitle>
<p className="text-sm text-muted-foreground">
Search the {shortBranchName(branchId)} shelf by SKU or name.
</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="pos-sku">Add item</Label>
<Input
id="pos-sku"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="AMX-500, paracetamol…"
disabled={!customer}
autoComplete="off"
/>
</div>
{suggestions.length > 0 ? (
<ul className="space-y-1.5">
{suggestions.map((row) => (
<li key={`${row.sku}-${row.branchId}`}>
<button
type="button"
className="flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-colors hover:bg-grayScale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
onClick={() => addLine(row.sku)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-forest">{row.name}</span>
<span className="block text-xs text-muted-foreground">
{row.sku} · {row.qty} on shelf · {formatEtb(row.unitEtb)}
</span>
</span>
<Plus className="h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
</button>
</li>
))}
</ul>
) : null}
{lines.length === 0 ? (
<EmptyState
icon={Package}
variant="dashed"
title={customer ? 'Basket is empty' : 'Waiting for a scan'}
description={
customer
? 'Search above to add what the customer is buying.'
: 'Identify the customer first — the basket unlocks once a code resolves.'
}
/>
) : (
<div className="space-y-3">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Item</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Unit</TableHead>
<TableHead>Line</TableHead>
<TableHead className="text-right">Remove</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const gate = customer
? gateFor({
sku: line.sku,
productName: line.name,
customerName: customer.name,
role,
})
: null
const short = isShort(line)
return (
<TableRow key={line.sku} className="align-top">
<TableCell>
<p className="font-medium text-forest">{line.name}</p>
<p className="text-xs text-muted-foreground">
{line.sku} · {line.available} on shelf
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{gate && gate.dispenseClass !== 'otc' ? (
<Badge
variant={
gate.state === 'rx_linked'
? 'success'
: gate.state === 'blocked'
? 'danger'
: 'warning'
}
>
{gate.dispenseClass === 'controlled' ? 'Controlled' : 'Rx only'}
</Badge>
) : null}
{short ? <Badge variant="danger">Insufficient stock</Badge> : null}
{line.overrideReason ? <Badge variant="warning">Emergency supply</Badge> : null}
</div>
{gate && gate.state !== 'clear' && !line.overrideReason ? (
<p
className={
gate.state === 'blocked'
? 'mt-1.5 text-xs text-red-700'
: 'mt-1.5 text-xs text-muted-foreground'
}
>
{gate.message}
</p>
) : null}
{short ? (
<p className="mt-1 text-xs text-red-700">
Only {line.available} available — reduce the quantity or transfer stock in.
</p>
) : null}
{gate?.state === 'needs_rx' && gate.overridable && !line.overrideReason ? (
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
className="h-8 gap-1.5 px-2.5 text-xs"
onClick={() => {
setOverrideSku(line.sku)
setOverrideReason('')
}}
>
<ShieldAlert className="h-3.5 w-3.5" />
Record emergency supply
</Button>
<Button asChild variant="ghost" className="h-8 gap-1.5 px-2.5 text-xs">
<Link to="/prescriptions/new">
<FileHeart className="h-3.5 w-3.5" />
Log an Rx
</Link>
</Button>
</div>
) : null}
{line.overrideReason ? (
<p className="mt-1.5 text-xs text-orange-deep">
Emergency supply: {line.overrideReason}
</p>
) : null}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setQty(line.sku, line.qty - 1)}
aria-label={`Decrease ${line.sku}`}
>
<Minus className="h-3.5 w-3.5" />
</Button>
<Input
className="h-8 w-14 text-center"
type="number"
min={1}
value={line.qty}
onChange={(event) => setQty(line.sku, Number(event.target.value))}
aria-label={`Quantity for ${line.sku}`}
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setQty(line.sku, line.qty + 1)}
aria-label={`Increase ${line.sku}`}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">{formatEtb(line.unitEtb)}</TableCell>
<TableCell className="whitespace-nowrap font-medium">
{formatEtb(lineTotal(line))}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeLine(line.sku)}
aria-label={`Remove ${line.sku}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 p-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total due</p>
<p className="font-display text-3xl font-bold text-forest">{formatEtb(total)}</p>
{customer ? (
<p className="mt-1 text-xs text-brand-700">
Earns {points} points · {customer.tier} multiplier on 1 point per 100 ETB
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
className="h-11 gap-2"
onClick={() => setLines([])}
disabled={lines.length === 0}
>
<RotateCcw className="h-4 w-4" />
Clear basket
</Button>
<Button
className="h-11 gap-2 shadow-none"
onClick={confirmPurchase}
disabled={!customer || lines.length === 0 || issues.length > 0}
>
<CheckCircle2 className="h-4 w-4" />
Confirm purchase
</Button>
</div>
</div>
{issues.length > 0 ? (
<div className="rounded-xl border border-red-200 bg-red-50 p-3">
<p className="flex items-center gap-1.5 text-sm font-semibold text-red-700">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
{issues.length} thing{issues.length === 1 ? '' : 's'} to resolve before confirming
</p>
<ul className="mt-1.5 space-y-1 text-xs text-red-700">
{issues.map((issue) => (
<li key={`${issue.sku}-${issue.label}`}>{issue.label}</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
</div>
<Dialog
open={!!overrideSku}
onOpenChange={(open) => {
if (!open) setOverrideSku(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Record an emergency supply</DialogTitle>
<DialogDescription>
Prescription-only item with no approved Rx on file. Your name, the reason and the SKU go to the security
review queue in the audit log.
</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={saveOverride}>
<div className="space-y-1.5">
<Label htmlFor="pos-override">Clinical reason</Label>
<Input
id="pos-override"
value={overrideReason}
onChange={(event) => setOverrideReason(event.target.value)}
placeholder="Repeat inhaler, prescriber contacted by phone…"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOverrideSku(null)}>
Cancel
</Button>
<Button type="submit" disabled={!overrideReason.trim()}>
Record and continue
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Enrol a new customer</DialogTitle>
<DialogDescription>
Creates a loyalty record on the spot and generates a QR code this tool can scan next time.
</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={createCustomer}>
<div className="space-y-1.5">
<Label htmlFor="pos-new-name">Full name</Label>
<Input
id="pos-new-name"
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="pos-new-phone">Phone</Label>
<Input
id="pos-new-phone"
value={draft.phone}
onChange={(event) => setDraft({ ...draft, phone: event.target.value })}
placeholder="+2519…"
/>
</div>
<div className="flex items-center gap-3 rounded-xl border bg-cream/60 p-3">
<QrImage value={customerQrPayload(`c${customers.length + 1}`)} size={72} alt="Preview of the new loyalty code" />
<p className="text-xs text-muted-foreground">
Their code will be <code>{customerQrPayload(`c${customers.length + 1}`)}</code> — printable from the
Customer codes tab.
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={!draft.name.trim() || !draft.phone.trim()}>
Enrol and continue
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

198
src/pages/pos/ScanPad.tsx Normal file
View File

@ -0,0 +1,198 @@
/**
* The scanning surface: a camera viewport plus the two paths that always work
* without one — keypad entry and a simulated scan.
*
* Camera is an enhancement, never a requirement. Every failure resolves to a
* specific, actionable message and the other two paths stay enabled, so the
* counter keeps serving and a reviewer can demo the flow on a desktop.
*/
import { useState, type FormEvent } from 'react'
import { Camera, CameraOff, Keyboard, ScanLine, Sparkles, VideoOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useQrScanner, type ScannerStatus } from '@/pages/pos/useQrScanner'
import type { ScanSource } from '@/mocks/data'
type FailureCopy = { title: string; detail: string }
const FAILURES: Record<string, FailureCopy> = {
denied: {
title: 'Camera permission refused',
detail:
'Allow camera access for this site in your browser settings and press Start camera again. Keypad entry below needs no permission.',
},
no_device: {
title: 'No camera found',
detail: 'This device has no video input. Use keypad entry, or a simulated scan to walk through the flow.',
},
busy: {
title: 'Camera is in use',
detail: 'Another application is holding the camera. Close it, then press Start camera again.',
},
insecure: {
title: 'Camera needs a secure connection',
detail: 'Browsers only release a camera over https or on localhost. Keypad entry works on any connection.',
},
unsupported: {
title: 'Camera unavailable in this browser',
detail: 'This browser does not expose a camera API. Use keypad entry or a simulated scan.',
},
error: {
title: 'Camera could not start',
detail: 'Something went wrong opening the video stream. Use keypad entry or a simulated scan.',
},
}
function statusBadge(status: ScannerStatus) {
if (status === 'scanning') return { label: 'Camera live', variant: 'success' as const }
if (status === 'starting') return { label: 'Starting…', variant: 'warning' as const }
if (status === 'idle') return { label: 'Camera off', variant: 'muted' as const }
return { label: 'Camera unavailable', variant: 'danger' as const }
}
export type SimulateOption = { label: string; hint?: string; value: string }
export function ScanPad({
onCode,
manualLabel,
manualPlaceholder,
manualHint,
simulateOptions,
simulateLabel = 'Simulate a scan',
simulateHint,
className,
}: {
onCode: (code: string, source: ScanSource) => void
manualLabel: string
manualPlaceholder: string
manualHint?: string
simulateOptions: SimulateOption[]
simulateLabel?: string
simulateHint?: string
className?: string
}) {
const [manual, setManual] = useState('')
const { videoRef, status, start, stop, isLive } = useQrScanner((text) => {
stop()
onCode(text, 'camera')
})
const failure = FAILURES[status]
const badge = statusBadge(status)
const submitManual = (event: FormEvent) => {
event.preventDefault()
const value = manual.trim()
if (!value) return
onCode(value, 'manual')
setManual('')
}
return (
<div className={cn('space-y-4', className)}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ScanLine className="h-4 w-4 text-brand-700" aria-hidden="true" />
<p className="text-sm font-semibold text-forest">Scan</p>
</div>
<Badge variant={badge.variant}>{badge.label}</Badge>
</div>
<div className="relative overflow-hidden rounded-xl border bg-forest/[0.04]">
<video
ref={videoRef}
className={cn('aspect-video w-full bg-forest object-cover', !isLive && 'hidden')}
playsInline
muted
/>
{isLive ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<div className="h-40 w-40 rounded-xl border-2 border-brand-500/90 shadow-[0_0_0_9999px_rgba(20,20,20,0.35)]" />
</div>
) : (
<div className="flex aspect-video w-full flex-col items-center justify-center gap-2 px-6 text-center">
{failure ? (
<>
<CameraOff className="h-6 w-6 text-red-700" aria-hidden="true" />
<p className="text-sm font-semibold text-forest">{failure.title}</p>
<p className="max-w-sm text-xs text-muted-foreground">{failure.detail}</p>
</>
) : (
<>
<Camera className="h-6 w-6 text-grayScale-400" aria-hidden="true" />
<p className="text-sm font-semibold text-forest">Camera is off</p>
<p className="max-w-sm text-xs text-muted-foreground">
Point the customer&rsquo;s loyalty QR at the camera, or use one of the paths below.
</p>
</>
)}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
{isLive ? (
<Button type="button" variant="outline" className="h-10 gap-2" onClick={stop}>
<VideoOff className="h-4 w-4" />
Stop camera
</Button>
) : (
<Button type="button" className="h-10 gap-2 shadow-none" onClick={start}>
<Camera className="h-4 w-4" />
Start camera
</Button>
)}
</div>
<div className="grid gap-4 rounded-xl border bg-cream/60 p-4 md:grid-cols-2">
<form className="space-y-2" onSubmit={submitManual}>
<Label htmlFor="scan-manual" className="flex items-center gap-1.5">
<Keyboard className="h-3.5 w-3.5 text-grayScale-500" aria-hidden="true" />
{manualLabel}
</Label>
<div className="flex gap-2">
<Input
id="scan-manual"
value={manual}
onChange={(event) => setManual(event.target.value)}
placeholder={manualPlaceholder}
autoComplete="off"
/>
<Button type="submit" variant="secondary" className="h-11 shrink-0">
Look up
</Button>
</div>
{manualHint ? <p className="text-xs text-muted-foreground">{manualHint}</p> : null}
</form>
<div className="space-y-2">
<p className="flex items-center gap-1.5 text-sm font-medium leading-none">
<Sparkles className="h-3.5 w-3.5 text-orange" aria-hidden="true" />
{simulateLabel}
</p>
<div className="flex flex-wrap gap-2">
{simulateOptions.map((option) => (
<Button
key={option.value}
type="button"
variant="outline"
className="h-9 px-3 text-xs"
onClick={() => onCode(option.value, 'simulated')}
title={option.hint}
>
{option.label}
</Button>
))}
</div>
{simulateHint ? <p className="text-xs text-muted-foreground">{simulateHint}</p> : null}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,227 @@
/**
* Inbound purchase tickets from the tickets app / POS bridge / Telegram bot.
* Manual counter entry never depends on these integrations being connected —
* they are an equal path into the same Quick purchase flow.
*/
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
CheckCircle2,
Link2,
PlugZap,
RefreshCw,
Ticket,
WifiOff,
XCircle,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { EmptyState } from '@/components/ui/empty-state'
import { IconTile } from '@/components/ui/icon-tile'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { useAuthStore } from '@/zustand/authStore'
import { formatEtb, formatRelative } from '@/lib/utils'
import {
inboundPurchaseTickets,
purchaseSources,
type PurchaseTicket,
} from '@/mocks/data'
import { PersonAvatar } from '@/components/ui/avatar'
const STATUS_TONE: Record<PurchaseSourceStatus, 'default' | 'warning' | 'muted'> = {
connected: 'default',
degraded: 'warning',
disconnected: 'muted',
}
type PurchaseSourceStatus = 'connected' | 'degraded' | 'disconnected'
export function TicketQueuePanel({
onAccept,
}: {
/** Send a pending ticket into Quick purchase for operator confirmation. */
onAccept: (ticket: PurchaseTicket) => void
}) {
const user = useAuthStore((s) => s.user)
const [tickets, setTickets] = useState(() => [...inboundPurchaseTickets])
const [sources, setSources] = useState(() => [...purchaseSources])
const [pulling, setPulling] = useState(false)
const scoped = useMemo(
() => (user?.branchId ? tickets.filter((t) => t.branchId === user.branchId) : tickets),
[tickets, user],
)
const pending = scoped.filter((t) => t.status === 'pending')
const pullPending = () => {
setPulling(true)
window.setTimeout(() => {
const now = new Date().toISOString()
setSources((prev) =>
prev.map((s) =>
s.status === 'disconnected' ? s : { ...s, lastSyncAt: now, status: s.status === 'degraded' ? 'degraded' : 'connected' },
),
)
toast.success('Pulled pending tickets (mock)', {
description: `${pending.length} still waiting for an operator`,
})
setPulling(false)
}, 700)
}
const reject = (id: string) => {
setTickets((prev) => prev.map((t) => (t.id === id ? { ...t, status: 'rejected' } : t)))
const target = inboundPurchaseTickets.find((t) => t.id === id)
if (target) target.status = 'rejected'
toast.message('Ticket rejected')
}
const accept = (ticket: PurchaseTicket) => {
onAccept(ticket)
toast.success(`Opened ${ticket.externalRef}`, {
description: 'Confirm lines on the Quick purchase tab',
})
}
return (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
{sources.map((source) => (
<Card key={source.id}>
<CardContent className="flex items-start gap-3 p-4">
<IconTile
icon={source.status === 'disconnected' ? WifiOff : PlugZap}
tone={source.status === 'connected' ? 'brand' : source.status === 'degraded' ? 'orange' : 'muted'}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-forest">{source.name}</p>
<Badge variant={STATUS_TONE[source.status]}>{source.status}</Badge>
</div>
<p className="text-xs text-muted-foreground">{source.vendor}</p>
<p className="mt-1 text-xs text-muted-foreground">{source.note}</p>
{source.lastSyncAt ? (
<p className="mt-1 text-[11px] text-muted-foreground">
Last sync {formatRelative(source.lastSyncAt)}
</p>
) : null}
</div>
</CardContent>
</Card>
))}
</div>
<Card className="overflow-hidden">
<CardHeader className="flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-3">
<IconTile icon={Ticket} tone="brand" />
<div>
<CardTitle>Inbound purchase tickets</CardTitle>
<p className="text-sm text-muted-foreground">
Accept opens Quick purchase with the basket pre-filled. Manual entry on that tab
always works, even when every integration is disconnected.
</p>
</div>
</div>
<Button
type="button"
variant="outline"
className="h-10 gap-2"
disabled={pulling}
onClick={pullPending}
>
<RefreshCw className={`h-4 w-4 ${pulling ? 'animate-spin' : ''}`} />
Pull pending
</Button>
</CardHeader>
<CardContent className="p-0">
{pending.length === 0 ? (
<div className="p-6">
<EmptyState
icon={CheckCircle2}
title="Queue is clear"
description="Pull again later, or raise a purchase on the Quick purchase tab."
/>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Ticket</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="hidden lg:table-cell">Source</TableHead>
<TableHead>Lines</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((ticket) => {
const source = sources.find((s) => s.id === ticket.sourceId)
return (
<TableRow key={ticket.id}>
<TableCell>
<p className="font-medium">{ticket.externalRef}</p>
<p className="text-xs text-muted-foreground">{formatRelative(ticket.raisedAt)}</p>
{ticket.note ? (
<p className="mt-1 max-w-xs text-xs text-muted-foreground">{ticket.note}</p>
) : null}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<PersonAvatar
name={ticket.customerName}
seeded
size="sm"
/>
<div>
<p className="font-medium">{ticket.customerName}</p>
<p className="text-xs text-muted-foreground">{ticket.phone ?? '—'}</p>
</div>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center gap-1.5 text-sm">
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{source?.name ?? ticket.sourceId}
</div>
</TableCell>
<TableCell>
<ul className="text-xs text-muted-foreground">
{ticket.lines.map((line) => (
<li key={line.sku}>
{line.qty}× {line.name}
</li>
))}
</ul>
</TableCell>
<TableCell className="text-right font-medium">{formatEtb(ticket.totalEtb)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" className="h-8 gap-1.5" onClick={() => accept(ticket)}>
<CheckCircle2 className="h-3.5 w-3.5" />
Accept
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 text-destructive"
onClick={() => reject(ticket.id)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,155 @@
/**
* The single write path for a completed counter purchase.
*
* Shared by the quick-purchase basket and by accepting an inbound ticket, so a
* sale lands identically however it arrived: an order, its lines and timeline,
* a loyalty award, a stock decrement with a movement per line, the customer's
* own totals, and an entry in the scan log.
*
* Follows the existing local-state-plus-mock-array convention — the arrays in
* `src/mocks/data.ts` stand in for the database.
*/
import {
branches,
counterScanHistory,
loyaltyLedger,
orderLines,
orderTimeline,
orders,
stockMovements,
stockRows,
type CounterScan,
type Customer,
type ScanSource,
} from '@/mocks/data'
import type { Order } from '@/types'
import { formatEtb } from '@/lib/utils'
import {
cartTotal,
customerQrPayload,
gateFor,
nextOrderId,
pointsForPurchase,
type CartLine,
} from '@/pages/pos/posEngine'
import type { StaffRole } from '@/types'
/** `loyaltyLedger.branch` and `customers.branch` hold the short branch name. */
export function shortBranchName(branchId: string) {
return (branches.find((b) => b.id === branchId)?.name ?? branchId).replace(/ Branch$/, '')
}
export type CommitResult = { order: Order; points: number; scan: CounterScan }
export function commitPurchase({
customer,
branchId,
lines,
role,
source,
staffName,
code,
}: {
customer: Customer
branchId: string
lines: CartLine[]
role?: StaffRole
source: ScanSource
staffName: string
/** Raw code or external reference to record against the scan. */
code?: string
}): CommitResult {
const now = new Date().toISOString()
const total = cartTotal(lines)
const points = pointsForPurchase(total, customer.tier)
const restricted = lines
.map((line) => gateFor({ sku: line.sku, productName: line.name, customerName: customer.name, role }))
.filter((gate) => gate.dispenseClass !== 'otc')
const order: Order = {
id: nextOrderId(),
customerName: customer.name,
branchId,
fulfillment: 'pickup',
status: 'delivered',
totalEtb: total,
channel: 'counter',
zone: branches.find((b) => b.id === branchId)?.zone,
createdAt: now,
paid: true,
// Only meaningful when a restricted line was involved; an emergency supply
// is deliberately not counted as an approved prescription.
rxApproved: restricted.length > 0 ? restricted.every((gate) => gate.state === 'rx_linked') : undefined,
}
orders.unshift(order)
orderLines[order.id] = lines.map((line) => ({
sku: line.sku,
name: line.name,
qty: line.qty,
unitEtb: line.unitEtb,
}))
orderTimeline[order.id] = [
{
id: '1',
at: now,
title: source === 'ticket' ? 'Accepted from the ticket queue' : 'Scanned at the counter',
detail: `${customer.name} · ${shortBranchName(branchId)}`,
tone: 'success',
},
{ id: '2', at: now, title: 'Paid in store', detail: formatEtb(total), tone: 'success' },
{ id: '3', at: now, title: 'Handed over', detail: `${lines.length} line(s) dispensed`, tone: 'default' },
]
loyaltyLedger.unshift({
id: `lp-${Date.now().toString().slice(-4)}`,
customerId: customer.id,
customerName: customer.name,
activity: 'purchase',
points,
orderId: order.id,
branch: shortBranchName(branchId),
at: now,
})
for (const line of lines) {
const row = stockRows.find((r) => r.branchId === branchId && r.sku === line.sku)
if (!row) continue
row.qty = Math.max(0, row.qty - line.qty)
row.erpQty = Math.max(0, row.erpQty - line.qty)
const movements = (stockMovements[line.sku] ??= [])
movements.unshift({
id: `m-${Date.now().toString().slice(-5)}-${line.sku}`,
at: now,
type: 'sale',
qty: -line.qty,
note: `${order.id} · ${source === 'ticket' ? 'inbound ticket' : 'counter scan'}`,
})
}
customer.points += points
customer.orders += 1
customer.spendEtb += total
customer.lastOrderAt = now
const scan: CounterScan = {
id: `scn-${Date.now()}`,
at: now,
code: code ?? customerQrPayload(customer.id),
customerId: customer.id,
customerName: customer.name,
source,
outcome: 'resolved',
branchId,
staff: staffName,
orderId: order.id,
totalEtb: total,
pointsAwarded: points,
}
counterScanHistory.unshift(scan)
return { order, points, scan }
}

270
src/pages/pos/posEngine.ts Normal file
View File

@ -0,0 +1,270 @@
/**
* Counter logic for the scan-to-purchase tool: code parsing, customer
* resolution, the dispense gate and loyalty maths.
*
* Everything here is pure and mock-backed so the panels stay presentational
* and the same rules apply however a purchase arrived — camera scan, keypad
* entry, a simulated scan or an inbound integration ticket.
*/
import {
customerQrPrefix,
customers,
dispenseClassBySku,
posEarnRule,
posTierMultipliers,
prescriptions,
stockRows,
type Customer,
type DispenseClass,
type StockRow,
} from '@/mocks/data'
import type { StaffRole } from '@/types'
/** The payload printed into a customer's loyalty QR code. */
export function customerQrPayload(customerId: string) {
return `${customerQrPrefix}:${customerId}`
}
/** Digits only, so "+251 911 22 33 44" and "0911223344" compare equal. */
function phoneDigits(value: string) {
const digits = value.replace(/\D/g, '')
// Ethiopian numbers arrive as +2519……, 2519…… or 09……; compare the last nine.
return digits.slice(-9)
}
export type ParsedCode =
| { kind: 'customer'; customerId: string }
| { kind: 'phone'; phone: string }
| { kind: 'unknown' }
/**
* Understands every shape a counter code realistically arrives in: the QR
* payload this console prints, a loyalty URL, a bare customer id typed by
* hand, or a phone number when the customer has no card on them.
*/
export function parseScannedCode(raw: string): ParsedCode {
const value = raw.trim()
if (!value) return { kind: 'unknown' }
const prefixed = value.match(new RegExp(`^${customerQrPrefix}[:/-](c\\w+)$`, 'i'))
if (prefixed) return { kind: 'customer', customerId: prefixed[1].toLowerCase() }
const url = value.match(/^https?:\/\/\S+\/(?:c|customer)\/(c\w+)\/?$/i)
if (url) return { kind: 'customer', customerId: url[1].toLowerCase() }
if (/^c\d+$/i.test(value)) return { kind: 'customer', customerId: value.toLowerCase() }
if (/^\+?\d[\d\s-]{7,}$/.test(value)) return { kind: 'phone', phone: value }
return { kind: 'unknown' }
}
export type ResolutionResult = {
customer?: Customer
parsed: ParsedCode
/** What the operator typed or the camera read, kept for the scan log. */
raw: string
}
export function resolveScannedCode(raw: string): ResolutionResult {
const parsed = parseScannedCode(raw)
if (parsed.kind === 'customer') {
return { customer: customers.find((c) => c.id === parsed.customerId), parsed, raw }
}
if (parsed.kind === 'phone') {
const needle = phoneDigits(parsed.phone)
return { customer: customers.find((c) => phoneDigits(c.phone) === needle), parsed, raw }
}
return { parsed, raw }
}
// ── Dispense gating ─────────────────────────────────────────────────────────
export function dispenseClassFor(sku: string): DispenseClass {
return dispenseClassBySku[sku] ?? 'otc'
}
/** Only these roles may sign for a prescription-only or controlled item. */
const DISPENSING_ROLES: StaffRole[] = ['pharmacist', 'super_admin']
export function canDispenseRestricted(role?: StaffRole) {
return !!role && DISPENSING_ROLES.includes(role)
}
/**
* An approved prescription for this customer that names this medicine.
* Prescription items carry product names rather than SKUs, so match on the
* name exactly as it appears in the catalogue.
*/
export function approvedRxFor(customerName: string, productName: string) {
return prescriptions.find(
(rx) =>
rx.status === 'approved' &&
rx.customerName.toLowerCase() === customerName.toLowerCase() &&
rx.items.some((item) => item.name.toLowerCase() === productName.toLowerCase()),
)
}
export type GateState = 'clear' | 'rx_linked' | 'needs_rx' | 'blocked'
export type DispenseGate = {
state: GateState
dispenseClass: DispenseClass
message: string
/** Set when an approved prescription covers the line. */
rxId?: string
/** True when a pharmacist may record an emergency supply instead of an Rx. */
overridable: boolean
}
/**
* Whether a line may be sold to this customer by this operator.
*
* OTC is free. `rx` needs an approved prescription naming the medicine, and a
* pharmacist may instead record an emergency supply with a reason. `controlled`
* is never overridable — no approved prescription means no sale.
*/
export function gateFor({
sku,
productName,
customerName,
role,
}: {
sku: string
productName: string
customerName: string
role?: StaffRole
}): DispenseGate {
const dispenseClass = dispenseClassFor(sku)
if (dispenseClass === 'otc') {
return { state: 'clear', dispenseClass, message: 'Over the counter', overridable: false }
}
const rx = approvedRxFor(customerName, productName)
const dispenser = canDispenseRestricted(role)
if (dispenseClass === 'controlled') {
if (!dispenser) {
return {
state: 'blocked',
dispenseClass,
message: 'Controlled item — only a pharmacist can dispense it. Hand this sale to a pharmacist.',
overridable: false,
}
}
if (!rx) {
return {
state: 'blocked',
dispenseClass,
message: 'Controlled item — no approved prescription on file names this medicine. Cannot be overridden.',
overridable: false,
}
}
return {
state: 'rx_linked',
dispenseClass,
rxId: rx.id,
message: `Controlled — cleared against ${rx.id}, signed by ${rx.reviewedBy ?? 'a pharmacist'}.`,
overridable: false,
}
}
if (rx) {
return {
state: 'rx_linked',
dispenseClass,
rxId: rx.id,
message: `Prescription-only — cleared against ${rx.id}.`,
overridable: false,
}
}
return {
state: 'needs_rx',
dispenseClass,
message: dispenser
? 'Prescription-only — no approved Rx on file. Record an emergency supply or send the customer to Rx intake.'
: 'Prescription-only — needs an approved Rx, and only a pharmacist can sign for it.',
overridable: dispenser,
}
}
// ── Cart ────────────────────────────────────────────────────────────────────
export type CartLine = {
sku: string
name: string
qty: number
unitEtb: number
/** Shelf count at the selling branch when the line was added. */
available: number
/** Pharmacist reason recorded in place of an approved prescription. */
overrideReason?: string
}
/** Stock at one branch. SKUs repeat across branches, so never look up by SKU alone. */
export function branchStock(branchId: string): StockRow[] {
return stockRows.filter((row) => row.branchId === branchId)
}
export function findStock(branchId: string, sku: string) {
return stockRows.find((row) => row.branchId === branchId && row.sku === sku)
}
export function searchBranchStock(branchId: string, query: string, limit = 6) {
const needle = query.trim().toLowerCase()
if (!needle) return []
return branchStock(branchId)
.filter((row) => row.sku.toLowerCase().includes(needle) || row.name.toLowerCase().includes(needle))
.slice(0, limit)
}
export function lineTotal(line: CartLine) {
return line.qty * line.unitEtb
}
export function cartTotal(lines: CartLine[]) {
return lines.reduce((sum, line) => sum + lineTotal(line), 0)
}
export function isShort(line: CartLine) {
return line.qty > line.available
}
/** Points a purchase earns: base rate on the basket, multiplied by tier. */
export function pointsForPurchase(totalEtb: number, tier: string) {
const base = Math.floor(totalEtb / posEarnRule.perEtb) * posEarnRule.pointsPer
return Math.round(base * (posTierMultipliers[tier] ?? 1))
}
export type BlockingIssue = { sku: string; label: string }
/**
* Everything standing between the current basket and a confirmed sale:
* short stock, and any gate the operator has not cleared.
*/
export function blockingIssues(
lines: CartLine[],
customerName: string,
role?: StaffRole,
): BlockingIssue[] {
const issues: BlockingIssue[] = []
for (const line of lines) {
if (isShort(line)) {
issues.push({ sku: line.sku, label: `${line.sku} — only ${line.available} on the shelf` })
}
const gate = gateFor({ sku: line.sku, productName: line.name, customerName, role })
if (gate.state === 'blocked') {
issues.push({ sku: line.sku, label: `${line.sku} — ${gate.message}` })
}
if (gate.state === 'needs_rx' && !line.overrideReason) {
issues.push({ sku: line.sku, label: `${line.sku} — prescription or pharmacist override required` })
}
}
return issues
}
/** Sequential-looking ids in the existing `ord-500x` family. */
export function nextOrderId() {
return `ord-${Date.now().toString().slice(-4)}`
}

View File

@ -0,0 +1,165 @@
/**
* Camera QR scanning over `getUserMedia` + jsQR.
*
* The hook owns the media stream and guarantees it is released — on `stop()`,
* on unmount, and when the browser tab is hidden. A camera light left on after
* the operator navigates away is a bug, not a cosmetic issue.
*
* Failures are classified rather than collapsed into one message, because
* "you denied permission" and "this laptop has no camera" need different
* instructions from the person standing at the counter.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import jsQR from 'jsqr'
export type ScannerStatus =
| 'idle'
| 'starting'
| 'scanning'
/** Permission was refused, either just now or previously for this origin. */
| 'denied'
| 'no_device'
/** Another application is holding the camera. */
| 'busy'
/** Not https and not localhost, so the browser will not hand over a camera. */
| 'insecure'
| 'unsupported'
| 'error'
/** Decode attempts per second are capped; 60fps decoding pins a CPU core. */
const SCAN_INTERVAL_MS = 120
function classifyError(error: unknown): ScannerStatus {
const name = (error as { name?: string } | null)?.name ?? ''
if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || name === 'SecurityError') {
return 'denied'
}
if (name === 'NotFoundError' || name === 'DevicesNotFoundError' || name === 'OverconstrainedError') {
return 'no_device'
}
if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError') return 'busy'
return 'error'
}
export function useQrScanner(onResult: (text: string) => void) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const frameRef = useRef<number | null>(null)
const lastDecodeRef = useRef(0)
const onResultRef = useRef(onResult)
const [status, setStatus] = useState<ScannerStatus>('idle')
useEffect(() => {
onResultRef.current = onResult
}, [onResult])
const stop = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
const stream = streamRef.current
if (stream) {
for (const track of stream.getTracks()) track.stop()
streamRef.current = null
}
const video = videoRef.current
if (video) video.srcObject = null
// Keep a failure status on screen; only a live session resets to idle.
setStatus((prev) => (prev === 'scanning' || prev === 'starting' ? 'idle' : prev))
}, [])
const tick = useCallback(() => {
frameRef.current = requestAnimationFrame(tick)
const video = videoRef.current
if (!video || video.readyState < 2) return
const now = performance.now()
if (now - lastDecodeRef.current < SCAN_INTERVAL_MS) return
lastDecodeRef.current = now
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) return
const canvas = (canvasRef.current ??= document.createElement('canvas'))
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d', { willReadFrequently: true })
if (!context) return
context.drawImage(video, 0, 0, width, height)
const frame = context.getImageData(0, 0, width, height)
const decoded = jsQR(frame.data, width, height, { inversionAttempts: 'dontInvert' })
if (decoded?.data) onResultRef.current(decoded.data)
}, [])
const start = useCallback(async () => {
if (streamRef.current) return
if (!window.isSecureContext) {
setStatus('insecure')
return
}
if (!navigator.mediaDevices?.getUserMedia) {
setStatus('unsupported')
return
}
setStatus('starting')
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
})
const video = videoRef.current
if (!video) {
// Unmounted while the permission prompt was open — do not leak the stream.
for (const track of stream.getTracks()) track.stop()
setStatus('idle')
return
}
streamRef.current = stream
video.srcObject = stream
video.setAttribute('playsinline', 'true')
video.muted = true
await video.play()
lastDecodeRef.current = 0
setStatus('scanning')
frameRef.current = requestAnimationFrame(tick)
} catch (error) {
setStatus(classifyError(error))
}
}, [tick])
useEffect(
() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
const stream = streamRef.current
if (stream) for (const track of stream.getTracks()) track.stop()
streamRef.current = null
},
[],
)
useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') stop()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
}, [stop])
return {
videoRef,
status,
start,
stop,
isLive: status === 'scanning' || status === 'starting',
}
}

View File

@ -1,12 +1,18 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import {
CreatePageLayout,
createFieldFull,
createFieldWide,
createFormGrid,
} from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { useAuthStore } from '@/zustand/authStore'
import { cn } from '@/lib/utils'
import { branches, prescriptions } from '@/mocks/data'
import type { Prescription } from '@/types'
@ -60,8 +66,8 @@ export function CreatePrescriptionPage() {
</>
}
>
<form id="create-rx-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5 sm:col-span-2">
<form id="create-rx-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Customer name</Label>
<Input
required
@ -109,7 +115,7 @@ export function CreatePrescriptionPage() {
onChange={(e) => setForm({ ...form, qty: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<label className={cn('flex items-center gap-2 text-sm', createFieldFull)}>
<input
type="checkbox"
checked={form.controlled}

View File

@ -0,0 +1,329 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, ClipboardCopy, HelpCircle, X } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { ApprovalStepper, type ApprovalStep } from '@/components/detail/ApprovalStepper'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { RowActions, type RowAction } from '@/components/table/RowActions'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { StatusBadge, statusLabel } from '@/components/ui/status-badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { branches, prescriptions } from '@/mocks/data'
import { useAuthStore } from '@/zustand/authStore'
import { formatDateTime } from '@/lib/utils'
import type { Prescription } from '@/types'
const OPEN_STATUSES: Prescription['status'][] = ['submitted', 'under_review', 'queried']
export function PrescriptionDetailPage() {
const { id } = useParams()
const user = useAuthStore((s) => s.user)
const seed = prescriptions.find((p) => p.id === id)
const [rx, setRx] = useState<Prescription | undefined>(seed ? { ...seed, items: [...seed.items] } : undefined)
if (!rx) {
return (
<DetailPageLayout backTo="/prescriptions" title="Prescription not found">
<Button asChild>
<Link to="/prescriptions">Back to prescriptions</Link>
</Button>
</DetailPageLayout>
)
}
const branch = branches.find((b) => b.id === rx.branchId)
const controlled = rx.items.some((i) => i.controlled)
const isOpen = OPEN_STATUSES.includes(rx.status)
const act = (next: Prescription['status']) => {
const patch = {
status: next,
reviewedBy: user?.name ?? 'Pharmacist',
reviewedAt: new Date().toISOString(),
}
setRx((prev) => (prev ? { ...prev, ...patch } : prev))
const target = prescriptions.find((p) => p.id === rx.id)
if (target) Object.assign(target, patch)
toast.success(`Prescription ${next}`)
}
const copyId = () => {
void navigator.clipboard?.writeText(rx.id)
toast.message(`${rx.id} copied to clipboard`)
}
const overflow: RowAction[] = [
...(isOpen
? [
{
label: 'Reject prescription',
icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive' as const,
onClick: () => act('rejected'),
},
]
: []),
{ label: 'Copy Rx ID', icon: <ClipboardCopy className="h-3.5 w-3.5" />, onClick: copyId },
]
const steps = useMemo<ApprovalStep[]>(() => {
const decided = rx.status === 'approved' || rx.status === 'rejected'
const reviewSeen = decided || rx.status === 'queried'
return [
{
key: 'submitted',
label: 'Submitted',
state: 'done',
actor: rx.customerName,
at: rx.submittedAt,
},
{
key: 'review',
label: 'Pharmacist review',
state: reviewSeen ? 'done' : 'current',
actor: reviewSeen ? rx.reviewedBy : rx.reviewStartedAt ? 'Review in progress' : undefined,
at: rx.reviewStartedAt,
hint: rx.status === 'submitted' ? 'Waiting to be picked up from the queue' : undefined,
},
{
key: 'decision',
label:
rx.status === 'approved'
? 'Approved for dispensing'
: rx.status === 'rejected'
? 'Rejected'
: rx.status === 'queried'
? 'Queried with customer'
: 'Decision',
state:
rx.status === 'approved'
? 'done'
: rx.status === 'rejected'
? 'failed'
: rx.status === 'queried'
? 'current'
: 'upcoming',
actor: rx.status === 'queried' || rx.status === 'approved' || rx.status === 'rejected' ? rx.reviewedBy : undefined,
at: rx.status === 'submitted' || rx.status === 'under_review' ? undefined : rx.reviewedAt,
hint: rx.status === 'queried' ? 'Awaiting a clearer scan from the customer' : undefined,
},
]
}, [rx])
const decisionCluster = (
<div className="flex justify-end gap-2">
{isOpen ? (
<>
<Button
type="button"
variant="ghost"
className="h-10 gap-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => act('rejected')}
>
<X className="h-4 w-4" />
Reject
</Button>
<Button type="button" variant="outline" className="h-10 gap-1.5" onClick={() => act('queried')}>
<HelpCircle className="h-4 w-4" />
Query
</Button>
<Button type="button" className="h-10 gap-1.5" onClick={() => act('approved')}>
<Check className="h-4 w-4" />
Approve
</Button>
</>
) : (
<Button asChild variant="outline" className="h-10">
<Link to="/prescriptions">Back to the review queue</Link>
</Button>
)}
</div>
)
return (
<DetailPageLayout
backTo="/prescriptions"
backLabel="Back to prescriptions"
title={rx.id}
subtitle={`${rx.customerName} · ${rx.prescriber ?? 'Unknown prescriber'}`}
badges={
<>
<StatusBadge domain="prescription" status={rx.status} />
{controlled ? <Badge variant="danger">Controlled</Badge> : null}
</>
}
actions={
<>
{isOpen ? (
<>
<Button className="h-10 gap-1.5" onClick={() => act('approved')}>
<Check className="h-4 w-4" />
Approve
</Button>
<Button variant="outline" className="h-10 gap-1.5" onClick={() => act('queried')}>
<HelpCircle className="h-4 w-4" />
Query
</Button>
</>
) : null}
<RowActions items={overflow} />
</>
}
sidebar={
<DetailSection title="Review trail">
<ActivityTimeline
events={[
{
id: '1',
at: rx.submittedAt,
title: 'Submitted by customer',
detail: branch?.name,
tone: 'default',
},
...(rx.reviewStartedAt
? [
{
id: '2',
at: rx.reviewStartedAt,
title: 'Picked up for pharmacist review',
detail: branch?.name,
tone: 'warning' as const,
},
]
: [
{
id: '2',
at: rx.submittedAt,
title: 'Queued for pharmacist review',
tone: 'warning' as const,
},
]),
...(rx.status === 'approved'
? [
{
id: '3',
at: rx.reviewedAt ?? rx.submittedAt,
title: 'Approved for dispensing',
detail: rx.reviewedBy,
tone: 'success' as const,
},
]
: []),
...(rx.status === 'queried'
? [
{
id: '3',
at: rx.reviewedAt ?? rx.submittedAt,
title: 'Query sent to customer',
detail: rx.reviewedBy,
tone: 'warning' as const,
},
]
: []),
...(rx.status === 'rejected'
? [
{
id: '3',
at: rx.reviewedAt ?? rx.submittedAt,
title: 'Rejected',
detail: rx.reviewedBy,
tone: 'danger' as const,
},
]
: []),
]}
/>
</DetailSection>
}
>
<div className="space-y-5">
<DetailSection
title="Review status"
description="Where this prescription sits in the pharmacist workflow"
>
<ApprovalStepper steps={steps} />
<div
className={
rx.status === 'rejected'
? 'mt-5 rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3'
: rx.status === 'approved'
? 'mt-5 rounded-xl border border-brand-200 bg-brand-50 px-4 py-3'
: 'mt-5 rounded-xl border border-orange/30 bg-orange/5 px-4 py-3'
}
>
<p className="text-sm font-semibold text-forest">
{rx.status === 'approved'
? 'Approved and ready to dispense'
: rx.status === 'rejected'
? 'Rejected — nothing will be dispensed'
: rx.status === 'queried'
? 'Waiting on the customer'
: rx.status === 'under_review'
? 'Open with a pharmacist'
: 'Waiting for a pharmacist'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{rx.reviewedBy && rx.reviewedAt
? `${statusLabel('prescription', rx.status)} by ${rx.reviewedBy} · ${formatDateTime(rx.reviewedAt)}`
: `Submitted ${formatDateTime(rx.submittedAt)} · no decision recorded yet`}
</p>
{rx.decisionNote ? (
<p className="mt-2 text-xs text-grayScale-600">{rx.decisionNote}</p>
) : null}
</div>
</DetailSection>
<DetailSection title="Details">
<KeyValueGrid
columns={3}
items={[
{ label: 'Branch', value: branch?.name },
{ label: 'Prescriber', value: rx.prescriber ?? '—' },
{ label: 'Submitted', value: formatDateTime(rx.submittedAt) },
{ label: 'Items', value: String(rx.items.length) },
{ label: 'Controlled items', value: controlled ? 'Yes — stamp required' : 'None' },
{ label: 'Reviewed by', value: rx.reviewedBy ?? 'Not reviewed yet' },
]}
/>
</DetailSection>
<DetailSection title="Medications" description="Verify strength, qty, and controlled flags">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Medication</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Flags</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rx.items.map((item) => (
<TableRow key={item.name}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.qty}</TableCell>
<TableCell>
{item.controlled ? <Badge variant="danger">Controlled</Badge> : <Badge variant="secondary">OTC/Rx</Badge>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-5 flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{isOpen
? controlled
? 'Approving stamps a dispensing record and logs the controlled item against your licence.'
: 'Approving stamps a dispensing record against this branch’s stock.'
: `Decision recorded by ${rx.reviewedBy ?? 'a pharmacist'}.`}
</p>
{decisionCluster}
</div>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -2,18 +2,40 @@ import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Check, HelpCircle, Plus, X } from 'lucide-react'
import {
Check,
Clock3,
Eye,
FileHeart,
HelpCircle,
Plus,
ShieldAlert,
X,
} from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { StatusBadge } from '@/components/ui/status-badge'
import { TableToolbar, type TableFilter } from '@/components/table/TableToolbar'
import { RowActions, type RowAction } from '@/components/table/RowActions'
import { useAuthStore } from '@/zustand/authStore'
import { branches, prescriptions as seed } from '@/mocks/data'
import type { Prescription } from '@/types'
const OPEN_STATUSES: Prescription['status'][] = ['submitted', 'under_review', 'queried']
function isToday(iso: string, now: Date) {
const date = new Date(iso)
return (
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
)
}
export function PrescriptionsPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
@ -23,13 +45,28 @@ export function PrescriptionsPage() {
const [branchFilter, setBranchFilter] = useState('all')
const [controlledOnly, setControlledOnly] = useState('all')
const filtered = useMemo(() => {
let list = rows
const canPickBranch = !(user?.role === 'pharmacist' && user.branchId)
const scoped = useMemo(() => {
if (user?.role === 'pharmacist' && user.branchId) {
list = list.filter((p) => p.branchId === user.branchId)
} else if (branchFilter !== 'all') {
list = list.filter((p) => p.branchId === branchFilter)
return rows.filter((p) => p.branchId === user.branchId)
}
return rows
}, [rows, user])
const kpis = useMemo(() => {
const now = new Date()
return {
today: scoped.filter((p) => isToday(p.submittedAt, now)).length,
open: scoped.filter((p) => OPEN_STATUSES.includes(p.status)).length,
approved: scoped.filter((p) => p.status === 'approved').length,
controlled: scoped.filter((p) => p.items.some((i) => i.controlled)).length,
}
}, [scoped])
const filtered = useMemo(() => {
let list = scoped
if (canPickBranch && branchFilter !== 'all') list = list.filter((p) => p.branchId === branchFilter)
if (status !== 'all') list = list.filter((p) => p.status === status)
if (controlledOnly === 'yes') list = list.filter((p) => p.items.some((i) => i.controlled))
if (q) {
@ -42,72 +79,116 @@ export function PrescriptionsPage() {
)
}
return list
}, [rows, user, q, status, branchFilter, controlledOnly])
const filtersActive =
status !== 'all' || branchFilter !== 'all' || controlledOnly !== 'all' || !!q
}, [scoped, canPickBranch, q, status, branchFilter, controlledOnly])
const act = (id: string, next: Prescription['status']) => {
setRows((prev) => prev.map((p) => (p.id === id ? { ...p, status: next } : p)))
const patch = {
status: next,
reviewedBy: user?.name ?? 'Pharmacist',
reviewedAt: new Date().toISOString(),
}
setRows((prev) => prev.map((p) => (p.id === id ? { ...p, ...patch } : p)))
const target = seed.find((p) => p.id === id)
if (target) target.status = next
if (target) Object.assign(target, patch)
toast.success(`Prescription ${next}`)
}
return (
<PageShell title={t('prescriptions.title')} subtitle={t('prescriptions.subtitle')}>
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search Rx, customer, prescriber…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Status"
value={status}
onChange={setStatus}
options={[
const rowActions = (p: Prescription): { primary: RowAction[]; items: RowAction[] } => {
const open = OPEN_STATUSES.includes(p.status)
return {
primary: [
{ label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/prescriptions/${p.id}` },
...(open
? [
{
label: t('prescriptions.approve'),
icon: <Check className="h-3.5 w-3.5" />,
onClick: () => act(p.id, 'approved'),
},
]
: []),
],
items: open
? [
{
label: t('prescriptions.query'),
icon: <HelpCircle className="h-3.5 w-3.5" />,
onClick: () => act(p.id, 'queried'),
},
{
label: t('prescriptions.reject'),
icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive',
onClick: () => act(p.id, 'rejected'),
},
]
: [],
}
}
const filters: TableFilter[] = [
{
id: 'status',
label: 'Status',
value: status,
onChange: setStatus,
options: [
{ value: 'all', label: 'All statuses' },
{ value: 'submitted', label: 'Submitted' },
{ value: 'under_review', label: 'Under review' },
{ value: 'approved', label: 'Approved' },
{ value: 'queried', label: 'Queried' },
{ value: 'rejected', label: 'Rejected' },
]}
/>
{user?.role !== 'pharmacist' ? (
<FilterSelect
label="Branch"
value={branchFilter}
onChange={setBranchFilter}
options={[
],
},
...(canPickBranch
? [
{
id: 'branch',
label: 'Branch',
value: branchFilter,
onChange: setBranchFilter,
options: [
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.id, label: b.name })),
]}
/>
) : null}
<FilterSelect
label="Controlled"
value={controlledOnly}
onChange={setControlledOnly}
options={[
{ value: 'all', label: 'All items' },
{ value: 'yes', label: 'Has controlled' },
]}
/>
<ClearFiltersButton
visible={filtersActive}
onClick={() => {
],
},
]
: []),
{
id: 'controlled',
label: 'Controlled',
value: controlledOnly,
onChange: setControlledOnly,
options: [
{ value: 'all', label: 'All prescriptions' },
{ value: 'yes', label: 'Controlled only' },
],
},
]
return (
<PageShell title={t('prescriptions.title')} subtitle={t('prescriptions.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Submitted today" value={kpis.today} hint="Across the scoped queue" icon={FileHeart} tone="brand" />
<StatCard label="Awaiting decision" value={kpis.open} hint="Submitted, in review or queried" icon={Clock3} tone="orange" />
<StatCard label="Approved" value={kpis.approved} hint="Ready to dispense" icon={Check} tone="forest" />
<StatCard label="Controlled in queue" value={kpis.controlled} hint="Needs stamp / schedule check" icon={ShieldAlert} tone="muted" />
</div>
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search Rx, customer, prescriber…"
resultCount={filtered.length}
filters={filters}
onClearFilters={() => {
setQ('')
setStatus('all')
setBranchFilter('all')
setControlledOnly('all')
}}
/>
</>
}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/prescriptions/new">
@ -137,67 +218,37 @@ export function PrescriptionsPage() {
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filtered.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.id}</TableCell>
<TableCell>{p.customerName}</TableCell>
<TableCell>{p.prescriber}</TableCell>
<TableCell>
<div className="space-y-1">
{p.items.map((i) => (
<div key={i.name} className="text-xs">
{i.name} × {i.qty}
{i.controlled ? (
<Badge variant="danger" className="ml-1">
controlled
</Badge>
) : null}
</div>
{filtered.map((p) => {
const actions = rowActions(p)
return (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link to={`/prescriptions/${p.id}`} className="text-brand-700 hover:underline">
{p.id}
</Link>
</TableCell>
<TableCell>{p.customerName}</TableCell>
<TableCell>{p.prescriber ?? '—'}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{p.items.map((i) => (
<Badge key={i.name} variant={i.controlled ? 'warning' : 'secondary'}>
{i.qty}× {i.name}
</Badge>
))}
</div>
</TableCell>
<TableCell>{branches.find((b) => b.id === p.branchId)?.name}</TableCell>
<TableCell>{branches.find((b) => b.id === p.branchId)?.name ?? p.branchId}</TableCell>
<TableCell>
<Badge
variant={
p.status === 'approved'
? 'success'
: p.status === 'rejected'
? 'danger'
: 'warning'
}
>
{p.status}
</Badge>
<StatusBadge domain="prescription" status={p.status} />
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: t('prescriptions.approve'),
icon: <Check className="h-3.5 w-3.5" />,
onClick: () => act(p.id, 'approved'),
},
]}
items={[
{
label: t('prescriptions.query'),
icon: <HelpCircle className="h-3.5 w-3.5" />,
onClick: () => act(p.id, 'queried'),
},
{
label: t('prescriptions.reject'),
icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive',
onClick: () => act(p.id, 'rejected'),
},
]}
/>
<RowActions primary={actions.primary} items={actions.items} />
</TableCell>
</TableRow>
))
)}
)
})}
</TableBody>
</Table>
</CardContent>

View File

@ -1,12 +1,13 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { CreatePageLayout, createFieldFull, createFormGrid } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { stockRows } from '@/mocks/data'
export function CreateProcurementRequestPage() {
@ -44,7 +45,7 @@ export function CreateProcurementRequestPage() {
</>
}
>
<form id="create-proc-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<form id="create-proc-form" className={createFormGrid} onSubmit={onSubmit}>
<div className="space-y-1.5">
<Label>SKU</Label>
<SelectNative value={form.sku} onChange={(e) => setForm({ ...form, sku: e.target.value })}>
@ -72,7 +73,7 @@ export function CreateProcurementRequestPage() {
onChange={(e) => setForm({ ...form, qty: e.target.value })}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<div className={cn('space-y-1.5', createFieldFull)}>
<Label>Notes</Label>
<Textarea
value={form.notes}

View File

@ -0,0 +1,121 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { CheckCircle2, Package, Send } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { branches, procurementRequests } from '@/mocks/data'
/** Mock rows narrow `status` to a single literal, so widen it for the editable copy */
type ProcurementRequest = Omit<(typeof procurementRequests)[number], 'status'> & {
status: 'open' | 'ordered' | 'received'
}
export function ProcurementDetailPage() {
const { id } = useParams()
const seed = procurementRequests.find((p) => p.id === id)
const [req, setReq] = useState<ProcurementRequest | undefined>(seed ? { ...seed } : undefined)
if (!req) {
return (
<DetailPageLayout backTo="/procurement" title="Request not found">
<Button asChild>
<Link to="/procurement">Back to procurement</Link>
</Button>
</DetailPageLayout>
)
}
const branch = branches.find((b) => b.id === req.branchId)
const advance = (status: typeof req.status, message: string) => {
setReq((prev) => (prev ? { ...prev, status } : prev))
const target = procurementRequests.find((p) => p.id === req.id)
if (target) target.status = status
toast.success(message)
}
return (
<DetailPageLayout
backTo="/procurement"
backLabel="Back to procurement"
title={req.id}
subtitle={`${req.name} · ${req.sku}`}
badges={
<>
<Badge variant={req.priority === 'high' ? 'danger' : req.priority === 'medium' ? 'warning' : 'secondary'}>
{req.priority}
</Badge>
<Badge variant="outline">{req.status}</Badge>
</>
}
actions={
<>
<Button
size="sm"
className="h-9 gap-1.5"
disabled={req.status !== 'open'}
onClick={() => advance('ordered', 'PO sent to supplier (mock)')}
>
<Send className="h-3.5 w-3.5" />
Place PO
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
disabled={req.status === 'received'}
onClick={() => advance('received', 'Goods received · stock updated')}
>
<Package className="h-3.5 w-3.5" />
Mark received
</Button>
<Button
size="sm"
variant="secondary"
className="h-9 gap-1.5"
onClick={() => toast.message('AI suggested alternate supplier: Addis Pharma Dist.')}
>
<CheckCircle2 className="h-3.5 w-3.5" />
AI suggest
</Button>
</>
}
sidebar={
<DetailSection title="Workflow">
<ActivityTimeline
events={[
{ id: '1', at: req.createdAt, title: 'Request created', detail: req.requestedBy, tone: 'default' },
...(req.status !== 'open'
? [{ id: '2', at: req.createdAt, title: 'Purchase order raised', tone: 'warning' as const }]
: []),
...(req.status === 'received'
? [{ id: '3', at: new Date().toISOString(), title: 'Received into stock', tone: 'success' as const }]
: []),
]}
/>
</DetailSection>
}
>
<DetailSection title="Request details">
<KeyValueGrid
columns={3}
items={[
{ label: 'SKU', value: <Link className="text-brand-700 hover:underline" to={`/stock/${req.sku}`}>{req.sku}</Link> },
{ label: 'Product', value: req.name },
{ label: 'Quantity', value: String(req.qty) },
{ label: 'Branch', value: branch?.name },
{ label: 'Requested by', value: req.requestedBy },
{ label: 'Created', value: new Date(req.createdAt).toLocaleString() },
{ label: 'Notes', value: req.notes },
{ label: 'Status', value: req.status },
{ label: 'Priority', value: req.priority },
]}
/>
</DetailSection>
</DetailPageLayout>
)
}

View File

@ -2,15 +2,73 @@ import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { PackagePlus, Plus } from 'lucide-react'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
AlertTriangle,
ArrowLeftRight,
ClipboardList,
Eye,
PackageCheck,
PackagePlus,
Plus,
Truck,
} from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { stockRows } from '@/mocks/data'
import { branches, procurementRequests, stockRows } from '@/mocks/data'
import { branchRevenue, stockMovement } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
/** Same threshold the dashboard uses to flag low stock */
const LOW_STOCK_THRESHOLD = 20
const NEAR_EXPIRY_MONTHS = 3
const CRITICAL_EXPIRY_MONTHS = 1
type StockRow = (typeof stockRows)[number]
type RestockSignal = 'low' | 'expiry' | 'mismatch'
const signalLabel: Record<RestockSignal, string> = {
low: 'Low stock',
expiry: 'Near expiry',
mismatch: 'ERP mismatch',
}
/** Mock expiry is a `YYYY-MM` string, so compare whole months instead of parsing a full date */
function monthsUntilExpiry(expiry: string, from: Date) {
const [year, month] = expiry.split('-').map(Number)
if (!year || !month) return Number.POSITIVE_INFINITY
return (year - from.getFullYear()) * 12 + (month - 1 - from.getMonth())
}
/** Single source of truth for "needs restocking" — the KPI count and the table both read this */
function restockSignals(row: StockRow, from: Date): RestockSignal[] {
const signals: RestockSignal[] = []
if (row.qty < LOW_STOCK_THRESHOLD) signals.push('low')
if (monthsUntilExpiry(row.expiry, from) <= NEAR_EXPIRY_MONTHS) signals.push('expiry')
if (row.qty !== row.erpQty) signals.push('mismatch')
return signals
}
const recommendations = [
{ sku: 'MTF-850', reason: 'Below reorder point', qty: 100, velocity: 'high', suggestion: 'order' as const },
@ -18,22 +76,128 @@ const recommendations = [
{ sku: 'VIT-C', reason: 'Dead stock risk at Bole — consider transfer', qty: 0, velocity: 'transfer', suggestion: 'transfer' as const },
]
const priorityVariant = { high: 'danger', medium: 'warning', low: 'secondary' } as const
const statusVariant = { open: 'warning', ordered: 'outline', received: 'success' } as const
const requestStatusOptions = [
{ value: 'all', label: 'All statuses' },
{ value: 'open', label: 'Open' },
{ value: 'ordered', label: 'Ordered' },
{ value: 'received', label: 'Received' },
]
const priorityOptions = [
{ value: 'all', label: 'All priorities' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
]
const signalOptions = [
{ value: 'all', label: 'All signals' },
{ value: 'low', label: signalLabel.low },
{ value: 'expiry', label: signalLabel.expiry },
{ value: 'mismatch', label: signalLabel.mismatch },
]
export function ProcurementPage() {
const { t } = useTranslation()
const [requestQuery, setRequestQuery] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const [priorityFilter, setPriorityFilter] = useState('all')
const [q, setQ] = useState('')
const [branchFilter, setBranchFilter] = useState('all')
const [signal, setSignal] = useState('all')
const filteredStock = useMemo(() => {
return stockRows.filter((r) => {
const sig = r.qty < 20 ? 'reorder' : r.qty > 150 ? 'slow' : 'healthy'
if (signal !== 'all' && sig !== signal) return false
const restockRows = useMemo(() => {
const now = new Date()
return stockRows
.map((r) => ({
...r,
signals: restockSignals(r, now),
monthsLeft: monthsUntilExpiry(r.expiry, now),
}))
.filter((r) => r.signals.length > 0)
}, [])
const counts = useMemo(
() => ({
open: procurementRequests.filter((r) => r.status === 'open').length,
ordered: procurementRequests.filter((r) => r.status === 'ordered').length,
received: procurementRequests.filter((r) => r.status === 'received').length,
needsRestock: restockRows.length,
}),
[restockRows],
)
const suggestions = useMemo(
() =>
recommendations.map((r) => ({
...r,
name: stockRows.find((s) => s.sku === r.sku)?.name ?? r.sku,
})),
[],
)
const filteredRequests = useMemo(() => {
let list = procurementRequests
if (statusFilter !== 'all') list = list.filter((r) => r.status === statusFilter)
if (priorityFilter !== 'all') list = list.filter((r) => r.priority === priorityFilter)
if (requestQuery) {
const needle = requestQuery.toLowerCase()
list = list.filter(
(r) =>
r.id.toLowerCase().includes(needle) ||
r.sku.toLowerCase().includes(needle) ||
r.name.toLowerCase().includes(needle),
)
}
return list
}, [requestQuery, statusFilter, priorityFilter])
const filteredRestock = useMemo(() => {
let list = restockRows
if (branchFilter !== 'all') list = list.filter((r) => r.branchId === branchFilter)
if (signal !== 'all') list = list.filter((r) => r.signals.includes(signal as RestockSignal))
if (q) {
const needle = q.toLowerCase()
if (!r.sku.toLowerCase().includes(needle) && !r.name.toLowerCase().includes(needle)) return false
list = list.filter(
(r) => r.sku.toLowerCase().includes(needle) || r.name.toLowerCase().includes(needle),
)
}
return true
})
}, [q, signal])
return list
}, [restockRows, q, branchFilter, signal])
const requestFilters: TableFilter[] = [
{
id: 'status',
label: t('common.status'),
value: statusFilter,
onChange: setStatusFilter,
options: requestStatusOptions,
},
{
id: 'priority',
label: t('common.priority'),
value: priorityFilter,
onChange: setPriorityFilter,
options: priorityOptions,
},
]
const restockFilters: TableFilter[] = [
{
id: 'branch',
label: t('common.branch'),
value: branchFilter,
onChange: setBranchFilter,
options: [
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.id, label: b.name })),
],
},
{ id: 'signal', label: 'Signal', value: signal, onChange: setSignal, options: signalOptions },
]
return (
<PageShell
@ -48,89 +212,142 @@ export function ProcurementPage() {
</Button>
}
>
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Open requests"
value={counts.open}
hint="Awaiting a purchase order"
icon={ClipboardList}
tone="brand"
/>
<StatCard
label="Awaiting delivery"
value={counts.ordered}
hint="Ordered from supplier"
icon={Truck}
tone="forest"
/>
<StatCard label="Received" value={counts.received} hint="Closed this month" icon={PackageCheck} tone="muted" />
<StatCard
label="Needs restocking"
value={counts.needsRestock}
hint="Low stock, expiry, or ERP gap"
icon={AlertTriangle}
tone="orange"
/>
</div>
<Tabs defaultValue="requests">
<div className="overflow-x-auto pb-1">
<TabsList>
<TabsTrigger value="requests">Requests</TabsTrigger>
<TabsTrigger value="restock">Restock</TabsTrigger>
<TabsTrigger value="insight">Supplier insight</TabsTrigger>
</TabsList>
</div>
<TabsContent value="requests">
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search SKU…"
resultCount={filteredStock.length}
filters={
<>
<FilterSelect
label="Signal"
value={signal}
onChange={setSignal}
options={[
{ value: 'all', label: 'All signals' },
{ value: 'reorder', label: 'Reorder' },
{ value: 'slow', label: 'Slow / dead' },
{ value: 'healthy', label: 'Healthy' },
]}
/>
<ClearFiltersButton
visible={signal !== 'all' || !!q}
onClick={() => {
setQ('')
setSignal('all')
search={requestQuery}
onSearchChange={setRequestQuery}
searchPlaceholder="Search request, SKU, or product…"
resultCount={filteredRequests.length}
filters={requestFilters}
onClearFilters={() => {
setRequestQuery('')
setStatusFilter('all')
setPriorityFilter('all')
}}
/>
</>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>ID</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Product</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Signal</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t('common.branch')}</TableHead>
<TableHead>Priority</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredStock.map((r) => {
const sig = r.qty < 20 ? 'reorder' : r.qty > 150 ? 'slow' : 'healthy'
return (
<TableRow key={r.sku + r.branchId}>
<TableCell className="font-medium">{r.sku}</TableCell>
{filteredRequests.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filteredRequests.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
<Link to={`/procurement/${r.id}`} className="text-brand-700 hover:underline">
{r.id}
</Link>
</TableCell>
<TableCell>{r.sku}</TableCell>
<TableCell>{r.name}</TableCell>
<TableCell>{r.qty}</TableCell>
<TableCell>{branches.find((b) => b.id === r.branchId)?.name}</TableCell>
<TableCell>
<Badge
variant={sig === 'reorder' ? 'warning' : sig === 'slow' ? 'muted' : 'success'}
>
{sig}
</Badge>
<Badge variant={priorityVariant[r.priority]}>{r.priority}</Badge>
</TableCell>
<TableCell>
<Badge variant={statusVariant[r.status]}>{r.status}</Badge>
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'Request',
icon: <PackagePlus className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Reorder draft for ${r.sku}`),
label: t('common.view'),
icon: <Eye className="h-3.5 w-3.5" />,
to: `/procurement/${r.id}`,
},
]}
items={[
{
label: 'Nudge supplier',
icon: <Truck className="h-3.5 w-3.5" />,
onClick: () => toast.message(`Supplier reminder sent for ${r.id}`),
},
]}
/>
</TableCell>
</TableRow>
)
})}
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Reorder recommendations</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{recommendations.map((r) => (
<div key={r.sku} className="flex items-center justify-between rounded-xl border p-3">
<div>
</TabsContent>
<TabsContent value="restock">
<div className="space-y-4">
<DetailSection
title="Suggested restock"
description="AI reorder signals across branches (demo data)"
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{suggestions.map((r) => (
<div key={r.sku} className="flex flex-col gap-2 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{r.sku}</p>
<p className="text-xs text-muted-foreground">{r.reason}</p>
<p className="truncate text-sm text-muted-foreground">{r.name}</p>
</div>
<Badge variant={r.suggestion === 'transfer' ? 'muted' : 'warning'}>
{r.suggestion === 'transfer' ? 'Transfer' : 'Reorder'}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{r.reason}</p>
<p className="text-sm">
Suggested:{' '}
<strong>{r.suggestion === 'transfer' ? 'inter-branch transfer' : `${r.qty} units`}</strong>
</p>
<div className="mt-auto flex flex-wrap gap-2 pt-1">
<Button
size="sm"
variant="outline"
@ -145,11 +362,181 @@ export function ProcurementPage() {
>
{r.suggestion === 'transfer' ? 'Request transfer' : `Order ${r.qty}`}
</Button>
<Button
size="sm"
variant="ghost"
className="h-9"
onClick={() => toast.success(`Reorder request drafted for ${r.sku}`)}
>
Create request
</Button>
</div>
</div>
))}
</div>
</DetailSection>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>Needs restocking</CardTitle>
<CardDescription>
Below {LOW_STOCK_THRESHOLD} units, expiring within {NEAR_EXPIRY_MONTHS} months, or out of sync with
ERP
</CardDescription>
</CardHeader>
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search SKU or product…"
resultCount={filteredRestock.length}
filters={restockFilters}
onClearFilters={() => {
setQ('')
setBranchFilter('all')
setSignal('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>SKU</TableHead>
<TableHead>Product</TableHead>
<TableHead>{t('common.branch')}</TableHead>
<TableHead>On hand</TableHead>
<TableHead>ERP</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Expiry</TableHead>
<TableHead>Reason</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRestock.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filteredRestock.map((r) => (
<TableRow key={`${r.sku}-${r.branchId}`}>
<TableCell className="font-medium">
<Link to={`/stock/${r.sku}`} className="text-brand-700 hover:underline">
{r.sku}
</Link>
</TableCell>
<TableCell>{r.name}</TableCell>
<TableCell>{branches.find((b) => b.id === r.branchId)?.name}</TableCell>
<TableCell>{r.qty}</TableCell>
<TableCell>{r.erpQty}</TableCell>
<TableCell>{r.batch}</TableCell>
<TableCell>
<div className="flex flex-col">
<span>{r.expiry}</span>
{r.monthsLeft <= NEAR_EXPIRY_MONTHS ? (
<span className="text-xs text-muted-foreground">
{r.monthsLeft <= 0 ? 'expired' : `${r.monthsLeft} mo left`}
</span>
) : null}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{r.signals.map((s) => (
<Badge
key={s}
variant={
s === 'mismatch' ||
(s === 'expiry' && r.monthsLeft <= CRITICAL_EXPIRY_MONTHS)
? 'danger'
: 'warning'
}
>
{signalLabel[s]}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'Create request',
icon: <PackagePlus className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Reorder draft for ${r.sku}`),
},
]}
items={[
{
label: 'Request transfer',
icon: <ArrowLeftRight className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Transfer request sent to Stock Manager for ${r.sku}`),
},
{
label: 'View stock',
icon: <Eye className="h-3.5 w-3.5" />,
to: `/stock/${r.sku}`,
},
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="insight">
<div className="grid gap-4 xl:grid-cols-3">
<ChartCard
title="Stock inflow vs outflow"
description="Units moved per week (demo data)"
className="xl:col-span-2"
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stockMovement}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="week" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => `${Number(v)} units`} />
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="in"
name="Inflow"
stroke="#A8C73A"
fill="#A8C73A"
fillOpacity={0.25}
/>
<Area
type="monotone"
dataKey="out"
name="Outflow"
stroke="#E28A1A"
fill="#E28A1A"
fillOpacity={0.2}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Revenue by branch" description="Pull-through demand (demo data)">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={branchRevenue}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="branch" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(v) => formatEtb(Number(v))} />
<Bar dataKey="revenue" name="Revenue" fill="#141414" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
</TabsContent>
</Tabs>
</PageShell>
)
}

View File

@ -0,0 +1,745 @@
import { useMemo, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Bell,
Building2,
Check,
KeyRound,
Languages,
LogOut,
Monitor,
Pencil,
ShieldCheck,
SlidersHorizontal,
Sparkles,
UserRound,
X,
} from 'lucide-react'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Separator } from '@/components/ui/separator'
import { PersonAvatar } from '@/components/ui/avatar'
import { StatusBadge } from '@/components/ui/status-badge'
import { EmptyState } from '@/components/ui/empty-state'
import { IconTile } from '@/components/ui/icon-tile'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { branchProfiles, branches, staffActivity, staffProfiles, staffSessions } from '@/mocks/data'
import type { StaffProfile, StaffSession } from '@/mocks/data'
import { canAccess, demoAccounts, roleModules } from '@/config/permissions'
import { sidebarEntries } from '@/config/navigation'
import { useAuthStore } from '@/zustand/authStore'
import { formatDate, formatDateTime, formatRelative, humanize } from '@/lib/utils'
import i18n from '@/i18n/config'
const LOCALE_LABEL: Record<'en' | 'am', string> = { en: 'English', am: 'አማርኛ' }
const NOTIFICATION_FIELDS: { key: keyof StaffProfile['notifications']; label: string; description: string }[] = [
{ key: 'orderAlerts', label: 'Order alerts', description: 'New and at-risk orders on your branch' },
{ key: 'lowStockAlerts', label: 'Low stock alerts', description: 'Reorder points and near-expiry batches' },
{ key: 'weeklyDigest', label: 'Weekly digest', description: 'A Monday summary of your modules' },
{ key: 'securityAlerts', label: 'Security alerts', description: 'New sign-ins and permission changes' },
]
function fallbackProfile(id: string): StaffProfile {
return {
id,
jobTitle: 'Staff member',
phone: '',
status: 'active',
joinedAt: new Date().toISOString(),
lastSignInAt: new Date().toISOString(),
lastSignInFrom: 'Addis Ababa, Ethiopia',
passwordUpdatedAt: new Date().toISOString(),
twoFactorEnabled: false,
preferredLocale: 'en',
notifications: { orderAlerts: false, lowStockAlerts: false, weeklyDigest: false, securityAlerts: true },
}
}
function ToggleSwitch({
checked,
onChange,
label,
}: {
checked: boolean
onChange: (next: boolean) => void
label: string
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
checked ? 'bg-brand-500' : 'bg-grayScale-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
)
}
export function ProfilePage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const updateUser = useAuthStore((s) => s.updateUser)
// Resolved once: editing your own email must not detach the page from its mocks.
const [accountId] = useState(
() => demoAccounts.find((a) => a.email === user?.email)?.id ?? user?.id ?? 'unknown',
)
const [profile, setProfile] = useState<StaffProfile>(
() => staffProfiles[accountId] ?? fallbackProfile(accountId),
)
const [sessions, setSessions] = useState<StaffSession[]>(() => staffSessions[accountId] ?? [])
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({
name: user?.name ?? '',
email: user?.email ?? '',
phone: profile.phone,
jobTitle: profile.jobTitle,
locale: (user?.preferredLocale ?? profile.preferredLocale) as 'en' | 'am',
})
const [passwordOpen, setPasswordOpen] = useState(false)
const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' })
const role = user?.role
const modules = useMemo(() => {
if (!role) return []
return roleModules[role].map((module) => {
const entry = sidebarEntries.find((e) => e.module === module)
return {
module,
label: entry?.labelKey
? t(entry.labelKey, { defaultValue: entry.title })
: (entry?.title ?? humanize(module)),
path: entry?.path,
icon: entry?.icon,
}
})
}, [role, t])
const activity = useMemo(
() =>
(staffActivity[accountId] ?? [])
.slice()
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()),
[accountId],
)
const branch = useMemo(() => branches.find((b) => b.id === user?.branchId), [user?.branchId])
const branchProfile = user?.branchId ? branchProfiles[user.branchId] : undefined
if (!user || !role) return <Navigate to="/login" replace />
const totalModules = new Set(Object.values(roleModules).flat()).size
const persistProfile = (next: StaffProfile) => {
staffProfiles[next.id] = next
setProfile(next)
}
const startEditing = () => {
setForm({
name: user.name,
email: user.email,
phone: profile.phone,
jobTitle: profile.jobTitle,
locale: (user.preferredLocale ?? profile.preferredLocale) as 'en' | 'am',
})
setEditing(true)
}
const saveAccountDetails = () => {
if (!form.name.trim()) {
toast.error('Enter your full name')
return
}
if (!form.email.includes('@')) {
toast.error('Enter a valid email address')
return
}
setSaving(true)
window.setTimeout(() => {
persistProfile({
...profile,
phone: form.phone.trim(),
jobTitle: form.jobTitle.trim(),
preferredLocale: form.locale,
})
updateUser({ name: form.name.trim(), email: form.email.trim(), preferredLocale: form.locale })
if (i18n.language !== form.locale) void i18n.changeLanguage(form.locale)
setSaving(false)
setEditing(false)
toast.success('Profile updated')
}, 400)
}
const changeLocale = (locale: 'en' | 'am') => {
if (locale === (user.preferredLocale ?? profile.preferredLocale)) return
persistProfile({ ...profile, preferredLocale: locale })
updateUser({ preferredLocale: locale })
setForm((prev) => ({ ...prev, locale }))
void i18n.changeLanguage(locale)
toast.success(`Language set to ${LOCALE_LABEL[locale]}`)
}
const toggleNotification = (key: keyof StaffProfile['notifications'], next: boolean) => {
persistProfile({ ...profile, notifications: { ...profile.notifications, [key]: next } })
toast.success(`${NOTIFICATION_FIELDS.find((f) => f.key === key)?.label} ${next ? 'on' : 'off'}`)
}
const toggleTwoFactor = () => {
const next = !profile.twoFactorEnabled
persistProfile({ ...profile, twoFactorEnabled: next })
toast.success(next ? 'Two-factor authentication enabled' : 'Two-factor authentication disabled')
}
const revokeSession = (session: StaffSession) => {
setSessions((prev) => prev.filter((s) => s.id !== session.id))
staffSessions[accountId] = (staffSessions[accountId] ?? []).filter((s) => s.id !== session.id)
toast.success(`Signed out ${session.device}`)
}
const revokeOtherSessions = () => {
const remaining = sessions.filter((s) => s.current)
setSessions(remaining)
staffSessions[accountId] = remaining
toast.success('All other sessions signed out')
}
const submitPasswordChange = () => {
if (!passwordForm.current) {
toast.error('Enter your current password')
return
}
if (passwordForm.next.length < 8) {
toast.error('New password must be at least 8 characters')
return
}
if (passwordForm.next !== passwordForm.confirm) {
toast.error('New passwords do not match')
return
}
persistProfile({ ...profile, passwordUpdatedAt: new Date().toISOString() })
setPasswordForm({ current: '', next: '', confirm: '' })
setPasswordOpen(false)
toast.success('Password updated')
}
const activeLocale = (user.preferredLocale ?? profile.preferredLocale) as 'en' | 'am'
return (
<div className="w-full min-w-0 space-y-5">
<section className="overflow-hidden rounded-2xl border bg-card shadow-soft">
<div className="relative h-24 bg-gradient-to-r from-brand-500 via-brand-400 to-orange">
<div
className="absolute inset-0"
style={{
backgroundImage: 'radial-gradient(rgba(20,20,20,0.12) 1px, transparent 1px)',
backgroundSize: '18px 18px',
}}
aria-hidden="true"
/>
</div>
<div className="flex flex-col gap-4 px-5 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="-mt-12 flex flex-col gap-4 sm:flex-row sm:items-end">
<PersonAvatar
name={user.name}
avatarUrl={user.avatarUrl}
size="xl"
className="h-24 w-24 text-2xl ring-4 ring-card"
/>
<div className="min-w-0 pb-1">
<h1 className="font-display text-2xl font-bold tracking-tight text-forest">{user.name}</h1>
<p className="mt-0.5 truncate text-sm text-muted-foreground">{user.email}</p>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<StatusBadge domain="role" status={role} />
<Badge variant="outline" className="gap-1.5">
<Building2 className="h-3 w-3" aria-hidden="true" />
{user.branchName ?? 'All branches'}
</Badge>
<Badge variant={profile.status === 'active' ? 'success' : 'warning'} className="gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
{humanize(profile.status)}
</Badge>
<Badge variant="muted">{profile.jobTitle}</Badge>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{editing ? (
<>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => setEditing(false)}>
<X className="h-3.5 w-3.5" />
Cancel
</Button>
<Button size="sm" className="h-9 gap-1.5" disabled={saving} onClick={saveAccountDetails}>
<Check className="h-3.5 w-3.5" />
{saving ? 'Saving…' : 'Save changes'}
</Button>
</>
) : (
<>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => setPasswordOpen(true)}>
<KeyRound className="h-3.5 w-3.5" />
Change password
</Button>
<Button size="sm" className="h-9 gap-1.5" onClick={startEditing}>
<Pencil className="h-3.5 w-3.5" />
Edit profile
</Button>
</>
)}
</div>
</div>
</section>
<div className="grid gap-5 [&>*]:min-w-0 lg:grid-cols-[minmax(0,1fr)_22rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
<div className="space-y-5">
<DetailSection
title="Account details"
description="How you appear across the console"
action={
editing ? null : (
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-muted-foreground" onClick={startEditing}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)
}
>
{editing ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-name">Full name</Label>
<Input
id="profile-name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-email">Email</Label>
<Input
id="profile-email"
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-phone">Phone</Label>
<Input
id="profile-phone"
value={form.phone}
placeholder="+251 …"
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-job">Job title</Label>
<Input
id="profile-job"
value={form.jobTitle}
onChange={(e) => setForm((prev) => ({ ...prev, jobTitle: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-locale">Language preference</Label>
<SelectNative
id="profile-locale"
value={form.locale}
onChange={(e) => setForm((prev) => ({ ...prev, locale: e.target.value as 'en' | 'am' }))}
>
<option value="en">{LOCALE_LABEL.en}</option>
<option value="am">{LOCALE_LABEL.am}</option>
</SelectNative>
</div>
</div>
<Separator />
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={() => setEditing(false)}>
Cancel
</Button>
<Button disabled={saving} onClick={saveAccountDetails}>
{saving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</div>
) : (
<KeyValueGrid
columns={3}
items={[
{ label: 'Full name', value: user.name },
{ label: 'Email', value: user.email },
{ label: 'Phone', value: profile.phone || '—' },
{ label: 'Job title', value: profile.jobTitle },
{ label: 'Language', value: LOCALE_LABEL[activeLocale] },
{ label: 'Member since', value: formatDate(profile.joinedAt) },
]}
/>
)}
</DetailSection>
<DetailSection
title="Role & permissions"
description={`${modules.length} of ${totalModules} modules enabled for this role`}
action={<StatusBadge domain="role" status={role} />}
>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{modules.map(({ module, label, path, icon }) => {
const content = (
<>
{icon ? (
<IconTile icon={icon} size="sm" tone="brand" />
) : (
<IconTile icon={Sparkles} size="sm" tone="muted" />
)}
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">{label}</span>
<span className="block truncate text-xs text-muted-foreground">{module}</span>
</span>
</>
)
return path ? (
<Link
key={module}
to={path}
className="flex items-center gap-3 rounded-xl border bg-background p-3 transition-colors hover:bg-grayScale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content}
</Link>
) : (
<div key={module} className="flex items-center gap-3 rounded-xl border bg-background p-3">
{content}
</div>
)
})}
</div>
<p className="mt-4 text-xs text-muted-foreground">
Module access is granted by role. Ask a super admin if you need a module that is not listed here.
</p>
</DetailSection>
<DetailSection title="Branch assignment" description="Where your work is scoped">
{branch ? (
<div className="space-y-4">
<KeyValueGrid
columns={3}
items={[
{ label: 'Branch', value: branch.name },
{ label: 'Zone', value: branch.zone },
{ label: 'Phone', value: branch.phone },
{ label: 'Manager', value: branchProfile?.manager ?? '—' },
{ label: 'Opening hours', value: branchProfile?.openingHours ?? '—' },
{ label: 'Address', value: branchProfile?.address ?? '—' },
]}
/>
{canAccess(role, 'settings') ? (
<Button asChild variant="outline" size="sm" className="h-9">
<Link to={`/settings/branches/${branch.id}`}>Open branch settings</Link>
</Button>
) : null}
</div>
) : (
<div className="flex items-start gap-3 rounded-xl border bg-background p-4">
<IconTile icon={Building2} size="sm" tone="forest" />
<div>
<p className="text-sm font-medium text-foreground">All branches</p>
<p className="mt-0.5 text-xs text-muted-foreground">
This account is platform-wide and is not scoped to a single branch.
</p>
</div>
</div>
)}
</DetailSection>
<DetailSection title="Recent activity" description="Your last actions in the console">
{activity.length === 0 ? (
<EmptyState
icon={UserRound}
title="No activity yet"
description="Actions you take in the console will show up here."
variant="dashed"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Module</TableHead>
<TableHead>Outcome</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activity.map((event) => {
const linkable = event.targetPath && canAccess(role, event.module)
return (
<TableRow key={event.id}>
<TableCell className="whitespace-nowrap align-top">
<span className="block text-sm font-medium text-foreground">
{formatRelative(event.at)}
</span>
<span className="block text-xs text-muted-foreground">{formatDateTime(event.at)}</span>
</TableCell>
<TableCell className="align-top">
<span className="block text-sm font-medium text-foreground">{event.summary}</span>
<span className="block font-mono text-xs text-muted-foreground">{event.action}</span>
{linkable ? (
<Link
to={event.targetPath as string}
className="mt-0.5 inline-block text-xs font-medium text-brand-700 underline-offset-2 hover:underline"
>
{event.target}
</Link>
) : event.target ? (
<span className="mt-0.5 block text-xs text-muted-foreground">{event.target}</span>
) : null}
</TableCell>
<TableCell className="align-top">
<Badge variant="outline">{humanize(event.module)}</Badge>
</TableCell>
<TableCell className="align-top">
<Badge variant={event.outcome === 'success' ? 'success' : 'danger'}>
{humanize(event.outcome)}
</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</DetailSection>
</div>
<div className="space-y-5">
<DetailSection title="Security" description="Password, verification and devices">
<div className="space-y-4">
<KeyValueGrid
columns={1}
items={[
{
label: 'Last sign-in',
value: (
<>
<span className="block">{formatDateTime(profile.lastSignInAt)}</span>
<span className="block text-xs font-normal text-muted-foreground">
{profile.lastSignInFrom}
</span>
</>
),
},
{
label: 'Password updated',
value: `${formatDate(profile.passwordUpdatedAt)} · ${formatRelative(profile.passwordUpdatedAt)}`,
},
]}
/>
<div className="flex items-start justify-between gap-3 rounded-xl border bg-background p-4">
<div className="flex items-start gap-3">
<IconTile icon={ShieldCheck} size="sm" tone={profile.twoFactorEnabled ? 'brand' : 'muted'} />
<div>
<p className="text-sm font-medium text-foreground">Two-factor authentication</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{profile.twoFactorEnabled ? 'Required at every sign-in' : 'Not enabled on this account'}
</p>
</div>
</div>
<ToggleSwitch
checked={profile.twoFactorEnabled}
onChange={toggleTwoFactor}
label="Two-factor authentication"
/>
</div>
<Button variant="outline" className="w-full gap-2" onClick={() => setPasswordOpen(true)}>
<KeyRound className="h-4 w-4" />
Change password
</Button>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Active sessions
</p>
{sessions.some((s) => !s.current) ? (
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" onClick={revokeOtherSessions}>
<LogOut className="h-3.5 w-3.5" />
Sign out others
</Button>
) : null}
</div>
{sessions.length === 0 ? (
<EmptyState icon={Monitor} title="No active sessions" size="sm" />
) : (
<ul className="space-y-2">
{sessions.map((session) => (
<li key={session.id} className="flex items-start justify-between gap-3 rounded-xl border p-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{session.device}</span>
{session.current ? <Badge variant="success">This device</Badge> : null}
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{session.browser} · {session.location}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{session.ip} · {formatRelative(session.lastActiveAt)}
</p>
</div>
{session.current ? null : (
<Button
size="sm"
variant="ghost"
className="h-8 shrink-0 text-xs text-destructive hover:bg-red-50"
onClick={() => revokeSession(session)}
>
Revoke
</Button>
)}
</li>
))}
</ul>
)}
</div>
</div>
</DetailSection>
<DetailSection title="Preferences" description="Language and what we notify you about">
<div className="space-y-5">
<div>
<div className="mb-2 flex items-center gap-2">
<Languages className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Console language</p>
</div>
<div className="grid grid-cols-2 gap-2" role="group" aria-label="Console language">
{(['en', 'am'] as const).map((locale) => (
<Button
key={locale}
type="button"
variant={activeLocale === locale ? 'default' : 'outline'}
aria-pressed={activeLocale === locale}
onClick={() => changeLocale(locale)}
>
{LOCALE_LABEL[locale]}
</Button>
))}
</div>
</div>
<Separator />
<div>
<div className="mb-3 flex items-center gap-2">
<Bell className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Notifications</p>
</div>
<ul className="space-y-3">
{NOTIFICATION_FIELDS.map((field) => (
<li key={field.key} className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{field.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p>
</div>
<ToggleSwitch
checked={profile.notifications[field.key]}
onChange={(next) => toggleNotification(field.key, next)}
label={field.label}
/>
</li>
))}
</ul>
</div>
<div className="flex items-start gap-3 rounded-xl bg-brand-50 p-3">
<SlidersHorizontal className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
<p className="text-xs leading-relaxed text-muted-foreground">
Preferences are stored against your account and apply to this console only.
</p>
</div>
</div>
</DetailSection>
</div>
</div>
<Dialog open={passwordOpen} onOpenChange={setPasswordOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>
Choose a password of at least 8 characters. You stay signed in on this device.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current password</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={passwordForm.current}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, current: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={passwordForm.next}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, next: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={passwordForm.confirm}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, confirm: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setPasswordOpen(false)}>
Cancel
</Button>
<Button onClick={submitPasswordChange}>Update password</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,267 @@
import { useMemo } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { AlertTriangle, Boxes, Download, Pencil, Phone, Receipt, ShoppingBag } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { PersonAvatar } from '@/components/ui/avatar'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { auditLog, branchProfiles, branchStaff, branches, settingsActivity, stockRows } from '@/mocks/data'
import { branchOrdersSeries } from '@/mocks/charts'
import { formatEtb } from '@/lib/utils'
import type { TimelineEvent } from '@/components/detail/ActivityTimeline'
const STATUS_BADGE = {
open: 'success',
maintenance: 'warning',
closed: 'danger',
} as const
export function BranchDetailPage() {
const { id } = useParams()
const branch = branches.find((b) => b.id === id)
const profile = branch ? branchProfiles[branch.id] : undefined
const branchId = branch?.id ?? ''
const staff = useMemo(() => branchStaff[branchId] ?? [], [branchId])
const series = useMemo(() => branchOrdersSeries[branchId] ?? [], [branchId])
const stock = useMemo(() => stockRows.filter((r) => r.branchId === branchId), [branchId])
const timeline = useMemo<TimelineEvent[]>(() => {
const fromSettings: TimelineEvent[] = settingsActivity
.filter((s) => s.area === 'Branches')
.map((s) => ({
id: s.id,
at: s.at,
title: s.change,
detail: `${s.actor} · ${s.detail}`,
tone: 'default',
}))
const fromAudit: TimelineEvent[] = auditLog
.filter((e) => e.target === branchId)
.map((e) => ({
id: e.id,
at: e.at,
title: `${e.action} — ${e.actor}`,
detail: e.note ?? e.targetLabel,
tone: e.outcome === 'failed' ? 'danger' : e.security ? 'warning' : 'success',
}))
return [...fromSettings, ...fromAudit].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
}, [branchId])
if (!branch || !profile) {
return (
<DetailPageLayout backTo="/settings" title="Branch not found">
<Button asChild>
<Link to="/settings">Back to settings</Link>
</Button>
</DetailPageLayout>
)
}
return (
<DetailPageLayout
backTo="/settings"
backLabel="Back to settings"
title={branch.name}
subtitle={`${branch.zone} · ${profile.address}`}
badges={<Badge variant={STATUS_BADGE[profile.status]}>{profile.status}</Badge>}
actions={
<>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.message(`Edit ${branch.name} (mock)`)}
>
<Pencil className="h-3.5 w-3.5" />
Edit branch
</Button>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`Stock report queued for ${branch.name}`)}
>
<Download className="h-3.5 w-3.5" />
Export stock report
</Button>
<Button
size="sm"
className="h-9 gap-1.5"
onClick={() => toast.success(`Calling ${profile.manager} on ${branch.phone}`)}
>
<Phone className="h-3.5 w-3.5" />
Call branch
</Button>
</>
}
sidebar={
<DetailSection title="Branch activity" description="Settings changes and audit events">
<ActivityTimeline events={timeline} empty="No branch activity recorded" />
</DetailSection>
}
>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-4">
<StatCard label="Orders today" value={profile.ordersToday} icon={ShoppingBag} tone="brand" />
<StatCard label="Revenue today" value={formatEtb(profile.revenueTodayEtb)} icon={Receipt} tone="forest" />
<StatCard label="SKUs stocked" value={profile.skuCount} icon={Boxes} tone="muted" />
<StatCard
label="Low-stock lines"
value={profile.lowStockCount}
hint={`${profile.expiringSoonCount} expiring soon`}
icon={AlertTriangle}
tone="orange"
/>
</div>
<DetailSection title="Branch profile">
<KeyValueGrid
columns={2}
items={[
{ label: 'Manager', value: profile.manager },
{ label: 'Phone', value: <span className="whitespace-nowrap">{branch.phone}</span> },
{ label: 'Email', value: profile.email },
{ label: 'Address', value: profile.address },
{ label: 'Opening hours', value: profile.openingHours },
{ label: 'Zone', value: branch.zone },
{ label: 'Opened', value: new Date(profile.openedAt).toLocaleDateString() },
{ label: 'Status', value: <Badge variant={STATUS_BADGE[profile.status]}>{profile.status}</Badge> },
]}
/>
</DetailSection>
<ChartCard title="Orders by day" description="Order volume against revenue for the last week">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis yAxisId="orders" tick={{ fontSize: 12 }} width={32} />
<YAxis
yAxisId="revenue"
orientation="right"
tick={{ fontSize: 12 }}
width={48}
tickFormatter={(v) => `${Math.round(Number(v) / 1000)}k`}
/>
<Tooltip formatter={(value, name) => (name === 'Revenue' ? formatEtb(Number(value)) : String(value))} />
<Legend />
<Bar yAxisId="orders" dataKey="orders" name="Orders" fill="#A8C73A" radius={[6, 6, 0, 0]} />
<Line
yAxisId="revenue"
type="monotone"
dataKey="revenue"
name="Revenue"
stroke="#E28A1A"
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
</ChartCard>
<DetailSection title="Staff" description={`${profile.staffCount} people assigned to this branch`}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead className="hidden md:table-cell">Shift</TableHead>
<TableHead className="hidden lg:table-cell">Phone</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{staff.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No staff assigned yet
</TableCell>
</TableRow>
) : (
staff.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
<span className="flex items-center gap-2.5">
<PersonAvatar name={s.name} avatarUrl={s.avatarUrl} size="sm" />
{s.name}
</span>
</TableCell>
<TableCell>{s.role}</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">{s.shift}</TableCell>
<TableCell className="hidden whitespace-nowrap lg:table-cell">{s.phone}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
<DetailSection
title="Stock health"
description={`${profile.lowStockCount} low-stock lines · ${profile.expiringSoonCount} expiring soon · ${profile.skuCount} SKUs`}
>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>SKU</TableHead>
<TableHead>Name</TableHead>
<TableHead>Qty</TableHead>
<TableHead className="hidden md:table-cell">Batch</TableHead>
<TableHead className="hidden lg:table-cell">Expiry</TableHead>
<TableHead>Alert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stock.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
No stock lines for this branch
</TableCell>
</TableRow>
) : (
stock.map((r) => (
<TableRow key={`${r.sku}-${r.batch}`}>
<TableCell className="font-medium">
<Link to={`/stock/${r.sku}`} className="text-brand-700 hover:underline">
{r.sku}
</Link>
</TableCell>
<TableCell>{r.name}</TableCell>
<TableCell>{r.qty}</TableCell>
<TableCell className="hidden md:table-cell">{r.batch}</TableCell>
<TableCell className="hidden whitespace-nowrap lg:table-cell">{r.expiry}</TableCell>
<TableCell>
{r.qty < 20 ? (
<Badge variant="warning">low stock</Badge>
) : (
<Badge variant="success">ok</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -1,16 +1,227 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import {
AlertTriangle,
ArrowLeftRight,
Ban,
Bike,
Building2,
Download,
Eye,
MessageCircle,
Pencil,
Users,
} from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { ChartCard } from '@/components/charts/ChartCard'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { SelectNative } from '@/components/ui/select-native'
import { TableToolbar } from '@/components/table/TableToolbar'
import type { TableFilter } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { auditLog, branchProfiles, branches, configIssues, riderStats, riders, settingsActivity } from '@/mocks/data'
import { demoAccounts } from '@/config/permissions'
import { formatEtb } from '@/lib/utils'
const BRANCH_STATUS_BADGE = {
open: 'success',
maintenance: 'warning',
closed: 'danger',
} as const
const RIDER_STATUS_BADGE = {
on_shift: 'success',
on_break: 'warning',
off_shift: 'muted',
} as const
const RIDER_STATUS_LABEL = {
on_shift: 'on shift',
on_break: 'on break',
off_shift: 'off shift',
} as const
const branchStatusOptions = [
{ value: 'all', label: 'All statuses' },
{ value: 'open', label: 'Open' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'closed', label: 'Closed' },
]
const riderShiftOptions = [
{ value: 'all', label: 'All shift states' },
{ value: 'on_shift', label: 'On shift' },
{ value: 'on_break', label: 'On break' },
{ value: 'off_shift', label: 'Off shift' },
]
export function SettingsPage() {
const { t } = useTranslation()
const [locale, setLocale] = useState('en')
const [lowStockThreshold, setLowStockThreshold] = useState('20')
const [sessionTimeout, setSessionTimeout] = useState('30')
const [branchQuery, setBranchQuery] = useState('')
const [branchStatus, setBranchStatus] = useState('all')
const [riderQuery, setRiderQuery] = useState('')
const [riderBranch, setRiderBranch] = useState('all')
const [riderShift, setRiderShift] = useState('all')
const [areaFilter, setAreaFilter] = useState('all')
const activeRiders = useMemo(
() => riders.filter((r) => riderStats[r.id]?.status !== 'off_shift').length,
[],
)
const filteredBranches = useMemo(() => {
const needle = branchQuery.trim().toLowerCase()
return branches.filter((b) => {
const profile = branchProfiles[b.id]
if (branchStatus !== 'all' && profile?.status !== branchStatus) return false
if (!needle) return true
return b.name.toLowerCase().includes(needle) || b.zone.toLowerCase().includes(needle)
})
}, [branchQuery, branchStatus])
const filteredRiders = useMemo(() => {
const needle = riderQuery.trim().toLowerCase()
return riders.filter((r) => {
if (riderBranch !== 'all' && r.branchId !== riderBranch) return false
if (riderShift !== 'all' && riderStats[r.id]?.status !== riderShift) return false
if (!needle) return true
return r.name.toLowerCase().includes(needle) || r.telegramId.toLowerCase().includes(needle)
})
}, [riderQuery, riderBranch, riderShift])
const filteredActivity = useMemo(
() => (areaFilter === 'all' ? settingsActivity : settingsActivity.filter((s) => s.area === areaFilter)),
[areaFilter],
)
const areaOptions = useMemo(
() => [
{ value: 'all', label: 'All areas' },
...Array.from(new Set(settingsActivity.map((s) => s.area))).map((area) => ({ value: area, label: area })),
],
[],
)
const branchFilters: TableFilter[] = [
{
id: 'status',
label: t('common.status'),
value: branchStatus,
onChange: setBranchStatus,
options: branchStatusOptions,
},
]
const riderFilters: TableFilter[] = [
{
id: 'branch',
label: t('common.branch'),
value: riderBranch,
onChange: setRiderBranch,
options: [
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.id, label: b.name })),
],
},
{
id: 'shift',
label: 'Shift status',
value: riderShift,
onChange: setRiderShift,
options: riderShiftOptions,
},
]
const activityFilters: TableFilter[] = [
{ id: 'area', label: 'Area', value: areaFilter, onChange: setAreaFilter, options: areaOptions },
]
const branchChartData = useMemo(
() =>
branches.map((b) => ({
name: b.zone,
orders: branchProfiles[b.id]?.ordersToday ?? 0,
revenue: branchProfiles[b.id]?.revenueTodayEtb ?? 0,
})),
[],
)
const settingsTimeline = useMemo(
() =>
auditLog
.filter((e) => e.category === 'settings')
.slice()
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
.slice(0, 8)
.map((e) => ({
id: e.id,
at: e.at,
title: `${e.action} — ${e.actor}`,
detail: e.note ?? e.targetLabel,
tone:
e.outcome === 'failed' ? ('danger' as const) : e.security ? ('warning' as const) : ('success' as const),
})),
[],
)
return (
<PageShell title={t('settings.title')} subtitle={t('settings.subtitle')}>
<Card className="max-w-xl">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Branches" value={branches.length} hint="Live in the console" icon={Building2} tone="brand" />
<StatCard
label="Active delivery people"
value={activeRiders}
hint={`${riders.length} riders on the roster`}
icon={Bike}
tone="forest"
/>
<StatCard
label="Staff accounts"
value={demoAccounts.length}
hint="Roles managed under Team"
icon={Users}
tone="muted"
/>
<StatCard
label="Open config issues"
value={configIssues.length}
hint="Needs an admin decision"
icon={AlertTriangle}
tone="orange"
/>
</div>
<Tabs defaultValue="preferences">
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<TabsList>
<TabsTrigger value="preferences">Preferences</TabsTrigger>
<TabsTrigger value="branches">Branches</TabsTrigger>
<TabsTrigger value="riders">Delivery people</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
</div>
<TabsContent value="preferences">
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Platform</CardTitle>
</CardHeader>
@ -23,9 +234,345 @@ export function SettingsPage() {
<Label>API base URL</Label>
<Input defaultValue={import.meta.env.VITE_API_BASE_URL || 'https://api.gishen.example/v1'} />
</div>
<div className="space-y-1">
<Label htmlFor="default-locale">Default locale</Label>
<SelectNative id="default-locale" value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="en">English (en)</option>
<option value="am">አማርኛ (am)</option>
</SelectNative>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="low-stock-threshold">Low-stock threshold (units)</Label>
<Input
id="low-stock-threshold"
inputMode="numeric"
value={lowStockThreshold}
onChange={(e) => setLowStockThreshold(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="session-timeout">Session timeout (minutes)</Label>
<Input
id="session-timeout"
inputMode="numeric"
value={sessionTimeout}
onChange={(e) => setSessionTimeout(e.target.value)}
/>
</div>
</div>
<Button onClick={() => toast.success('Settings saved (local demo)')}>{t('common.save')}</Button>
</CardContent>
</Card>
<DetailSection
title="Configuration issues"
description="Raised by the nightly configuration audit"
>
<div className="space-y-3">
{configIssues.map((issue) => (
<div
key={issue.id}
className="flex flex-col gap-2 rounded-xl border p-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{issue.message}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{issue.area}</p>
</div>
<Badge variant={issue.severity === 'warning' ? 'warning' : 'secondary'}>{issue.severity}</Badge>
</div>
))}
{configIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('common.noResults')}</p>
) : null}
</div>
</DetailSection>
</div>
</TabsContent>
<TabsContent value="branches">
<div className="space-y-5">
<ChartCard
title="Today by branch"
description="Orders on the left axis, revenue on the right"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={branchChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis yAxisId="orders" tick={{ fontSize: 12 }} width={32} />
<YAxis
yAxisId="revenue"
orientation="right"
tick={{ fontSize: 12 }}
width={48}
tickFormatter={(v) => `${Math.round(Number(v) / 1000)}k`}
/>
<Tooltip
formatter={(value, name) => (name === 'Revenue' ? formatEtb(Number(value)) : String(value))}
/>
<Legend />
<Bar yAxisId="orders" dataKey="orders" name="Orders" fill="#A8C73A" radius={[6, 6, 0, 0]} />
<Bar yAxisId="revenue" dataKey="revenue" name="Revenue" fill="#E28A1A" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<Card className="overflow-hidden">
<TableToolbar
search={branchQuery}
onSearchChange={setBranchQuery}
searchPlaceholder="Search branch or zone…"
resultCount={filteredBranches.length}
filters={branchFilters}
onClearFilters={() => {
setBranchQuery('')
setBranchStatus('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead className="hidden md:table-cell">Zone</TableHead>
<TableHead className="hidden lg:table-cell">Phone</TableHead>
<TableHead className="hidden lg:table-cell">Manager</TableHead>
<TableHead className="hidden xl:table-cell">Staff</TableHead>
<TableHead className="hidden md:table-cell">Orders today</TableHead>
<TableHead className="hidden lg:table-cell">Revenue today</TableHead>
<TableHead className="hidden xl:table-cell">Low stock</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredBranches.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filteredBranches.map((b) => {
const profile = branchProfiles[b.id]
return (
<TableRow key={b.id}>
<TableCell className="font-medium">
<Link to={`/settings/branches/${b.id}`} className="text-brand-700 hover:underline">
{b.name}
</Link>
</TableCell>
<TableCell className="hidden md:table-cell">{b.zone}</TableCell>
<TableCell className="hidden whitespace-nowrap lg:table-cell">{b.phone}</TableCell>
<TableCell className="hidden lg:table-cell">{profile?.manager ?? '—'}</TableCell>
<TableCell className="hidden xl:table-cell">{profile?.staffCount ?? 0}</TableCell>
<TableCell className="hidden md:table-cell">{profile?.ordersToday ?? 0}</TableCell>
<TableCell className="hidden whitespace-nowrap lg:table-cell">
{formatEtb(profile?.revenueTodayEtb ?? 0)}
</TableCell>
<TableCell className="hidden xl:table-cell">{profile?.lowStockCount ?? 0}</TableCell>
<TableCell>
{profile ? (
<Badge variant={BRANCH_STATUS_BADGE[profile.status]}>{profile.status}</Badge>
) : (
<Badge variant="muted">unknown</Badge>
)}
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'View',
icon: <Eye className="h-3.5 w-3.5" />,
to: `/settings/branches/${b.id}`,
},
]}
items={[
{
label: 'Edit branch',
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: () => toast.message(`Edit ${b.name} (mock)`),
},
{
label: 'Export stock report',
icon: <Download className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Stock report queued for ${b.name}`),
},
{
label: 'Suspend branch',
icon: <Ban className="h-3.5 w-3.5" />,
variant: 'destructive',
onClick: () => toast.error(`${b.name} suspension needs a second approver`),
},
]}
/>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="riders">
<Card className="overflow-hidden">
<TableToolbar
search={riderQuery}
onSearchChange={setRiderQuery}
searchPlaceholder="Search name or Telegram id…"
resultCount={filteredRiders.length}
filters={riderFilters}
onClearFilters={() => {
setRiderQuery('')
setRiderBranch('all')
setRiderShift('all')
}}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead className="hidden lg:table-cell">Phone</TableHead>
<TableHead className="hidden md:table-cell">Telegram id</TableHead>
<TableHead className="hidden xl:table-cell">Zone</TableHead>
<TableHead className="hidden lg:table-cell">Branch</TableHead>
<TableHead className="hidden md:table-cell">Trips today</TableHead>
<TableHead className="hidden md:table-cell">On-time</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRiders.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filteredRiders.map((r) => {
const stats = riderStats[r.id]
return (
<TableRow key={r.id}>
<TableCell className="font-medium">
<Link to={`/dispatch/riders/${r.id}`} className="text-brand-700 hover:underline">
{r.name}
</Link>
</TableCell>
<TableCell className="hidden whitespace-nowrap lg:table-cell">{r.phone}</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant="outline" className="font-mono">
{r.telegramId}
</Badge>
</TableCell>
<TableCell className="hidden xl:table-cell">{r.zone}</TableCell>
<TableCell className="hidden lg:table-cell">
{branches.find((b) => b.id === r.branchId)?.name ?? '—'}
</TableCell>
<TableCell className="hidden md:table-cell">{stats?.tripsToday ?? 0}</TableCell>
<TableCell className="hidden whitespace-nowrap md:table-cell">
{stats ? `${stats.onTimeRate}%` : '—'}
</TableCell>
<TableCell>
{stats ? (
<Badge variant={RIDER_STATUS_BADGE[stats.status]}>
{RIDER_STATUS_LABEL[stats.status]}
</Badge>
) : (
<Badge variant="muted">unknown</Badge>
)}
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'View',
icon: <Eye className="h-3.5 w-3.5" />,
to: `/dispatch/riders/${r.id}`,
},
]}
items={[
{
label: 'Message on Telegram',
icon: <MessageCircle className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Telegram message drafted to ${r.telegramId}`),
},
{
label: 'Reassign branch',
icon: <ArrowLeftRight className="h-3.5 w-3.5" />,
onClick: () => toast.message(`Branch reassignment for ${r.name} (mock)`),
},
]}
/>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="activity">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_22rem]">
<Card className="overflow-hidden">
<TableToolbar
resultCount={filteredActivity.length}
filters={activityFilters}
onClearFilters={() => setAreaFilter('all')}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>When</TableHead>
<TableHead className="hidden md:table-cell">Actor</TableHead>
<TableHead>Area</TableHead>
<TableHead>Change</TableHead>
<TableHead className="hidden lg:table-cell">Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredActivity.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filteredActivity.map((s) => (
<TableRow key={s.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">
{new Date(s.at).toLocaleString()}
</TableCell>
<TableCell className="hidden md:table-cell">{s.actor}</TableCell>
<TableCell>
<Badge variant="secondary">{s.area}</Badge>
</TableCell>
<TableCell className="font-medium">{s.change}</TableCell>
<TableCell className="hidden text-muted-foreground lg:table-cell">{s.detail}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<DetailSection title="Settings audit trail" description="Latest settings events from the audit log">
<ActivityTimeline events={settingsTimeline} empty="No settings events recorded" />
</DetailSection>
</div>
</TabsContent>
</Tabs>
</PageShell>
)
}

View File

@ -1,60 +1,133 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMemo, useState, type FormEvent } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { CreatePageLayout, createFieldWide, createFormGrid } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { branches, stockRows } from '@/mocks/data'
import { cn } from '@/lib/utils'
import { branches, catalogItems, findCatalogItem, productCategories, stockRows } from '@/mocks/data'
import type { ProductCategory } from '@/mocks/data'
function categoryForItem(therapeuticClass: string, productType: string): ProductCategory {
const map: Record<string, ProductCategory> = {
Antibiotic: 'Antibiotics',
Analgesic: 'Analgesics',
'Opioid analgesic': 'Analgesics',
Antihistamine: 'Respiratory',
Bronchodilator: 'Respiratory',
Antidiabetic: 'Chronic care',
Antihypertensive: 'Chronic care',
Anxiolytic: 'Chronic care',
'Vitamin / supplement': 'Vitamins & supplements',
'Iron supplement': 'Maternal & child',
Dermatological: 'Dermatology',
Skincare: 'Dermatology',
Rehydration: 'First aid',
Corticosteroid: 'Dermatology',
}
if (map[therapeuticClass]) return map[therapeuticClass]
if (productType === 'Beauty' || productType === 'Wellness') return 'Vitamins & supplements'
if (productType === 'Medical device') return 'First aid'
return productCategories[0]
}
export function CreateStockPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const presetItemId = searchParams.get('item') ?? ''
const initialItem = presetItemId ? findCatalogItem(presetItemId) : undefined
const [itemId, setItemId] = useState(initialItem?.id ?? '')
const selected = useMemo(() => (itemId ? findCatalogItem(itemId) : undefined), [itemId])
const activeItems = useMemo(
() => catalogItems.filter((item) => item.status === 'active'),
[],
)
const [form, setForm] = useState({
sku: '',
name: '',
branchId: branches[0]?.id ?? '',
qty: '',
batch: '',
expiry: '',
unitEtb: initialItem ? String(initialItem.sellingPriceEtb) : '',
})
const onItemChange = (nextId: string) => {
setItemId(nextId)
const item = findCatalogItem(nextId)
if (item) {
setForm((prev) => ({
...prev,
unitEtb: String(item.sellingPriceEtb),
}))
}
}
const onSubmit = (e: FormEvent) => {
e.preventDefault()
if (!selected) {
toast.error('Pick a catalogue item first')
return
}
const qty = Number(form.qty) || 0
stockRows.unshift({
sku: form.sku,
name: form.name,
sku: selected.sku,
name: selected.brandName || selected.genericName,
category: categoryForItem(selected.therapeuticClass, selected.productType),
branchId: form.branchId,
qty,
batch: form.batch,
expiry: form.expiry,
erpQty: qty,
unitEtb: Number(form.unitEtb) || selected.sellingPriceEtb,
itemId: selected.id,
reserved: 0,
})
toast.success('Stock line added')
navigate('/stock')
toast.success(`Stock received for ${selected.sku}`)
navigate(`/stock/${selected.sku}`)
}
return (
<CreatePageLayout
title="Add stock line"
subtitle="Manual inventory entry for a branch"
title="Receive stock"
subtitle="Add a batch / lot for an existing catalogue item — create the item first if it is missing."
backTo="/stock"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/stock')}>
Cancel
</Button>
<Button type="submit" form="create-stock-form">
<Button type="submit" form="create-stock-form" disabled={!selected}>
Save stock line
</Button>
</>
}
>
<form id="create-stock-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<form id="create-stock-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Catalogue item *</Label>
<SelectNative required value={itemId} onChange={(e) => onItemChange(e.target.value)}>
<option value="">Select an item…</option>
{activeItems.map((item) => (
<option key={item.id} value={item.id}>
{item.sku} · {item.brandName || item.genericName} ({item.packSize})
</option>
))}
</SelectNative>
<p className="text-[11px] text-muted-foreground">
Missing the product?{' '}
<Link to="/items/new" className="text-brand-700 hover:underline">
Create a medication item
</Link>{' '}
first.
</p>
</div>
<div className="space-y-1.5">
<Label>SKU</Label>
<Input required value={form.sku} onChange={(e) => setForm({ ...form, sku: e.target.value })} />
<Label>Internal SKU</Label>
<Input readOnly value={selected?.sku ?? '—'} className="bg-muted/40 font-mono" />
</div>
<div className="space-y-1.5">
<Label>Branch</Label>
@ -69,9 +142,29 @@ export function CreateStockPage() {
))}
</SelectNative>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label>Product name</Label>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Display name</Label>
<Input
readOnly
value={
selected
? `${selected.brandName || selected.genericName}${
selected.ingredients[0]?.strength ? ` ${selected.ingredients[0].strength}` : ''
}`
: ''
}
className="bg-muted/40"
/>
</div>
<div className="space-y-1.5">
<Label>Unit price (ETB)</Label>
<Input
required
type="number"
min={0}
value={form.unitEtb}
onChange={(e) => setForm({ ...form, unitEtb: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Quantity</Label>
@ -87,11 +180,11 @@ export function CreateStockPage() {
<Label>Batch</Label>
<Input required value={form.batch} onChange={(e) => setForm({ ...form, batch: e.target.value })} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<div className="space-y-1.5">
<Label>Expiry (YYYY-MM)</Label>
<Input
required
placeholder="2027-01"
placeholder="2027-06"
value={form.expiry}
onChange={(e) => setForm({ ...form, expiry: e.target.value })}
/>

View File

@ -0,0 +1,122 @@
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { RefreshCw, ArrowLeftRight } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { ChartCard } from '@/components/charts/ChartCard'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { branches, stockMovements, stockRows } from '@/mocks/data'
import { stockMovement } from '@/mocks/charts'
export function StockDetailPage() {
const { sku } = useParams()
const row = stockRows.find((r) => r.sku === sku)
if (!row) {
return (
<DetailPageLayout backTo="/stock" title="SKU not found">
<Button asChild>
<Link to="/stock">Back to stock</Link>
</Button>
</DetailPageLayout>
)
}
const branch = branches.find((b) => b.id === row.branchId)
const moves = stockMovements[row.sku] ?? []
const mismatch = row.qty !== row.erpQty
return (
<DetailPageLayout
backTo="/stock"
backLabel="Back to stock"
title={row.sku}
subtitle={row.name}
badges={
<>
{row.qty < 20 ? <Badge variant="warning">Low stock</Badge> : <Badge variant="success">Healthy</Badge>}
{mismatch ? <Badge variant="danger">ERP mismatch</Badge> : null}
</>
}
actions={
<>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5"
onClick={() => toast.success(`ERP sync queued for ${row.sku}`)}
>
<RefreshCw className="h-3.5 w-3.5" />
Sync ERP
</Button>
<Button
size="sm"
className="h-9 gap-1.5"
onClick={() => toast.message('Inter-branch transfer draft created')}
>
<ArrowLeftRight className="h-3.5 w-3.5" />
Transfer
</Button>
</>
}
>
<div className="grid gap-5 lg:grid-cols-2">
<DetailSection title="Inventory">
<KeyValueGrid
columns={2}
items={[
{ label: 'On hand', value: String(row.qty) },
{ label: 'ERP qty', value: String(row.erpQty) },
{ label: 'Batch', value: row.batch },
{ label: 'Expiry', value: row.expiry },
{ label: 'Branch', value: branch?.name },
{ label: 'Reorder hint', value: row.qty < 20 ? 'Reorder now' : 'OK' },
]}
/>
</DetailSection>
<ChartCard title="4-week movement" description="Demo inflow vs outflow">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stockMovement}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="week" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Area type="monotone" dataKey="in" stroke="#A8C73A" fill="#A8C73A33" name="In" />
<Area type="monotone" dataKey="out" stroke="#E28A1A" fill="#E28A1A22" name="Out" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
<DetailSection title="Recent movements" className="lg:col-span-2">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>When</TableHead>
<TableHead>Type</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Note</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{moves.map((m) => (
<TableRow key={m.id}>
<TableCell>{new Date(m.at).toLocaleString()}</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className={m.qty < 0 ? 'text-destructive' : 'text-emerald-700'}>
{m.qty > 0 ? `+${m.qty}` : m.qty}
</TableCell>
<TableCell>{m.note}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DetailSection>
</div>
</DetailPageLayout>
)
}

View File

@ -1,9 +1,10 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Plus, RefreshCw } from 'lucide-react'
import { Boxes, CalendarClock, Eye, PackageX, Plus, RefreshCw, TrendingDown, Wallet } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
@ -13,19 +14,66 @@ import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/tab
import { RowActions } from '@/components/table/RowActions'
import { branches, stockRows } from '@/mocks/data'
import { Input } from '@/components/ui/input'
import { useAuthStore } from '@/zustand/authStore'
import { formatEtb } from '@/lib/utils'
/** Same threshold the dashboard and procurement use to flag low stock. */
const LOW_STOCK_THRESHOLD = 20
const NEAR_EXPIRY_MONTHS = 3
/** Mock expiry is a `YYYY-MM` string, so compare whole months instead of parsing a full date. */
function monthsUntilExpiry(expiry: string, from: Date) {
const [year, month] = expiry.split('-').map(Number)
if (!year || !month) return Number.POSITIVE_INFINITY
return (year - from.getFullYear()) * 12 + (month - 1 - from.getMonth())
}
export function StockPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const [searchParams, setSearchParams] = useSearchParams()
const [entry, setEntry] = useState('')
const [rows, setRows] = useState(stockRows)
const [q, setQ] = useState('')
const [branchFilter, setBranchFilter] = useState('all')
const [alertFilter, setAlertFilter] = useState('all')
/** The alert filter lives in the URL so the KPI cards can link into a filtered inventory view. */
const alertFilter = searchParams.get('alert') ?? 'all'
const setAlertFilter = (value: string) => {
const next = new URLSearchParams(searchParams)
if (value === 'all') next.delete('alert')
else next.set('alert', value)
setSearchParams(next, { replace: true })
}
/** Branch-scoped roles only ever see their own shelves, so the KPIs and the table share one base list. */
const canPickBranch = !user?.branchId
const scoped = useMemo(
() => (user?.branchId ? rows.filter((r) => r.branchId === user.branchId) : rows),
[rows, user],
)
const kpis = useMemo(() => {
const now = new Date()
return {
skus: new Set(scoped.map((r) => r.sku)).size,
lines: scoped.length,
branches: new Set(scoped.map((r) => r.branchId)).size,
low: scoped.filter((r) => r.qty > 0 && r.qty < LOW_STOCK_THRESHOLD).length,
out: scoped.filter((r) => r.qty === 0).length,
expiring: scoped.filter((r) => monthsUntilExpiry(r.expiry, now) <= NEAR_EXPIRY_MONTHS).length,
retailValueEtb: scoped.reduce((sum, r) => sum + r.qty * r.unitEtb, 0),
}
}, [scoped])
const filtered = useMemo(() => {
let list = rows
if (branchFilter !== 'all') list = list.filter((r) => r.branchId === branchFilter)
if (alertFilter === 'low') list = list.filter((r) => r.qty < 20)
const now = new Date()
let list = scoped
if (canPickBranch && branchFilter !== 'all') list = list.filter((r) => r.branchId === branchFilter)
if (alertFilter === 'low') list = list.filter((r) => r.qty > 0 && r.qty < LOW_STOCK_THRESHOLD)
if (alertFilter === 'out') list = list.filter((r) => r.qty === 0)
if (alertFilter === 'expiry') list = list.filter((r) => monthsUntilExpiry(r.expiry, now) <= NEAR_EXPIRY_MONTHS)
if (alertFilter === 'mismatch') list = list.filter((r) => r.qty !== r.erpQty)
if (q) {
const needle = q.toLowerCase()
@ -34,7 +82,7 @@ export function StockPage() {
)
}
return list
}, [rows, q, branchFilter, alertFilter])
}, [scoped, canPickBranch, q, branchFilter, alertFilter])
const duplicates =
entry.length > 2
@ -52,6 +100,47 @@ export function StockPage() {
return (
<PageShell title={t('stock.title')} subtitle={t('stock.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<StatCard
label="Total SKUs"
value={kpis.skus}
hint={`${kpis.lines} stock lines · ${kpis.branches} branch${kpis.branches === 1 ? '' : 'es'}`}
icon={Boxes}
tone="brand"
/>
<StatCard
label="Low stock"
value={kpis.low}
hint={`Under ${LOW_STOCK_THRESHOLD} units left`}
icon={TrendingDown}
tone="orange"
to="/stock?alert=low"
/>
<StatCard
label="Out of stock"
value={kpis.out}
hint="Nothing on the shelf"
icon={PackageX}
tone="muted"
to="/stock?alert=out"
/>
<StatCard
label="Expiring soon"
value={kpis.expiring}
hint={`Within ${NEAR_EXPIRY_MONTHS} months`}
icon={CalendarClock}
tone="orange"
to="/stock?alert=expiry"
/>
<StatCard
label="Inventory value"
value={formatEtb(kpis.retailValueEtb)}
hint="On hand at shelf price"
icon={Wallet}
tone="forest"
/>
</div>
<Tabs defaultValue="inventory">
<TabsList>
<TabsTrigger value="inventory">Inventory</TabsTrigger>
@ -68,6 +157,7 @@ export function StockPage() {
resultCount={filtered.length}
filters={
<>
{canPickBranch ? (
<FilterSelect
label="Branch"
value={branchFilter}
@ -77,6 +167,7 @@ export function StockPage() {
...branches.map((b) => ({ value: b.id, label: b.name })),
]}
/>
) : null}
<FilterSelect
label="Alert"
value={alertFilter}
@ -84,6 +175,8 @@ export function StockPage() {
options={[
{ value: 'all', label: 'All stock' },
{ value: 'low', label: 'Low stock' },
{ value: 'out', label: 'Out of stock' },
{ value: 'expiry', label: 'Expiring soon' },
{ value: 'mismatch', label: 'ERP mismatch' },
]}
/>
@ -121,16 +214,29 @@ export function StockPage() {
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r) => (
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
) : (
filtered.map((r) => (
<TableRow key={`${r.sku}-${r.branchId}`}>
<TableCell className="font-medium">{r.sku}</TableCell>
<TableCell className="font-medium">
<Link to={`/stock/${r.sku}`} className="text-brand-700 hover:underline">
{r.sku}
</Link>
</TableCell>
<TableCell>{r.name}</TableCell>
<TableCell>{branches.find((b) => b.id === r.branchId)?.name}</TableCell>
<TableCell>{r.qty}</TableCell>
<TableCell>{r.batch}</TableCell>
<TableCell>{r.expiry}</TableCell>
<TableCell>
{r.qty < 20 ? (
{r.qty === 0 ? (
<Badge variant="danger">out of stock</Badge>
) : r.qty < LOW_STOCK_THRESHOLD ? (
<Badge variant="warning">low stock</Badge>
) : (
<Badge variant="success">ok</Badge>
@ -138,8 +244,13 @@ export function StockPage() {
</TableCell>
<TableCell>
<RowActions
primary={
r.qty !== r.erpQty
primary={[
{
label: 'View',
icon: <Eye className="h-3.5 w-3.5" />,
to: `/stock/${r.sku}`,
},
...(r.qty !== r.erpQty
? [
{
label: 'Sync ERP',
@ -147,12 +258,13 @@ export function StockPage() {
onClick: () => resolve(r.sku),
},
]
: undefined
}
: []),
]}
/>
</TableCell>
</TableRow>
))}
))
)}
</TableBody>
</Table>
</CardContent>

View File

@ -1,11 +1,12 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { CreatePageLayout, createFieldWide, createFormGrid } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { cn } from '@/lib/utils'
import { demoAccounts } from '@/config/permissions'
import { branches } from '@/mocks/data'
import type { StaffRole } from '@/types'
@ -25,6 +26,7 @@ export function CreateTeamMemberPage() {
e.preventDefault()
const branch = branches.find((b) => b.id === form.branchId)
demoAccounts.push({
id: `tm-${Date.now()}`,
email: form.email,
password: 'demo123',
role: form.role,
@ -52,12 +54,12 @@ export function CreateTeamMemberPage() {
</>
}
>
<form id="create-team-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5 sm:col-span-2">
<form id="create-team-form" className={createFormGrid} onSubmit={onSubmit}>
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Full name</Label>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<div className={cn('space-y-1.5', createFieldWide)}>
<Label>Email</Label>
<Input
required

View File

@ -0,0 +1,109 @@
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Mail, ShieldOff, KeyRound } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { ChartCard } from '@/components/charts/ChartCard'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { PersonAvatar } from '@/components/ui/avatar'
import { demoAccounts, roleModules } from '@/config/permissions'
import { customerPointsSeries } from '@/mocks/charts'
export function TeamMemberDetailPage() {
const { id } = useParams()
const member = demoAccounts.find((a) => a.id === id)
if (!member) {
return (
<DetailPageLayout backTo="/team" title="Team member not found">
<Button asChild>
<Link to="/team">Back to team</Link>
</Button>
</DetailPageLayout>
)
}
const modules = roleModules[member.role] ?? []
return (
<DetailPageLayout
backTo="/team"
backLabel="Back to team"
title={member.name}
subtitle={member.email}
badges={
<>
<PersonAvatar name={member.name} avatarUrl={member.avatarUrl} size="md" />
<Badge variant="secondary">{member.role}</Badge>
{member.branchName ? <Badge variant="outline">{member.branchName}</Badge> : <Badge variant="outline">Platform</Badge>}
</>
}
actions={
<>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => toast.success('Password reset email queued')}>
<KeyRound className="h-3.5 w-3.5" />
Reset password
</Button>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => toast.message('Invite re-sent')}>
<Mail className="h-3.5 w-3.5" />
Resend invite
</Button>
<Button size="sm" variant="destructive" className="h-9 gap-1.5" onClick={() => toast.message('Account disabled (mock)')}>
<ShieldOff className="h-3.5 w-3.5" />
Disable
</Button>
</>
}
sidebar={
<DetailSection title="Recent access">
<ActivityTimeline
events={[
{ id: '1', at: '2026-08-06T10:00:00Z', title: 'Signed in', detail: 'Chrome · Addis Ababa', tone: 'success' },
{ id: '2', at: '2026-08-05T16:20:00Z', title: 'Updated settings', tone: 'default' },
{ id: '3', at: '2026-08-04T09:10:00Z', title: 'Viewed audit log', tone: 'default' },
]}
/>
</DetailSection>
}
>
<div className="space-y-5">
<DetailSection title="Profile">
<KeyValueGrid
columns={3}
items={[
{ label: 'Role', value: member.role },
{ label: 'Branch scope', value: member.branchName ?? 'All branches' },
{ label: 'Email', value: member.email },
{ label: 'Modules', value: `${modules.length} enabled` },
{ label: 'Status', value: 'Active' },
{ label: 'Demo password', value: 'demo123' },
]}
/>
</DetailSection>
<DetailSection title="Module access">
<div className="flex flex-wrap gap-2">
{modules.map((m) => (
<Badge key={m} variant="outline">
{m}
</Badge>
))}
</div>
</DetailSection>
<ChartCard title="Session activity (demo)" description="Placeholder engagement trend">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={customerPointsSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Area type="monotone" dataKey="points" stroke="#141414" fill="#A8C73A44" name="Actions" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
</div>
</DetailPageLayout>
)
}

View File

@ -1,14 +1,20 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Plus } from 'lucide-react'
import { Eye, MailPlus, Plus, ShieldCheck, UserCheck, Users } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { StatCard } from '@/components/dashboard/StatCard'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { demoAccounts } from '@/config/permissions'
import { RowActions } from '@/components/table/RowActions'
import { PersonAvatar } from '@/components/ui/avatar'
import { demoAccounts, roleModules } from '@/config/permissions'
import { staffInvitations, staffProfiles } from '@/mocks/data'
import { useAuthStore } from '@/zustand/authStore'
import { formatRelative } from '@/lib/utils'
import type { StaffRole } from '@/types'
const roles: { value: string; label: string }[] = [
@ -22,14 +28,53 @@ const roles: { value: string; label: string }[] = [
{ value: 'super_admin', label: 'Super admin' },
]
function signedInToday(iso: string | undefined, now: Date) {
if (!iso) return false
const date = new Date(iso)
return (
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
)
}
export function TeamPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const [q, setQ] = useState('')
const [role, setRole] = useState('all')
const [branchOnly, setBranchOnly] = useState('all')
/** A branch-scoped admin only manages their own branch, so the KPIs and the table share one list. */
const scoped = useMemo(
() => (user?.branchId ? demoAccounts.filter((a) => a.branchId === user.branchId) : demoAccounts),
[user],
)
const scopedInvitations = useMemo(
() => (user?.branchId ? staffInvitations.filter((i) => i.branchId === user.branchId) : staffInvitations),
[user],
)
const kpis = useMemo(() => {
const now = new Date()
const oldestInvite = scopedInvitations.reduce<string | null>(
(oldest, invite) => (!oldest || invite.invitedAt < oldest ? invite.invitedAt : oldest),
null,
)
return {
total: scoped.length,
branchScoped: scoped.filter((a) => a.branchId).length,
activeToday: scoped.filter((a) => signedInToday(staffProfiles[a.id]?.lastSignInAt, now)).length,
rolesInUse: new Set(scoped.map((a) => a.role)).size,
invitations: scopedInvitations.length,
oldestInvite,
}
}, [scoped, scopedInvitations])
const filtered = useMemo(() => {
return demoAccounts.filter((a) => {
return scoped.filter((a) => {
if (role !== 'all' && a.role !== (role as StaffRole)) return false
if (branchOnly === 'yes' && !a.branchId) return false
if (branchOnly === 'no' && a.branchId) return false
@ -45,10 +90,41 @@ export function TeamPage() {
}
return true
})
}, [q, role, branchOnly])
}, [scoped, q, role, branchOnly])
return (
<PageShell title={t('team.title')} subtitle={t('team.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Total staff"
value={kpis.total}
hint={`${kpis.branchScoped} tied to a branch`}
icon={Users}
tone="brand"
/>
<StatCard
label="Active today"
value={kpis.activeToday}
hint="Signed in since midnight"
icon={UserCheck}
tone="forest"
/>
<StatCard
label="Roles in use"
value={kpis.rolesInUse}
hint={`Of ${Object.keys(roleModules).length} configured roles`}
icon={ShieldCheck}
tone="muted"
/>
<StatCard
label="Pending invitations"
value={kpis.invitations}
hint={kpis.oldestInvite ? `Oldest sent ${formatRelative(kpis.oldestInvite)}` : 'Everyone has accepted'}
icon={MailPlus}
tone="orange"
/>
</div>
<Card className="overflow-hidden">
<TableToolbar
search={q}
@ -63,7 +139,7 @@ export function TeamPage() {
value={branchOnly}
onChange={setBranchOnly}
options={[
{ value: 'all', label: 'All staff' },
{ value: 'all', label: 'All branch scopes' },
{ value: 'yes', label: 'Branch-scoped' },
{ value: 'no', label: 'Platform-wide' },
]}
@ -95,17 +171,30 @@ export function TeamPage() {
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Branch</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((a) => (
<TableRow key={a.email}>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell className="font-medium">
<Link to={`/team/${a.id}`} className="flex items-center gap-2.5 text-brand-700 hover:underline">
<PersonAvatar name={a.name} avatarUrl={a.avatarUrl} size="sm" />
<span>{a.name}</span>
</Link>
</TableCell>
<TableCell>{a.email}</TableCell>
<TableCell>
<Badge>{a.role.replaceAll('_', ' ')}</Badge>
</TableCell>
<TableCell>{a.branchName ?? '—'}</TableCell>
<TableCell>
<RowActions
primary={[
{ label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/team/${a.id}` },
]}
/>
</TableCell>
</TableRow>
))}
</TableBody>

Some files were not shown because too many files have changed in this diff Show More