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:
parent
81731dc945
commit
fce0149b1a
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -23,6 +23,7 @@ dist-ssr
|
|||
*.sln
|
||||
*.sw?
|
||||
.vercel
|
||||
.avcheck/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -17,11 +17,25 @@
|
|||
| 2026-08-06 | Finance, orgs create + pending self-reg approval |
|
||||
| 2026-08-06 | Marketing CRM / loyalty / campaigns |
|
||||
| 2026-08-06 | Dispatch board + Telegram bot events |
|
||||
| 2026-08-06 | Super Admin team/inbox/audit/settings + FAQ bilingual |
|
||||
| 2026-08-06 | Super Admin team/audit/settings + FAQ bilingual |
|
||||
| 2026-08-06 | Data migration import jobs |
|
||||
| 2026-08-06 | Deployed Admin SPA: https://gishen-admin.vercel.app |
|
||||
| 2026-08-06 | List UX: filters + create pages for orders, Rx, orgs, stock, team, procurement; `POST` create sketches below |
|
||||
| 2026-08-06 | Finance: gateway enable/disable moved to `/admin/finance/gateways` UI route `/finance/gateways` |
|
||||
| 2026-08-06 | Detail pages for orders, Rx, stock, orgs, procurement, team, CRM, FAQ, settlements; `GET /:id` + sub-resources below |
|
||||
| 2026-08-06 | Dashboard / analytics / marketing / dispatch chart widgets; KPI series endpoints below |
|
||||
| 2026-08-06 | Removed Service Inbox module (pages, route, nav, permissions, mocks, i18n) |
|
||||
| 2026-08-06 | Topbar command palette (Cmd+K) + central page directory; register new pages in src/config/navigation.ts |
|
||||
| 2026-08-06 | Sidebar consolidated into grouped, collapsible domain sections driven by the same navigation registry |
|
||||
| 2026-08-06 | Staff profile page `/profile` + Yimaru-style login with demo-account picker; `avatarUrl` on staff/customers/riders |
|
||||
| 2026-08-06 | Medication **Item** master catalogue (`/items`) separate from Stock; create stock picks existing item |
|
||||
| 2026-08-06 | Counter **POS / scan-to-purchase** (`/pos`): customer QR, invoice association scan, tickets-app integration queue |
|
||||
| 2026-08-06 | Loyalty ledger + earning activities tabs; Finance/Procurement KPI tab shells; module list KPI strips |
|
||||
| 2026-08-06 | Detail pages: campaign, segment, rider, migration job, audit event, branch; approve UX (primary + overflow reject) |
|
||||
| 2026-08-06 | Dashboard charts: popular products, category mix, sales trend, channel, branch, Rx funnel; "today" KPIs date-scoped |
|
||||
| 2026-08-06 | Table filters: >3 filters open a modal with Apply/Cancel + active chips (`TableToolbar` declarative API) |
|
||||
| 2026-08-06 | i18n: en/am parity (~666 keys), Noto Sans Ethiopic; locale-aware `formatters`; Gregorian + Amharic month names |
|
||||
| 2026-08-06 | New `ModuleKey`: `pos` (pharmacist, stock_manager, operations, super_admin). Items gated by existing `stock` |
|
||||
|
||||
## Conventions
|
||||
|
||||
|
|
@ -48,8 +62,9 @@ Pharmacist (and optionally stock) sessions include `branchId`.
|
|||
| POST | `/auth/staff/login` | — | Email/password → tokens + user |
|
||||
| POST | `/auth/refresh` | refresh | Rotate access |
|
||||
| POST | `/auth/logout` | staff | Revoke |
|
||||
| GET | `/auth/me` | staff | Current principal, roles, branchId |
|
||||
| PATCH | `/users/me` | staff | `{ preferredLocale?: "en"\|"am", name? }` |
|
||||
| GET | `/auth/me` | staff | Current principal, roles, branchId, avatarUrl |
|
||||
| PATCH | `/users/me` | staff | `{ preferredLocale?: "en"\|"am", name?, phone?, avatarUrl? }` |
|
||||
| POST | `/users/me/avatar` | staff | Multipart image upload → `{ avatarUrl }` |
|
||||
|
||||
**Login response sketch:**
|
||||
|
||||
|
|
@ -63,26 +78,33 @@ Pharmacist (and optionally stock) sessions include `branchId`.
|
|||
"email": "pharmacist@gishen.et",
|
||||
"role": "pharmacist",
|
||||
"branchId": "uuid",
|
||||
"preferredLocale": "en"
|
||||
"preferredLocale": "en",
|
||||
"avatarUrl": "https://cdn.example/avatars/hana.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mock key: `authStore.login` → `/auth/staff/login`
|
||||
Mock key: `authStore.login` → `/auth/staff/login`
|
||||
UI: `/profile` (every authenticated role; not permission-gated), topbar “My profile”
|
||||
|
||||
---
|
||||
|
||||
## Orders (branch inbox)
|
||||
## Orders (branch queue)
|
||||
|
||||
| Method | Path | Roles | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `q` |
|
||||
| POST | `/pharmacy/orders` | pharmacist, operations, super_admin | Staff-created order (call centre / walk-in) |
|
||||
| GET | `/pharmacy/orders/:id` | pharmacist, operations, super_admin | Detail: header + line items + payment + fulfillment |
|
||||
| GET | `/pharmacy/orders/:id/timeline` | pharmacist, operations, super_admin | Ordered status/audit events for the detail timeline |
|
||||
| POST | `/orders/:id/confirm` | pharmacist, super_admin | Confirm stock |
|
||||
| POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` |
|
||||
| PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle |
|
||||
|
||||
Mock: `mocks/data.orders`
|
||||
Detail response adds `items[] { sku, name, qty, unitEtb }`, `timeline[] { at, title, detail, tone }`, and resolved `rider`.
|
||||
|
||||
Mock: `mocks/data.orders`, `mocks/data.orderLines`, `mocks/data.orderTimeline`
|
||||
UI: `/orders/:id`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -95,8 +117,49 @@ Mock: `mocks/data.orders`
|
|||
| POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` |
|
||||
| POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time |
|
||||
| GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report |
|
||||
| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin | Detail: items, controlled flags, review trail |
|
||||
| GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight |
|
||||
|
||||
UI: `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section.
|
||||
|
||||
---
|
||||
|
||||
## Medication items (catalogue)
|
||||
|
||||
Master product identity. **Stock rows reference an Item** via `itemId`; batch/lot, manufacture date, expiry, and branch qty live on Stock only. Do not recreate a product on every goods receipt.
|
||||
|
||||
| Method | Path | Roles | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/catalog/items` | stock_manager, pharmacist (read), procurement (read), super_admin | Filters: `productType`, `efdaStatus`, `mergeStatus`, `therapeuticClass`, `q` |
|
||||
| POST | `/admin/catalog/items` | stock_manager, super_admin | Create item; server assigns immutable internal SKU |
|
||||
| GET | `/admin/catalog/items/:id` | stock_manager, pharmacist (read), procurement (read), super_admin | Full record + ownership tags + audit |
|
||||
| PATCH | `/admin/catalog/items/:id` | stock_manager, super_admin | Update (SKU immutable); field-group ownership may reject ERP-owned writes from Platform |
|
||||
| GET | `/admin/catalog/items/:id/history` | stock_manager, super_admin | Change log |
|
||||
| POST | `/admin/catalog/items/match` | stock_manager, super_admin | Duplicate assist body `{ genericName, strength, dosageForm, packSize, manufacturer }` → `{ candidates: [{ id, confidence, matchKey }] }` |
|
||||
| POST | `/admin/catalog/items/:id/merge` | stock_manager, super_admin | `{ candidateId, status: approved\|rejected }` — reversible 30d |
|
||||
| POST | `/admin/catalog/items/:id/merge/reverse` | stock_manager, super_admin | Undo approved merge within 30 days |
|
||||
| GET | `/admin/catalog/items/:id/variants` | same read | Pack-size children linked to parent |
|
||||
| POST | `/admin/catalog/items/:id/variants` | stock_manager, super_admin | Attach pack variant `{ packSize, barcode, sellingPriceEtb }` |
|
||||
| POST | `/admin/catalog/items/:id/media` | stock_manager, super_admin | Multipart images (min 1); badges blocked for Rx/Controlled |
|
||||
|
||||
**Field groups (create/detail):**
|
||||
|
||||
1. Core identification — SKU (immutable), generic/INN, brand, manufacturer, country, EFDA number, barcodes[]
|
||||
2. Classification — product type (`OTC`\|`Rx`\|`Controlled`\|`Medical device`\|`Beauty`\|`Wellness`), therapeutic class, activeIngredients[], dosage form, route, controlled schedule
|
||||
3. Packaging — pack size, base UoM, sell-by vs pack unit, parent/variant links
|
||||
4. Pricing — cost (ERP-owned), selling price (+ owner ERP\|Platform), currency ETB, VAT, promo eligibility (**auto-off for Rx/Controlled**, no override), B2B price-list ref
|
||||
5. Regulatory — Rx-required, EFDA status, controlled flag+schedule, advertising-restriction (auto from type), age restriction
|
||||
6. Content — descriptions, dosage guidance, side effects, warnings, storage, similar alternatives[] (display text only)
|
||||
7. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock)
|
||||
8. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description
|
||||
9. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps
|
||||
10. Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at
|
||||
11. Relationships — B2B-eligible category tag; loyalty earn/redeem eligibility (**auto-excluded for Rx/Controlled**)
|
||||
|
||||
Mock: `mocks/catalog.ts` (`catalogItems`, `itemChangeHistory`, …)
|
||||
UI: `/items`, `/items/new`, `/items/:id`
|
||||
Stock create: `POST /pharmacy/inventory` prefers `{ itemId, branchId, batch, … }` over free-typed product fields. UI deep-link `?item=`
|
||||
|
||||
---
|
||||
|
||||
## Stock & ERP
|
||||
|
|
@ -104,7 +167,7 @@ Mock: `mocks/data.orders`
|
|||
| Method | Path | Roles | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/pharmacy/inventory` | stock_manager, pharmacist (read), procurement (read), super_admin | Per-branch stock; filters: `branchId`, `q`, `alert=low\|mismatch` |
|
||||
| POST | `/pharmacy/inventory` | stock_manager, super_admin | Add stock line |
|
||||
| POST | `/pharmacy/inventory` | stock_manager, super_admin | Receive stock against catalogue item: `{ itemId, branchId, batch, manufacturedAt?, expiresAt, qty, reorderPoint? }` — must not invent a new Item |
|
||||
| GET | `/integrations/erp/sync-status` | stock_manager, super_admin | Last sync |
|
||||
| GET | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP |
|
||||
| POST | `/admin/stock/discrepancies/:id/resolve` | stock_manager, super_admin | One-click resolve |
|
||||
|
|
@ -114,6 +177,34 @@ Mock: `mocks/data.orders`
|
|||
| POST | `/admin/stock/merge-proposals/:id/approve` | stock_manager, super_admin | Human approve; reversible 30d |
|
||||
| GET | `/admin/stock/alerts` | stock_manager, super_admin | Low stock / expiry watchlist |
|
||||
| POST | `/admin/stock/transfers` | stock_manager, super_admin | Create/approve transfer (owner) |
|
||||
| GET | `/pharmacy/inventory/:sku` | stock_manager, pharmacist (read), super_admin | Detail: batch, expiry, ERP delta, linked `itemId` |
|
||||
| GET | `/pharmacy/inventory/:sku/movements` | stock_manager, super_admin | Ledger: `{ at, type, qty, note }` (sale/receive/adjust/reserve) |
|
||||
|
||||
UI: `/stock/:sku` · create UI deep-link `?item=`
|
||||
|
||||
---
|
||||
|
||||
## Counter POS / scan-to-purchase
|
||||
|
||||
Staff module (`ModuleKey: pos`) for walk-in counter sales identified by customer QR. Roles: `pharmacist`, `stock_manager`, `operations`, `super_admin`.
|
||||
|
||||
| Method | Path | Roles | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/pos/resolve` | pos roles | Resolve scanned code → customer. Body `{ code }` accepts QR payload (`gishen-customer:{id}`), loyalty URL, bare `c*`, or phone digits |
|
||||
| GET | `/pos/customers/:id/context` | pos roles | Name, phone, tier, points balance, last order — shown before money moves |
|
||||
| POST | `/pos/purchases` | pos roles | Commit quick purchase `{ customerId, branchId, lines: [{ sku, qty }], channel?: "pos" }` → creates order, decrements stock, awards loyalty ledger entry. **Rejects / gates Controlled & Rx-only SKUs** without a valid dispensed prescription |
|
||||
| GET | `/pos/earn-preview` | pos roles | `{ subtotalEtb, pointsEarn }` using active loyalty rule + tier multiplier |
|
||||
| POST | `/pos/invoices/extract` | pos roles | Multipart invoice image/PDF → assisted OCR `{ lines[], confidence, warnings[] }` (operator must confirm) |
|
||||
| POST | `/pos/invoices/associate` | pos roles | `{ lines[], association: { type: procurement\|supplier\|branch, id }, note? }` — purchase association against existing record |
|
||||
| GET | `/pos/tickets` | pos roles | Inbound queue from external tickets / till app (`source`, `status`, payload) |
|
||||
| POST | `/pos/tickets/pull` | pos roles | Pull pending from configured integration (mockable when disconnected) |
|
||||
| POST | `/pos/tickets/:id/accept` | pos roles | Materialise ticket into POS purchase draft |
|
||||
| POST | `/pos/tickets/:id/dismiss` | pos roles | Discard |
|
||||
| GET/PUT | `/pos/integrations/tickets` | finance\|super_admin | Connection status + credentials for tickets app |
|
||||
|
||||
Manual code entry and a demo “simulate scan” path must work when camera/`getUserMedia` is unavailable. Camera streams must stop on unmount.
|
||||
|
||||
Mock: `mocks/data` POS tickets / earn rules · UI: `/pos` (tabs: Quick purchase, Invoice scan, Ticket queue, Customer codes)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -124,6 +215,14 @@ Mock: `mocks/data.orders`
|
|||
| GET | `/admin/procurement/reports` | procurement, stock_manager, super_admin | Dead-stock / fast-mover |
|
||||
| GET | `/admin/procurement/reorder-recommendations` | procurement, super_admin | From velocity |
|
||||
| POST | `/admin/stock/transfers/request` | procurement, super_admin | Request only; Stock Manager approves |
|
||||
| GET | `/admin/procurement/requests` | procurement, stock_manager, super_admin | Reorder requests; filters `status`, `priority`, `branchId` |
|
||||
| GET | `/admin/procurement/requests/:id` | procurement, stock_manager, super_admin | Detail + workflow trail |
|
||||
| POST | `/admin/procurement/requests/:id/order` | procurement, super_admin | Raise PO → `status=ordered` |
|
||||
| POST | `/admin/procurement/requests/:id/receive` | procurement, stock_manager, super_admin | Goods received → `status=received`, posts stock movement |
|
||||
|
||||
Request shape: `{ id, sku, name, qty, branchId, status: open\|ordered\|received, priority: low\|medium\|high, requestedBy, notes, createdAt }`
|
||||
|
||||
Mock: `mocks/data.procurementRequests` · UI: `/procurement/:id`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -135,28 +234,69 @@ Mock: `mocks/data.orders`
|
|||
| GET | `/admin/finance/reconciliations` | finance, super_admin | Matched to orders |
|
||||
| GET/PUT | `/admin/finance/gateways` | finance, super_admin | Enable/disable Chapa, ArifPay, Telebirr, M-Pesa |
|
||||
| POST | `/admin/finance/refunds` | finance, super_admin | Refund handling |
|
||||
| GET | `/admin/finance/loyalty-liability` | finance, super_admin | Monthly Birr liability |
|
||||
| GET | `/admin/finance/loyalty-liability` | finance, super_admin | Outstanding Birr liability (points × redemption rate; must reconcile with Loyalty KPI) |
|
||||
| GET | `/admin/organisations` | finance, super_admin | List |
|
||||
| POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (active) |
|
||||
| GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue |
|
||||
| POST | `/admin/organisations/:id/approve` | finance, super_admin | Activate credit |
|
||||
| POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject |
|
||||
| GET | `/admin/organisations/:id/statements` | finance, super_admin | Consolidated invoices |
|
||||
| GET | `/admin/finance/settlements/:id` | finance, super_admin | Batch detail: `txnCount`, `feesEtb`, net, unmatched slips |
|
||||
| POST | `/admin/finance/settlements/:id/reconcile` | finance, super_admin | Re-run reconciliation |
|
||||
| GET | `/admin/organisations/:id` | finance, super_admin | Detail: credit profile + utilization |
|
||||
| GET | `/admin/organisations/:id/members` | finance, super_admin | HR roster `{ name, dept, plan }` |
|
||||
| GET | `/admin/organisations/:id/invoices` | finance, super_admin | `{ id, period, amountEtb, status }` |
|
||||
| PATCH | `/admin/organisations/:id/credit` | finance, super_admin | `{ creditLimitEtb }` (review-gated) |
|
||||
|
||||
**Cross-repo:** B2B portal `POST /company/register` creates `status=pending`. Admin approve activates credit.
|
||||
|
||||
UI: `/organisations/:id` (Approve primary; Reject in overflow + credit-section footer), `/finance` (tabs: Settlements / Trends / Operations + gateways), `/finance/settlements/:id`, `/finance/gateways`
|
||||
|
||||
---
|
||||
|
||||
## Marketing
|
||||
|
||||
| Method | Path | Roles | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/crm/customers` | marketing_manager, super_admin | Read profiles |
|
||||
| GET | `/admin/crm/customers` | marketing_manager, super_admin | Read profiles; include `avatarUrl` |
|
||||
| GET | `/admin/crm/segments` | marketing_manager, super_admin | Self-updating segments |
|
||||
| GET | `/admin/crm/segments/:id` | marketing_manager, super_admin | Segment detail + members + engagement series |
|
||||
| GET/POST | `/admin/campaigns` | marketing_manager, super_admin | Compose/schedule SMS/Telegram/push |
|
||||
| GET | `/admin/campaigns/:id` | marketing_manager, super_admin | Campaign detail: recipients, channel mix, send timeline |
|
||||
| GET/PUT | `/admin/loyalty/config` | marketing_manager, super_admin | Earn/tiers/referral bonus |
|
||||
| GET | `/admin/loyalty/ledger` | marketing_manager, finance, super_admin | Points movements; filters `activity`, `direction`, `branchId`, `customerId`, `q` |
|
||||
| GET | `/admin/loyalty/customers/summary` | marketing_manager, finance, super_admin | Per-customer earned / redeemed / balance / tier |
|
||||
| GET | `/admin/loyalty/activities` | marketing_manager, super_admin | Earning rule catalogue |
|
||||
| PATCH | `/admin/loyalty/activities/:id` | marketing_manager, super_admin | Pause/activate / edit points |
|
||||
| GET | `/admin/crm/customers/:id` | marketing_manager, super_admin | Detail: tier, points, recent orders, touch history, avatar |
|
||||
| POST | `/admin/crm/customers/:id/points` | marketing_manager, super_admin | `{ delta, reason }` manual loyalty adjustment |
|
||||
| GET | `/admin/crm/customers/:id/qr` | marketing_manager, pharmacist, pos roles, super_admin | Loyalty QR payload + printable image (PNG/SVG) |
|
||||
| GET | `/admin/kpi/summary` | marketing_manager, finance, super_admin | Scoped KPIs |
|
||||
|
||||
### Chart / KPI series
|
||||
|
||||
Dashboard, Analytics, Marketing, Dispatch, Finance, and detail pages read series from these (currently `mocks/charts.ts`):
|
||||
|
||||
| Method | Path | Returns |
|
||||
| --- | --- | --- |
|
||||
| GET | `/admin/kpi/sales?range=7d\|30d` | `[{ day, sales, orders }]` — sales trend + order overlay |
|
||||
| GET | `/admin/kpi/popular-products?range=` | `[{ sku, name, units, revenueEtb }]` — ranked; deep-link `/stock/:sku` |
|
||||
| GET | `/admin/kpi/category-mix` | `[{ category, value }]` — share by therapeutic / product category |
|
||||
| GET | `/admin/kpi/channel-mix` | `[{ name, value }]` — order share by channel |
|
||||
| GET | `/admin/kpi/branch-performance` | `[{ branch, revenue, orders, rx }]` |
|
||||
| GET | `/admin/kpi/rx-funnel` | `{ submitted, underReview, approved, rejected }` |
|
||||
| GET | `/admin/kpi/settlement-trend` | `[{ day, chapa, telebirr, cod }]` — stacked settlement volume |
|
||||
| GET | `/admin/kpi/dispatch-load` | `[{ hour, trips }]` |
|
||||
| GET | `/admin/kpi/stock-movement?sku=` | `[{ week, in, out }]` |
|
||||
| GET | `/admin/kpi/org-spend?orgId=` | `[{ month, spend }]` |
|
||||
| GET | `/admin/kpi/customer-points?customerId=` | `[{ month, points }]` |
|
||||
| GET | `/admin/kpi/loyalty-issued-redeemed` | `[{ month, earned, redeemed }]` |
|
||||
| GET | `/admin/kpi/needs-attention` | Action queue: pending Rx, low stock, unassigned deliveries, pending orgs |
|
||||
|
||||
**Dashboard rule:** any “today” metric must filter on `createdAt`/`submittedAt` calendar day in the staff timezone. Branch-scoped roles receive branch-scoped aggregates only.
|
||||
|
||||
UI: `/crm/:id`, `/marketing/:id` (segment), `/campaigns/:id`, `/loyalty`
|
||||
|
||||
---
|
||||
|
||||
## Dispatch & Telegram bot
|
||||
|
|
@ -168,10 +308,14 @@ Mock: `mocks/data.orders`
|
|||
| POST | `/dispatch/trips/:id/assign` | operations, super_admin | `{ riderId }` → emits Telegram manifest |
|
||||
| POST | `/dispatch/bot/webhook` | service | Bot status + PoD callbacks |
|
||||
| GET | `/dispatch/intake` | operations, super_admin | Partner/POS webhook monitor |
|
||||
| GET | `/dispatch/riders` | operations, super_admin | Roster with `avatarUrl`, on-shift flag |
|
||||
| POST | `/dispatch/riders` | operations, super_admin | Link Telegram account |
|
||||
| GET | `/dispatch/riders/:id` | operations, super_admin | Rider detail: stats, trips, on-time series |
|
||||
|
||||
**Events:** `trip.assigned` → bot push; `trip.status_updated` → board + customer tracking.
|
||||
|
||||
UI: `/dispatch`, `/dispatch/riders/:id`
|
||||
|
||||
---
|
||||
|
||||
## FAQ (bilingual)
|
||||
|
|
@ -181,20 +325,30 @@ Mock: `mocks/data.orders`
|
|||
| GET | `/admin/faq` | all staff | Published articles |
|
||||
| POST | `/admin/faq` | marketing_manager, super_admin | Create |
|
||||
| PUT | `/admin/faq/:id` | marketing_manager, super_admin | Update / publish |
|
||||
| GET | `/admin/faq/:id` | all staff | Article detail (drafts visible to `faq_write`) |
|
||||
| DELETE | `/admin/faq/:id` | super_admin | Delete |
|
||||
|
||||
Fields: `title_en`, `title_am`, `body_en`, `body_am`, `category`, `published`.
|
||||
|
||||
UI: `/faq/:id` — inline bilingual editor for `faq_write`, read-only otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Team, inbox, audit, settings
|
||||
## Team, audit, settings
|
||||
|
||||
| Method | Path | Roles | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| GET/POST | `/admin/users` | super_admin | Staff + role + branch; list supports `role`, `branchScoped`, `q` |
|
||||
| GET | `/admin/inbox/threads` | super_admin | Chatwoot/Telegram/WhatsApp attached to customer |
|
||||
| GET | `/admin/audit` | super_admin | Audit log |
|
||||
| GET/POST | `/admin/users` | super_admin | Staff + role + branch; list supports `role`, `branchScoped`, `q`; include `avatarUrl` |
|
||||
| GET | `/admin/users/:id` | super_admin | Staff detail: role, branch scope, module access, sessions, avatar |
|
||||
| POST | `/admin/users/:id/reset-password` | super_admin | Email reset link |
|
||||
| POST | `/admin/users/:id/disable` | super_admin | Deactivate account |
|
||||
| GET | `/admin/audit` | super_admin | Audit log; filters `domain`, `category`, `outcome`, `q`. `target` deep-links to entity detail |
|
||||
| GET | `/admin/audit/:id` | super_admin | Event detail: actor, before/after, IP, UA, security flag |
|
||||
| GET/PUT | `/admin/settings` | super_admin | Retention, access controls |
|
||||
| GET | `/admin/branches` | super_admin, operations | Branch list |
|
||||
| GET | `/admin/branches/:id` | super_admin, operations | Branch profile, staff, order series |
|
||||
|
||||
UI: `/team/:id`, `/audit/:id`, `/settings/branches/:id`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -208,10 +362,56 @@ Fields: `title_en`, `title_am`, `body_en`, `body_am`, `category`, `published`.
|
|||
| POST | `/admin/imports/:id/validate` | same | Dry-run row errors |
|
||||
| POST | `/admin/imports/:id/commit` | same | Upsert commit |
|
||||
| GET | `/admin/imports` | same | Job history |
|
||||
| GET | `/admin/imports/:id` | same | Job detail + error rows |
|
||||
| CRUD | `/admin/imports/mapping-templates` | same | Saved presets |
|
||||
|
||||
Entity templates: `stock`, `catalog`, `hr_members`, `branches`, `riders`, `generic`.
|
||||
|
||||
UI: `/migrations`, `/migrations/:id`
|
||||
|
||||
---
|
||||
|
||||
## Navigation & page directory (frontend convention)
|
||||
|
||||
`src/config/navigation.ts` is the **single source of truth** for every navigable
|
||||
destination in the Admin SPA. It feeds both the sidebar and the Cmd+K / Ctrl+K
|
||||
command palette in the topbar.
|
||||
|
||||
**When you add a page or feature, register it there.** A route added to
|
||||
`src/app/AppRoutes.tsx` but missing from the registry is unreachable by search;
|
||||
an entry whose `path` has no matching route produces a broken result.
|
||||
|
||||
Each entry carries `id`, `title` (+ optional `labelKey` for i18next),
|
||||
`description`, `path`, `module` (`ModuleKey`, used for RBAC via `canAccess`),
|
||||
`icon`, `group` (`NavGroupId`), `keywords` (search synonyms), `kind`
|
||||
(`page` | `action`) and `showInSidebar`.
|
||||
|
||||
`group` is the operational domain — Overview, Daily operations, Inventory &
|
||||
supply, Finance & accounts, Customers & growth, Administration, Support, plus a
|
||||
palette-only Actions group. It drives the sidebar section headers *and* the
|
||||
palette headings from one field, so the two cannot drift. Labels live in
|
||||
`navGroups`; `sidebarSections(role)` returns the grouped, permission-filtered
|
||||
sidebar and drops any section whose children the role cannot access.
|
||||
|
||||
Palette results are permission-filtered with the same `roleModules` map the
|
||||
route guards use, so no backend change is needed — but any new module key added
|
||||
server-side must also exist in `ModuleKey` and `roleModules`.
|
||||
|
||||
Current SPA destinations of note: `/pos`, `/items`, `/profile` (palette + topbar;
|
||||
not a gated module), Payment Gateways `/finance/gateways` (palette-only).
|
||||
|
||||
Entity detail routes (`orders/:id`, `stock/:sku`, `items/:id`, …) are
|
||||
deliberately not registered: they require a record id. Register the list page
|
||||
instead.
|
||||
|
||||
**List filters (UI):** toolbars with more than three filters collapse into a
|
||||
modal with staged Apply/Cancel and removable active chips. Search stays inline.
|
||||
Backend continues to accept flat query params; no change required.
|
||||
|
||||
**Avatars:** staff, customers, and riders expose optional `avatarUrl`. Clients
|
||||
resolve `avatarUrl` → generated placeholder → initials. Upload: `POST /users/me/avatar`
|
||||
or admin `POST /admin/users/:id/avatar`.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
|
@ -219,3 +419,7 @@ Entity templates: `stock`, `catalog`, `hr_members`, `branches`, `riders`, `gener
|
|||
1. Money units: ETB major vs cents — keep consistent with Ecom/B2B sheets
|
||||
2. Driver channel assumption: Telegram bot (flag if SMS/WhatsApp preferred)
|
||||
3. Checkout entitlement-split: shared component across Ecom/Mobile/B2B (out of Admin UI)
|
||||
4. Amharic dates: Gregorian with Amharic month names (current) vs Ethiopian calendar (`am-ET-u-ca-ethiopic`) — year differs (~2018 E.C. for 2026 G.C.); need product decision
|
||||
5. Tickets-app vendor: POS integration is pluggable — confirm production partner API once chosen
|
||||
6. Invoice OCR provider for `/pos/invoices/extract` (Admin currently simulates extraction)
|
||||
7. Catalogue merge queue ownership: Stock Manager vs dedicated data-steward role
|
||||
|
|
|
|||
351
package-lock.json
generated
351
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
32
src/components/charts/ChartCard.tsx
Normal file
32
src/components/charts/ChartCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
src/components/charts/chartTheme.ts
Normal file
63
src/components/charts/chartTheme.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
78
src/components/detail/ActivityTimeline.tsx
Normal file
78
src/components/detail/ActivityTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/components/detail/ApprovalStepper.tsx
Normal file
83
src/components/detail/ApprovalStepper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/components/detail/KeyValueGrid.tsx
Normal file
31
src/components/detail/KeyValueGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div key={section.group.id} className="space-y-1">
|
||||
{section.items.map(renderItem)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
<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"
|
||||
>
|
||||
<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(
|
||||
'ml-auto h-3.5 w-3.5 shrink-0 transition-transform duration-200',
|
||||
groupCollapsed && '-rotate-90',
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
/>
|
||||
</button>
|
||||
<div id={listId} className={cn('space-y-1', groupCollapsed && 'hidden')}>
|
||||
{section.items.map(renderItem)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -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,21 +33,38 @@ export function RowActions({
|
|||
const overflow = items ?? []
|
||||
return (
|
||||
<div className={cn('flex items-center justify-end gap-1', className)}>
|
||||
{primary?.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={action.variant === 'destructive' ? 'destructive' : 'outline'}
|
||||
className="h-9 gap-1.5 px-3 shadow-none"
|
||||
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>
|
||||
</Button>
|
||||
))}
|
||||
{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"
|
||||
size="sm"
|
||||
variant={action.variant === 'destructive' ? 'destructive' : 'outline'}
|
||||
className="h-9 gap-1.5 px-3 shadow-none"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{overflow.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -59,20 +79,29 @@ export function RowActions({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[11rem]">
|
||||
{overflow.map((action) => (
|
||||
<DropdownMenuItem
|
||||
key={action.label}
|
||||
disabled={action.disabled}
|
||||
className={cn(
|
||||
'gap-2',
|
||||
action.variant === 'destructive' && 'text-destructive focus:text-destructive',
|
||||
)}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{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}
|
||||
className={cn(
|
||||
'gap-2',
|
||||
action.variant === 'destructive' && 'text-destructive focus:text-destructive',
|
||||
)}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
|
|
|||
264
src/components/table/TableFilterModal.tsx
Normal file
264
src/components/table/TableFilterModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,29 +146,35 @@ 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"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={label}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</SelectNative>
|
||||
</div>
|
||||
<SelectNative
|
||||
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}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</SelectNative>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
172
src/components/topbar/CommandPalette.tsx
Normal file
172
src/components/topbar/CommandPalette.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
<User className="h-5 w-5" />
|
||||
<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()
|
||||
|
|
|
|||
209
src/components/ui/avatar.tsx
Normal file
209
src/components/ui/avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
src/components/ui/command.tsx
Normal file
149
src/components/ui/command.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
75
src/components/ui/empty-state.tsx
Normal file
75
src/components/ui/empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/components/ui/icon-tile.tsx
Normal file
54
src/components/ui/icon-tile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/components/ui/progress.tsx
Normal file
45
src/components/ui/progress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/components/ui/separator.tsx
Normal file
21
src/components/ui/separator.tsx
Normal 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
|
||||
5
src/components/ui/skeleton.tsx
Normal file
5
src/components/ui/skeleton.tsx
Normal 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)} />
|
||||
}
|
||||
136
src/components/ui/status-badge.tsx
Normal file
136
src/components/ui/status-badge.tsx
Normal 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
557
src/config/navigation.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
9
src/i18n/i18next.d.ts
vendored
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
59
src/layouts/DetailPageLayout.tsx
Normal file
59
src/layouts/DetailPageLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
146
src/lib/avatar.ts
Normal 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
94
src/lib/formatters.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "የእንደገና ትዕዛዝ እና የማስተላለፍ ጥያቄዎች"
|
||||
},
|
||||
"finance": {
|
||||
"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 ልዩነት"
|
||||
},
|
||||
"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": "ድርጅት ፍጠር እና ራስ መመዝገቢያዎችን አጽድቅ",
|
||||
"pending": "በመጠባበቅ ላይ",
|
||||
"createOrg": "ድርጅት ፍጠር"
|
||||
"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": "በመጠባበቅ ላይ",
|
||||
"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": "የሙከራ ማረጋገጫ"
|
||||
},
|
||||
"team": {
|
||||
"title": "ቡድን እና ሚናዎች",
|
||||
"subtitle": "ሰራተኞች እና RBAC"
|
||||
},
|
||||
"crm": {
|
||||
"title": "CRM",
|
||||
"subtitle": "የደንበኛ መዝገቦች (ንባብ)"
|
||||
},
|
||||
"loyalty": {
|
||||
"title": "የታማኝነት ፕሮግራም",
|
||||
"subtitle": "ነጥቦች፣ ደረጃዎች፣ ሪፈራል"
|
||||
},
|
||||
"campaigns": {
|
||||
"title": "ዘመቻዎች",
|
||||
"subtitle": "SMS፣ ቴሌግራም፣ push"
|
||||
},
|
||||
"inbox": {
|
||||
"title": "የአገልግሎት ሳጥን",
|
||||
"subtitle": "Chatwoot፣ ቴሌግራም፣ WhatsApp"
|
||||
},
|
||||
"audit": {
|
||||
"title": "የኦዲት መዝገብ",
|
||||
"subtitle": "ማመሳሰል፣ ማስተካከያዎች፣ ማጽደቂያዎች"
|
||||
},
|
||||
"settings": {
|
||||
"title": "ቅንብሮች",
|
||||
"subtitle": "የመድረክ ምርጫዎች"
|
||||
"dryRun": "የሙከራ ማረጋገጫ",
|
||||
"fields": "መስኮች፡ {{list}}",
|
||||
"uploadTitle": "CSV / Excel ስቀል (በሙከራው የCSV ጽሑፍ ይነበባል)",
|
||||
"useSample": "የናሙና የክምችት CSV ተጠቀም",
|
||||
"skip": "— ዝለል —",
|
||||
"validationPassed": "ማረጋገጫው ተሳክቷል",
|
||||
"jobLogged": "ሥራው በኦዲት መዝገብ ተመዝግቧል።",
|
||||
"steps": {
|
||||
"template": "አብነት",
|
||||
"upload": "መስቀል",
|
||||
"map": "ማያያዝ",
|
||||
"validate": "ማረጋገጫ",
|
||||
"done": "ተጠናቋል"
|
||||
},
|
||||
"errors": {
|
||||
"unmapped": "ያልተያዙ መስኮች፡ {{list}}",
|
||||
"emptyRows": "ባዶ ረድፎች ተገኝተዋል"
|
||||
},
|
||||
"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}}”ን የሚያነጣጥር የዘመቻ ረቂቅ"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
"finance": {
|
||||
"title": "Finance",
|
||||
"subtitle": "Settlements, gateways, refunds"
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team & Roles",
|
||||
"subtitle": "Staff users and RBAC"
|
||||
},
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"errors": {
|
||||
"unmapped": "Unmapped fields: {{list}}",
|
||||
"emptyRows": "Empty rows detected"
|
||||
},
|
||||
"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
1130
src/mocks/catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
326
src/mocks/charts.ts
Normal file
326
src/mocks/charts.ts
Normal 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 },
|
||||
]
|
||||
2869
src/mocks/data.ts
2869
src/mocks/data.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
296
src/pages/audit/AuditEventDetailPage.tsx
Normal file
296
src/pages/audit/AuditEventDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</div>
|
||||
<div className="flex items-center justify-center bg-cream p-6">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<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>
|
||||
<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>
|
||||
<span className="font-display text-lg font-bold text-forest">{t('app.name')}</span>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="space-y-4 rounded-2xl border bg-white p-6 shadow-soft">
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{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)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
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('/')
|
||||
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"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
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"
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<span className="truncate text-xs font-semibold">{a.role.replaceAll('_', ' ')}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick context</CardTitle>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
{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 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="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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
303
src/pages/dispatch/RiderDetailPage.tsx
Normal file
303
src/pages/dispatch/RiderDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
src/pages/faq/FaqDetailPage.tsx
Normal file
140
src/pages/faq/FaqDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
<Badge variant="secondary">{a.category}</Badge>
|
||||
<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">
|
||||
{lang === 'am' ? a.body_am : a.body_en}
|
||||
<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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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,62 +102,175 @@ export function FinancePage() {
|
|||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily settlements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Volume</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settlements.map((s) => (
|
||||
<TableRow key={s.channel}>
|
||||
<TableCell>{s.channel}</TableCell>
|
||||
<TableCell>{s.date}</TableCell>
|
||||
<TableCell>{formatEtb(s.volumeEtb)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.status === 'reconciled' ? 'success' : 'warning'}>{s.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Operations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<div className="rounded-xl border bg-muted/40 p-3 text-sm">
|
||||
Loyalty points liability (Aug): <strong>{formatEtb(182400)}</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.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" className="h-10" onClick={() => toast.message('Refund queued (mock)')}>
|
||||
Process refund
|
||||
</Button>
|
||||
<Button asChild variant="secondary" className="h-10 gap-2">
|
||||
<Link to="/finance/gateways">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Payment gateways
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
<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 className="hover:bg-transparent">
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Volume</TableHead>
|
||||
<TableHead>Txns</TableHead>
|
||||
<TableHead>Fees</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>
|
||||
|
||||
<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(LOYALTY_LIABILITY_ETB)}</strong>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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)')}>
|
||||
Process refund
|
||||
</Button>
|
||||
<Button asChild variant="secondary" className="h-10 gap-2">
|
||||
<Link to="/finance/gateways">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Payment gateways
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="gateways">
|
||||
<GatewaySettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
50
src/pages/finance/GatewaySettings.tsx
Normal file
50
src/pages/finance/GatewaySettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
69
src/pages/finance/SettlementDetailPage.tsx
Normal file
69
src/pages/finance/SettlementDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
776
src/pages/items/CreateItemPage.tsx
Normal file
776
src/pages/items/CreateItemPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
263
src/pages/items/ItemDetailPage.tsx
Normal file
263
src/pages/items/ItemDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
src/pages/items/ItemsPage.tsx
Normal file
248
src/pages/items/ItemsPage.tsx
Normal 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>
|
||||
}
|
||||
380
src/pages/marketing/CampaignDetailPage.tsx
Normal file
380
src/pages/marketing/CampaignDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,56 +1,402 @@
|
|||
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')}>
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Compose campaign</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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>
|
||||
</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>
|
||||
</SelectNative>
|
||||
</div>
|
||||
<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>
|
||||
<div className="space-y-1">
|
||||
<Label>Message</Label>
|
||||
<Textarea value={body} onChange={(e) => setBody(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Schedule (optional)</Label>
|
||||
<Input type="datetime-local" />
|
||||
</div>
|
||||
<Button onClick={() => toast.success(`Campaign queued on ${channel} → ${segment}`)}>Send / schedule</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="compose">
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Compose campaign</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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 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)}>
|
||||
{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"
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,49 +73,72 @@ 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={() => {
|
||||
setQ('')
|
||||
setTier('all')
|
||||
setBranch('all')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
filters={customerFilters}
|
||||
onClearFilters={() => {
|
||||
setQ('')
|
||||
setTier('all')
|
||||
setBranch('all')
|
||||
}}
|
||||
/>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
|
|
@ -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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
118
src/pages/marketing/CustomerDetailPage.tsx
Normal file
118
src/pages/marketing/CustomerDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,41 +1,598 @@
|
|||
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')}>
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>No-code loyalty config</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Earn rate (points per ETB)</Label>
|
||||
<Input value={earn} onChange={(e) => setEarn(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Referral bonus (points)</Label>
|
||||
<Input value={referral} onChange={(e) => setReferral(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Minimum redemption</Label>
|
||||
<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>
|
||||
<Button onClick={() => toast.success('Loyalty rules saved (mock)')}>{t('common.save')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Earn rate (points per ETB)</Label>
|
||||
<Input value={earn} onChange={(e) => setEarn(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Referral bonus (points)</Label>
|
||||
<Input value={referral} onChange={(e) => setReferral(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Minimum redemption</Label>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
320
src/pages/marketing/SegmentDetailPage.tsx
Normal file
320
src/pages/marketing/SegmentDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
316
src/pages/migrations/MigrationJobDetailPage.tsx
Normal file
316
src/pages/migrations/MigrationJobDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,145 +144,358 @@ export function MigrationsPage() {
|
|||
|
||||
return (
|
||||
<PageShell title={t('migrations.title')} subtitle={t('migrations.subtitle')}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['template', 'upload', 'map', 'validate', 'done'] as Step[]).map((s) => (
|
||||
<Badge key={s} variant={step === s ? 'default' : 'outline'}>
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
<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>
|
||||
|
||||
{step === 'template' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('migrations.templates')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<SelectNative value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
|
||||
{importTemplates.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.label}
|
||||
</option>
|
||||
))}
|
||||
</SelectNative>
|
||||
<p className="text-sm text-muted-foreground">Fields: {template.fields.join(', ')}</p>
|
||||
<Button onClick={() => setStep('upload')}>{t('common.next')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Tabs defaultValue="wizard">
|
||||
<TabsList>
|
||||
<TabsTrigger value="wizard">Import wizard</TabsTrigger>
|
||||
<TabsTrigger value="history">Job history</TabsTrigger>
|
||||
<TabsTrigger value="templates">Templates</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{step === 'upload' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('common.upload')} CSV / Excel (CSV text parsed in demo)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.txt,.tsv"
|
||||
onChange={(e) => void onFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setHeaders(['sku', 'branch', 'qty_on_hand', 'batch_no'])
|
||||
setPreviewRows([
|
||||
['AMX-500', 'br-bole', '50', 'B99'],
|
||||
['PCM-500', 'br-cmc', 'x', 'B98'],
|
||||
])
|
||||
setMapping({ sku: 'sku', branchId: 'branch', qty: 'qty_on_hand', batch: 'batch_no' })
|
||||
setStep('map')
|
||||
}}
|
||||
>
|
||||
Use sample stock CSV
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setStep('template')}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<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'}>
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 'map' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('migrations.mapColumns')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{template.fields.map((field) => (
|
||||
<div key={field} className="grid items-center gap-2 sm:grid-cols-2">
|
||||
<span className="text-sm font-medium">{field}</span>
|
||||
<SelectNative
|
||||
value={mapping[field] ?? ''}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, [field]: e.target.value }))}
|
||||
>
|
||||
<option value="">— skip —</option>
|
||||
{headers.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{h}
|
||||
{step === 'template' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('migrations.templates')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<SelectNative value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
|
||||
{importTemplates.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.label}
|
||||
</option>
|
||||
))}
|
||||
</SelectNative>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('upload')}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button onClick={validate}>{t('common.validate')}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">Fields: {template.fields.join(', ')}</p>
|
||||
<Button onClick={() => setStep('upload')}>{t('common.next')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(step === 'validate' || step === 'done') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('migrations.dryRun')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{errors.length ? (
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-red-600">
|
||||
{errors.map((e) => (
|
||||
<li key={e}>{e}</li>
|
||||
{step === 'upload' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('common.upload')} CSV / Excel (CSV text parsed in demo)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.txt,.tsv"
|
||||
onChange={(e) => void onFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setHeaders(['sku', 'branch', 'qty_on_hand', 'batch_no'])
|
||||
setPreviewRows([
|
||||
['AMX-500', 'br-bole', '50', 'B99'],
|
||||
['PCM-500', 'br-cmc', 'x', 'B98'],
|
||||
])
|
||||
setMapping({ sku: 'sku', branchId: 'branch', qty: 'qty_on_hand', batch: 'batch_no' })
|
||||
setStep('map')
|
||||
}}
|
||||
>
|
||||
Use sample stock CSV
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setStep('template')}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 'map' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('migrations.mapColumns')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{template.fields.map((field) => (
|
||||
<div key={field} className="grid items-center gap-2 sm:grid-cols-2">
|
||||
<span className="text-sm font-medium">{field}</span>
|
||||
<SelectNative
|
||||
value={mapping[field] ?? ''}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, [field]: e.target.value }))}
|
||||
>
|
||||
<option value="">— skip —</option>
|
||||
{headers.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{h}
|
||||
</option>
|
||||
))}
|
||||
</SelectNative>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Badge variant="success">Validation passed (demo)</Badge>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headers.map((h) => (
|
||||
<TableHead key={h}>{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewRows.map((r, i) => (
|
||||
<TableRow key={i}>
|
||||
{r.map((c, j) => (
|
||||
<TableCell key={j} className={c === 'x' ? 'text-red-600' : ''}>
|
||||
{c}
|
||||
{c === 'x' ? ' ⚠' : ''}
|
||||
</TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('upload')}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button onClick={validate}>{t('common.validate')}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(step === 'validate' || step === 'done') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('migrations.dryRun')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{errors.length ? (
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-red-600">
|
||||
{errors.map((e) => (
|
||||
<li key={e}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Badge variant="success">Validation passed (demo)</Badge>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headers.map((h) => (
|
||||
<TableHead key={h}>{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewRows.map((r, i) => (
|
||||
<TableRow key={i}>
|
||||
{r.map((c, j) => (
|
||||
<TableCell key={j} className={c === 'x' ? 'text-red-600' : ''}>
|
||||
{c}
|
||||
{c === 'x' ? ' ⚠' : ''}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('map')}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button onClick={commit} disabled={step === 'done'}>
|
||||
{t('common.commit')}
|
||||
</Button>
|
||||
</div>
|
||||
{step === 'done' && <p className="text-sm text-muted-foreground">Job logged to audit (mock).</p>}
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('map')}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button onClick={commit} disabled={step === 'done'}>
|
||||
{t('common.commit')}
|
||||
</Button>
|
||||
</div>
|
||||
{step === 'done' && <p className="text-sm text-muted-foreground">Job logged to audit (mock).</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
157
src/pages/orders/OrderDetailPage.tsx
Normal file
157
src/pages/orders/OrderDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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={() => {
|
||||
setQ('')
|
||||
setStatus('all')
|
||||
setFulfillment('all')
|
||||
setBranchFilter('all')
|
||||
setChannel('all')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
filters={orderFilters}
|
||||
clearVisible={filtersActive}
|
||||
onClearFilters={() => {
|
||||
setQ('')
|
||||
setFulfillment('all')
|
||||
setBranchFilter('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,9 +315,11 @@ export function OrdersPage() {
|
|||
variant={
|
||||
o.status === 'cancelled'
|
||||
? 'danger'
|
||||
: o.status === 'out_for_delivery'
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: o.status === 'pending'
|
||||
? 'secondary'
|
||||
: o.status === 'out_for_delivery'
|
||||
? 'warning'
|
||||
: 'success'
|
||||
}
|
||||
>
|
||||
{o.status}
|
||||
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
312
src/pages/organisations/OrganisationDetailPage.tsx
Normal file
312
src/pages/organisations/OrganisationDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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={() => {
|
||||
setQ('')
|
||||
setStatus('all')
|
||||
setSource('all')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
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>
|
||||
|
|
|
|||
85
src/pages/pos/CustomerCodesPanel.tsx
Normal file
85
src/pages/pos/CustomerCodesPanel.tsx
Normal 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:<id></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>
|
||||
)
|
||||
}
|
||||
614
src/pages/pos/InvoiceScanPanel.tsx
Normal file
614
src/pages/pos/InvoiceScanPanel.tsx
Normal 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
50
src/pages/pos/PosPage.tsx
Normal 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
59
src/pages/pos/QrImage.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
873
src/pages/pos/QuickPurchasePanel.tsx
Normal file
873
src/pages/pos/QuickPurchasePanel.tsx
Normal 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
198
src/pages/pos/ScanPad.tsx
Normal 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’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>
|
||||
)
|
||||
}
|
||||
227
src/pages/pos/TicketQueuePanel.tsx
Normal file
227
src/pages/pos/TicketQueuePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
src/pages/pos/commitPurchase.ts
Normal file
155
src/pages/pos/commitPurchase.ts
Normal 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
270
src/pages/pos/posEngine.ts
Normal 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)}`
|
||||
}
|
||||
165
src/pages/pos/useQrScanner.ts
Normal file
165
src/pages/pos/useQrScanner.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
329
src/pages/prescriptions/PrescriptionDetailPage.tsx
Normal file
329
src/pages/prescriptions/PrescriptionDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
||||
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' },
|
||||
],
|
||||
},
|
||||
...(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: '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={
|
||||
<>
|
||||
<FilterSelect
|
||||
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={[
|
||||
{ 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={() => {
|
||||
setQ('')
|
||||
setStatus('all')
|
||||
setBranchFilter('all')
|
||||
setControlledOnly('all')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
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) => (
|
||||
) : null}
|
||||
{filtered.map((p) => {
|
||||
const actions = rowActions(p)
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.id}</TableCell>
|
||||
<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>{p.prescriber ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap gap-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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
121
src/pages/procurement/ProcurementDetailPage.tsx
Normal file
121
src/pages/procurement/ProcurementDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
if (q) {
|
||||
const needle = q.toLowerCase()
|
||||
if (!r.sku.toLowerCase().includes(needle) && !r.name.toLowerCase().includes(needle)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [q, signal])
|
||||
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()
|
||||
list = list.filter(
|
||||
(r) => r.sku.toLowerCase().includes(needle) || r.name.toLowerCase().includes(needle),
|
||||
)
|
||||
}
|
||||
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,108 +212,331 @@ export function ProcurementPage() {
|
|||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<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')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Signal</TableHead>
|
||||
<TableHead className="text-right">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>
|
||||
<TableCell>{r.qty}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={sig === 'reorder' ? 'warning' : sig === 'slow' ? 'muted' : 'success'}
|
||||
>
|
||||
{sig}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
primary={[
|
||||
{
|
||||
label: 'Request',
|
||||
icon: <PackagePlus className="h-3.5 w-3.5" />,
|
||||
onClick: () => toast.success(`Reorder draft for ${r.sku}`),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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={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>{t('common.branch')}</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRequests.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t('common.noResults')}
|
||||
</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>
|
||||
<p className="font-medium">{r.sku}</p>
|
||||
<p className="text-xs text-muted-foreground">{r.reason}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
onClick={() =>
|
||||
toast.success(
|
||||
r.suggestion === 'transfer'
|
||||
? 'Transfer request sent to Stock Manager'
|
||||
: `PO draft for ${r.qty} units`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{r.suggestion === 'transfer' ? 'Request transfer' : `Order ${r.qty}`}
|
||||
</Button>
|
||||
) : (
|
||||
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={priorityVariant[r.priority]}>{r.priority}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[r.status]}>{r.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
primary={[
|
||||
{
|
||||
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>
|
||||
</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="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"
|
||||
className="h-9"
|
||||
onClick={() =>
|
||||
toast.success(
|
||||
r.suggestion === 'transfer'
|
||||
? 'Transfer request sent to Stock Manager'
|
||||
: `PO draft for ${r.qty} units`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
745
src/pages/profile/ProfilePage.tsx
Normal file
745
src/pages/profile/ProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
src/pages/settings/BranchDetailPage.tsx
Normal file
267
src/pages/settings/BranchDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,31 +1,578 @@
|
|||
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">
|
||||
<CardHeader>
|
||||
<CardTitle>Platform</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Data retention (days)</Label>
|
||||
<Input defaultValue="365" />
|
||||
<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>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Data retention (days)</Label>
|
||||
<Input defaultValue="365" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
<div className="space-y-1">
|
||||
<Label>API base URL</Label>
|
||||
<Input defaultValue={import.meta.env.VITE_API_BASE_URL || 'https://api.gishen.example/v1'} />
|
||||
</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>
|
||||
<Button onClick={() => toast.success('Settings saved (local demo)')}>{t('common.save')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
122
src/pages/stock/StockDetailPage.tsx
Normal file
122
src/pages/stock/StockDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,15 +157,17 @@ export function StockPage() {
|
|||
resultCount={filtered.length}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect
|
||||
label="Branch"
|
||||
value={branchFilter}
|
||||
onChange={setBranchFilter}
|
||||
options={[
|
||||
{ value: 'all', label: t('common.allBranches') },
|
||||
...branches.map((b) => ({ value: b.id, label: b.name })),
|
||||
]}
|
||||
/>
|
||||
{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="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,38 +214,57 @@ export function StockPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((r) => (
|
||||
<TableRow key={`${r.sku}-${r.branchId}`}>
|
||||
<TableCell className="font-medium">{r.sku}</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 ? (
|
||||
<Badge variant="warning">low stock</Badge>
|
||||
) : (
|
||||
<Badge variant="success">ok</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
primary={
|
||||
r.qty !== r.erpQty
|
||||
? [
|
||||
{
|
||||
label: 'Sync ERP',
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
onClick: () => resolve(r.sku),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{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">
|
||||
<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 === 0 ? (
|
||||
<Badge variant="danger">out of stock</Badge>
|
||||
) : r.qty < LOW_STOCK_THRESHOLD ? (
|
||||
<Badge variant="warning">low stock</Badge>
|
||||
) : (
|
||||
<Badge variant="success">ok</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
primary={[
|
||||
{
|
||||
label: 'View',
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
to: `/stock/${r.sku}`,
|
||||
},
|
||||
...(r.qty !== r.erpQty
|
||||
? [
|
||||
{
|
||||
label: 'Sync ERP',
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
onClick: () => resolve(r.sku),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
109
src/pages/team/TeamMemberDetailPage.tsx
Normal file
109
src/pages/team/TeamMemberDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
Reference in New Issue
Block a user