domain change

This commit is contained in:
2026-09-28 18:08:55 +05:30
parent c9616edad9
commit eeef8851e4
34 changed files with 99 additions and 4211 deletions

View File

@@ -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 # 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 # default would turn every environment that had not been told about this into a
# platform console on the day it shipped. # platform console on the day it shipped.
ARG VITE_WORKSPACE="merchant" # There is no workspace flag in this image.
ENV VITE_WORKSPACE=$VITE_WORKSPACE #
# One existed while a single codebase served both consoles and had to be told
# The other console's address, for the sentence shown to somebody in the wrong # which it was. Nearle's own staff now have their own application —
# place. Only the one this build is NOT is used; both are given so a single set # `nearle-platform`, its own repository and its own deploy — and this image is
# of build args works for either image. # 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" ARG VITE_PLATFORM_HOST="platform.nearledaily.com"
ENV VITE_PLATFORM_HOST=$VITE_PLATFORM_HOST 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 RUN npm run build

View File

@@ -3,10 +3,8 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { Spinner } from '@astryxdesign/core/Spinner'; import { Spinner } from '@astryxdesign/core/Spinner';
import { RequireRole, useAuth } from '@/auth/AuthContext'; import { RequireRole, useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles'; import { HOME_ROUTE } from '@/auth/roles';
import { IS_PLATFORM } from '@/auth/workspace';
import { withStaleChunkRecovery } from '@/lib/staleChunk'; import { withStaleChunkRecovery } from '@/lib/staleChunk';
import { LoginPage } from '@/features/auth/LoginPage'; import { LoginPage } from '@/features/auth/LoginPage';
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell'; import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
import { StoreUserShell } from '@/features/store-user/StoreUserShell'; import { StoreUserShell } from '@/features/store-user/StoreUserShell';
@@ -29,15 +27,8 @@ import { StoreUserShell } from '@/features/store-user/StoreUserShell';
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) => const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
lazy(withStaleChunkRecovery(() => loader().then((module) => ({ default: module[key] })))); 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 /* 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. */ 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, /* 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 which pins a store user to their outlet and lets an admin choose. Both routes
@@ -89,71 +80,20 @@ export function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
{/* {/* Nearle staff have their own application.
Each site mounts ONE workspace.
`platform.nearledaily.com` is Nearle's own staff; `app.nearledaily.com` The platform workspace lived here until the two consoles were split.
is merchants and their branch users. The same build produces both, with It is now `nearle-platform`, a separate repository deployed on its own
`VITE_WORKSPACE` deciding which of the two blocks below exists. domain, and this one serves merchants and their branch users only.
Unmounted, not guarded. A route that is not in the table cannot render `/nearle/*` is therefore not a protected path here — it is not a path
whatever state the app is in, where a `RequireRole` around it is one at all, and falls to the catch-all like any other unknown URL. A
redirect away from rendering if the role check is ever wrong. On this `nearle-admin` account cannot sign in either: `login` and `restore`
site `/admin/*` is not a protected path — it is not a path at all, and refuse it before a session is written. */}
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 ? (
<Route
path="/nearle"
element={
<RequireRole role="nearle-admin">
<Suspense fallback={<RouteFallback />}>
<NearleAdminShell />
</Suspense>
</RequireRole>
}
>
<Route index element={<Navigate to="/nearle/stores" replace />} />
<Route path="stores" element={<StoresPage />} />
<Route path="stores/:tenantId" element={<StoreDetailPage />} />
<Route path="onboard/tenant" element={<OnboardTenantPage />} />
{/* 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. */}
<Route path="onboard/branch" element={<Navigate to="/nearle/stores" replace />} />
<Route path="catalogue" element={<GlobalCataloguePage />} />
{/* Delivery partners — the companies that supply riders. Platform-side
only: a merchant is assigned one, never allowed to create one. */}
<Route path="partners" element={<PartnersPage />} />
<Route path="dispatch" element={<NearleDispatchPage />} />
{/* The platform's rider tracking was a "Fleet" page showing presence
only. It is a tab on Dispatch now; the old link still works. */}
<Route path="fleet" element={<Navigate to="/nearle/dispatch" replace />} />
<Route path="uploads" element={<NearleUploadsPage />} />
{/* 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. */}
<Route path="*" element={<Navigate to="/nearle/stores" replace />} />
</Route>
) : null}
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */} {/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
{!IS_PLATFORM ? (
<>
<Route <Route
path="/admin" path="/admin"
element={ element={
@@ -216,13 +156,11 @@ export function App() {
<Route path="setup" element={<StoreSetupPage />} /> <Route path="setup" element={<StoreSetupPage />} />
<Route path="*" element={<Navigate to="/store/console" replace />} /> <Route path="*" element={<Navigate to="/store/console" replace />} />
</Route> </Route>
</>
) : null}
{/* `user` is only ever a role this build serves — `restore` and `login` {/* `user` is only ever a merchant role — `login` and `restore` refuse a
both refuse the other console's accounts — so its home route is always Nearle staff account before a session is written — so its home route
one of the blocks mounted above, and this cannot bounce into a is always one of the two workspaces above, and this cannot bounce
workspace that is not here. */} somewhere that does not exist. */}
<Route <Route
path="*" path="*"
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />} element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}

View File

@@ -103,7 +103,17 @@ export function toSessionUser(user: FiestaUser): SessionUser {
* sub-path is absorbed there and never reaches the global one. * sub-path is absorbed there and never reaches the global one.
*/ */
export const HOME_ROUTE: Record<ConsoleRole, string> = { export const HOME_ROUTE: Record<ConsoleRole, string> = {
'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-admin': '/admin/console',
'store-manager': '/store/console', 'store-manager': '/store/console',
}; };

View File

@@ -1,108 +1,57 @@
import { strict as assert } from 'node:assert'; import { strict as assert } from 'node:assert';
import { test } from 'node:test'; import { test } from 'node:test';
import { IS_PLATFORM, isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace'; import { isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
import type { ConsoleRole } from './roles'; 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 Merchants and their branch users sign in here. Nearle's own staff have their own
branch users at app.nearledaily.com. Neither site accepts the other's accounts, application — `nearle-platform`, a separate repository with its own deploy — and
and the refusal is a refusal — not a redirect carrying a half-made session a staff account is turned away rather than redirected: no session crosses to
across a domain boundary. another site half-made.
These run against the DEFAULT build, which is `merchant`. That default is itself The role is not known until the password has been checked, so the refusal
the thing most worth pinning: an unset flag is the ordinary state of a developer happens after credentials are verified. What matters is that it happens BEFORE
machine and of any deployment not yet told about the split, and the wrong the session is written: `login` refuses first and persists second, and `restore`
default would turn every one of them into a platform console. 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']; const ROLES: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager'];
test('an unset flag builds the merchant console', () => { test('this build is the merchant console, with nothing to configure', () => {
// Nothing sets VITE_WORKSPACE under the test runner, so this is the default // A constant, not a build flag. The flag existed only while one codebase
// path — the same one every existing deployment takes until it is told // served both consoles; a variable now would be a way to deploy this
// otherwise. // application as something it is not.
assert.equal(WORKSPACE, 'merchant'); 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-admin'), true);
assert.equal(isAllowedHere('store-manager'), true); assert.equal(isAllowedHere('store-manager'), true);
assert.equal(isAllowedHere('nearle-admin'), false); assert.equal(isAllowedHere('nearle-admin'), false);
}); });
test('every role is decided, none left to a default', () => { test('every role is decided, none left to a default', () => {
// A role added later must be listed deliberately on one side or the other. // A role added later must be listed deliberately. Falling through to allowed
// Falling through to "allowed" would put it on both consoles silently; this // would quietly admit it to the merchant console.
// asserts each of the three is a decision that was actually made. const admitted = ROLES.filter(isAllowedHere);
for (const role of ROLES) { assert.deepEqual(admitted, ['store-admin', 'store-manager']);
assert.equal(typeof isAllowedHere(role), 'boolean', `${role} has no verdict`);
}
const allowed = ROLES.filter(isAllowedHere);
assert.equal(allowed.length, 2, `merchant admits ${allowed.join(', ')}`);
}); });
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'); const message = wrongConsoleMessage('nearle-admin');
// The host, so somebody knows where to go.
assert.match(message, /platform\.nearledaily\.com/); assert.match(message, /platform\.nearledaily\.com/);
// And not a word that reads as "your account is broken" — the password was // The password was right and the account is fine. Anyone told "failed" or
// right and the account is fine. Anyone told "failed" or "denied" goes and // "denied" goes and resets a working password, or asks an administrator to
// resets a working password, or asks an administrator to fix nothing. // fix nothing at all.
for (const blame of ['failed', 'invalid', 'denied', 'not recognised', 'wrong password']) { for (const blame of ['failed', 'invalid', 'denied', 'not recognised', 'wrong password']) {
assert.ok( assert.ok(!message.toLowerCase().includes(blame), `the refusal reads as a fault: ${message}`);
!message.toLowerCase().includes(blame),
`the refusal reads as a fault: ${message}`,
);
} }
}); });
test('each role is named in words a person uses', () => { test('the refused role is named in words a person uses', () => {
// The message is read by whoever typed the password, not by us.
assert.match(wrongConsoleMessage('nearle-admin'), /Nearle staff/); 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',
);
}); });

View File

@@ -1,90 +1,63 @@
import type { ConsoleRole } from './roles'; 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 * There was a `VITE_WORKSPACE` build flag here while one codebase served both
* branch users work at `app.nearledaily.com`. They are the same application * consoles and had to be told which it was. That is over: Nearle's own staff
* built twice with this flag set differently, rather than two repositories, * have their own application, `nearle-platform`, and this repository is the
* because every screen below the workspace split — drawers, tables, the * merchant console and nothing else. A flag would only be a way to deploy this
* assistant, the design system — is shared and would otherwise be maintained * application as something it is not.
* in two places and drift.
* *
* What the flag changes is which routes are mounted and which roles may sign * ── The separation is a refusal, not a redirect ─────────────────────────────
* 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 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 * The role is not known until the password has been checked — `applogin`
* deployment that has not been told about this yet. Defaulting to `merchant` * returns it — so the refusal can only happen after credentials are verified.
* means the existing site keeps behaving exactly as it did, and the platform * `login` applies it BEFORE the session is written and `restore` applies it to
* build is the one that has to be asked for. The opposite default would turn * what is already stored. That order is the point: a session persisted first
* every un-migrated environment into a platform console the day this shipped. * 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(); const ALLOWED: ReadonlySet<ConsoleRole> = new Set<ConsoleRole>(['store-admin', 'store-manager']);
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<Workspace, ReadonlySet<ConsoleRole>> = {
platform: new Set<ConsoleRole>(['nearle-admin']),
merchant: new Set<ConsoleRole>(['store-admin', 'store-manager']),
};
export function isAllowedHere(role: ConsoleRole): boolean { 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 * The platform console's address, for the sentence shown to a staff member who
* place. * signs in at the wrong site.
* *
* Named rather than derived from `location.hostname`, because the two sites are * A build variable rather than a constant, because the two are separate
* not a naming convention apart — they are separate deployments and either can * deployments and either can move. The fallback is the production hostname,
* move. A build that was not told falls back to the production hostnames, which * which is right far more often than saying nothing would be.
* is right far more often than saying nothing.
*/ */
const OTHER_SITE: Record<Workspace, string> = { const PLATFORM_HOST =
platform: (import.meta.env?.['VITE_MERCHANT_HOST'] ?? '').trim() || 'app.nearledaily.com', (import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com';
merchant: (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 one sign-in screen
* * to another reads as a redirect that failed, and this is not a failure — it is
* Names the host rather than linking to it. A live link from a sign-in screen * the right answer to the wrong door.
* 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.
*/ */
export function wrongConsoleMessage(role: ConsoleRole): string { export function wrongConsoleMessage(role: ConsoleRole): string {
const site = OTHER_SITE[WORKSPACE]; return `${roleWord(role)} accounts sign in at ${PLATFORM_HOST}, not here.`;
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.`;
} }
function roleWord(role: ConsoleRole): string { function roleWord(role: ConsoleRole): string {
switch (role) { switch (role) {
case 'nearle-admin': case 'nearle-admin':
return 'Nearle staff'; 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': case 'store-admin':
return 'Store admin'; return 'Store admin';
case 'store-manager': case 'store-manager':

View File

@@ -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: <Bike size={13} />,
note: 'Who we are contracted with',
},
{
to: '/nearle/uploads',
label: 'Uploads',
icon: <FileSpreadsheet size={13} />,
note: 'Spreadsheets sent to the catalogue',
},
];
export function NearleAdminShell() {
return (
<DateScopeProvider>
<AppShell
nav={NAV}
home="/nearle/stores"
navLabel="Nearle Admin"
manageItems={MANAGE}
/>
</DateScopeProvider>
);
}

View File

@@ -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<number>(currentPartnerId);
const [error, setError] = useState<string | null>(null);
const regionName = useMemo(() => {
const map = new Map<number, string>();
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 (
<Drawer
title="Delivery partner"
subtitle={tenantname}
width={460}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : 'Save'}
variant="primary"
icon={<Truck size={15} />}
isDisabled={save.isPending || chosen === currentPartnerId}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={2}>
{error ? <Alert>{error}</Alert> : null}
<Note icon={<Bike size={15} />}>
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.
</Note>
<Section title="Currently">
<DrawerCard>
<Row
label="Partner"
value={current?.partnername ?? (currentPartnerId > 0 ? `Partner ${currentPartnerId}` : 'Own riders only')}
/>
</DrawerCard>
</Section>
<Section title="Change to">
<DrawerCard>
{/* "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. */}
<Choice
label="Own riders only"
detail="This shop delivers with riders it hired itself."
isChosen={chosen === 0}
onChoose={() => setChosen(0)}
/>
{partners.isLoading ? (
<Row label="Partners" value="Reading…" />
) : (
partners.data.map((partner) => (
<Choice
key={partner.partnerid}
label={partner.partnername ?? `Partner ${partner.partnerid}`}
detail={
[
regionName.get(partner.applocationid ?? 0),
partner.companyname,
]
.filter(Boolean)
.join(' · ') || 'No region recorded'
}
isChosen={chosen === partner.partnerid}
onChoose={() => setChosen(partner.partnerid)}
/>
))
)}
</DrawerCard>
</Section>
</VStack>
</Drawer>
);
}
function Choice({
label,
detail,
isChosen,
onChoose,
}: {
label: string;
detail: string;
isChosen: boolean;
onChoose: () => void;
}) {
return (
<button
type="button"
onClick={onChoose}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
width: '100%',
padding: '12px 14px',
border: 0,
borderTop: '1px solid var(--color-line)',
background: isChosen ? 'var(--color-brand-tint)' : 'transparent',
cursor: 'pointer',
textAlign: 'left',
}}
>
<span
aria-hidden
style={{
width: 18,
height: 18,
flex: 'none',
borderRadius: 999,
display: 'grid',
placeItems: 'center',
border: isChosen ? 0 : '1.5px solid var(--color-line)',
background: isChosen ? 'var(--color-brand)' : 'transparent',
color: '#fff',
}}
>
{isChosen ? <Check size={11} strokeWidth={3} /> : null}
</span>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span
style={{
font: '500 13.5px/1.4 var(--font-sans)',
color: isChosen ? 'var(--color-brand)' : 'var(--color-ink-1)',
}}
>
{label}
</span>
<span style={{ font: '400 12px/1.4 var(--font-sans)', color: 'var(--color-ink-3)' }}>
{detail}
</span>
</span>
</button>
);
}

View File

@@ -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<RiderRosterRow | 'new' | null>(null);
const rows = riders.data ?? [];
const onDuty = rows.filter((rider) => rider.isonduty).length;
return (
<>
<Drawer
title={partner.partnername ?? `Partner ${partner.partnerid}`}
subtitle="Riders"
width={520}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Close" variant="ghost" onClick={onClose} />
<DrawerButton
label="Add rider"
variant="primary"
icon={<Plus size={15} />}
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. */}
<>
<Note icon={<Bike size={15} />}>
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.
</Note>
{riders.isLoading ? (
<Note>Reading riders…</Note>
) : rows.length === 0 ? (
<Section title="No riders yet">
<DrawerCard>
<Row
label="Fleet"
value="This partner has nobody on their books. Add one to get started."
isStacked
/>
</DrawerCard>
</Section>
) : (
<Section title={`${rows.length} rider${rows.length === 1 ? '' : 's'} · ${onDuty} on duty`}>
<DrawerCard>
{rows.map((rider) => (
<Row
key={rider.userid}
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
value={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
<Badge
label={rider.isonduty ? 'On duty' : 'Off shift'}
/* The success token, not a hex green typed here. The
#0f8a5f this used was a fourth green in a console
that has one. */
colour={
rider.isonduty ? 'var(--color-success, #1f9d55)' : 'var(--color-ink-4)'
}
/>
<LinkAction label="Edit" onClick={() => setEditing(rider)} />
</span>
}
/>
))}
</DrawerCard>
</Section>
)}
</>
</Drawer>
{editing ? (
<RiderDrawer
row={editing === 'new' ? null : editing}
/* A partner's rider, so no tenant and no branch — they work a region
and serve whoever the partner supplies in it. */
owner={{
kind: 'partner',
partnerid: partner.partnerid,
partnername: partner.partnername ?? `Partner ${partner.partnerid}`,
applocationid: partner.applocationid ?? 0,
}}
onClose={() => setEditing(null)}
/>
) : null}
</>
);
}

View File

@@ -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<number | null>(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 (
<VStack gap={3}>
{/* 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. */}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
Who was on, and for how long
</Text>
<input
type="date"
className="fleet-select"
aria-label="Day"
value={day}
max={today()}
onChange={(event) => {
setDay(event.target.value);
setFocused(null);
}}
/>
</HStack>
<Banner advice={advice} />
<div className="kpi-grid">
<KpiCard
label="Riders online"
value={String(fleet.riders.length)}
note={
fleet.riders.length === 0
? 'nobody checked in on this day'
: fleet.measured === fleet.riders.length
? 'all with enough check-ins to time'
: `${fleet.measured} with enough check-ins to time`
}
tone="accent"
icon={<Bike size={15} />}
/>
<KpiCard
label="Hours online"
value={duration(fleet.onlineMs)}
note="inferred from check-ins, not clock-ins"
tone="neutral"
icon={<Clock size={15} />}
/>
<KpiCard
label="Average day"
value={duration(fleet.measured > 0 ? fleet.onlineMs / fleet.measured : null)}
note={`longest ${duration(fleet.longestMs)}`}
tone={toneOf(advice.severity)}
icon={<Timer size={15} />}
/>
<KpiCard
label="Reported places"
value={String(pins.length)}
note="one fixed point per rider — not a live position"
tone="neutral"
icon={<MapPin size={15} />}
/>
</div>
<div className="fleet-body">
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<Text type="label" size="sm" weight="semibold">
Where the fleet is registered
</Text>
<TrailMap
pins={pins}
height={420}
emptyNote={
logs.isLoading
? 'Reading the day…'
: 'No rider reported a location on this day.'
}
/>
<Note>
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.
</Note>
</VStack>
</Card>
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<Text type="label" size="sm" weight="semibold">
The day, rider by rider
</Text>
{logs.isLoading ? (
<Text type="body" size="sm" color="secondary">
Reading check-ins…
</Text>
) : fleet.riders.length === 0 ? (
<Text type="body" size="sm" color="secondary">
Nobody checked in on {day}.
</Text>
) : (
<div className="gantt">
{fleet.riders.map((rider, index) => (
<GanttRow
key={rider.userid}
rider={rider}
colour={trailColour(index)}
window={fleet.window}
isFocused={focused === rider.userid}
onClick={() =>
setFocused((prev) => (prev === rider.userid ? null : rider.userid))
}
/>
))}
{fleet.window ? (
<div className="gantt-axis">
<span>{clock(fleet.window.from)}</span>
<span>{clock(fleet.window.to)}</span>
</div>
) : null}
</div>
)}
{focused !== null ? <ShiftDetail rider={fleet.riders.find((r) => r.userid === focused)} /> : null}
</VStack>
</Card>
</div>
<Card padding={0}>
<VStack gap={1} padding={2}>
<HStack gap={1} align="center">
<span className="fleet-note-icon">
<Info size={14} />
</span>
<Text type="label" size="xsm" weight="semibold">
What these numbers are, and are not
</Text>
</HStack>
<Text type="body" size="xsm" color="secondary">
Nothing here comes from a timesheet. The rider app sends a check-in every twenty
seconds or so and never closes a shift — <code>workhours</code>, <code>logout</code> 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.
</Text>
</VStack>
</Card>
</VStack>
);
}
/* ── 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 (
<button type="button" className="gantt-row" data-active={isFocused} onClick={onClick}>
<span className="gantt-name" title={rider.username}>
<i style={{ background: colour }} />
{rider.username}
</span>
<span className="gantt-track">
{rider.shifts.map((shift) => (
<i
key={shift.from}
className="gantt-bar"
style={{
left: `${at(shift.from)}%`,
width: `${Math.max(0.4, at(shift.to) - at(shift.from))}%`,
background: colour,
}}
title={`Online ${clock(shift.from)}–${clock(shift.to)} · ${shift.pings} check-ins`}
/>
))}
</span>
<span className="gantt-figure">
{rider.onlineMs === null ? (
<em title={`Only ${rider.coverage.pings} check-ins — too few to time a shift`}>—</em>
) : (
duration(rider.onlineMs)
)}
</span>
<span className="gantt-figure gantt-quiet" title="Separate runs of check-ins">
{rider.shifts.length || '—'}
</span>
<span className="gantt-figure gantt-quiet">{rider.coverage.pings}</span>
</button>
);
}
function ShiftDetail({ rider }: { rider: RiderDay | undefined }) {
if (!rider) return null;
return (
<div className="shift-detail">
<strong>{rider.username}</strong>
{rider.shifts.length === 0 ? (
<span>
{rider.coverage.pings} check-in{rider.coverage.pings === 1 ? '' : 's'}, too few to make a
shift out of.
</span>
) : (
<ul>
{rider.shifts.map((shift) => (
<li key={shift.from}>
<span>
{clock(shift.from)} – {clock(shift.to)}
</span>
<em>{duration(shift.to - shift.from)}</em>
{/* 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 ? (
<span className="shift-gap">
quiet for {duration(shift.longestGapMs)} inside this run
</span>
) : null}
</li>
))}
</ul>
)}
</div>
);
}
/* ── Chrome ──────────────────────────────────────────────────────────────── */
function Banner({ advice }: { advice: ReturnType<typeof recommend> }) {
const Icon =
advice.severity === 'good' ? CheckCircle2 : advice.severity === 'watch' ? Info : AlertTriangle;
return (
<div className="fleet-banner" data-severity={advice.severity}>
<Icon size={17} />
<div>
<strong>{advice.headline}</strong>
<span>{advice.detail}</span>
</div>
</div>
);
}
function Note({ children }: { children: React.ReactNode }) {
return (
<div className="fleet-inline-note">
<Info size={13} />
<span>{children}</span>
</div>
);
}
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' });
}

View File

@@ -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<number>(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 (
<VStack gap={3}>
<PageHeader
isTabsInline
title="Dispatch"
actions={
<HStack gap={1} align="center" wrap="wrap">
<select
className="fleet-select"
aria-label="Rider partner"
value={partnerid}
onChange={(event) => setPartnerid(Number(event.target.value))}
>
{partners.data.length === 0 ? <option value={0}>No partners</option> : null}
{partners.data.map((partner) => (
<option key={partner.partnerid} value={partner.partnerid}>
{partner.partnername ?? `Partner ${partner.partnerid}`}
</option>
))}
</select>
</HStack>
}
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. */
<TabBar aria-label="Dispatch views">
<Tab
label="The work"
icon={<Bike size={14} />}
isActive={view === 'work'}
onClick={() => setView('work')}
/>
<Tab
label="Shifts"
icon={<Clock size={14} />}
isActive={view === 'shifts'}
onClick={() => setView('shifts')}
/>
</TabBar>
}
/>
{partnerid === 0 ? (
<Text type="body" size="sm" color="secondary">
{partners.isLoading
? 'Reading partners…'
: 'No rider partners are onboarded yet. Onboard one from Rider partners, in the account menu, to see their dispatch.'}
</Text>
) : view === 'work' ? (
/* The shops' own board, against a partner. Nothing about it is
duplicated here — this is the same component. */
<DispatchBoard scope={scope} />
) : (
<ShiftsPanel partnerid={partnerid} />
)}
</VStack>
);
}

View File

@@ -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 (
<VStack gap={3}>
<PageHeader title="Global catalogue" />
{mode === 'sheet' ? (
<SheetImportPanel />
) : (
<CatalogueBrowser
tenantid={undefined}
locationid={undefined}
actionLabel="Add to store"
isReadOnly
/>
)}
</VStack>
);
}

View File

@@ -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<FormState>(EMPTY);
const categories = useAppCategories();
const [error, setError] = useState<string | null>(null);
function set<K extends keyof FormState>(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 (
<PageBody measure="reading">
<VStack gap={3}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={() => navigate('/nearle/stores')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: 'transparent',
border: 'none',
padding: '4px 0',
fontSize: 13,
fontWeight: 500,
color: 'var(--color-ink-3, #657081)',
cursor: 'pointer',
}}
>
<ArrowLeft size={14} />
<span>Back to stores</span>
</button>
</div>
<Card padding={4}>
<VStack gap={3}>
<HStack align="center" gap={1.5}>
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
<Text type="large" weight="semibold">
{form.tenantname} is live
</Text>
</HStack>
<Text type="body" color="secondary">
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&apos;s own list as a spreadsheet.
</Text>
{created?.tenantid && created?.locationid ? (
<Card padding={3} variant="transparent">
<StoreQrPanel
tenantid={created.tenantid}
locationid={created.locationid}
locationname={form.locationname}
where={[form.suburb, form.city].filter(Boolean).join(', ')}
/>
</Card>
) : null}
<HStack gap={1.5} wrap="wrap">
<Button
label="Stock from global catalogue"
variant="primary"
onClick={() =>
navigate(
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
)
}
/>
<Button
label="Back to stores"
variant="secondary"
onClick={() => navigate('/nearle/stores')}
/>
</HStack>
</VStack>
</Card>
</VStack>
</PageBody>
);
}
return (
<PageBody measure="reading">
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
<VStack gap={3}>
{/* Top Back Action & Required Indicator */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
<h1 className="sr-only">Onboard Tenant</h1>
<button
type="button"
onClick={() => navigate('/nearle/stores')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
background: 'transparent',
border: 'none',
padding: '4px 0',
fontSize: 13,
fontWeight: 500,
color: 'var(--color-ink-3, #657081)',
cursor: 'pointer',
transition: 'color 0.15s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-brand, #662582)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-ink-3, #657081)')}
>
<ArrowLeft size={14} />
<span>Back to stores</span>
</button>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '3px 8px',
borderRadius: 6,
background: 'var(--color-surface-sunken, #f2f4f7)',
border: '1px solid var(--color-line, #e0e4ea)',
fontSize: 11.5,
color: 'var(--color-ink-3, #657081)',
fontWeight: 500,
}}
>
<span>Fields marked with <span style={{ color: 'var(--color-error, #d64545)', fontWeight: 600 }}>*</span> are required</span>
</div>
</div>
{/* Section 1: Business Details Card */}
<Card padding={0}>
<VStack gap={2} padding={3}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
paddingBottom: 10,
borderBottom: '1px solid var(--color-line, #e0e4ea)',
}}
>
<Building2 size={16} style={{ color: 'var(--color-brand, #662582)' }} />
<span
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--color-ink-1, #0f172a)',
}}
>
Business Details
</span>
</div>
<div className="form-grid">
<TextInput
label={<span>Merchant name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.tenantname}
onChange={set('tenantname')}
placeholder="e.g. Kaveri Groceries"
/>
<TextInput
label={<span>Company registered name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.companyname}
onChange={set('companyname')}
placeholder="e.g. Kaveri Retail Pvt. Ltd."
/>
<TextInput
label={<span>Store admin <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.adminname}
onChange={set('adminname')}
placeholder="e.g. Ravi Kumar"
/>
<TextInput
label={<span>Primary phone <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.primarycontact}
onChange={set('primarycontact')}
placeholder="9876543210"
/>
<TextInput
label={<span>Primary admin email <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
type="email"
value={form.primaryemail}
onChange={set('primaryemail')}
placeholder="admin@kaveri.com"
/>
<TextInput
label={<span>First outlet name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.locationname}
onChange={set('locationname')}
placeholder="e.g. Kaveri RS Puram"
/>
<Selector
label="Business category"
options={(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
}))}
placeholder="Select category"
isDisabled={categories.isLoading}
value={form.categoryid}
onChange={set('categoryid')}
/>
</div>
</VStack>
</Card>
{/* Section 2: Head Office Card */}
<Card padding={0}>
<VStack gap={2} padding={3}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
paddingBottom: 10,
borderBottom: '1px solid var(--color-line, #e0e4ea)',
}}
>
<MapPin size={16} style={{ color: 'var(--color-brand, #662582)' }} />
<span
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--color-ink-1, #0f172a)',
}}
>
Head Office
</span>
</div>
<TextInput
label={<span>Street address <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.address}
onChange={set('address')}
placeholder="e.g. 12, Avinashi Road"
/>
<div className="form-grid-4">
<TextInput
label="Suburb"
value={form.suburb}
onChange={set('suburb')}
placeholder="e.g. Peelamedu"
/>
<TextInput
label={<span>City <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.city}
onChange={set('city')}
placeholder="e.g. Coimbatore"
/>
<TextInput
label={<span>State <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.state}
onChange={set('state')}
placeholder="e.g. Tamil Nadu"
/>
<TextInput
label={<span>Postcode <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.postcode}
onChange={set('postcode')}
placeholder="641004"
/>
</div>
</VStack>
</Card>
{/* Error Alert */}
{error ? (
<HStack
align="center"
gap={1}
padding={1.5}
style={{
background: 'var(--color-error-muted, #fceeee)',
borderRadius: 8,
color: 'var(--color-error, #d64545)',
border: '1px solid var(--color-error-border, #fad1d1)',
}}
>
<AlertCircle size={15} />
<Text type="body" size="sm" style={{ color: 'inherit' }}>
{error}
</Text>
</HStack>
) : null}
{/* Sticky Bottom Actions */}
<div
style={{
position: 'sticky',
bottom: 0,
zIndex: 20,
padding: '12px 18px',
background: 'rgba(255, 255, 255, 0.94)',
backdropFilter: 'blur(8px)',
border: '1px solid var(--color-line, #e0e4ea)',
borderRadius: 10,
boxShadow: '0 4px 16px -2px rgba(15, 23, 42, 0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<Button
type="button"
variant="secondary"
label="Cancel"
onClick={() => navigate('/nearle/stores')}
/>
<Button
type="submit"
variant="primary"
label={mutation.isPending ? 'Saving…' : 'Save & Continue'}
isLoading={mutation.isPending}
isDisabled={!isComplete}
/>
</div>
</VStack>
</form>
</PageBody>
);
}

View File

@@ -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<string, unknown> {
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<Partner | 'new' | null>(null);
const [ridersFor, setRidersFor] = useState<Partner | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('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<number, string>();
for (const region of regions.data ?? []) {
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
}
return map;
}, [regions.data]);
const rawRows = useMemo<PartnerRow[]>(
() =>
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<PartnerRow>[] = [
{
key: 'partnername',
header: 'Partner',
width: { type: 'proportional', value: 3.5 },
renderCell: (row) => {
const initial = (row.partnername || 'P')[0]?.toUpperCase() ?? 'P';
return (
<HStack align="center" gap={1.5}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'var(--color-brand-tint, #f4eef8)',
color: 'var(--color-brand, #662582)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 13,
flexShrink: 0,
}}
>
{initial}
</div>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.partnername}
</Text>
{row.companyname ? (
<Text type="body" size="xsm" color="secondary">
{row.companyname}
</Text>
) : null}
</VStack>
</HStack>
);
},
},
{
key: 'region',
header: 'Home region',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<HStack align="center" gap={0.5}>
<MapPin size={13} style={{ color: 'var(--color-ink-4, #97a1b0)' }} />
<Text type="body" size="sm" color="secondary">
{row.region}
</Text>
</HStack>
),
},
{
key: 'contact',
header: 'Contact',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" style={{ fontFamily: 'var(--font-mono)' }}>
{row.contact || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
key: 'actions',
header: 'Fleet',
align: 'end',
width: { type: 'pixel', value: 190 },
renderCell: (row) => (
<HStack gap={0.5} justify="end" align="center">
{/* 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. */}
<Button
label={
row.riders === null
? '—'
: row.riders === 0
? 'Add riders'
: `${row.riders} rider${row.riders === 1 ? '' : 's'}`
}
variant={row.riders === 0 ? 'primary' : 'secondary'}
size="sm"
icon={row.riders === 0 ? <Plus size={13} /> : <Bike size={13} />}
onClick={() => {
const found = partners.data.find((p) => p.partnerid === row.partnerid);
if (found) setRidersFor(found);
}}
/>
<Button
label="Edit"
variant="ghost"
size="sm"
onClick={() => {
const found = partners.data.find((p) => p.partnerid === row.partnerid);
if (found) setEditing(found);
}}
/>
</HStack>
),
},
];
return (
<VStack gap={3}>
<PageHeader
title="Rider partners"
isTabsInline
tabs={
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FilterTab
label="All partners"
count={rawRows.length}
isActive={statusFilter === 'all'}
onClick={() => setStatusFilter('all')}
/>
<FilterTab
label="Active"
count={totals.activePartners}
isActive={statusFilter === 'active'}
onClick={() => setStatusFilter('active')}
/>
<FilterTab
label="Inactive"
count={rawRows.length - totals.activePartners}
isActive={statusFilter === 'inactive'}
onClick={() => setStatusFilter('inactive')}
/>
</div>
}
actions={
<Button
label="Onboard rider partner"
variant="primary"
size="sm"
icon={<Plus size={14} />}
onClick={() => setEditing('new')}
/>
}
/>
{/* Partners Table Card */}
<Panel
count={paged.total}
countLabel="partners"
footer={<TablePager paged={paged} label="partners" />}
>
<DataState
isLoading={partners.isLoading}
error={null}
isEmpty={filteredRows.length === 0}
emptyTitle="No rider partners yet"
emptyDescription="A rider partner is the company that supplies delivery riders. Click Onboard rider partner to add your first partner."
>
<div className="table-scroll">
<Table<PartnerRow>
data={paged.rows}
columns={columns}
idKey="partnerid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</DataState>
</Panel>
{/* Drawers */}
{ridersFor ? (
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
) : null}
{editing ? (
<PartnerDrawer
partner={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
/* Straight into staffing it, which is what onboarding a partner is
for. Falls back silently if the row has not landed yet — a missed
hand-off is a drawer that does not open, not a wrong one. */
onCreated={(partnerid) => {
const created = partners.data.find((p) => p.partnerid === partnerid);
if (created) setRidersFor(created);
}}
/>
) : null}
</VStack>
);
}
/** Filter pill button */
function FilterTab({
label,
count,
isActive,
onClick,
}: {
label: string;
count: number;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '5px 12px',
/* Matches the search box and the tab track it sits beside — see
`--radius-element` in `theme/nearle.ts`. */
borderRadius: 'var(--card-radius-sm, 10px)',
border: `1px solid ${isActive ? 'var(--color-brand, #662582)' : 'var(--color-line, #e0e4ea)'}`,
background: isActive ? 'var(--color-brand-tint, #f4eef8)' : '#ffffff',
color: isActive ? 'var(--color-brand, #662582)' : 'var(--color-ink-2, #414b5a)',
fontSize: 12.5,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
<span>{label}</span>
<span
style={{
padding: '1px 6px',
borderRadius: 999,
background: isActive ? 'var(--color-brand, #662582)' : 'var(--color-surface-sunken, #f2f4f7)',
color: isActive ? '#ffffff' : 'var(--color-ink-3, #657081)',
fontSize: 11,
fontWeight: 600,
}}
>
{count}
</span>
</button>
);
}
/* ── 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<FormState>(() =>
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<string | null>(null);
function set<K extends keyof FormState>(key: K) {
return (value: FormState[K]) => {
setError(null);
setForm((prev) => ({ ...prev, [key]: value }));
};
}
const [districtSearch, setDistrictSearch] = useState('');
const [district, setDistrict] = useState<string>(() => 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<number, string>();
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 (
<Drawer
title={isNew ? 'Onboard a rider partner' : form.partnername || 'Rider partner'}
subtitle={isNew ? 'The company that supplies riders' : `Partner ${partner?.partnerid}`}
width={520}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Onboard rider partner' : 'Save changes'}
variant="primary"
icon={<Truck size={15} />}
isDisabled={!isComplete || save.isPending}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={2}>
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
<TextInput
label="Partner name"
size="sm"
value={form.partnername}
onChange={set('partnername')}
placeholder="e.g. Xpress-Cbe-Main"
description="How the partner is named on rider records and in the assign picker."
/>
<TextInput
label="Registered company"
size="sm"
value={form.companyname}
onChange={set('companyname')}
/>
<TextInput
label="Primary contact"
size="sm"
value={form.primarycontact}
onChange={set('primarycontact')}
placeholder="9876543210"
description="One partner per number — the server refuses a second."
/>
<TextInput
label="Email"
size="sm"
value={form.primaryemail}
onChange={set('primaryemail')}
/>
<TextInput
label="Registration number"
size="sm"
value={form.registrationno}
onChange={set('registrationno')}
/>
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
District
</Text>
{/* 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. */}
<SearchInput
label="Search districts"
value={districtSearch}
onChange={setDistrictSearch}
placeholder="Search all 38 districts…"
width="full"
/>
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
{shown.map((option) => {
const running = isRunning(option);
const chosen = district === option.name;
return (
<button
key={option.name}
type="button"
role="option"
aria-selected={chosen}
className="district-row"
data-chosen={chosen ? 'yes' : 'no'}
onClick={() => {
setDistrict(option.name);
setForm((prev) => ({ ...prev, tenantid: 0, locationid: 0 }));
}}
>
<span className="district-row-name">{option.name}</span>
<span className="district-row-state" data-running={running ? 'yes' : 'no'}>
{running ? 'Running' : 'New district'}
</span>
</button>
);
})}
{shown.length === 0 ? (
<Text type="body" size="sm" color="secondary" style={{ padding: '10px 12px' }}>
No district matches “{districtSearch}”.
</Text>
) : null}
</div>
<Text type="body" size="xsm" color="secondary">
{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.'}
</Text>
</VStack>
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
Delivers for
</Text>
<Selector
label="Merchant"
size="sm"
value={form.tenantid ? String(form.tenantid) : ''}
onChange={(value) => {
setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 }));
}}
options={merchantOptions}
placeholder={merchants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
/>
<Selector
label="Branch"
size="sm"
value={form.locationid ? String(form.locationid) : ''}
onChange={(value) => 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'
}
/>
<Text type="body" size="xsm" color="secondary">
Optional. Set it and this partner’s riders become an option on that shop’s assign
screen, beside any riders it hired itself.
</Text>
</VStack>
<TextInput label="Address" size="sm" value={form.address} onChange={set('address')} />
<HStack gap={1}>
<TextInput label="Area" size="sm" value={form.suburb} onChange={set('suburb')} />
<TextInput label="City" size="sm" value={form.city} onChange={set('city')} />
</HStack>
<HStack gap={1}>
<TextInput label="State" size="sm" value={form.state} onChange={set('state')} />
<TextInput label="Postcode" size="sm" value={form.postcode} onChange={set('postcode')} />
</HStack>
</VStack>
</Drawer>
);
}

View File

@@ -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<string, unknown> {
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<BranchRow[]>(() => {
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<BranchRow | null>(null);
/** Open while the merchant's delivery partner is being changed. */
const [isPartnerOpen, setPartnerOpen] = useState(false);
const columns: TableColumn<BranchRow>[] = [
{
key: 'locationname',
header: 'Branch',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.locationname}
</Text>
<Text type="body" size="xsm" color="secondary">
{row.city || '—'}
</Text>
</VStack>
),
},
{
key: 'hours',
header: 'Hours',
width: { type: 'pixel', value: 130 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary">
{row.hours}
</Text>
),
},
{
key: 'radius',
header: 'Radius',
align: 'end',
width: { type: 'pixel', value: 90 },
renderCell: (row) => (
<Text type="body" size="sm" hasTabularNumbers>
{row.radius}
</Text>
),
},
{
key: 'orders',
header: 'Orders',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Text
type="label"
size="sm"
hasTabularNumbers
color={row.orders === 0 ? 'disabled' : 'primary'}
>
{row.orders}
</Text>
),
},
{
key: 'revenue',
header: 'Revenue',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Text
type="label"
size="sm"
hasTabularNumbers
color={row.revenue === 0 ? 'disabled' : 'primary'}
>
{money(row.revenue)}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
/* 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) => (
<Button
label="QR"
variant="ghost"
size="sm"
icon={<QrCode size={14} />}
onClick={() => setQrFor(row)}
/>
),
},
];
return (
<VStack gap={3}>
<PageHeader
title={tenant?.tenantname ?? 'Tenant'}
actions={
<HStack gap={1}>
<Button
label="Delivery partner"
variant="secondary"
icon={<Truck size={14} />}
onClick={() => setPartnerOpen(true)}
/>
<Button
label="Stock catalogue"
variant="secondary"
href={`/nearle/catalogue?tenantid=${tenantid ?? ''}`}
as={Link}
/>
</HStack>
}
/>
<div className="kpi-grid">
<KpiCard
label="Branches"
value={String(totals.branches)}
note={`${totals.active} active`}
tone="accent"
icon={<Store size={15} />}
fill={totals.branches ? totals.active / totals.branches : 0}
/>
<KpiCard
label="Orders"
value={String(totals.totalOrders)}
note="all statuses"
tone="neutral"
icon={<ShoppingCart size={15} />}
/>
<KpiCard
label="Revenue"
value={money(totals.totalRevenue)}
note="across every branch"
tone={totals.totalRevenue > 0 ? 'success' : 'neutral'}
icon={<IndianRupee size={15} />}
/>
<KpiCard
label="Cancelled"
value={String(totals.cancelled)}
note={
totals.totalOrders
? `${Math.round((totals.cancelled / totals.totalOrders) * 100)}% of orders`
: 'no orders yet'
}
tone={totals.cancelled > 0 ? 'error' : 'neutral'}
icon={<TriangleAlert size={15} />}
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
/>
{/* No "Catalogue — Linked" tile.
It read `tenant ? 'Linked' : '—'`, so it said Linked whenever a
tenant row came back at all — it described the tenant existing, not
the catalogue. `products/getimportedcatalogueproducts` could give a
real imported count here; until something calls it, a tile that is
always green is worse than no tile. */}
</div>
<VStack gap={1.5}>
<Panel
title="Branches"
count={paged.total}
countLabel="branches"
actions={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
footer={<TablePager paged={paged} label="branches" />}
>
<DataState
isLoading={isLoading}
error={error}
isEmpty={rows.length === 0}
emptyTitle="No branches yet"
/* No CTA here: this tenant's own Administrator commissions their
outlets. Naming who acts is more useful than a button that would
take an action out of their hands. */
emptyDescription="This tenant's Administrator opens their outlets from their own workspace."
>
{/* Columns carry meaning, so the table scrolls sideways rather
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<BranchRow>
data={paged.rows}
columns={columns}
idKey="locationid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</DataState>
</Panel>
</VStack>
{isPartnerOpen && tenantid ? (
<PartnerAssignDrawer
tenantid={tenantid}
tenantname={tenant?.tenantname ?? 'this merchant'}
currentPartnerId={Number((tenant as unknown as Record<string, number>)?.['partnerid'] ?? 0)}
onClose={() => setPartnerOpen(false)}
/>
) : null}
{qrFor && tenantid ? (
<Drawer
title="Store QR code"
subtitle={qrFor.locationname}
width={400}
onClose={() => setQrFor(null)}
>
<StoreQrPanel
tenantid={tenantid}
locationid={qrFor.locationid}
locationname={qrFor.locationname}
{...(qrFor.city ? { where: String(qrFor.city) } : {})}
/>
</Drawer>
) : null}
</VStack>
);
}

View File

@@ -1,454 +0,0 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Building2, Plus, Store, Users } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { DataState } from '@/components/DataState';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { Tab, TabBar } from '@/components/TabBar';
import { Panel } from '@/components/Panel';
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types';
/** A tenant, with its branches folded in. */
interface TenantRow extends Record<string, unknown> {
tenantid: number;
tenantname: string;
companyname: string;
city: string;
branches: number;
status: string;
primaryemail: string;
}
/**
* The Nearle Admin's home: every tenant on the platform, and how many branches
* sit under each.
*
* ── The branch count comes from the server ──────────────────────────────────
*
* This file used to say `getalltenants` returns one row per tenant-location
* pair, and counted repeated tenantids to get the number of branches. That was
* never true: the query is `SELECT … FROM tenants`, with no join to
* tenantlocations anywhere in it, so a tenantid has never appeared twice — and
* the count was therefore 1 for every merchant on the platform, however many
* shops they ran. The "Branches" and "Avg branches" tiles above this list were
* the tenant count under two other names, and a tenant's own detail page
* contradicted the row that opened it.
*
* The endpoint now returns `branchcount` and this reads it. The grouping by
* tenantid is kept: it costs nothing, and it is the one thing standing between
* a future join on that query and six rows for a six-branch merchant.
*/
type Tab = 'directory' | 'pending';
/** Rows per page. One more than this is fetched, to know whether there is a next. */
const PAGE_SIZE = 50;
export function StoresPage() {
const [tab, setTab] = useState<Tab>('directory');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
/**
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
* and has no total, so paging is "ask for one more than we show and see if it
* comes back". A platform list read whole is fine at twenty tenants and not
* at two thousand.
*/
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
/**
* The queue of merchants awaiting approval.
*
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
* `getalltenants` at all, so without this they are invisible. Nothing here
* can approve one — `approved` is writable only at creation — so this lists
* and says so.
*/
const pending = useTenantsByApproval('pending');
const rows = useMemo<TenantRow[]>(() => {
if (!data) return [];
const grouped = new Map<number, TenantRow>();
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
const existing = grouped.get(tenant.tenantid);
if (existing) {
// Only reachable if the endpoint ever starts joining locations. Then
// the rows ARE per-pair and counting them is right again.
existing.branches += 1;
continue;
}
grouped.set(tenant.tenantid, {
tenantid: tenant.tenantid,
tenantname: tenant.tenantname,
companyname: tenant.companyname ?? '',
city: tenant.city ?? '',
// `?? 1` rather than `?? 0`: against a backend that does not send the
// field yet, one branch is the safer guess — every merchant has at
// least the outlet they were onboarded with, and showing 0 next to a
// shop that plainly exists reads as broken rather than as unknown.
branches: tenant.branchcount ?? 1,
status: tenant.status ?? 'Unknown',
primaryemail: tenant.primaryemail ?? '',
});
}
const all = [...grouped.values()];
const term = search.trim().toLowerCase();
if (!term) return all;
return all.filter(
(row) =>
row.tenantname.toLowerCase().includes(term) ||
row.companyname.toLowerCase().includes(term) ||
row.city.toLowerCase().includes(term),
);
}, [data, search]);
const totals = useMemo(() => {
const tenants = rows.length;
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
return { tenants, branches, active };
}, [rows]);
const columns: TableColumn<TenantRow>[] = [
{
key: 'tenantname',
header: 'Tenant',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.tenantname}
</Text>
<Text type="body" size="xsm" color="secondary">
{row.companyname || '—'}
</Text>
</VStack>
),
},
{
key: 'city',
header: 'City',
width: { type: 'proportional', value: 1.5 },
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
},
{
key: 'branches',
header: 'Branches',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
{row.branches}
</Text>
),
},
{
key: 'primaryemail',
header: 'Primary admin',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="xsm" color="secondary">
{row.primaryemail || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
key: 'actions',
header: '',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Link
to={`/nearle/stores/${row.tenantid}`}
style={{
color: 'var(--color-brand)',
fontWeight: 600,
fontSize: 13,
textDecoration: 'none',
}}
>
Open →
</Link>
),
},
];
return (
<VStack gap={3}>
{/* No `isLive` here. Both of this page's reads — `useTenants` and
`useTenantsByApproval` — use the `stable` query options: a 5-minute
staleTime and no refetchInterval. The pill claimed a freshness the page
does not have. The pages that keep it (Console, Sales, Counters, store
detail) poll on a real interval. */}
<PageHeader
title="Stores"
actions={
<Button
label="Onboard tenant"
variant="primary"
icon={<Plus size={15} />}
href="/nearle/onboard/tenant"
as={Link}
/>
}
/>
<div className="kpi-grid">
<KpiCard
label="Tenants"
value={String(totals.tenants)}
note={`${totals.active} active`}
tone="accent"
icon={<Building2 size={15} />}
fill={totals.tenants ? totals.active / totals.tenants : 0}
/>
<KpiCard
label="Branches"
value={String(totals.branches)}
note="across the network"
tone="neutral"
icon={<Store size={15} />}
/>
<KpiCard
label="Avg branches"
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
note="per tenant"
tone="neutral"
icon={<Users size={15} />}
/>
</div>
<TabBar>
<Tab
label="Directory"
isActive={tab === 'directory'}
onClick={() => setTab('directory')}
/>
<Tab
label="Awaiting approval"
isActive={tab === 'pending'}
count={pending.data?.length || undefined}
onClick={() => setTab('pending')}
/>
</TabBar>
{tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : (
/* The title, the count and the search are the panel's header band now.
The search filters THIS table; floating above the card it read as a
page-level control that happened to sit nearby, and the count was a
note beside a heading rather than the figure you check a filter
against. */
<Panel
footer={
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
}
title="Tenant directory"
count={rows.length}
countLabel="tenants"
actions={
<SearchInput
label="Search tenants"
value={search}
onChange={setSearch}
placeholder="Name, company or city…"
width={260}
/>
}
>
<DataState
isLoading={isLoading}
error={error}
isEmpty={rows.length === 0}
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
emptyDescription={
search
? 'Try a different name, company or city.'
: 'Onboard the first merchant group to get started.'
}
emptyAction={
search ? undefined : (
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
)
}
>
{/* Columns carry meaning, so the table scrolls sideways rather
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<TenantRow>
data={rows}
columns={columns}
idKey="tenantid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</DataState>
</Panel>
)}
</VStack>
);
}
/**
* Merchants that have been created but never approved.
*
* Read-only, and it says why on the panel rather than offering a button that
* cannot work: `tenants.approved` is written once, at creation, and there is no
* update or approve endpoint anywhere in the backend. Until there is, this is
* the only place these rows are visible at all — `getalltenants` cannot see
* them.
*/
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) {
return (
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the approval queue…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0}>
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every merchant on the platform has been approved.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
These merchants exist but are not approved, so they do not appear in the directory.
Approving is a database change today — the backend has no endpoint for it.
</Text>
<Card padding={0}>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Merchant</Th>
<Th>Company</Th>
<Th>Contact</Th>
<Th>City</Th>
<Th>Tenant id</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.tenantid}-${row.locationid}`}>
<Td isStrong>{row.tenantname}</Td>
<Td isMuted>{row.companyname || '—'}</Td>
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
<Td isMuted>{row.city || '—'}</Td>
<Td isMuted>{row.tenantid}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
/* The page-local `TabButton` lived here — see `components/TabBar`. */
function Th({ children }: { children?: React.ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
children: React.ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
}}
>
{children}
</td>
);
}

View File

@@ -1,39 +0,0 @@
import { useDateScope } from '@/components/shell/DateScope';
import { useNavigate } from 'react-router-dom';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
/**
* Every spreadsheet on the platform, whoever sent it.
*
* No tenant scope, which is the whole difference from the two store versions:
* the Nearle Admin is who chases the catalogue team when a drop sits unreviewed,
* and they cannot do that from one merchant at a time. The panel shows the
* merchant name on each row when it is not given one.
*/
export function UploadsPage() {
const navigate = useNavigate();
/*
No date filter by default, unlike the reporting pages.
This is a receipt log, and the reason somebody opens it is to find an upload
from a while ago. Starting at month-to-date hid every receipt older than the
1st — the list read "Nothing matches" while the tab beside it said there were
two. The read already returns only the most recent fifty, so the range is a
narrowing rather than the thing that makes the page tractable.
*/
const dates = useDateScope();
return (
<VStack gap={3}>
<PageHeader
title="Uploads"
/>
{/* A platform operator's upload writes the GLOBAL catalogue, which is a
different action from a merchant sending their own list — so this goes
to the page that owns it rather than opening a merchant's drawer. */}
<UploadsPanel range={dates.range} onClearRange={dates.clear} onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
</VStack>
);
}

View File

@@ -1,293 +0,0 @@
/**
* The fleet page: a banner, a map beside a gantt, and the gantt itself.
*/
/* The console's small control: a branch picker, a rider-partner picker and the
Shifts day input all wear this.
Its own shape before was 32px at a 7px corner — a seventh radius in an app
that has one. It takes the search box's measurements now, which matters most
on Inventory, where a `.fleet-select` branch picker sits immediately beside a
`SearchInput`: the two were a different height AND a different corner, six
pixels apart. */
.fleet-select {
height: 38px;
padding: 0 10px;
border: var(--card-border);
border-radius: var(--card-radius-sm);
background: var(--card-bg);
box-shadow: var(--card-shadow);
font: inherit;
font-size: 13px;
color: var(--color-ink-1);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.fleet-select:hover { border-color: var(--color-ink-4); }
/* The same focus treatment as the search box — a brand border and a 3px tint
ring, not the global 2px outline, so two controls sitting side by side do
not light up in different ways. */
.fleet-select:focus,
.fleet-select:focus-visible {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px var(--color-brand-tint);
}
/* ── The recommendation banner ───────────────────────────────────────────── */
/* Severity is carried by the left rail and the icon, not by a wash of colour
across the whole strip — a full red panel reads as an error the console has
hit, rather than a finding about the fleet. */
.fleet-banner {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-left-width: 3px;
border-radius: var(--card-radius);
background: var(--card-bg);
}
.fleet-banner > svg {
flex: none;
margin-top: 1px;
}
.fleet-banner div {
display: flex;
flex-direction: column;
gap: 2px;
}
.fleet-banner strong {
font-size: 13.5px;
font-weight: 600;
color: var(--color-ink-1);
}
.fleet-banner span {
font-size: 12.5px;
line-height: 1.5;
color: var(--color-ink-3);
}
.fleet-banner[data-severity='good'] {
border-left-color: #0f8a5f;
color: #0f8a5f;
}
.fleet-banner[data-severity='watch'] {
border-left-color: #b45309;
color: #b45309;
}
.fleet-banner[data-severity='act'] {
border-left-color: #b91c1c;
color: #b91c1c;
}
/* ── Map beside gantt ────────────────────────────────────────────────────── */
.fleet-body {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@media (max-width: 1100px) {
.fleet-body {
grid-template-columns: minmax(0, 1fr);
}
}
/* ── The gantt ───────────────────────────────────────────────────────────── */
.gantt {
display: flex;
flex-direction: column;
gap: 2px;
}
/* The track is the flexible column; the name and the three figures are fixed,
so every row's bars start and end at the same x and the day lines up down the
list. A shrink-to-fit name column would stagger them by rider. */
.gantt-row {
display: grid;
grid-template-columns: 132px minmax(0, 1fr) 62px 34px 54px;
gap: 10px;
align-items: center;
width: 100%;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 7px;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.gantt-row:hover {
background: var(--color-surface-subtle);
}
.gantt-row[data-active='true'] {
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
.gantt-row:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
.gantt-name {
display: flex;
gap: 7px;
align-items: center;
overflow: hidden;
font-size: 12.5px;
color: var(--color-ink-1);
text-overflow: ellipsis;
white-space: nowrap;
}
/* The same colour as this rider's line on the map — the only thing tying the
two panels together. */
.gantt-name i {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
}
.gantt-track {
position: relative;
height: 16px;
border-radius: 4px;
background: var(--color-surface-sunken);
}
.gantt-bar {
position: absolute;
top: 3px;
height: 10px;
border-radius: 3px;
}
.gantt-figure {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-1);
text-align: right;
}
.gantt-quiet {
color: var(--color-ink-3);
}
.gantt-figure em {
color: var(--color-ink-4);
font-style: normal;
}
.gantt-axis {
display: flex;
justify-content: space-between;
/* Aligned to the track column, so the two clock readings sit under the ends
of the bars they label rather than under the whole row. */
padding: 4px 160px 0 150px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-4);
}
.fleet-note-icon {
display: inline-flex;
color: var(--color-ink-4);
}
/* ── One rider's runs, opened from the gantt ─────────────────────────────── */
.shift-detail {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-subtle);
}
.shift-detail strong {
font-size: 12.5px;
color: var(--color-ink-1);
}
.shift-detail > span {
font-size: 12px;
color: var(--color-ink-3);
}
.shift-detail ul {
display: flex;
flex-direction: column;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
}
.shift-detail li {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-2);
}
.shift-detail em {
font-style: normal;
font-weight: 600;
color: var(--color-ink-1);
}
/* A long silence inside a run means the shift is stitched across it, so the
total above is an upper bound. Said on the row rather than in a footnote. */
.shift-gap {
color: #b45309;
}
.fleet-inline-note {
display: flex;
gap: 7px;
align-items: flex-start;
padding: 8px 10px;
border-radius: 7px;
background: var(--color-surface-subtle);
font-size: 11.5px;
line-height: 1.5;
color: var(--color-ink-3);
}
.fleet-inline-note svg {
flex: none;
margin-top: 2px;
}
/* ── The dispatch page's two view tabs ─────────────────────────────────────
`.nearle-view-tabs` was the flex row that held them. Both the row and the
buttons are gone: the page renders `components/TabBar`, which is its own
track.
The intent behind the old comment here was right — it borrowed `.sbt` from
the store board's `dispatch.css` so "the platform's tabs and the shops' tabs
are the same control rather than two that happen to look alike". It just
reached across a feature boundary to get there, and `.sbt` was itself one of
seven tab treatments. Both boards import the same component now, which is
what that comment was trying to achieve. */

View File

@@ -1,251 +0,0 @@
/**
* Presence, and the line between a reading and a guess.
*
* The refusals matter most: `riderlogs` really does return days with a handful
* of pings in them, and "0h 2m online" derived from two heartbeats would be
* read as a fact about somebody's working day.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { duration, fleetDay, groupPings, recommend, riderDay, type RiderPing } from './riderShifts';
const DAY = Date.parse('2026-08-14T09:00:00+05:30');
/**
* Pings every 20 seconds — the live cadence (median gap 19 s, p90 30 s).
*
* The position is fixed on purpose: that is what production sends. One rider's
* 2,404 pings on 14 August all read 11.052998, 76.929958.
*/
function heartbeat(opts: {
from: number;
count: number;
userid?: number;
username?: string;
lat?: string;
lng?: string;
}): RiderPing[] {
return Array.from({ length: opts.count }, (_, i) => ({
userid: opts.userid ?? 7,
username: opts.username ?? 'Murali P',
logdate: new Date(opts.from + i * 20_000).toISOString(),
latitude: opts.lat ?? '11.052998',
longitude: opts.lng ?? '76.929958',
}));
}
/* ── Grouping ────────────────────────────────────────────────────────────── */
test('pings are grouped per rider and put in time order', () => {
const grouped = groupPings([
{ userid: 2, logdate: '2026-08-14T09:00:30+05:30' },
{ userid: 1, logdate: '2026-08-14T09:00:20+05:30' },
{ userid: 1, logdate: '2026-08-14T09:00:10+05:30' },
]);
assert.equal(grouped.size, 2);
assert.deepEqual(
grouped.get(1)!.map((ping) => ping.logdate),
['2026-08-14T09:00:10+05:30', '2026-08-14T09:00:20+05:30'],
);
});
test('a row with an unreadable date is skipped rather than sorted to the epoch', () => {
const grouped = groupPings([
{ userid: 1, logdate: '2026-08-14T09:00:00+05:30' },
{ userid: 1, logdate: 'not a date' },
]);
assert.equal(grouped.get(1)!.length, 1);
});
/* ── One rider's day ─────────────────────────────────────────────────────── */
test('a continuous run of heartbeats is one shift', () => {
const day = riderDay(7, 'Murali P', heartbeat({ from: DAY, count: 200 }));
assert.equal(day.shifts.length, 1);
// 199 gaps of 20 s.
assert.ok(Math.abs((day.onlineMs as number) - 199 * 20_000) < 1000);
});
// Forty minutes of silence is the app closed or the rider gone home. Counting
// it as time online would inflate every figure on the page.
test('a long silence splits the day and is not counted as time online', () => {
const day = riderDay(7, 'Murali P', [
...heartbeat({ from: DAY, count: 100 }),
...heartbeat({ from: DAY + 3 * 60 * 60 * 1000, count: 100 }),
]);
assert.equal(day.shifts.length, 2);
assert.ok(
(day.onlineMs as number) < 75 * 60 * 1000,
'the three-hour gap was counted as time online',
);
});
test('a ten-minute pause stays inside one shift', () => {
// The first run is 100 pings at 20 s, so it ends 33 minutes in; the second
// starts ten minutes after that.
const firstRunMs = 99 * 20_000;
const day = riderDay(7, 'M', [
...heartbeat({ from: DAY, count: 100 }),
...heartbeat({ from: DAY + firstRunMs + 10 * 60 * 1000, count: 100 }),
]);
assert.equal(day.shifts.length, 1);
assert.ok(day.shifts[0]!.longestGapMs > 8 * 60 * 1000, 'the pause is reported, not hidden');
});
test('opening the app once and closing it is not a shift', () => {
const day = riderDay(7, 'M', heartbeat({ from: DAY, count: 1 }));
assert.deepEqual(day.shifts, []);
assert.equal(day.onlineMs, null);
assert.equal(day.first, DAY, 'the ping still happened, and is still reported');
});
// The refusal that matters.
test('a day with four heartbeats states no hours', () => {
const day = riderDay(7, 'Murali P', heartbeat({ from: DAY, count: 4 }));
assert.equal(day.onlineMs, null);
assert.equal(day.coverage.isSound, false);
assert.equal(day.coverage.pings, 4);
assert.equal(day.shifts.length, 1, 'the run is still drawn, it just carries no figure');
});
test('no pings at all is an empty day rather than a throw', () => {
const day = riderDay(7, 'Murali P', []);
assert.deepEqual(day.shifts, []);
assert.equal(day.onlineMs, null);
assert.equal(day.first, null);
assert.equal(day.place, null);
});
/* ── The one position ────────────────────────────────────────────────────── */
// Not a trail. The whole module exists because this value never changes.
test("the rider's single reported position is read from the pings", () => {
const day = riderDay(7, 'M', heartbeat({ from: DAY, count: 50 }));
assert.deepEqual(day.place, { lat: 11.052998, lng: 76.929958 });
});
// 0,0 is the Atlantic and is what a phone sends before it has a fix. One of
// these becomes a map centred on the Gulf of Guinea.
test('a 0,0 fix is not taken as a place', () => {
const day = riderDay(7, 'M', [
...heartbeat({ from: DAY, count: 10 }),
...heartbeat({ from: DAY + 200_000, count: 5, lat: '0', lng: '0' }),
]);
assert.deepEqual(day.place, { lat: 11.052998, lng: 76.929958 });
});
test('a rider whose app never sent a position has none, rather than a zero', () => {
const day = riderDay(7, 'M', [
{ userid: 7, logdate: new Date(DAY).toISOString() },
{ userid: 7, logdate: new Date(DAY + 20_000).toISOString() },
]);
assert.equal(day.place, null);
});
/* ── The fleet ───────────────────────────────────────────────────────────── */
test('the fleet is every rider in the batch, longest day first', () => {
const day = fleetDay([
...heartbeat({ from: DAY, count: 60, userid: 9, username: 'Varun Edward' }),
...heartbeat({ from: DAY, count: 300, userid: 7, username: 'Murali P' }),
]);
assert.deepEqual(day.riders.map((rider) => rider.username), ['Murali P', 'Varun Edward']);
assert.equal(day.measured, 2);
});
test('a rider with too few check-ins is listed but left out of the totals', () => {
const day = fleetDay([
...heartbeat({ from: DAY, count: 300, userid: 7, username: 'A' }),
...heartbeat({ from: DAY, count: 3, userid: 9, username: 'B' }),
]);
assert.equal(day.riders.length, 2, 'a thin rider still belongs on the list');
assert.equal(day.measured, 1);
assert.ok(Math.abs(day.onlineMs - 299 * 20_000) < 1000, "B's three pings were added in");
});
test('the window spans every rider, for a shared gantt axis', () => {
const day = fleetDay([
...heartbeat({ from: DAY, count: 100, userid: 7 }),
...heartbeat({ from: DAY + 60 * 60 * 1000, count: 100, userid: 9 }),
]);
assert.equal(day.window!.from, DAY);
assert.ok(day.window!.to > DAY + 60 * 60 * 1000);
});
test('the longest day is reported, not just the total', () => {
const day = fleetDay([
...heartbeat({ from: DAY, count: 300, userid: 7 }),
...heartbeat({ from: DAY, count: 100, userid: 9 }),
]);
assert.ok(Math.abs(day.longestMs - 299 * 20_000) < 1000);
});
test('an empty batch gives an empty fleet with no window', () => {
const day = fleetDay([]);
assert.deepEqual(day.riders, []);
assert.equal(day.window, null);
assert.equal(day.onlineMs, 0);
});
/* ── The recommendation ──────────────────────────────────────────────────── */
test('nobody reporting in is stated as ambiguous, not as an idle fleet', () => {
const advice = recommend(fleetDay([]));
assert.match(advice.headline, /Nobody was online/);
assert.match(advice.detail, /app was not opened/);
});
test('thin coverage outranks any finding drawn from it', () => {
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 3 })));
assert.equal(advice.severity, 'watch');
assert.match(advice.headline, /Too few check-ins/);
});
test('an ordinary shift length is graded good', () => {
// Eight hours of heartbeats at one every 20 s.
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 1440 })));
assert.equal(advice.severity, 'good');
assert.match(advice.headline, /8h/);
});
test('a fifteen-hour day is flagged', () => {
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 2700 })));
assert.equal(advice.severity, 'act');
assert.match(advice.detail, /long day to be on call/);
});
test('very short days are flagged as either short shifts or a closed app', () => {
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 200 })));
assert.equal(advice.severity, 'watch');
assert.match(advice.detail, /closed between jobs/);
});
// The claim this module must never make. The position never changes, so a rider
// parked all day and one who crossed the city are indistinguishable here.
test('nothing in the advice claims to know how hard anybody worked', () => {
for (const count of [0, 3, 200, 1440, 2700]) {
const advice = recommend(fleetDay(heartbeat({ from: DAY, count })));
const text = `${advice.headline} ${advice.detail}`.toLowerCase();
for (const word of ['moving', 'idle', 'utilis', 'km', 'distance', 'productiv']) {
assert.ok(!text.includes(word), `advice for ${count} pings claimed "${word}": ${text}`);
}
}
});
test('riders left out for thin coverage are named in the advice', () => {
const advice = recommend(
fleetDay([
...heartbeat({ from: DAY, count: 1440, userid: 7, username: 'A' }),
...heartbeat({ from: DAY, count: 4, userid: 9, username: 'B' }),
]),
);
assert.match(advice.detail, /checked in too rarely/);
});
test('durations read the way a person says them', () => {
assert.equal(duration(null), '—');
assert.equal(duration(0), '—');
assert.equal(duration(45 * 60 * 1000), '45m');
assert.equal(duration(2 * 60 * 60 * 1000), '2h');
assert.equal(duration(2 * 60 * 60 * 1000 + 20 * 60 * 1000), '2h 20m');
});

View File

@@ -1,324 +0,0 @@
/**
* When a partner's riders were logged in.
*
* ── What `riderlogs` actually contains ──────────────────────────────────────
*
* It looks like a GPS trail and is not one. Every row carries a latitude and a
* longitude, and every row for a given rider carries the SAME latitude and
* longitude. Measured on 2026-09-09 across partner 44 and regions 1, 2 and 23,
* on four separate days:
*
* 14 Aug 12,976 pings, 5 riders — distinct positions per rider: 1, 1, 1, 1, 1
* 05 Aug 12,514 pings, 5 riders — 1, 1, 1, 1, 1
* 20 Aug 13,595 pings, 6 riders — 1, 1, 1, 1, 1, 1
* 28 Aug 8,948 pings, 4 riders — 1, 1, 1, 1
*
* One rider sent 2,404 pings on 14 August, all reading 11.052998, 76.929958.
* The app stamps a position once and repeats it on every heartbeat. So there is
* no trail to draw, no distance to total and no "moving versus idle" to compute
* — anything of that shape derived from this table would be invented.
*
* The rider positions that DO move live on the `deliveries` rows, written when
* a rider moves a job along; see `deliveryTrack`.
*
* ── What is real here, and worth having ─────────────────────────────────────
*
* The timestamps. A ping means the app was open and the rider was reachable, so
* the pings say when somebody was on and for how long — which is the one thing
* a platform operator cannot get anywhere else, and which the columns built to
* answer it (`login`, `logout`, `workhours`, `shorthours`) never carry: they are
* empty or zero on every row of all 320,132 read for August.
*
* So a shift is inferred: a run of pings with no long silence in it. That is a
* weaker claim than a clock-in, and everything below is shaped around saying so
* rather than rounding it up to a timesheet.
*/
/** One ping as `getriderlogs` returns it. */
export interface RiderPing {
userid: number;
username?: string;
logdate: string;
latitude?: string;
longitude?: string;
}
/**
* Silence that ends a shift.
*
* The app pings every twenty seconds or so — median gap 19 s, p90 30 s, measured
* over one rider's 2,404 pings. Forty minutes with nothing is the app closed,
* the phone out of signal, or the rider gone home; all three mean the same thing
* here. Set long deliberately: too short and a rider waiting out a quiet hour
* gets their day chopped into a dozen shifts, too long and two days run
* together. Forty minutes survives a long stop and never bridges a night.
*/
const SHIFT_GAP_MS = 40 * 60 * 1000;
/** Fewer pings than this in a day and the shift is a guess, not a reading. */
const MIN_PINGS_FOR_A_FIGURE = 30;
/** One unbroken run of pings. */
export interface Shift {
from: number;
to: number;
pings: number;
/** The longest silence inside it. A big one means the run is stitched. */
longestGapMs: number;
}
/** How much evidence stands behind a rider's day. */
export interface Coverage {
pings: number;
/**
* The longest silence anywhere in the day, INCLUDING the gaps between shifts.
* Distinct from `Shift.longestGapMs`, which is the longest silence inside one
* run: a rider with two shifts three hours apart has a three-hour figure here
* and a small one on each shift, and both readings are correct.
*/
longestGapMs: number;
/** False when there is too little data to state an hours figure at all. */
isSound: boolean;
}
/** The single position this rider's app has ever reported. */
export interface RiderPlace {
lat: number;
lng: number;
}
export interface RiderDay {
userid: number;
username: string;
shifts: Shift[];
/** Sum of shift spans. Blank when coverage is too thin to state one. */
onlineMs: number | null;
/** First ping of the day, and last. What the gantt draws between. */
first: number | null;
last: number | null;
coverage: Coverage;
/**
* Where the app says this rider is — one fixed point, not a position.
* Null when the app never sent a usable one.
*/
place: RiderPlace | null;
}
export interface FleetDay {
riders: RiderDay[];
/** Riders with enough pings to state hours for. The rest are listed, not timed. */
measured: number;
/** Total hours online across the measured riders. */
onlineMs: number;
/** The longest anyone was online. The busiest day on the page. */
longestMs: number;
/** Earliest and latest ping across everyone — the gantt's axis. */
window: { from: number; to: number } | null;
}
/** Pings grouped by rider, in time order, with unusable rows dropped. */
export function groupPings(logs: readonly RiderPing[]): Map<number, RiderPing[]> {
const byRider = new Map<number, RiderPing[]>();
for (const ping of logs) {
if (!Number.isFinite(Date.parse(ping.logdate))) continue;
const list = byRider.get(ping.userid);
if (list) list.push(ping);
else byRider.set(ping.userid, [ping]);
}
for (const list of byRider.values()) {
list.sort((a, b) => Date.parse(a.logdate) - Date.parse(b.logdate));
}
return byRider;
}
/**
* The one position a rider's app reports.
*
* Taken from the last ping that carries a usable pair. 0,0 is the Atlantic and
* is what a phone sends before it has a fix — the single most common way a map
* ends up centred on the Gulf of Guinea.
*/
function placeOf(pings: readonly RiderPing[]): RiderPlace | null {
for (let i = pings.length - 1; i >= 0; i -= 1) {
const lat = Number(pings[i]?.latitude);
const lng = Number(pings[i]?.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
return { lat, lng };
}
}
return null;
}
/** One rider's day, from their pings. */
export function riderDay(userid: number, username: string, pings: readonly RiderPing[]): RiderDay {
const times = pings
.map((ping) => Date.parse(ping.logdate))
.filter((at) => Number.isFinite(at));
if (times.length === 0) {
return {
userid,
username,
shifts: [],
onlineMs: null,
first: null,
last: null,
coverage: { pings: 0, longestGapMs: 0, isSound: false },
place: placeOf(pings),
};
}
const shifts: Shift[] = [];
let start = times[0] as number;
let previous = start;
let count = 1;
let longestInShift = 0;
let longestOverall = 0;
const close = () => {
// A shift of one ping has no duration and nothing to draw — somebody
// opening the app and closing it. Counting it would add a zero-length shift
// to the day and a sliver to the gantt.
if (count > 1) {
shifts.push({ from: start, to: previous, pings: count, longestGapMs: longestInShift });
}
};
for (let i = 1; i < times.length; i += 1) {
const at = times[i] as number;
const gap = at - previous;
if (gap > longestOverall) longestOverall = gap;
if (gap > SHIFT_GAP_MS) {
close();
start = at;
count = 1;
longestInShift = 0;
} else {
count += 1;
if (gap > longestInShift) longestInShift = gap;
}
previous = at;
}
close();
const onlineMs = shifts.reduce((total, shift) => total + (shift.to - shift.from), 0);
const isSound = times.length >= MIN_PINGS_FOR_A_FIGURE && onlineMs > 0;
return {
userid,
username,
shifts,
onlineMs: isSound ? onlineMs : null,
first: times[0] as number,
last: times[times.length - 1] as number,
coverage: { pings: times.length, longestGapMs: longestOverall, isSound },
place: placeOf(pings),
};
}
/** The whole fleet's day, from one batch of `getriderlogs` rows. */
export function fleetDay(logs: readonly RiderPing[]): FleetDay {
const grouped = groupPings(logs);
const names = new Map<number, string>();
for (const ping of logs) {
if (ping.username && !names.has(ping.userid)) names.set(ping.userid, ping.username);
}
const riders = [...grouped.entries()]
.map(([userid, pings]) => riderDay(userid, names.get(userid) ?? `Rider ${userid}`, pings))
.sort((a, b) => (b.onlineMs ?? 0) - (a.onlineMs ?? 0));
const sound = riders.filter((rider) => rider.coverage.isSound);
let from = Infinity;
let to = -Infinity;
for (const rider of riders) {
if (rider.first !== null && rider.first < from) from = rider.first;
if (rider.last !== null && rider.last > to) to = rider.last;
}
return {
riders,
measured: sound.length,
onlineMs: sound.reduce((total, rider) => total + (rider.onlineMs ?? 0), 0),
longestMs: sound.reduce((most, rider) => Math.max(most, rider.onlineMs ?? 0), 0),
window: Number.isFinite(from) ? { from, to } : null,
};
}
export type Severity = 'good' | 'watch' | 'act';
export interface Recommendation {
severity: Severity;
headline: string;
detail: string;
}
/**
* What the day suggests, graded.
*
* Everything stated here is about PRESENCE — who was on and for how long. It is
* deliberately silent about how hard anybody worked, because this data cannot
* tell: the position never changes, so a rider parked all day and a rider who
* crossed the city look identical from here.
*
* Thin coverage outranks every other finding: hours drawn from four pings are
* worse than no hours, because they will be believed.
*/
export function recommend(day: FleetDay): Recommendation {
if (day.riders.length === 0) {
return {
severity: 'watch',
headline: 'Nobody was online on this day',
detail:
'No rider app reported in. Either no shifts ran, or the app was not opened — the two look identical from here.',
};
}
if (day.measured === 0) {
return {
severity: 'watch',
headline: 'Too few check-ins to state hours',
detail: `${day.riders.length} rider${day.riders.length === 1 ? '' : 's'} reported in, but none often enough to measure a shift. Hours are left blank rather than guessed.`,
};
}
const averageMs = day.onlineMs / day.measured;
const thin = day.riders.length - day.measured;
const thinNote =
thin > 0
? ` ${thin} of ${day.riders.length} checked in too rarely to be included.`
: '';
const who = `${day.measured} rider${day.measured === 1 ? '' : 's'}`;
if (averageMs > 13 * 60 * 60 * 1000) {
return {
severity: 'act',
headline: `${who} averaged ${duration(averageMs)} online`,
detail: `That is a very long day to be on call, and the longest was ${duration(day.longestMs)}. Worth checking against what the shifts are meant to be.${thinNote}`,
};
}
if (averageMs < 3 * 60 * 60 * 1000) {
return {
severity: 'watch',
headline: `${who} averaged only ${duration(averageMs)} online`,
detail: `Short days, or an app that is being closed between jobs. The order flow for the day says which is more likely.${thinNote}`,
};
}
return {
severity: 'good',
headline: `${who} averaged ${duration(averageMs)} online`,
detail: `Ordinary shift lengths, the longest being ${duration(day.longestMs)}.${thinNote}`,
};
}
/** "6h 20m" — the gantt's totals and the axis labels. */
export function duration(ms: number | null): string {
if (ms === null || ms <= 0) return '—';
const minutes = Math.round(ms / 60_000);
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
if (hours === 0) return `${rest}m`;
return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
}

View File

@@ -1,74 +0,0 @@
/**
* The district list, and the line between "not open yet" and "not a place".
*
* A partner placed in a district with no `app_location` row is a partner whose
* riders no query returns — `getriders` filters on that id and `CreateRider`
* refuses a region with no config. Every district can be chosen — picking one
* that is not running yet OPENS it, writing both rows server-side — so what
* these pin is that the list is complete and that the running ones carry the id
* they will be saved with.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
TAMIL_NADU_DISTRICTS,
districtOptions,
isRunning,
matchDistrict,
} from './tamilNaduDistricts';
const live = [
{ applocationid: 1, locationname: 'Coimbatore' },
{ applocationid: 2, locationname: 'Madurai' },
{ applocationid: 23, locationname: 'Nagercoil' },
];
test('all 38 districts are offered, not just the three that are running', () => {
assert.equal(TAMIL_NADU_DISTRICTS.length, 38);
assert.ok(districtOptions(live).length >= 38);
});
test('a running district carries the region id it will be saved with', () => {
const options = districtOptions(live);
const coimbatore = options.find((option) => option.name === 'Coimbatore');
assert.equal(coimbatore?.applocationid, 1);
assert.equal(isRunning(coimbatore!), true);
});
// A district Nearle does not run yet carries no id — the form sends its NAME
// instead and the server opens it. Reporting 0 is what makes the form say
// "Erode will be opened" rather than silently saving a partner into nothing.
test('a district Nearle does not run yet is offered, with no id yet', () => {
const erode = districtOptions(live).find((option) => option.name === 'Erode');
assert.ok(erode, 'Erode is a district and belongs in the list');
assert.equal(erode!.applocationid, 0);
assert.equal(isRunning(erode!), false);
});
// Nagercoil is the district's town; the district is Kanyakumari. Matching on
// name means it arrives as its own entry rather than being lost — which is the
// safe direction: a region somebody is already working must never disappear.
test('a running region whose name is not one of the 38 is still offered', () => {
const options = districtOptions(live);
const nagercoil = options.find((option) => option.name === 'Nagercoil');
assert.equal(nagercoil?.applocationid, 23);
});
test('matching is case-insensitive and matches anywhere in the name', () => {
const [erode] = districtOptions(live).filter((option) => option.name === 'Erode');
assert.equal(matchDistrict(erode!, 'ero'), true);
assert.equal(matchDistrict(erode!, 'ROD'), true);
assert.equal(matchDistrict(erode!, ''), true, 'an empty box hides nothing');
assert.equal(matchDistrict(erode!, 'salem'), false);
});
test('with no regions read yet, every district reads as new and none is lost', () => {
const options = districtOptions([]);
assert.equal(options.length, 38);
assert.equal(options.every((option) => !isRunning(option)), true);
});
test('the list is alphabetical, so a long list can be scanned', () => {
const names = districtOptions(live).map((option) => option.name);
assert.deepEqual(names, [...names].sort((a, b) => a.localeCompare(b)));
});

View File

@@ -1,133 +0,0 @@
/**
* The districts of Tamil Nadu, for the partner onboarding form.
*
* ── Why this is a list in the code ──────────────────────────────────────────
*
* The platform has no district master. `app_location` holds the districts
* Nearle actually RUNS — three of them when this was written: Coimbatore,
* Madurai and Nagercoil — each with a radius, opening hours and an image,
* because it is an operating record rather than a geography one.
*
* A picker built from `app_location` alone can therefore offer three names for
* a state with thirty-eight districts. This list is the geography; the regions
* read back from the API say which of them are already running.
*
* ── Every district can be chosen ────────────────────────────────────────────
*
* Choosing one that is not running yet OPENS it: the partner is sent with a
* district NAME and the server writes the `app_location` and
* `app_locationconfig` rows before creating the partner. Both rows matter —
* `getriders` joins the first and `CreateRider` refuses a region missing the
* second — so opening a district means both or neither.
*
* `isRunning` therefore marks what already exists, not what is permitted. It is
* shown so an operator can tell "this is where we work" from "this will be a
* new district", which is a real operational difference and not a restriction.
*/
/** All 38 districts, alphabetically. Names as the state government writes them. */
export const TAMIL_NADU_DISTRICTS = [
'Ariyalur',
'Chengalpattu',
'Chennai',
'Coimbatore',
'Cuddalore',
'Dharmapuri',
'Dindigul',
'Erode',
'Kallakurichi',
'Kanchipuram',
'Kanyakumari',
'Karur',
'Krishnagiri',
'Madurai',
'Mayiladuthurai',
'Nagapattinam',
'Namakkal',
'Nilgiris',
'Perambalur',
'Pudukkottai',
'Ramanathapuram',
'Ranipet',
'Salem',
'Sivaganga',
'Tenkasi',
'Thanjavur',
'Theni',
'Thoothukudi',
'Tiruchirappalli',
'Tirunelveli',
'Tirupathur',
'Tiruppur',
'Tiruvallur',
'Tiruvannamalai',
'Tiruvarur',
'Vellore',
'Viluppuram',
'Virudhunagar',
] as const;
export type TamilNaduDistrict = (typeof TAMIL_NADU_DISTRICTS)[number];
/** One district as the form sees it: its name, and whether it is running. */
export interface DistrictOption {
name: string;
/** The `app_location` id when the district already runs, 0 when it is new. */
applocationid: number;
}
/**
* The district list, matched against the regions the platform actually runs.
*
* Matched on NAME, case-insensitively, because that is the only thing the two
* sources share — `app_location` has no district column, and its `locationname`
* is the district's name ("Coimbatore", "Madurai", "Nagercoil").
*
* A serviced region whose name is not one of the 38 is still listed rather than
* dropped. Nearle opening a place this file has not heard of is a reason to
* update the file, not a reason to hide a region somebody is already working.
*/
export function districtOptions(
regions: readonly { applocationid: number; locationname?: string }[] = [],
): DistrictOption[] {
const serviced = new Map<string, number>();
for (const region of regions) {
const name = (region.locationname ?? '').trim();
if (name) serviced.set(name.toLowerCase(), region.applocationid);
}
const options: DistrictOption[] = TAMIL_NADU_DISTRICTS.map((name) => ({
name,
applocationid: serviced.get(name.toLowerCase()) ?? 0,
}));
// A running region whose name is not one of the 38 — Nagercoil is the town,
// the district is Kanyakumari — is still listed rather than dropped. A place
// somebody is already working must never disappear from the picker.
const known = new Set(TAMIL_NADU_DISTRICTS.map((name) => name.toLowerCase()));
for (const region of regions) {
const name = (region.locationname ?? '').trim();
if (name && !known.has(name.toLowerCase())) {
options.push({ name, applocationid: region.applocationid });
}
}
return options.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* True when Nearle already runs this district.
*
* Not a permission — every district can be chosen. This distinguishes "we work
* here" from "this will be opened", which the form says out loud because
* opening a district writes rows and is worth knowing before you press save.
*/
export function isRunning(option: DistrictOption): boolean {
return option.applocationid > 0;
}
/** Filter for the search box. Matches anywhere in the name, case-insensitively. */
export function matchDistrict(option: DistrictOption, term: string): boolean {
const needle = term.trim().toLowerCase();
return needle === '' || option.name.toLowerCase().includes(needle);
}

View File

@@ -1,5 +1,5 @@
import { Drawer } from './Drawer'; import { Drawer } from './Drawer';
import { SheetImportPanel } from '@/features/nearle-admin/import/SheetImportPanel'; import { SheetImportPanel } from '@/features/import/SheetImportPanel';
/** /**
* Upload a product spreadsheet, from the Uploads page. * Upload a product spreadsheet, from the Uploads page.

View File

@@ -37,7 +37,7 @@ import {
downloadTemplate, downloadTemplate,
parseProductSheet, parseProductSheet,
type ParsedSheet, type ParsedSheet,
} from '@/features/nearle-admin/import/parseProductSheet'; } from '@/features/import/parseProductSheet';
/** How the category was arrived at, in the operator's words. */ /** How the category was arrived at, in the operator's words. */
const RULE_LABEL: Record<CategoryRule, string> = { const RULE_LABEL: Record<CategoryRule, string> = {

View File

@@ -52,8 +52,8 @@ import { uploadsApi, type UploadReceipt } from '@/api/uploads';
import type { DateRange } from '@/api/insights'; import type { DateRange } from '@/api/insights';
import type { SheetProductRow } from '@/api/products'; import type { SheetProductRow } from '@/api/products';
import { readStoredRows } from './shelveRows'; import { readStoredRows } from './shelveRows';
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet'; import { parseProductSheet } from '@/features/import/parseProductSheet';
import { shelveBatch } from '@/features/nearle-admin/import/shelve'; import { shelveBatch } from '@/features/import/shelve';
import { SheetDropzone } from '@/components/SheetDropzone'; import { SheetDropzone } from '@/components/SheetDropzone';
import { Panel } from '@/components/Panel'; import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';

View File

@@ -21,7 +21,7 @@ import { customersApi, type CustomerQuery } from '@/api/customers';
import { telemetryApi, type RiderSnapshot } from '@/api/telemetry'; import { telemetryApi, type RiderSnapshot } from '@/api/telemetry';
import { uploadsApi } from '@/api/uploads'; import { uploadsApi } from '@/api/uploads';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { dedupePartners } from '@/features/nearle-admin/partnerList'; import { dedupePartners } from '@/queries/partnerList';
import { queryKeys } from './keys'; import { queryKeys } from './keys';
import { LIVE_REFETCH_MS } from './interval'; import { LIVE_REFETCH_MS } from './interval';