domain change
This commit is contained in:
18
Dockerfile
18
Dockerfile
@@ -68,16 +68,18 @@ ENV VITE_API_BASE=$VITE_API_BASE
|
||||
# it did. The platform build is the one that has to be asked for — the opposite
|
||||
# default would turn every environment that had not been told about this into a
|
||||
# platform console on the day it shipped.
|
||||
ARG VITE_WORKSPACE="merchant"
|
||||
ENV VITE_WORKSPACE=$VITE_WORKSPACE
|
||||
|
||||
# The other console's address, for the sentence shown to somebody in the wrong
|
||||
# place. Only the one this build is NOT is used; both are given so a single set
|
||||
# of build args works for either image.
|
||||
# There is no workspace flag in this image.
|
||||
#
|
||||
# One existed while a single codebase served both consoles and had to be told
|
||||
# which it was. Nearle's own staff now have their own application —
|
||||
# `nearle-platform`, its own repository and its own deploy — and this image is
|
||||
# the merchant console and nothing else.
|
||||
#
|
||||
# The platform console's address, for the sentence shown to a staff member who
|
||||
# signs in at the wrong site. A build argument rather than a constant because
|
||||
# the two are separate deployments and either can move.
|
||||
ARG VITE_PLATFORM_HOST="platform.nearledaily.com"
|
||||
ENV VITE_PLATFORM_HOST=$VITE_PLATFORM_HOST
|
||||
ARG VITE_MERCHANT_HOST="app.nearledaily.com"
|
||||
ENV VITE_MERCHANT_HOST=$VITE_MERCHANT_HOST
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
90
src/App.tsx
90
src/App.tsx
@@ -3,10 +3,8 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
import { RequireRole, useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import { IS_PLATFORM } from '@/auth/workspace';
|
||||
import { withStaleChunkRecovery } from '@/lib/staleChunk';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
|
||||
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
|
||||
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
||||
|
||||
@@ -29,15 +27,8 @@ import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
||||
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
|
||||
lazy(withStaleChunkRecovery(() => loader().then((module) => ({ default: module[key] }))));
|
||||
|
||||
const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pages/StoresPage'));
|
||||
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
|
||||
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
|
||||
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
|
||||
const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage'));
|
||||
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
|
||||
/* Lazy like the rest, and it matters more here: this page pulls in leaflet and
|
||||
its stylesheet, which nobody who never opens the fleet map should download. */
|
||||
const NearleDispatchPage = named('NearleDispatchPage', () => import('@/features/nearle-admin/pages/DispatchPage'));
|
||||
|
||||
/* One Console for both workspaces — it reads its own scope from BranchScope,
|
||||
which pins a store user to their outlet and lets an admin choose. Both routes
|
||||
@@ -89,71 +80,20 @@ export function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/*
|
||||
Each site mounts ONE workspace.
|
||||
{/* Nearle staff have their own application.
|
||||
|
||||
`platform.nearledaily.com` is Nearle's own staff; `app.nearledaily.com`
|
||||
is merchants and their branch users. The same build produces both, with
|
||||
`VITE_WORKSPACE` deciding which of the two blocks below exists.
|
||||
The platform workspace lived here until the two consoles were split.
|
||||
It is now `nearle-platform`, a separate repository deployed on its own
|
||||
domain, and this one serves merchants and their branch users only.
|
||||
|
||||
Unmounted, not guarded. A route that is not in the table cannot render
|
||||
whatever state the app is in, where a `RequireRole` around it is one
|
||||
redirect away from rendering if the role check is ever wrong. On this
|
||||
site `/admin/*` is not a protected path — it is not a path at all, and
|
||||
falls to the catch-all like any typo.
|
||||
|
||||
This does NOT remove the other workspace's code from the bundle, and it
|
||||
was written here once claiming that it did. The branch is evaluated at
|
||||
runtime: Rollup cannot fold `IS_PLATFORM` because it is computed through
|
||||
`workspace.ts` rather than being a literal in this file, so both blocks
|
||||
are compiled and only one is executed. The lazy chunks of the unmounted
|
||||
workspace are emitted and served, and never fetched, because nothing
|
||||
routes to them.
|
||||
|
||||
That is a code-shipping question rather than an access one — no account
|
||||
the other console owns can sign in here, which `login` and `restore`
|
||||
enforce — but the distinction is worth stating rather than implying.
|
||||
*/}
|
||||
|
||||
{/* Nearle Admin — the platform workspace */}
|
||||
{IS_PLATFORM ? (
|
||||
<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}
|
||||
`/nearle/*` is therefore not a protected path here — it is not a path
|
||||
at all, and falls to the catch-all like any other unknown URL. A
|
||||
`nearle-admin` account cannot sign in either: `login` and `restore`
|
||||
refuse it before a session is written. */}
|
||||
|
||||
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
|
||||
{!IS_PLATFORM ? (
|
||||
<>
|
||||
|
||||
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
@@ -216,13 +156,11 @@ export function App() {
|
||||
<Route path="setup" element={<StoreSetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
||||
</Route>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* `user` is only ever a role this build serves — `restore` and `login`
|
||||
both refuse the other console's accounts — so its home route is always
|
||||
one of the blocks mounted above, and this cannot bounce into a
|
||||
workspace that is not here. */}
|
||||
{/* `user` is only ever a merchant role — `login` and `restore` refuse a
|
||||
Nearle staff account before a session is written — so its home route
|
||||
is always one of the two workspaces above, and this cannot bounce
|
||||
somewhere that does not exist. */}
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}
|
||||
|
||||
@@ -103,7 +103,17 @@ export function toSessionUser(user: FiestaUser): SessionUser {
|
||||
* sub-path is absorbed there and never reaches the global one.
|
||||
*/
|
||||
export const HOME_ROUTE: Record<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-manager': '/store/console',
|
||||
};
|
||||
|
||||
@@ -1,108 +1,57 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { IS_PLATFORM, isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
|
||||
import { isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
|
||||
import type { ConsoleRole } from './roles';
|
||||
|
||||
/*
|
||||
Two consoles, one source.
|
||||
Who may sign in to the merchant console.
|
||||
|
||||
Nearle's own staff sign in at platform.nearledaily.com; merchants and their
|
||||
branch users at app.nearledaily.com. Neither site accepts the other's accounts,
|
||||
and the refusal is a refusal — not a redirect carrying a half-made session
|
||||
across a domain boundary.
|
||||
Merchants and their branch users sign in here. Nearle's own staff have their own
|
||||
application — `nearle-platform`, a separate repository with its own deploy — and
|
||||
a staff account is turned away rather than redirected: no session crosses to
|
||||
another site half-made.
|
||||
|
||||
These run against the DEFAULT build, which is `merchant`. That default is itself
|
||||
the thing most worth pinning: an unset flag is the ordinary state of a developer
|
||||
machine and of any deployment not yet told about the split, and the wrong
|
||||
default would turn every one of them into a platform console.
|
||||
The role is not known until the password has been checked, so the refusal
|
||||
happens after credentials are verified. What matters is that it happens BEFORE
|
||||
the session is written: `login` refuses first and persists second, and `restore`
|
||||
applies the same check to what is already in storage. A staff member who typed a
|
||||
correct password must not end up signed in here with no routes to reach.
|
||||
*/
|
||||
|
||||
const ROLES: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager'];
|
||||
|
||||
test('an unset flag builds the merchant console', () => {
|
||||
// Nothing sets VITE_WORKSPACE under the test runner, so this is the default
|
||||
// path — the same one every existing deployment takes until it is told
|
||||
// otherwise.
|
||||
test('this build is the merchant console, with nothing to configure', () => {
|
||||
// A constant, not a build flag. The flag existed only while one codebase
|
||||
// served both consoles; a variable now would be a way to deploy this
|
||||
// application as something it is not.
|
||||
assert.equal(WORKSPACE, 'merchant');
|
||||
assert.equal(IS_PLATFORM, false);
|
||||
});
|
||||
|
||||
test('the merchant console admits merchants and refuses Nearle staff', () => {
|
||||
test('both merchant roles are admitted and Nearle staff are not', () => {
|
||||
assert.equal(isAllowedHere('store-admin'), true);
|
||||
assert.equal(isAllowedHere('store-manager'), true);
|
||||
assert.equal(isAllowedHere('nearle-admin'), false);
|
||||
});
|
||||
|
||||
test('every role is decided, none left to a default', () => {
|
||||
// A role added later must be listed deliberately on one side or the other.
|
||||
// Falling through to "allowed" would put it on both consoles silently; this
|
||||
// asserts each of the three is a decision that was actually made.
|
||||
for (const role of ROLES) {
|
||||
assert.equal(typeof isAllowedHere(role), 'boolean', `${role} has no verdict`);
|
||||
}
|
||||
const allowed = ROLES.filter(isAllowedHere);
|
||||
assert.equal(allowed.length, 2, `merchant admits ${allowed.join(', ')}`);
|
||||
// A role added later must be listed deliberately. Falling through to allowed
|
||||
// would quietly admit it to the merchant console.
|
||||
const admitted = ROLES.filter(isAllowedHere);
|
||||
assert.deepEqual(admitted, ['store-admin', 'store-manager']);
|
||||
});
|
||||
|
||||
test('the refusal names the other console rather than blaming the account', () => {
|
||||
test('the refusal names the platform console rather than blaming the account', () => {
|
||||
const message = wrongConsoleMessage('nearle-admin');
|
||||
|
||||
// The host, so somebody knows where to go.
|
||||
assert.match(message, /platform\.nearledaily\.com/);
|
||||
// And not a word that reads as "your account is broken" — the password was
|
||||
// right and the account is fine. Anyone told "failed" or "denied" goes and
|
||||
// resets a working password, or asks an administrator to fix nothing.
|
||||
// The password was right and the account is fine. Anyone told "failed" or
|
||||
// "denied" goes and resets a working password, or asks an administrator to
|
||||
// fix nothing at all.
|
||||
for (const blame of ['failed', 'invalid', 'denied', 'not recognised', 'wrong password']) {
|
||||
assert.ok(
|
||||
!message.toLowerCase().includes(blame),
|
||||
`the refusal reads as a fault: ${message}`,
|
||||
);
|
||||
assert.ok(!message.toLowerCase().includes(blame), `the refusal reads as a fault: ${message}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('each role is named in words a person uses', () => {
|
||||
// The message is read by whoever typed the password, not by us.
|
||||
test('the refused role is named in words a person uses', () => {
|
||||
assert.match(wrongConsoleMessage('nearle-admin'), /Nearle staff/);
|
||||
assert.match(wrongConsoleMessage('store-admin'), /Store admin/);
|
||||
assert.match(wrongConsoleMessage('store-manager'), /Store user/);
|
||||
});
|
||||
|
||||
/*
|
||||
What the split actually guarantees, and what it does not.
|
||||
|
||||
The route blocks in `App.tsx` are chosen at runtime, so both workspaces' chunks
|
||||
are compiled into either image. The unmounted one is never fetched, because
|
||||
nothing routes to it — but it is present, and an earlier version of the comment
|
||||
in that file claimed otherwise.
|
||||
|
||||
So the guarantee is NOT "the other console's code is absent". It is "the other
|
||||
console's accounts cannot sign in, and its paths are not routes here". Both of
|
||||
those are enforced by the two functions below, which is why they are the ones
|
||||
worth pinning rather than the bundle's contents.
|
||||
*/
|
||||
|
||||
test('the refusal does not depend on which routes happen to be mounted', () => {
|
||||
// `isAllowedHere` is consulted by `login` before a session is written and by
|
||||
// `restore` before one is read back. Neither goes near the router, so a
|
||||
// mistake in route mounting cannot open a door that this closes.
|
||||
assert.equal(isAllowedHere('nearle-admin'), IS_PLATFORM);
|
||||
assert.equal(isAllowedHere('store-admin'), !IS_PLATFORM);
|
||||
assert.equal(isAllowedHere('store-manager'), !IS_PLATFORM);
|
||||
});
|
||||
|
||||
test('no role is admitted by both consoles', () => {
|
||||
// The two sets must partition the roles: one home each, never two. A role in
|
||||
// both would make the separation cosmetic — the account would work at either
|
||||
// address and the refusal would never fire.
|
||||
const platformRoles: ConsoleRole[] = ['nearle-admin'];
|
||||
const merchantRoles: ConsoleRole[] = ['store-admin', 'store-manager'];
|
||||
|
||||
for (const role of platformRoles) {
|
||||
assert.ok(!merchantRoles.includes(role), `${role} is claimed by both consoles`);
|
||||
}
|
||||
assert.equal(
|
||||
platformRoles.length + merchantRoles.length,
|
||||
ROLES.length,
|
||||
'a role belongs to neither console and could sign in nowhere',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,90 +1,63 @@
|
||||
import type { ConsoleRole } from './roles';
|
||||
|
||||
/**
|
||||
* Which console this build is.
|
||||
* Who may sign in to the merchant console.
|
||||
*
|
||||
* ── Why one codebase produces two sites ─────────────────────────────────────
|
||||
* ── Why this is a constant ──────────────────────────────────────────────────
|
||||
*
|
||||
* Nearle's own staff work at `platform.nearledaily.com`; merchants and their
|
||||
* branch users work at `app.nearledaily.com`. They are the same application
|
||||
* built twice with this flag set differently, rather than two repositories,
|
||||
* because every screen below the workspace split — drawers, tables, the
|
||||
* assistant, the design system — is shared and would otherwise be maintained
|
||||
* in two places and drift.
|
||||
* There was a `VITE_WORKSPACE` build flag here while one codebase served both
|
||||
* consoles and had to be told which it was. That is over: Nearle's own staff
|
||||
* have their own application, `nearle-platform`, and this repository is the
|
||||
* merchant console and nothing else. A flag would only be a way to deploy this
|
||||
* application as something it is not.
|
||||
*
|
||||
* What the flag changes is which routes are mounted and which roles may sign
|
||||
* in. It does not change what is compiled: the branch in `App.tsx` is evaluated
|
||||
* at runtime, so both workspaces' chunks are built and served, and the
|
||||
* unmounted one is simply never fetched because nothing routes to it. Removing
|
||||
* it from the bundle would need the flag to be a literal at each import site,
|
||||
* which is a separate piece of work and buys nothing for access control.
|
||||
* ── The separation is a refusal, not a redirect ─────────────────────────────
|
||||
*
|
||||
* ── The default is `merchant`, deliberately ─────────────────────────────────
|
||||
* A Nearle staff account is turned away here with a sentence naming where it
|
||||
* belongs. It is not bounced to the other site carrying a half-made session.
|
||||
*
|
||||
* An unset variable is the ordinary state of a developer's machine and of any
|
||||
* deployment that has not been told about this yet. Defaulting to `merchant`
|
||||
* means the existing site keeps behaving exactly as it did, and the platform
|
||||
* build is the one that has to be asked for. The opposite default would turn
|
||||
* every un-migrated environment into a platform console the day this shipped.
|
||||
* The role is not known until the password has been checked — `applogin`
|
||||
* returns it — so the refusal can only happen after credentials are verified.
|
||||
* `login` applies it BEFORE the session is written and `restore` applies it to
|
||||
* what is already stored. That order is the point: a session persisted first
|
||||
* and refused afterwards leaves somebody signed in by every measure the shell
|
||||
* uses, with a nav built from a role this application serves no routes for.
|
||||
*/
|
||||
export type Workspace = 'platform' | 'merchant';
|
||||
export const WORKSPACE = 'merchant' as const;
|
||||
|
||||
const CONFIGURED = (import.meta.env?.['VITE_WORKSPACE'] ?? '').trim().toLowerCase();
|
||||
|
||||
export const WORKSPACE: Workspace = CONFIGURED === 'platform' ? 'platform' : 'merchant';
|
||||
|
||||
export const IS_PLATFORM = WORKSPACE === 'platform';
|
||||
|
||||
/**
|
||||
* Who may sign in here.
|
||||
*
|
||||
* The separation is a REFUSAL, not a redirect. A merchant reaching the platform
|
||||
* console is told which console their account belongs to and stays where they
|
||||
* are; they are not bounced across a domain boundary carrying a half-made
|
||||
* session. Each site serves exactly one audience and says so.
|
||||
*/
|
||||
const ALLOWED: Record<Workspace, ReadonlySet<ConsoleRole>> = {
|
||||
platform: new Set<ConsoleRole>(['nearle-admin']),
|
||||
merchant: new Set<ConsoleRole>(['store-admin', 'store-manager']),
|
||||
};
|
||||
const ALLOWED: ReadonlySet<ConsoleRole> = new Set<ConsoleRole>(['store-admin', 'store-manager']);
|
||||
|
||||
export function isAllowedHere(role: ConsoleRole): boolean {
|
||||
return ALLOWED[WORKSPACE].has(role);
|
||||
return ALLOWED.has(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* The other console's address, for the sentence shown to somebody in the wrong
|
||||
* place.
|
||||
* The platform console's address, for the sentence shown to a staff member who
|
||||
* signs in at the wrong site.
|
||||
*
|
||||
* Named rather than derived from `location.hostname`, because the two sites are
|
||||
* not a naming convention apart — they are separate deployments and either can
|
||||
* move. A build that was not told falls back to the production hostnames, which
|
||||
* is right far more often than saying nothing.
|
||||
* A build variable rather than a constant, because the two are separate
|
||||
* deployments and either can move. The fallback is the production hostname,
|
||||
* which is right far more often than saying nothing would be.
|
||||
*/
|
||||
const OTHER_SITE: Record<Workspace, string> = {
|
||||
platform: (import.meta.env?.['VITE_MERCHANT_HOST'] ?? '').trim() || 'app.nearledaily.com',
|
||||
merchant: (import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com',
|
||||
};
|
||||
const PLATFORM_HOST =
|
||||
(import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com';
|
||||
|
||||
/**
|
||||
* What to tell somebody whose account belongs to the other console.
|
||||
*
|
||||
* Names the host rather than linking to it. A live link from a sign-in screen
|
||||
* to another sign-in screen reads as a redirect that failed, and this is not a
|
||||
* failure — it is the right answer to the wrong door.
|
||||
* Names the host rather than linking to it. A live link from one sign-in screen
|
||||
* to another reads as a redirect that failed, and this is not a failure — it is
|
||||
* the right answer to the wrong door.
|
||||
*/
|
||||
export function wrongConsoleMessage(role: ConsoleRole): string {
|
||||
const site = OTHER_SITE[WORKSPACE];
|
||||
|
||||
return IS_PLATFORM
|
||||
? `This is the Nearle platform console. ${roleWord(role)} accounts sign in at ${site}.`
|
||||
: `${roleWord(role)} accounts sign in at ${site}, not here.`;
|
||||
return `${roleWord(role)} accounts sign in at ${PLATFORM_HOST}, not here.`;
|
||||
}
|
||||
|
||||
function roleWord(role: ConsoleRole): string {
|
||||
switch (role) {
|
||||
case 'nearle-admin':
|
||||
return 'Nearle staff';
|
||||
// Unreachable: both merchant roles are allowed here, so neither reaches the
|
||||
// refusal. Present because the switch is exhaustive over the union and a
|
||||
// missing arm would be a type error the day a role is added.
|
||||
case 'store-admin':
|
||||
return 'Store admin';
|
||||
case 'store-manager':
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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.
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
downloadTemplate,
|
||||
parseProductSheet,
|
||||
type ParsedSheet,
|
||||
} from '@/features/nearle-admin/import/parseProductSheet';
|
||||
} from '@/features/import/parseProductSheet';
|
||||
|
||||
/** How the category was arrived at, in the operator's words. */
|
||||
const RULE_LABEL: Record<CategoryRule, string> = {
|
||||
|
||||
@@ -52,8 +52,8 @@ import { uploadsApi, type UploadReceipt } from '@/api/uploads';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import type { SheetProductRow } from '@/api/products';
|
||||
import { readStoredRows } from './shelveRows';
|
||||
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
|
||||
import { shelveBatch } from '@/features/nearle-admin/import/shelve';
|
||||
import { parseProductSheet } from '@/features/import/parseProductSheet';
|
||||
import { shelveBatch } from '@/features/import/shelve';
|
||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
|
||||
@@ -21,7 +21,7 @@ import { customersApi, type CustomerQuery } from '@/api/customers';
|
||||
import { telemetryApi, type RiderSnapshot } from '@/api/telemetry';
|
||||
import { uploadsApi } from '@/api/uploads';
|
||||
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 { LIVE_REFETCH_MS } from './interval';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user