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