From eeef8851e4b7033e3211f19be9bbddff7daadc5b Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 28 Sep 2026 18:08:55 +0530 Subject: [PATCH] domain change --- Dockerfile | 18 +- src/App.tsx | 90 +-- src/auth/roles.ts | 12 +- src/auth/workspace.test.ts | 103 +-- src/auth/workspace.ts | 93 +-- .../{nearle-admin => }/import/ImportScope.tsx | 0 .../import/SheetImportPanel.tsx | 0 .../import/openingStock.test.ts | 0 .../{nearle-admin => }/import/openingStock.ts | 0 .../import/parseProductSheet.test.ts | 0 .../import/parseProductSheet.ts | 0 .../{nearle-admin => }/import/shelve.ts | 0 .../nearle-admin/NearleAdminShell.tsx | 63 -- .../nearle-admin/PartnerAssignDrawer.tsx | 204 ----- .../nearle-admin/PartnerRidersDrawer.tsx | 135 ---- src/features/nearle-admin/ShiftsPanel.tsx | 373 ---------- .../nearle-admin/pages/DispatchPage.tsx | 135 ---- .../pages/GlobalCataloguePage.tsx | 30 - .../nearle-admin/pages/OnboardTenantPage.tsx | 438 ----------- .../nearle-admin/pages/PartnersPage.tsx | 698 ------------------ .../nearle-admin/pages/StoreDetailPage.tsx | 340 --------- .../nearle-admin/pages/StoresPage.tsx | 454 ------------ .../nearle-admin/pages/UploadsPage.tsx | 39 - src/features/nearle-admin/pages/fleet.css | 293 -------- src/features/nearle-admin/riderShifts.test.ts | 251 ------- src/features/nearle-admin/riderShifts.ts | 324 -------- .../nearle-admin/tamilNaduDistricts.test.ts | 74 -- .../nearle-admin/tamilNaduDistricts.ts | 133 ---- .../store-admin/SheetUploadDrawer.tsx | 2 +- .../store-admin/TenantSheetImportPanel.tsx | 2 +- src/features/uploads/UploadsPanel.tsx | 4 +- src/queries/hooks.ts | 2 +- .../partnerList.test.ts | 0 .../nearle-admin => queries}/partnerList.ts | 0 34 files changed, 99 insertions(+), 4211 deletions(-) rename src/features/{nearle-admin => }/import/ImportScope.tsx (100%) rename src/features/{nearle-admin => }/import/SheetImportPanel.tsx (100%) rename src/features/{nearle-admin => }/import/openingStock.test.ts (100%) rename src/features/{nearle-admin => }/import/openingStock.ts (100%) rename src/features/{nearle-admin => }/import/parseProductSheet.test.ts (100%) rename src/features/{nearle-admin => }/import/parseProductSheet.ts (100%) rename src/features/{nearle-admin => }/import/shelve.ts (100%) delete mode 100644 src/features/nearle-admin/NearleAdminShell.tsx delete mode 100644 src/features/nearle-admin/PartnerAssignDrawer.tsx delete mode 100644 src/features/nearle-admin/PartnerRidersDrawer.tsx delete mode 100644 src/features/nearle-admin/ShiftsPanel.tsx delete mode 100644 src/features/nearle-admin/pages/DispatchPage.tsx delete mode 100644 src/features/nearle-admin/pages/GlobalCataloguePage.tsx delete mode 100644 src/features/nearle-admin/pages/OnboardTenantPage.tsx delete mode 100644 src/features/nearle-admin/pages/PartnersPage.tsx delete mode 100644 src/features/nearle-admin/pages/StoreDetailPage.tsx delete mode 100644 src/features/nearle-admin/pages/StoresPage.tsx delete mode 100644 src/features/nearle-admin/pages/UploadsPage.tsx delete mode 100644 src/features/nearle-admin/pages/fleet.css delete mode 100644 src/features/nearle-admin/riderShifts.test.ts delete mode 100644 src/features/nearle-admin/riderShifts.ts delete mode 100644 src/features/nearle-admin/tamilNaduDistricts.test.ts delete mode 100644 src/features/nearle-admin/tamilNaduDistricts.ts rename src/{features/nearle-admin => queries}/partnerList.test.ts (100%) rename src/{features/nearle-admin => queries}/partnerList.ts (100%) diff --git a/Dockerfile b/Dockerfile index 486fab3..fd61e9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,16 +68,18 @@ ENV VITE_API_BASE=$VITE_API_BASE # it did. The platform build is the one that has to be asked for — the opposite # default would turn every environment that had not been told about this into a # platform console on the day it shipped. -ARG VITE_WORKSPACE="merchant" -ENV VITE_WORKSPACE=$VITE_WORKSPACE - -# The other console's address, for the sentence shown to somebody in the wrong -# place. Only the one this build is NOT is used; both are given so a single set -# of build args works for either image. +# There is no workspace flag in this image. +# +# One existed while a single codebase served both consoles and had to be told +# which it was. Nearle's own staff now have their own application — +# `nearle-platform`, its own repository and its own deploy — and this image is +# the merchant console and nothing else. +# +# The platform console's address, for the sentence shown to a staff member who +# signs in at the wrong site. A build argument rather than a constant because +# the two are separate deployments and either can move. ARG VITE_PLATFORM_HOST="platform.nearledaily.com" ENV VITE_PLATFORM_HOST=$VITE_PLATFORM_HOST -ARG VITE_MERCHANT_HOST="app.nearledaily.com" -ENV VITE_MERCHANT_HOST=$VITE_MERCHANT_HOST RUN npm run build diff --git a/src/App.tsx b/src/App.tsx index e80c1e2..60d3f50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,10 +3,8 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { Spinner } from '@astryxdesign/core/Spinner'; import { RequireRole, useAuth } from '@/auth/AuthContext'; import { HOME_ROUTE } from '@/auth/roles'; -import { IS_PLATFORM } from '@/auth/workspace'; import { withStaleChunkRecovery } from '@/lib/staleChunk'; import { LoginPage } from '@/features/auth/LoginPage'; -import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell'; import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell'; import { StoreUserShell } from '@/features/store-user/StoreUserShell'; @@ -29,15 +27,8 @@ import { StoreUserShell } from '@/features/store-user/StoreUserShell'; const named = (key: T, loader: () => Promise>) => lazy(withStaleChunkRecovery(() => loader().then((module) => ({ default: module[key] })))); -const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pages/StoresPage')); -const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage')); -const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage')); -const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage')); -const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage')); -const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage')); /* Lazy like the rest, and it matters more here: this page pulls in leaflet and its stylesheet, which nobody who never opens the fleet map should download. */ -const NearleDispatchPage = named('NearleDispatchPage', () => import('@/features/nearle-admin/pages/DispatchPage')); /* One Console for both workspaces — it reads its own scope from BranchScope, which pins a store user to their outlet and lets an admin choose. Both routes @@ -89,71 +80,20 @@ export function App() { } /> - {/* - Each site mounts ONE workspace. + {/* Nearle staff have their own application. - `platform.nearledaily.com` is Nearle's own staff; `app.nearledaily.com` - is merchants and their branch users. The same build produces both, with - `VITE_WORKSPACE` deciding which of the two blocks below exists. + The platform workspace lived here until the two consoles were split. + It is now `nearle-platform`, a separate repository deployed on its own + domain, and this one serves merchants and their branch users only. - Unmounted, not guarded. A route that is not in the table cannot render - whatever state the app is in, where a `RequireRole` around it is one - redirect away from rendering if the role check is ever wrong. On this - site `/admin/*` is not a protected path — it is not a path at all, and - falls to the catch-all like any typo. - - This does NOT remove the other workspace's code from the bundle, and it - was written here once claiming that it did. The branch is evaluated at - runtime: Rollup cannot fold `IS_PLATFORM` because it is computed through - `workspace.ts` rather than being a literal in this file, so both blocks - are compiled and only one is executed. The lazy chunks of the unmounted - workspace are emitted and served, and never fetched, because nothing - routes to them. - - That is a code-shipping question rather than an access one — no account - the other console owns can sign in here, which `login` and `restore` - enforce — but the distinction is worth stating rather than implying. - */} - - {/* Nearle Admin — the platform workspace */} - {IS_PLATFORM ? ( - - }> - - - - } - > - } /> - } /> - } /> - } /> - {/* Branch onboarding moved to the Store Admin workspace — the merchant - opens their own outlets. Kept as a redirect rather than deleted so a - bookmark or a stale link lands on the directory instead of a 404. */} - } /> - } /> - {/* Delivery partners — the companies that supply riders. Platform-side - only: a merchant is assigned one, never allowed to create one. */} - } /> - } /> - {/* The platform's rider tracking was a "Fleet" page showing presence - only. It is a tab on Dispatch now; the old link still works. */} - } /> - } /> - {/* Absorbed here rather than by the global `*`, so a wrong sub-path can - never bounce out to a HOME_ROUTE that points back into this - workspace and loop. */} - } /> - - ) : null} + `/nearle/*` is therefore not a protected path here — it is not a path + at all, and falls to the catch-all like any other unknown URL. A + `nearle-admin` account cannot sign in either: `login` and `restore` + refuse it before a session is written. */} {/* Store Admin — the merchant workspace, scoped to one tenant's branches */} - {!IS_PLATFORM ? ( - <> + + } /> } /> - - ) : null} - {/* `user` is only ever a role this build serves — `restore` and `login` - both refuse the other console's accounts — so its home route is always - one of the blocks mounted above, and this cannot bounce into a - workspace that is not here. */} + {/* `user` is only ever a merchant role — `login` and `restore` refuse a + Nearle staff account before a session is written — so its home route + is always one of the two workspaces above, and this cannot bounce + somewhere that does not exist. */} } diff --git a/src/auth/roles.ts b/src/auth/roles.ts index f527e6b..21ff080 100644 --- a/src/auth/roles.ts +++ b/src/auth/roles.ts @@ -103,7 +103,17 @@ export function toSessionUser(user: FiestaUser): SessionUser { * sub-path is absorbed there and never reaches the global one. */ export const HOME_ROUTE: Record = { - 'nearle-admin': '/nearle/stores', + // `/nearle/stores` is not a route in this application any more — Nearle's own + // staff have their own, `nearle-platform`. Pointing at it would be exactly + // the failure the comment above describes: the router sends an unknown path + // to `*`, `*` sends it back here, and React Router resolves the loop by + // rendering nothing — a blank page with no console error. + // + // It cannot be reached today, because `login` and `restore` both refuse this + // role before a session exists. It is `/login` rather than that unreachable + // path so that if one of those two checks is ever weakened, the result is a + // sign-in screen rather than a white screen nobody can diagnose. + 'nearle-admin': '/login', 'store-admin': '/admin/console', 'store-manager': '/store/console', }; diff --git a/src/auth/workspace.test.ts b/src/auth/workspace.test.ts index d4ab17d..cb8d278 100644 --- a/src/auth/workspace.test.ts +++ b/src/auth/workspace.test.ts @@ -1,108 +1,57 @@ import { strict as assert } from 'node:assert'; import { test } from 'node:test'; -import { IS_PLATFORM, isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace'; +import { isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace'; import type { ConsoleRole } from './roles'; /* -Two consoles, one source. +Who may sign in to the merchant console. -Nearle's own staff sign in at platform.nearledaily.com; merchants and their -branch users at app.nearledaily.com. Neither site accepts the other's accounts, -and the refusal is a refusal — not a redirect carrying a half-made session -across a domain boundary. +Merchants and their branch users sign in here. Nearle's own staff have their own +application — `nearle-platform`, a separate repository with its own deploy — and +a staff account is turned away rather than redirected: no session crosses to +another site half-made. -These run against the DEFAULT build, which is `merchant`. That default is itself -the thing most worth pinning: an unset flag is the ordinary state of a developer -machine and of any deployment not yet told about the split, and the wrong -default would turn every one of them into a platform console. +The role is not known until the password has been checked, so the refusal +happens after credentials are verified. What matters is that it happens BEFORE +the session is written: `login` refuses first and persists second, and `restore` +applies the same check to what is already in storage. A staff member who typed a +correct password must not end up signed in here with no routes to reach. */ const ROLES: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager']; -test('an unset flag builds the merchant console', () => { - // Nothing sets VITE_WORKSPACE under the test runner, so this is the default - // path — the same one every existing deployment takes until it is told - // otherwise. +test('this build is the merchant console, with nothing to configure', () => { + // A constant, not a build flag. The flag existed only while one codebase + // served both consoles; a variable now would be a way to deploy this + // application as something it is not. assert.equal(WORKSPACE, 'merchant'); - assert.equal(IS_PLATFORM, false); }); -test('the merchant console admits merchants and refuses Nearle staff', () => { +test('both merchant roles are admitted and Nearle staff are not', () => { assert.equal(isAllowedHere('store-admin'), true); assert.equal(isAllowedHere('store-manager'), true); assert.equal(isAllowedHere('nearle-admin'), false); }); test('every role is decided, none left to a default', () => { - // A role added later must be listed deliberately on one side or the other. - // Falling through to "allowed" would put it on both consoles silently; this - // asserts each of the three is a decision that was actually made. - for (const role of ROLES) { - assert.equal(typeof isAllowedHere(role), 'boolean', `${role} has no verdict`); - } - const allowed = ROLES.filter(isAllowedHere); - assert.equal(allowed.length, 2, `merchant admits ${allowed.join(', ')}`); + // A role added later must be listed deliberately. Falling through to allowed + // would quietly admit it to the merchant console. + const admitted = ROLES.filter(isAllowedHere); + assert.deepEqual(admitted, ['store-admin', 'store-manager']); }); -test('the refusal names the other console rather than blaming the account', () => { +test('the refusal names the platform console rather than blaming the account', () => { const message = wrongConsoleMessage('nearle-admin'); - // The host, so somebody knows where to go. assert.match(message, /platform\.nearledaily\.com/); - // And not a word that reads as "your account is broken" — the password was - // right and the account is fine. Anyone told "failed" or "denied" goes and - // resets a working password, or asks an administrator to fix nothing. + // The password was right and the account is fine. Anyone told "failed" or + // "denied" goes and resets a working password, or asks an administrator to + // fix nothing at all. for (const blame of ['failed', 'invalid', 'denied', 'not recognised', 'wrong password']) { - assert.ok( - !message.toLowerCase().includes(blame), - `the refusal reads as a fault: ${message}`, - ); + assert.ok(!message.toLowerCase().includes(blame), `the refusal reads as a fault: ${message}`); } }); -test('each role is named in words a person uses', () => { - // The message is read by whoever typed the password, not by us. +test('the refused role is named in words a person uses', () => { assert.match(wrongConsoleMessage('nearle-admin'), /Nearle staff/); - assert.match(wrongConsoleMessage('store-admin'), /Store admin/); - assert.match(wrongConsoleMessage('store-manager'), /Store user/); -}); - -/* -What the split actually guarantees, and what it does not. - -The route blocks in `App.tsx` are chosen at runtime, so both workspaces' chunks -are compiled into either image. The unmounted one is never fetched, because -nothing routes to it — but it is present, and an earlier version of the comment -in that file claimed otherwise. - -So the guarantee is NOT "the other console's code is absent". It is "the other -console's accounts cannot sign in, and its paths are not routes here". Both of -those are enforced by the two functions below, which is why they are the ones -worth pinning rather than the bundle's contents. -*/ - -test('the refusal does not depend on which routes happen to be mounted', () => { - // `isAllowedHere` is consulted by `login` before a session is written and by - // `restore` before one is read back. Neither goes near the router, so a - // mistake in route mounting cannot open a door that this closes. - assert.equal(isAllowedHere('nearle-admin'), IS_PLATFORM); - assert.equal(isAllowedHere('store-admin'), !IS_PLATFORM); - assert.equal(isAllowedHere('store-manager'), !IS_PLATFORM); -}); - -test('no role is admitted by both consoles', () => { - // The two sets must partition the roles: one home each, never two. A role in - // both would make the separation cosmetic — the account would work at either - // address and the refusal would never fire. - const platformRoles: ConsoleRole[] = ['nearle-admin']; - const merchantRoles: ConsoleRole[] = ['store-admin', 'store-manager']; - - for (const role of platformRoles) { - assert.ok(!merchantRoles.includes(role), `${role} is claimed by both consoles`); - } - assert.equal( - platformRoles.length + merchantRoles.length, - ROLES.length, - 'a role belongs to neither console and could sign in nowhere', - ); }); diff --git a/src/auth/workspace.ts b/src/auth/workspace.ts index b9b9e6b..6c61d5e 100644 --- a/src/auth/workspace.ts +++ b/src/auth/workspace.ts @@ -1,90 +1,63 @@ import type { ConsoleRole } from './roles'; /** - * Which console this build is. + * Who may sign in to the merchant console. * - * ── Why one codebase produces two sites ───────────────────────────────────── + * ── Why this is a constant ────────────────────────────────────────────────── * - * Nearle's own staff work at `platform.nearledaily.com`; merchants and their - * branch users work at `app.nearledaily.com`. They are the same application - * built twice with this flag set differently, rather than two repositories, - * because every screen below the workspace split — drawers, tables, the - * assistant, the design system — is shared and would otherwise be maintained - * in two places and drift. + * There was a `VITE_WORKSPACE` build flag here while one codebase served both + * consoles and had to be told which it was. That is over: Nearle's own staff + * have their own application, `nearle-platform`, and this repository is the + * merchant console and nothing else. A flag would only be a way to deploy this + * application as something it is not. * - * What the flag changes is which routes are mounted and which roles may sign - * in. It does not change what is compiled: the branch in `App.tsx` is evaluated - * at runtime, so both workspaces' chunks are built and served, and the - * unmounted one is simply never fetched because nothing routes to it. Removing - * it from the bundle would need the flag to be a literal at each import site, - * which is a separate piece of work and buys nothing for access control. + * ── The separation is a refusal, not a redirect ───────────────────────────── * - * ── The default is `merchant`, deliberately ───────────────────────────────── + * A Nearle staff account is turned away here with a sentence naming where it + * belongs. It is not bounced to the other site carrying a half-made session. * - * An unset variable is the ordinary state of a developer's machine and of any - * deployment that has not been told about this yet. Defaulting to `merchant` - * means the existing site keeps behaving exactly as it did, and the platform - * build is the one that has to be asked for. The opposite default would turn - * every un-migrated environment into a platform console the day this shipped. + * The role is not known until the password has been checked — `applogin` + * returns it — so the refusal can only happen after credentials are verified. + * `login` applies it BEFORE the session is written and `restore` applies it to + * what is already stored. That order is the point: a session persisted first + * and refused afterwards leaves somebody signed in by every measure the shell + * uses, with a nav built from a role this application serves no routes for. */ -export type Workspace = 'platform' | 'merchant'; +export const WORKSPACE = 'merchant' as const; -const CONFIGURED = (import.meta.env?.['VITE_WORKSPACE'] ?? '').trim().toLowerCase(); - -export const WORKSPACE: Workspace = CONFIGURED === 'platform' ? 'platform' : 'merchant'; - -export const IS_PLATFORM = WORKSPACE === 'platform'; - -/** - * Who may sign in here. - * - * The separation is a REFUSAL, not a redirect. A merchant reaching the platform - * console is told which console their account belongs to and stays where they - * are; they are not bounced across a domain boundary carrying a half-made - * session. Each site serves exactly one audience and says so. - */ -const ALLOWED: Record> = { - platform: new Set(['nearle-admin']), - merchant: new Set(['store-admin', 'store-manager']), -}; +const ALLOWED: ReadonlySet = new Set(['store-admin', 'store-manager']); export function isAllowedHere(role: ConsoleRole): boolean { - return ALLOWED[WORKSPACE].has(role); + return ALLOWED.has(role); } /** - * The other console's address, for the sentence shown to somebody in the wrong - * place. + * The platform console's address, for the sentence shown to a staff member who + * signs in at the wrong site. * - * Named rather than derived from `location.hostname`, because the two sites are - * not a naming convention apart — they are separate deployments and either can - * move. A build that was not told falls back to the production hostnames, which - * is right far more often than saying nothing. + * A build variable rather than a constant, because the two are separate + * deployments and either can move. The fallback is the production hostname, + * which is right far more often than saying nothing would be. */ -const OTHER_SITE: Record = { - platform: (import.meta.env?.['VITE_MERCHANT_HOST'] ?? '').trim() || 'app.nearledaily.com', - merchant: (import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com', -}; +const PLATFORM_HOST = + (import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com'; /** - * What to tell somebody whose account belongs to the other console. - * - * Names the host rather than linking to it. A live link from a sign-in screen - * to another sign-in screen reads as a redirect that failed, and this is not a - * failure — it is the right answer to the wrong door. + * Names the host rather than linking to it. A live link from one sign-in screen + * to another reads as a redirect that failed, and this is not a failure — it is + * the right answer to the wrong door. */ export function wrongConsoleMessage(role: ConsoleRole): string { - const site = OTHER_SITE[WORKSPACE]; - - return IS_PLATFORM - ? `This is the Nearle platform console. ${roleWord(role)} accounts sign in at ${site}.` - : `${roleWord(role)} accounts sign in at ${site}, not here.`; + return `${roleWord(role)} accounts sign in at ${PLATFORM_HOST}, not here.`; } function roleWord(role: ConsoleRole): string { switch (role) { case 'nearle-admin': return 'Nearle staff'; + // Unreachable: both merchant roles are allowed here, so neither reaches the + // refusal. Present because the switch is exhaustive over the union and a + // missing arm would be a type error the day a role is added. case 'store-admin': return 'Store admin'; case 'store-manager': diff --git a/src/features/nearle-admin/import/ImportScope.tsx b/src/features/import/ImportScope.tsx similarity index 100% rename from src/features/nearle-admin/import/ImportScope.tsx rename to src/features/import/ImportScope.tsx diff --git a/src/features/nearle-admin/import/SheetImportPanel.tsx b/src/features/import/SheetImportPanel.tsx similarity index 100% rename from src/features/nearle-admin/import/SheetImportPanel.tsx rename to src/features/import/SheetImportPanel.tsx diff --git a/src/features/nearle-admin/import/openingStock.test.ts b/src/features/import/openingStock.test.ts similarity index 100% rename from src/features/nearle-admin/import/openingStock.test.ts rename to src/features/import/openingStock.test.ts diff --git a/src/features/nearle-admin/import/openingStock.ts b/src/features/import/openingStock.ts similarity index 100% rename from src/features/nearle-admin/import/openingStock.ts rename to src/features/import/openingStock.ts diff --git a/src/features/nearle-admin/import/parseProductSheet.test.ts b/src/features/import/parseProductSheet.test.ts similarity index 100% rename from src/features/nearle-admin/import/parseProductSheet.test.ts rename to src/features/import/parseProductSheet.test.ts diff --git a/src/features/nearle-admin/import/parseProductSheet.ts b/src/features/import/parseProductSheet.ts similarity index 100% rename from src/features/nearle-admin/import/parseProductSheet.ts rename to src/features/import/parseProductSheet.ts diff --git a/src/features/nearle-admin/import/shelve.ts b/src/features/import/shelve.ts similarity index 100% rename from src/features/nearle-admin/import/shelve.ts rename to src/features/import/shelve.ts diff --git a/src/features/nearle-admin/NearleAdminShell.tsx b/src/features/nearle-admin/NearleAdminShell.tsx deleted file mode 100644 index 6f02225..0000000 --- a/src/features/nearle-admin/NearleAdminShell.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { FileSpreadsheet, Bike } from 'lucide-react'; -import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell'; -import { DateScopeProvider } from '@/components/shell/DateScope'; - -/** - * Nearle Admin — the platform workspace. - * - * No "Onboard branch" here on purpose. Opening an outlet is the merchant's - * decision about their own business, so it sits in the Store Admin workspace. - * We onboard the tenant; they onboard their branches. - */ -const NAV: readonly NavEntry[] = [ - { to: '/nearle/stores', label: 'Stores' }, - { to: '/nearle/onboard/tenant', label: 'Onboard tenant' }, - { to: '/nearle/catalogue', label: 'Global catalogue' }, - { to: '/nearle/dispatch', label: 'Dispatch' }, -]; - -/** - * Setup destinations, in the account menu rather than the nav. - * - * Both of these used to hold a nav slot beside Stores and Dispatch, and neither - * earns one: a partner is signed up once and then edited when a contract - * changes, and an upload is checked in the days after it was sent. The nav's - * four remaining slots are the places the platform actually works from. - * - * Onboarding a partner and watching their riders are also not the same job. - * Dispatch answers "what is happening on the road right now", every day; Rider - * partners answers "who are we contracted with", a handful of times a year. Two - * adjacent nav entries made them look like two halves of one screen — and now - * that Dispatch reads a partner's whole book of work, the one that matters - * daily is the one that stayed. - * - * This mirrors the Store Admin's own menu, where Uploads sits for exactly the - * same reason. See `AppShellProps.manageItems`. - */ -const MANAGE: readonly MenuEntry[] = [ - { - to: '/nearle/partners', - label: 'Rider partners', - icon: , - note: 'Who we are contracted with', - }, - { - to: '/nearle/uploads', - label: 'Uploads', - icon: , - note: 'Spreadsheets sent to the catalogue', - }, -]; - -export function NearleAdminShell() { - return ( - - - - ); -} diff --git a/src/features/nearle-admin/PartnerAssignDrawer.tsx b/src/features/nearle-admin/PartnerAssignDrawer.tsx deleted file mode 100644 index 6d66526..0000000 --- a/src/features/nearle-admin/PartnerAssignDrawer.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { VStack } from '@astryxdesign/core/VStack'; -import { Bike, Check, Truck } from 'lucide-react'; -import { errorMessage } from '@/api/client'; -import { tenantsApi } from '@/api/tenants'; -import { useAllPartners, useAppRegions } from '@/queries/hooks'; -import { queryKeys } from '@/queries/keys'; -import { Drawer } from '@/features/store-admin/Drawer'; -import { Alert, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit'; - -/** - * Which delivery partner supplies a merchant's riders. - * - * ── Why this is the platform's decision ───────────────────────────────────── - * - * `partnerid` is kept out of the merchant-editable allowlist on the server, so - * this cannot be done from the shop's own profile — a merchant who could set it - * would move themselves under another partner's riders and billing. It is - * changed here, by whoever is looking at that merchant's record. - * - * ── Why "own riders" is an option and not an absence ──────────────────────── - * - * Sending `partnerid: 0` is a real instruction: it means the shop delivers with - * riders it hired itself. The server reads a zero as sent rather than as a - * missing field, which is exactly why the endpoint is separate — everywhere - * else in the tenant API a zero means "not supplied", and there it would - * silently unassign somebody. - * - * One partner per merchant, which is what `tenants.partnerid` allows and what - * the assign picker later branches on. A partner serving several merchants is - * the ordinary case in the other direction: 44 supplies 48 shops. - */ -export function PartnerAssignDrawer({ - tenantid, - tenantname, - currentPartnerId, - onClose, -}: { - tenantid: number; - tenantname: string; - currentPartnerId: number; - onClose: () => void; -}) { - const client = useQueryClient(); - const partners = useAllPartners(); - const regions = useAppRegions(); - const [chosen, setChosen] = useState(currentPartnerId); - const [error, setError] = useState(null); - - const regionName = useMemo(() => { - const map = new Map(); - for (const region of regions.data ?? []) { - map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`); - } - return map; - }, [regions.data]); - - const save = useMutation({ - mutationFn: () => tenantsApi.assignPartner(tenantid, chosen), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: queryKeys.tenants.all }); - onClose(); - }, - onError: (cause) => setError(errorMessage(cause)), - }); - - const current = partners.data.find((p) => p.partnerid === currentPartnerId); - - return ( - - - } - isDisabled={save.isPending || chosen === currentPartnerId} - onClick={() => save.mutate()} - /> - - } - > - - {error ? {error} : null} - - }> - A partner supplies riders to this shop. With one set, the assign screen offers the - partner’s riders alongside any the shop hired itself; without one, only its own. - - -
- - 0 ? `Partner ${currentPartnerId}` : 'Own riders only')} - /> - -
- -
- - {/* "Own riders" first and always present. It is not the empty state - — a shop that hires its own riders is a real arrangement, and - the option has to be as reachable as any partner. */} - setChosen(0)} - /> - {partners.isLoading ? ( - - ) : ( - partners.data.map((partner) => ( - setChosen(partner.partnerid)} - /> - )) - )} - -
-
-
- ); -} - -function Choice({ - label, - detail, - isChosen, - onChoose, -}: { - label: string; - detail: string; - isChosen: boolean; - onChoose: () => void; -}) { - return ( - - ); -} diff --git a/src/features/nearle-admin/PartnerRidersDrawer.tsx b/src/features/nearle-admin/PartnerRidersDrawer.tsx deleted file mode 100644 index 2f51b39..0000000 --- a/src/features/nearle-admin/PartnerRidersDrawer.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useState } from 'react'; -import { Bike, Plus } from 'lucide-react'; -import type { Partner, RiderRosterRow } from '@/api/deliveries'; -import { usePartnerRiders } from '@/queries/hooks'; -import { Drawer } from '@/features/store-admin/Drawer'; -import { - Badge, - DrawerButton, - DrawerCard, - LinkAction, - Note, - Row, - Section, -} from '@/features/store-admin/drawerKit'; -import { RiderDrawer } from '@/features/store-admin/RiderDrawer'; - -/** - * A delivery partner's riders, kept by the platform. - * - * ── Why the platform keeps them ───────────────────────────────────────────── - * - * A partner has no console of its own. Their riders serve whichever merchants - * the partner supplies — one partner covers 48 shops today, another 63 — so - * they sit under no single merchant and no merchant's console can manage them. - * That leaves here. - * - * ── Duty is a state, never a filter ───────────────────────────────────────── - * - * This reads the ROSTER, not `getriders`. The second wants a clock-in stamped - * today, so a rider hired five minutes ago is absent from it — which is exactly - * what a failed save looks like. Everybody is listed, and whether they are - * working right now is shown beside them. - */ -export function PartnerRidersDrawer({ - partner, - onClose, -}: { - partner: Partner; - onClose: () => void; -}) { - const riders = usePartnerRiders(partner.partnerid); - const [editing, setEditing] = useState(null); - - const rows = riders.data ?? []; - const onDuty = rows.filter((rider) => rider.isonduty).length; - - return ( - <> - - - } - onClick={() => setEditing('new')} - /> - - } - > - {/* No `VStack` around this. The drawer body IS a column with the - system's own gap; a second stack inside it set a second, different - one, which is why this drawer's sections sat closer together than - every other drawer's. */} - <> - }> - These riders deliver for every merchant this partner supplies. A rider hired here does - not appear in the on-duty fleet until they open the rider app and start a shift — that - is correct, and it looks exactly like a failed save. - - - {riders.isLoading ? ( - Reading riders… - ) : rows.length === 0 ? ( -
- - - -
- ) : ( -
- - {rows.map((rider) => ( - - - setEditing(rider)} /> - - } - /> - ))} - -
- )} - -
- - {editing ? ( - setEditing(null)} - /> - ) : null} - - ); -} diff --git a/src/features/nearle-admin/ShiftsPanel.tsx b/src/features/nearle-admin/ShiftsPanel.tsx deleted file mode 100644 index f9eae5c..0000000 --- a/src/features/nearle-admin/ShiftsPanel.tsx +++ /dev/null @@ -1,373 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Card } from '@astryxdesign/core/Card'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; -import { AlertTriangle, Bike, CheckCircle2, Clock, Info, MapPin, Timer } from 'lucide-react'; -import { KpiCard } from '@/components/KpiCard'; -import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap'; -import { usePartnerRiderLogs } from '@/queries/hooks'; -import { duration, fleetDay, recommend, type RiderDay, type Severity } from './riderShifts'; -import './pages/fleet.css'; - -/** - * Who was online, and for how long. - * - * ── Why this is a platform page and not a shop's ──────────────────────────── - * - * `getriderlogs` filters on a partner or on a region — never on a tenant. A - * partner's riders serve every merchant that partner supplies (one covers 48 - * shops, another 63), so there is no shop this could belong to, and scoping it - * by region would hand one merchant every rider in the city. - * - * ── Why there is no distance, speed or utilisation on this page ───────────── - * - * Because the data cannot support any of them. `riderlogs` carries a position - * on every row and it is the SAME position on every row for a given rider — - * 2,404 pings from one rider on 14 August 2026, all reading 11.052998, - * 76.929958, and the same on every day and region checked. The app stamps a - * location once and repeats it on each heartbeat. A "moved 62% of their shift" - * figure drawn from that would be invented, and it would be believed. - * - * The positions that do move are written on the delivery rows when a rider - * moves a job along, and they are tenant-scoped — so the shop's own dispatch - * board draws the map, and this page does not pretend to. - * - * ── What is real, and worth the page ──────────────────────────────────────── - * - * The timestamps. A heartbeat means the app was open and the rider was - * reachable, so the pings say who was on and for how long. The columns built to - * answer exactly that — `login`, `logout`, `workhours`, `shorthours` — are - * empty or zero on all 320,132 rows read for August, so this is the only way to - * know, and it is inferred rather than recorded. The page says so out loud. - */ -export function ShiftsPanel({ partnerid }: { partnerid: number }) { - const [day, setDay] = useState(() => yesterday()); - - const logs = usePartnerRiderLogs(partnerid || undefined, { fromdate: day, todate: day }); - - const fleet = useMemo(() => fleetDay(logs.data ?? []), [logs.data]); - const advice = useMemo(() => recommend(fleet), [fleet]); - const [focused, setFocused] = useState(null); - - /* One pin per rider, and each is the single location that rider's app has - ever reported — hollow, because a filled pin says "here now" and this is - not that. It is worth drawing anyway: it shows how the fleet is spread - across the city, which nothing else on the platform answers. */ - const pins: Pin[] = useMemo( - () => - fleet.riders.flatMap((rider, index) => - rider.place - ? [ - { - id: rider.userid, - lat: rider.place.lat, - lng: rider.place.lng, - label: rider.username, - lines: [ - `${duration(rider.onlineMs)} online · ${rider.coverage.pings} check-ins`, - 'The only location this rider’s app has reported', - ], - colour: trailColour(index), - isFaded: true, - }, - ] - : [], - ), - [fleet.riders], - ); - - return ( - - {/* No page header. This is a tab on the platform's Dispatch page now, - and the partner it describes is chosen there — a second picker here - would be two controls setting one thing. */} - - - Who was on, and for how long - - { - setDay(event.target.value); - setFocused(null); - }} - /> - - - - -
- } - /> - } - /> - 0 ? fleet.onlineMs / fleet.measured : null)} - note={`longest ${duration(fleet.longestMs)}`} - tone={toneOf(advice.severity)} - icon={} - /> - } - /> -
- -
- - - - Where the fleet is registered - - - - One pin per rider, and each is the only location that rider’s app has ever sent — it - does not change between check-ins, so this is where they are registered, not where - they are. Live positions are written when a rider moves an order along, and appear on - that shop’s own dispatch board. - - - - - - - - The day, rider by rider - - {logs.isLoading ? ( - - Reading check-ins… - - ) : fleet.riders.length === 0 ? ( - - Nobody checked in on {day}. - - ) : ( -
- {fleet.riders.map((rider, index) => ( - - setFocused((prev) => (prev === rider.userid ? null : rider.userid)) - } - /> - ))} - {fleet.window ? ( -
- {clock(fleet.window.from)} - {clock(fleet.window.to)} -
- ) : null} -
- )} - {focused !== null ? r.userid === focused)} /> : null} -
-
-
- - - - - - - - - What these numbers are, and are not - - - - Nothing here comes from a timesheet. The rider app sends a check-in every twenty - seconds or so and never closes a shift — workhours, logout and - the rest are empty on every row — so a shift is read as a run of check-ins with no - silence longer than forty minutes in it. That says who was reachable and when. It says - nothing about how hard anybody worked: the location on those rows never changes, so a - rider parked all day and one who crossed the city look identical from here. Riders who - checked in too rarely to time are listed with blanks rather than given a figure. - - - -
- ); -} - -/* ── The gantt ───────────────────────────────────────────────────────────── */ - -function GanttRow({ - rider, - colour, - window, - isFocused, - onClick, -}: { - rider: RiderDay; - colour: string; - window: { from: number; to: number } | null; - isFocused: boolean; - onClick: () => void; -}) { - const span = window ? Math.max(1, window.to - window.from) : 1; - const at = (time: number) => (window ? ((time - window.from) / span) * 100 : 0); - - return ( - - ); -} - -function ShiftDetail({ rider }: { rider: RiderDay | undefined }) { - if (!rider) return null; - return ( -
- {rider.username} - {rider.shifts.length === 0 ? ( - - {rider.coverage.pings} check-in{rider.coverage.pings === 1 ? '' : 's'}, too few to make a - shift out of. - - ) : ( -
    - {rider.shifts.map((shift) => ( -
  • - - {clock(shift.from)} – {clock(shift.to)} - - {duration(shift.to - shift.from)} - {/* A long silence inside a run means the shift is stitched across - it, so the total is an upper bound rather than a reading. */} - {shift.longestGapMs > 5 * 60_000 ? ( - - quiet for {duration(shift.longestGapMs)} inside this run - - ) : null} -
  • - ))} -
- )} -
- ); -} - -/* ── Chrome ──────────────────────────────────────────────────────────────── */ - -function Banner({ advice }: { advice: ReturnType }) { - const Icon = - advice.severity === 'good' ? CheckCircle2 : advice.severity === 'watch' ? Info : AlertTriangle; - return ( -
- -
- {advice.headline} - {advice.detail} -
-
- ); -} - -function Note({ children }: { children: React.ReactNode }) { - return ( -
- - {children} -
- ); -} - -function toneOf(severity: Severity): 'success' | 'warning' | 'error' { - return severity === 'good' ? 'success' : severity === 'watch' ? 'warning' : 'error'; -} - -/* ── Dates ───────────────────────────────────────────────────────────────── */ - -function iso(date: Date): string { - // Built from the local parts, not `toISOString()` — that converts to UTC and - // in IST hands back yesterday's date for anything before 05:30. - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; -} - -function today(): string { - return iso(new Date()); -} - -/** - * Yesterday, as the default. - * - * A day that is still running is half a day, and "average shift 4h" read at - * eleven in the morning describes a morning. Yesterday is a whole one. - */ -function yesterday(): string { - const date = new Date(); - date.setDate(date.getDate() - 1); - return iso(date); -} - -function clock(at: number): string { - return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} diff --git a/src/features/nearle-admin/pages/DispatchPage.tsx b/src/features/nearle-admin/pages/DispatchPage.tsx deleted file mode 100644 index 2b3f2e3..0000000 --- a/src/features/nearle-admin/pages/DispatchPage.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; -import { Bike, Clock } from 'lucide-react'; -import { Tab, TabBar } from '@/components/TabBar'; -import { PageHeader } from '@/components/PageHeader'; -import { useAllPartners } from '@/queries/hooks'; -import { DispatchBoard } from '@/features/store-admin/pages/DispatchPage'; -import { ShiftsPanel } from '../ShiftsPanel'; -import './fleet.css'; - -/** - * The platform's dispatch board — one delivery partner at a time. - * - * ── Why this replaced the Fleet page ──────────────────────────────────────── - * - * There were two pages answering halves of one question. "Fleet" read - * `getriderlogs` and showed presence — who clocked on, for how long — while the - * shops' "Dispatch" read `getdeliveries` and showed the work itself. So the - * platform could see that a partner's riders were online and had no way to see - * what they delivered. - * - * `getdeliveries` takes a `partnerid`. Verified live on 2026-09-11: partner 60 - * answered with 376 deliveries spanning 12 merchants and 12 riders. That is the - * platform's real view of dispatch and it was one query parameter away the - * whole time. - * - * So this is the same board the shops use — same tabs, same cards, same map, - * same component — scoped to a partner instead of a tenant. Presence survives - * as the Shifts tab rather than as a page of its own. - * - * ── What the platform deliberately cannot do here ─────────────────────────── - * - * See a merchant's OWN riders, and assign anything. - * - * Both fall out of the scope type rather than being hidden: `DispatchScope`'s - * partner variant has no field to put a tenantid in, so the reads cannot be - * widened by accident, and `canAssign` is false for it, so the assign bar, the - * auto-assign drawer and the queue's tick boxes are all absent. The platform - * watches partners; it does not dispatch on a shop's behalf. - */ -export function NearleDispatchPage() { - const partners = useAllPartners(); - const [partnerid, setPartnerid] = useState(0); - const [view, setView] = useState<'work' | 'shifts'>('work'); - - // The first partner, once they load. An empty page with a dropdown on it - // makes the reader do a step the page could have done. - useEffect(() => { - if (partnerid === 0 && partners.data.length > 0) { - setPartnerid(partners.data[0]?.partnerid ?? 0); - } - }, [partnerid, partners.data]); - - const chosen = useMemo( - () => partners.data.find((partner) => partner.partnerid === partnerid), - [partners.data, partnerid], - ); - - const scope = useMemo( - () => - ({ - kind: 'partner' as const, - partnerid, - partnername: chosen?.partnername ?? `Partner ${partnerid}`, - }), - [partnerid, chosen], - ); - - return ( - - - - - } - tabs={ - /* The console's segmented control, the same one the store board and - every other page uses. - - These borrowed `.sbt` from `dispatch.css` — the store board's own - tab styling, loaded here through `DispatchBoard`. Before that they - were written against a `.mode-tab` class that exists in no - stylesheet at all, so they rendered as bare browser buttons above a - row of styled ones. Nothing catches that: the markup is valid, the - class is just never matched. Both problems end the same way. */ - - } - isActive={view === 'work'} - onClick={() => setView('work')} - /> - } - isActive={view === 'shifts'} - onClick={() => setView('shifts')} - /> - - } - /> - - {partnerid === 0 ? ( - - {partners.isLoading - ? 'Reading partners…' - : 'No rider partners are onboarded yet. Onboard one from Rider partners, in the account menu, to see their dispatch.'} - - ) : view === 'work' ? ( - /* The shops' own board, against a partner. Nothing about it is - duplicated here — this is the same component. */ - - ) : ( - - )} - - ); -} diff --git a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx deleted file mode 100644 index c113f26..0000000 --- a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useSearchParams } from 'react-router-dom'; -import { VStack } from '@astryxdesign/core/VStack'; -import { PageHeader } from '@/components/PageHeader'; -import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser'; -import { SheetImportPanel } from '../import/SheetImportPanel'; - -/** - * The platform operator's catalogue. - */ -export function GlobalCataloguePage() { - const [params] = useSearchParams(); - const mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue'; - - return ( - - - - {mode === 'sheet' ? ( - - ) : ( - - )} - - ); -} diff --git a/src/features/nearle-admin/pages/OnboardTenantPage.tsx b/src/features/nearle-admin/pages/OnboardTenantPage.tsx deleted file mode 100644 index cc0149f..0000000 --- a/src/features/nearle-admin/pages/OnboardTenantPage.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Button } from '@astryxdesign/core/Button'; -import { Card } from '@astryxdesign/core/Card'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Selector } from '@astryxdesign/core/Selector'; -import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; -import { VStack } from '@astryxdesign/core/VStack'; -import { - AlertCircle, - ArrowLeft, - Building2, - CheckCircle2, - MapPin, -} from 'lucide-react'; -import { tenantsApi, type CreateTenantRequest } from '@/api/tenants'; -import { errorMessage } from '@/api/client'; -import { PageBody } from '@/components/PageBody'; -import { queryKeys } from '@/queries/keys'; -import { StoreQrPanel } from '@/features/qr/StoreQrPanel'; -import { useAppCategories } from '@/queries/hooks'; - -interface FormState { - tenantname: string; - companyname: string; - adminname: string; - primarycontact: string; - primaryemail: string; - locationname: string; - categoryid: string; - address: string; - suburb: string; - city: string; - state: string; - postcode: string; -} - -const EMPTY: FormState = { - tenantname: '', - companyname: '', - adminname: '', - primarycontact: '', - primaryemail: '', - locationname: '', - categoryid: '', - address: '', - suburb: '', - city: '', - state: '', - postcode: '', -}; - -/** - * Onboard Tenant Page - * - * Clean, standard enterprise SaaS onboarding experience using native design system inputs and components. - */ -export function OnboardTenantPage() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [form, setForm] = useState(EMPTY); - const categories = useAppCategories(); - const [error, setError] = useState(null); - - function set(key: K) { - return (value: string) => setForm((prev) => ({ ...prev, [key]: value })); - } - - const mutation = useMutation({ - mutationFn: (body: CreateTenantRequest) => tenantsApi.createTenant(body), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all }); - }, - onError: (cause) => setError(errorMessage(cause)), - }); - - const isComplete = - form.tenantname.trim() !== '' && - form.companyname.trim() !== '' && - form.adminname.trim() !== '' && - form.primarycontact.trim() !== '' && - form.primaryemail.trim() !== '' && - form.locationname.trim() !== '' && - form.address.trim() !== '' && - form.city.trim() !== '' && - form.state.trim() !== '' && - form.categoryid !== '' && - form.postcode.trim() !== ''; - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - if (!isComplete || mutation.isPending) return; - - setError(null); - mutation.mutate({ - tenantname: form.tenantname.trim(), - companyname: form.companyname.trim(), - firstname: form.adminname.trim(), - primarycontact: form.primarycontact.trim(), - primaryemail: form.primaryemail.trim(), - locationname: form.locationname.trim(), - categoryid: Number(form.categoryid) || 0, - address: form.address.trim(), - suburb: form.suburb.trim(), - city: form.city.trim(), - state: form.state.trim(), - postcode: form.postcode.trim(), - status: 'Active', - }); - } - - if (mutation.isSuccess) { - const created = mutation.data; - return ( - - -
- -
- - - - - - - {form.tenantname} is live - - - - Its first outlet, {form.locationname}, has been commissioned. The next step is - stocking the catalogue — pick products from the global catalogue, or upload the - tenant's own list as a spreadsheet. - - - {created?.tenantid && created?.locationid ? ( - - - - ) : null} - - - -
- Fields marked with * are required -
- - - {/* Section 1: Business Details Card */} - - -
- - - Business Details - -
- -
- Merchant name * as any} - value={form.tenantname} - onChange={set('tenantname')} - placeholder="e.g. Kaveri Groceries" - /> - Company registered name * as any} - value={form.companyname} - onChange={set('companyname')} - placeholder="e.g. Kaveri Retail Pvt. Ltd." - /> - Store admin * as any} - value={form.adminname} - onChange={set('adminname')} - placeholder="e.g. Ravi Kumar" - /> - Primary phone * as any} - value={form.primarycontact} - onChange={set('primarycontact')} - placeholder="9876543210" - /> - Primary admin email * as any} - type="email" - value={form.primaryemail} - onChange={set('primaryemail')} - placeholder="admin@kaveri.com" - /> - First outlet name * as any} - value={form.locationname} - onChange={set('locationname')} - placeholder="e.g. Kaveri RS Puram" - /> - ({ - value: String(entry.categoryid), - label: entry.categoryname, - }))} - placeholder="Select category" - isDisabled={categories.isLoading} - value={form.categoryid} - onChange={set('categoryid')} - /> -
-
-
- - {/* Section 2: Head Office Card */} - - -
- - - Head Office - -
- - Street address * as any} - value={form.address} - onChange={set('address')} - placeholder="e.g. 12, Avinashi Road" - /> - -
- - City * as any} - value={form.city} - onChange={set('city')} - placeholder="e.g. Coimbatore" - /> - State * as any} - value={form.state} - onChange={set('state')} - placeholder="e.g. Tamil Nadu" - /> - Postcode * as any} - value={form.postcode} - onChange={set('postcode')} - placeholder="641004" - /> -
-
-
- - {/* Error Alert */} - {error ? ( - - - - {error} - - - ) : null} - - {/* Sticky Bottom Actions */} -
-
-
- -
- ); -} diff --git a/src/features/nearle-admin/pages/PartnersPage.tsx b/src/features/nearle-admin/pages/PartnersPage.tsx deleted file mode 100644 index e4c7a61..0000000 --- a/src/features/nearle-admin/pages/PartnersPage.tsx +++ /dev/null @@ -1,698 +0,0 @@ -/** - * Rider partners — the companies that supply riders. - * - * Clean, modern SaaS management workspace for delivery partners, fleet tracking, and region coverage. - */ - -import { useMemo, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Badge } from '@astryxdesign/core/Badge'; -import { Button } from '@astryxdesign/core/Button'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Table, type TableColumn } from '@astryxdesign/core/Table'; -import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; -import { VStack } from '@astryxdesign/core/VStack'; -import { - Bike, - MapPin, - Plus, - Truck, -} from 'lucide-react'; -import { errorMessage } from '@/api/client'; -import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries'; -import { tenantsApi } from '@/api/tenants'; -import type { TenantInfo } from '@/api/types'; -import { Selector } from '@astryxdesign/core/Selector'; -import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts'; -import { DataState } from '@/components/DataState'; -import { PageHeader } from '@/components/PageHeader'; -import { Panel } from '@/components/Panel'; -import { SearchInput } from '@/components/SearchInput'; -import { TablePager } from '@/components/TablePager'; -import { usePaged } from '@/components/usePaged'; -import { - useAllPartners, - useAppRegions, - usePartnerRiderCounts, - useTenantLocations, - useTenants, -} from '@/queries/hooks'; -import { queryKeys } from '@/queries/keys'; -import { Drawer } from '@/features/store-admin/Drawer'; -import { PartnerRidersDrawer } from '../PartnerRidersDrawer'; -import { DrawerButton } from '@/features/store-admin/drawerKit'; - -interface PartnerRow extends Record { - partnerid: number; - partnername: string; - companyname: string; - region: string; - contact: string; - status: string; - /** How many riders they have. `null` while the count is still being read. */ - riders: number | null; -} - -type StatusFilter = 'all' | 'active' | 'inactive'; - -export function PartnersPage() { - const partners = useAllPartners(); - const regions = useAppRegions(); - const [editing, setEditing] = useState(null); - const [ridersFor, setRidersFor] = useState(null); - const [statusFilter, setStatusFilter] = useState('all'); - - /* The fleet size per partner, read alongside the directory. */ - const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid)); - - const regionName = useMemo(() => { - const map = new Map(); - for (const region of regions.data ?? []) { - map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`); - } - return map; - }, [regions.data]); - - const rawRows = useMemo( - () => - partners.data.map((partner) => ({ - partnerid: partner.partnerid, - partnername: partner.partnername ?? `Partner ${partner.partnerid}`, - companyname: partner.companyname ?? '', - region: regionName.get(partner.applocationid ?? 0) ?? '—', - contact: partner.primarycontact || partner.contactno || '', - status: partner.status || 'Active', - riders: riderCounts.get(partner.partnerid) ?? null, - })), - [partners.data, regionName, riderCounts], - ); - - // Filtered rows based on status filter - const filteredRows = useMemo(() => { - if (statusFilter === 'all') return rawRows; - return rawRows.filter((row) => - statusFilter === 'active' - ? row.status.toLowerCase() === 'active' - : row.status.toLowerCase() !== 'active', - ); - }, [rawRows, statusFilter]); - - const paged = usePaged(filteredRows); - - // KPI Calculations - const totals = useMemo(() => { - const totalPartners = rawRows.length; - const activePartners = rawRows.filter((r) => r.status.toLowerCase() === 'active').length; - const totalRiders = rawRows.reduce((sum, r) => sum + (r.riders ?? 0), 0); - const uniqueDistricts = new Set( - rawRows.map((r) => r.region).filter((reg) => reg && reg !== '—'), - ).size; - - return { totalPartners, activePartners, totalRiders, uniqueDistricts }; - }, [rawRows]); - - const columns: TableColumn[] = [ - { - key: 'partnername', - header: 'Partner', - width: { type: 'proportional', value: 3.5 }, - renderCell: (row) => { - const initial = (row.partnername || 'P')[0]?.toUpperCase() ?? 'P'; - return ( - -
- {initial} -
- - - {row.partnername} - - {row.companyname ? ( - - {row.companyname} - - ) : null} - -
- ); - }, - }, - { - key: 'region', - header: 'Home region', - width: { type: 'proportional', value: 2 }, - renderCell: (row) => ( - - - - {row.region} - - - ), - }, - { - key: 'contact', - header: 'Contact', - width: { type: 'proportional', value: 2 }, - renderCell: (row) => ( - - {row.contact || '—'} - - ), - }, - { - key: 'status', - header: 'Status', - align: 'end', - width: { type: 'pixel', value: 110 }, - renderCell: (row) => ( - - ), - }, - { - key: 'actions', - header: 'Fleet', - align: 'end', - width: { type: 'pixel', value: 190 }, - renderCell: (row) => ( - - {/* A count when there is one, an instruction when there is not. - This is the ONLY way into a partner's riders, so on a partner - nobody has staffed yet it was a button labelled "0 riders" — which - reads as a figure to note rather than a thing to press, and left - people hunting for an "add rider" control that exists nowhere - else. */} - - ); -} - -/* ── The form ─────────────────────────────────────────────────────────────── */ - -interface FormState { - partnername: string; - companyname: string; - registrationno: string; - primarycontact: string; - primaryemail: string; - address: string; - suburb: string; - city: string; - state: string; - postcode: string; - applocationid: number; - tenantid: number; - locationid: number; -} - -const EMPTY: FormState = { - partnername: '', - companyname: '', - registrationno: '', - primarycontact: '', - primaryemail: '', - address: '', - suburb: '', - city: '', - state: '', - postcode: '', - applocationid: 0, - tenantid: 0, - locationid: 0, -}; - -function PartnerDrawer({ - partner, - onClose, - onCreated, -}: { - partner: Partner | null; - onClose: () => void; - /** - * A partner that has just been onboarded, so the list can offer the next step. - * - * Onboarding a partner and staffing it are one job done in two places, and - * the join between them was a dead end: the drawer closed, and the only way - * back in was a button in the Fleet column labelled "0 riders" — a statistic, - * not an action. People read that as a count and went looking for an "add - * rider" control that does not exist anywhere else. - */ - onCreated?: (partnerid: number) => void; -}) { - const client = useQueryClient(); - const regions = useAppRegions(); - const isNew = partner === null; - - const [form, setForm] = useState(() => - partner - ? { - partnername: partner.partnername ?? '', - companyname: partner.companyname ?? '', - registrationno: partner.registrationno ?? '', - primarycontact: partner.primarycontact ?? partner.contactno ?? '', - primaryemail: partner.primaryemail ?? '', - address: partner.address ?? '', - suburb: partner.suburb ?? '', - city: partner.city ?? '', - state: partner.state ?? '', - postcode: '', - applocationid: partner.applocationid ?? 0, - tenantid: 0, - locationid: 0, - } - : EMPTY, - ); - const [error, setError] = useState(null); - - function set(key: K) { - return (value: FormState[K]) => { - setError(null); - setForm((prev) => ({ ...prev, [key]: value })); - }; - } - - const [districtSearch, setDistrictSearch] = useState(''); - const [district, setDistrict] = useState(() => partner?.city ?? ''); - const districts = useMemo(() => districtOptions(regions.data ?? []), [regions.data]); - const shown = useMemo( - () => districts.filter((option) => matchDistrict(option, districtSearch)), - [districts, districtSearch], - ); - const chosenDistrict = districts.find((option) => option.name === district); - - const merchants = useTenants({ pageno: 1, pagesize: 200 }); - const merchantOptions = useMemo(() => { - const here = district.trim().toLowerCase(); - const seen = new Map(); - for (const entry of (merchants.data ?? []) as TenantInfo[]) { - if (!entry.tenantid || seen.has(entry.tenantid)) continue; - const city = (entry.city ?? '').trim().toLowerCase(); - if (here && city && city !== here) continue; - seen.set(entry.tenantid, entry.tenantname); - } - return [...seen.entries()].map(([value, label]) => ({ value: String(value), label })); - }, [merchants.data, district]); - - const branches = useTenantLocations(form.tenantid || undefined); - const branchOptions = useMemo( - () => - (branches.data ?? []).map((branch) => ({ - value: String(branch.locationid), - label: branch.locationname || `Branch ${branch.locationid}`, - })), - [branches.data], - ); - - const save = useMutation({ - mutationFn: () => { - const body: NewPartner = { - partnername: form.partnername.trim(), - companyname: form.companyname.trim(), - registrationno: form.registrationno.trim(), - primarycontact: form.primarycontact.trim(), - primaryemail: form.primaryemail.trim(), - address: form.address.trim(), - suburb: form.suburb.trim(), - city: form.city.trim(), - state: form.state.trim(), - ...(form.postcode.trim() ? { postcode: Number(form.postcode) || 0 } : {}), - applocationid: chosenDistrict?.applocationid ?? 0, - ...(chosenDistrict && chosenDistrict.applocationid === 0 - ? { district: chosenDistrict.name } - : {}), - }; - return partner - ? partnersApi.update({ ...body, partnerid: partner.partnerid }).then(() => partner.partnerid) - : partnersApi.create(body).then((result) => result?.partnerid ?? 0); - }, - onSuccess: async (partnerid) => { - if (partnerid > 0 && form.tenantid > 0) { - try { - await tenantsApi.assignPartner(form.tenantid, partnerid); - if (form.locationid > 0) { - await tenantsApi.updateBranch({ locationid: form.locationid, partnerid }); - } - } catch (cause) { - await client.invalidateQueries({ queryKey: queryKeys.partners.all }); - setError( - `${form.partnername.trim()} was saved, but could not be assigned to that branch: ${errorMessage(cause)}`, - ); - return; - } - } - await client.invalidateQueries({ queryKey: queryKeys.partners.all }); - await client.invalidateQueries({ queryKey: queryKeys.tenants.all }); - onClose(); - // Only for a NEW partner, and only once the list has been refetched — - // the riders drawer reads the partner it is handed, and one that is not - // in the list yet would open against a row that does not exist. - if (!partner && partnerid > 0) onCreated?.(partnerid); - }, - onError: (cause) => setError(errorMessage(cause)), - }); - - const isComplete = - form.partnername.trim() !== '' && form.primarycontact.trim() !== '' && form.applocationid > 0; - - return ( - - - } - isDisabled={!isComplete || save.isPending} - onClick={() => save.mutate()} - /> - - } - > - - {error ? ( - - {error} - - ) : null} - - - - - - - - - - District - - {/* Full width, not a fixed one: this sits above the 38-row district - list it filters, inside a drawer, and a 260px box over a list that - fills the panel reads as unrelated to it. */} - -
- {shown.map((option) => { - const running = isRunning(option); - const chosen = district === option.name; - return ( - - ); - })} - {shown.length === 0 ? ( - - No district matches “{districtSearch}”. - - ) : null} -
- - {chosenDistrict - ? isRunning(chosenDistrict) - ? `Riders are listed against ${chosenDistrict.name}.` - : `${chosenDistrict.name} will be opened when this partner is saved.` - : 'One district per partner. Choosing one Nearle does not run yet opens it.'} - -
- - - - Delivers for - - { - setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 })); - }} - options={merchantOptions} - placeholder={merchants.isLoading ? 'Loading merchants…' : 'Choose a merchant'} - /> - set('locationid')(Number(value) || 0)} - options={branchOptions} - isDisabled={!form.tenantid} - placeholder={ - !form.tenantid - ? 'Choose a merchant first' - : branches.isLoading - ? 'Loading branches…' - : 'Choose the branch they cover' - } - /> - - Optional. Set it and this partner’s riders become an option on that shop’s assign - screen, beside any riders it hired itself. - - - - - - - - - - - - -
-
- ); -} diff --git a/src/features/nearle-admin/pages/StoreDetailPage.tsx b/src/features/nearle-admin/pages/StoreDetailPage.tsx deleted file mode 100644 index 6b4fec3..0000000 --- a/src/features/nearle-admin/pages/StoreDetailPage.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; -import { Badge } from '@astryxdesign/core/Badge'; -import { Button } from '@astryxdesign/core/Button'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Table, type TableColumn } from '@astryxdesign/core/Table'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; -import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert, Truck } from 'lucide-react'; -import { DataState } from '@/components/DataState'; -import { Freshness } from '@/components/Freshness'; -import { KpiCard } from '@/components/KpiCard'; -import { PageHeader } from '@/components/PageHeader'; -import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks'; -import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats'; -import type { TenantInfo, TenantLocation } from '@/api/types'; -import { Panel } from '@/components/Panel'; -import { TablePager } from '@/components/TablePager'; -import { usePaged } from '@/components/usePaged'; -import { Drawer } from '@/features/store-admin/Drawer'; -import { StoreQrPanel } from '@/features/qr/StoreQrPanel'; -import { PartnerAssignDrawer } from '../PartnerAssignDrawer'; - -interface BranchRow extends Record { - locationid: number; - locationname: string; - city: string; - hours: string; - radius: string; - status: string; - orders: number; - revenue: number; -} - -function money(value: number | undefined): string { - if (typeof value !== 'number' || Number.isNaN(value)) return '₹0'; - return `₹${value.toLocaleString('en-IN')}`; -} - -/** - * One tenant: its branches, and how each is performing. - * - * Order figures are totalled from the tenant's own order rows and grouped by - * branch. They used to come from `/orders/getlocationsummary`, which does return - * one row per branch but carries no money on it at all — so every Revenue cell - * in this table read ₹0 for as long as the page has existed. - * - * Counter sales are NOT folded in here: the POS endpoints take a single required - * locationid, so a tenant-wide till figure would mean one request per branch, - * and a blended number would be eventually consistent in a way this page cannot - * honestly caption. Branch-level POS lives on the branch screen instead. - */ -export function StoreDetailPage() { - const { tenantId } = useParams<{ tenantId: string }>(); - const tenantid = Number(tenantId ?? 0) || undefined; - - const { data: tenants } = useTenants(); - const { data: locations, isLoading, error } = useTenantLocations(tenantid); - /* Per-branch takings come from the order rows. `getlocationsummary`, which - used to feed this table, sends counts and no money at all — see - `branchStats.ts`. No date range here on purpose: this is the platform's - all-time view of a merchant, not a trading-day board. */ - const summary = useOrders(tenantid ? { tenantid, pagesize: 500 } : undefined); - const orders = useOrderSummary(tenantid); - - const tenant = useMemo( - () => (tenants as TenantInfo[] | undefined)?.find((entry) => entry.tenantid === tenantid), - [tenants, tenantid], - ); - - const rows = useMemo(() => { - const perLocation = branchOrderStats(summary.data ?? []); - - return ((locations ?? []) as TenantLocation[]).map((branch) => { - const stats = perLocation.get(branch.locationid) ?? NO_ORDERS; - return { - locationid: branch.locationid, - locationname: branch.locationname, - city: [branch.suburb, branch.city].filter(Boolean).join(', '), - hours: - branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—', - radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—', - status: branch.status ?? 'Unknown', - // Both channels, because this page answers "how is this merchant - // doing" rather than "through which door". `branchOrderStats` splits - // app orders from imported counter bills — a split the Store Admin - // console needs and this page does not — so they are added back here - // rather than left half-counted. - orders: stats.orders + stats.counterOrders, - revenue: stats.revenue + stats.counterRevenue, - }; - }); - }, [locations, summary.data]); - - const paged = usePaged(rows); - - const totals = useMemo(() => { - const branches = rows.length; - const active = rows.filter((row) => row.status.toLowerCase() === 'active').length; - const totalOrders = rows.reduce((sum, row) => sum + row.orders, 0); - const totalRevenue = rows.reduce((sum, row) => sum + row.revenue, 0); - const cancelled = Number(orders.data?.cancelled ?? 0); - return { branches, active, totalOrders, totalRevenue, cancelled }; - }, [rows, orders.data]); - - /** The branch whose code is on screen, if any. */ - const [qrFor, setQrFor] = useState(null); - /** Open while the merchant's delivery partner is being changed. */ - const [isPartnerOpen, setPartnerOpen] = useState(false); - - const columns: TableColumn[] = [ - { - key: 'locationname', - header: 'Branch', - width: { type: 'proportional', value: 3 }, - renderCell: (row) => ( - - - {row.locationname} - - - {row.city || '—'} - - - ), - }, - { - key: 'hours', - header: 'Hours', - width: { type: 'pixel', value: 130 }, - renderCell: (row) => ( - - {row.hours} - - ), - }, - { - key: 'radius', - header: 'Radius', - align: 'end', - width: { type: 'pixel', value: 90 }, - renderCell: (row) => ( - - {row.radius} - - ), - }, - { - key: 'orders', - header: 'Orders', - align: 'end', - width: { type: 'pixel', value: 100 }, - renderCell: (row) => ( - - {row.orders} - - ), - }, - { - key: 'revenue', - header: 'Revenue', - align: 'end', - width: { type: 'pixel', value: 120 }, - renderCell: (row) => ( - - {money(row.revenue)} - - ), - }, - { - key: 'status', - header: 'Status', - align: 'end', - width: { type: 'pixel', value: 110 }, - renderCell: (row) => ( - - ), - }, - { - /* The storefront code, per branch, after the day it was commissioned. - Shown at creation too — but a poster gets lost, a shop reopens, a - merchant asks for it again a month later, and the person they ask is - whoever is looking at this page. The code is derived from the two ids - on this row, so there is nothing to look up and nothing to reissue. */ - key: 'qr', - header: 'QR', - align: 'end', - width: { type: 'pixel', value: 84 }, - renderCell: (row) => ( -