From b36a30385c835eb6dcb76d167e3a0e9e3d56089b Mon Sep 17 00:00:00 2001 From: Aravind Date: Fri, 25 Sep 2026 16:29:41 +0530 Subject: [PATCH] update the adminpage ui --- docs/ADMIN-MONITORING-API.md | 310 +++++++ next.config.ts | 21 + package.json | 3 +- scripts/staff-access.test.mts | 95 ++ src/app/(admin)/admin/commerce/page.tsx | 10 + src/app/(admin)/admin/footfall/page.tsx | 10 + .../admin/merchants/[merchantId]/page.tsx | 15 + .../[shopId]/cameras/[cameraId]/page.tsx | 17 + .../[merchantId]/shops/[shopId]/page.tsx | 15 + src/app/(admin)/admin/merchants/page.tsx | 25 + src/app/(admin)/admin/page.tsx | 36 +- src/app/(admin)/admin/profile/page.tsx | 10 + src/app/(admin)/admin/settings/page.tsx | 10 + src/app/(public)/join/page.tsx | 23 + src/app/(workspace)/activity/page.tsx | 10 + src/app/(workspace)/customers/page.tsx | 24 + src/app/(workspace)/dashboard/page.tsx | 32 +- src/app/(workspace)/floor/page.tsx | 24 +- src/app/(workspace)/settings/layout.tsx | 10 +- src/app/(workspace)/settings/stores/page.tsx | 2 +- src/app/api/activities/events/route.ts | 33 + src/app/api/activities/impact/route.ts | 18 + src/app/api/activities/route.ts | 26 + src/app/api/auth/invitation/route.ts | 42 + src/app/api/auth/register/route.ts | 109 +++ src/app/api/cameras/[id]/live/route.ts | 21 + src/app/api/campaigns/route.ts | 16 + src/app/api/dashboard/summary/route.ts | 23 + src/app/api/purchases/route.ts | 29 + src/app/api/reports/journey/route.ts | 21 + src/app/api/sites/[site]/check/route.ts | 35 + src/app/api/sites/[site]/route.ts | 40 + src/app/api/sites/route.ts | 28 +- src/app/api/visitors/[id]/history/route.ts | 19 + src/app/api/visitors/[id]/image/route.ts | 37 + src/app/api/visitors/[id]/profile/route.ts | 23 + src/app/api/visitors/[id]/route.ts | 23 + src/app/api/visitors/route.ts | 28 + src/app/api/visits/route.ts | 28 +- src/app/api/visits/stream/route.ts | 27 + src/app/globals.css | 55 ++ src/app/layout.tsx | 2 +- .../admin/components/AdminAccount.tsx | 139 +++ src/features/admin/components/AdminLayout.tsx | 103 ++- .../admin/components/CameraDetail.tsx | 206 +++++ .../admin/components/CompaniesPanel.tsx | 278 ------ .../admin/components/CompanyActions.tsx | 148 ++++ .../admin/components/CompanyDetail.tsx | 402 +++++++++ .../admin/components/CreateCompanyDialog.tsx | 18 +- .../admin/components/DeleteCompanyDialog.tsx | 12 +- .../admin/components/MerchantsPanel.tsx | 261 ++++++ .../admin/components/MonitoringTables.tsx | 299 +++++++ .../admin/components/PlatformOverview.tsx | 821 ++++++++++++++++++ .../admin/components/PlatformPages.tsx | 336 +++++++ .../components/ResetOwnerPasswordDialog.tsx | 4 +- src/features/admin/components/StoreDetail.tsx | 205 +++++ .../admin/components/SuspendCompanyDialog.tsx | 10 +- .../components/analytics/AnalyticsPanel.tsx | 341 ++++++++ .../analytics/CommerceDashboard.tsx | 153 ++++ .../analytics/FootfallDashboard.tsx | 215 +++++ .../components/common/AdminPageHeader.tsx | 106 +++ .../admin/components/common/AdminSection.tsx | 59 ++ .../components/common/PendingIntegration.tsx | 41 + .../admin/components/shell/AdminShell.tsx | 274 ++++++ .../admin/components/shell/admin-nav.ts | 36 + src/features/admin/config/capabilities.ts | 108 +++ src/features/admin/hooks/useAiContext.ts | 28 + src/features/admin/hooks/useAnalytics.ts | 63 ++ src/features/admin/hooks/useCompanies.ts | 22 +- src/features/admin/hooks/useMonitoring.ts | 135 +++ .../admin/repositories/analyticsRepository.ts | 72 ++ .../repositories/monitoringRepository.ts | 103 +++ src/features/admin/types/analytics.ts | 109 +++ src/features/admin/types/company.ts | 13 +- src/features/admin/types/monitoring.ts | 67 ++ src/features/auth/components/JoinForm.tsx | 175 ++++ .../auth/components/LoginCredentialsForm.tsx | 2 +- src/features/auth/components/LoginSplit.tsx | 36 +- src/features/auth/hooks/useJoinForm.ts | 123 +++ .../auth/providers/SessionProvider.tsx | 141 ++- .../auth/repositories/authRepository.ts | 13 + src/features/auth/services/roles.ts | 15 + src/features/auth/services/staffAccess.ts | 108 +++ src/features/auth/services/tabSession.ts | 34 +- src/features/auth/types/join.ts | 14 + .../commerce/components/SaleDetailDialog.tsx | 23 +- .../commerce/components/SaleEntryDialog.tsx | 45 +- .../commerce/repositories/saleRepository.ts | 13 +- .../customers/components/CustomerDialog.tsx | 189 ++++ .../components/CustomerDirectory.tsx | 137 +++ .../customers/components/CustomerHistory.tsx | 49 ++ .../components/EraseCustomerDialog.tsx | 88 ++ .../components/RecordPurchaseDialog.tsx | 126 +++ src/features/customers/hooks/useCustomers.ts | 32 + .../repositories/customerRepository.ts | 46 + .../customers/services/mapCustomer.ts | 110 +++ src/features/customers/types/customer.ts | 74 ++ .../dashboard/hooks/useArrivalStream.ts | 81 ++ src/features/dashboard/hooks/useDashboard.ts | 21 +- .../repositories/reportRepository.ts | 29 +- .../engagement/components/ActivitiesPanel.tsx | 137 +++ .../engagement/components/AttributionNote.tsx | 18 + .../engagement/components/CampaignsPanel.tsx | 102 +++ .../components/EngagementSection.tsx | 25 + .../engagement/components/JourneyPanel.tsx | 52 ++ .../components/RecordActivityDialog.tsx | 111 +++ .../engagement/hooks/useEngagement.ts | 26 + .../repositories/engagementRepository.ts | 27 + .../engagement/services/mapEngagement.ts | 88 ++ src/features/engagement/types/engagement.ts | 72 ++ .../floor/components/NameCustomerDialog.tsx | 19 +- .../floor/components/TodaySummaryCard.tsx | 39 + src/features/floor/hooks/useFloor.ts | 39 +- src/features/floor/hooks/useTodaySummary.ts | 18 + .../floor/repositories/floorRepository.ts | 17 +- src/features/floor/services/mapSummary.ts | 15 + src/features/floor/types/summary.ts | 18 + .../loyaly-ai/components/EmptyState.tsx | 46 + .../loyaly-ai/components/SuggestionChips.tsx | 10 +- .../loyaly-ai/providers/LoyalyAiProvider.tsx | 24 +- .../repositories/loyalyAiRepository.ts | 40 +- src/features/loyaly-ai/types/chat.ts | 21 + src/features/loyaly-ai/utils/suggestions.ts | 28 + .../components/ActiveSessionsPanel.tsx | 131 +++ .../settings/components/SecurityManager.tsx | 125 +-- .../settings/components/StoreManagement.tsx | 374 ++++---- src/features/stores/components/CameraCard.tsx | 6 +- .../stores/components/LiveViewDialog.tsx | 83 ++ .../stores/components/OpenShopDialog.tsx | 110 +++ .../stores/components/RemoveShopDialog.tsx | 85 ++ .../stores/components/RenameShopDialog.tsx | 90 ++ .../stores/components/ShopSection.tsx | 22 +- .../stores/components/SiteCheckDialog.tsx | 107 +++ src/features/stores/hooks/useLiveView.ts | 65 ++ .../stores/repositories/cameraRepository.ts | 10 + .../stores/repositories/siteRepository.ts | 40 +- src/features/stores/types/site.ts | 2 + src/features/stores/types/siteCheck.ts | 23 + .../team/components/AddMemberDialog.tsx | 2 +- src/proxy.ts | 58 +- src/services/api/engagementApi.ts | 62 ++ src/services/api/purchasesApi.ts | 13 +- src/services/api/refs.ts | 44 + src/services/api/reportsApi.ts | 19 +- src/services/api/sitesApi.ts | 77 +- src/services/api/types.ts | 235 ++++- src/services/api/visitorsApi.ts | 40 +- src/services/api/visitsApi.ts | 19 +- .../components/patterns/LoadingState.tsx | 15 +- src/shared/components/patterns/MetricCard.tsx | 72 +- src/shared/components/scope/StoreSwitcher.tsx | 44 +- src/shared/hooks/useBreakpoint.ts | 3 +- src/shared/layouts/workspace/AppSideNav.tsx | 6 +- src/shared/layouts/workspace/MobileMenu.tsx | 6 +- src/shared/layouts/workspace/SideNavBrand.tsx | 6 +- .../layouts/workspace/WorkspaceShell.tsx | 6 +- src/shared/layouts/workspace/nav-config.ts | 1 + src/shared/layouts/workspace/useRoleNav.ts | 27 + src/shared/providers/WorkspaceProvider.tsx | 34 +- src/shared/services/bff.ts | 35 + src/shared/services/httpClient.ts | 121 +++ src/shared/utils/icons.ts | 9 + src/theme/loyaly.css | 6 +- src/theme/loyaly.d.ts | 2 +- src/theme/loyaly.js | 6 +- src/theme/loyaly.variants.d.ts | 2 +- src/theme/loyalyTheme.ts | 10 +- tsconfig.json | 2 +- 168 files changed, 10535 insertions(+), 1008 deletions(-) create mode 100644 docs/ADMIN-MONITORING-API.md create mode 100644 scripts/staff-access.test.mts create mode 100644 src/app/(admin)/admin/commerce/page.tsx create mode 100644 src/app/(admin)/admin/footfall/page.tsx create mode 100644 src/app/(admin)/admin/merchants/[merchantId]/page.tsx create mode 100644 src/app/(admin)/admin/merchants/[merchantId]/shops/[shopId]/cameras/[cameraId]/page.tsx create mode 100644 src/app/(admin)/admin/merchants/[merchantId]/shops/[shopId]/page.tsx create mode 100644 src/app/(admin)/admin/merchants/page.tsx create mode 100644 src/app/(admin)/admin/profile/page.tsx create mode 100644 src/app/(admin)/admin/settings/page.tsx create mode 100644 src/app/(public)/join/page.tsx create mode 100644 src/app/(workspace)/customers/page.tsx create mode 100644 src/app/api/activities/events/route.ts create mode 100644 src/app/api/activities/impact/route.ts create mode 100644 src/app/api/activities/route.ts create mode 100644 src/app/api/auth/invitation/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/cameras/[id]/live/route.ts create mode 100644 src/app/api/campaigns/route.ts create mode 100644 src/app/api/dashboard/summary/route.ts create mode 100644 src/app/api/purchases/route.ts create mode 100644 src/app/api/reports/journey/route.ts create mode 100644 src/app/api/sites/[site]/check/route.ts create mode 100644 src/app/api/sites/[site]/route.ts create mode 100644 src/app/api/visitors/[id]/history/route.ts create mode 100644 src/app/api/visitors/[id]/image/route.ts create mode 100644 src/app/api/visitors/[id]/profile/route.ts create mode 100644 src/app/api/visitors/[id]/route.ts create mode 100644 src/app/api/visitors/route.ts create mode 100644 src/app/api/visits/stream/route.ts create mode 100644 src/features/admin/components/AdminAccount.tsx create mode 100644 src/features/admin/components/CameraDetail.tsx delete mode 100644 src/features/admin/components/CompaniesPanel.tsx create mode 100644 src/features/admin/components/CompanyActions.tsx create mode 100644 src/features/admin/components/CompanyDetail.tsx create mode 100644 src/features/admin/components/MerchantsPanel.tsx create mode 100644 src/features/admin/components/MonitoringTables.tsx create mode 100644 src/features/admin/components/PlatformOverview.tsx create mode 100644 src/features/admin/components/PlatformPages.tsx create mode 100644 src/features/admin/components/StoreDetail.tsx create mode 100644 src/features/admin/components/analytics/AnalyticsPanel.tsx create mode 100644 src/features/admin/components/analytics/CommerceDashboard.tsx create mode 100644 src/features/admin/components/analytics/FootfallDashboard.tsx create mode 100644 src/features/admin/components/common/AdminPageHeader.tsx create mode 100644 src/features/admin/components/common/AdminSection.tsx create mode 100644 src/features/admin/components/common/PendingIntegration.tsx create mode 100644 src/features/admin/components/shell/AdminShell.tsx create mode 100644 src/features/admin/components/shell/admin-nav.ts create mode 100644 src/features/admin/config/capabilities.ts create mode 100644 src/features/admin/hooks/useAiContext.ts create mode 100644 src/features/admin/hooks/useAnalytics.ts create mode 100644 src/features/admin/hooks/useMonitoring.ts create mode 100644 src/features/admin/repositories/analyticsRepository.ts create mode 100644 src/features/admin/repositories/monitoringRepository.ts create mode 100644 src/features/admin/types/analytics.ts create mode 100644 src/features/admin/types/monitoring.ts create mode 100644 src/features/auth/components/JoinForm.tsx create mode 100644 src/features/auth/hooks/useJoinForm.ts create mode 100644 src/features/auth/services/roles.ts create mode 100644 src/features/auth/services/staffAccess.ts create mode 100644 src/features/auth/types/join.ts create mode 100644 src/features/customers/components/CustomerDialog.tsx create mode 100644 src/features/customers/components/CustomerDirectory.tsx create mode 100644 src/features/customers/components/CustomerHistory.tsx create mode 100644 src/features/customers/components/EraseCustomerDialog.tsx create mode 100644 src/features/customers/components/RecordPurchaseDialog.tsx create mode 100644 src/features/customers/hooks/useCustomers.ts create mode 100644 src/features/customers/repositories/customerRepository.ts create mode 100644 src/features/customers/services/mapCustomer.ts create mode 100644 src/features/customers/types/customer.ts create mode 100644 src/features/dashboard/hooks/useArrivalStream.ts create mode 100644 src/features/engagement/components/ActivitiesPanel.tsx create mode 100644 src/features/engagement/components/AttributionNote.tsx create mode 100644 src/features/engagement/components/CampaignsPanel.tsx create mode 100644 src/features/engagement/components/EngagementSection.tsx create mode 100644 src/features/engagement/components/JourneyPanel.tsx create mode 100644 src/features/engagement/components/RecordActivityDialog.tsx create mode 100644 src/features/engagement/hooks/useEngagement.ts create mode 100644 src/features/engagement/repositories/engagementRepository.ts create mode 100644 src/features/engagement/services/mapEngagement.ts create mode 100644 src/features/engagement/types/engagement.ts create mode 100644 src/features/floor/components/TodaySummaryCard.tsx create mode 100644 src/features/floor/hooks/useTodaySummary.ts create mode 100644 src/features/floor/services/mapSummary.ts create mode 100644 src/features/floor/types/summary.ts create mode 100644 src/features/settings/components/ActiveSessionsPanel.tsx create mode 100644 src/features/stores/components/LiveViewDialog.tsx create mode 100644 src/features/stores/components/OpenShopDialog.tsx create mode 100644 src/features/stores/components/RemoveShopDialog.tsx create mode 100644 src/features/stores/components/RenameShopDialog.tsx create mode 100644 src/features/stores/components/SiteCheckDialog.tsx create mode 100644 src/features/stores/hooks/useLiveView.ts create mode 100644 src/features/stores/types/siteCheck.ts create mode 100644 src/services/api/engagementApi.ts create mode 100644 src/services/api/refs.ts create mode 100644 src/shared/layouts/workspace/useRoleNav.ts diff --git a/docs/ADMIN-MONITORING-API.md b/docs/ADMIN-MONITORING-API.md new file mode 100644 index 0000000..94bb4fd --- /dev/null +++ b/docs/ADMIN-MONITORING-API.md @@ -0,0 +1,310 @@ +# Platform admin monitoring: backend API plan + +Status: **proposal for the platform team** · Written 2026-09-24 · Frontend ready on `main` + +The platform console (`/admin`) now has the full drill-down built: + +``` +Overview → Merchants → Merchant → Shops → Shop → Cameras → Camera → Events / Alerts +``` + +(Console routes: `/admin/merchants/{clientId}/shops/{siteId}/cameras/{cameraId}`. +The platform's own names stay `clients` / `sites`; "merchant" and "shop" are +the console's words for them.) + +Only the first rung has data. Everything below it shows **"Backend integration +required"** because the platform has no admin endpoint that can answer it. This +document lists what the console needs, the rules each endpoint must enforce, and +exactly what the frontend changes when each one ships (usually one line). + +--- + +## 1. Where things stand + +| Level | Endpoint | Status | +|---|---|---| +| Companies | `GET /api/admin/clients` | **Live** | +| Create / suspend / reinstate / reset owner password / delete | `/api/admin/clients…` | **Live** | +| Company detail | `GET /api/admin/clients/{clientId}` | Missing. The console reads the row from the list, which has every field there is today. | +| Company → stores | `GET /api/admin/clients/{clientId}/sites` | **Missing** | +| Store detail | `GET /api/admin/clients/{clientId}/sites/{siteId}` | **Missing** | +| Store → cameras | `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras` | **Missing** | +| Camera detail | `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras/{cameraId}` | **Missing** | +| Events | `GET /api/admin/clients/{clientId}/sites/{siteId}/events` | **Missing** | +| Alerts | `GET /api/admin/clients/{clientId}/sites/{siteId}/alerts` | **Missing** | +| Platform totals | `GET /api/admin/monitoring/summary` | **Missing** | +| Platform-scope assistant | `POST /api/admin/assistant` | **Missing** | + +### Why the tenant endpoints cannot be reused + +`/api/sites`, cameras, visits and `/api/assistant` all take the company from the +**signed-in account's `client_id`**. A platform admin has `role = "admin"` and an +**empty** `client_id`, which is the very thing that makes `adminOnly` pass. So +those routes have no company to scope to, and the frontend proxy refuses them for +admin sessions (403). + +The console must **not** get around this by: +- passing a company id to a tenant route, +- listing every site on the platform and filtering in the browser, +- signing in as the tenant's owner behind the scenes. + +Each of those moves the tenancy check out of the server that holds the data. +The endpoints below keep it there. + +--- + +## 2. Rules for every endpoint below + +### 2.1 Authorisation +- Same gate as today: `adminOnly` (`role === "admin"` AND empty `client_id`). + Anyone else gets **404, not 403**, matching `/api/admin/clients`. + +### 2.2 Ownership, checked on every nested request +The URL is a claim, not proof. For +`/api/admin/clients/A/sites/B/cameras/C` the server must verify **all** of: + +``` +client A exists +site B.client_id == A → otherwise 404 +camera C.site_id == B → otherwise 404 +``` + +Return **404** when any link fails, never the object found under a different +parent. A camera id that is valid, but under another company's store, has to +look exactly like one that does not exist. + +Do this in a single query joined up to `client_id`, e.g. +`WHERE c.id = $cam AND c.site_id = $site AND s.client_id = $client`, +not as three lookups that each trust the previous id. + +### 2.3 Reuse, don't fork +The tenant handlers already compute everything the console shows +(`SiteHealth`, camera liveness, `last_seen_at`). Take their query functions +and call them with an explicit `client_id` argument, rather than writing a +second copy for admin. The only difference between the two routes should be +where `client_id` comes from: the session for tenants, the path for admins. + +### 2.4 Redaction +Admins see **operational** state, not credentials. From camera rows, drop +`username`, `host`, `port`, `path` and `has_password`. The console doesn't +need them, and a platform admin has no business collecting a merchant's +camera credentials in one place. + +### 2.5 Suspended companies +Still readable. A suspended company is usually the one somebody is on the phone +about, so the admin needs to see it. + +### 2.6 Audit +Log every admin read below the company level (`admin_id`, `client_id`, path). +Face and visit data is biometric-adjacent, and looking into a tenant's data is +a different act from administering the tenant. + +### 2.7 Pagination +Lists that can grow take `?limit=` (default 50, max 200) and `?cursor=`, and +return `{items: [...], next_cursor: "…"|null}`. The console reads `items` and +handles either a bare array or this envelope in its mapper. + +--- + +## 3. Endpoints + +Shapes deliberately **match the tenant payloads the frontend already +understands** (`ApiSite`, `ApiCamera` in `src/services/api/types.ts`), so one +mapper serves both consoles. Field names below are the wire names. + +### 3.1 `GET /api/admin/clients/{clientId}` +Company detail. Same `ClientRow` as the list. Optionally add `owner_email` and +`owner_name`: the console has a slot for "Owner" and leaves it out today +because nothing sends it. + +### 3.2 `GET /api/admin/clients/{clientId}/sites` +Stores of one company. **Only** that company's sites. + +```jsonc +[ + { + "site_id": "uuid", + "slug": "…", + "name": "…", + "timezone": "Asia/Kolkata", + "online": true, + "cameras_up": 3, + "cameras_total": 4, + "created_at": "2026-…" + } +] +``` +`?q=` (name/slug contains) is useful once a tenant has dozens of stores. + +### 3.3 `GET /api/admin/clients/{clientId}/sites/{siteId}` +One store, the same row as 3.2. 404 unless `site.client_id = clientId`. + +### 3.4 `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras` +```jsonc +[ + { + "id": "uuid", + "camera_id": "entrance-1", + "label": "Entrance", + "enabled": true, + "connected": true, // null = never reported + "last_seen_at": "2026-…", + "live_available": false // true only when an admin stream route exists (3.8) + } +] +``` +The console derives status from `connected`: `true` is Online, `false` is +Offline, `null` is Unknown. It invents no "maintenance" or "warning" state. +If the platform has such a state, add it as a field and the console will show +it. + +### 3.5 `GET …/cameras/{cameraId}` +One camera, same row. 404 unless the full chain in §2.2 holds. + +### 3.6 `GET …/sites/{siteId}/events?camera=&since=&limit=&cursor=` +```jsonc +{ "items": [ + { "id": "…", "at": "2026-…", "type": "visit|face_match|…", + "camera_id": "…", "camera_label": "…", "severity": "info|warning|critical" } +], "next_cursor": null } +``` +`camera` narrows to one camera **of this site**. A camera from another site +returns an empty list, never that camera's events. + +### 3.7 `GET …/sites/{siteId}/alerts?camera=&status=open` +```jsonc +[ { "id": "…", "at": "…", "title": "Camera offline", + "severity": "critical|warning|info", + "status": "open|acknowledged|resolved", + "camera_id": "…", "camera_label": "…" } ] +``` +If the platform has no alert model yet, this is the one endpoint that needs a +design decision first, not just plumbing. "Camera offline for more than N +minutes" is the obvious first alert, and it can be derived from data already +stored. + +### 3.8 Live stream (later) +`GET …/cameras/{cameraId}/live`, which reuses the tenant live route with the +ownership chain from §2.2 and an audit entry. Until it exists the console shows +"Live feed unavailable" and never a placeholder that looks live. + +### 3.9 `GET /api/admin/monitoring/summary` +Platform-wide totals for the Overview page. Today it shows cameras online, +open alerts and events today as "—". + +```jsonc +{ "cameras_total": 0, "cameras_online": 0, + "alerts_open": 0, "events_today": 0, + "as_of": "2026-…" } +``` +Aggregate counts only, with no per-tenant rows. + +### 3.10 `POST /api/admin/assistant` +Platform-scope Loyaly AI. The body is the same as `/api/assistant` plus +`context: {level, company_id?, site_id?, camera_id?}`, which the console +already tracks per page. Its tools must go through 3.1–3.9, so the assistant +inherits the same ownership checks rather than having its own. + +--- + +### 3.11 Merchant-level areas (added 2026-09-25) + +The console now lists these on every merchant page with their state, and has +top-level **Footfall** and **Commerce** pages that pick Merchant → Shop before +asking for anything. Each is a flag in `features/admin/config/capabilities.ts`. +Same rules as §2: admin-only (404 otherwise), ownership checked on every nested +id, never answered from a tenant route. + +| Area | Endpoint needed | Flag | +|---|---|---| +| Edit merchant | `PATCH /api/admin/clients/{clientId}` accepting `name` (today it takes `active` only) | `merchantEdit` | +| Sales persons | `GET/POST /api/admin/clients/{clientId}/salespersons`, `PATCH/DELETE …/salespersons/{id}` — they sign in on the mobile app | `salesPersons` | +| Customers | `GET /api/admin/clients/{clientId}/customers` | `customers` | +| Sales | `GET …/sites/{siteId}/sales` | `sales` | +| Analytics | `GET …/sites/{siteId}/analytics` | `analytics` | +| Footfall | `GET …/sites/{siteId}/visits?since=&until=` | `footfall` | +| Commerce | `GET …/sites/{siteId}/commerce` (or reuse `sales`) | `commerce` | +| Camera CRUD | `POST …/sites/{siteId}/cameras`, `PATCH/DELETE …/cameras/{cameraId}` (read is §3.4) | `storeCameras` | +| Camera heartbeat | `GET …/sites/{siteId}/cameras/heartbeat` — `last_seen_at` per camera | `cameraHeartbeat` | +| Device logs | `GET …/sites/{siteId}/device-logs?cursor=` | `deviceLogs` | +| Testing software | `GET …/sites/{siteId}/testing` — shop-PC test runs | `testingSoftware` | +| Create shop / access code at onboarding | `POST /api/admin/clients/{clientId}/sites`, plus whatever issues the shop-PC access code. Today `POST /api/admin/clients` creates the merchant + owner login only. | — | + +#### Footfall and Commerce — response fields the console needs + +**Why the frontend cannot do this itself:** the only admin data is the merchant +list. Tenant routes (`/api/visits`, `/api/purchases`, `/api/sites`) scope by the +signed-in account's `client_id`, which a platform admin does not have, and the +BFF refuses them for admin sessions. Calling them with a swapped id, or summing +every merchant in the browser, would move the tenancy check out of the server. + +| Required backend endpoint | Required response fields | +|---|---| +| `GET /api/admin/clients/{clientId}/sites` | `site_id, slug, name, area` (or `location`), `online, cameras_total, cameras_up, created_at` — **`area` does not exist on any site today** and is needed for the Area filter and area comparison | +| `GET /api/admin/clients/{clientId}/footfall?from=&to=&area=&site=` | `[{date, site_id, area, visits}]` — one row per site per day, so area × date and day-by-day are derived without guessing | +| `GET /api/admin/footfall/summary?from=&to=` (optional, platform scope) | `[{client_id, visits}]` — server-side sum, so the browser never fetches every tenant's rows | +| `GET /api/admin/clients/{clientId}/sales?from=&to=&site=` | `[{date, site_id, sales_inr, transactions}]` | +| `GET /api/admin/sales/summary?from=&to=` | `[{client_id, sales_inr, transactions}]` for the all-merchants comparison | +| `GET /api/admin/clients/{clientId}/sites/{siteId}/transactions?from=&to=&cursor=` | `[{id, at, amount_inr, payment_method?, category?}]` — only what the platform records | + +#### Footfall and Commerce — the exact contract the console calls (added 2026-09-25) + +The Footfall and Commerce pages are fully built against these paths +(`features/admin/repositories/analyticsRepository.ts`, shapes in +`features/admin/types/analytics.ts`). Each panel renders "Backend integration +required" until the flag is on; turning it on is the only frontend change. + +Common query parameters on every call: `from`, `to` (ISO `YYYY-MM-DD`, +inclusive), and optionally `merchant` (client id), `area`, `shop` (site id). +`area`/`shop` are only ever sent together with `merchant`; the server must +refuse a shop that is not that merchant's (§2.2) and answer 404 to non-admins. + +| Endpoint | Returns | Flag | +|---|---|---| +| `GET /api/admin/footfall/overview` | `{totalFootfall, averageDaily, peakDay?: {date, footfall}, activeLocations, reportingShops}` | `footfall` | +| `GET /api/admin/footfall/by-area` | `[{area, footfall}]` | `footfall` | +| `GET /api/admin/footfall/daily` | `[{date, footfall}]` | `footfall` | +| `GET /api/admin/footfall/area-date` | `[{date, area, footfall}]` (long form; the console pivots) | `footfall` | +| `GET /api/admin/footfall/details` | `[{date, merchantId, merchantName, area?, shopId, shopName, footfall, entries?, exits?}]` | `footfall` | +| `GET /api/admin/commerce/overview` | `{salesInr, transactions, averageTransactionInr, activeMerchants, activeShops}` | `commerce` | +| `GET /api/admin/commerce/by-merchant` | `[{merchantId, merchantName, salesInr, transactions}]` | `commerce` | +| `GET /api/admin/commerce/daily` | `[{date, salesInr, transactions}]` | `commerce` | +| `GET /api/admin/commerce/details` | `[{date, merchantId, merchantName, shopId, shopName, salesInr, transactions, paymentMethod?, category?}]` | `commerce` | + +The BFF routes (`src/app/api/admin/footfall/*`, `src/app/api/admin/commerce/*`) +must be added alongside, enveloping the response like `/api/admin/clients`. +Area also needs a real field on sites (`area` or `location`) — it exists +nowhere today, so the Area filter stays disabled until it does. + +## 4. Frontend wiring per endpoint + +Everything else is already built: the pages, breadcrumbs, empty, loading and +error states, and the tables that render the rows. + +For each endpoint: + +1. **Upstream call.** Add a method to `src/services/api/adminApi.ts`. +2. **BFF route.** Add `src/app/api/admin/clients/[id]/sites/…/route.ts`, the + same pattern as `clients/[id]/route.ts` (`serveUpstream` + a mapper). +3. **Mapper.** Wire → `AdminStore` / `AdminCamera` / `AdminEvent` / `AdminAlert` + (`src/features/admin/types/monitoring.ts`). `cameras_total` maps to + `cameras`, `cameras_up` to `camerasOnline`, `connected` to `status`. +4. **Flag.** Set the level to `true` in `src/features/admin/config/capabilities.ts`. + +With the flag on, the repository (`features/admin/repositories/monitoringRepository.ts`) +returns its endpoint instead of `null`. The section then fetches and renders +the table in place of the integration-required state, and the Overview's +"Monitoring coverage" panel switches that row to **Live**. No component +changes. + +--- + +## 5. Acceptance checks for the platform team + +- [ ] A tenant token on any `/api/admin/*` route gets 404. +- [ ] `GET /clients/A/sites` never returns a site whose `client_id ≠ A`. +- [ ] `GET /clients/A/sites/B` where B belongs to company C gets 404. +- [ ] `GET /clients/A/sites/B/cameras/X` where X belongs to another site gets 404. +- [ ] `?camera=X` on events/alerts, with X from another site, returns an empty list. +- [ ] Camera rows contain no host, port, path, username or password flag. +- [ ] Every admin read below the company level writes an audit entry. diff --git a/next.config.ts b/next.config.ts index be4da9c..438d94d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,6 +10,27 @@ const nextConfig: NextConfig = { // local `npm run build` keeps its warm cache. turbopackFileSystemCacheForBuild: process.env.CI_BUILD !== "1", }, + // The platform console renamed Companies → Merchants and Stores → Shops. + // Temporary (307) so bookmarks keep working without browsers caching the + // move forever while the admin IA is still settling. + async redirects() { + return [ + { + source: "/admin/companies/:id/stores/:shopId/:rest*", + destination: "/admin/merchants/:id/shops/:shopId/:rest*", + permanent: false, + }, + { + source: "/admin/companies/:rest*", + destination: "/admin/merchants/:rest*", + permanent: false, + }, + {source: "/admin/stores", destination: "/admin/merchants", permanent: false}, + // The overview IS /admin; these are the names people guess for it. + {source: "/admin/overview", destination: "/admin", permanent: false}, + {source: "/admin/dashboard", destination: "/admin", permanent: false}, + ]; + }, allowedDevOrigins: ["192.168.0.117", "192.168.0.*", "192.168.1.*", "localhost", "127.0.0.1"], images: { remotePatterns: [ diff --git a/package.json b/package.json index f1e5e81..928a83f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "bundle": "bash scripts/bundle.sh", "theme:build": "astryx theme build src/theme/loyalyTheme.ts", "typecheck": "tsc --noEmit", - "dev:preview": "next dev" + "dev:preview": "next dev", + "test:access": "node --test scripts/staff-access.test.mts" }, "dependencies": { "@astryxdesign/core": "^0.2.0", diff --git a/scripts/staff-access.test.mts b/scripts/staff-access.test.mts new file mode 100644 index 0000000..deec236 --- /dev/null +++ b/scripts/staff-access.test.mts @@ -0,0 +1,95 @@ +/** + * The staff access policy the proxy enforces (src/features/auth/services/ + * staffAccess.ts). Run with `npm run test:access` — Node's built-in runner, no + * dependencies. Covers the role matrix only; tenant isolation is the + * platform's (the company comes from the upstream token, never the request). + */ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import { + STAFF_HOME, + isStaffPage, + isStaffRole, + staffApiDecision, +} from '../src/features/auth/services/staffAccess.ts'; + +const q = (o: Record = {}) => new URLSearchParams(o); +const one = q({storeId: 'chennai', range: '30d'}); + +test('only role=staff is gated', () => { + assert.equal(isStaffRole('staff'), true); + for (const r of ['owner', 'manager', 'admin', undefined, '', 'STAFF']) { + assert.equal(isStaffRole(r), false, String(r)); + } +}); + +test('staff home is a staff page', () => { + assert.equal(isStaffPage(STAFF_HOME), true); +}); + +test('staff pages: floor work allowed, merchant pages refused', () => { + for (const p of ['/floor', '/customers', '/customers/abc', '/activity', '/settings/profile', '/settings/security']) { + assert.equal(isStaffPage(p), true, p); + } + for (const p of ['/dashboard', '/commerce', '/stores', '/lyts', '/staff', '/settings', '/settings/team', '/settings/billing', '/settings/roles', '/settings/stores', '/admin', '/floorplan', '/customersX']) { + assert.equal(isStaffPage(p), false, p); + } +}); + +test('staff APIs: floor work allowed with one store', () => { + const allow: [string, string, URLSearchParams][] = [ + ['GET', '/api/sites', q()], + ['GET', '/api/floor/visits', one], + ['POST', '/api/visits/v1/attend', q()], + ['POST', '/api/visits/v1/release', q()], + ['POST', '/api/visits/v1/complete', q()], + ['GET', '/api/visits', one], + ['GET', '/api/visits/stream', q({storeId: 'chennai'})], + ['POST', '/api/customers', q()], + ['GET', '/api/visitors', q()], + ['GET', '/api/visitors/x/history', q()], + ['GET', '/api/visitors/x/image', q()], + ['PUT', '/api/visitors/x/profile', q()], + ['GET', '/api/faces', q({src: '/api/faces/a'})], + ['POST', '/api/sales', q()], + ['POST', '/api/purchases', q()], + ['GET', '/api/health', q()], + ]; + for (const [m, p, s] of allow) assert.equal(staffApiDecision(m, p, s), 'allow', `${m} ${p}`); +}); + +test('staff APIs: "All stores" or no store is refused on scoped reads', () => { + for (const p of ['/api/floor/visits', '/api/visits', '/api/visits/stream']) { + assert.equal(staffApiDecision('GET', p, q({storeId: 'all'})), 'needs_store', p); + assert.equal(staffApiDecision('GET', p, q()), 'needs_store', p); + } +}); + +test('staff APIs: merchant-only surfaces are forbidden', () => { + const deny: [string, string][] = [ + ['GET', '/api/reports/footfall'], + ['GET', '/api/reports/conversion'], + ['GET', '/api/reports/journey'], + ['GET', '/api/dashboard/summary'], + ['GET', '/api/sales'], + ['GET', '/api/sales/abc'], + ['GET', '/api/team'], + ['GET', '/api/team/invitations'], + ['GET', '/api/cameras'], + ['GET', '/api/cameras/c1/live'], + ['GET', '/api/images'], + ['GET', '/api/campaigns'], + ['GET', '/api/activities'], + ['POST', '/api/assistant'], + ['POST', '/api/sites'], + ['PATCH', '/api/sites/chennai'], + ['DELETE', '/api/sites/chennai'], + ['DELETE', '/api/visitors/x'], + ['GET', '/api/admin/clients'], + ['GET', '/api/unknown-new-route'], + // Method matters: an allowed path with the wrong verb is refused. + ['DELETE', '/api/sales'], + ['POST', '/api/floor/visits'], + ]; + for (const [m, p] of deny) assert.equal(staffApiDecision(m, p, one), 'forbidden', `${m} ${p}`); +}); diff --git a/src/app/(admin)/admin/commerce/page.tsx b/src/app/(admin)/admin/commerce/page.tsx new file mode 100644 index 0000000..e7f9acd --- /dev/null +++ b/src/app/(admin)/admin/commerce/page.tsx @@ -0,0 +1,10 @@ +import type {Metadata} from 'next'; +import {PlatformCommerce} from '@/features/admin/components/PlatformPages'; + +export const metadata: Metadata = { + title: 'Commerce', +}; + +export default function PlatformCommercePage() { + return ; +} diff --git a/src/app/(admin)/admin/footfall/page.tsx b/src/app/(admin)/admin/footfall/page.tsx new file mode 100644 index 0000000..4aabc18 --- /dev/null +++ b/src/app/(admin)/admin/footfall/page.tsx @@ -0,0 +1,10 @@ +import type {Metadata} from 'next'; +import {PlatformFootfall} from '@/features/admin/components/PlatformPages'; + +export const metadata: Metadata = { + title: 'Footfall', +}; + +export default function PlatformFootfallPage() { + return ; +} diff --git a/src/app/(admin)/admin/merchants/[merchantId]/page.tsx b/src/app/(admin)/admin/merchants/[merchantId]/page.tsx new file mode 100644 index 0000000..19fe2c2 --- /dev/null +++ b/src/app/(admin)/admin/merchants/[merchantId]/page.tsx @@ -0,0 +1,15 @@ +import type {Metadata} from 'next'; +import {CompanyDetail} from '@/features/admin/components/CompanyDetail'; + +export const metadata: Metadata = { + title: 'Merchant', +}; + +export default async function MerchantPage({ + params, +}: { + params: Promise<{merchantId: string}>; +}) { + const {merchantId} = await params; + return ; +} diff --git a/src/app/(admin)/admin/merchants/[merchantId]/shops/[shopId]/cameras/[cameraId]/page.tsx b/src/app/(admin)/admin/merchants/[merchantId]/shops/[shopId]/cameras/[cameraId]/page.tsx new file mode 100644 index 0000000..fbb08ee --- /dev/null +++ b/src/app/(admin)/admin/merchants/[merchantId]/shops/[shopId]/cameras/[cameraId]/page.tsx @@ -0,0 +1,17 @@ +import type {Metadata} from 'next'; +import {CameraDetail} from '@/features/admin/components/CameraDetail'; + +export const metadata: Metadata = { + title: 'Camera', +}; + +export default async function CameraPage({ + params, +}: { + params: Promise<{merchantId: string; shopId: string; cameraId: string}>; +}) { + const {merchantId, shopId, cameraId} = await params; + return ( + + ); +} diff --git a/src/app/(admin)/admin/merchants/[merchantId]/shops/[shopId]/page.tsx b/src/app/(admin)/admin/merchants/[merchantId]/shops/[shopId]/page.tsx new file mode 100644 index 0000000..3af6777 --- /dev/null +++ b/src/app/(admin)/admin/merchants/[merchantId]/shops/[shopId]/page.tsx @@ -0,0 +1,15 @@ +import type {Metadata} from 'next'; +import {StoreDetail} from '@/features/admin/components/StoreDetail'; + +export const metadata: Metadata = { + title: 'Shop', +}; + +export default async function ShopPage({ + params, +}: { + params: Promise<{merchantId: string; shopId: string}>; +}) { + const {merchantId, shopId} = await params; + return ; +} diff --git a/src/app/(admin)/admin/merchants/page.tsx b/src/app/(admin)/admin/merchants/page.tsx new file mode 100644 index 0000000..8c19a63 --- /dev/null +++ b/src/app/(admin)/admin/merchants/page.tsx @@ -0,0 +1,25 @@ +import type {Metadata} from 'next'; +import {VStack} from '@astryxdesign/core/Layout'; +import {MerchantsPanel} from '@/features/admin/components/MerchantsPanel'; +import {AdminPageHeader} from '@/features/admin/components/common/AdminPageHeader'; + +export const metadata: Metadata = { + title: 'Merchants', +}; + +/** + * Every merchant on the platform as a card: search, filter, sort, create, + * suspend, reinstate, reset the owner's password, delete — and the way into + * each one. + */ +export default function MerchantsPage() { + return ( + + + + + ); +} diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 97b1d22..7f9679f 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -1,35 +1,19 @@ import type {Metadata} from 'next'; -import {VStack} from '@astryxdesign/core/Layout'; -import {Text} from '@astryxdesign/core/Text'; -import {CompaniesPanel} from '@/features/admin/components/CompaniesPanel'; +import {PlatformOverview} from '@/features/admin/components/PlatformOverview'; export const metadata: Metadata = { - title: 'Platform admin · Loyaly', + title: 'Overview', }; /** - * The platform console. + * The platform console's landing page — where a platform admin arrives after + * sign-in (the proxy sends them to /admin). * - * Company administration and nothing else — that is the whole of the platform's - * admin surface upstream (`/api/admin/*`), and this page deliberately does not - * grow past it. There is no endpoint to browse a tenant's visitors, cameras, - * reports or shops, and no impersonation: seeing a company's data means signing - * in as that company's owner, which is a different decision with a different - * audit trail. + * Merchant CRUD lives at /admin/merchants; this page is the overview above it. + * What the console can and cannot read today is recorded in + * features/admin/config/capabilities.ts, and the endpoints still needed in + * docs/ADMIN-MONITORING-API.md. */ -export default function AdminPage() { - return ( - - - - Platform admin - - - Create, suspend and remove the merchant companies on this platform. - - - - - - ); +export default function AdminOverviewPage() { + return ; } diff --git a/src/app/(admin)/admin/profile/page.tsx b/src/app/(admin)/admin/profile/page.tsx new file mode 100644 index 0000000..6f3d395 --- /dev/null +++ b/src/app/(admin)/admin/profile/page.tsx @@ -0,0 +1,10 @@ +import type {Metadata} from 'next'; +import {AdminProfile} from '@/features/admin/components/AdminAccount'; + +export const metadata: Metadata = { + title: 'Profile', +}; + +export default function AdminProfilePage() { + return ; +} diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx new file mode 100644 index 0000000..463bfd2 --- /dev/null +++ b/src/app/(admin)/admin/settings/page.tsx @@ -0,0 +1,10 @@ +import type {Metadata} from 'next'; +import {AdminSettings} from '@/features/admin/components/AdminAccount'; + +export const metadata: Metadata = { + title: 'Settings', +}; + +export default function AdminSettingsPage() { + return ; +} diff --git a/src/app/(public)/join/page.tsx b/src/app/(public)/join/page.tsx new file mode 100644 index 0000000..5547f81 --- /dev/null +++ b/src/app/(public)/join/page.tsx @@ -0,0 +1,23 @@ +import type {Metadata} from 'next'; +import {LoginSplit} from '@/features/auth/components/LoginSplit'; +import {JoinForm} from '@/features/auth/components/JoinForm'; + +export const metadata: Metadata = { + title: 'Join your team', +}; + +/** + * Where an invitation code is redeemed. Public — see PUBLIC_PATHS in proxy.ts — + * because the person here has no account yet; that is the point of the page. + * Sits in the (public) group with /login and shares its frame. + */ +export default function JoinPage() { + return ( + + + + ); +} diff --git a/src/app/(workspace)/activity/page.tsx b/src/app/(workspace)/activity/page.tsx index 27e016c..558689f 100644 --- a/src/app/(workspace)/activity/page.tsx +++ b/src/app/(workspace)/activity/page.tsx @@ -5,6 +5,7 @@ import {PageHeader} from '@/shared/components/primitives/PageHeader'; import {ScopeControls} from '@/shared/components/scope/ScopeControls'; import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed'; import {useRecentVisits} from '@/features/dashboard/hooks/useReports'; +import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream'; import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; /** @@ -14,14 +15,23 @@ import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; * size — one feed implementation, two budgets. The cursor the platform returns * is the supported way to page further; the dashboard never needs it, so it is * wired here first when infinite scroll lands. + * + * New arrivals are pushed over GET /api/visits/stream and each one re-reads + * the feed; "Live" shows only while that stream is actually connected. */ export default function ActivityPage() { const visits = useRecentVisits(50); + const {isLive} = useArrivalStream( + visits.refetch, + // Stale rows from the previous store are not a position to resume from. + visits.isRefreshing ? undefined : visits.data?.cursor, + ); const scopeLabel = useScopeLabel(); return ( } diff --git a/src/app/(workspace)/customers/page.tsx b/src/app/(workspace)/customers/page.tsx new file mode 100644 index 0000000..23a7246 --- /dev/null +++ b/src/app/(workspace)/customers/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import {VStack} from '@astryxdesign/core/Layout'; +import {PageHeader} from '@/shared/components/primitives/PageHeader'; +import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory'; + +/** + * The customer directory, from GET /api/visitors. + * + * Company-wide, not scoped by the store switcher: a customer belongs to the + * business, and somebody who first walked into one branch is the same person + * at another. + */ +export default function CustomersPage() { + return ( + + + + + ); +} diff --git a/src/app/(workspace)/dashboard/page.tsx b/src/app/(workspace)/dashboard/page.tsx index 7edc1b1..bcf5416 100644 --- a/src/app/(workspace)/dashboard/page.tsx +++ b/src/app/(workspace)/dashboard/page.tsx @@ -9,7 +9,7 @@ import {AreaChartView} from '@/shared/components/charts/AreaChartView'; import {BarChartView} from '@/shared/components/charts/BarChartView'; import {KpiRow} from '@/features/dashboard/components/KpiRow'; import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed'; -import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable'; +import {EngagementSection} from '@/features/engagement/components/EngagementSection'; import {CHART} from '@/shared/components/charts/palette'; import { useConversionReport, @@ -17,6 +17,7 @@ import { useFootfallReport, useRecentVisits, } from '@/features/dashboard/hooks/useDashboard'; +import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream'; import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; import {greetingFor} from '@/features/dashboard/services/dashboardService'; import {formatCompact, formatInrCompact} from '@/shared/utils/format'; @@ -30,23 +31,27 @@ import {formatCompact, formatInrCompact} from '@/shared/utils/format'; * endpoint, and no fixture: if the platform returns nothing, this page shows * nothing rather than something plausible. * - * ── What was removed and why ───────────────────────────────────────────── - * The engagement layer that used to sit here — ten activity types, impact - * chains, campaign funnels, a customer journey, generated insights — was built - * against a loyalty domain the platform does not expose. It rendered numbers - * with no source. Rather than keep them behind a demo flag where a merchant - * could mistake them for real, the panels are replaced by a statement of what - * they need. The layout, spacing and hierarchy are otherwise untouched. + * ── Engagement ─────────────────────────────────────────────────────────── + * The engagement layer that used to sit here was generated locally and was + * removed. It is back, read from the platform's own endpoints — activities and + * their impact, campaigns, and the customer journey — so every figure has a + * source, and every attributed one says it is estimated. * * Bucket labels from the reports are rendered as STRINGS. They are local wall * time with no offset; parsing one into a Date re-interprets it in the * viewer's zone and shifts every label on the axis. */ export default function DashboardPage() { - const kpis = useDashboardKpis(); - const footfall = useFootfallReport({bucket: 'day'}); - const conversion = useConversionReport({bucket: 'day'}); + // One fetch per report, shared by the KPI row and the charts. + const footfall = useFootfallReport({bucket: 'day', compare: true}); + const conversion = useConversionReport({bucket: 'day', compare: true}); + const kpis = useDashboardKpis(footfall, conversion); const visits = useRecentVisits(6); + // Pushes new arrivals into the recent-arrivals panel as they happen. + useArrivalStream( + visits.refetch, + visits.isRefreshing ? undefined : visits.data?.cursor, + ); const scopeLabel = useScopeLabel(); return ( @@ -119,10 +124,7 @@ export default function DashboardPage() { - + ); } diff --git a/src/app/(workspace)/floor/page.tsx b/src/app/(workspace)/floor/page.tsx index d9d8a49..a8f2cf9 100644 --- a/src/app/(workspace)/floor/page.tsx +++ b/src/app/(workspace)/floor/page.tsx @@ -15,8 +15,12 @@ import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState'; import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; import {NameCustomerDialog} from '@/features/floor/components/NameCustomerDialog'; import {SaleEntryDialog} from '@/features/commerce/components/SaleEntryDialog'; -import {useFloor} from '@/features/floor/hooks/useFloor'; +import {useFloor, type FloorAction} from '@/features/floor/hooks/useFloor'; +import {useTodaySummary} from '@/features/floor/hooks/useTodaySummary'; +import {TodaySummaryCard} from '@/features/floor/components/TodaySummaryCard'; import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; +import {useSession} from '@/features/auth/providers/SessionProvider'; +import {isStaffRole} from '@/features/auth/services/staffAccess'; import type {FloorVisit} from '@/features/floor/types/floor'; /** @@ -35,7 +39,13 @@ function whenSeen(iso: string): string { } export default function FloorPage() { - const {resource, act, pending, conflict} = useFloor(); + const {resource, act, pending, conflict, failure} = useFloor(); + // Today's summary carries revenue — merchant-only (staffAccess.ts). + const isStaff = isStaffRole(useSession().user?.role); + const today = useTodaySummary(!isStaff); + // Taking, releasing or completing a customer changes "on the floor now". + const run = (visitId: string, action: FloorAction) => + void act(visitId, action).then(today.refetch); const scopeLabel = useScopeLabel(); const [naming, setNaming] = useState(null); const [selling, setSelling] = useState(null); @@ -49,9 +59,12 @@ export default function FloorPage() { controls={} /> + {isStaff ? null : } + {/* The platform's own refusal, shown verbatim — it names who holds the customer, which is the part staff need. */} {conflict ? : null} + {failure ? : null} void act(v.visitId, 'release')} + onClick={() => run(v.visitId, 'release')} label="Release" />