Initial commit
This commit is contained in:
152
src/App.tsx
Normal file
152
src/App.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { lazy, Suspense, type ComponentType } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
import { RequireRole, useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
|
||||
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
|
||||
|
||||
/**
|
||||
* Routes are split per page.
|
||||
*
|
||||
* The reference console ships as one 1.8MB chunk with no route splitting, which
|
||||
* means every visitor downloads the spreadsheet importer to look at a dashboard.
|
||||
* That is the one thing from it worth deliberately not copying.
|
||||
*/
|
||||
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
|
||||
lazy(() => 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 ConsolePage = named('ConsolePage', () => import('@/features/store-admin/pages/ConsolePage'));
|
||||
const SalesPage = named('SalesPage', () => import('@/features/store-admin/pages/SalesPage'));
|
||||
const InventoryPage = named('InventoryPage', () => import('@/features/store-admin/pages/InventoryPage'));
|
||||
const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pages/ReportsPage'));
|
||||
const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/store-admin/pages/OnboardBranchPage'));
|
||||
const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/UsersPage'));
|
||||
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 64 }}>
|
||||
<Spinner size="md" label="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes.
|
||||
*
|
||||
* Three workspaces, hard-partitioned by role and by URL prefix, the same way the
|
||||
* three logins imply. Only the Nearle Admin workspace is built so far; the other
|
||||
* two resolve to a placeholder rather than a 404, so a Store Admin signing in
|
||||
* today lands somewhere that explains itself.
|
||||
*/
|
||||
export function App() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Nearle Admin — the platform workspace */}
|
||||
<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 />} />
|
||||
{/* 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>
|
||||
|
||||
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireRole role="store-admin">
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<StoreAdminShell />
|
||||
</Suspense>
|
||||
</RequireRole>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/console" replace />} />
|
||||
<Route path="console" element={<ConsolePage />} />
|
||||
<Route path="sales" element={<SalesPage />} />
|
||||
<Route path="inventory" element={<InventoryPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="branches/new" element={<OnboardBranchPage />} />
|
||||
{/* Reached from the account menu, not the nav — setup is not a place
|
||||
anyone works from day to day. See `AppShellProps.manageItems`. */}
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="terminals" element={<TerminalsPage />} />
|
||||
{/* Catches `/admin/dashboard` and anything else that does not resolve.
|
||||
Without this, an unknown sub-path escapes to the global `*`, which
|
||||
redirects to this role's HOME_ROUTE — and if that is itself an
|
||||
unknown path, the two bounce forever and the page renders blank. */}
|
||||
<Route path="*" element={<Navigate to="/admin/console" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Not built yet. Named rather than missing, so the redirect has somewhere to land. */}
|
||||
<Route path="/store/*" element={<NotBuiltYet workspace="Store Manager" />} />
|
||||
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function NotBuiltYet({ workspace }: { workspace: string }) {
|
||||
const { signOut } = useAuth();
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
|
||||
<div style={{ maxWidth: 460, textAlign: 'center' }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 8 }}>
|
||||
The {workspace} workspace is not built yet
|
||||
</h1>
|
||||
<p style={{ color: 'var(--color-slate-500)', fontSize: 14, lineHeight: 1.6 }}>
|
||||
This console currently covers the Nearle Admin workspace. Your account signs in
|
||||
correctly — there is simply nothing here for it to open.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={signOut}
|
||||
style={{
|
||||
marginTop: 20,
|
||||
padding: '9px 16px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-slate-200)',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/api/catalogue.ts
Normal file
53
src/api/catalogue.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The global FMCG catalogue — a separate pgvector database, bridged to tenant
|
||||
* data by the composite key `(brand, catalogueid)`.
|
||||
*
|
||||
* A catalogue row's bare `id` is only unique WITHIN its own brand table:
|
||||
* `brand_dabur.id = 1` and `brand_nestle.id = 1` are different products. Every
|
||||
* call that references a catalogue product sends both.
|
||||
*/
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type { CatalogueBrand, CatalogueProduct, CatalogueRef } from './types';
|
||||
|
||||
export interface CatalogueQuery {
|
||||
/** Omit to search every brand merged — that is the "show everything" entry point. */
|
||||
brand?: string;
|
||||
category?: string;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
export const catalogueApi = {
|
||||
/**
|
||||
* Browse the global catalogue. Called with no `brand` this returns the full
|
||||
* merged, paginated list — the list is never gated behind a brand selector.
|
||||
*/
|
||||
products: (query: CatalogueQuery = {}) =>
|
||||
api.get<CatalogueProduct[]>(`${WEB}/catalogue/getproducts`, {
|
||||
brand: query.brand,
|
||||
category: query.category,
|
||||
keyword: query.keyword,
|
||||
pageno: query.pageno ?? 0,
|
||||
pagesize: query.pagesize ?? 48,
|
||||
}),
|
||||
|
||||
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
|
||||
brands: () => api.get<CatalogueBrand[]>(`${WEB}/catalogue/getbrands`),
|
||||
|
||||
categories: () => api.get<string[]>(`${WEB}/catalogue/getcategories`),
|
||||
|
||||
/**
|
||||
* The `(brand, catalogueid)` pairs this tenant has already imported, for
|
||||
* badging "Imported" in the browser. Called without `brand` because the list
|
||||
* mixes brands.
|
||||
*/
|
||||
importedRefs: (tenantid: number) =>
|
||||
api.get<CatalogueRef[]>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
|
||||
};
|
||||
|
||||
/** Key for the imported-refs lookup. Both halves, always. */
|
||||
export function catalogueKey(ref: CatalogueRef | CatalogueProduct): string {
|
||||
return 'catalogueid' in ref ? `${ref.brand}:${ref.catalogueid}` : `${ref.brand}:${ref.id}`;
|
||||
}
|
||||
186
src/api/client.ts
Normal file
186
src/api/client.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* The Fiesta HTTP client.
|
||||
*
|
||||
* Everything the console knows about talking to the backend lives here, so the
|
||||
* day the backend starts issuing a session token, this is the only file that
|
||||
* changes. Nothing else in the app calls `fetch`.
|
||||
*/
|
||||
|
||||
import { demoResolve, isDemoActive, MISS } from '@/demo';
|
||||
import type { FiestaEnvelope } from './types';
|
||||
|
||||
/**
|
||||
* In dev, Vite proxies `/fiesta` -> https://fiesta.nearle.app (see
|
||||
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
|
||||
* surprises. In production the deployed host is set by VITE_API_BASE.
|
||||
*/
|
||||
const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
||||
|
||||
/** Every console route lives under this prefix. `/mob/*` and `/pos/*` differ. */
|
||||
export const WEB = '/live/api/v1/web';
|
||||
export const POS = '/live/api/v1/pos';
|
||||
|
||||
/**
|
||||
* A failed call, carrying the backend's own message.
|
||||
*
|
||||
* Fiesta answers HTTP 200 with `status: false` in several places, so the HTTP
|
||||
* status alone is not enough to tell success from failure — both are checked.
|
||||
*/
|
||||
export class FiestaError extends Error {
|
||||
readonly code: number;
|
||||
readonly endpoint: string;
|
||||
|
||||
constructor(message: string, code: number, endpoint: string) {
|
||||
super(message);
|
||||
this.name = 'FiestaError';
|
||||
this.code = code;
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the backend rejected the call for want of a scoping id.
|
||||
*
|
||||
* The IDOR pass added controller-level guards: an unscoped list call 400s
|
||||
* rather than returning every tenant's rows. That is a bug in the caller,
|
||||
* not a server fault, and it should surface as one.
|
||||
*/
|
||||
get isMissingScope(): boolean {
|
||||
return this.code === 400 && /required/i.test(this.message);
|
||||
}
|
||||
}
|
||||
|
||||
export type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
/** Drops empty params rather than sending `?tenantid=` and getting a 400 back. */
|
||||
function toQueryString(params: Record<string, QueryValue> | undefined): string {
|
||||
if (!params) return '';
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
params?: Record<string, QueryValue>;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', params, body, signal } = options;
|
||||
|
||||
// Demo mode short-circuits before any network call. In a production build
|
||||
// `isDemoActive` is a constant `false`, so the bundler removes this branch
|
||||
// and the fixtures with it.
|
||||
if (import.meta.env.DEV && isDemoActive()) {
|
||||
const fixture = await demoResolve(path, params as Record<string, unknown> | undefined);
|
||||
if (fixture !== MISS) {
|
||||
// A beat of latency, so loading states are visible while working on them.
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
return fixture as T;
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${API_BASE}${path}${toQueryString(params)}`;
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: signal ?? null,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch (cause) {
|
||||
// A network failure and a 500 read very differently to a user; keep them
|
||||
// distinguishable rather than collapsing both into "something went wrong".
|
||||
throw new FiestaError(
|
||||
cause instanceof DOMException && cause.name === 'AbortError'
|
||||
? 'Request cancelled'
|
||||
: 'Could not reach the server',
|
||||
0,
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
let envelope: FiestaEnvelope<T>;
|
||||
try {
|
||||
envelope = (await response.json()) as FiestaEnvelope<T>;
|
||||
} catch {
|
||||
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
|
||||
}
|
||||
|
||||
if (!response.ok || envelope.status === false) {
|
||||
throw new FiestaError(
|
||||
envelope.message ?? `Request failed (HTTP ${response.status})`,
|
||||
envelope.code ?? response.status,
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
return envelope.details as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole envelope, for the handful of callers that need `message` or
|
||||
* `tenantform` on success — login being the one that matters.
|
||||
*/
|
||||
async function requestEnvelope<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<FiestaEnvelope<T>> {
|
||||
const { method = 'GET', params, body } = options;
|
||||
const url = `${API_BASE}${path}${toQueryString(params)}`;
|
||||
|
||||
const init: RequestInit = { method, headers: { Accept: 'application/json' } };
|
||||
if (body !== undefined) {
|
||||
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch {
|
||||
throw new FiestaError('Could not reach the server', 0, path);
|
||||
}
|
||||
|
||||
try {
|
||||
return (await response.json()) as FiestaEnvelope<T>;
|
||||
} catch {
|
||||
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
|
||||
request<T>(path, { method: 'GET', params, signal }),
|
||||
|
||||
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'POST', body, params }),
|
||||
|
||||
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'PUT', body, params }),
|
||||
|
||||
del: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'DELETE', body, params }),
|
||||
|
||||
envelope: requestEnvelope,
|
||||
};
|
||||
|
||||
/** Normalises anything thrown into a message worth showing a person. */
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof FiestaError) return error.message;
|
||||
if (error instanceof Error) return error.message;
|
||||
return 'Something went wrong';
|
||||
}
|
||||
159
src/api/insights.ts
Normal file
159
src/api/insights.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Order, delivery and POS reads — the numbers behind store performance.
|
||||
*
|
||||
* Note the split: online orders come from `/web/orders/*` and counter sales
|
||||
* from `/pos/*`, which is the ONLY part of the API carrying auth middleware.
|
||||
* Any figure that blends the two is eventually consistent by construction, so
|
||||
* screens that show one must also show its freshness.
|
||||
*/
|
||||
|
||||
import { api, POS, WEB } from './client';
|
||||
import type {
|
||||
DeliveryRow,
|
||||
DeliverySummary,
|
||||
LocationOrderSummary,
|
||||
OrderRow,
|
||||
OrderSummary,
|
||||
PosLocationHealth,
|
||||
PosSalesPage,
|
||||
PosSalesSummary,
|
||||
} from './types';
|
||||
|
||||
export interface DateRange {
|
||||
fromdate?: string;
|
||||
todate?: string;
|
||||
}
|
||||
|
||||
export interface OrderQuery extends DateRange {
|
||||
tenantid: number;
|
||||
/** Omit for every branch of the tenant. */
|
||||
locationid?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
export const insightsApi = {
|
||||
/**
|
||||
* The order rows themselves.
|
||||
*
|
||||
* `/orders/tenant/getorders` rather than the bare `/orders/getorders`: the
|
||||
* controller routes on which ids are present, and passing a tenant with no
|
||||
* partner, customer or app-user reaches `GetTenantOrders`. Passing a
|
||||
* `locationid` as well reaches `GetTenantLocationOrders`, which is the
|
||||
* branch-scoped read — so one call covers both "all branches" and "one
|
||||
* branch" by presence alone.
|
||||
*
|
||||
* `pageno` is 1-based here. The controller floors anything <= 0 to 1, so
|
||||
* sending 0 silently gives page one rather than an error.
|
||||
*/
|
||||
orders: (query: OrderQuery) =>
|
||||
api.get<OrderRow[]>(`${WEB}/orders/tenant/getorders`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
fromdate: query.fromdate,
|
||||
todate: query.todate,
|
||||
pageno: query.pageno ?? 1,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
/**
|
||||
* The delivery jobs.
|
||||
*
|
||||
* A separate read from the orders list, NOT a filter over it. The rows are a
|
||||
* different struct with different fields — rider name, planned vs actual
|
||||
* distance, rider charge vs job value, notes — and a different status ladder.
|
||||
* Deriving deliveries from orders, which is what this page did first, loses
|
||||
* every one of those.
|
||||
*
|
||||
* The controller 400s unless one of tenantid/partnerid/customerid/
|
||||
* applocationid/userid/appuserid is present, so `tenantid` is required here.
|
||||
*/
|
||||
deliveries: (query: OrderQuery) =>
|
||||
api.get<DeliveryRow[]>(`${WEB}/deliveries/getdeliveries`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
fromdate: query.fromdate,
|
||||
todate: query.todate,
|
||||
pageno: query.pageno ?? 1,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
orderSummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<OrderSummary>(`${WEB}/orders/getordersummary`, { tenantid, ...range }),
|
||||
|
||||
/** Per-branch order totals for one tenant. `tenantid` is required. */
|
||||
locationSummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<LocationOrderSummary[]>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
|
||||
|
||||
revenueSummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
|
||||
|
||||
timeSeries: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<Record<string, unknown>[]>(`${WEB}/orders/gettimeseries`, { tenantid, ...range }),
|
||||
|
||||
deliverySummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
|
||||
|
||||
/**
|
||||
* Counter sales for ONE outlet.
|
||||
*
|
||||
* `locationid` is required and singular — there is no tenant-wide POS call,
|
||||
* so a multi-branch view fans out one request per branch.
|
||||
*/
|
||||
posSales: (
|
||||
locationid: number,
|
||||
params: DateRange & {
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
/** The three server-side filters the old console's bills tab offers. */
|
||||
terminalid?: string;
|
||||
cashiername?: string;
|
||||
paymentmode?: string;
|
||||
} = {},
|
||||
) =>
|
||||
api.get<PosSalesPage>(`${POS}/sales`, {
|
||||
locationid,
|
||||
pageno: params.pageno ?? 0,
|
||||
pagesize: params.pagesize ?? 50,
|
||||
fromdate: params.fromdate,
|
||||
todate: params.todate,
|
||||
terminalid: params.terminalid,
|
||||
cashiername: params.cashiername,
|
||||
paymentmode: params.paymentmode,
|
||||
}),
|
||||
|
||||
/**
|
||||
* One counter bill in full, with its lines.
|
||||
*
|
||||
* `reference` accepts the till's order UUID, the invoice number, or this
|
||||
* backend's posorderid — a support call starts from whichever the person is
|
||||
* looking at, so the endpoint takes all three.
|
||||
*/
|
||||
posSaleDetail: (locationid: number, reference: string) =>
|
||||
api.get<unknown>(`${POS}/sales/detail`, { locationid, reference }),
|
||||
|
||||
/**
|
||||
* Counter-sales totals for ONE outlet.
|
||||
*
|
||||
* The richest read in the API: bill count, gross, tax, discount, roundoff and
|
||||
* average bill, already broken down by payment mode, by day and by terminal.
|
||||
* Reports gets its offline half from this and nothing else.
|
||||
*
|
||||
* Kept apart from the order summaries above on purpose. These are `posorders`
|
||||
* rows; those are `orders` rows. The two are never added together — see
|
||||
* `store-admin-backend-gap.md` §3.1 for why that would double-count a branch
|
||||
* that both runs a till and uploads a spreadsheet.
|
||||
*/
|
||||
posSalesSummary: (locationid: number, range: DateRange = {}) =>
|
||||
api.get<PosSalesSummary>(`${POS}/sales/summary`, { locationid, ...range }),
|
||||
|
||||
/** Till presence for one outlet — how many are online, how many bills are stranded. */
|
||||
posHealth: (locationid: number) =>
|
||||
api.get<PosLocationHealth>(`${POS}/health/location`, { location_id: locationid }),
|
||||
};
|
||||
160
src/api/people.ts
Normal file
160
src/api/people.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* People — back-office staff and till accounts.
|
||||
*
|
||||
* Two account systems that happen to share one table. A till account is NOT a
|
||||
* Nearle Daily user: the backend excludes roles 7 and 8 from every application
|
||||
* lookup inside the query itself, deliberately, so a cashier is "not found"
|
||||
* rather than "refused". They are read and written through different endpoints
|
||||
* with different conventions, and this file keeps them apart.
|
||||
*
|
||||
* Three things this module will not do, each for a reason recorded in
|
||||
* `store-admin-user-menu-plan.md`:
|
||||
*
|
||||
* - **No delete.** `DELETE /users/delete` and `DELETE /deleteposuser` are hard
|
||||
* deletes with no cascade. Deactivating via `status` is the safe equivalent
|
||||
* and is what both list screens offer.
|
||||
* - **No password management.** `PUT /users/update` doubles as the
|
||||
* password-reset call and passwords are stored in clear. Creating an account
|
||||
* is in scope; issuing its password is not, until hashing exists.
|
||||
* - **Never send `roleid: -1`.** The old console clears a role that way;
|
||||
* `UpdateStaff` does not special-case it, so `-1` lands in the column and the
|
||||
* account ends up holding a role that matches nothing.
|
||||
*/
|
||||
|
||||
import { api, POS, WEB } from './client';
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
|
||||
|
||||
/* ── Back-office staff ───────────────────────────────────────────────────── */
|
||||
|
||||
export interface CreateStaffRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
firstname: string;
|
||||
lastname?: string;
|
||||
email: string;
|
||||
contactno: string;
|
||||
roleid: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdateStaffRequest {
|
||||
userid: number;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
email?: string;
|
||||
contactno?: string;
|
||||
roleid?: number;
|
||||
locationid?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const staffApi = {
|
||||
/**
|
||||
* The tenant's back-office directory.
|
||||
*
|
||||
* `getstaffs`, NOT `getallusers`. This one resolves `rolename` server-side,
|
||||
* and the backend says why that matters: "`app_roles` holds six rows for four
|
||||
* back-office roles and most accounts carry an id absent from it, so any
|
||||
* mapping written client-side is wrong."
|
||||
*/
|
||||
list: (tenantid: number) =>
|
||||
api.get<StaffInfo[]>(`${WEB}/tenants/getstaffs`, { tenantid }),
|
||||
|
||||
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
|
||||
|
||||
/**
|
||||
* Update a person.
|
||||
*
|
||||
* GORM's `Updates` with a struct skips zero values, so an omitted field is
|
||||
* left alone rather than blanked — which is why every field here is optional
|
||||
* and why clearing something is not possible through this call.
|
||||
*/
|
||||
update: (body: UpdateStaffRequest) => api.put<StaffInfo>(`${WEB}/users/update`, body),
|
||||
};
|
||||
|
||||
/* ── Till accounts ───────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CreatePosUserRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
full_name: string;
|
||||
/**
|
||||
* The role NAME, lowercase — "supervisor" or "cashier".
|
||||
*
|
||||
* Not the id. `PosRoleFromName` reads the name off the request and returns 0
|
||||
* for anything it does not recognise, which every caller treats as a refusal
|
||||
* rather than as a default. Sending 7 or 8 here does nothing.
|
||||
*/
|
||||
role: string;
|
||||
/** Ten digits. The till matches on it exactly — see the note in `normaliseMobile`. */
|
||||
contactno: string;
|
||||
pin?: string;
|
||||
/** A `staffshifts.staff_shift_id`. Zero leaves it unset. */
|
||||
shift_id?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePosUserRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
user_id: number;
|
||||
full_name?: string;
|
||||
role?: string;
|
||||
contactno?: string;
|
||||
shift_id?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const posUsersApi = {
|
||||
/** Till accounts at one outlet. `locationid` is required and singular. */
|
||||
list: (tenantid: number, locationid: number) =>
|
||||
api.get<PosUser[]>(`${POS}/getposusers`, { tenantid, locationid }),
|
||||
|
||||
/**
|
||||
* The role picker's source.
|
||||
*
|
||||
* Read rather than hardcoded. Supervisor is 7 and Cashier is 8 today, but the
|
||||
* endpoint also carries the label and the description a person needs to
|
||||
* choose between them — and a third role would appear here first.
|
||||
*/
|
||||
roles: () => api.get<PosRole[]>(`${POS}/posroles`),
|
||||
|
||||
/**
|
||||
* Create a till account.
|
||||
*
|
||||
* The response carries the PIN or password ONCE. The backend is explicit that
|
||||
* a listing never returns it: "An admin who loses it reissues rather than
|
||||
* looks it up." So it is shown at creation and never read back.
|
||||
*/
|
||||
create: (body: CreatePosUserRequest) => api.post<PosUser>(`${POS}/createposuser`, body),
|
||||
|
||||
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${POS}/updateposuser`, body),
|
||||
|
||||
/** Shift windows a till account can be put on. */
|
||||
shifts: (tenantid: number, locationid: number) =>
|
||||
api.get<StaffShift[]>(`${POS}/getstaffshifts`, { tenantid, locationid }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Ten digits, or nothing.
|
||||
*
|
||||
* The till matches the mobile number EXACTLY, so `+91 98765 43210` typed back
|
||||
* as `9876543210` would not find the row. Stripping to the last ten digits at
|
||||
* the edge means an admin can paste whatever their contact list gave them.
|
||||
*/
|
||||
export function normaliseMobile(input: string): string {
|
||||
const digits = input.replace(/\D/g, '');
|
||||
return digits.length > 10 ? digits.slice(-10) : digits;
|
||||
}
|
||||
|
||||
/** Mon-first mask → "Mon–Fri", "Every day". `weekdays` empty means every day. */
|
||||
export function weekdayLabel(mask: string | undefined): string {
|
||||
if (!mask || !/^[01]{7}$/.test(mask)) return 'Every day';
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const on = [...mask].map((bit, index) => (bit === '1' ? days[index] : null)).filter(Boolean);
|
||||
if (on.length === 7) return 'Every day';
|
||||
if (on.length === 0) return '—';
|
||||
if (mask === '1111100') return 'Mon–Fri';
|
||||
if (mask === '0000011') return 'Weekends';
|
||||
return on.join(', ');
|
||||
}
|
||||
213
src/api/products.ts
Normal file
213
src/api/products.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Product, stock and import endpoints.
|
||||
*
|
||||
* Two import paths exist and they are NOT symmetric — the asymmetry is the
|
||||
* backend's, not a choice made here:
|
||||
*
|
||||
* Catalogue path : one batch call, idempotent. Re-importing the same
|
||||
* (tenantid, brand, catalogueid) tops up stock and
|
||||
* overwrites price instead of duplicating.
|
||||
*
|
||||
* Sheet path : `create` accepts ONE product, not an array, and does not
|
||||
* return the generated productid — the controller passes the
|
||||
* struct to the service by value, so GORM writes the id into
|
||||
* a copy that is then discarded, and the response echoes what
|
||||
* was sent. So a sheet import is N creates, then a lookup by
|
||||
* SKU to resolve ids, then one batched location call and one
|
||||
* batched stock call.
|
||||
*
|
||||
* `importSheetProducts` below encapsulates that whole dance so no screen has to
|
||||
* know about it.
|
||||
*/
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type {
|
||||
ImportCatalogueProductRequest,
|
||||
Product,
|
||||
ProductCategory,
|
||||
ProductLocationRequest,
|
||||
ProductStockRequest,
|
||||
ProductSubCategory,
|
||||
} from './types';
|
||||
|
||||
export interface LocationProductQuery {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
export const productsApi = {
|
||||
/** A store's own catalogue — what is actually imported, with live stock. */
|
||||
locationProducts: (query: LocationProductQuery) =>
|
||||
api.get<Product[]>(`${WEB}/products/getlocationproducts`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
pageno: query.pageno ?? 0,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
allProducts: (tenantid: number) =>
|
||||
api.get<Product[]>(`${WEB}/products/getallproducts`, { tenantid }),
|
||||
|
||||
count: (tenantid: number) =>
|
||||
api.get<{ count?: number }>(`${WEB}/products/getproductscount`, { tenantid }),
|
||||
|
||||
categories: (tenantid: number) =>
|
||||
api.get<ProductCategory[]>(`${WEB}/products/getproductcategories`, { tenantid }),
|
||||
|
||||
subCategories: (tenantid: number, categoryid: number) =>
|
||||
api.get<ProductSubCategory[]>(`${WEB}/products/getproductsubcategories`, {
|
||||
tenantid,
|
||||
categoryid,
|
||||
}),
|
||||
|
||||
/** Batch, idempotent. Send the whole selection in one call. */
|
||||
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
||||
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
|
||||
|
||||
/** Single product only — the backend parses one object, not an array. */
|
||||
createProduct: (product: Partial<Product>) =>
|
||||
api.post<Product>(`${WEB}/products/create`, product),
|
||||
|
||||
/** Array. Upserts on (tenantid, locationid, productid). */
|
||||
createProductLocations: (rows: ProductLocationRequest[]) =>
|
||||
api.post<unknown>(`${WEB}/products/createproductlocation`, rows),
|
||||
|
||||
/** Array. Appends to the stock ledger. */
|
||||
createProductStock: (rows: ProductStockRequest[]) =>
|
||||
api.post<unknown>(`${WEB}/products/createproductstock`, rows),
|
||||
|
||||
publish: (body: { tenantid: number; locationid: number; productid: number }) =>
|
||||
api.post<unknown>(`${WEB}/products/publishproduct`, body),
|
||||
|
||||
/** Unlinks from the store. The product row and its order history survive. */
|
||||
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
||||
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
|
||||
};
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
The sheet-import dance
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** One validated row from the uploaded workbook. */
|
||||
export interface SheetProductRow {
|
||||
productname: string;
|
||||
productsku: string;
|
||||
categoryid: number;
|
||||
subcategoryid: number;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
taxpercent: number;
|
||||
quantity: number;
|
||||
productunit?: string;
|
||||
unitvalue?: string;
|
||||
productbrand?: string;
|
||||
productdesc?: string;
|
||||
}
|
||||
|
||||
export interface SheetImportResult {
|
||||
created: number;
|
||||
linked: number;
|
||||
stocked: number;
|
||||
/** Rows the backend rejected, paired with the reason, so they can be retried. */
|
||||
failures: { row: SheetProductRow; reason: string }[];
|
||||
}
|
||||
|
||||
export interface SheetImportOptions {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
rows: SheetProductRow[];
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a parsed sheet.
|
||||
*
|
||||
* NOT idempotent, and it cannot be made so from this side: nothing in the API
|
||||
* dedupes on SKU, so uploading the same workbook twice creates the products
|
||||
* twice. The importer UI is responsible for warning before a re-upload.
|
||||
*
|
||||
* Creates run sequentially rather than in parallel on purpose. There is no
|
||||
* batch create, and firing 500 concurrent writes at a single-instance Go
|
||||
* service to save a few seconds is a poor trade against a half-imported tenant.
|
||||
*/
|
||||
export async function importSheetProducts(
|
||||
options: SheetImportOptions,
|
||||
): Promise<SheetImportResult> {
|
||||
const { tenantid, locationid, rows, onProgress } = options;
|
||||
const failures: SheetImportResult['failures'] = [];
|
||||
const createdSkus: string[] = [];
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
try {
|
||||
await productsApi.createProduct({
|
||||
tenantid,
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
categoryid: row.categoryid,
|
||||
subcategoryid: row.subcategoryid,
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
taxpercent: row.taxpercent,
|
||||
productunit: row.productunit,
|
||||
unitvalue: row.unitvalue,
|
||||
productbrand: row.productbrand,
|
||||
productdesc: row.productdesc,
|
||||
productstatus: 'Active',
|
||||
});
|
||||
createdSkus.push(row.productsku);
|
||||
} catch (error) {
|
||||
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
|
||||
}
|
||||
onProgress?.(index + 1, rows.length);
|
||||
}
|
||||
|
||||
if (createdSkus.length === 0) {
|
||||
return { created: 0, linked: 0, stocked: 0, failures };
|
||||
}
|
||||
|
||||
// Resolve the ids the create endpoint refused to hand back.
|
||||
const all = await productsApi.allProducts(tenantid);
|
||||
const bySku = new Map<string, Product>();
|
||||
for (const product of all) {
|
||||
if (product.productsku) bySku.set(product.productsku, product);
|
||||
}
|
||||
|
||||
const locationRows: ProductLocationRequest[] = [];
|
||||
const stockRows: ProductStockRequest[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!createdSkus.includes(row.productsku)) continue;
|
||||
const product = bySku.get(row.productsku);
|
||||
if (!product) {
|
||||
failures.push({ row, reason: 'Created, but could not be found again by SKU' });
|
||||
continue;
|
||||
}
|
||||
locationRows.push({
|
||||
tenantid,
|
||||
locationid,
|
||||
productid: product.productid,
|
||||
price: row.retailprice,
|
||||
status: 'available',
|
||||
});
|
||||
stockRows.push({
|
||||
tenantid,
|
||||
locationid,
|
||||
productid: product.productid,
|
||||
quantity: row.quantity,
|
||||
stocktype: 'in',
|
||||
status: 'Active',
|
||||
});
|
||||
}
|
||||
|
||||
if (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
|
||||
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
|
||||
|
||||
return {
|
||||
created: createdSkus.length,
|
||||
linked: locationRows.length,
|
||||
stocked: stockRows.length,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
98
src/api/stock.ts
Normal file
98
src/api/stock.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Stock requests and stock movement — the Store Admin's approval surface.
|
||||
*
|
||||
* Read `store-admin-backend-gap.md` §2.2 before extending this file. The
|
||||
* approval workflow the spec describes does not exist in Fiesta: the request
|
||||
* table has no reason, requester, approved quantity, approver or remarks, and
|
||||
* `UpdateStockRequest(requestID, status)` takes a bare string. Exactly one
|
||||
* value does anything — "Received" — and it posts a stock movement for the FULL
|
||||
* requested quantity, guarded against double-receiving.
|
||||
*
|
||||
* So "approve for a different quantity" is not implemented here because it
|
||||
* cannot be implemented here. It is a backend change, not a frontend one.
|
||||
*/
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type { StockRequest, StockStatementRow } from './types';
|
||||
|
||||
/**
|
||||
* The status values the backend actually distinguishes.
|
||||
*
|
||||
* `Received` is the only one with behaviour. The others are stored verbatim and
|
||||
* read back, which is enough to drive a queue but is not a state machine — the
|
||||
* backend will accept any string at all.
|
||||
*/
|
||||
export const STOCK_REQUEST_STATUS = {
|
||||
pending: 'Pending',
|
||||
received: 'Received',
|
||||
rejected: 'Rejected',
|
||||
} as const;
|
||||
|
||||
export type StockRequestStatus = (typeof STOCK_REQUEST_STATUS)[keyof typeof STOCK_REQUEST_STATUS];
|
||||
|
||||
export interface StockRequestQuery {
|
||||
tenantid: number;
|
||||
/** Omit for every branch. */
|
||||
locationid?: number;
|
||||
status?: string;
|
||||
date?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
|
||||
export const stockApi = {
|
||||
requests: (query: StockRequestQuery) =>
|
||||
api.get<StockRequest[]>(`${WEB}/products/getstockrequests`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
date: query.date,
|
||||
pageno: query.pageno ?? 0,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Approve a request — which means marking it Received.
|
||||
*
|
||||
* This adds `request.qty` to the branch's stock. There is no way to approve a
|
||||
* different amount: the service reads the quantity off the request row, not
|
||||
* off this call.
|
||||
*/
|
||||
approve: (requestid: number) =>
|
||||
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||
requestid,
|
||||
status: STOCK_REQUEST_STATUS.received,
|
||||
}),
|
||||
|
||||
/** Reject — a status write and nothing else. No stock moves, no reason stored. */
|
||||
reject: (requestid: number) =>
|
||||
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||
requestid,
|
||||
status: STOCK_REQUEST_STATUS.rejected,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Stock movement for one branch.
|
||||
*
|
||||
* Opening, credit, debit, closing — and that is the whole vocabulary.
|
||||
* `productstocks.stocktype` is `in`/`out`, so a sale, a transfer and a
|
||||
* correction are indistinguishable once written. The spec's seven movement
|
||||
* types (§3.8) cannot be sourced; see gap §2.5.
|
||||
*/
|
||||
statement: (params: {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
subcategoryid?: number;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}) =>
|
||||
api.get<StockStatementRow[]>(`${WEB}/products/getstockstatement`, {
|
||||
tenantid: params.tenantid,
|
||||
locationid: params.locationid,
|
||||
subcategoryid: params.subcategoryid,
|
||||
keyword: params.keyword,
|
||||
pageno: params.pageno ?? 0,
|
||||
pagesize: params.pagesize ?? 100,
|
||||
}),
|
||||
};
|
||||
71
src/api/tenants.ts
Normal file
71
src/api/tenants.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type { TenantInfo, TenantLocation } from './types';
|
||||
|
||||
/** Everything the tenant-onboarding form collects. */
|
||||
export interface CreateTenantRequest {
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
locationname: string;
|
||||
categoryid: number;
|
||||
subcategoryid?: number;
|
||||
address: string;
|
||||
suburb?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
moduleid?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** Everything the branch-onboarding form collects. */
|
||||
export interface CreateBranchRequest {
|
||||
tenantid: number;
|
||||
locationname: string;
|
||||
email?: string;
|
||||
contactno?: string;
|
||||
address: string;
|
||||
suburb?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
opentime?: string;
|
||||
closetime?: string;
|
||||
deliveryradius?: number;
|
||||
deliverymins?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const tenantsApi = {
|
||||
/**
|
||||
* Every tenant on the platform. Deliberately unscoped — this is the
|
||||
* Nearle Admin's list, and the backend treats it as the platform-operator
|
||||
* endpoint rather than a tenant-scoped one.
|
||||
*/
|
||||
listAll: () => api.get<TenantInfo[]>(`${WEB}/tenants/getalltenants`),
|
||||
|
||||
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
|
||||
locations: (tenantid: number) =>
|
||||
api.get<TenantLocation[]>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
|
||||
|
||||
search: (keyword: string) =>
|
||||
api.get<TenantInfo[]>(`${WEB}/tenants/searchbykeyword`, { keyword }),
|
||||
|
||||
/** Provisions the enterprise and spawns its primary Administrator account. */
|
||||
createTenant: (body: CreateTenantRequest) =>
|
||||
api.post<TenantInfo>(`${WEB}/tenants/createtenantlocation`, body),
|
||||
|
||||
/** Commissions a branch and spawns a placeholder branch-manager account. */
|
||||
createBranch: (body: CreateBranchRequest) =>
|
||||
api.post<TenantLocation>(`${WEB}/tenants/createlocation`, body),
|
||||
|
||||
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
|
||||
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
|
||||
};
|
||||
697
src/api/types.ts
Normal file
697
src/api/types.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* Types for the Fiesta REST API.
|
||||
*
|
||||
* Hand-written from the Go structs in `backend_fiesta/models` — there is no
|
||||
* OpenAPI document to generate from. Field names mirror the `json:` tags
|
||||
* EXACTLY, including the ones that are misspelled on the wire
|
||||
* (`applolcationid`, `Subcategoryname`, `Catlougeid`, `Accountname`). Fixing
|
||||
* them here would only mean the client silently reads undefined.
|
||||
*/
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Envelope
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Every Fiesta handler answers in this shape. `details` is the payload and its
|
||||
* type varies per endpoint — sometimes an object, sometimes an array, and on
|
||||
* the create endpoints an echo of what was sent.
|
||||
*
|
||||
* `status` is the success flag and `code` repeats the HTTP status in the body.
|
||||
* Both are checked: several handlers return HTTP 200 with `status: false`.
|
||||
*/
|
||||
export interface FiestaEnvelope<T> {
|
||||
status: boolean;
|
||||
code: number;
|
||||
message?: string;
|
||||
details?: T;
|
||||
/** Present on the tenant login endpoints. */
|
||||
tenantform?: boolean;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Users — models/users.go
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface FiestaUser {
|
||||
userid: number;
|
||||
authname: string;
|
||||
configid: number;
|
||||
authmode: number;
|
||||
roleid: number;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
fullname?: string;
|
||||
email: string;
|
||||
contactno: string;
|
||||
address?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
shiftid?: number;
|
||||
shiftname?: string;
|
||||
partnerid?: number;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
applocationid?: number;
|
||||
applocation?: string;
|
||||
status: string;
|
||||
/** Server-derived platform-operator flag. Never inferred client-side. */
|
||||
issuperadmin?: boolean;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Tenants & locations — models/tenant.go
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface TenantInfo {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
tenantname: string;
|
||||
locationname: string;
|
||||
tenanttype?: string;
|
||||
registrationno?: string;
|
||||
companyname?: string;
|
||||
primaryemail?: string;
|
||||
primarycontact?: string;
|
||||
locationcontact?: string;
|
||||
categoryid?: number;
|
||||
subcategoryid?: number;
|
||||
address?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
tenantimage?: string;
|
||||
tenantinfo?: string;
|
||||
partnerid?: number;
|
||||
minorder?: number;
|
||||
/** Misspelled on the wire — `applolcationid`, not `applocationid`. */
|
||||
applolcationid?: number;
|
||||
applocation?: string;
|
||||
approved?: number;
|
||||
moduleid?: number;
|
||||
subcategoryname?: string;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
/** Capitalised on the wire. */
|
||||
Accountname?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TenantLocation {
|
||||
locationid: number;
|
||||
tenantid: number;
|
||||
applocationid?: number;
|
||||
moduleid?: number;
|
||||
roleid?: number;
|
||||
locationname: string;
|
||||
email?: string;
|
||||
contactno?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
address?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
opentime?: string;
|
||||
closetime?: string;
|
||||
partnerid?: number;
|
||||
deliveryradius?: number;
|
||||
deliverymins?: number;
|
||||
cancelsecs?: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Global catalogue — models/catalogue.go (the separate pgvector database)
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CatalogueProduct {
|
||||
id: number;
|
||||
brand: string;
|
||||
product_name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
image_id?: string;
|
||||
images?: string[];
|
||||
size?: string;
|
||||
variant_key?: string;
|
||||
product_sku?: string;
|
||||
sku_source?: string;
|
||||
/**
|
||||
* A DISPLAY STRING, not a number — e.g. "₹33-37". The catalogue has no exact
|
||||
* price; `retailprice` always comes from the store owner at import time.
|
||||
*/
|
||||
price_range?: string;
|
||||
providers?: string[];
|
||||
fssai_license?: string;
|
||||
highlights?: string[];
|
||||
nutrients?: string[];
|
||||
}
|
||||
|
||||
export interface CatalogueBrand {
|
||||
brand: string;
|
||||
product_count: number;
|
||||
}
|
||||
|
||||
/** The composite key. A bare `id` repeats across brand tables — never use it alone. */
|
||||
export interface CatalogueRef {
|
||||
brand: string;
|
||||
catalogueid: number;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Products & stock — models/product.go
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface Product {
|
||||
productid: number;
|
||||
applocationid?: number;
|
||||
productlocationid?: number;
|
||||
tenantid?: number;
|
||||
categoryid?: number;
|
||||
categoryname?: string;
|
||||
subcategoryid?: number;
|
||||
/** Capitalised on the wire. */
|
||||
Subcategoryname?: string;
|
||||
catalogueid?: number;
|
||||
productname?: string;
|
||||
productimage?: string;
|
||||
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
|
||||
productimages?: string;
|
||||
productdesc?: string;
|
||||
productsku?: string;
|
||||
brandid?: number;
|
||||
productbrand?: string;
|
||||
productunit?: string;
|
||||
unitvalue?: string;
|
||||
productcost?: number;
|
||||
taxamount?: number;
|
||||
taxpercent?: number;
|
||||
productstock?: number;
|
||||
quantity?: number;
|
||||
/** Effective selling price at the scoped location. Read-only, computed. */
|
||||
price?: number;
|
||||
retailprice?: number;
|
||||
productstatus?: string;
|
||||
locationstatus?: string;
|
||||
}
|
||||
|
||||
export interface ProductCategory {
|
||||
categoryid: number;
|
||||
categoryname: string;
|
||||
}
|
||||
|
||||
export interface ProductSubCategory {
|
||||
subcategoryid: number;
|
||||
subcategoryname: string;
|
||||
categoryid?: number;
|
||||
}
|
||||
|
||||
/** Body of POST /web/products/importcatalogueproduct — send an ARRAY of these. */
|
||||
export interface ImportCatalogueProductRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
brand: string;
|
||||
catalogueid: number;
|
||||
categoryid: number;
|
||||
subcategoryid: number;
|
||||
quantity: number;
|
||||
stocktype: string;
|
||||
status: string;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
taxpercent: number;
|
||||
}
|
||||
|
||||
/** Body of POST /web/products/createproductlocation — send an ARRAY. Upserts. */
|
||||
export interface ProductLocationRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
productid: number;
|
||||
/** Misspelled on the wire — `catlougeid`. */
|
||||
catlougeid?: number;
|
||||
minquantity?: number;
|
||||
maxquantity?: number;
|
||||
price?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** Body of POST /web/products/createproductstock — send an ARRAY. */
|
||||
export interface ProductStockRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
productid: number;
|
||||
quantity: number;
|
||||
stocktype: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Orders & deliveries — the summary shapes the console reads
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface OrderSummary {
|
||||
totalorders?: number;
|
||||
delivered?: number;
|
||||
pending?: number;
|
||||
cancelled?: number;
|
||||
revenue?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LocationOrderSummary {
|
||||
locationid?: number;
|
||||
locationname?: string;
|
||||
totalorders?: number;
|
||||
delivered?: number;
|
||||
cancelled?: number;
|
||||
revenue?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DeliverySummary {
|
||||
totaldeliveries?: number;
|
||||
dispatched?: number;
|
||||
delivered?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
POS — /v1/pos/*
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** `GET /pos/sales` — verified against `models/pos.go` `PosSalesPage`. */
|
||||
export interface PosSalesPage {
|
||||
total?: number;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
bills?: PosSale[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One counter bill, from `models/posorder.go` `PosOrders`.
|
||||
*
|
||||
* Two identifiers, and they mean different things. `terminalorderid` is a UUID
|
||||
* minted at the till, globally unique, and the only thing that safely keys a
|
||||
* row — delivery is at-least-once, so the same bill legitimately arrives more
|
||||
* than once. `invoicenumber` is the human-facing one and is unique only per
|
||||
* terminal: a replaced till restarts its series, so duplicates across terminals
|
||||
* are expected and gaps are normal.
|
||||
*
|
||||
* `billedat` is when the sale was rung, NOT when it reached us. A till that was
|
||||
* offline all day uploads bills dated yesterday, so every daily figure must use
|
||||
* this rather than `receivedat` — otherwise a sync outage looks like a bumper
|
||||
* trading day the moment it clears.
|
||||
*/
|
||||
export interface PosSale {
|
||||
posorderid?: number;
|
||||
terminalorderid?: string;
|
||||
invoicenumber?: string;
|
||||
tenantid?: number;
|
||||
locationid?: number;
|
||||
terminalid?: string;
|
||||
cashiername?: string;
|
||||
customerid?: number;
|
||||
customername?: string;
|
||||
customermobile?: string;
|
||||
billedat?: string;
|
||||
businessdate?: string;
|
||||
subtotal?: number;
|
||||
discount?: number;
|
||||
taxamount?: number;
|
||||
roundoff?: number;
|
||||
/** What the shopper actually paid — the figure every revenue report sums. */
|
||||
total?: number;
|
||||
itemcount?: number;
|
||||
/** The largest tender. `paymentsjson` carries the full split. */
|
||||
paymentmode?: string;
|
||||
paymentsjson?: string;
|
||||
/** Which upload batch carried this bill, for tracing a till's complaint. */
|
||||
batchid?: string;
|
||||
receivedat?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One order row, from `models/order.go` `OrderInfo`.
|
||||
*
|
||||
* A subset — the struct has ~70 fields including pickup/drop geometry and push
|
||||
* tokens that no list needs. Note that the delivery half lives here too: rider,
|
||||
* KMS and every timestamp of the journey are columns on the order, which is why
|
||||
* the old console served orders and deliveries from one read.
|
||||
*
|
||||
* `orderheaderid` is the key, NOT `orderid`. The old console's own comment
|
||||
* records why: "orderid is not unique".
|
||||
*/
|
||||
export interface OrderRow {
|
||||
orderheaderid: number;
|
||||
orderid?: string;
|
||||
tenantid?: number;
|
||||
locationid?: number;
|
||||
locationname?: string;
|
||||
orderdate?: string;
|
||||
deliverydate?: string;
|
||||
orderstatus?: string;
|
||||
deliverystatus?: string;
|
||||
itemcount?: number;
|
||||
/** The old console reads `quantity` first; not every writer sets itemcount. */
|
||||
quantity?: number;
|
||||
/**
|
||||
* The order's money, and it moves.
|
||||
*
|
||||
* The old console reads `ordervalue || orderamount || deliveryamt` — a
|
||||
* fallback chain, not indecision. `ordervalue` is on the fuller Orders struct
|
||||
* (amount + tax + charges − promo), `orderamount` is the goods alone, and
|
||||
* `deliveryamt` is what a delivery job carries. Which one is populated
|
||||
* depends on the endpoint the row came through, so all three are typed.
|
||||
*/
|
||||
ordervalue?: number;
|
||||
orderamount?: number;
|
||||
/** Cash to collect on delivery. Shown only when > 0. */
|
||||
collectionamt?: number;
|
||||
deliverycharge?: number;
|
||||
deliveryamt?: number;
|
||||
paymenttype?: number;
|
||||
paymentstatus?: number;
|
||||
taxamount?: number;
|
||||
ordernotes?: string;
|
||||
/** The delivery REGION — a coarser grouping than the branch. */
|
||||
applocation?: string;
|
||||
/** Set when the shop finished packing. Sits between confirm and pickup. */
|
||||
packtime?: string;
|
||||
starttime?: string;
|
||||
arrivaltime?: string;
|
||||
/** Who placed it, and where it is going. */
|
||||
deliverycustomer?: string;
|
||||
deliverycontactno?: string;
|
||||
deliveryaddress?: string;
|
||||
deliverysuburb?: string;
|
||||
pickupcustomer?: string;
|
||||
pickupcontactno?: string;
|
||||
pickupaddress?: string;
|
||||
pickupsuburb?: string;
|
||||
/** The delivery half. Blank on an order nobody has been assigned to. */
|
||||
deliveryid?: number;
|
||||
rider?: string;
|
||||
ridercontactno?: string;
|
||||
riderkms?: string | number;
|
||||
assigntime?: string;
|
||||
pickuptime?: string;
|
||||
deliverytime?: string;
|
||||
canceltime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One till, as it last reported itself.
|
||||
*
|
||||
* Every value is a STRING. This is a Redis hash under a TTL, not a table:
|
||||
* `services/posService.go` types it `[]map[string]string` and hands the hash
|
||||
* straight back, so numbers arrive as `"14"` and booleans as `"true"`. Typing
|
||||
* them as numbers here would compile and then silently produce `NaN`, so the
|
||||
* shape stays honest and the parsing is explicit at the call site.
|
||||
*
|
||||
* A terminal that stops refreshing simply disappears from the array — there is
|
||||
* no reaper job and no stale row claiming "online" after closing time.
|
||||
*/
|
||||
export interface PosTerminalHealth {
|
||||
terminal_id?: string;
|
||||
location_id?: string;
|
||||
store_name?: string;
|
||||
app_version?: string;
|
||||
/** "online" while refreshing. The MQTT Last Will writes "offline" on power loss. */
|
||||
status?: string;
|
||||
/** Queue depth. The number that matters most — see `models/poshealth.go`. */
|
||||
pending_bills?: string;
|
||||
pending_registrations?: string;
|
||||
oldest_pending_at?: string;
|
||||
today_bills?: string;
|
||||
today_amount?: string;
|
||||
last_bill_at?: string;
|
||||
battery_level?: string;
|
||||
battery_charging?: string;
|
||||
storage_free_mb?: string;
|
||||
printer_reachable?: string;
|
||||
drawer_status?: string;
|
||||
/** When the till says it wrote this hash — the till's own clock. */
|
||||
reported_at?: string;
|
||||
/**
|
||||
* When the server received it — the server's clock.
|
||||
*
|
||||
* The two disagreeing is itself the signal: a till whose clock is wrong
|
||||
* writes bills under the wrong business date. See `models/poshealth.go`.
|
||||
*/
|
||||
received_at?: string;
|
||||
/**
|
||||
* Why the till went offline, present only on an offline record.
|
||||
*
|
||||
* On the expired-key stub this is the ONLY field carrying information —
|
||||
* `{terminal_id, location_id, status, reason}` and nothing else. That
|
||||
* shape is how a vanished till is told apart from one that declared itself
|
||||
* offline on its way out; see `isExpiredStub` in `posStatus.ts`.
|
||||
*/
|
||||
reason?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
/** `GET /pos/health/location` returns the tills at one outlet, newest state first. */
|
||||
export type PosLocationHealth = PosTerminalHealth[];
|
||||
|
||||
/** `GET /pos/sales/summary` — verified against `models/pos.go` `PosSalesSummary`. */
|
||||
export interface PosSalesSummary {
|
||||
locationid?: number;
|
||||
fromdate?: string;
|
||||
todate?: string;
|
||||
billcount?: number;
|
||||
itemcount?: number;
|
||||
grosssales?: number;
|
||||
taxcollected?: number;
|
||||
discountgiven?: number;
|
||||
roundoff?: number;
|
||||
averagebill?: number;
|
||||
bypaymentmode?: { paymentmode?: string; amount?: number; billcount?: number }[];
|
||||
byday?: { day?: string; amount?: number; billcount?: number }[];
|
||||
/**
|
||||
* Which tills contributed. Note the spelling.
|
||||
*
|
||||
* `models/posorder.go` declares `PosTerminalTotal.Terminalid` with the tag
|
||||
* `json:"terminalid"` — no underscore — while the presence payload uses
|
||||
* `terminal_id`. The two surfaces disagree, and reading this one with the
|
||||
* presence spelling silently yields `undefined` for every row, which is
|
||||
* exactly the bug that made the billed-but-absent reconciliation a no-op.
|
||||
*
|
||||
* `terminalid` is also `COALESCE`d to `''` in the GROUP BY, so bills with no
|
||||
* code collapse into a single empty-id row — see `readTerminalId`.
|
||||
*/
|
||||
byterminal?: { terminalid?: string; amount?: number; billcount?: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A store's request for stock, from `models/stockrequest.go`.
|
||||
*
|
||||
* Note what is NOT here: reason, requestedby, approvedqty, approvedby,
|
||||
* approvaldate, remarks. The spec asks for all six; the table has none of them.
|
||||
* `status` is a free string whose only meaningful value is "Received", which
|
||||
* posts a stock movement for the FULL `qty` and cannot be overridden.
|
||||
*/
|
||||
export interface StockRequest {
|
||||
requestid: number;
|
||||
tenantid: number;
|
||||
tenantname?: string;
|
||||
locationid: number;
|
||||
locationname?: string;
|
||||
productid: number;
|
||||
productname?: string;
|
||||
productimage?: string;
|
||||
qty: number;
|
||||
status: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
/** From `models/product.go` `Productstockstatement` — opening/in/out/closing, nothing finer. */
|
||||
export interface StockStatementRow {
|
||||
productid: number;
|
||||
productname?: string;
|
||||
productimage?: string;
|
||||
productunit?: string;
|
||||
unitvalue?: string;
|
||||
retailprice?: number;
|
||||
locationid?: number;
|
||||
opening?: number;
|
||||
credit?: number;
|
||||
debit?: number;
|
||||
closing?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One delivery job, from `models/deliveries.go` `Deliveryinfo`.
|
||||
*
|
||||
* A SEPARATE read from the orders list, not a projection of it. The two agree
|
||||
* on the order id and diverge everywhere else: a delivery carries `ridername`
|
||||
* (the orders list calls it `rider`), a planned `kms` alongside an `actualkms`,
|
||||
* and `deliverycharges` — what the rider is paid — alongside `deliveryamt`,
|
||||
* what the job is worth. None of that exists on the order.
|
||||
*
|
||||
* `orderstatus` here walks the DELIVERY ladder, not the order one:
|
||||
* pending → accepted → arrived → picked → active → delivered, with skipped and
|
||||
* cancelled off to the side. Colouring it with the order map would be wrong.
|
||||
*
|
||||
* Note `Pickupaddress` is capitalised on the wire. That is the Go tag, not a
|
||||
* typo here — the old console falls back through four fields for the same
|
||||
* reason, because app-created jobs leave the customer blank but fill the
|
||||
* address.
|
||||
*/
|
||||
export interface DeliveryRow {
|
||||
deliveryid: number;
|
||||
orderheaderid?: number;
|
||||
orderid?: string;
|
||||
tenantid?: number;
|
||||
locationid?: number;
|
||||
locationname?: string;
|
||||
locationsuburb?: string;
|
||||
tenantname?: string;
|
||||
tenantcity?: string;
|
||||
orderstatus?: string;
|
||||
deliverydate?: string;
|
||||
assigntime?: string;
|
||||
starttime?: string;
|
||||
arrivaltime?: string;
|
||||
pickuptime?: string;
|
||||
deliverytime?: string;
|
||||
canceltime?: string;
|
||||
expecteddeliverytime?: string;
|
||||
itemcount?: number;
|
||||
orderamount?: number;
|
||||
pickupcustomer?: string;
|
||||
pickupcontactno?: string;
|
||||
/** Capitalised on the wire — see the note above. */
|
||||
Pickupaddress?: string;
|
||||
pickupaddress?: string;
|
||||
pickuplocation?: string;
|
||||
pickupsuburb?: string;
|
||||
deliverycustomer?: string;
|
||||
deliverycontactno?: string;
|
||||
deliveryaddress?: string;
|
||||
deliverylocation?: string;
|
||||
deliverysuburb?: string;
|
||||
/** What the rider is paid. Distinct from `deliveryamt`, the job's value. */
|
||||
deliverycharges?: number;
|
||||
deliveryamt?: number;
|
||||
deliverytype?: string;
|
||||
notes?: string;
|
||||
ordernotes?: string;
|
||||
ridername?: string;
|
||||
ridercontact?: string;
|
||||
/** Planned distance. `actualkms`/`riderkms` is what was really ridden. */
|
||||
kms?: string;
|
||||
actualkms?: string;
|
||||
riderkms?: string;
|
||||
transitminutes?: number;
|
||||
}
|
||||
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
People — back-office staff and till accounts
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One back-office person, from `GET /tenants/getstaffs` (`models.StaffInfo`).
|
||||
*
|
||||
* `password` is DELIBERATELY ABSENT from this type. The endpoint returns it —
|
||||
* passwords are stored and compared in clear — so declaring it here is all it
|
||||
* would take for a careless cell to render one. Leaving it off the type means
|
||||
* the compiler refuses.
|
||||
*
|
||||
* `rolename` is resolved server-side and is the only trustworthy label. The
|
||||
* backend's own comment: "`app_roles` holds six rows for four back-office roles
|
||||
* and most accounts carry an id absent from it, so any mapping written
|
||||
* client-side is wrong."
|
||||
*/
|
||||
export interface StaffInfo {
|
||||
userid: number;
|
||||
rolename?: string;
|
||||
roleid?: number;
|
||||
authname?: string;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
fullname?: string;
|
||||
email?: string;
|
||||
contactno?: string;
|
||||
address?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
tenantid?: number;
|
||||
locationid?: number;
|
||||
locationname?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One till account, from `GET /getposusers` (`models.PosUser`).
|
||||
*
|
||||
* Note the wire names are snake_case here and camel-ish everywhere else in this
|
||||
* API — the POS half was written later and to its own convention.
|
||||
*
|
||||
* `password` is returned ONLY in the answer to a creation or a reset, never by
|
||||
* a listing, which is the backend's deliberate shape: "An admin who loses it
|
||||
* reissues rather than looks it up." We show it once at creation and never
|
||||
* store or re-read it. `pin` is treated the same way.
|
||||
*/
|
||||
export interface PosUser {
|
||||
user_id: number;
|
||||
full_name?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
authname?: string;
|
||||
contactno?: string;
|
||||
role_id?: number;
|
||||
role?: string;
|
||||
has_password?: boolean;
|
||||
shift_id?: number;
|
||||
shift_name?: string;
|
||||
shift_start?: string;
|
||||
shift_end?: string;
|
||||
location_id?: number;
|
||||
status?: string;
|
||||
/** Present only on a create/reset response. Never on a listing. */
|
||||
password?: string;
|
||||
pin?: string;
|
||||
}
|
||||
|
||||
/** `GET /posroles` — the source of the role picker. Never hardcode 7 and 8. */
|
||||
export interface PosRole {
|
||||
role_id: number;
|
||||
/** The lowercase name the create endpoint expects — "supervisor", "cashier". */
|
||||
role: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shift window, from `models.StaffShifts`.
|
||||
*
|
||||
* `start_time` / `end_time` are wall-clock `HH:MM` text, not instants — they
|
||||
* repeat, and a date component on them invites exactly the timezone confusion
|
||||
* that once filed a day of POS bills under the wrong business date.
|
||||
*
|
||||
* `weekdays` is a 7-character mask starting Monday: "1111100" is Mon–Fri.
|
||||
* Empty means every day.
|
||||
*/
|
||||
export interface StaffShift {
|
||||
staff_shift_id: number;
|
||||
tenantid?: number;
|
||||
locationid?: number;
|
||||
name?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
weekdays?: string;
|
||||
status?: string;
|
||||
}
|
||||
73
src/auth/AuthContext.tsx
Normal file
73
src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createContext, use, useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { HOME_ROUTE, type ConsoleRole, type SessionUser } from './roles';
|
||||
import { clear, login as loginRequest, persist, restore } from './session';
|
||||
import { disableDemo, enableDemo } from '@/demo';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: SessionUser | null;
|
||||
signIn: (email: string, password: string) => Promise<SessionUser>;
|
||||
/** Development only — see `src/demo`. Absent from a production build. */
|
||||
signInAsDemo: (session: SessionUser) => void;
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<SessionUser | null>(() => restore());
|
||||
|
||||
const signIn = useCallback(async (email: string, password: string) => {
|
||||
const session = await loginRequest(email, password);
|
||||
setUser(session);
|
||||
return session;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Seeds a session without touching the API. Demo mode is switched on at the
|
||||
* same moment, so the fixture backend and the fixture session can never be
|
||||
* out of step — a demo user paired with live data would be the worst of both.
|
||||
*/
|
||||
const signInAsDemo = useCallback((session: SessionUser) => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
enableDemo();
|
||||
persist(session);
|
||||
setUser(session);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
if (import.meta.env.DEV) disableDemo();
|
||||
clear();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, signIn, signInAsDemo, signOut }),
|
||||
[user, signIn, signInAsDemo, signOut],
|
||||
);
|
||||
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = use(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used inside <AuthProvider>');
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard.
|
||||
*
|
||||
* A role that reaches a workspace it does not own is redirected to its own home
|
||||
* rather than shown an error — the same partition the old console enforces, and
|
||||
* the same one the three logins imply.
|
||||
*/
|
||||
export function RequireRole({ role, children }: { role: ConsoleRole; children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
if (user.role !== role) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
88
src/auth/roles.ts
Normal file
88
src/auth/roles.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Role resolution.
|
||||
*
|
||||
* The backend issues no session token: the login endpoints look the user up and
|
||||
* return the record. So "signed in" here means "we hold a verified user object",
|
||||
* and the role is DERIVED from that record rather than asserted by the client.
|
||||
*
|
||||
* When the backend does start issuing tokens, this file and `session.ts` are
|
||||
* the only two that should need to change.
|
||||
*/
|
||||
|
||||
import type { FiestaUser } from '@/api/types';
|
||||
|
||||
export type ConsoleRole = 'nearle-admin' | 'store-admin' | 'store-manager';
|
||||
|
||||
/**
|
||||
* Roleids that reach the Store Admin workspace.
|
||||
*
|
||||
* 7 (Supervisor) and 8 (Cashier) must NEVER appear here: they are till roles,
|
||||
* and a cashier landing in the tenant console is a privilege escalation, not a
|
||||
* cosmetic bug.
|
||||
*/
|
||||
const STORE_ADMIN_ROLE_IDS: ReadonlySet<number> = new Set([1, 3]);
|
||||
|
||||
/** Till-only roles, listed so the exclusion is explicit rather than implied. */
|
||||
export const TILL_ROLE_IDS: ReadonlySet<number> = new Set([7, 8]);
|
||||
|
||||
export interface SessionUser {
|
||||
userid: number;
|
||||
role: ConsoleRole;
|
||||
name: string;
|
||||
email: string;
|
||||
roleid: number;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
issuperadmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `issuperadmin` is checked FIRST because it is server-derived. A roleid cannot
|
||||
* be trusted to imply platform access, so the flag wins over the numeric split.
|
||||
*/
|
||||
export function resolveRole(user: Pick<FiestaUser, 'roleid' | 'issuperadmin'>): ConsoleRole {
|
||||
if (user.issuperadmin === true) return 'nearle-admin';
|
||||
if (STORE_ADMIN_ROLE_IDS.has(user.roleid)) return 'store-admin';
|
||||
return 'store-manager';
|
||||
}
|
||||
|
||||
export function toSessionUser(user: FiestaUser): SessionUser {
|
||||
const name = [user.firstname, user.lastname].filter(Boolean).join(' ').trim();
|
||||
return {
|
||||
userid: user.userid,
|
||||
role: resolveRole(user),
|
||||
name: name || user.fullname || user.authname || user.email,
|
||||
email: user.email,
|
||||
roleid: user.roleid,
|
||||
tenantid: user.tenantid,
|
||||
locationid: user.locationid,
|
||||
issuperadmin: user.issuperadmin === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where each role lands when it has nowhere more specific to go. */
|
||||
/**
|
||||
* Where each role lands.
|
||||
*
|
||||
* These MUST be paths that actually resolve. The global `*` route redirects
|
||||
* here, so a HOME_ROUTE pointing at a path with no matching route sends the
|
||||
* router straight back to `*`, which sends it here again: an infinite redirect
|
||||
* that React Router resolves by rendering nothing at all. It fails as a blank
|
||||
* page with no console error, which is the worst way for a routing bug to
|
||||
* present. `/admin/dashboard` did exactly that — it was the old console's name
|
||||
* for the page this one calls Console.
|
||||
*
|
||||
* Each workspace also carries its own catch-all in `App.tsx`, so a wrong
|
||||
* sub-path is absorbed there and never reaches the global one.
|
||||
*/
|
||||
export const HOME_ROUTE: Record<ConsoleRole, string> = {
|
||||
'nearle-admin': '/nearle/stores',
|
||||
'store-admin': '/admin/console',
|
||||
'store-manager': '/store/console',
|
||||
};
|
||||
|
||||
export const ROLE_LABEL: Record<ConsoleRole, string> = {
|
||||
'nearle-admin': 'Nearle Admin',
|
||||
'store-admin': 'Store Admin',
|
||||
'store-manager': 'Store Manager',
|
||||
};
|
||||
81
src/auth/session.ts
Normal file
81
src/auth/session.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Sign-in and session persistence.
|
||||
*
|
||||
* There is no token to hold. `TenantWebLogin` returns the user record and
|
||||
* nothing else, so the session IS that record. It is kept in sessionStorage
|
||||
* rather than localStorage: a shared back-office machine should not stay signed
|
||||
* in after the browser closes, and there is no server-side session to revoke.
|
||||
*/
|
||||
|
||||
import { api, WEB } from '@/api/client';
|
||||
import type { FiestaUser } from '@/api/types';
|
||||
import { toSessionUser, type SessionUser } from './roles';
|
||||
|
||||
const STORAGE_KEY = 'nearle.session.v1';
|
||||
|
||||
/** Thrown when the account exists but has never had a password set. */
|
||||
export class PasswordSetupRequiredError extends Error {
|
||||
readonly userid: number;
|
||||
constructor(userid: number) {
|
||||
super('This account needs a password before it can sign in.');
|
||||
this.name = 'PasswordSetupRequiredError';
|
||||
this.userid = userid;
|
||||
}
|
||||
}
|
||||
|
||||
interface LoginBody {
|
||||
authname: string;
|
||||
password: string;
|
||||
roleid?: number;
|
||||
configid?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in against the web login endpoint.
|
||||
*
|
||||
* The handler answers HTTP 200 with `status: false` for a wrong password, and
|
||||
* with `code: 409` plus `details.setup` when no password has been set, so the
|
||||
* envelope is inspected rather than trusting the HTTP status.
|
||||
*/
|
||||
export async function login(email: string, password: string): Promise<SessionUser> {
|
||||
const body: LoginBody = { authname: email.trim(), password };
|
||||
|
||||
const envelope = await api.envelope<FiestaUser & { setup?: boolean; userid?: number }>(
|
||||
`${WEB}/users/tenant/weblogin`,
|
||||
{ method: 'POST', body },
|
||||
);
|
||||
|
||||
if (envelope.code === 409 && envelope.details?.setup === true) {
|
||||
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
|
||||
}
|
||||
|
||||
if (envelope.status !== true || !envelope.details) {
|
||||
throw new Error(envelope.message ?? 'Sign-in failed');
|
||||
}
|
||||
|
||||
const session = toSessionUser(envelope.details);
|
||||
persist(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function persist(session: SessionUser): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function restore(): SessionUser | null {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as SessionUser;
|
||||
// A stored blob is only as trustworthy as the tab it came from; a shape
|
||||
// check keeps a corrupted value from crashing the shell on boot.
|
||||
if (typeof parsed?.userid !== 'number' || typeof parsed?.role !== 'string') return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
67
src/components/ConsoleSearchBar.tsx
Normal file
67
src/components/ConsoleSearchBar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
export interface ConsoleSearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function ConsoleSearchBar({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search',
|
||||
className,
|
||||
style,
|
||||
}: ConsoleSearchBarProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={className}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 10,
|
||||
color: 'var(--color-ink-4)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
style={{
|
||||
height: 32,
|
||||
width: '100%',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isFocused ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
||||
background: isFocused ? 'var(--color-surface)' : 'var(--color-surface-subtle)',
|
||||
paddingLeft: 32,
|
||||
paddingRight: 12, // Removed space for kbd since it's not present here
|
||||
fontSize: 13,
|
||||
color: 'var(--color-ink-1)',
|
||||
outline: 'none',
|
||||
transition: 'border-color .2s, background .2s',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
65
src/components/DataState.tsx
Normal file
65
src/components/DataState.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { AlertTriangle, Inbox } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
|
||||
export interface DataStateProps {
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
isEmpty: boolean;
|
||||
/** What is missing, in the user's words — "No tenants yet". */
|
||||
emptyTitle: string;
|
||||
emptyDescription?: string;
|
||||
emptyAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four states every list has, in one place.
|
||||
*
|
||||
* Written as a wrapper rather than repeated per screen because the failure mode
|
||||
* it prevents is a real one: pages that handle loading and success, and render
|
||||
* a blank rectangle for empty and error.
|
||||
*/
|
||||
export function DataState({
|
||||
isLoading,
|
||||
error,
|
||||
isEmpty,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
children,
|
||||
}: DataStateProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<HStack justify="center" padding={6}>
|
||||
<Spinner size="md" label="Loading" />
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="That did not load"
|
||||
description={errorMessage(error)}
|
||||
icon={<AlertTriangle size={22} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
icon={<Inbox size={22} />}
|
||||
actions={emptyAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
53
src/components/Freshness.tsx
Normal file
53
src/components/Freshness.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
export interface FreshnessProps {
|
||||
/** `dataUpdatedAt` from the query that fed the figure. */
|
||||
updatedAt: number | undefined;
|
||||
isFetching?: boolean;
|
||||
/** Bills sitting on a till that has not synced. Hidden when zero. */
|
||||
unsynced?: number;
|
||||
}
|
||||
|
||||
function clock(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How old the number above this line is.
|
||||
*
|
||||
* Online orders land as they are placed; counter sales arrive on the console's
|
||||
* 30-second refetch, and whatever is still sitting on an offline till has not
|
||||
* arrived at all. So any figure blending the two is eventually consistent, and
|
||||
* printing one confident number without saying when it was true is how a screen
|
||||
* loses the room's trust.
|
||||
*/
|
||||
export function Freshness({ updatedAt, isFetching, unsynced }: FreshnessProps) {
|
||||
return (
|
||||
<HStack align="center" gap={1.5} wrap="wrap">
|
||||
<HStack align="center" gap={0.5}>
|
||||
<RefreshCw
|
||||
size={12}
|
||||
style={{
|
||||
color: 'var(--color-slate-400)',
|
||||
animation: isFetching ? 'spin 1s linear infinite' : undefined,
|
||||
}}
|
||||
/>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{updatedAt ? `as of ${clock(updatedAt)}` : 'not loaded yet'}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{typeof unsynced === 'number' && unsynced > 0 ? (
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
· {unsynced} bill{unsynced === 1 ? '' : 's'} not yet synced
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
86
src/components/KpiCard.tsx
Normal file
86
src/components/KpiCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } 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';
|
||||
|
||||
export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral';
|
||||
|
||||
const TONE_COLOR: Record<KpiTone, string> = {
|
||||
accent: 'var(--color-brand)',
|
||||
success: 'var(--color-brand)',
|
||||
warning: 'var(--color-brand)',
|
||||
error: 'var(--color-brand)',
|
||||
neutral: 'var(--color-brand)',
|
||||
};
|
||||
|
||||
export interface KpiCardProps {
|
||||
/** Small-caps label. Say what it is, not what it means. */
|
||||
label: string;
|
||||
value: string;
|
||||
/**
|
||||
* The sub-pill. This is where the number gets its meaning — "1 of 84 orders"
|
||||
* says something "1.2%" does not.
|
||||
*/
|
||||
note?: string;
|
||||
tone?: KpiTone;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A KPI tile.
|
||||
*
|
||||
* Label and icon, a large tabular value, a muted sub-pill carrying the
|
||||
* interpretation, and a 2px underline in the tile's tone. Sized to sit 4–6
|
||||
* across rather than 4 — a console is glanced at all day, not read once.
|
||||
*/
|
||||
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
|
||||
const color = TONE_COLOR[tone];
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2} style={{ position: 'relative', paddingBottom: 12, minHeight: 108 }}>
|
||||
<HStack justify="between" align="start" gap={1}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{icon ? <span style={{ color, display: 'flex' }}>{icon}</span> : null}
|
||||
</HStack>
|
||||
|
||||
<HStack justify="center" align="center" style={{ flex: 1 }}>
|
||||
<Text
|
||||
as="div"
|
||||
hasTabularNumbers
|
||||
style={{ color, fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, lineHeight: 1.1 }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{note ? (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: 16,
|
||||
background: 'var(--color-slate-100)',
|
||||
color: 'var(--color-slate-500)',
|
||||
borderRadius: 12,
|
||||
padding: '2px 6px',
|
||||
fontSize: 10.5,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{note}
|
||||
</span>
|
||||
) : null}
|
||||
</VStack>
|
||||
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
34
src/components/PageBody.tsx
Normal file
34
src/components/PageBody.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
|
||||
/**
|
||||
* Two page measures, and the rule for choosing between them.
|
||||
*
|
||||
* KROW declares `--container-max: 80rem` for a page and a wider shell measure
|
||||
* for the app frame, and the distinction matters: a form stretched across
|
||||
* 1600px puts five inputs in a row and makes the eye travel further than the
|
||||
* reading task deserves, while a table at 80rem wastes half the screen.
|
||||
*
|
||||
* 'reading' — forms, single flows, anything read top to bottom
|
||||
* 'data' — tables, dashboards, catalogue grids
|
||||
*/
|
||||
export type PageMeasure = 'reading' | 'data';
|
||||
|
||||
const MAX_WIDTH: Record<PageMeasure, string> = {
|
||||
reading: 'var(--container-page, 80rem)',
|
||||
data: 'var(--container-admin, 102rem)',
|
||||
};
|
||||
|
||||
export function PageBody({
|
||||
measure = 'data',
|
||||
children,
|
||||
}: {
|
||||
measure?: PageMeasure;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={3} width="100%" style={{ maxWidth: MAX_WIDTH[measure] }}>
|
||||
{children}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
109
src/components/PageHeader.tsx
Normal file
109
src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
/** A real count beside the name — "8 on record". Tabular, meta-coloured. */
|
||||
count?: string;
|
||||
/** One line of context. */
|
||||
description?: string;
|
||||
/** Right-aligned actions, wrapping. */
|
||||
actions?: ReactNode;
|
||||
/** An optional tabs row directly under the header rule. */
|
||||
tabs?: ReactNode;
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page frame header, built to KROW's `AdminPage` spec.
|
||||
*
|
||||
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
|
||||
* right-aligned and wrapping, and — the part that makes pages feel like one
|
||||
* product — a hairline rule beneath the whole thing at half-opacity, with 16px
|
||||
* of padding above it. Everything the page stacks below sits on a 24px rhythm.
|
||||
*/
|
||||
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 50%, transparent)',
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h1
|
||||
className="page-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{count ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: 'var(--color-ink-4)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{isLive ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
padding: '2px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-brand)',
|
||||
}}
|
||||
/>
|
||||
Live
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actions ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
{tabs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/components/SectionHeader.tsx
Normal file
34
src/components/SectionHeader.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
|
||||
export interface SectionHeaderProps {
|
||||
title: string;
|
||||
/**
|
||||
* Sits inline beside the title, not beneath it.
|
||||
*
|
||||
* Required rather than optional on purpose: a bare heading tells the reader
|
||||
* what a block is called but not what it is showing them, and every section
|
||||
* in this console has something worth saying there.
|
||||
*/
|
||||
note: string;
|
||||
/** A control or a "View all" link, right-aligned. */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, note, action }: SectionHeaderProps) {
|
||||
return (
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack align="center" gap={1.5} wrap="wrap">
|
||||
<Heading level={2}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text type="body" color="secondary">
|
||||
{note}
|
||||
</Text>
|
||||
</HStack>
|
||||
{action}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
787
src/components/shell/AppShell.tsx
Normal file
787
src/components/shell/AppShell.tsx
Normal file
@@ -0,0 +1,787 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, Sparkles, X } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { ROLE_LABEL } from '@/auth/roles';
|
||||
import { AssistantPanel } from './AssistantPanel';
|
||||
|
||||
export interface NavEntry {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** One entry in the account menu's MANAGE group. */
|
||||
export interface MenuEntry {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
/** One line under the label, for an entry whose scope is not obvious. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface AppShellProps {
|
||||
/** Destinations for the header tabs and the mobile sheet. */
|
||||
nav: readonly NavEntry[];
|
||||
/** Where the logo links to — the workspace's own landing page. */
|
||||
home: string;
|
||||
/** Accessible name for the tab list, e.g. "Store Admin". */
|
||||
navLabel: string;
|
||||
/**
|
||||
* An optional control between the logo and the tabs — the Store Admin's
|
||||
* branch selector lives here. It sits inside the header rather than on each
|
||||
* page because it scopes every page, and a control that moves between pages
|
||||
* reads as a different control each time.
|
||||
*/
|
||||
scopeControl?: ReactNode;
|
||||
/**
|
||||
* Setup destinations, listed inside the account menu rather than in the nav.
|
||||
*
|
||||
* This follows the old console's judgement, and its reasoning holds: setup is
|
||||
* configuration, not a place anyone works from day to day, and a nav slot
|
||||
* spent on it is a slot taken from a section that IS worked from. The account
|
||||
* menu is where "my workspace's setup" belongs, beside the account and
|
||||
* sign-out.
|
||||
*
|
||||
* Listed as individual destinations rather than one "Settings" entry for the
|
||||
* old console's other reason: a single entry lands everyone on the first
|
||||
* section and makes them click again, and the sections are what people come
|
||||
* here for.
|
||||
*/
|
||||
manageItems?: readonly MenuEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The application chrome, shared by every workspace.
|
||||
*
|
||||
* Built to KROW's `AdminLayout` spec. Deliberately NOT a coloured slab: the
|
||||
* header is a 56px white-at-85% bar with a backdrop blur and a hairline bottom
|
||||
* border, carrying text tabs whose active state is accent-coloured text plus a
|
||||
* 2px accent bar sitting on that border. The repo's own reasoning for text tabs
|
||||
* over pills: several destinations means several competing shapes if each one
|
||||
* is a pill, and a console header should recede rather than compete with the
|
||||
* page.
|
||||
*
|
||||
* One component rather than one per workspace. The workspaces differ in exactly
|
||||
* three things — their destinations, their home, and whether they have a scope
|
||||
* control — so those are props. Copying four hundred lines of chrome per role
|
||||
* is how two headers drift apart and stop looking like one product.
|
||||
*/
|
||||
export function AppShell({ nav, home, navLabel, scopeControl, manageItems }: AppShellProps) {
|
||||
const { user, signOut } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isAssistantOpen, setIsAssistantOpen] = useState(true);
|
||||
const [isNavOpen, setIsNavOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ⌘K / Ctrl-K focuses search, the same global shortcut the reference uses.
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const isSearchWide = isSearchFocused || query !== '';
|
||||
|
||||
// Escape closes the account menu. A menu that can only be dismissed by
|
||||
// finding the trigger again is a trap for anyone on a keyboard, and this one
|
||||
// sits over the page rather than beside it.
|
||||
useEffect(() => {
|
||||
if (!isMenuOpen) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setIsMenuOpen(false);
|
||||
}
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh' }}>
|
||||
{/* Two elements, on purpose. The bar is full-bleed — the glass, the blur
|
||||
and the hairline run the whole width of the screen, because a header
|
||||
that stops short of the edge reads as a floating card, not as chrome.
|
||||
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
|
||||
on exactly the same left edge as the page title below them at every
|
||||
width, including a 2560px monitor where the body is centred. */}
|
||||
<header
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 40,
|
||||
background: 'rgba(255,255,255,.85)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="app-gutter"
|
||||
style={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to={home}
|
||||
aria-label={`${navLabel} — home`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
textDecoration: 'none',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
{/* 24px tall, width auto — the reference pins the logo's height and
|
||||
lets the wordmark set its own width. */}
|
||||
<img
|
||||
src="/logo-wordmark.png"
|
||||
alt="Nearle"
|
||||
style={{ height: 24, width: 'auto', display: 'block' }}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Below md the scope control moves into the navigation sheet. On a
|
||||
390px phone the logo, the selector and the right-hand cluster add
|
||||
up to 503px and push the header 113px past the viewport, so the
|
||||
page scrolls sideways. The sheet gives the branch names room to be
|
||||
read in full, and every page states its own scope in the header
|
||||
line beneath, so the phone never leaves the operator guessing. */}
|
||||
{scopeControl ? (
|
||||
<div className="show-from-md" style={{ flexShrink: 0 }}>
|
||||
{scopeControl}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Below lg the tabs are gone, so a flexible spacer keeps the right
|
||||
cluster against the edge instead of bunched beside the logo. */}
|
||||
<div className="lg-hidden" style={{ flex: 1 }} />
|
||||
|
||||
{/* Text tabs. Active = accent text + a 2px accent bar on the header rule. */}
|
||||
<nav
|
||||
aria-label={navLabel}
|
||||
className="show-from-lg"
|
||||
style={{ alignItems: 'center', gap: 2, flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{nav.map((entry) => {
|
||||
const isActive = pathname.startsWith(entry.to);
|
||||
return (
|
||||
<NavLink
|
||||
key={entry.to}
|
||||
to={entry.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
borderRadius: 12,
|
||||
padding: '6px 10px',
|
||||
fontSize: 13,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
transition: 'color .2s cubic-bezier(.16,1,.3,1)',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
{isActive ? (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: -13,
|
||||
height: 2,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-brand)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
{/* Search: 176px at rest, 256px once focused or filled. */}
|
||||
<form
|
||||
role="search"
|
||||
className="show-from-md"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
width: isSearchWide ? 256 : 176,
|
||||
transition: 'width .2s cubic-bezier(.16,1,.3,1)',
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 10,
|
||||
color: 'var(--color-ink-4)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setIsSearchFocused(false)}
|
||||
placeholder="Search"
|
||||
aria-label="Search the console"
|
||||
style={{
|
||||
height: 32,
|
||||
width: '100%',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isSearchFocused ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
||||
background: isSearchFocused
|
||||
? 'var(--color-surface)'
|
||||
: 'var(--color-surface-subtle)',
|
||||
paddingLeft: 32,
|
||||
paddingRight: 40,
|
||||
fontSize: 13,
|
||||
color: 'var(--color-ink-1)',
|
||||
outline: 'none',
|
||||
transition: 'border-color .2s, background .2s',
|
||||
boxShadow: isSearchFocused ? '0 0 0 2px rgba(102,37,130,.15)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<kbd
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
borderRadius: 4,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface)',
|
||||
padding: '0 4px',
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-ink-4)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
</form>
|
||||
|
||||
<IconButton label="Notifications, 2 unread" hasUnread>
|
||||
<Bell size={16} />
|
||||
</IconButton>
|
||||
|
||||
{/* Assistant button moved to floating pill */}
|
||||
|
||||
{/* Avatar trigger + chevron, with the account menu below it. */}
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen((open) => !open)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isMenuOpen}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 6px 4px 4px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isMenuOpen ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-brand)',
|
||||
color: '#fff',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{initials(user?.name ?? '')}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ color: 'var(--color-ink-3)' }} />
|
||||
</button>
|
||||
|
||||
{isMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 40,
|
||||
width: 268,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 12px 28px -6px rgb(16 24 40 / .12)',
|
||||
padding: 6,
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px 10px 10px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-ink-1)' }}>
|
||||
{user?.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--color-ink-4)' }}>{user?.email}</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
borderRadius: 999,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{user ? ROLE_LABEL[user.role] : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{manageItems && manageItems.length > 0 ? (
|
||||
<>
|
||||
<Rule />
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '6px 12px 4px',
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</p>
|
||||
{manageItems.map((entry) => (
|
||||
<MenuLink
|
||||
key={entry.to}
|
||||
entry={entry}
|
||||
onNavigate={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Rule />
|
||||
<button
|
||||
type="button"
|
||||
onClick={signOut}
|
||||
role="menuitem"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '9px 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
// Sign out is the one destructive-adjacent item here, and
|
||||
// it sits last so a mis-aimed click lands on nothing.
|
||||
color: 'var(--color-error, #d64545)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Sign out
|
||||
</button>
|
||||
|
||||
{/* When this code was built. If it does not match what you
|
||||
were told was delivered, you are looking at an old copy —
|
||||
which is not something any screen otherwise reveals. */}
|
||||
<p
|
||||
style={{
|
||||
margin: '6px 12px 2px',
|
||||
fontSize: 10.5,
|
||||
color: 'var(--color-ink-4)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
Build {__BUILD_STAMP__}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Wrapped rather than styled inline: an inline `display` would win
|
||||
over the media query that hides this above the tabs breakpoint. */}
|
||||
<span className="lg-hidden">
|
||||
<IconButton label="Open navigation" onClick={() => setIsNavOpen(true)}>
|
||||
<Menu size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body: a column on a phone so the assistant stacks under the page, a
|
||||
row from md where it becomes a side column. */}
|
||||
<div className="admin-body app-gutter">
|
||||
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{isAssistantOpen || isMobile ? (
|
||||
<AssistantPanel onClose={() => setIsAssistantOpen(false)} isStacked={isMobile} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Floating Assistant Button */}
|
||||
{!isMobile && !isAssistantOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAssistantOpen(true)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '6px 12px 6px 6px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-line)',
|
||||
boxShadow: '0 8px 24px -4px rgba(0,0,0,0.1), 0 4px 10px -2px rgba(0,0,0,0.05)',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-2px)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img src="/icon-192.png" alt="Nearle logo" style={{ width: 24, height: 24, objectFit: 'contain', borderRadius: '50%' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-ink-1)' }}>
|
||||
Nearle Buddy
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 2,
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--color-line)',
|
||||
color: 'var(--color-ink-3)',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{isNavOpen ? (
|
||||
<MobileNav
|
||||
nav={nav}
|
||||
scopeControl={scopeControl}
|
||||
onClose={() => setIsNavOpen(false)}
|
||||
pathname={pathname}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The navigation sheet, below lg.
|
||||
*
|
||||
* 288px from the right, opaque rather than glass — a translucent sheet over a
|
||||
* page of tables is unreadable — with the active item carried by a tinted fill
|
||||
* instead of the 2px underline, which has nothing to sit on here.
|
||||
*/
|
||||
function MobileNav({
|
||||
nav,
|
||||
scopeControl,
|
||||
onClose,
|
||||
pathname,
|
||||
}: {
|
||||
nav: readonly NavEntry[];
|
||||
scopeControl?: ReactNode;
|
||||
onClose: () => void;
|
||||
pathname: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .35)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 288,
|
||||
maxWidth: '85vw',
|
||||
height: '100%',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 600 }}>
|
||||
Menu
|
||||
</span>
|
||||
<IconButton label="Close navigation" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{scopeControl ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.09em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
Showing
|
||||
</span>
|
||||
{scopeControl}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', padding: 8, gap: 2 }}>
|
||||
{nav.map((entry) => {
|
||||
const isActive = pathname.startsWith(entry.to);
|
||||
return (
|
||||
<NavLink
|
||||
key={entry.to}
|
||||
to={entry.to}
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
fontSize: 13.5,
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-2)',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One destination in the account menu.
|
||||
*
|
||||
* A `NavLink` rather than a button with `navigate()`: middle-click, ⌘-click and
|
||||
* "open in new tab" all work on a real anchor and none of them work on a
|
||||
* button, and a setup screen is exactly the kind of thing someone parks in a
|
||||
* second tab.
|
||||
*/
|
||||
function MenuLink({ entry, onNavigate }: { entry: MenuEntry; onNavigate: () => void }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<NavLink
|
||||
to={entry.to}
|
||||
role="menuitem"
|
||||
onClick={onNavigate}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 12,
|
||||
textDecoration: 'none',
|
||||
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
color: 'var(--color-ink-1)',
|
||||
transition: 'background .15s',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: isHovered ? 'var(--color-brand-tint)' : 'var(--color-surface-sunken)',
|
||||
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
transition: 'background .15s, color .15s',
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
</span>
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>{entry.label}</span>
|
||||
{entry.note ? (
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}>
|
||||
{entry.note}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Rule = () => (
|
||||
<div style={{ height: 1, background: 'var(--color-line)', margin: '5px 0' }} />
|
||||
);
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '—';
|
||||
const first = parts[0]?.[0] ?? '';
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : '';
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A 32px icon-only control.
|
||||
*
|
||||
* `label` is required, not optional — an icon-only control with no accessible
|
||||
* name is a bug, so the API makes it impossible to omit.
|
||||
*/
|
||||
function IconButton({
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
hasUnread,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
hasUnread?: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
color: isHovered ? 'var(--color-ink-1)' : 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{hasUnread ? (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-brand)',
|
||||
boxShadow: '0 0 0 2px #fff',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
414
src/components/shell/AssistantPanel.tsx
Normal file
414
src/components/shell/AssistantPanel.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
|
||||
|
||||
/** Per-route context, so the panel knows which page it is sitting beside. */
|
||||
const CONTEXT: Record<
|
||||
string,
|
||||
{ page: string; title: string; greeting: string; reading: string; prompts: string[] }
|
||||
> = {
|
||||
'/nearle/stores': {
|
||||
page: 'Stores',
|
||||
title: 'Good afternoon',
|
||||
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
|
||||
reading: 'Reading the tenant directory — branch counts, status and per-tenant performance.',
|
||||
prompts: [
|
||||
'Which tenants have no branches?',
|
||||
'Who onboarded most recently?',
|
||||
'What needs attention?',
|
||||
'Summarise platform growth',
|
||||
],
|
||||
},
|
||||
'/nearle/onboard/tenant': {
|
||||
page: 'Onboard tenant',
|
||||
title: 'Provisioning a tenant',
|
||||
greeting: 'Registering the enterprise also creates its primary Administrator account.',
|
||||
reading: 'Reading the onboarding form — required fields, category and head-office address.',
|
||||
prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'],
|
||||
},
|
||||
'/nearle/catalogue': {
|
||||
page: 'Global catalogue',
|
||||
title: 'Stocking a store',
|
||||
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
|
||||
reading: 'Reading the global catalogue and what this tenant has already imported.',
|
||||
prompts: [
|
||||
'Which products are already imported?',
|
||||
'Catalogue or spreadsheet?',
|
||||
'What does re-importing do?',
|
||||
'Which columns does the sheet need?',
|
||||
],
|
||||
},
|
||||
|
||||
/* ── Store Admin ──────────────────────────────────────────────────────── */
|
||||
|
||||
'/admin/console': {
|
||||
page: 'Console',
|
||||
title: 'Across your branches',
|
||||
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
|
||||
reading: 'Reading every branch — revenue by channel, stock health, till status and what is waiting on you.',
|
||||
prompts: [
|
||||
'Which branch is underperforming?',
|
||||
'Any tills not syncing?',
|
||||
'What needs my approval?',
|
||||
'Where is stock running out?',
|
||||
],
|
||||
},
|
||||
'/admin/sales': {
|
||||
page: 'Sales',
|
||||
title: 'Orders and deliveries',
|
||||
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
|
||||
reading: 'Reading orders, counter bills and delivery progress across your branches.',
|
||||
prompts: [
|
||||
'Why is the cancel rate high?',
|
||||
'Which orders are stuck?',
|
||||
'Compare online and counter sales',
|
||||
'What is out for delivery?',
|
||||
],
|
||||
},
|
||||
'/admin/inventory': {
|
||||
page: 'Inventory',
|
||||
title: 'Catalogue and stock',
|
||||
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
|
||||
reading: 'Reading your catalogue, per-branch stock levels and the stock requests waiting on you.',
|
||||
prompts: [
|
||||
'What is waiting for approval?',
|
||||
'Which products are unpublished?',
|
||||
'What is low or out of stock?',
|
||||
'How do I upload a product sheet?',
|
||||
],
|
||||
},
|
||||
'/admin/users': {
|
||||
page: 'Users & access',
|
||||
title: 'Who can sign in',
|
||||
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
|
||||
reading: 'Reading your back-office directory and the till accounts at each branch.',
|
||||
prompts: [
|
||||
'What is the difference between the two?',
|
||||
'How do I add a cashier?',
|
||||
'Why can I not delete someone?',
|
||||
'Who is inactive?',
|
||||
],
|
||||
},
|
||||
'/admin/reports': {
|
||||
page: 'Reports',
|
||||
title: 'Revenue, sales and stock',
|
||||
greeting: 'Fast and slow movers are the two lists that change what you order next.',
|
||||
reading: 'Reading revenue by channel and branch, product performance and stock movement.',
|
||||
prompts: [
|
||||
'Which products are slow moving?',
|
||||
'Compare my three branches',
|
||||
'Online versus counter revenue',
|
||||
'What is my inventory worth?',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK = {
|
||||
page: 'Console',
|
||||
title: 'Good afternoon',
|
||||
greeting: 'Ask about anything on this page.',
|
||||
reading: 'Reading this page.',
|
||||
prompts: ['What needs attention?', 'Summarise this page'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Nearle Buddy, as a layout column.
|
||||
*
|
||||
* Not an overlay and not a floating bubble: the panel is part of the page on
|
||||
* every supported route, so the page reflows beside it rather than being
|
||||
* covered. It opens by stating the most useful thing about the page it is next
|
||||
* to, says what it read, and offers prompts scoped to that page.
|
||||
*
|
||||
* 380px by default, sticky under the 56px header, and its own height is the
|
||||
* viewport minus 104px so it never pushes the page taller than the screen.
|
||||
*/
|
||||
export function AssistantPanel({
|
||||
onClose,
|
||||
isStacked = false,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
/** Below md the panel sits under the page rather than beside it. */
|
||||
isStacked?: boolean;
|
||||
}) {
|
||||
const { pathname } = useLocation();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
|
||||
const context = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
|
||||
|
||||
const expandedStyle: React.CSSProperties = isExpanded
|
||||
? {
|
||||
width: '50vw',
|
||||
maxWidth: 'none',
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<aside aria-label="Nearle Buddy" className="assistant" style={expandedStyle}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
background: 'rgba(255,255,255,.72)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: '-4px 0 16px -2px rgb(16 24 32 / .06)',
|
||||
}}
|
||||
>
|
||||
{/* Header: mark, name, and the current page as the subtitle. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
overflow: 'hidden',
|
||||
flex: 'none',
|
||||
}}
|
||||
>
|
||||
<img src="/icon-192.png" alt="Nearle logo" style={{ width: 24, height: 24, objectFit: 'contain', borderRadius: '50%' }} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-ink-1)' }}>
|
||||
Nearle Buddy
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-ink-4)' }}>{context.page}</div>
|
||||
</div>
|
||||
{isStacked ? null : (
|
||||
<>
|
||||
<PanelIcon label="History">
|
||||
<History size={14} />
|
||||
</PanelIcon>
|
||||
<PanelIcon label={isExpanded ? "Collapse" : "Expand"} onClick={() => setIsExpanded(!isExpanded)}>
|
||||
{isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</PanelIcon>
|
||||
<PanelIcon label="Close Nearle Buddy" onClick={onClose}>
|
||||
<PanelRightClose size={14} />
|
||||
</PanelIcon>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Greeting — top-aligned, because it is the first thing in a
|
||||
conversation, not a splash screen. */}
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.35,
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{context.title}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--color-ink-2)',
|
||||
}}
|
||||
>
|
||||
{context.greeting}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: '10px 0 0',
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--color-ink-3)',
|
||||
}}
|
||||
>
|
||||
{context.reading}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge
|
||||
is a bug no amount of fade masking fixes. */}
|
||||
<div role="group" aria-label="Suggested prompts" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{context.prompts.map((prompt) => (
|
||||
<Chip key={prompt} label={prompt} onSelect={() => setDraft(prompt)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: `1px solid ${isFocused ? 'rgba(102,37,130,.5)' : 'var(--color-line)'}`,
|
||||
boxShadow: isFocused
|
||||
? '0 8px 28px -8px rgba(102,37,130,.28)'
|
||||
: '0 4px 16px -2px rgb(16 24 32 / .06)',
|
||||
transition: 'all .2s cubic-bezier(.16,1,.3,1)',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
const el = event.target;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={`Ask Buddy about ${context.page.toLowerCase()}…`}
|
||||
aria-label="Ask Nearle Buddy"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
resize: 'none',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
outline: 'none',
|
||||
padding: '2px 0',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--color-ink-1)',
|
||||
maxHeight: 148,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
disabled={draft.trim() === ''}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
background: draft.trim() ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: draft.trim() ? '#fff' : 'var(--color-ink-4)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
cursor: draft.trim() ? 'pointer' : 'default',
|
||||
transition: 'background .2s',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.4,
|
||||
color: 'var(--color-ink-4)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Buddy reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt chip.
|
||||
*
|
||||
* `14px` radius is pinned rather than `999px`: 14 is what a fully-rounded
|
||||
* corner already resolves to on a one-line chip, and pinning it means a chip
|
||||
* that wraps to two lines keeps the same corner instead of turning into
|
||||
* something that reads as a card.
|
||||
*/
|
||||
function Chip({ label, onSelect }: { label: string; onSelect: () => void }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
title={label}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '100%',
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${isHovered ? 'rgba(102,37,130,.4)' : 'var(--color-line)'}`,
|
||||
background: isHovered ? 'var(--color-brand-tint)' : 'var(--color-surface)',
|
||||
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-2)',
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
fontWeight: 500,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
transform: isHovered ? 'translateY(-1px)' : 'none',
|
||||
boxShadow: isHovered
|
||||
? '0 2px 10px 0 rgb(16 24 32 / .04)'
|
||||
: '0 1px 2px 0 rgb(16 24 32 / .03)',
|
||||
transition: 'all .2s cubic-bezier(.16,1,.3,1)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelIcon({
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
border: 0,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
color: isHovered ? 'var(--color-ink-1)' : 'var(--color-ink-4)',
|
||||
cursor: 'pointer',
|
||||
flex: 'none',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
137
src/demo/DemoPanel.tsx
Normal file
137
src/demo/DemoPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Demo sign-in — development only.
|
||||
*
|
||||
* Reached solely through a lazy import guarded by `import.meta.env.DEV`, so
|
||||
* this file and `./accounts` are absent from a production build.
|
||||
*/
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { ROLE_LABEL, type ConsoleRole, type SessionUser } from '@/auth/roles';
|
||||
import { DEMO_ACCOUNTS } from './accounts';
|
||||
|
||||
/**
|
||||
* Three one-click sessions, one per role.
|
||||
*
|
||||
* Present only when `import.meta.env.DEV` is true, so a production build drops
|
||||
* this component and the fixtures behind it. It is deliberately styled as a
|
||||
* development tool rather than as part of the form — dashed border, a "DEV
|
||||
* ONLY" tag — because a sign-in shortcut that looks like a real sign-in option
|
||||
* is exactly the thing that ends up in a screenshot for a customer.
|
||||
*/
|
||||
export function DemoPanel({
|
||||
onDemo,
|
||||
}: {
|
||||
onDemo: (role: ConsoleRole, session: SessionUser) => void;
|
||||
}) {
|
||||
const roles: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager'];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--color-border-emphasized, #cbd2db)',
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
background: 'color-mix(in oklab, var(--color-surface-sunken) 55%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--color-ink-1)',
|
||||
color: '#fff',
|
||||
borderRadius: 999,
|
||||
padding: '2px 7px',
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
DEV ONLY
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-ink-2)' }}>
|
||||
Sign in without the backend
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: '0 0 10px', fontSize: 11.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
|
||||
Seeds a session and serves fixtures instead of calling Fiesta. Stripped from production
|
||||
builds.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{roles.map((role) => {
|
||||
const account = DEMO_ACCOUNTS[role];
|
||||
if (!account) return null;
|
||||
return (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
onClick={() => onDemo(role, account.session)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '9px 11px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color .2s, background .2s',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
flex: 'none',
|
||||
}}
|
||||
>
|
||||
{account.session.name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.slice(0, 2)
|
||||
.join('')}
|
||||
</span>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{ROLE_LABEL[role]}
|
||||
<span style={{ fontWeight: 400, color: 'var(--color-ink-4)' }}>
|
||||
{' '}
|
||||
· {account.standsFor}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
color: 'var(--color-ink-3)',
|
||||
}}
|
||||
>
|
||||
{account.blurb}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight size={14} style={{ color: 'var(--color-ink-4)', flex: 'none' }} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/demo/accounts.ts
Normal file
69
src/demo/accounts.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The three demo accounts.
|
||||
*
|
||||
* Split from `src/demo/index.ts` so they can be reached only through the lazily
|
||||
* loaded demo panel — a static import would put these session objects in the
|
||||
* production bundle, and a build that ships fake accounts is a build that
|
||||
* eventually ships one to a customer.
|
||||
*
|
||||
* DEV ONLY.
|
||||
*/
|
||||
|
||||
import type { SessionUser } from '@/auth/roles';
|
||||
|
||||
export interface DemoAccount {
|
||||
session: SessionUser;
|
||||
/** What this role is for, in one line, shown on the button. */
|
||||
blurb: string;
|
||||
/** The real login this stands in for, so it is obvious what it maps to. */
|
||||
standsFor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One per role, matching how `resolveRole` actually decides:
|
||||
* `issuperadmin` first, then roleid 1 or 3, then everything else.
|
||||
*/
|
||||
export const DEMO_ACCOUNTS: Record<string, DemoAccount> = {
|
||||
'nearle-admin': {
|
||||
session: {
|
||||
userid: 9001,
|
||||
role: 'nearle-admin',
|
||||
name: 'Suriya Kumar',
|
||||
email: 'suriya@superadmin.com',
|
||||
roleid: 0,
|
||||
tenantid: 0,
|
||||
locationid: 0,
|
||||
issuperadmin: true,
|
||||
},
|
||||
blurb: 'Onboard tenants and branches, browse the global catalogue, see every store.',
|
||||
standsFor: 'issuperadmin = true',
|
||||
},
|
||||
'store-admin': {
|
||||
session: {
|
||||
userid: 9002,
|
||||
role: 'store-admin',
|
||||
name: 'Rmart Operations',
|
||||
email: 'rmart@gmail.com',
|
||||
roleid: 1,
|
||||
tenantid: 1135,
|
||||
locationid: 1,
|
||||
issuperadmin: false,
|
||||
},
|
||||
blurb: 'One merchant group — its branches, catalogue, reports and tills.',
|
||||
standsFor: 'roleid 1',
|
||||
},
|
||||
'store-manager': {
|
||||
session: {
|
||||
userid: 9003,
|
||||
role: 'store-manager',
|
||||
name: 'R Mart User',
|
||||
email: 'rmartuser@gmail.com',
|
||||
roleid: 5,
|
||||
tenantid: 1135,
|
||||
locationid: 1,
|
||||
issuperadmin: false,
|
||||
},
|
||||
blurb: 'A single store — its orders, stock, customers and counter sales.',
|
||||
standsFor: 'any other roleid',
|
||||
},
|
||||
};
|
||||
961
src/demo/fixtures.ts
Normal file
961
src/demo/fixtures.ts
Normal file
@@ -0,0 +1,961 @@
|
||||
/**
|
||||
* Demo fixtures.
|
||||
*
|
||||
* Shapes and values are copied from the live tenant so the screens read as the
|
||||
* real thing rather than as lorem ipsum: R mart in RS Puram, the Aachi/Amul/
|
||||
* Cadbury catalogue, the 97.6% cancel rate. Anyone who knows the data will
|
||||
* recognise it, which is the point — a demo that looks nothing like production
|
||||
* teaches you nothing about production.
|
||||
*
|
||||
* DEV ONLY. See `src/demo/index.ts` for how this is gated and stripped.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CatalogueBrand,
|
||||
CatalogueProduct,
|
||||
CatalogueRef,
|
||||
LocationOrderSummary,
|
||||
OrderSummary,
|
||||
Product,
|
||||
ProductCategory,
|
||||
TenantInfo,
|
||||
TenantLocation,
|
||||
} from '@/api/types';
|
||||
|
||||
export const DEMO_TENANTS: TenantInfo[] = [
|
||||
{
|
||||
tenantid: 1135,
|
||||
locationid: 1,
|
||||
tenantname: 'R mart',
|
||||
locationname: 'RS Puram',
|
||||
companyname: 'R Mart Retail Pvt. Ltd.',
|
||||
primaryemail: 'rmart@gmail.com',
|
||||
primarycontact: '9879879876',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
suburb: 'RS Puram',
|
||||
postcode: '641002',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
tenantid: 1136,
|
||||
locationid: 4,
|
||||
tenantname: 'Kaveri Groceries',
|
||||
locationname: 'Peelamedu',
|
||||
companyname: 'Kaveri Retail Pvt. Ltd.',
|
||||
primaryemail: 'admin@kaveri.com',
|
||||
primarycontact: '9876543210',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
suburb: 'Peelamedu',
|
||||
postcode: '641004',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
tenantid: 1136,
|
||||
locationid: 5,
|
||||
tenantname: 'Kaveri Groceries',
|
||||
locationname: 'Saibaba Colony',
|
||||
companyname: 'Kaveri Retail Pvt. Ltd.',
|
||||
primaryemail: 'admin@kaveri.com',
|
||||
primarycontact: '9876543210',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
tenantid: 1137,
|
||||
locationid: 8,
|
||||
tenantname: 'Anbu Stores',
|
||||
locationname: 'Gandhipuram',
|
||||
companyname: 'Anbu Traders',
|
||||
primaryemail: 'andiraj@gmail.com',
|
||||
primarycontact: '7397177923',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
status: 'InActive',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_LOCATIONS: Record<number, TenantLocation[]> = {
|
||||
1135: [
|
||||
{
|
||||
locationid: 1,
|
||||
tenantid: 1135,
|
||||
locationname: 'R mart — RS Puram',
|
||||
email: 'rspuram@rmart.com',
|
||||
contactno: '9879879876',
|
||||
address: '240, DB Road',
|
||||
suburb: 'RS Puram',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '641002',
|
||||
opentime: '08:00',
|
||||
closetime: '22:00',
|
||||
deliveryradius: 5000,
|
||||
deliverymins: 30,
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
locationid: 2,
|
||||
tenantid: 1135,
|
||||
locationname: 'R mart — Peelamedu',
|
||||
email: 'peelamedu@rmart.com',
|
||||
contactno: '9879879877',
|
||||
address: '18, Avinashi Road',
|
||||
suburb: 'Peelamedu',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '641004',
|
||||
opentime: '07:00',
|
||||
closetime: '23:00',
|
||||
deliveryradius: 6000,
|
||||
deliverymins: 25,
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
locationid: 3,
|
||||
tenantid: 1135,
|
||||
locationname: 'R mart — Saravanampatti',
|
||||
email: 'saravanampatti@rmart.com',
|
||||
contactno: '9879879878',
|
||||
address: '5, Sathy Main Road',
|
||||
suburb: 'Saravanampatti',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '641035',
|
||||
opentime: '08:00',
|
||||
closetime: '22:00',
|
||||
deliveryradius: 4500,
|
||||
deliverymins: 35,
|
||||
status: 'Active',
|
||||
},
|
||||
],
|
||||
1136: [
|
||||
{
|
||||
locationid: 4,
|
||||
tenantid: 1136,
|
||||
locationname: 'Kaveri — Peelamedu',
|
||||
address: '12, Avinashi Road',
|
||||
suburb: 'Peelamedu',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '641004',
|
||||
opentime: '07:00',
|
||||
closetime: '23:00',
|
||||
deliveryradius: 7000,
|
||||
deliverymins: 25,
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
locationid: 5,
|
||||
tenantid: 1136,
|
||||
locationname: 'Kaveri — Saibaba Colony',
|
||||
address: '88, Mettupalayam Road',
|
||||
suburb: 'Saibaba Colony',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '641011',
|
||||
opentime: '08:00',
|
||||
closetime: '22:00',
|
||||
deliveryradius: 4000,
|
||||
deliverymins: 35,
|
||||
status: 'Active',
|
||||
},
|
||||
],
|
||||
1137: [
|
||||
{
|
||||
locationid: 8,
|
||||
tenantid: 1137,
|
||||
locationname: 'Anbu — Gandhipuram',
|
||||
address: '5, Cross Cut Road',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '641012',
|
||||
opentime: '09:00',
|
||||
closetime: '21:00',
|
||||
deliveryradius: 3000,
|
||||
deliverymins: 40,
|
||||
status: 'InActive',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Per-branch order totals — the shape `/orders/getlocationsummary` returns. */
|
||||
export const DEMO_LOCATION_SUMMARY: Record<number, LocationOrderSummary[]> = {
|
||||
1135: [
|
||||
{ locationid: 1, locationname: 'R mart — RS Puram', totalorders: 84, delivered: 1, cancelled: 82, revenue: 1350 },
|
||||
{ locationid: 2, locationname: 'R mart — Peelamedu', totalorders: 268, delivered: 241, cancelled: 19, revenue: 58400 },
|
||||
{ locationid: 3, locationname: 'R mart — Saravanampatti', totalorders: 142, delivered: 126, cancelled: 12, revenue: 27950 },
|
||||
],
|
||||
1136: [
|
||||
{ locationid: 4, locationname: 'Kaveri — Peelamedu', totalorders: 412, delivered: 388, cancelled: 11, revenue: 84250 },
|
||||
{ locationid: 5, locationname: 'Kaveri — Saibaba Colony', totalorders: 196, delivered: 181, cancelled: 6, revenue: 39800 },
|
||||
],
|
||||
1137: [{ locationid: 8, locationname: 'Anbu — Gandhipuram', totalorders: 0, delivered: 0, cancelled: 0, revenue: 0 }],
|
||||
};
|
||||
|
||||
export const DEMO_ORDER_SUMMARY: Record<number, OrderSummary> = {
|
||||
1135: { totalorders: 84, delivered: 1, pending: 0, cancelled: 82, revenue: 1350 },
|
||||
1136: { totalorders: 608, delivered: 569, pending: 22, cancelled: 17, revenue: 124050 },
|
||||
1137: { totalorders: 0, delivered: 0, pending: 0, cancelled: 0, revenue: 0 },
|
||||
};
|
||||
|
||||
export const DEMO_BRANDS: CatalogueBrand[] = [
|
||||
{ brand: 'aachi', product_count: 176 },
|
||||
{ brand: 'amul', product_count: 122 },
|
||||
{ brand: 'anil', product_count: 50 },
|
||||
{ brand: 'cadbury', product_count: 110 },
|
||||
{ brand: 'cavinkare', product_count: 45 },
|
||||
{ brand: 'nestle', product_count: 123 },
|
||||
{ brand: 'pepsico', product_count: 88 },
|
||||
];
|
||||
|
||||
function catalogueProduct(
|
||||
id: number,
|
||||
brand: string,
|
||||
name: string,
|
||||
size: string,
|
||||
range: string,
|
||||
category: string,
|
||||
): CatalogueProduct {
|
||||
return {
|
||||
id,
|
||||
brand,
|
||||
product_name: name,
|
||||
size,
|
||||
price_range: range,
|
||||
category,
|
||||
product_sku: `${brand.toUpperCase()}-${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const DEMO_CATALOGUE: CatalogueProduct[] = [
|
||||
catalogueProduct(43, 'aachi', 'Aachi Appalams 100g', '100g', '₹33-37', 'Snacks'),
|
||||
catalogueProduct(89, 'aachi', 'Aachi Chilli Powder 100gm', '100gm', '₹31-39', 'Masala'),
|
||||
catalogueProduct(7, 'aachi', 'Aachi Chicken Masala 100g', '100g', '₹23-27', 'Masala'),
|
||||
catalogueProduct(20, 'aachi', 'Aachi Garam Masala 100g', '100g', '₹22-28', 'Masala'),
|
||||
catalogueProduct(102, 'aachi', 'Aachi Sambar Powder 50g', '50g', '₹14-16', 'Masala'),
|
||||
catalogueProduct(51, 'amul', 'Amul Milk 1L', '1L', '₹68-92', 'Dairy'),
|
||||
catalogueProduct(3, 'amul', 'Amul Butter 500ml', '500ml', '₹80-100', 'Dairy'),
|
||||
catalogueProduct(37, 'amul', 'Amul Dahi 200 g', '200 g', '₹28-32', 'Dairy'),
|
||||
catalogueProduct(20, 'amul', 'Amul Ghee 1L', '1L', '₹158-192', 'Dairy'),
|
||||
catalogueProduct(77, 'amul', 'Amul 150 g', '150 g', '₹37-43', 'Dairy'),
|
||||
catalogueProduct(9, 'cadbury', 'Cadbury Bournvita 500g', '500g', '₹210-230', 'Beverages'),
|
||||
catalogueProduct(12, 'cadbury', 'Cadbury Dairy Milk 100g', '100g', '₹77-93', 'Confectionery'),
|
||||
catalogueProduct(37, 'cadbury', 'Cadbury Oreo 100g', '100g', '₹10-14', 'Biscuits'),
|
||||
catalogueProduct(15, 'cadbury', 'Cadbury Gems 25g', '25g', '₹22-28', 'Confectionery'),
|
||||
catalogueProduct(10, 'anil', 'Anil Wheat Flour 1kg', '1kg', '₹251-299', 'Staples'),
|
||||
catalogueProduct(34, 'anil', 'Anil Idli Dosa Mix 500g', '500g', '₹98-122', 'Staples'),
|
||||
catalogueProduct(1, 'cavinkare', 'Cavinkare Frooti 200g', '200g', '₹42-48', 'Beverages'),
|
||||
catalogueProduct(30, 'cavinkare', 'Cavinkare Chik Shampoo 500ml', '500ml', '₹377-463', 'Personal care'),
|
||||
];
|
||||
|
||||
/** `(brand, catalogueid)` pairs the demo tenant has already imported. */
|
||||
export const DEMO_IMPORTED: Record<number, CatalogueRef[]> = {
|
||||
1135: [
|
||||
{ brand: 'cadbury', catalogueid: 9 },
|
||||
{ brand: 'amul', catalogueid: 77 },
|
||||
{ brand: 'amul', catalogueid: 51 },
|
||||
],
|
||||
1136: [{ brand: 'aachi', catalogueid: 89 }],
|
||||
1137: [],
|
||||
};
|
||||
|
||||
export const DEMO_CATEGORIES: ProductCategory[] = [
|
||||
{ categoryid: 1, categoryname: 'Grocery & Staples' },
|
||||
{ categoryid: 2, categoryname: 'Dairy & Chilled' },
|
||||
{ categoryid: 3, categoryname: 'Snacks & Beverages' },
|
||||
{ categoryid: 4, categoryname: 'Personal Care' },
|
||||
{ categoryid: 5, categoryname: 'Household' },
|
||||
];
|
||||
|
||||
export const DEMO_STORE_PRODUCTS: Product[] = [
|
||||
{
|
||||
productid: 7081,
|
||||
productname: 'Cadbury Bournvita 500g',
|
||||
productsku: 'B06XSB6RB2',
|
||||
productunit: '500g',
|
||||
retailprice: 100,
|
||||
price: 100,
|
||||
productstock: 0,
|
||||
productstatus: 'Active',
|
||||
categoryname: 'Snacks & Beverages',
|
||||
},
|
||||
{
|
||||
productid: 7077,
|
||||
productname: 'Hot Heads 30g',
|
||||
productsku: 'NESTLE-HOT-30-001',
|
||||
productunit: '30g',
|
||||
retailprice: 50,
|
||||
price: 50,
|
||||
productstock: 47,
|
||||
productstatus: 'Active',
|
||||
categoryname: 'Snacks & Beverages',
|
||||
},
|
||||
{
|
||||
productid: 7076,
|
||||
productname: 'Cheerios Breakfast Cereal 100g',
|
||||
productsku: 'B08X68PTVG',
|
||||
productunit: '100g',
|
||||
retailprice: 120,
|
||||
price: 120,
|
||||
productstock: 17,
|
||||
productstatus: 'Active',
|
||||
categoryname: 'Grocery & Staples',
|
||||
},
|
||||
];
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Store Admin — POS, stock and the approval queue
|
||||
|
||||
The three R Mart branches are deliberately in three different states, because
|
||||
a demo where everything is green teaches nothing about the screen:
|
||||
|
||||
- RS Puram one till, synced, trading normally
|
||||
- Peelamedu two tills, one of them syncing and one stranded for an hour
|
||||
- Saravanampatti till offline entirely, with takings queued behind it
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
import type { PosSalesSummary, PosTerminalHealth, StockRequest, StockStatementRow } from '@/api/types';
|
||||
|
||||
/** Minutes ago, as the ISO string a till would have reported. */
|
||||
const ago = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
|
||||
export const DEMO_POS_HEALTH: Record<number, PosTerminalHealth[]> = {
|
||||
1: [
|
||||
{
|
||||
terminal_id: 'RSP-01',
|
||||
location_id: '1',
|
||||
store_name: 'R mart — RS Puram',
|
||||
app_version: '2.4.1',
|
||||
status: 'online',
|
||||
pending_bills: '0',
|
||||
pending_registrations: '0',
|
||||
today_bills: '46',
|
||||
today_amount: '18240',
|
||||
last_bill_at: ago(3),
|
||||
reported_at: ago(0),
|
||||
received_at: ago(0),
|
||||
printer_reachable: 'true',
|
||||
storage_free_mb: '2400',
|
||||
battery_level: '86',
|
||||
battery_charging: 'true',
|
||||
drawer_status: 'closed',
|
||||
},
|
||||
{
|
||||
// The regression case, made visible: still claiming "online", but nothing
|
||||
// has arrived in six minutes. The old derivation badged this "Synced".
|
||||
terminal_id: 'RSP-02',
|
||||
location_id: '1',
|
||||
store_name: 'R mart — RS Puram',
|
||||
app_version: '2.4.1',
|
||||
status: 'online',
|
||||
pending_bills: '0',
|
||||
pending_registrations: '0',
|
||||
today_bills: '31',
|
||||
today_amount: '11405',
|
||||
last_bill_at: ago(7),
|
||||
reported_at: ago(6),
|
||||
received_at: ago(6),
|
||||
printer_reachable: 'true',
|
||||
storage_free_mb: '2100',
|
||||
},
|
||||
{
|
||||
// Commissioned, heartbeating, never put into service.
|
||||
terminal_id: 'RSP-03',
|
||||
location_id: '1',
|
||||
store_name: 'R mart — RS Puram',
|
||||
app_version: '2.4.1',
|
||||
status: 'online',
|
||||
pending_bills: '0',
|
||||
pending_registrations: '0',
|
||||
today_bills: '0',
|
||||
today_amount: '0',
|
||||
reported_at: ago(0),
|
||||
received_at: ago(0),
|
||||
printer_reachable: 'true',
|
||||
storage_free_mb: '3100',
|
||||
},
|
||||
],
|
||||
2: [
|
||||
{
|
||||
terminal_id: 'PLM-01',
|
||||
location_id: '2',
|
||||
store_name: 'R mart — Peelamedu',
|
||||
app_version: '2.4.1',
|
||||
status: 'online',
|
||||
pending_bills: '3',
|
||||
pending_registrations: '0',
|
||||
oldest_pending_at: ago(2),
|
||||
today_bills: '112',
|
||||
today_amount: '46310',
|
||||
last_bill_at: ago(1),
|
||||
// Reporting a time nine minutes ahead of when we received it — the till's
|
||||
// clock is wrong, and its bills carry the wrong business date.
|
||||
reported_at: ago(-9),
|
||||
received_at: ago(0),
|
||||
printer_reachable: 'true',
|
||||
storage_free_mb: '1850',
|
||||
},
|
||||
{
|
||||
terminal_id: 'PLM-02',
|
||||
location_id: '2',
|
||||
store_name: 'R mart — Peelamedu',
|
||||
app_version: '2.3.9',
|
||||
status: 'online',
|
||||
pending_bills: '27',
|
||||
pending_registrations: '2',
|
||||
oldest_pending_at: ago(64),
|
||||
today_bills: '58',
|
||||
today_amount: '21470',
|
||||
last_bill_at: ago(64),
|
||||
reported_at: ago(0),
|
||||
received_at: ago(0),
|
||||
printer_reachable: 'false',
|
||||
storage_free_mb: '140',
|
||||
battery_level: '14',
|
||||
battery_charging: 'false',
|
||||
},
|
||||
],
|
||||
3: [
|
||||
{
|
||||
terminal_id: 'SRV-01',
|
||||
location_id: '3',
|
||||
store_name: 'R mart — Saravanampatti',
|
||||
app_version: '2.3.9',
|
||||
status: 'offline',
|
||||
pending_bills: '41',
|
||||
pending_registrations: '0',
|
||||
oldest_pending_at: ago(196),
|
||||
today_bills: '39',
|
||||
today_amount: '15880',
|
||||
last_bill_at: ago(196),
|
||||
reported_at: ago(194),
|
||||
received_at: ago(194),
|
||||
drawer_status: 'open',
|
||||
},
|
||||
{
|
||||
// The expired-key stub: terminal, location, status, reason — and nothing
|
||||
// else. No heartbeat fields at all, which is the whole tell.
|
||||
terminal_id: 'SRV-02',
|
||||
location_id: '3',
|
||||
status: 'offline',
|
||||
reason: 'presence key expired',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DEMO_POS_SUMMARY: Record<number, PosSalesSummary> = {
|
||||
1: {
|
||||
locationid: 1,
|
||||
billcount: 46,
|
||||
itemcount: 214,
|
||||
grosssales: 18240,
|
||||
taxcollected: 1640,
|
||||
discountgiven: 380,
|
||||
roundoff: 12,
|
||||
averagebill: 396.5,
|
||||
bypaymentmode: [
|
||||
{ paymentmode: 'UPI', amount: 11420, billcount: 31 },
|
||||
{ paymentmode: 'Cash', amount: 5380, billcount: 12 },
|
||||
{ paymentmode: 'Card', amount: 1440, billcount: 3 },
|
||||
],
|
||||
byterminal: [
|
||||
{ terminalid: 'RSP-01', amount: 18240, billcount: 46 },
|
||||
{ terminalid: 'RSP-02', amount: 11405, billcount: 31 },
|
||||
// Billing, with no health record anywhere. Invisible on the board until
|
||||
// the sales split is reconciled against it — which is the point.
|
||||
{ terminalid: 'RSP-04', amount: 6820, billcount: 17 },
|
||||
],
|
||||
byday: [
|
||||
{ day: '2026-08-17', amount: 15900, billcount: 41 },
|
||||
{ day: '2026-08-18', amount: 17210, billcount: 44 },
|
||||
{ day: '2026-08-19', amount: 16480, billcount: 42 },
|
||||
{ day: '2026-08-20', amount: 19340, billcount: 49 },
|
||||
{ day: '2026-08-21', amount: 18240, billcount: 46 },
|
||||
],
|
||||
},
|
||||
2: {
|
||||
locationid: 2,
|
||||
billcount: 170,
|
||||
itemcount: 806,
|
||||
grosssales: 67780,
|
||||
taxcollected: 6120,
|
||||
discountgiven: 1450,
|
||||
roundoff: 38,
|
||||
averagebill: 398.7,
|
||||
bypaymentmode: [
|
||||
{ paymentmode: 'UPI', amount: 41260, billcount: 104 },
|
||||
{ paymentmode: 'Cash', amount: 18940, billcount: 52 },
|
||||
{ paymentmode: 'Card', amount: 7580, billcount: 14 },
|
||||
],
|
||||
byterminal: [
|
||||
{ terminalid: 'PLM-01', amount: 46310, billcount: 112 },
|
||||
{ terminalid: 'PLM-02', amount: 21470, billcount: 58 },
|
||||
],
|
||||
byday: [
|
||||
{ day: '2026-08-17', amount: 61200, billcount: 158 },
|
||||
{ day: '2026-08-18', amount: 64850, billcount: 163 },
|
||||
{ day: '2026-08-19', amount: 59900, billcount: 151 },
|
||||
{ day: '2026-08-20', amount: 71340, billcount: 178 },
|
||||
{ day: '2026-08-21', amount: 67780, billcount: 170 },
|
||||
],
|
||||
},
|
||||
3: {
|
||||
locationid: 3,
|
||||
billcount: 39,
|
||||
itemcount: 168,
|
||||
grosssales: 15880,
|
||||
taxcollected: 1430,
|
||||
discountgiven: 210,
|
||||
roundoff: 7,
|
||||
averagebill: 407.2,
|
||||
bypaymentmode: [
|
||||
{ paymentmode: 'Cash', amount: 9640, billcount: 26 },
|
||||
{ paymentmode: 'UPI', amount: 6240, billcount: 13 },
|
||||
],
|
||||
byterminal: [{ terminalid: 'SRV-01', amount: 15880, billcount: 39 }],
|
||||
byday: [
|
||||
{ day: '2026-08-19', amount: 17420, billcount: 44 },
|
||||
{ day: '2026-08-20', amount: 16900, billcount: 43 },
|
||||
{ day: '2026-08-21', amount: 15880, billcount: 39 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const DEMO_STOCK_REQUESTS: StockRequest[] = [
|
||||
{
|
||||
requestid: 501,
|
||||
tenantid: 1135,
|
||||
locationid: 2,
|
||||
locationname: 'R mart — Peelamedu',
|
||||
productid: 9001,
|
||||
productname: 'Aachi Chilli Powder 100gm',
|
||||
qty: 60,
|
||||
status: 'Pending',
|
||||
created: ago(95),
|
||||
},
|
||||
{
|
||||
requestid: 502,
|
||||
tenantid: 1135,
|
||||
locationid: 3,
|
||||
locationname: 'R mart — Saravanampatti',
|
||||
productid: 9004,
|
||||
productname: 'Amul Butter 500g',
|
||||
qty: 24,
|
||||
status: 'Pending',
|
||||
created: ago(240),
|
||||
},
|
||||
{
|
||||
requestid: 503,
|
||||
tenantid: 1135,
|
||||
locationid: 1,
|
||||
locationname: 'R mart — RS Puram',
|
||||
productid: 9007,
|
||||
productname: 'Cadbury Dairy Milk 50g',
|
||||
qty: 100,
|
||||
status: 'Pending',
|
||||
created: ago(410),
|
||||
},
|
||||
{
|
||||
requestid: 498,
|
||||
tenantid: 1135,
|
||||
locationid: 2,
|
||||
locationname: 'R mart — Peelamedu',
|
||||
productid: 9002,
|
||||
productname: 'Aachi Garam Masala 100g',
|
||||
qty: 40,
|
||||
status: 'Received',
|
||||
created: ago(1500),
|
||||
},
|
||||
{
|
||||
requestid: 497,
|
||||
tenantid: 1135,
|
||||
locationid: 1,
|
||||
locationname: 'R mart — RS Puram',
|
||||
productid: 9005,
|
||||
productname: 'Amul Cheese Slices 200g',
|
||||
qty: 15,
|
||||
status: 'Rejected',
|
||||
created: ago(2100),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A stock statement per branch.
|
||||
*
|
||||
* `debit` is what left the shelf — both channels together, which is why it is
|
||||
* the figure the fast/slow-mover lists rank on rather than either sales table.
|
||||
*/
|
||||
export const DEMO_STOCK_STATEMENT: Record<number, StockStatementRow[]> = {
|
||||
1: [
|
||||
{ productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 80, credit: 40, debit: 96, closing: 24, retailprice: 35, locationid: 1 },
|
||||
{ productid: 9004, productname: 'Amul Butter 500g', opening: 30, credit: 24, debit: 52, closing: 2, retailprice: 265, locationid: 1 },
|
||||
{ productid: 9007, productname: 'Cadbury Dairy Milk 50g', opening: 120, credit: 0, debit: 120, closing: 0, retailprice: 45, locationid: 1 },
|
||||
{ productid: 9002, productname: 'Aachi Garam Masala 100g', opening: 60, credit: 20, debit: 31, closing: 49, retailprice: 42, locationid: 1 },
|
||||
{ productid: 9005, productname: 'Amul Cheese Slices 200g', opening: 25, credit: 10, debit: 8, closing: 27, retailprice: 145, locationid: 1 },
|
||||
{ productid: 9009, productname: 'Nestlé Munch 32g', opening: 200, credit: 0, debit: 14, closing: 186, retailprice: 20, locationid: 1 },
|
||||
],
|
||||
2: [
|
||||
{ productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 140, credit: 60, debit: 196, closing: 4, retailprice: 35, locationid: 2 },
|
||||
{ productid: 9004, productname: 'Amul Butter 500g', opening: 60, credit: 48, debit: 91, closing: 17, retailprice: 265, locationid: 2 },
|
||||
{ productid: 9007, productname: 'Cadbury Dairy Milk 50g', opening: 220, credit: 100, debit: 288, closing: 32, retailprice: 45, locationid: 2 },
|
||||
{ productid: 9009, productname: 'Nestlé Munch 32g', opening: 300, credit: 0, debit: 41, closing: 259, retailprice: 20, locationid: 2 },
|
||||
],
|
||||
3: [
|
||||
{ productid: 9004, productname: 'Amul Butter 500g', opening: 20, credit: 0, debit: 20, closing: 0, retailprice: 265, locationid: 3 },
|
||||
{ productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 70, credit: 30, debit: 88, closing: 12, retailprice: 35, locationid: 3 },
|
||||
{ productid: 9009, productname: 'Nestlé Munch 32g', opening: 150, credit: 0, debit: 9, closing: 141, retailprice: 20, locationid: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Sales rows — app orders, deliveries and counter bills
|
||||
|
||||
The order set mirrors the shape of the live tenant rather than an idealised
|
||||
one: RS Puram's cancel rate is genuinely 98%, so most of its orders are
|
||||
cancelled here too. A demo that only shows the happy path hides the exact
|
||||
thing this page exists to surface.
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
|
||||
|
||||
/** Hours ago, as the timestamp string the API returns. */
|
||||
const hoursAgo = (h: number): string =>
|
||||
new Date(Date.now() - h * 3_600_000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
const BRANCH_NAME: Record<number, string> = {
|
||||
1: 'R mart — RS Puram',
|
||||
2: 'R mart — Peelamedu',
|
||||
3: 'R mart — Saravanampatti',
|
||||
};
|
||||
|
||||
const CUSTOMERS = [
|
||||
{ name: 'Meena Ravi', phone: '9840012233', suburb: 'RS Puram', address: '12, Bharathi Street, RS Puram' },
|
||||
{ name: 'Karthik S', phone: '9840044556', suburb: 'Peelamedu', address: '7, Nehru Nagar, Peelamedu' },
|
||||
{ name: 'Divya Prakash', phone: '9840077889', suburb: 'Saravanampatti', address: '22, Sathy Road, Saravanampatti' },
|
||||
{ name: 'Arun Kumar', phone: '9840099001', suburb: 'Gandhipuram', address: '90, Cross Cut Road, Gandhipuram' },
|
||||
{ name: 'Lakshmi N', phone: '9840033445', suburb: 'Peelamedu', address: '3, Avinashi Road, Peelamedu' },
|
||||
];
|
||||
|
||||
const RIDERS = ['Suresh M', 'Vignesh R', 'Prakash T'];
|
||||
|
||||
function order(
|
||||
id: number,
|
||||
locationid: number,
|
||||
hours: number,
|
||||
orderstatus: string,
|
||||
deliverystatus: string,
|
||||
amount: number,
|
||||
items: number,
|
||||
riderIndex: number | null,
|
||||
): OrderRow {
|
||||
const customer = CUSTOMERS[id % CUSTOMERS.length] as (typeof CUSTOMERS)[number];
|
||||
const isDone = deliverystatus === 'Delivered';
|
||||
const isMoving = deliverystatus === 'Out for Delivery' || isDone;
|
||||
return {
|
||||
orderheaderid: 7000 + id,
|
||||
orderid: `RM${String(24000 + id)}`,
|
||||
tenantid: 1135,
|
||||
locationid,
|
||||
locationname: BRANCH_NAME[locationid],
|
||||
orderdate: hoursAgo(hours),
|
||||
orderstatus,
|
||||
deliverystatus,
|
||||
itemcount: items,
|
||||
orderamount: amount,
|
||||
deliverycharge: 25,
|
||||
deliverycustomer: customer.name,
|
||||
deliverycontactno: customer.phone,
|
||||
deliveryaddress: customer.address,
|
||||
deliverysuburb: customer.suburb,
|
||||
pickupcustomer: BRANCH_NAME[locationid],
|
||||
pickupsuburb: customer.suburb,
|
||||
deliveryid: deliverystatus ? 5000 + id : 0,
|
||||
rider: riderIndex === null ? '' : RIDERS[riderIndex],
|
||||
ridercontactno: riderIndex === null ? '' : '9000011122',
|
||||
riderkms: isMoving ? (2 + (id % 5)).toFixed(1) : '',
|
||||
assigntime: riderIndex === null ? '' : hoursAgo(hours - 0.2),
|
||||
pickuptime: isMoving ? hoursAgo(hours - 0.4) : '',
|
||||
deliverytime: isDone ? hoursAgo(hours - 0.9) : '',
|
||||
canceltime: orderstatus === 'Cancelled' ? hoursAgo(hours - 0.3) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export const DEMO_ORDERS: OrderRow[] = [
|
||||
// Peelamedu — the branch that actually trades well.
|
||||
order(1, 2, 0.4, 'Confirmed', 'Out for Delivery', 862, 9, 0),
|
||||
order(2, 2, 1.1, 'Delivered', 'Delivered', 1240, 14, 1),
|
||||
order(3, 2, 2.3, 'Delivered', 'Delivered', 415, 5, 0),
|
||||
order(4, 2, 3.0, 'Preparing', 'Pending', 690, 7, null),
|
||||
order(5, 2, 4.6, 'Delivered', 'Delivered', 2180, 22, 2),
|
||||
order(6, 2, 6.2, 'Cancelled', 'Cancelled', 320, 4, null),
|
||||
order(7, 2, 8.0, 'Delivered', 'Delivered', 975, 11, 1),
|
||||
// Saravanampatti — steady, a couple in flight.
|
||||
order(8, 3, 0.8, 'Ready', 'Pending', 540, 6, null),
|
||||
order(9, 3, 2.0, 'Confirmed', 'Out for Delivery', 1105, 12, 2),
|
||||
order(10, 3, 5.4, 'Delivered', 'Delivered', 780, 8, 0),
|
||||
order(11, 3, 9.1, 'Cancelled', 'Cancelled', 260, 3, null),
|
||||
// RS Puram — the 98% cancel rate, which is the finding worth surfacing.
|
||||
order(12, 1, 1.5, 'Cancelled', 'Cancelled', 180, 2, null),
|
||||
order(13, 1, 3.7, 'Cancelled', 'Cancelled', 340, 4, null),
|
||||
order(14, 1, 5.9, 'Cancelled', 'Cancelled', 95, 1, null),
|
||||
order(15, 1, 7.2, 'Cancelled', 'Cancelled', 420, 5, null),
|
||||
order(16, 1, 10.5, 'Delivered', 'Delivered', 1350, 15, 1),
|
||||
];
|
||||
|
||||
function bill(
|
||||
id: number,
|
||||
locationid: number,
|
||||
terminalid: string,
|
||||
cashier: string,
|
||||
hours: number,
|
||||
total: number,
|
||||
items: number,
|
||||
mode: string,
|
||||
customer?: string,
|
||||
): PosSale {
|
||||
return {
|
||||
posorderid: 88000 + id,
|
||||
terminalorderid: `b7f${String(id).padStart(4, '0')}-4a2c-11f0-9cd2-0242ac120002`,
|
||||
invoicenumber: `${terminalid}/${String(1200 + id)}`,
|
||||
tenantid: 1135,
|
||||
locationid,
|
||||
terminalid,
|
||||
cashiername: cashier,
|
||||
customerid: customer ? 4000 + id : 0,
|
||||
customername: customer ?? '',
|
||||
billedat: hoursAgo(hours),
|
||||
businessdate: new Date().toISOString().slice(0, 10),
|
||||
subtotal: Math.round(total * 0.91),
|
||||
discount: Math.round(total * 0.02),
|
||||
taxamount: Math.round(total * 0.09),
|
||||
roundoff: 0.4,
|
||||
total,
|
||||
itemcount: items,
|
||||
paymentmode: mode,
|
||||
batchid: `batch-${locationid}-${Math.ceil(hours)}`,
|
||||
receivedat: hoursAgo(hours - 0.01),
|
||||
};
|
||||
}
|
||||
|
||||
export const DEMO_POS_BILLS: Record<number, PosSale[]> = {
|
||||
1: [
|
||||
bill(1, 1, 'RSP-01', 'Anitha', 0.2, 486, 6, 'UPI', 'Meena Ravi'),
|
||||
bill(2, 1, 'RSP-01', 'Anitha', 0.9, 1240, 14, 'Cash'),
|
||||
bill(3, 1, 'RSP-01', 'Anitha', 1.8, 312, 3, 'UPI'),
|
||||
bill(4, 1, 'RSP-01', 'Ramesh', 3.4, 875, 9, 'Card', 'Arun Kumar'),
|
||||
bill(5, 1, 'RSP-01', 'Ramesh', 5.1, 204, 2, 'UPI'),
|
||||
],
|
||||
2: [
|
||||
bill(6, 2, 'PLM-01', 'Kavitha', 0.1, 1580, 18, 'UPI', 'Karthik S'),
|
||||
bill(7, 2, 'PLM-01', 'Kavitha', 0.6, 640, 7, 'Cash'),
|
||||
bill(8, 2, 'PLM-02', 'Selvam', 1.2, 2340, 26, 'Card', 'Lakshmi N'),
|
||||
bill(9, 2, 'PLM-01', 'Kavitha', 2.5, 415, 4, 'UPI'),
|
||||
bill(10, 2, 'PLM-02', 'Selvam', 4.0, 980, 11, 'Cash'),
|
||||
bill(11, 2, 'PLM-01', 'Kavitha', 6.3, 1120, 13, 'UPI', 'Divya Prakash'),
|
||||
],
|
||||
3: [
|
||||
bill(12, 3, 'SRV-01', 'Bhuvana', 3.3, 720, 8, 'Cash', 'Divya Prakash'),
|
||||
bill(13, 3, 'SRV-01', 'Bhuvana', 4.8, 395, 4, 'UPI'),
|
||||
bill(14, 3, 'SRV-01', 'Bhuvana', 7.5, 1260, 15, 'Cash'),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Delivery jobs — a SEPARATE fixture from the orders above, because they are a
|
||||
* separate read with a different shape.
|
||||
*
|
||||
* Two things are deliberately imperfect. `actualkms` exceeds `kms` on a couple
|
||||
* of jobs, because that gap is the reason the table shows both. And the delivery
|
||||
* status uses its own ladder (accepted / picked / active), not the order one —
|
||||
* a demo that reused the order words would make the two maps look redundant.
|
||||
*/
|
||||
export const DEMO_DELIVERIES: DeliveryRow[] = [
|
||||
{
|
||||
deliveryid: 5001, orderheaderid: 7001, orderid: 'RM24001', tenantid: 1135, locationid: 2,
|
||||
deliverydate: hoursAgo(0.7),
|
||||
locationname: 'R mart — Peelamedu', orderstatus: 'active',
|
||||
assigntime: hoursAgo(0.6), starttime: hoursAgo(0.55), pickuptime: hoursAgo(0.4),
|
||||
itemcount: 9, orderamount: 862, deliveryamt: 862, deliverycharges: 45,
|
||||
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu', pickupcontactno: '9879879877',
|
||||
deliverycustomer: 'Karthik S', deliverycontactno: '9840044556',
|
||||
deliveryaddress: '7, Nehru Nagar, Peelamedu', deliverysuburb: 'Peelamedu',
|
||||
ridername: 'Suresh M', ridercontact: '9000011122', kms: '3.0', actualkms: '3.4',
|
||||
transitminutes: 18, ordernotes: 'Call on arrival, gate code 4412',
|
||||
},
|
||||
{
|
||||
deliveryid: 5002, orderheaderid: 7002, orderid: 'RM24002', tenantid: 1135, locationid: 2,
|
||||
deliverydate: hoursAgo(1.4),
|
||||
locationname: 'R mart — Peelamedu', orderstatus: 'delivered',
|
||||
assigntime: hoursAgo(1.3), pickuptime: hoursAgo(1.1), deliverytime: hoursAgo(0.6),
|
||||
itemcount: 14, orderamount: 1240, deliveryamt: 1240, deliverycharges: 55,
|
||||
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu',
|
||||
deliverycustomer: 'Divya Prakash', deliverycontactno: '9840077889',
|
||||
deliveryaddress: '22, Sathy Road, Saravanampatti', deliverysuburb: 'Saravanampatti',
|
||||
ridername: 'Vignesh R', ridercontact: '9000011133', kms: '4.0', actualkms: '9.2',
|
||||
transitminutes: 41, notes: 'Wrong pin dropped, rider re-routed',
|
||||
},
|
||||
{
|
||||
deliveryid: 5003, orderheaderid: 7005, orderid: 'RM24005', tenantid: 1135, locationid: 2,
|
||||
deliverydate: hoursAgo(4.9),
|
||||
locationname: 'R mart — Peelamedu', orderstatus: 'delivered',
|
||||
assigntime: hoursAgo(4.8), pickuptime: hoursAgo(4.6), deliverytime: hoursAgo(3.9),
|
||||
itemcount: 22, orderamount: 2180, deliveryamt: 2180, deliverycharges: 60,
|
||||
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu',
|
||||
deliverycustomer: 'Meena Ravi', deliverycontactno: '9840012233',
|
||||
deliveryaddress: '12, Bharathi Street, RS Puram', deliverysuburb: 'RS Puram',
|
||||
ridername: 'Prakash T', ridercontact: '9000011144', kms: '2.0', actualkms: '2.1',
|
||||
transitminutes: 14,
|
||||
},
|
||||
{
|
||||
deliveryid: 5004, orderheaderid: 7004, orderid: 'RM24004', tenantid: 1135, locationid: 2,
|
||||
deliverydate: hoursAgo(3.1),
|
||||
locationname: 'R mart — Peelamedu', orderstatus: 'pending',
|
||||
itemcount: 7, orderamount: 690, deliveryamt: 690, deliverycharges: 0,
|
||||
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu',
|
||||
deliverycustomer: 'Lakshmi N', deliverycontactno: '9840033445',
|
||||
deliveryaddress: '3, Avinashi Road, Peelamedu', deliverysuburb: 'Peelamedu',
|
||||
kms: '1.8', ordernotes: 'Waiting on packing',
|
||||
},
|
||||
{
|
||||
deliveryid: 5005, orderheaderid: 7009, orderid: 'RM24009', tenantid: 1135, locationid: 3,
|
||||
deliverydate: hoursAgo(2.3),
|
||||
locationname: 'R mart — Saravanampatti', orderstatus: 'picked',
|
||||
assigntime: hoursAgo(2.2), pickuptime: hoursAgo(1.9),
|
||||
itemcount: 12, orderamount: 1105, deliveryamt: 1105, deliverycharges: 50,
|
||||
pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti',
|
||||
deliverycustomer: 'Lakshmi N', deliverycontactno: '9840033445',
|
||||
deliveryaddress: '3, Avinashi Road, Peelamedu', deliverysuburb: 'Peelamedu',
|
||||
ridername: 'Prakash T', ridercontact: '9000011144', kms: '6.0', actualkms: '6.3',
|
||||
transitminutes: 27,
|
||||
},
|
||||
{
|
||||
deliveryid: 5006, orderheaderid: 7008, orderid: 'RM24008', tenantid: 1135, locationid: 3,
|
||||
deliverydate: hoursAgo(1.0),
|
||||
locationname: 'R mart — Saravanampatti', orderstatus: 'accepted',
|
||||
assigntime: hoursAgo(0.9),
|
||||
itemcount: 6, orderamount: 540, deliveryamt: 540, deliverycharges: 40,
|
||||
pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti',
|
||||
deliverycustomer: 'Arun Kumar', deliverycontactno: '9840099001',
|
||||
deliveryaddress: '90, Cross Cut Road, Gandhipuram', deliverysuburb: 'Gandhipuram',
|
||||
ridername: 'Suresh M', ridercontact: '9000011122', kms: '5.5',
|
||||
},
|
||||
{
|
||||
deliveryid: 5007, orderheaderid: 7010, orderid: 'RM24010', tenantid: 1135, locationid: 3,
|
||||
deliverydate: hoursAgo(5.7),
|
||||
locationname: 'R mart — Saravanampatti', orderstatus: 'delivered',
|
||||
assigntime: hoursAgo(5.6), pickuptime: hoursAgo(5.4), deliverytime: hoursAgo(4.8),
|
||||
itemcount: 8, orderamount: 780, deliveryamt: 780, deliverycharges: 45,
|
||||
pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti',
|
||||
deliverycustomer: 'Meena Ravi', deliverycontactno: '9840012233',
|
||||
deliveryaddress: '12, Bharathi Street, RS Puram', deliverysuburb: 'RS Puram',
|
||||
ridername: 'Suresh M', ridercontact: '9000011122', kms: '2.0', actualkms: '2.4',
|
||||
transitminutes: 16,
|
||||
},
|
||||
{
|
||||
deliveryid: 5008, orderheaderid: 7006, orderid: 'RM24006', tenantid: 1135, locationid: 2,
|
||||
deliverydate: hoursAgo(6.2),
|
||||
locationname: 'R mart — Peelamedu', orderstatus: 'cancelled',
|
||||
canceltime: hoursAgo(6.0),
|
||||
itemcount: 4, orderamount: 320, deliveryamt: 0, deliverycharges: 0,
|
||||
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu',
|
||||
deliverycustomer: 'Karthik S', deliverycontactno: '9840044556',
|
||||
deliveryaddress: '7, Nehru Nagar, Peelamedu', deliverysuburb: 'Peelamedu',
|
||||
kms: '3.0', notes: 'Customer cancelled before pickup',
|
||||
},
|
||||
{
|
||||
deliveryid: 5009, orderheaderid: 7012, orderid: 'RM24012', tenantid: 1135, locationid: 1,
|
||||
deliverydate: hoursAgo(1.7),
|
||||
locationname: 'R mart — RS Puram', orderstatus: 'skipped',
|
||||
assigntime: hoursAgo(1.6),
|
||||
itemcount: 2, orderamount: 180, deliveryamt: 180, deliverycharges: 35,
|
||||
pickupcustomer: 'R mart — RS Puram', pickupsuburb: 'RS Puram',
|
||||
deliverycustomer: 'Divya Prakash', deliverycontactno: '9840077889',
|
||||
deliveryaddress: '22, Sathy Road, Saravanampatti', deliverysuburb: 'Saravanampatti',
|
||||
ridername: 'Vignesh R', kms: '7.2', notes: 'Nobody at address, returned to store',
|
||||
},
|
||||
];
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
People — back office and till accounts
|
||||
|
||||
Two separate sets on purpose, because they are two separate account systems.
|
||||
Note the till accounts carry NO password or pin: the backend returns those
|
||||
only in the answer to a creation, never in a listing, and a fixture that
|
||||
handed them back in a list would teach the UI a habit the real API refuses.
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift } from '@/api/types';
|
||||
|
||||
export const DEMO_STAFF: StaffInfo[] = [
|
||||
{
|
||||
userid: 9002, rolename: 'Administrator', roleid: 1, fullname: 'Rmart Operations',
|
||||
firstname: 'Rmart', lastname: 'Operations', email: 'rmart@gmail.com', contactno: '9876543210',
|
||||
tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', status: 'Active',
|
||||
},
|
||||
{
|
||||
userid: 9010, rolename: 'Manager', roleid: 4, fullname: 'Anitha Selvam',
|
||||
firstname: 'Anitha', lastname: 'Selvam', email: 'anitha@rmart.com', contactno: '9840111222',
|
||||
tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', status: 'Active',
|
||||
},
|
||||
{
|
||||
userid: 9011, rolename: 'Manager', roleid: 4, fullname: 'Vijay Anand',
|
||||
firstname: 'Vijay', lastname: 'Anand', email: 'vijay@rmart.com', contactno: '9840111333',
|
||||
tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', status: 'Active',
|
||||
},
|
||||
{
|
||||
userid: 9012, rolename: 'Staff', roleid: 5, fullname: 'Priya Raman',
|
||||
firstname: 'Priya', lastname: 'Raman', email: 'priya@rmart.com', contactno: '9840111444',
|
||||
tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', status: 'Active',
|
||||
},
|
||||
{
|
||||
userid: 9013, rolename: 'Staff', roleid: 5, fullname: 'Mohan Das',
|
||||
firstname: 'Mohan', lastname: 'Das', email: 'mohan@rmart.com', contactno: '9840111555',
|
||||
tenantid: 1135, locationid: 3, locationname: 'R mart — Saravanampatti', status: 'Inactive',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_POS_ROLES: PosRole[] = [
|
||||
{
|
||||
role_id: 7, role: 'supervisor', label: 'Supervisor',
|
||||
description:
|
||||
'Runs the terminal: imports, settings, voids, and creating counter staff. ' +
|
||||
'Signs in at a till only — a till account has no Nearle Daily login.',
|
||||
},
|
||||
{
|
||||
role_id: 8, role: 'cashier', label: 'Cashier',
|
||||
description: 'Billing only.',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_SHIFTS: Record<number, StaffShift[]> = {
|
||||
1: [
|
||||
{ staff_shift_id: 11, tenantid: 1135, locationid: 1, name: 'Morning', start_time: '08:00', end_time: '15:00', weekdays: '1111100', status: 'Active' },
|
||||
{ staff_shift_id: 12, tenantid: 1135, locationid: 1, name: 'Evening', start_time: '15:00', end_time: '22:00', weekdays: '1111111', status: 'Active' },
|
||||
],
|
||||
2: [
|
||||
{ staff_shift_id: 13, tenantid: 1135, locationid: 2, name: 'Morning', start_time: '07:00', end_time: '15:00', weekdays: '1111111', status: 'Active' },
|
||||
{ staff_shift_id: 14, tenantid: 1135, locationid: 2, name: 'Late', start_time: '15:00', end_time: '23:00', weekdays: '1111111', status: 'Active' },
|
||||
],
|
||||
3: [
|
||||
{ staff_shift_id: 15, tenantid: 1135, locationid: 3, name: 'Full day', start_time: '08:00', end_time: '22:00', weekdays: '', status: 'Active' },
|
||||
],
|
||||
};
|
||||
|
||||
export const DEMO_POS_USERS: Record<number, PosUser[]> = {
|
||||
1: [
|
||||
{ user_id: 7001, full_name: 'Anitha Selvam', role: 'supervisor', role_id: 7, contactno: '9840111222', has_password: true, shift_id: 11, shift_name: 'Morning', shift_start: '08:00', shift_end: '15:00', location_id: 1, status: 'Active' },
|
||||
{ user_id: 7002, full_name: 'Ramesh K', role: 'cashier', role_id: 8, contactno: '9840222111', has_password: true, shift_id: 12, shift_name: 'Evening', shift_start: '15:00', shift_end: '22:00', location_id: 1, status: 'Active' },
|
||||
],
|
||||
2: [
|
||||
{ user_id: 7003, full_name: 'Kavitha M', role: 'supervisor', role_id: 7, contactno: '9840333111', has_password: true, shift_id: 13, shift_name: 'Morning', shift_start: '07:00', shift_end: '15:00', location_id: 2, status: 'Active' },
|
||||
{ user_id: 7004, full_name: 'Selvam R', role: 'cashier', role_id: 8, contactno: '9840333222', has_password: true, shift_id: 14, shift_name: 'Late', shift_start: '15:00', shift_end: '23:00', location_id: 2, status: 'Active' },
|
||||
{ user_id: 7005, full_name: 'Deepa N', role: 'cashier', role_id: 8, contactno: '9840333444', has_password: false, location_id: 2, status: 'Inactive' },
|
||||
],
|
||||
3: [
|
||||
{ user_id: 7006, full_name: 'Bhuvana S', role: 'supervisor', role_id: 7, contactno: '9840444111', has_password: true, shift_id: 15, shift_name: 'Full day', shift_start: '08:00', shift_end: '22:00', location_id: 3, status: 'Active' },
|
||||
],
|
||||
};
|
||||
217
src/demo/index.ts
Normal file
217
src/demo/index.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Demo mode — three signed-in roles and a fixture backend, for working on the
|
||||
* console without reaching Fiesta.
|
||||
*
|
||||
* WHY THIS EXISTS. The API is wired and correct, but it has never been
|
||||
* exercised end to end from a development machine that cannot reach
|
||||
* `fiesta.nearle.app`, and the Store Admin and Store Manager workspaces have no
|
||||
* accounts to sign into yet. Rather than leave every screen showing an error,
|
||||
* demo mode seeds a session and answers the same endpoints from fixtures.
|
||||
*
|
||||
* HOW IT IS GATED — two locks, and both must be open:
|
||||
*
|
||||
* 1. `import.meta.env.DEV` — a build-time constant. In a production build
|
||||
* this is `false`, so every branch below is dead code that the bundler
|
||||
* removes entirely. The fixtures never ship.
|
||||
* 2. A `sessionStorage` flag the person sets by clicking a demo button. Even
|
||||
* in development, a normal sign-in against the real API is unaffected
|
||||
* unless demo mode was explicitly chosen.
|
||||
*
|
||||
* This is a development affordance, NOT an authentication bypass: it cannot be
|
||||
* reached in a production build at all, and it grants nothing on the server —
|
||||
* it only decides what this browser tab renders.
|
||||
*/
|
||||
|
||||
const FLAG = 'nearle.demo.v1';
|
||||
|
||||
/** Compile-time gate. `false` in a production build, so everything below drops. */
|
||||
export const DEMO_AVAILABLE = import.meta.env.DEV;
|
||||
|
||||
export function isDemoActive(): boolean {
|
||||
if (!DEMO_AVAILABLE) return false;
|
||||
try {
|
||||
return sessionStorage.getItem(FLAG) === 'on';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function enableDemo(): void {
|
||||
sessionStorage.setItem(FLAG, 'on');
|
||||
}
|
||||
|
||||
export function disableDemo(): void {
|
||||
sessionStorage.removeItem(FLAG);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
The fixture backend
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Answers a request from fixtures, or returns `MISS` to let the real call go
|
||||
* out.
|
||||
*
|
||||
* `MISS` is a sentinel rather than `undefined` because an endpoint legitimately
|
||||
* answering `undefined` and an endpoint we have no fixture for are different
|
||||
* things, and collapsing them would silently swallow the second.
|
||||
*/
|
||||
export const MISS = Symbol('demo-miss');
|
||||
|
||||
/**
|
||||
* Answers a request from fixtures, or returns `MISS` to let the real call go
|
||||
* out.
|
||||
*
|
||||
* `MISS` is a sentinel rather than `undefined` because an endpoint legitimately
|
||||
* answering `undefined` and an endpoint we have no fixture for are different
|
||||
* things, and collapsing them would silently swallow the second.
|
||||
*
|
||||
* The fixtures are behind a dynamic import INSIDE the `import.meta.env.DEV`
|
||||
* guard, and that is load-bearing rather than stylistic: with a static import
|
||||
* the bundler cannot prove the module is unreachable, so the whole fixture set
|
||||
* ships to production. Verified by grepping the built assets for fixture
|
||||
* strings — see the note in the README.
|
||||
*/
|
||||
export async function demoResolve(
|
||||
path: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
): Promise<unknown | typeof MISS> {
|
||||
if (!import.meta.env.DEV) return MISS;
|
||||
|
||||
const f = await import('./fixtures');
|
||||
const tenantid = Number(params?.['tenantid'] ?? 0);
|
||||
|
||||
if (path.endsWith('/tenants/getalltenants')) return f.DEMO_TENANTS;
|
||||
|
||||
if (path.endsWith('/tenants/gettenantlocations')) {
|
||||
return f.DEMO_LOCATIONS[tenantid] ?? [];
|
||||
}
|
||||
|
||||
if (path.endsWith('/catalogue/getproducts')) {
|
||||
const brand = String(params?.['brand'] ?? '');
|
||||
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
|
||||
return f.DEMO_CATALOGUE.filter(
|
||||
(product) =>
|
||||
(brand === '' || product.brand === brand) &&
|
||||
(keyword === '' || product.product_name.toLowerCase().includes(keyword)),
|
||||
);
|
||||
}
|
||||
|
||||
if (path.endsWith('/catalogue/getbrands')) return f.DEMO_BRANDS;
|
||||
|
||||
if (path.endsWith('/products/getimportedcatalogueproducts')) {
|
||||
return f.DEMO_IMPORTED[tenantid] ?? [];
|
||||
}
|
||||
|
||||
if (path.endsWith('/products/getproductcategories')) return f.DEMO_CATEGORIES;
|
||||
|
||||
if (path.endsWith('/products/getlocationproducts')) return f.DEMO_STORE_PRODUCTS;
|
||||
|
||||
if (path.endsWith('/orders/getlocationsummary')) {
|
||||
return f.DEMO_LOCATION_SUMMARY[tenantid] ?? [];
|
||||
}
|
||||
|
||||
if (path.endsWith('/orders/getordersummary')) {
|
||||
return f.DEMO_ORDER_SUMMARY[tenantid] ?? {};
|
||||
}
|
||||
|
||||
/* ── Store Admin ──────────────────────────────────────────────────────── */
|
||||
|
||||
if (path.endsWith('/orders/tenant/getorders') || path.endsWith('/orders/getorders')) {
|
||||
const locationid = Number(params?.['locationid'] ?? 0);
|
||||
const status = String(params?.['status'] ?? '');
|
||||
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
|
||||
return f.DEMO_ORDERS.filter(
|
||||
(order) =>
|
||||
(!locationid || order.locationid === locationid) &&
|
||||
(status === '' || order.orderstatus === status) &&
|
||||
(keyword === '' ||
|
||||
`${order.orderid} ${order.deliverycustomer}`.toLowerCase().includes(keyword)),
|
||||
);
|
||||
}
|
||||
|
||||
if (path.endsWith('/deliveries/getdeliveries')) {
|
||||
const locationid = Number(params?.['locationid'] ?? 0);
|
||||
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
|
||||
return f.DEMO_DELIVERIES.filter(
|
||||
(job) =>
|
||||
(!locationid || job.locationid === locationid) &&
|
||||
(keyword === '' ||
|
||||
`${job.orderid} ${job.deliverycustomer} ${job.ridername}`
|
||||
.toLowerCase()
|
||||
.includes(keyword)),
|
||||
);
|
||||
}
|
||||
|
||||
if (path.includes('/pos/sales') && !path.includes('summary') && !path.includes('detail')) {
|
||||
const locationid = Number(params?.['locationid'] ?? 0);
|
||||
const bills = f.DEMO_POS_BILLS[locationid] ?? [];
|
||||
return { total: bills.length, pageno: 0, pagesize: 100, bills };
|
||||
}
|
||||
|
||||
if (path.includes('/health/location')) {
|
||||
const locationid = Number(params?.['location_id'] ?? 0);
|
||||
return f.DEMO_POS_HEALTH[locationid] ?? [];
|
||||
}
|
||||
|
||||
if (path.includes('/sales/summary')) {
|
||||
const locationid = Number(params?.['locationid'] ?? 0);
|
||||
return f.DEMO_POS_SUMMARY[locationid] ?? {};
|
||||
}
|
||||
|
||||
if (path.endsWith('/tenants/getstaffs')) return f.DEMO_STAFF;
|
||||
|
||||
if (path.endsWith('/posroles')) return f.DEMO_POS_ROLES;
|
||||
|
||||
if (path.endsWith('/getposusers')) {
|
||||
const locationid = Number(params?.['locationid'] ?? 0);
|
||||
return f.DEMO_POS_USERS[locationid] ?? [];
|
||||
}
|
||||
|
||||
if (path.endsWith('/getstaffshifts')) {
|
||||
const locationid = Number(params?.['locationid'] ?? 0);
|
||||
return f.DEMO_SHIFTS[locationid] ?? [];
|
||||
}
|
||||
|
||||
// A created till account answers with its PIN once — the same shape the real
|
||||
// endpoint uses, so the "shown once" screen is exercised in demo too.
|
||||
if (path.includes('/createposuser')) {
|
||||
return { user_id: 7099, full_name: 'New till user', role: 'cashier', status: 'Active', pin: '4821' };
|
||||
}
|
||||
if (path.includes('/updateposuser')) return { user_id: 7099, status: 'Active' };
|
||||
if (path.includes('/users/create')) return { userid: 9099, status: 'Active' };
|
||||
if (path.includes('/users/update')) return { userid: 9099, status: 'Active' };
|
||||
|
||||
if (path.endsWith('/products/getstockrequests')) {
|
||||
const locationid = Number(params?.['locationid'] ?? 0);
|
||||
const status = String(params?.['status'] ?? '');
|
||||
return f.DEMO_STOCK_REQUESTS.filter(
|
||||
(request) =>
|
||||
(!locationid || request.locationid === locationid) &&
|
||||
(status === '' || request.status === status),
|
||||
);
|
||||
}
|
||||
|
||||
if (path.endsWith('/products/getstockstatement')) {
|
||||
const locationid = Number(params?.['locationid'] ?? 0);
|
||||
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
|
||||
const rows = f.DEMO_STOCK_STATEMENT[locationid] ?? [];
|
||||
return keyword === ''
|
||||
? rows
|
||||
: rows.filter((row) => (row.productname ?? '').toLowerCase().includes(keyword));
|
||||
}
|
||||
|
||||
if (path.includes('/updatestockrequest')) return { updated: true };
|
||||
|
||||
// Writes acknowledge without persisting: the point of demo mode is to walk
|
||||
// the screens, and a form that silently fails teaches the wrong thing.
|
||||
if (path.includes('/createtenantlocation')) {
|
||||
return { tenantid: 1140, locationid: 20, tenantname: 'New tenant', status: 'Active' };
|
||||
}
|
||||
if (path.includes('/createlocation')) {
|
||||
return { locationid: 21, tenantid, locationname: 'New branch', status: 'Active' };
|
||||
}
|
||||
if (path.includes('/importcatalogueproduct')) return { imported: true };
|
||||
|
||||
return MISS;
|
||||
}
|
||||
9
src/env.d.ts
vendored
Normal file
9
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* When this bundle was built, injected by `vite.config.ts`.
|
||||
*
|
||||
* Shown at the foot of the account menu so a stale copy is visible on the
|
||||
* screen rather than only in someone's memory of what was delivered.
|
||||
*/
|
||||
declare const __BUILD_STAMP__: string;
|
||||
539
src/features/auth/LoginPage.tsx
Normal file
539
src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, Sparkles } from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import { PasswordSetupRequiredError } from '@/auth/session';
|
||||
import type { ConsoleRole, SessionUser } from '@/auth/roles';
|
||||
|
||||
/**
|
||||
* The demo panel is loaded lazily behind the build-time DEV constant, so in a
|
||||
* production build this expression is `null`, the import is dead code, and
|
||||
* neither the panel nor the accounts behind it are emitted at all.
|
||||
*/
|
||||
const DemoPanel = import.meta.env.DEV
|
||||
? lazy(() => import('@/demo/DemoPanel').then((m) => ({ default: m.DemoPanel })))
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Sign-in — KROW's full-bleed auth archetype.
|
||||
*
|
||||
* A single 1024px card at 28px radius over the ambient canvas, split two-up:
|
||||
* a tinted brand panel on the left carrying an inset product image, and the
|
||||
* form on the right at a 448px measure. Rhythm is 24px between regions, 16px
|
||||
* between fields, 6px inside a field.
|
||||
*
|
||||
* One form for all three roles. The old console had three separate login paths
|
||||
* because the backend compares a client-declared roleid against the stored one;
|
||||
* here the role is read off the login response, so a person signs in once and
|
||||
* lands where their account says they belong.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const { user, signIn, signInAsDemo } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isRemembered, setIsRemembered] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const session = await signIn(email, password);
|
||||
navigate(HOME_ROUTE[session.role], { replace: true });
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof PasswordSetupRequiredError
|
||||
? 'This account has no password set yet. An administrator needs to set one before you can sign in.'
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: 'Sign-in failed',
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="login-shell"
|
||||
style={{ minHeight: '100dvh', display: 'grid', placeItems: 'center' }}
|
||||
>
|
||||
<div
|
||||
className="login-split"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 1024,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 28,
|
||||
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<BrandPanel />
|
||||
<FormPanel
|
||||
email={email}
|
||||
password={password}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
isRemembered={isRemembered}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
canSubmit={canSubmit}
|
||||
onEmail={setEmail}
|
||||
onPassword={setPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onToggleRemember={() => setIsRemembered((remembered) => !remembered)}
|
||||
onSubmit={handleSubmit}
|
||||
onDemo={(role, session) => {
|
||||
signInAsDemo(session);
|
||||
navigate(HOME_ROUTE[role], { replace: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Left — the brand panel
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A tint, not a slab.
|
||||
*
|
||||
* The reference panel is a soft wash rather than a saturated block, so the card
|
||||
* reads as one surface with a warm side rather than two glued halves. The
|
||||
* gradient runs from the ambient canvas's violet stop into its warm stop, which
|
||||
* is what ties the card to the page behind it.
|
||||
*/
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<div
|
||||
className="login-brand"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
background: 'linear-gradient(155deg, #F4EEF8 0%, #F7F3F9 45%, #FBF8F2 100%)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img
|
||||
src="/logo-wordmark.png"
|
||||
alt="Nearle"
|
||||
style={{ height: 28, width: 'auto', display: 'block' }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--color-brand)',
|
||||
color: '#fff',
|
||||
borderRadius: 999,
|
||||
padding: '3px 10px',
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
CONSOLE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* The inset image card: white, 8px inset, hairline border, soft shadow.
|
||||
Hidden on a phone, where it would push the form below the fold. */}
|
||||
<div
|
||||
className="login-hero"
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
maxWidth: 320,
|
||||
width: '100%',
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 20,
|
||||
padding: 8,
|
||||
boxShadow: '0 4px 16px -2px rgb(16 24 32 / .06), 0 2px 6px -3px rgb(16 24 32 / .04)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
aspectRatio: '1',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/logo-512.png"
|
||||
alt=""
|
||||
width={280}
|
||||
height={280}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
color: 'var(--color-brand)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.11em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Retail operations platform
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 24,
|
||||
lineHeight: 1.28,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.015em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
Every store, every till, one console.
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: '10px 0 0',
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.65,
|
||||
color: 'var(--color-ink-3)',
|
||||
maxWidth: 380,
|
||||
}}
|
||||
>
|
||||
Onboard tenants and branches, publish the catalogue, and watch online orders and counter
|
||||
sales land side by side.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Right — the form
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface FormPanelProps {
|
||||
email: string;
|
||||
password: string;
|
||||
isPasswordVisible: boolean;
|
||||
isRemembered: boolean;
|
||||
error: string | null;
|
||||
isBusy: boolean;
|
||||
canSubmit: boolean;
|
||||
onEmail: (value: string) => void;
|
||||
onPassword: (value: string) => void;
|
||||
onToggleVisible: () => void;
|
||||
onToggleRemember: () => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onDemo: (role: ConsoleRole, session: SessionUser) => void;
|
||||
}
|
||||
|
||||
function FormPanel({
|
||||
email,
|
||||
password,
|
||||
isPasswordVisible,
|
||||
isRemembered,
|
||||
error,
|
||||
isBusy,
|
||||
canSubmit,
|
||||
onEmail,
|
||||
onPassword,
|
||||
onToggleVisible,
|
||||
onToggleRemember,
|
||||
onSubmit,
|
||||
onDemo,
|
||||
}: FormPanelProps) {
|
||||
return (
|
||||
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
||||
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 26,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
Welcome back
|
||||
</h1>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
||||
Sign in to manage tenants, branches, catalogue and counter sales.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Field label="Work email" htmlFor="email" icon={<Mail size={15} />}>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => onEmail(event.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Password"
|
||||
htmlFor="password"
|
||||
icon={<Lock size={15} />}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleVisible}
|
||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: 8,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<input
|
||||
id="password"
|
||||
type={isPasswordVisible ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(event) => onPassword(event.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
style={{ ...inputStyle, paddingRight: 40 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: 'var(--color-ink-2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRemembered}
|
||||
onChange={onToggleRemember}
|
||||
style={{ width: 15, height: 15, accentColor: 'var(--color-brand)', cursor: 'pointer' }}
|
||||
/>
|
||||
Keep me signed in
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-brand)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 9,
|
||||
padding: '11px 13px',
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-error-muted, #FCEEEE)',
|
||||
border: '1px solid rgba(214,69,69,.22)',
|
||||
color: '#B03434',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 9,
|
||||
height: 46,
|
||||
width: '100%',
|
||||
borderRadius: 14,
|
||||
border: 0,
|
||||
background: canSubmit ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
|
||||
color: '#fff',
|
||||
fontSize: 14.5,
|
||||
fontWeight: 600,
|
||||
cursor: canSubmit ? 'pointer' : 'default',
|
||||
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
|
||||
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
|
||||
}}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--color-ink-4)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Use the account your administrator set up for you. Your role decides which workspace
|
||||
opens — you do not pick one.
|
||||
</p>
|
||||
|
||||
{DemoPanel ? (
|
||||
<Suspense fallback={null}>
|
||||
<DemoPanel onDemo={onDemo} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Field
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
height: 44,
|
||||
width: '100%',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
padding: '0 12px 0 38px',
|
||||
fontSize: 14,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--color-ink-1)',
|
||||
outline: 'none',
|
||||
transition: 'border-color .2s, background .2s, box-shadow .2s',
|
||||
};
|
||||
|
||||
/**
|
||||
* A labelled field.
|
||||
*
|
||||
* The label is a small-caps overline rather than sentence case — the auth page
|
||||
* is the one screen with only two inputs on it, and the extra weight there
|
||||
* reads as deliberate rather than shouty.
|
||||
*/
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
icon,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
icon: ReactNode;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.09em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-ink-3)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 13,
|
||||
display: 'flex',
|
||||
color: 'var(--color-ink-4)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{children}
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
18
src/features/nearle-admin/NearleAdminShell.tsx
Normal file
18
src/features/nearle-admin/NearleAdminShell.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
|
||||
|
||||
/**
|
||||
* 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' },
|
||||
];
|
||||
|
||||
export function NearleAdminShell() {
|
||||
return <AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />;
|
||||
}
|
||||
181
src/features/nearle-admin/import/ImportDrawer.tsx
Normal file
181
src/features/nearle-admin/import/ImportDrawer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||
import { useProductCategories } from '@/queries/hooks';
|
||||
|
||||
export interface ImportDrawerProps {
|
||||
product: CatalogueProduct;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
isSaving: boolean;
|
||||
error: unknown;
|
||||
onClose: () => void;
|
||||
onSubmit: (row: ImportCatalogueProductRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five things the catalogue cannot supply.
|
||||
*
|
||||
* The global catalogue carries a price RANGE and a free-text category, and
|
||||
* neither maps onto a tenant's own pricing or category tree. So category,
|
||||
* subcategory, retail price, cost and tax are collected here before Import can
|
||||
* be enabled — an import with them missing produces a product a store cannot
|
||||
* legally sell.
|
||||
*/
|
||||
export function ImportDrawer({
|
||||
product,
|
||||
tenantid,
|
||||
locationid,
|
||||
isSaving,
|
||||
error,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ImportDrawerProps) {
|
||||
const { data: categories } = useProductCategories(tenantid);
|
||||
|
||||
const [categoryid, setCategoryid] = useState('');
|
||||
const [subcategoryid, setSubcategoryid] = useState('');
|
||||
const [retailprice, setRetailprice] = useState<number | null>(null);
|
||||
const [productcost, setProductcost] = useState<number | null>(null);
|
||||
const [taxpercent, setTaxpercent] = useState<number | null>(0);
|
||||
const [quantity, setQuantity] = useState<number | null>(0);
|
||||
|
||||
const categoryOptions = (categories ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
}));
|
||||
|
||||
const isComplete =
|
||||
categoryid !== '' &&
|
||||
typeof retailprice === 'number' &&
|
||||
retailprice > 0 &&
|
||||
typeof productcost === 'number' &&
|
||||
typeof taxpercent === 'number';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
className="dialog-panel"
|
||||
padding={0}
|
||||
>
|
||||
<DialogHeader title="Add to store" subtitle="Set what this store will sell it for" onOpenChange={(open) => (open ? undefined : onClose())} hasDivider />
|
||||
<VStack gap={3} padding={3}>
|
||||
<HStack gap={2} align="start">
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-slate-50)',
|
||||
flex: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="large" weight="semibold">
|
||||
{product.product_name}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{product.brand} · id {product.id} · {product.size ?? '—'}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Catalogue range {product.price_range ?? '—'} — a guide, not a price. What you set below
|
||||
is what the store sells at.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Category <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
options={categoryOptions}
|
||||
value={categoryid}
|
||||
onChange={setCategoryid}
|
||||
placeholder="Select category…"
|
||||
description="The tenant's own category, not the catalogue's."
|
||||
hasSearch
|
||||
/>
|
||||
<Selector
|
||||
label="Subcategory"
|
||||
options={[{ value: '', label: 'None' }]}
|
||||
value={subcategoryid}
|
||||
onChange={setSubcategoryid}
|
||||
placeholder="Select subcategory…"
|
||||
isDisabled={categoryid === ''}
|
||||
/>
|
||||
<NumberInput label={<span>Retail price (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={retailprice} onChange={setRetailprice} min={0} />
|
||||
<NumberInput label={<span>Cost (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={productcost} onChange={setProductcost} min={0} />
|
||||
<NumberInput label="Tax (%)" value={taxpercent} onChange={setTaxpercent} min={0} max={100} />
|
||||
<NumberInput
|
||||
label="Opening stock"
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={0}
|
||||
description="Re-importing adds to this rather than replacing it."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{errorMessage(error)}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="end" gap={1.5}>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={isSaving ? 'Adding…' : 'Add to store'}
|
||||
variant="primary"
|
||||
isLoading={isSaving}
|
||||
isDisabled={!isComplete}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
tenantid,
|
||||
locationid,
|
||||
brand: product.brand,
|
||||
catalogueid: product.id,
|
||||
categoryid: Number(categoryid),
|
||||
subcategoryid: subcategoryid === '' ? 0 : Number(subcategoryid),
|
||||
quantity: quantity ?? 0,
|
||||
stocktype: 'in',
|
||||
status: 'available',
|
||||
retailprice: retailprice ?? 0,
|
||||
productcost: productcost ?? 0,
|
||||
taxpercent: taxpercent ?? 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
320
src/features/nearle-admin/import/SheetImportPanel.tsx
Normal file
320
src/features/nearle-admin/import/SheetImportPanel.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
|
||||
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
|
||||
|
||||
interface PreviewRow extends Record<string, unknown> {
|
||||
productname: string;
|
||||
productsku: string;
|
||||
categoryid: number;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface SheetImportPanelProps {
|
||||
tenantid: number | undefined;
|
||||
locationid: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The spreadsheet import path.
|
||||
*
|
||||
* Staged rather than one-shot — upload, parse, show what is wrong, then commit —
|
||||
* because the underlying calls are N creates with no transaction and no batch,
|
||||
* so a row that fails on the server fails alone and has to be recoverable.
|
||||
*
|
||||
* It is also not idempotent, and cannot be made so from the client: nothing on
|
||||
* the server dedupes on SKU. Re-uploading the same file creates the products
|
||||
* twice, so that is said out loud before the button rather than discovered
|
||||
* afterwards.
|
||||
*/
|
||||
export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [result, setResult] = useState<SheetImportResult | null>(null);
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
async function handleFile(next: File | File[] | null) {
|
||||
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
|
||||
setFile(chosen);
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
setResult(null);
|
||||
if (!chosen) return;
|
||||
|
||||
try {
|
||||
setParsed(await parseProductSheet(chosen));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsed || !tenantid || !locationid) return;
|
||||
setProgress({ done: 0, total: parsed.rows.length });
|
||||
try {
|
||||
const outcome = await importSheetProducts({
|
||||
tenantid,
|
||||
locationid,
|
||||
rows: parsed.rows,
|
||||
onProgress: (done, total) => setProgress({ done, total }),
|
||||
});
|
||||
setResult(outcome);
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
setProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
const previewColumns: TableColumn<PreviewRow>[] = [
|
||||
{
|
||||
key: 'productname',
|
||||
header: 'Product',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.productname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{row.productsku}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'categoryid',
|
||||
header: 'Category',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.categoryid}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'retailprice',
|
||||
header: 'Retail',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="label" size="sm" hasTabularNumbers>
|
||||
₹{row.retailprice}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'productcost',
|
||||
header: 'Cost',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
|
||||
₹{row.productcost}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
header: 'Opening stock',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 130 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.quantity}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const preview: PreviewRow[] = (parsed?.rows ?? []).slice(0, 25).map((row: SheetProductRow) => ({
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
categoryid: row.categoryid,
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
quantity: row.quantity,
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
const isClean = result.failures.length === 0;
|
||||
return (
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
{isClean ? (
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
) : (
|
||||
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
)}
|
||||
<Text type="large" weight="semibold">
|
||||
{result.created} product{result.created === 1 ? '' : 's'} imported
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Text type="body" color="secondary">
|
||||
{result.linked} linked to the store, {result.stocked} given opening stock.
|
||||
{isClean ? '' : ` ${result.failures.length} row(s) did not make it.`}
|
||||
</Text>
|
||||
|
||||
{!isClean ? (
|
||||
<VStack gap={1}>
|
||||
{result.failures.slice(0, 10).map((failure, index) => (
|
||||
<HStack key={`${failure.row.productsku}-${index}`} gap={1} align="center">
|
||||
<Badge variant="error" label={failure.row.productsku || '—'} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{failure.reason}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
label="Import another file"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={2}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Upload the tenant's product list"
|
||||
note="parsed in the browser, then written row by row"
|
||||
action={
|
||||
<Button
|
||||
label="Download template"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download size={14} />}
|
||||
onClick={() => void downloadTemplate()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
label="Product workbook"
|
||||
value={file}
|
||||
onChange={handleFile}
|
||||
accept=".xlsx,.xls,.csv"
|
||||
mode="dropzone"
|
||||
placeholder="Drop an .xlsx or .csv here"
|
||||
description="Required columns: productname, productsku, categoryid, retailprice, productcost. The catalogue's category names do not map to a tenant's own ids, so categoryid must be in the file."
|
||||
isDisabled={!hasTarget}
|
||||
/>
|
||||
|
||||
{parseError ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{parseError}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{parsed ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="What is in the file"
|
||||
note={`${parsed.totalRows} rows read · ${parsed.rows.length} ready · ${parsed.issues.length} to fix`}
|
||||
/>
|
||||
|
||||
{parsed.unmappedColumns.length > 0 ? (
|
||||
<HStack gap={1} wrap="wrap" align="center">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Ignored columns:
|
||||
</Text>
|
||||
{parsed.unmappedColumns.map((column) => (
|
||||
<Badge key={column} variant="neutral" label={column} />
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{parsed.issues.length > 0 ? (
|
||||
<VStack
|
||||
gap={0.5}
|
||||
padding={2}
|
||||
style={{ background: 'var(--color-warning-muted, #fdf6e3)', borderRadius: 12 }}
|
||||
>
|
||||
<HStack align="center" gap={1}>
|
||||
<AlertTriangle size={15} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
<Text type="label" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
{parsed.issues.length} row{parsed.issues.length === 1 ? '' : 's'} will be skipped
|
||||
</Text>
|
||||
</HStack>
|
||||
{parsed.issues.slice(0, 8).map((issue, index) => (
|
||||
<Text key={index} type="body" size="xsm" color="secondary">
|
||||
Row {issue.line} · {issue.field}: {issue.message}
|
||||
</Text>
|
||||
))}
|
||||
{parsed.issues.length > 8 ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
…and {parsed.issues.length - 8} more.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{preview.length > 0 ? (
|
||||
<Table<PreviewRow>
|
||||
data={preview}
|
||||
columns={previewColumns}
|
||||
idKey="productsku"
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{progress ? (
|
||||
<ProgressBar
|
||||
label="Importing"
|
||||
value={progress.done}
|
||||
max={progress.total}
|
||||
hasValueLabel
|
||||
formatValueLabel={(value, max) => `${value} of ${max}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack align="center" gap={1}>
|
||||
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Nothing dedupes on SKU — importing this file twice creates every product twice.
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button
|
||||
label={progress ? 'Importing…' : `Import ${parsed.rows.length} products`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={Boolean(progress)}
|
||||
isDisabled={!hasTarget || parsed.rows.length === 0}
|
||||
onClick={handleImport}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
224
src/features/nearle-admin/import/parseProductSheet.ts
Normal file
224
src/features/nearle-admin/import/parseProductSheet.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Parsing and validating an uploaded product workbook.
|
||||
*
|
||||
* All of this happens in the browser: the backend does no spreadsheet handling
|
||||
* at all, so a sheet becomes N create calls, and every row that is going to fail
|
||||
* should be caught here rather than halfway through those calls.
|
||||
*/
|
||||
|
||||
import type { SheetProductRow } from '@/api/products';
|
||||
|
||||
/**
|
||||
* `xlsx` is around 400kB and only the spreadsheet path ever touches it, so it is
|
||||
* imported on demand rather than shipped to everyone who opens the console.
|
||||
*/
|
||||
async function loadXlsx() {
|
||||
return import('xlsx');
|
||||
}
|
||||
|
||||
/** What a column may be called in the wild, lowercased and stripped. */
|
||||
const COLUMN_ALIASES: Record<keyof SheetProductRow, string[]> = {
|
||||
productname: ['productname', 'product', 'name', 'itemname', 'description'],
|
||||
productsku: ['productsku', 'sku', 'code', 'itemcode', 'barcode'],
|
||||
categoryid: ['categoryid', 'category'],
|
||||
subcategoryid: ['subcategoryid', 'subcategory'],
|
||||
retailprice: ['retailprice', 'price', 'mrp', 'sellingprice'],
|
||||
productcost: ['productcost', 'cost', 'purchaseprice', 'costprice'],
|
||||
taxpercent: ['taxpercent', 'tax', 'gst', 'gstpercent'],
|
||||
quantity: ['quantity', 'qty', 'stock', 'openingstock'],
|
||||
productunit: ['productunit', 'unit', 'uom'],
|
||||
unitvalue: ['unitvalue', 'size', 'packsize'],
|
||||
productbrand: ['productbrand', 'brand'],
|
||||
productdesc: ['productdesc', 'productdescription', 'details'],
|
||||
};
|
||||
|
||||
export interface RowIssue {
|
||||
/** 1-based, and counting the header — the number the user sees in Excel. */
|
||||
line: number;
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ParsedSheet {
|
||||
rows: SheetProductRow[];
|
||||
issues: RowIssue[];
|
||||
/** Header cells that matched nothing. Surfaced so a rename is obvious. */
|
||||
unmappedColumns: string[];
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
function normalise(header: string): string {
|
||||
return header.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const cleaned = value.replace(/[^0-9.-]/g, '');
|
||||
if (cleaned === '') return null;
|
||||
const parsed = Number(cleaned);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first sheet of a workbook into validated rows.
|
||||
*
|
||||
* Every problem is collected rather than thrown on the first one: an importer
|
||||
* that stops at row 3 of 400 makes the user run it 400 times to find out what
|
||||
* is wrong with their file.
|
||||
*/
|
||||
export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
const XLSX = await loadXlsx();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
if (!firstSheetName) {
|
||||
return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
}
|
||||
|
||||
const sheet = workbook.Sheets[firstSheetName];
|
||||
if (!sheet) return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
|
||||
const raw = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' });
|
||||
if (raw.length === 0) {
|
||||
return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
}
|
||||
|
||||
// Map the file's headers onto our field names once, not per row.
|
||||
const headers = Object.keys(raw[0] ?? {});
|
||||
const headerToField = new Map<string, keyof SheetProductRow>();
|
||||
const matchedHeaders = new Set<string>();
|
||||
|
||||
for (const header of headers) {
|
||||
const key = normalise(header);
|
||||
for (const [field, aliases] of Object.entries(COLUMN_ALIASES)) {
|
||||
if (aliases.includes(key)) {
|
||||
headerToField.set(header, field as keyof SheetProductRow);
|
||||
matchedHeaders.add(header);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unmappedColumns = headers.filter((header) => !matchedHeaders.has(header));
|
||||
const rows: SheetProductRow[] = [];
|
||||
const issues: RowIssue[] = [];
|
||||
|
||||
raw.forEach((record, index) => {
|
||||
const line = index + 2; // +1 for zero-index, +1 for the header row
|
||||
const picked: Partial<Record<keyof SheetProductRow, unknown>> = {};
|
||||
|
||||
for (const [header, field] of headerToField) {
|
||||
picked[field] = record[header];
|
||||
}
|
||||
|
||||
const productname = String(picked.productname ?? '').trim();
|
||||
const productsku = String(picked.productsku ?? '').trim();
|
||||
const retailprice = toNumber(picked.retailprice);
|
||||
const productcost = toNumber(picked.productcost);
|
||||
const taxpercent = toNumber(picked.taxpercent) ?? 0;
|
||||
const quantity = toNumber(picked.quantity) ?? 0;
|
||||
const categoryid = toNumber(picked.categoryid) ?? 0;
|
||||
const subcategoryid = toNumber(picked.subcategoryid) ?? 0;
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!productname) {
|
||||
issues.push({ line, field: 'productname', message: 'Product name is missing' });
|
||||
isValid = false;
|
||||
}
|
||||
if (!productsku) {
|
||||
// Without a SKU the created product cannot be found again, and the
|
||||
// create endpoint does not return its new id — so the row would import
|
||||
// and then be impossible to link to the store.
|
||||
issues.push({ line, field: 'productsku', message: 'SKU is missing — the row cannot be linked to a store without one' });
|
||||
isValid = false;
|
||||
}
|
||||
if (retailprice === null || retailprice <= 0) {
|
||||
issues.push({ line, field: 'retailprice', message: 'Retail price is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (productcost === null) {
|
||||
issues.push({ line, field: 'productcost', message: 'Cost is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (categoryid <= 0) {
|
||||
issues.push({ line, field: 'categoryid', message: 'Category id is missing — the tenant has no mapping from a category name' });
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
rows.push({
|
||||
productname,
|
||||
productsku,
|
||||
categoryid,
|
||||
subcategoryid,
|
||||
retailprice: retailprice as number,
|
||||
productcost: productcost as number,
|
||||
taxpercent,
|
||||
quantity,
|
||||
productunit: String(picked.productunit ?? '').trim() || undefined,
|
||||
unitvalue: String(picked.unitvalue ?? '').trim() || undefined,
|
||||
productbrand: String(picked.productbrand ?? '').trim() || undefined,
|
||||
productdesc: String(picked.productdesc ?? '').trim() || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Duplicate SKUs inside one file are the user's own error and will produce
|
||||
// duplicate products, since nothing on the server dedupes.
|
||||
const seen = new Set<string>();
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (seen.has(row.productsku)) {
|
||||
issues.push({
|
||||
line: index + 2,
|
||||
field: 'productsku',
|
||||
message: `SKU ${row.productsku} appears more than once in this file`,
|
||||
});
|
||||
}
|
||||
seen.add(row.productsku);
|
||||
}
|
||||
|
||||
return { rows, issues, unmappedColumns, totalRows: raw.length };
|
||||
}
|
||||
|
||||
/** The template handed to a tenant, so the columns arrive named correctly. */
|
||||
export async function downloadTemplate(): Promise<void> {
|
||||
const XLSX = await loadXlsx();
|
||||
const headers = [
|
||||
'productname',
|
||||
'productsku',
|
||||
'categoryid',
|
||||
'subcategoryid',
|
||||
'retailprice',
|
||||
'productcost',
|
||||
'taxpercent',
|
||||
'quantity',
|
||||
'productunit',
|
||||
'unitvalue',
|
||||
'productbrand',
|
||||
'productdesc',
|
||||
];
|
||||
const example = {
|
||||
productname: 'Aachi Chilli Powder 100g',
|
||||
productsku: 'AACHI-CHILLI-100',
|
||||
categoryid: 1,
|
||||
subcategoryid: 0,
|
||||
retailprice: 35,
|
||||
productcost: 28,
|
||||
taxpercent: 5,
|
||||
quantity: 24,
|
||||
productunit: 'g',
|
||||
unitvalue: '100',
|
||||
productbrand: 'Aachi',
|
||||
productdesc: '',
|
||||
};
|
||||
|
||||
const sheet = XLSX.utils.json_to_sheet([example], { header: headers });
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, 'Products');
|
||||
XLSX.writeFile(workbook, 'nearle-product-template.xlsx');
|
||||
}
|
||||
295
src/features/nearle-admin/pages/GlobalCataloguePage.tsx
Normal file
295
src/features/nearle-admin/pages/GlobalCataloguePage.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
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 { Check, PackagePlus, Upload } from 'lucide-react';
|
||||
import { catalogueKey } from '@/api/catalogue';
|
||||
import { productsApi } from '@/api/products';
|
||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
useCatalogueBrands,
|
||||
useCatalogueProducts,
|
||||
useImportedRefs,
|
||||
useTenants,
|
||||
} from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { ImportDrawer } from '../import/ImportDrawer';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/**
|
||||
* The global catalogue browser, and the place both import paths start.
|
||||
*
|
||||
* The list loads with NO brand filter — brand, category and keyword are filters
|
||||
* applied on top of an already-visible list, not a prerequisite to seeing one.
|
||||
* Products already imported by the selected tenant are badged rather than
|
||||
* hidden, because re-importing tops up stock and is a legitimate thing to do.
|
||||
*/
|
||||
export function GlobalCataloguePage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const tenantid = Number(params.get('tenantid') ?? 0) || undefined;
|
||||
const locationid = Number(params.get('locationid') ?? 0) || undefined;
|
||||
|
||||
const [brand, setBrand] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selected, setSelected] = useState<CatalogueProduct | null>(null);
|
||||
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
const { data: brands } = useCatalogueBrands();
|
||||
const {
|
||||
data: products,
|
||||
isLoading,
|
||||
error,
|
||||
} = useCatalogueProducts({ brand: brand || undefined, keyword: keyword || undefined, pagesize: 48 });
|
||||
const { data: importedRefs } = useImportedRefs(tenantid);
|
||||
|
||||
/** `brand:catalogueid` — both halves, because a bare id repeats across brands. */
|
||||
const importedKeys = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const ref of importedRefs ?? []) set.add(catalogueKey(ref));
|
||||
return set;
|
||||
}, [importedRefs]);
|
||||
|
||||
const tenantOptions = useMemo(() => {
|
||||
if (!tenants) return [];
|
||||
const seen = new Map<number, { name: string; locationid: number }>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) {
|
||||
seen.set(tenant.tenantid, { name: tenant.tenantname, locationid: tenant.locationid });
|
||||
}
|
||||
}
|
||||
return [...seen.entries()].map(([id, value]) => ({ value: String(id), label: value.name }));
|
||||
}, [tenants]);
|
||||
|
||||
const brandOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'All brands' },
|
||||
...(brands ?? []).map((entry) => ({
|
||||
value: entry.brand,
|
||||
label: `${entry.brand} (${entry.product_count})`,
|
||||
})),
|
||||
],
|
||||
[brands],
|
||||
);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (rows: ImportCatalogueProductRequest[]) => productsApi.importFromCatalogue(rows),
|
||||
onSuccess: async () => {
|
||||
// Both the imported-refs lookup and the store catalogue go stale together.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.products.all }),
|
||||
]);
|
||||
setSelected(null);
|
||||
},
|
||||
});
|
||||
|
||||
function chooseTenant(value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('tenantid', value);
|
||||
const tenant = (tenants as TenantInfo[] | undefined)?.find(
|
||||
(entry) => String(entry.tenantid) === value,
|
||||
);
|
||||
if (tenant) next.set('locationid', String(tenant.locationid));
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Global catalogue"
|
||||
count={products ? `${products.length} shown` : undefined}
|
||||
description="Browse the FMCG catalogue and stock a store from it — or upload the tenant's own product list instead."
|
||||
actions={
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="From catalogue"
|
||||
variant={mode === 'catalogue' ? 'primary' : 'secondary'}
|
||||
icon={<PackagePlus size={15} />}
|
||||
onClick={() => setMode('catalogue')}
|
||||
/>
|
||||
<Button
|
||||
label="From spreadsheet"
|
||||
variant={mode === 'sheet' ? 'primary' : 'secondary'}
|
||||
icon={<Upload size={15} />}
|
||||
onClick={() => setMode('sheet')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Which store is being stocked. Every import call needs both ids, so this
|
||||
is asked once, up front, rather than per product. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader title="Stocking" note="which store these products go to" />
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label="Tenant"
|
||||
options={tenantOptions}
|
||||
value={tenantid ? String(tenantid) : ''}
|
||||
onChange={chooseTenant}
|
||||
placeholder="Select tenant…"
|
||||
hasSearch
|
||||
/>
|
||||
<TextInput
|
||||
label="Location id"
|
||||
value={locationid ? String(locationid) : ''}
|
||||
onChange={(value) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('locationid', value);
|
||||
setParams(next, { replace: true });
|
||||
}}
|
||||
description="Defaults to the tenant's first outlet."
|
||||
/>
|
||||
</div>
|
||||
{!hasTarget ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
Pick a tenant before importing — every import call is scoped to a tenant and a
|
||||
location, and the backend rejects one without both.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Products"
|
||||
note="filters narrow an already-visible list"
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 190 }}>
|
||||
<Selector
|
||||
label="Brand"
|
||||
isLabelHidden
|
||||
options={brandOptions}
|
||||
value={brand}
|
||||
onChange={setBrand}
|
||||
placeholder="All brands"
|
||||
hasSearch
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Search catalogue"
|
||||
isLabelHidden
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search catalogue…"
|
||||
hasClear
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={(products ?? []).length === 0}
|
||||
emptyTitle="Nothing matches those filters"
|
||||
emptyDescription="Clear the brand or keyword to see the full catalogue."
|
||||
>
|
||||
<div className="card-grid">
|
||||
{(products ?? []).map((product) => {
|
||||
const isImported = importedKeys.has(catalogueKey(product));
|
||||
return (
|
||||
<Card key={`${product.brand}:${product.id}`} padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
background: 'var(--color-slate-50)',
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<PackagePlus size={26} style={{ color: 'var(--color-slate-300)' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={2}>
|
||||
{product.product_name}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{product.brand} · {product.size ?? '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack justify="between" align="center">
|
||||
{/* A RANGE, not a price. The store sets the real one at import. */}
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{product.price_range ?? '—'}
|
||||
</Text>
|
||||
{isImported ? (
|
||||
<Badge variant="success" label="Imported" icon={<Check size={11} />} />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
label={isImported ? 'Top up stock' : 'Add to store'}
|
||||
variant={isImported ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
width="100%"
|
||||
isDisabled={!hasTarget}
|
||||
onClick={() => setSelected(product)}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DataState>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{selected && hasTarget ? (
|
||||
<ImportDrawer
|
||||
product={selected}
|
||||
tenantid={tenantid as number}
|
||||
locationid={locationid as number}
|
||||
isSaving={importMutation.isPending}
|
||||
error={importMutation.error}
|
||||
onClose={() => setSelected(null)}
|
||||
onSubmit={(row) => importMutation.mutate([row])}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
279
src/features/nearle-admin/pages/OnboardTenantPage.tsx
Normal file
279
src/features/nearle-admin/pages/OnboardTenantPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
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, Building2, CheckCircle2, MapPin } from 'lucide-react';
|
||||
import { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
|
||||
const BUSINESS_CATEGORIES = [
|
||||
{ value: '1', label: 'Grocery & Daily' },
|
||||
{ value: '2', label: 'Fresh & Produce' },
|
||||
{ value: '3', label: 'Pharmacy' },
|
||||
{ value: '4', label: 'Bakery' },
|
||||
];
|
||||
|
||||
interface FormState {
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
locationname: string;
|
||||
categoryid: string;
|
||||
address: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
tenantname: '',
|
||||
companyname: '',
|
||||
primarycontact: '',
|
||||
primaryemail: '',
|
||||
locationname: '',
|
||||
categoryid: '1',
|
||||
address: '',
|
||||
suburb: '',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Provision a new merchant tenant.
|
||||
*
|
||||
* This registers the enterprise, its first outlet, and spawns the primary
|
||||
* Administrator account — account creation is a side effect of provisioning
|
||||
* here, because that is what the backend does. There is no separate invite.
|
||||
*
|
||||
* Products are NOT collected on this form. Once the tenant exists it has a
|
||||
* tenantid and a locationid, and only then can either import path run: both
|
||||
* `importcatalogueproduct` and `createproductlocation` require the pair. So the
|
||||
* form hands off to the catalogue on success rather than pretending the two
|
||||
* steps are one.
|
||||
*/
|
||||
export function OnboardTenantPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
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.primarycontact.trim() !== '' &&
|
||||
form.primaryemail.trim() !== '' &&
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantname: form.tenantname.trim(),
|
||||
companyname: form.companyname.trim(),
|
||||
primarycontact: form.primarycontact.trim(),
|
||||
primaryemail: form.primaryemail.trim(),
|
||||
locationname: form.locationname.trim(),
|
||||
categoryid: Number(form.categoryid),
|
||||
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 (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Tenant provisioned"
|
||||
description="The enterprise is registered and its primary Administrator account has been created."
|
||||
/>
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard tenant"
|
||||
description="Register a merchant group, commission its first outlet, and create its Administrator account."
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack gap={3}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Business"
|
||||
note="who the merchant is"
|
||||
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<TextInput
|
||||
label={<span>Merchant name <span style={{ color: 'var(--color-error)' }}>*</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)' }}>*</span></span> as any}
|
||||
value={form.companyname}
|
||||
onChange={set('companyname')}
|
||||
placeholder="e.g. Kaveri Retail Pvt. Ltd."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary phone <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.primarycontact}
|
||||
onChange={set('primarycontact')}
|
||||
placeholder="9876543210"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary admin email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
type="email"
|
||||
value={form.primaryemail}
|
||||
onChange={set('primaryemail')}
|
||||
placeholder="admin@kaveri.com"
|
||||
description="The Administrator account is created against this address."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. Kaveri RS Puram"
|
||||
/>
|
||||
<Selector
|
||||
label="Business category"
|
||||
options={BUSINESS_CATEGORIES}
|
||||
value={form.categoryid}
|
||||
onChange={set('categoryid')}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Head office"
|
||||
note="where the business is registered"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</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)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.postcode}
|
||||
onChange={set('postcode')}
|
||||
placeholder="641004"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Products are added after the tenant exists — both import paths need its tenantid and
|
||||
locationid.
|
||||
</Text>
|
||||
<Button
|
||||
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
273
src/features/nearle-admin/pages/StoreDetailPage.tsx
Normal file
273
src/features/nearle-admin/pages/StoreDetailPage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
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, PackageCheck, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { Freshness } from '@/components/Freshness';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
|
||||
|
||||
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 come from `/orders/getlocationsummary`, which is scoped to the
|
||||
* tenant and returns one row per branch. 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);
|
||||
const summary = useLocationSummary(tenantid);
|
||||
const orders = useOrderSummary(tenantid);
|
||||
|
||||
const tenant = useMemo(
|
||||
() => (tenants as TenantInfo[] | undefined)?.find((entry) => entry.tenantid === tenantid),
|
||||
[tenants, tenantid],
|
||||
);
|
||||
|
||||
const rows = useMemo<BranchRow[]>(() => {
|
||||
const perLocation = new Map<number, LocationOrderSummary>();
|
||||
for (const entry of summary.data ?? []) {
|
||||
if (typeof entry.locationid === 'number') perLocation.set(entry.locationid, entry);
|
||||
}
|
||||
|
||||
return ((locations ?? []) as TenantLocation[]).map((branch) => {
|
||||
const stats = perLocation.get(branch.locationid);
|
||||
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',
|
||||
orders: Number(stats?.totalorders ?? 0),
|
||||
revenue: Number(stats?.revenue ?? 0),
|
||||
};
|
||||
});
|
||||
}, [locations, summary.data]);
|
||||
|
||||
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]);
|
||||
|
||||
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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title={tenant?.tenantname ?? 'Tenant'}
|
||||
count={tenant?.companyname}
|
||||
description={`${totals.branches} branch${totals.branches === 1 ? '' : 'es'} · ${
|
||||
tenant?.city ?? '—'
|
||||
}`}
|
||||
isLive
|
||||
actions={
|
||||
<HStack gap={1}>
|
||||
<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="from order summary"
|
||||
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}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Catalogue"
|
||||
value={tenant ? 'Linked' : '—'}
|
||||
note="stocked per branch"
|
||||
tone="neutral"
|
||||
icon={<PackageCheck size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Branches"
|
||||
note="orders and revenue are online only — counter sales are per branch"
|
||||
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<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={rows}
|
||||
columns={columns}
|
||||
idKey="locationid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
252
src/features/nearle-admin/pages/StoresPage.tsx
Normal file
252
src/features/nearle-admin/pages/StoresPage.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
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 { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Building2, Plus, Store, Users } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useTenants } 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.
|
||||
*
|
||||
* `getalltenants` returns one row per tenant-location pair, so the rows are
|
||||
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
|
||||
* branches reads as six tenants.
|
||||
*/
|
||||
export function StoresPage() {
|
||||
const { data, isLoading, error } = useTenants();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const rows = useMemo<TenantRow[]>(() => {
|
||||
if (!data) return [];
|
||||
const grouped = new Map<number, TenantRow>();
|
||||
|
||||
for (const tenant of data as TenantInfo[]) {
|
||||
const existing = grouped.get(tenant.tenantid);
|
||||
if (existing) {
|
||||
existing.branches += 1;
|
||||
continue;
|
||||
}
|
||||
grouped.set(tenant.tenantid, {
|
||||
tenantid: tenant.tenantid,
|
||||
tenantname: tenant.tenantname,
|
||||
companyname: tenant.companyname ?? '',
|
||||
city: tenant.city ?? '',
|
||||
branches: 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}>
|
||||
<PageHeader
|
||||
title="Stores"
|
||||
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
|
||||
description="Every merchant on the platform, the branches under them, and how each one is performing."
|
||||
isLive
|
||||
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>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Tenant directory"
|
||||
note={`${rows.length} shown`}
|
||||
action={
|
||||
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
|
||||
<TextInput
|
||||
label="Search tenants"
|
||||
isLabelHidden
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, company or city…"
|
||||
hasClear
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<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>
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
95
src/features/store-admin/BranchScope.tsx
Normal file
95
src/features/store-admin/BranchScope.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTenantLocations } from '@/queries/hooks';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
|
||||
/** `null` means All branches. */
|
||||
export type BranchSelection = number | null;
|
||||
|
||||
export interface BranchScopeValue {
|
||||
/** Every branch this tenant runs, in a stable order. */
|
||||
branches: TenantLocation[];
|
||||
isLoading: boolean;
|
||||
/** The tenant these branches belong to — from the session, never the URL. */
|
||||
tenantid: number;
|
||||
/** `null` = All branches. */
|
||||
selected: BranchSelection;
|
||||
select: (next: BranchSelection) => void;
|
||||
/**
|
||||
* The branches a page should actually read.
|
||||
*
|
||||
* All branches → every one of them; a specific branch → just that one. Pages
|
||||
* fan out over this rather than branching on `selected` themselves, which is
|
||||
* what keeps "All" and "one" the same code path.
|
||||
*/
|
||||
scoped: TenantLocation[];
|
||||
/** The selected branch, or undefined under All. */
|
||||
current: TenantLocation | undefined;
|
||||
}
|
||||
|
||||
const BranchScopeContext = createContext<BranchScopeValue | null>(null);
|
||||
|
||||
/** The URL param. In the URL so a link to a page carries its branch with it. */
|
||||
const PARAM = 'branch';
|
||||
|
||||
/**
|
||||
* Which branch the Store Admin is looking at.
|
||||
*
|
||||
* Held in the URL rather than in component state for two reasons. A link to
|
||||
* "Inventory, Peelamedu" has to survive being pasted into a chat, and a reload
|
||||
* during a shift must not silently drop the operator back to All branches while
|
||||
* they are reading a number that only makes sense for one shop.
|
||||
*
|
||||
* The tenant, by contrast, comes from the session and is deliberately NOT in
|
||||
* the URL. Fiesta has no web auth, so tenant scoping is enforced by this client
|
||||
* alone — putting the tenant id in an editable address bar would turn the one
|
||||
* boundary we control into a text field.
|
||||
*/
|
||||
export function BranchScopeProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tenantid = user?.tenantid ?? 0;
|
||||
|
||||
const { data, isLoading } = useTenantLocations(tenantid || undefined);
|
||||
|
||||
const branches = useMemo(
|
||||
() => [...(data ?? [])].sort((a, b) => a.locationid - b.locationid),
|
||||
[data],
|
||||
);
|
||||
|
||||
const raw = params.get(PARAM);
|
||||
const parsed = raw === null || raw === 'all' ? null : Number(raw);
|
||||
// An id in the URL that this tenant does not own falls back to All rather
|
||||
// than showing an empty page — the id is user-editable, so it is untrusted.
|
||||
const selected =
|
||||
parsed !== null && Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)
|
||||
? parsed
|
||||
: null;
|
||||
|
||||
const value = useMemo<BranchScopeValue>(() => {
|
||||
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
|
||||
return {
|
||||
branches,
|
||||
isLoading,
|
||||
tenantid,
|
||||
selected,
|
||||
current,
|
||||
scoped: current ? [current] : branches,
|
||||
select: (next) => {
|
||||
const nextParams = new URLSearchParams(params);
|
||||
if (next === null) nextParams.delete(PARAM);
|
||||
else nextParams.set(PARAM, String(next));
|
||||
setParams(nextParams, { replace: true });
|
||||
},
|
||||
};
|
||||
}, [branches, isLoading, tenantid, selected, params, setParams]);
|
||||
|
||||
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useBranchScope(): BranchScopeValue {
|
||||
const value = useContext(BranchScopeContext);
|
||||
if (!value) throw new Error('useBranchScope must be used inside a BranchScopeProvider');
|
||||
return value;
|
||||
}
|
||||
97
src/features/store-admin/DateRangePicker.tsx
Normal file
97
src/features/store-admin/DateRangePicker.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DateRangeInput, type DateRange as AstryxDateRange, type ISODateString } from '@astryxdesign/core';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
|
||||
export type RangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom';
|
||||
|
||||
/** `YYYY-MM-DD` in LOCAL time — `toISOString()` would shift a shop in IST back a day. */
|
||||
export function isoDay(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function presetRange(preset: RangePreset, now = new Date()): DateRange {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { fromdate: isoDay(today), todate: isoDay(today) };
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
return { fromdate: isoDay(yesterday), todate: isoDay(yesterday) };
|
||||
}
|
||||
case 'week': {
|
||||
// Monday-first. A retail week that starts on Sunday makes Monday's
|
||||
// takings the previous week's, which is not how anyone here counts.
|
||||
const weekday = (today.getDay() + 6) % 7;
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - weekday);
|
||||
return { fromdate: isoDay(monday), todate: isoDay(today) };
|
||||
}
|
||||
case 'month': {
|
||||
const first = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { fromdate: isoDay(first), todate: isoDay(today) };
|
||||
}
|
||||
case 'custom':
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
preset: RangePreset;
|
||||
range: DateRange;
|
||||
onChange: (preset: RangePreset, range: DateRange) => void;
|
||||
}
|
||||
|
||||
export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
|
||||
const astryxRange = useMemo<AstryxDateRange | null>(() => {
|
||||
if (range.fromdate && range.todate) {
|
||||
return { start: range.fromdate as ISODateString, end: range.todate as ISODateString };
|
||||
}
|
||||
return null;
|
||||
}, [range]);
|
||||
|
||||
const presets = useMemo(() => {
|
||||
return [
|
||||
{ label: 'Today', getRange: () => { const r = presetRange('today'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'Yesterday', getRange: () => { const r = presetRange('yesterday'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'This week', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'This month', getRange: () => { const r = presetRange('month'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: 180 }}>
|
||||
<DateRangeInput
|
||||
width={180}
|
||||
size="sm"
|
||||
label="Date range"
|
||||
isLabelHidden
|
||||
value={astryxRange}
|
||||
presets={presets}
|
||||
onChange={(newRange) => {
|
||||
if (!newRange) {
|
||||
onChange('custom', {});
|
||||
return;
|
||||
}
|
||||
// Determine if the new range matches a preset
|
||||
const today = presetRange('today');
|
||||
const yesterday = presetRange('yesterday');
|
||||
const week = presetRange('week');
|
||||
const month = presetRange('month');
|
||||
|
||||
let matchedPreset: RangePreset = 'custom';
|
||||
if (newRange.start === today.fromdate && newRange.end === today.todate) matchedPreset = 'today';
|
||||
else if (newRange.start === yesterday.fromdate && newRange.end === yesterday.todate) matchedPreset = 'yesterday';
|
||||
else if (newRange.start === week.fromdate && newRange.end === week.todate) matchedPreset = 'week';
|
||||
else if (newRange.start === month.fromdate && newRange.end === month.todate) matchedPreset = 'month';
|
||||
|
||||
onChange(matchedPreset, { fromdate: newRange.start, todate: newRange.end });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
517
src/features/store-admin/OrderDetailDrawer.tsx
Normal file
517
src/features/store-admin/OrderDetailDrawer.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import { useEffect } 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 { Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow } from '@/api/types';
|
||||
import { branchLabel, moneyExact } from './format';
|
||||
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
|
||||
|
||||
type Row = OrderRow | DeliveryRow;
|
||||
|
||||
export type RowKind = 'order' | 'delivery';
|
||||
|
||||
/**
|
||||
* The five stages an order walks, taken from the old console verbatim.
|
||||
*
|
||||
* The middle one matters and is easy to drop: **Packed & Ready** sits between
|
||||
* confirmation and pickup, and it is the stage where an order stalls when a
|
||||
* shop is busy. A four-step timeline that jumps from Confirmed to Out for
|
||||
* Delivery hides exactly the delay an operator is trying to find.
|
||||
*/
|
||||
const STEPS: { label: string; fields: string[] }[] = [
|
||||
// A delivery job has no `orderdate` of its own — its clock starts at
|
||||
// `deliverydate`. Falling back keeps the first step from reading as a missing
|
||||
// stamp on every delivery ever opened.
|
||||
{ label: 'Order placed', fields: ['orderdate', 'deliverydate'] },
|
||||
{ label: 'Confirmed', fields: ['starttime', 'assigntime'] },
|
||||
{ label: 'Packed & ready', fields: ['packtime', 'arrivaltime'] },
|
||||
{ label: 'Out for delivery', fields: ['pickuptime'] },
|
||||
{ label: 'Delivered', fields: ['deliverytime'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* One order or delivery, in full.
|
||||
*
|
||||
* A right-hand sheet rather than a modal: the operator is comparing this row
|
||||
* against the list behind it — "is this the one that stalled?" — and a modal
|
||||
* that greys the list out removes the thing being compared against.
|
||||
*/
|
||||
export function OrderDetailDrawer({
|
||||
row,
|
||||
kind,
|
||||
onClose,
|
||||
}: {
|
||||
row: Row;
|
||||
kind: RowKind;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const delivery = kind === 'delivery';
|
||||
// Narrowed once, here. `kind` comes from the table the row was clicked in, so
|
||||
// it is authoritative in a way that sniffing fields is not — a delivery row
|
||||
// carries an `orderheaderid` too, which is what made the first attempt at
|
||||
// this discriminator silently mis-classify every delivery.
|
||||
const job = delivery ? (row as DeliveryRow) : undefined;
|
||||
const order = delivery ? undefined : (row as OrderRow);
|
||||
|
||||
// Escape closes. A dialog that can only be dismissed by finding the scrim
|
||||
// with a mouse is not dismissible for anyone on a keyboard, and this one sits
|
||||
// over the list an operator is working through.
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
|
||||
const status = row.orderstatus;
|
||||
const colour = statusColor(delivery ? DELIVERY_STATUS : ORDER_STATUS, status);
|
||||
|
||||
const value = job ? (job.deliveryamt ?? 0) : orderValue(row);
|
||||
const cancelled = (status ?? '').toLowerCase().includes('cancel');
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Order ${title}`}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 70, display: 'flex', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .32)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="dialog-panel"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 460,
|
||||
maxWidth: '92vw',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<VStack gap={0.5}>
|
||||
<Text
|
||||
type="label"
|
||||
size="lg"
|
||||
weight="semibold"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: 999,
|
||||
padding: '2px 9px',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
color: colour,
|
||||
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{status || '—'}
|
||||
</span>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(row.locationname)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={3} padding={2}>
|
||||
{/* ── Money ─────────────────────────────────────────────────── */}
|
||||
<div className="form-grid">
|
||||
<Figure label="Value" value={value > 0 ? moneyExact(value) : '—'} isStrong />
|
||||
{job ? (
|
||||
<Figure
|
||||
label="Rider charge"
|
||||
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
|
||||
/>
|
||||
) : (
|
||||
<Figure
|
||||
label="Cash to collect"
|
||||
value={order?.collectionamt ? moneyExact(order.collectionamt) : '—'}
|
||||
/>
|
||||
)}
|
||||
<Figure
|
||||
label="Items"
|
||||
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Journey ───────────────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Label>Journey</Label>
|
||||
<Timeline row={row} isCancelled={cancelled} />
|
||||
</VStack>
|
||||
|
||||
{/* ── Pickup and drop ───────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Label>Route</Label>
|
||||
<Party
|
||||
icon={<MapPin size={13} />}
|
||||
heading="Pickup"
|
||||
name={row.pickupcustomer || branchLabel(row.locationname)}
|
||||
detail={
|
||||
job
|
||||
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
|
||||
: order?.pickupaddress || order?.pickupsuburb
|
||||
}
|
||||
phone={row.pickupcontactno}
|
||||
/>
|
||||
<Party
|
||||
icon={<MapPin size={13} />}
|
||||
heading="Drop"
|
||||
name={row.deliverycustomer}
|
||||
detail={row.deliveryaddress || row.deliverysuburb}
|
||||
phone={row.deliverycontactno}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* ── Rider ─────────────────────────────────────────────────── */}
|
||||
{job ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Rider</Label>
|
||||
{job.ridername ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'color-mix(in oklab, #8b5cf6 14%, transparent)',
|
||||
color: '#8b5cf6',
|
||||
}}
|
||||
>
|
||||
<Bike size={14} />
|
||||
</span>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{job.ridername}
|
||||
</Text>
|
||||
{job.ridercontact ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{job.ridercontact}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
{/* Planned against actual. The gap is the number worth
|
||||
reading — a job quoted 3km and ridden 9 is either a bad
|
||||
address or a rider taking a detour, and one figure hides
|
||||
both. */}
|
||||
<HStack gap={2}>
|
||||
<Figure label="Planned" value={km(job.kms)} />
|
||||
<Figure label="Actual" value={km(job.actualkms || job.riderkms)} />
|
||||
{job.transitminutes ? (
|
||||
<Figure label="Transit" value={`${job.transitminutes} min`} />
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
No rider assigned yet.
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : order?.rider ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Rider</Label>
|
||||
<Text type="body" size="sm">
|
||||
{order.rider}
|
||||
{order.ridercontactno ? ` · ${order.ridercontactno}` : ''}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{/* ── Notes ─────────────────────────────────────────────────── */}
|
||||
{row.ordernotes || job?.notes ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Notes</Label>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.ordernotes || job?.notes}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Line items are not in the list read — Fiesta returns them from the order-detail
|
||||
endpoint, which this sheet does not call yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The journey, as stamps rather than as a guess.
|
||||
*
|
||||
* A step is complete only when its timestamp is present. Nothing is inferred
|
||||
* from the status: an order marked Delivered with no `pickuptime` really does
|
||||
* have a missing stamp, and drawing that step as done would paper over the
|
||||
* data-quality problem the operator opened this sheet to find.
|
||||
*/
|
||||
function Timeline({ row, isCancelled }: { row: Row; isCancelled: boolean }) {
|
||||
const steps = STEPS.map((step) => ({
|
||||
label: step.label,
|
||||
// Read by name: the two row shapes do not carry the same stamps — a
|
||||
// delivery has no `packtime`, an order has no `arrivaltime` — so a step
|
||||
// whose field is absent simply reads undefined and renders as not-yet.
|
||||
at: step.fields
|
||||
.map((field) => (row as unknown as Record<string, string | undefined>)[field])
|
||||
.find(Boolean),
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
{steps.map((step, index) => {
|
||||
const done = Boolean(step.at);
|
||||
const isLast = index === steps.length - 1;
|
||||
return (
|
||||
<HStack key={step.label} gap={1.5} align="start">
|
||||
<VStack gap={0} align="center" style={{ width: 20, flex: 'none' }}>
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
flex: 'none',
|
||||
background: done ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: done ? '#fff' : 'var(--color-ink-4)',
|
||||
border: done ? 0 : '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
{done ? <Check size={11} /> : null}
|
||||
</span>
|
||||
{!isLast ? (
|
||||
<span
|
||||
style={{
|
||||
width: 2,
|
||||
height: 22,
|
||||
background: done ? 'var(--color-brand)' : 'var(--color-line)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
<VStack gap={0} style={{ paddingBottom: isLast ? 0 : 8 }}>
|
||||
<Text
|
||||
type="body"
|
||||
size="sm"
|
||||
style={{ color: done ? 'var(--color-ink-1)' : 'var(--color-ink-4)' }}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
{step.at ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{new Date(step.at).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
{isCancelled ? (
|
||||
<HStack gap={1.5} align="center" style={{ paddingTop: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'color-mix(in oklab, var(--color-error, #d64545) 14%, transparent)',
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</span>
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
Cancelled
|
||||
{row.canceltime
|
||||
? ` · ${new Date(row.canceltime).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}`
|
||||
: ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Party({
|
||||
icon,
|
||||
heading,
|
||||
name,
|
||||
detail,
|
||||
phone,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
heading: string;
|
||||
name: string | undefined;
|
||||
detail: string | undefined;
|
||||
phone: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<HStack gap={1.5} align="start">
|
||||
<span
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{heading}
|
||||
</Text>
|
||||
<Text type="body" size="sm" weight="semibold">
|
||||
{name || '—'}
|
||||
</Text>
|
||||
{detail ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{detail}
|
||||
</Text>
|
||||
) : null}
|
||||
{phone ? (
|
||||
<HStack gap={0.5} align="center">
|
||||
<Phone size={11} style={{ color: 'var(--color-ink-4)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{phone}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
label,
|
||||
value,
|
||||
isStrong,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="label"
|
||||
size={isStrong ? 'lg' : 'sm'}
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const Label = ({ children }: { children: React.ReactNode }) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const km = (value: string | undefined): string => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? `${n.toFixed(1)} km` : '—';
|
||||
};
|
||||
622
src/features/store-admin/PeopleDrawers.tsx
Normal file
622
src/features/store-admin/PeopleDrawers.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
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 { AlertTriangle, Check, Copy, X } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { branchLabel } from './format';
|
||||
|
||||
/* ── Shell ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The sheet both drawers sit in.
|
||||
*
|
||||
* Right-hand rather than centred, and Escape closes it — the same contract as
|
||||
* the order drawer, so the two do not behave differently for no reason.
|
||||
*/
|
||||
function Sheet({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .32)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="dialog-panel"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 440,
|
||||
maxWidth: '92vw',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{ padding: 20, borderBottom: '1px solid var(--color-line)', flex: 'none' }}
|
||||
>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>{children}</div>
|
||||
|
||||
<HStack
|
||||
justify="between"
|
||||
align="center"
|
||||
gap={1}
|
||||
style={{ padding: 16, borderTop: '1px solid var(--color-line)', flex: 'none' }}
|
||||
>
|
||||
{footer}
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Problem({ message }: { message: string }) {
|
||||
return (
|
||||
<HStack
|
||||
gap={1}
|
||||
align="start"
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: 'color-mix(in oklab, var(--color-error, #d64545) 8%, transparent)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 1 }}
|
||||
/>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{message}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Back-office person ──────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Roles a Store Admin may hand out.
|
||||
*
|
||||
* Hardcoded, and that is a stopgap rather than a choice: `app_roles` lives
|
||||
* outside Fiesta, so there is no first-party endpoint to read. The ids are the
|
||||
* ones `resolveRole` already keys on. Editing an existing person shows whatever
|
||||
* `getstaffs` resolved, even when it is not in this list, because the backend's
|
||||
* label is authoritative and most accounts carry an id absent from `app_roles`.
|
||||
*/
|
||||
const STAFF_ROLES = [
|
||||
{ id: 3, label: 'Administrator' },
|
||||
{ id: 4, label: 'Manager' },
|
||||
{ id: 5, label: 'Staff' },
|
||||
];
|
||||
|
||||
export function PersonDrawer({
|
||||
row,
|
||||
tenantid,
|
||||
branches,
|
||||
onClose,
|
||||
}: {
|
||||
row: StaffInfo | null;
|
||||
tenantid: number;
|
||||
branches: TenantLocation[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isNew = row === null;
|
||||
|
||||
const [firstname, setFirstname] = useState(row?.firstname ?? '');
|
||||
const [lastname, setLastname] = useState(row?.lastname ?? '');
|
||||
const [email, setEmail] = useState(row?.email ?? '');
|
||||
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
||||
const [roleid, setRoleid] = useState(String(row?.roleid ?? STAFF_ROLES[1]?.id ?? 4));
|
||||
const [locationid, setLocationid] = useState(
|
||||
String(row?.locationid ?? branches[0]?.locationid ?? 0),
|
||||
);
|
||||
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const status = isActive ? 'Active' : 'Inactive';
|
||||
if (isNew) {
|
||||
return staffApi.create({
|
||||
tenantid,
|
||||
locationid: Number(locationid),
|
||||
firstname: firstname.trim(),
|
||||
lastname: lastname.trim(),
|
||||
email: email.trim(),
|
||||
contactno: normaliseMobile(contactno),
|
||||
roleid: Number(roleid),
|
||||
status,
|
||||
});
|
||||
}
|
||||
return staffApi.update({
|
||||
userid: row.userid,
|
||||
firstname: firstname.trim(),
|
||||
lastname: lastname.trim(),
|
||||
email: email.trim(),
|
||||
contactno: normaliseMobile(contactno),
|
||||
// Never send a non-positive roleid. `UpdateStaff` writes whatever it is
|
||||
// given, so a 0 or -1 here lands in the column as a role nothing matches.
|
||||
roleid: Number(roleid) > 0 ? Number(roleid) : undefined,
|
||||
locationid: Number(locationid) || undefined,
|
||||
status,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setProblem(null);
|
||||
if (!firstname.trim()) return setProblem('A first name is required.');
|
||||
if (!email.trim()) return setProblem('An email is required — it is what they sign in with.');
|
||||
save.mutate();
|
||||
}
|
||||
|
||||
// An existing role the picker does not know about is still shown, so editing
|
||||
// someone's phone number cannot silently reassign their role.
|
||||
const roleOptions = [
|
||||
...STAFF_ROLES.map((role) => ({ value: String(role.id), label: role.label })),
|
||||
...(row?.roleid && !STAFF_ROLES.some((role) => role.id === row.roleid)
|
||||
? [{ value: String(row.roleid), label: row.rolename || `Role ${row.roleid}` }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={isNew ? 'Add a person' : (row.fullname ?? 'Edit person')}
|
||||
subtitle={isNew ? 'They will be able to sign in to this console' : `User ${row.userid}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
variant="primary"
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<VStack gap={2}>
|
||||
{problem ? <Problem message={problem} /> : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<TextInput label={<span>First name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={firstname} onChange={setFirstname} />
|
||||
<TextInput label="Last name" value={lastname} onChange={setLastname} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label={<span>Email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
description="What they sign in with."
|
||||
/>
|
||||
<TextInput label="Mobile" value={contactno} onChange={setContactno} />
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector label="Role" value={roleid} onChange={setRoleid} options={roleOptions} />
|
||||
<Selector
|
||||
label="Branch"
|
||||
value={locationid}
|
||||
onChange={setLocationid}
|
||||
options={branches.map((branch) => ({
|
||||
value: String(branch.locationid),
|
||||
label: branchLabel(branch.locationname),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActiveToggle
|
||||
isActive={isActive}
|
||||
onChange={setIsActive}
|
||||
activeNote="Can sign in"
|
||||
inactiveNote="Cannot sign in. History is kept."
|
||||
/>
|
||||
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
No password is set here. Passwords are stored in clear in Fiesta, so issuing one is a
|
||||
backend job until that changes.
|
||||
</Text>
|
||||
</VStack>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Till account ────────────────────────────────────────────────────────── */
|
||||
|
||||
export function TillDrawer({
|
||||
row,
|
||||
tenantid,
|
||||
locationid,
|
||||
roles,
|
||||
shifts,
|
||||
onClose,
|
||||
}: {
|
||||
row: PosUser | null;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
roles: PosRole[];
|
||||
shifts: StaffShift[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isNew = row === null;
|
||||
|
||||
const [fullName, setFullName] = useState(row?.full_name ?? '');
|
||||
const [role, setRole] = useState(row?.role?.toLowerCase() ?? 'cashier');
|
||||
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
||||
const [pin, setPin] = useState('');
|
||||
const [shiftId, setShiftId] = useState(String(row?.shift_id ?? 0));
|
||||
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
/** The credential the backend hands back on create. Shown once, never re-read. */
|
||||
const [issued, setIssued] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const status = isActive ? 'Active' : 'Inactive';
|
||||
if (isNew) {
|
||||
return posUsersApi.create({
|
||||
tenantid,
|
||||
locationid,
|
||||
full_name: fullName.trim(),
|
||||
role,
|
||||
contactno: normaliseMobile(contactno),
|
||||
pin: pin.trim() || undefined,
|
||||
shift_id: Number(shiftId) || undefined,
|
||||
status,
|
||||
});
|
||||
}
|
||||
return posUsersApi.update({
|
||||
tenantid,
|
||||
locationid,
|
||||
user_id: row.user_id,
|
||||
full_name: fullName.trim(),
|
||||
role,
|
||||
contactno: normaliseMobile(contactno),
|
||||
shift_id: Number(shiftId) || undefined,
|
||||
status,
|
||||
});
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
|
||||
// The backend returns the PIN or password only in this answer, never in a
|
||||
// listing. If it came back, this is the one moment it can be read.
|
||||
const credential = (result as PosUser | undefined)?.pin ?? (result as PosUser | undefined)?.password;
|
||||
if (isNew && credential) setIssued(credential);
|
||||
else onClose();
|
||||
},
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setProblem(null);
|
||||
if (!fullName.trim()) return setProblem('A name is required.');
|
||||
const mobile = normaliseMobile(contactno);
|
||||
if (mobile.length !== 10) {
|
||||
return setProblem('A ten-digit mobile is required — the till matches on it exactly.');
|
||||
}
|
||||
if (isNew && pin.trim() && !/^\d{4,6}$/.test(pin.trim())) {
|
||||
return setProblem('A PIN is 4 to 6 digits.');
|
||||
}
|
||||
save.mutate();
|
||||
}
|
||||
|
||||
if (issued) {
|
||||
return (
|
||||
<Sheet
|
||||
title="Account created"
|
||||
subtitle={fullName}
|
||||
onClose={onClose}
|
||||
footer={<Button label="Done" variant="primary" onClick={onClose} />}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
<Text type="body" size="sm">
|
||||
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it
|
||||
is lost, reissue rather than look it up.
|
||||
</Text>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.18em',
|
||||
}}
|
||||
>
|
||||
{issued}
|
||||
</span>
|
||||
<Button
|
||||
label="Copy"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Copy size={13} />}
|
||||
onClick={() => void navigator.clipboard?.writeText(issued)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const roleOptions =
|
||||
roles.length > 0
|
||||
? roles.map((entry) => ({ value: entry.role, label: entry.label }))
|
||||
: // Only if `posroles` is unreachable. The endpoint is the source; this is
|
||||
// a fallback so the form is not unusable, not a second definition.
|
||||
[
|
||||
{ value: 'supervisor', label: 'Supervisor' },
|
||||
{ value: 'cashier', label: 'Cashier' },
|
||||
];
|
||||
|
||||
const chosenRole = roles.find((entry) => entry.role === role);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={isNew ? 'Add a till account' : (row.full_name ?? 'Edit till account')}
|
||||
subtitle={
|
||||
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
|
||||
}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
variant="primary"
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<VStack gap={2}>
|
||||
{problem ? <Problem message={problem} /> : null}
|
||||
|
||||
<TextInput label={<span>Full name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={fullName} onChange={setFullName} />
|
||||
|
||||
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} />
|
||||
{chosenRole?.description ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{chosenRole.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label={<span>Mobile <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={contactno}
|
||||
onChange={setContactno}
|
||||
description="Ten digits. This is what they sign in with at the till."
|
||||
/>
|
||||
|
||||
{isNew ? (
|
||||
<TextInput
|
||||
label="PIN"
|
||||
value={pin}
|
||||
onChange={setPin}
|
||||
description="4–6 digits. Leave blank and the backend issues one — either way it is shown once."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Selector
|
||||
label="Shift"
|
||||
value={shiftId}
|
||||
onChange={setShiftId}
|
||||
options={[
|
||||
{ value: '0', label: 'Any shift' },
|
||||
...shifts.map((shift) => ({
|
||||
value: String(shift.staff_shift_id),
|
||||
label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`,
|
||||
})),
|
||||
]}
|
||||
description="Informational. Nothing refuses a bill rung outside it."
|
||||
/>
|
||||
|
||||
<ActiveToggle
|
||||
isActive={isActive}
|
||||
onChange={setIsActive}
|
||||
activeNote="Can open a till"
|
||||
inactiveNote="Cannot sign in at a till. Their bills are kept."
|
||||
/>
|
||||
</VStack>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Active / inactive, as a pair of buttons rather than a checkbox.
|
||||
*
|
||||
* Deactivating is the closest thing this console has to a delete, so it says
|
||||
* what it does in words on the control itself instead of leaving the
|
||||
* consequence to a checkbox label the eye skips.
|
||||
*/
|
||||
function ActiveToggle({
|
||||
isActive,
|
||||
onChange,
|
||||
activeNote,
|
||||
inactiveNote,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
activeNote: string;
|
||||
inactiveNote: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
Access
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<Choice
|
||||
label="Active"
|
||||
note={activeNote}
|
||||
isSelected={isActive}
|
||||
onSelect={() => onChange(true)}
|
||||
/>
|
||||
<Choice
|
||||
label="Inactive"
|
||||
note={inactiveNote}
|
||||
isSelected={!isActive}
|
||||
onSelect={() => onChange(false)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
label,
|
||||
note,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
note: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={isSelected}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${isSelected ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
||||
background: isSelected ? 'var(--color-brand-tint)' : 'var(--color-surface)',
|
||||
transition: 'border-color .15s, background .15s',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{isSelected ? <Check size={12} /> : null}
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)', marginTop: 2 }}>
|
||||
{note}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
247
src/features/store-admin/StoreAdminShell.tsx
Normal file
247
src/features/store-admin/StoreAdminShell.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Check, ChevronDown, Monitor, Store, Users } from 'lucide-react';
|
||||
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
||||
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
||||
|
||||
/**
|
||||
* Store Admin — the merchant workspace.
|
||||
*
|
||||
* Four destinations, deliberately. The old console gave this role five
|
||||
* (Dashboard, Products, Reports, Store Console, POS) and split the same
|
||||
* question across them: counter sales lived in POS, online sales in Dashboard,
|
||||
* and neither page could answer "how did this branch do today". Console, Sales,
|
||||
* Inventory and Reports each own one question instead.
|
||||
*/
|
||||
const NAV: readonly NavEntry[] = [
|
||||
{ to: '/admin/console', label: 'Console' },
|
||||
{ to: '/admin/sales', label: 'Sales' },
|
||||
{ to: '/admin/inventory', label: 'Inventory' },
|
||||
{ to: '/admin/reports', label: 'Reports' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Setup destinations, in the account menu rather than the nav.
|
||||
*
|
||||
* Three entries, not a Settings page. Opening an outlet, deciding who can sign
|
||||
* in, and checking on the tills are the things a merchant does outside the flow
|
||||
* of a trading day — reached often enough to deserve naming, none of them worth
|
||||
* a nav slot beside Console and Sales.
|
||||
*
|
||||
* Terminals belongs here rather than under Sales for the same reason: it is
|
||||
* where you go when the Console has already told you something is wrong, not a
|
||||
* place anyone works from.
|
||||
*/
|
||||
const MANAGE: readonly MenuEntry[] = [
|
||||
{
|
||||
to: '/admin/branches/new',
|
||||
label: 'New branch',
|
||||
icon: <Store size={13} />,
|
||||
note: 'Commission an outlet',
|
||||
},
|
||||
{
|
||||
to: '/admin/users',
|
||||
label: 'Users & access',
|
||||
icon: <Users size={13} />,
|
||||
note: 'Store and till accounts',
|
||||
},
|
||||
{
|
||||
to: '/admin/terminals',
|
||||
label: 'Terminals',
|
||||
icon: <Monitor size={13} />,
|
||||
note: 'Till health, per counter',
|
||||
},
|
||||
];
|
||||
|
||||
export function StoreAdminShell() {
|
||||
return (
|
||||
<BranchScopeProvider>
|
||||
<AppShell
|
||||
nav={NAV}
|
||||
home="/admin/console"
|
||||
navLabel="Store Admin"
|
||||
scopeControl={<BranchSelector />}
|
||||
manageItems={MANAGE}
|
||||
/>
|
||||
</BranchScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch selector, in the header.
|
||||
*
|
||||
* It lives in the chrome rather than on each page because it scopes all four
|
||||
* pages: a control that reappears in a different place on every screen reads as
|
||||
* a different control each time, and the operator loses track of what is
|
||||
* filtered.
|
||||
*
|
||||
* "All branches" is a real option rather than a cleared filter, and it is the
|
||||
* default. A merchant with three shops wants the group view first; the single
|
||||
* shop is the drill-down.
|
||||
*/
|
||||
function BranchSelector() {
|
||||
const { branches, selected, select, current, isLoading } = useBranchScope();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handle(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [isOpen]);
|
||||
|
||||
const label = isLoading
|
||||
? 'Loading…'
|
||||
: (current?.locationname ?? `All branches (${branches.length})`);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`Branch: ${label}. Filters every page.`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
height: 32,
|
||||
maxWidth: 210,
|
||||
padding: '0 8px 0 10px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isOpen ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
||||
background: isOpen ? 'var(--color-surface)' : 'var(--color-surface-subtle)',
|
||||
color: 'var(--color-ink-1)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color .2s, background .2s',
|
||||
}}
|
||||
>
|
||||
<Store size={14} style={{ color: 'var(--color-brand)', flex: 'none' }} />
|
||||
<span
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ color: 'var(--color-ink-3)', flex: 'none' }} />
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Branch"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 38,
|
||||
zIndex: 50,
|
||||
width: 260,
|
||||
padding: 6,
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: '0 12px 28px -6px rgb(16 24 40 / .12)',
|
||||
}}
|
||||
>
|
||||
<BranchOption
|
||||
label={`All branches (${branches.length})`}
|
||||
note="Everything, consolidated"
|
||||
isSelected={selected === null}
|
||||
onSelect={() => {
|
||||
select(null);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div style={{ height: 1, background: 'var(--color-line)', margin: '4px 0' }} />
|
||||
{branches.map((branch) => (
|
||||
<BranchOption
|
||||
key={branch.locationid}
|
||||
label={branch.locationname}
|
||||
note={branch.suburb ?? branch.city ?? ''}
|
||||
isSelected={selected === branch.locationid}
|
||||
onSelect={() => {
|
||||
select(branch.locationid);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchOption({
|
||||
label,
|
||||
note,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
note: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
background: isSelected
|
||||
? 'var(--color-brand-tint)'
|
||||
: isHovered
|
||||
? 'var(--color-surface-sunken)'
|
||||
: 'transparent',
|
||||
color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-2)',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? 600 : 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{note ? (
|
||||
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-ink-4)' }}>
|
||||
{note}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected ? <Check size={14} style={{ flex: 'none' }} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
125
src/features/store-admin/counterLabels.ts
Normal file
125
src/features/store-admin/counterLabels.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Local names for counters, and a way to hide the dead ones.
|
||||
*
|
||||
* This exists because of two facts about Fiesta, both verified in the source:
|
||||
*
|
||||
* 1. **A terminal has no name.** There is no name, label, alias or counter
|
||||
* number anywhere in the backend. `PosHealth.Storename` is the *shop's*
|
||||
* name, self-reported by the till. The only identifier is the code — which
|
||||
* `models/poshealth.go` calls "the short code printed on invoices — the
|
||||
* thing a support call starts with". So the code is worth keeping visible;
|
||||
* it just isn't *where the counter is*, which is what staff need.
|
||||
*
|
||||
* 2. **A terminal can never be removed.** There is no `SRem` anywhere in
|
||||
* Fiesta, and the location SET that lists terminals deliberately has no
|
||||
* TTL: "it is an index of what exists, not a claim that any of it is alive
|
||||
* right now." A till replaced last year, or a code typed wrong once, sits
|
||||
* on this board as permanently offline, forever, with no API to remove it.
|
||||
*
|
||||
* Point 2 is what makes this feature load-bearing rather than a nicety. Without
|
||||
* hiding, a board that is useful in week one is mostly ghosts by month six —
|
||||
* every one of them red — and a board of permanent false alarms is one nobody
|
||||
* reads. That is a worse outcome than not having built it.
|
||||
*
|
||||
* Both are stored per browser, and the UI says so plainly rather than implying
|
||||
* a change everyone will see. Making them shared needs a small
|
||||
* `terminal_labels` table keyed on `(tenantid, terminalid)` with a name and a
|
||||
* retired flag — worth asking for, not worth waiting for.
|
||||
*/
|
||||
const KEY = 'nearle.counterLabels.v1';
|
||||
|
||||
interface Stored {
|
||||
names: Record<string, string>;
|
||||
hidden: string[];
|
||||
}
|
||||
|
||||
const EMPTY: Stored = { names: {}, hidden: [] };
|
||||
|
||||
function read(): Stored {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed = JSON.parse(raw) as Partial<Stored>;
|
||||
return {
|
||||
names: parsed.names ?? {},
|
||||
hidden: parsed.hidden ?? [],
|
||||
};
|
||||
} catch {
|
||||
// A browser refusing storage, or a value someone hand-edited into
|
||||
// nonsense, must not take the page down with it.
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function write(value: Stored): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
// Storage full or blocked. The rename still applies for this render; it
|
||||
// simply will not survive a reload, which is better than an error dialog.
|
||||
}
|
||||
}
|
||||
|
||||
export interface CounterLabels {
|
||||
/** The name to show: the operator's own, or the code it came with. */
|
||||
nameFor: (terminalId: string) => string;
|
||||
/** Has this counter been given a name? Decides whether the code is shown too. */
|
||||
isNamed: (terminalId: string) => boolean;
|
||||
isHidden: (terminalId: string) => boolean;
|
||||
/** Every hidden counter, so they can actually be brought back. */
|
||||
hidden: readonly string[];
|
||||
hiddenCount: number;
|
||||
rename: (terminalId: string, name: string) => void;
|
||||
hide: (terminalId: string) => void;
|
||||
unhide: (terminalId: string) => void;
|
||||
}
|
||||
|
||||
export function useCounterLabels(): CounterLabels {
|
||||
const [stored, setStored] = useState<Stored>(read);
|
||||
|
||||
const update = useCallback((next: Stored) => {
|
||||
write(next);
|
||||
setStored(next);
|
||||
}, []);
|
||||
|
||||
const rename = useCallback(
|
||||
(terminalId: string, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
const names = { ...stored.names };
|
||||
// An empty name is a request to go back to the code, not a request to
|
||||
// display nothing.
|
||||
if (trimmed === '') delete names[terminalId];
|
||||
else names[terminalId] = trimmed.slice(0, 40);
|
||||
update({ ...stored, names });
|
||||
},
|
||||
[stored, update],
|
||||
);
|
||||
|
||||
const hide = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (stored.hidden.includes(terminalId)) return;
|
||||
update({ ...stored, hidden: [...stored.hidden, terminalId] });
|
||||
},
|
||||
[stored, update],
|
||||
);
|
||||
|
||||
const unhide = useCallback(
|
||||
(terminalId: string) => {
|
||||
update({ ...stored, hidden: stored.hidden.filter((id) => id !== terminalId) });
|
||||
},
|
||||
[stored, update],
|
||||
);
|
||||
|
||||
return {
|
||||
nameFor: (terminalId) => stored.names[terminalId] ?? terminalId,
|
||||
isNamed: (terminalId) => stored.names[terminalId] !== undefined,
|
||||
isHidden: (terminalId) => stored.hidden.includes(terminalId),
|
||||
hidden: stored.hidden,
|
||||
hiddenCount: stored.hidden.length,
|
||||
rename,
|
||||
hide,
|
||||
unhide,
|
||||
};
|
||||
}
|
||||
77
src/features/store-admin/format.ts
Normal file
77
src/features/store-admin/format.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Number formatting, in one place.
|
||||
*
|
||||
* Every figure on these pages is money, a count, or a percentage, and each has
|
||||
* exactly one correct rendering. Scattering `toFixed(2)` across four pages is
|
||||
* how ₹1,350 becomes ₹1350.00 on one screen and ₹1.35K on another.
|
||||
*/
|
||||
|
||||
const INR = new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const INR_PRECISE = new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const COUNT = new Intl.NumberFormat('en-IN');
|
||||
|
||||
/**
|
||||
* Money for a KPI or a table cell — whole rupees.
|
||||
*
|
||||
* Paise are dropped on purpose. A day's takings read at a glance do not need
|
||||
* two decimal places, and the digits they add are the two the eye has to skip
|
||||
* past to compare two branches.
|
||||
*/
|
||||
export const money = (value: number | undefined | null): string =>
|
||||
value === undefined || value === null || !Number.isFinite(value) ? '—' : INR.format(value);
|
||||
|
||||
/** Money where the paise matter — a single bill, a unit price, a tax line. */
|
||||
export const moneyExact = (value: number | undefined | null): string =>
|
||||
value === undefined || value === null || !Number.isFinite(value) ? '—' : INR_PRECISE.format(value);
|
||||
|
||||
export const count = (value: number | undefined | null): string =>
|
||||
value === undefined || value === null || !Number.isFinite(value) ? '—' : COUNT.format(value);
|
||||
|
||||
/**
|
||||
* A percentage of a total, guarded.
|
||||
*
|
||||
* Returns `null` rather than 0 when the denominator is zero: "0% cancelled"
|
||||
* across no orders is a claim the data does not support, and a tile that says
|
||||
* "—" is the honest rendering of a shop that has not traded yet.
|
||||
*/
|
||||
export function share(part: number | undefined, total: number | undefined): number | null {
|
||||
if (!total || !Number.isFinite(total) || total <= 0) return null;
|
||||
if (part === undefined || !Number.isFinite(part)) return null;
|
||||
return part / total;
|
||||
}
|
||||
|
||||
export const percent = (value: number | null): string =>
|
||||
value === null ? '—' : `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
|
||||
|
||||
/** A short local time — "14:32". Used on bills and last-sync stamps. */
|
||||
export const clock = (date: Date | null): string =>
|
||||
date ? date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false }) : '—';
|
||||
|
||||
/**
|
||||
* A branch name with the tenant prefix stripped: "R mart — Peelamedu" → "Peelamedu".
|
||||
*
|
||||
* Every row of every table in this workspace belongs to the same merchant, so
|
||||
* repeating their name down a column is pure noise — and it is expensive noise:
|
||||
* the prefix cost about 55px per row, which was enough to push the Amount
|
||||
* column off the edge of the deliveries table on a 1440px screen.
|
||||
*
|
||||
* Splits on an em dash or a hyphen surrounded by spaces, which is how the
|
||||
* onboarding form composes these names. A name without a separator is returned
|
||||
* untouched rather than guessed at.
|
||||
*/
|
||||
export function branchLabel(name: string | undefined): string {
|
||||
if (!name) return '—';
|
||||
const match = name.split(/\s+[—–-]\s+/);
|
||||
return match.length > 1 ? (match[match.length - 1] as string) : name;
|
||||
}
|
||||
143
src/features/store-admin/orderStatus.ts
Normal file
143
src/features/store-admin/orderStatus.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Order and delivery status, taken verbatim from the old console.
|
||||
*
|
||||
* Two ladders, not one, and that is the important part. An order walks
|
||||
* created → pending → processing → confirmed/ready → delivered, while its
|
||||
* delivery job walks pending → accepted → arrived → picked → active →
|
||||
* delivered. Colouring a delivery with the order map paints "picked" and
|
||||
* "arrived" grey, which is exactly the middle of the journey where an operator
|
||||
* most needs to see progress.
|
||||
*
|
||||
* Values are the old console's `consoleUi.tsx` hex codes, unchanged, so a row
|
||||
* that was amber there is amber here.
|
||||
*/
|
||||
|
||||
/** The order lifecycle. */
|
||||
export const ORDER_STATUS: Record<string, string> = {
|
||||
created: '#0ea5e9',
|
||||
pending: '#f59e0b',
|
||||
processing: '#0ea5e9',
|
||||
modified: '#06b6d4',
|
||||
confirmed: '#10b981',
|
||||
accepted: '#6366f1',
|
||||
ready: '#6366f1',
|
||||
delivered: '#10b981',
|
||||
cancelled: '#ef4444',
|
||||
};
|
||||
|
||||
/** The delivery lifecycle. Different words, different colours. */
|
||||
export const DELIVERY_STATUS: Record<string, string> = {
|
||||
pending: '#f59e0b',
|
||||
accepted: '#6366f1',
|
||||
arrived: '#06b6d4',
|
||||
picked: '#8b5cf6',
|
||||
active: '#14b8a6',
|
||||
skipped: '#f97316',
|
||||
delivered: '#10b981',
|
||||
cancelled: '#ef4444',
|
||||
};
|
||||
|
||||
/** Unknown statuses fall to meta grey rather than to a colour that means something. */
|
||||
export const statusColor = (map: Record<string, string>, status: string | undefined): string =>
|
||||
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)';
|
||||
|
||||
/**
|
||||
* The status tabs, as the old console groups them.
|
||||
*
|
||||
* Tabs rather than a dropdown: there are six, an operator switches between them
|
||||
* constantly during a shift, and a dropdown hides the counts. Each carries how
|
||||
* many rows it holds, which is half the reason to look.
|
||||
*/
|
||||
export const STATUS_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'created', label: 'Created' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'processing', label: 'Processing' },
|
||||
{ key: 'delivered', label: 'Delivered' },
|
||||
{ key: 'cancelled', label: 'Cancelled' },
|
||||
] as const;
|
||||
|
||||
export type StatusKey = (typeof STATUS_TABS)[number]['key'];
|
||||
|
||||
/**
|
||||
* Does a row belong under this tab?
|
||||
*
|
||||
* Substring matching, lowercased. Fiesta stores status as free text and the
|
||||
* casing is inconsistent between writers, so an exact match is how a
|
||||
* "Delivered" row silently stops counting the day someone writes "delivered".
|
||||
* "processing" also catches confirmed, preparing and ready — everything between
|
||||
* accepted and out the door, which is what an operator means by the word.
|
||||
*/
|
||||
export function matchesStatus(key: StatusKey, status: string | undefined): boolean {
|
||||
if (key === 'all') return true;
|
||||
const s = (status ?? '').trim().toLowerCase();
|
||||
if (!s) return false;
|
||||
switch (key) {
|
||||
case 'created':
|
||||
return s.includes('created') || s.includes('new');
|
||||
case 'pending':
|
||||
return s.includes('pending');
|
||||
case 'processing':
|
||||
return (
|
||||
s.includes('process') ||
|
||||
s.includes('confirm') ||
|
||||
s.includes('prepar') ||
|
||||
s.includes('ready') ||
|
||||
s.includes('accept') ||
|
||||
s.includes('picked') ||
|
||||
s.includes('active') ||
|
||||
s.includes('arrived') ||
|
||||
s.includes('out for')
|
||||
);
|
||||
case 'delivered':
|
||||
return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for');
|
||||
case 'cancelled':
|
||||
// `skipped` lives here too. It is its own delivery status — the rider
|
||||
// reached the address and nobody was in — and it keeps its own orange
|
||||
// chip, but it has no tab of its own in this six-tab strip. Without this
|
||||
// line a skipped job appears under "All" and under nothing else, so an
|
||||
// operator working through the tabs never sees it.
|
||||
return s.includes('cancel') || s.includes('skip');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The money on an order row, in the order the old console reads it.
|
||||
*
|
||||
* `ordervalue` is the full figure (goods + tax + charges − promo), `orderamount`
|
||||
* is the goods alone, and `deliveryamt` is what a delivery job carries. Which
|
||||
* one is populated depends on which endpoint produced the row, so this walks
|
||||
* them rather than picking one and showing blanks half the time.
|
||||
*/
|
||||
export function orderValue(row: {
|
||||
ordervalue?: number;
|
||||
orderamount?: number;
|
||||
deliveryamt?: number;
|
||||
}): number {
|
||||
return row.ordervalue || row.orderamount || row.deliveryamt || 0;
|
||||
}
|
||||
|
||||
/** Items on an order. `quantity` first, `itemcount` as the fallback. */
|
||||
export function orderQuantity(row: { quantity?: number; itemcount?: number }): number {
|
||||
return row.quantity || row.itemcount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far behind a counter bill was when it reached us.
|
||||
*
|
||||
* `billedat` is when the sale was rung; `receivedat` is when it landed here. A
|
||||
* gap means the till was offline and caught up later, and it is worth showing
|
||||
* on the row: a day's takings that all arrived at 6pm did not happen at 6pm.
|
||||
*
|
||||
* Returns null when the gap is under a minute (normal) or when either stamp is
|
||||
* missing — a bill with no `receivedat` predates the field, and calling that
|
||||
* "0 seconds late" would be a claim the data does not support.
|
||||
*/
|
||||
export function syncLagMs(bill: { billedat?: string; receivedat?: string }): number | null {
|
||||
if (!bill.billedat || !bill.receivedat) return null;
|
||||
const billed = new Date(bill.billedat).getTime();
|
||||
const received = new Date(bill.receivedat).getTime();
|
||||
if (Number.isNaN(billed) || Number.isNaN(received)) return null;
|
||||
const lag = received - billed;
|
||||
return lag >= 60_000 ? lag : null;
|
||||
}
|
||||
785
src/features/store-admin/pages/ConsolePage.tsx
Normal file
785
src/features/store-admin/pages/ConsolePage.tsx
Normal file
@@ -0,0 +1,785 @@
|
||||
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 { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ClipboardList,
|
||||
Info,
|
||||
Monitor,
|
||||
PackageX,
|
||||
Receipt,
|
||||
ShoppingCart,
|
||||
TrendingDown,
|
||||
} from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
useLocationSummary,
|
||||
usePosHealthByBranch,
|
||||
usePosSalesByBranch,
|
||||
useStockRequests,
|
||||
} from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
import {
|
||||
SYNC_COLOR,
|
||||
SYNC_LABEL,
|
||||
SYNC_TONE,
|
||||
shortAge,
|
||||
summariseBranch,
|
||||
type BranchSyncSummary,
|
||||
type TerminalStatus,
|
||||
} from '../posStatus';
|
||||
|
||||
/**
|
||||
* The Store Admin's landing page.
|
||||
*
|
||||
* One rule governs the whole screen: **app sales and counter sales are never
|
||||
* added together.** They come from two different tables — `orders` and
|
||||
* `posorders` — and a branch that both runs a till and uploads a sales
|
||||
* spreadsheet would be counted twice by a combined total. There is no headline
|
||||
* "Total Revenue" tile here for that reason, and its absence is deliberate
|
||||
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
|
||||
*/
|
||||
export function ConsolePage() {
|
||||
const { branches, scoped, selected, current, tenantid, isLoading } = useBranchScope();
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
const posSales = usePosSalesByBranch(branchIds, range);
|
||||
const posHealth = usePosHealthByBranch(branchIds);
|
||||
const requests = useStockRequests(
|
||||
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
|
||||
);
|
||||
|
||||
// One instant for the whole board — see `summariseBranch`.
|
||||
const now = Date.now();
|
||||
|
||||
/** Everything the page needs, per branch, assembled once. */
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
scoped.map((branch, index) => {
|
||||
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
|
||||
const pos = posSales[index]?.data;
|
||||
const health = summariseBranch(posHealth[index]?.data ?? [], now);
|
||||
return {
|
||||
branch,
|
||||
onlineRevenue: order?.revenue ?? 0,
|
||||
onlineOrders: order?.totalorders ?? 0,
|
||||
cancelled: order?.cancelled ?? 0,
|
||||
delivered: order?.delivered ?? 0,
|
||||
counterRevenue: pos?.grosssales ?? 0,
|
||||
counterBills: pos?.billcount ?? 0,
|
||||
health,
|
||||
pendingRequests: (requests.data ?? []).filter(
|
||||
(entry) => entry.locationid === branch.locationid,
|
||||
).length,
|
||||
};
|
||||
}),
|
||||
[scoped, orders.data, posSales, posHealth, requests.data, now],
|
||||
);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
rows.reduce(
|
||||
(acc, row) => ({
|
||||
onlineRevenue: acc.onlineRevenue + row.onlineRevenue,
|
||||
onlineOrders: acc.onlineOrders + row.onlineOrders,
|
||||
cancelled: acc.cancelled + row.cancelled,
|
||||
counterRevenue: acc.counterRevenue + row.counterRevenue,
|
||||
counterBills: acc.counterBills + row.counterBills,
|
||||
pendingRequests: acc.pendingRequests + row.pendingRequests,
|
||||
pendingBills: acc.pendingBills + row.health.pendingBills,
|
||||
}),
|
||||
{
|
||||
onlineRevenue: 0,
|
||||
onlineOrders: 0,
|
||||
cancelled: 0,
|
||||
counterRevenue: 0,
|
||||
counterBills: 0,
|
||||
pendingRequests: 0,
|
||||
pendingBills: 0,
|
||||
},
|
||||
),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const alerts = useMemo(() => buildAlerts(rows), [rows]);
|
||||
const cancelShare = share(totals.cancelled, totals.onlineOrders);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Console"
|
||||
count={
|
||||
selected === null
|
||||
? `${branches.length} branch${branches.length === 1 ? '' : 'es'}`
|
||||
: (current?.locationname ?? undefined)
|
||||
}
|
||||
description="Everything trading right now — by channel, by branch, and what is waiting on you."
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Revenue"
|
||||
note="two ledgers, shown apart"
|
||||
action={
|
||||
<HStack gap={0.5} align="center">
|
||||
<Info size={12} style={{ color: 'var(--color-ink-4)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
App and counter sales are not added together
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="App revenue"
|
||||
value={money(totals.onlineRevenue)}
|
||||
note={`${count(totals.onlineOrders)} orders`}
|
||||
tone="accent"
|
||||
icon={<ShoppingCart size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter revenue"
|
||||
value={money(totals.counterRevenue)}
|
||||
note={`${count(totals.counterBills)} bills`}
|
||||
tone="success"
|
||||
icon={<Receipt size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={percent(cancelShare)}
|
||||
note={`${count(totals.cancelled)} of ${count(totals.onlineOrders)} app orders`}
|
||||
tone={cancelShare !== null && cancelShare > 0.1 ? 'error' : 'neutral'}
|
||||
icon={<TrendingDown size={15} />}
|
||||
fill={cancelShare ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Unsynced bills"
|
||||
value={count(totals.pendingBills)}
|
||||
note="sitting on tills"
|
||||
tone={totals.pendingBills > 0 ? 'warning' : 'success'}
|
||||
icon={<Monitor size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The caveat that keeps the two figures honest, stated where the
|
||||
figures are rather than in a doc nobody opens at 9am. */}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
App revenue currently also contains any counter bills imported from a spreadsheet —
|
||||
Fiesta records the bill number on the order but does not return it, so the two cannot yet
|
||||
be told apart. Live till sales are counted separately and are never double-counted.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* ── Branch overview ─────────────────────────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={selected === null ? 'Branches' : 'Branch'}
|
||||
note="how each shop is trading"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Loading branches…
|
||||
</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No branches yet
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Commission your first outlet and its numbers will appear here.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{rows.map((row) => (
|
||||
<BranchCard key={row.branch.locationid} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* ── Comparison, only when there is something to compare ─────────── */}
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Comparison" note="the same metrics, side by side" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<ComparisonTable rows={rows} />
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{/* ── Tills ───────────────────────────────────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Till sync"
|
||||
note="a queue nobody can see from the shop floor"
|
||||
action={
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Tills report every 30s
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<TillTable rows={rows} />
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{/* ── Action required ─────────────────────────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Needs you" note="ranked by what costs money soonest" />
|
||||
{alerts.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Every till is synced and no stock request is outstanding.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<VStack gap={1}>
|
||||
{alerts.map((alert) => (
|
||||
<AlertRow key={alert.id} alert={alert} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
type Row = {
|
||||
branch: { locationid: number; locationname: string; suburb?: string; city?: string; status: string };
|
||||
onlineRevenue: number;
|
||||
onlineOrders: number;
|
||||
cancelled: number;
|
||||
delivered: number;
|
||||
counterRevenue: number;
|
||||
counterBills: number;
|
||||
health: ReturnType<typeof summariseBranch>;
|
||||
pendingRequests: number;
|
||||
};
|
||||
|
||||
function BranchCard({ row }: { row: Row }) {
|
||||
const { branch, health } = row;
|
||||
const isActive = branch.status?.toLowerCase() === 'active';
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="start" gap={1}>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||
{branch.locationname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branch.suburb ?? branch.city ?? '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge
|
||||
variant={isActive ? 'success' : 'neutral'}
|
||||
label={isActive ? 'Active' : 'Inactive'}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* Two channels, two rows, never a sum. */}
|
||||
<VStack gap={0.5}>
|
||||
<ChannelRow label="App" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} />
|
||||
<ChannelRow
|
||||
label="Counter"
|
||||
value={money(row.counterRevenue)}
|
||||
note={`${count(row.counterBills)} bills`}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--color-line)' }} />
|
||||
|
||||
<HStack justify="between" align="center" gap={1}>
|
||||
{/* Plain words, and a link. This line used to read "Stale · 2/3
|
||||
tills" — three pieces of jargon in four words, on the page a
|
||||
merchant lands on. It is now a sentence, and it goes somewhere. */}
|
||||
<Link
|
||||
to="/admin/terminals"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: SYNC_COLOR[health.state],
|
||||
}}
|
||||
/>
|
||||
<Text type="body" size="xsm" color="secondary" maxLines={1}>
|
||||
{tillLine(health)}
|
||||
</Text>
|
||||
</Link>
|
||||
{row.pendingRequests > 0 ? (
|
||||
<Badge variant="warning" label={`${row.pendingRequests} to approve`} />
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "2 counters need a look", not "Stale · 2/3 tills".
|
||||
*
|
||||
* Counts what a person would count: how many counters we cannot currently see,
|
||||
* and whether any of them is holding sales. Everything else is a detail for the
|
||||
* Counters page, which this line links to.
|
||||
*/
|
||||
function tillLine(health: BranchSyncSummary): string {
|
||||
if (health.total === 0) return 'No counter reporting';
|
||||
const absent = health.terminals.filter((t) => t.presence !== 'online').length;
|
||||
if (absent === 0) {
|
||||
return health.pendingBills > 0
|
||||
? `All counters on · ${health.pendingBills} sales still sending`
|
||||
: `All ${health.total} counter${health.total === 1 ? '' : 's'} fine`;
|
||||
}
|
||||
const stuck = health.terminals
|
||||
.filter((t) => t.presence !== 'online')
|
||||
.reduce((sum, t) => sum + t.pendingBills, 0);
|
||||
if (stuck > 0) {
|
||||
return `${stuck} sale${stuck === 1 ? '' : 's'} stuck on ${absent} counter${absent === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${absent} counter${absent === 1 ? '' : 's'} not answering`;
|
||||
}
|
||||
|
||||
function ChannelRow({ label, value, note }: { label: string; value: string; note: string }) {
|
||||
return (
|
||||
<HStack justify="between" align="center" gap={1}>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
<HStack gap={0.5} align="center">
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{note}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The comparison table.
|
||||
*
|
||||
* Metrics run down and branches run across, which is the transpose of a normal
|
||||
* data table and the right way round here: the question is "which branch is
|
||||
* behind on revenue", and that is a comparison along a row.
|
||||
*/
|
||||
function ComparisonTable({ rows }: { rows: Row[] }) {
|
||||
const metrics: { label: string; get: (row: Row) => string; total: string }[] = [
|
||||
{
|
||||
label: 'App revenue',
|
||||
get: (row) => money(row.onlineRevenue),
|
||||
total: money(rows.reduce((sum, row) => sum + row.onlineRevenue, 0)),
|
||||
},
|
||||
{
|
||||
label: 'Counter revenue',
|
||||
get: (row) => money(row.counterRevenue),
|
||||
total: money(rows.reduce((sum, row) => sum + row.counterRevenue, 0)),
|
||||
},
|
||||
{
|
||||
label: 'App orders',
|
||||
get: (row) => count(row.onlineOrders),
|
||||
total: count(rows.reduce((sum, row) => sum + row.onlineOrders, 0)),
|
||||
},
|
||||
{
|
||||
label: 'Counter bills',
|
||||
get: (row) => count(row.counterBills),
|
||||
total: count(rows.reduce((sum, row) => sum + row.counterBills, 0)),
|
||||
},
|
||||
{
|
||||
label: 'Cancelled',
|
||||
get: (row) => percent(share(row.cancelled, row.onlineOrders)),
|
||||
total: percent(
|
||||
share(
|
||||
rows.reduce((sum, row) => sum + row.cancelled, 0),
|
||||
rows.reduce((sum, row) => sum + row.onlineOrders, 0),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Awaiting approval',
|
||||
get: (row) => count(row.pendingRequests),
|
||||
total: count(rows.reduce((sum, row) => sum + row.pendingRequests, 0)),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Metric</Th>
|
||||
{rows.map((row) => (
|
||||
<Th key={row.branch.locationid} align="right">
|
||||
{branchLabel(row.branch.locationname)}
|
||||
</Th>
|
||||
))}
|
||||
<Th align="right">All</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.map((metric) => (
|
||||
<tr key={metric.label}>
|
||||
<Td align="left" isMuted>
|
||||
{metric.label}
|
||||
</Td>
|
||||
{rows.map((row) => (
|
||||
<Td key={row.branch.locationid} align="right">
|
||||
{metric.get(row)}
|
||||
</Td>
|
||||
))}
|
||||
<Td align="right" isStrong>
|
||||
{metric.total}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The device readings, in words rather than as flags.
|
||||
*
|
||||
* `posStatus` no longer pre-formats a `warnings` array, because the strings it
|
||||
* produced ("Storage low (140 MB)") were written for a status board and are
|
||||
* read here by whoever is scanning a table. Each reading is nullable on
|
||||
* purpose — `models/poshealth.go` uses pointers so that "not reported" stays
|
||||
* distinguishable from "reported as zero" — so absent must never render.
|
||||
*/
|
||||
function deviceNote(terminal: TerminalStatus): string {
|
||||
const notes: string[] = [];
|
||||
if (terminal.printerReachable === false) notes.push('Printer not responding');
|
||||
if (terminal.storageFreeMb !== null && terminal.storageFreeMb < 200) {
|
||||
notes.push(`Low space (${terminal.storageFreeMb} MB)`);
|
||||
}
|
||||
if (terminal.batteryLevel !== null && terminal.batteryLevel < 20 && !terminal.batteryCharging) {
|
||||
notes.push(`Battery ${terminal.batteryLevel}%`);
|
||||
}
|
||||
if (terminal.drawerStatus === 'open') notes.push('Drawer open');
|
||||
return notes.length > 0 ? notes.join(' · ') : '—';
|
||||
}
|
||||
|
||||
function TillTable({ rows }: { rows: Row[] }) {
|
||||
const terminals = rows.flatMap((row) =>
|
||||
row.health.terminals.map((terminal) => ({ branch: row.branch.locationname, terminal })),
|
||||
);
|
||||
|
||||
if (terminals.length === 0) {
|
||||
return (
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No till is reporting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
A terminal disappears from this board within seconds of going quiet, so an empty board
|
||||
means nothing is currently reaching us — not that nothing is selling.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Till</Th>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right">Queued</Th>
|
||||
<Th align="right">Oldest</Th>
|
||||
<Th align="right">Today</Th>
|
||||
<Th align="left">Notes</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{terminals.map(({ branch, terminal }) => (
|
||||
<tr key={`${branch}:${terminal.terminalId}`}>
|
||||
<Td align="left" isStrong>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{terminal.terminalId}
|
||||
</span>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Badge variant={SYNC_TONE[terminal.state]} label={SYNC_LABEL[terminal.state]} />
|
||||
</Td>
|
||||
<Td align="right" isStrong={terminal.pendingBills > 0}>
|
||||
{terminal.pendingBills > 0 ? count(terminal.pendingBills) : '—'}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{shortAge(terminal.oldestPendingMs)}
|
||||
</Td>
|
||||
<Td align="right">
|
||||
{count(terminal.todayBills)} · {money(terminal.todayAmount)}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{deviceNote(terminal)}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Alerts ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
tone: 'error' | 'warning' | 'accent';
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
detail: string;
|
||||
to: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What needs the admin, ordered by what costs money soonest.
|
||||
*
|
||||
* Stranded takings first: an unsynced till is money already collected that the
|
||||
* business cannot see, and it is the only item here that gets worse purely by
|
||||
* being ignored.
|
||||
*/
|
||||
function buildAlerts(rows: Row[]): Alert[] {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const stranded = row.health.terminals.filter((terminal) => terminal.pendingBills > 0);
|
||||
if (stranded.length > 0) {
|
||||
const bills = stranded.reduce((sum, terminal) => sum + terminal.pendingBills, 0);
|
||||
const oldest = Math.max(...stranded.map((terminal) => terminal.oldestPendingMs));
|
||||
// Name the till, not just the shop. "Something is wrong at Peelamedu"
|
||||
// makes someone go and look; "PLM-02 is holding 29 bills" tells them
|
||||
// which counter to walk to.
|
||||
const worst = stranded.reduce((acc, terminal) =>
|
||||
terminal.oldestPendingMs > acc.oldestPendingMs ? terminal : acc,
|
||||
);
|
||||
alerts.push({
|
||||
id: `sync:${row.branch.locationid}`,
|
||||
tone: oldest > 30 * 60 * 1000 ? 'error' : 'warning',
|
||||
icon: <Monitor size={15} />,
|
||||
title: `${count(bills)} bill${bills === 1 ? '' : 's'} stranded at ${row.branch.locationname}`,
|
||||
detail: `${stranded.length > 1 ? `${stranded.length} tills, oldest on ` : ''}${worst.terminalId}, waiting ${shortAge(oldest)}. Takings already collected that the business cannot see.`,
|
||||
to: '/admin/terminals',
|
||||
action: 'Open Terminals',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tills we cannot see.
|
||||
*
|
||||
* Presence, not the old `state === 'offline'` test — that check predates
|
||||
* `stale` and `no-heartbeat`, so a till whose heartbeat simply stopped would
|
||||
* have raised nothing here at all. Which was the bug in miniature.
|
||||
*/
|
||||
const unseen = rows.flatMap((row) =>
|
||||
row.health.terminals
|
||||
.filter((terminal) => terminal.presence !== 'online' && terminal.pendingBills === 0)
|
||||
.map((terminal) => ({ row, terminal })),
|
||||
);
|
||||
if (unseen.length > 0) {
|
||||
const branches = [...new Set(unseen.map(({ row }) => row.branch.locationname))];
|
||||
alerts.push({
|
||||
id: 'unseen',
|
||||
tone: 'warning',
|
||||
icon: <AlertTriangle size={15} />,
|
||||
title: `${unseen.length} till${unseen.length === 1 ? '' : 's'} not reporting`,
|
||||
detail: unseen
|
||||
.map(({ terminal }) => `${terminal.terminalId} (${SYNC_LABEL[terminal.state].toLowerCase()})`)
|
||||
.join(', ')
|
||||
.concat(branches.length > 1 ? ` across ${branches.length} branches` : ` at ${branches[0]}`),
|
||||
to: '/admin/terminals',
|
||||
action: 'See tills',
|
||||
});
|
||||
}
|
||||
|
||||
const waiting = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
|
||||
if (waiting > 0) {
|
||||
alerts.push({
|
||||
id: 'requests',
|
||||
tone: 'warning',
|
||||
icon: <ClipboardList size={15} />,
|
||||
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`,
|
||||
detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.',
|
||||
to: '/admin/inventory',
|
||||
action: 'Review requests',
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const cancelRate = share(row.cancelled, row.onlineOrders);
|
||||
if (cancelRate !== null && cancelRate > 0.25 && row.onlineOrders >= 10) {
|
||||
alerts.push({
|
||||
id: `cancel:${row.branch.locationid}`,
|
||||
tone: 'error',
|
||||
icon: <PackageX size={15} />,
|
||||
title: `${percent(cancelRate)} of app orders cancelled at ${row.branch.locationname}`,
|
||||
detail: `${count(row.cancelled)} of ${count(row.onlineOrders)} orders. Usually stock the app thinks is on the shelf and is not.`,
|
||||
to: '/admin/sales',
|
||||
action: 'Open Sales',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
function AlertRow({ alert }: { alert: Alert }) {
|
||||
const color =
|
||||
alert.tone === 'error'
|
||||
? 'var(--color-error, #d64545)'
|
||||
: alert.tone === 'warning'
|
||||
? 'var(--color-warning, #b7860b)'
|
||||
: 'var(--color-brand)';
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||
<HStack gap={1.5} align="start" style={{ minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
flex: 'none',
|
||||
background: `color-mix(in oklab, ${color} 12%, transparent)`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{alert.icon}
|
||||
</span>
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{alert.title}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{alert.detail}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Button
|
||||
label={alert.action}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={alert.to}
|
||||
as={Link}
|
||||
icon={<ArrowRight size={14} />}
|
||||
style={{ width: 160, flex: 'none', justifyContent: 'center' }}
|
||||
/>
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Table primitives ────────────────────────────────────────────────────── */
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '11px 14px',
|
||||
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,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
727
src/features/store-admin/pages/InventoryPage.tsx
Normal file
727
src/features/store-admin/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
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 { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ClipboardList,
|
||||
Info,
|
||||
Layers,
|
||||
PackageSearch,
|
||||
Send,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { stockApi } from '@/api/stock';
|
||||
import type { StockRequest } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchLabel, count, money } from '../format';
|
||||
|
||||
type Tab = 'catalogue' | 'stock' | 'requests';
|
||||
|
||||
/**
|
||||
* Inventory — catalogue, stock and the approval queue.
|
||||
*
|
||||
* The spec's three catalogue tiers are real in Fiesta, not an invention of the
|
||||
* UI. `productlocations.publishedat` is NULL while a product sits in the admin
|
||||
* catalogue and stamped once it is released to the shops, and the column is
|
||||
* deliberately read-only through ordinary writes so that setting a price cannot
|
||||
* accidentally publish. So "review, price, then publish" is the backend's own
|
||||
* model, and this page follows it rather than layering a second one on top.
|
||||
*
|
||||
* What is NOT real: per-store assignment. `PublishProduct` takes a tenant and a
|
||||
* product and releases to every outlet. The store checkboxes below are built to
|
||||
* the spec and marked pending, per the decision to have the UI lead and the
|
||||
* backend catch up. Nothing on screen claims the selection is in force.
|
||||
*/
|
||||
export function InventoryPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('requests');
|
||||
|
||||
const requests = useStockRequests(
|
||||
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
|
||||
);
|
||||
|
||||
const pending = useMemo(
|
||||
() => (requests.data ?? []).filter((entry) => entry.status === 'Pending'),
|
||||
[requests.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Inventory"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="What you sell, where it is stocked, and what the shops are asking you for."
|
||||
actions={
|
||||
pending.length > 0 ? (
|
||||
<Badge variant="warning" label={`${pending.length} awaiting approval`} />
|
||||
) : undefined
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Stock requests"
|
||||
icon={<ClipboardList size={14} />}
|
||||
isActive={tab === 'requests'}
|
||||
onClick={() => setTab('requests')}
|
||||
badge={pending.length || undefined}
|
||||
/>
|
||||
<TabButton
|
||||
label="Stock"
|
||||
icon={<Layers size={14} />}
|
||||
isActive={tab === 'stock'}
|
||||
onClick={() => setTab('stock')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Catalogue"
|
||||
icon={<PackageSearch size={14} />}
|
||||
isActive={tab === 'catalogue'}
|
||||
onClick={() => setTab('catalogue')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{tab === 'requests' ? (
|
||||
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
||||
) : tab === 'stock' ? (
|
||||
<StockPanel />
|
||||
) : (
|
||||
<CataloguePanel branchCount={scoped.length} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stock requests ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The approval queue.
|
||||
*
|
||||
* Approve means "mark Received", which posts the full requested quantity to
|
||||
* that branch's stock. There is no approve-for-less: the service reads the
|
||||
* quantity off the request row and the call carries only a status string. The
|
||||
* quantity field below is therefore shown read-only with the reason attached,
|
||||
* rather than as an input that silently discards what the admin types.
|
||||
*/
|
||||
function RequestsPanel({
|
||||
requests,
|
||||
isLoading,
|
||||
}: {
|
||||
requests: StockRequest[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [acting, setActing] = useState<number | null>(null);
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
|
||||
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
|
||||
onSettled: async () => {
|
||||
setActing(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
||||
// Approving moves stock, so anything counting stock is stale too.
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
||||
},
|
||||
});
|
||||
|
||||
const pending = requests.filter((entry) => entry.status === 'Pending');
|
||||
const settled = requests.filter((entry) => entry.status !== 'Pending');
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Awaiting you"
|
||||
value={count(pending.length)}
|
||||
note="nothing reaches a shelf until approved"
|
||||
tone={pending.length > 0 ? 'warning' : 'success'}
|
||||
icon={<ClipboardList size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Units requested"
|
||||
value={count(pending.reduce((sum, entry) => sum + entry.qty, 0))}
|
||||
note="across open requests"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Settled"
|
||||
value={count(settled.length)}
|
||||
note="approved or rejected"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Open requests" note="a store cannot add its own stock" />
|
||||
{isLoading ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the queue…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : pending.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Every request has been decided. New ones appear here the moment a store raises them.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<VStack gap={1}>
|
||||
{pending.map((request) => (
|
||||
<Card key={request.requestid} padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
flex: 'none',
|
||||
background: 'var(--color-slate-50)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{request.productimage ? (
|
||||
<img
|
||||
src={request.productimage}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<PackageSearch size={16} style={{ color: 'var(--color-slate-300)' }} />
|
||||
)}
|
||||
</span>
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||
{request.productname ?? `Product ${request.productid}`}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(request.locationname) || `Branch ${request.locationid}`} · asked for{' '}
|
||||
<strong>{count(request.qty)}</strong> units
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={1} align="center" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
label="Reject"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<X size={14} />}
|
||||
isDisabled={decide.isPending && acting === request.requestid}
|
||||
onClick={() => {
|
||||
setActing(request.requestid);
|
||||
decide.mutate({ requestid: request.requestid, approve: false });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label={`Approve ${count(request.qty)}`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Check size={14} />}
|
||||
isDisabled={decide.isPending && acting === request.requestid}
|
||||
onClick={() => {
|
||||
setActing(request.requestid);
|
||||
decide.mutate({ requestid: request.requestid, approve: true });
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Stated once, under the queue, rather than on every row. */}
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Approving posts the <strong>full requested quantity</strong> to that branch. Fiesta
|
||||
reads the amount off the request row, so approving for less is not possible yet — and
|
||||
the request carries no reason, requester or remarks, because those columns do not exist.
|
||||
To give a shop a different amount, reject and ask them to re-raise it.
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{settled.length > 0 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Decided" note="most recent first" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Product</Th>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="right">Qty</Th>
|
||||
<Th align="left">Outcome</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settled.slice(0, 25).map((request) => (
|
||||
<tr key={request.requestid}>
|
||||
<Td align="left" isStrong>
|
||||
{request.productname ?? `Product ${request.productid}`}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(request.locationname) || `Branch ${request.locationid}`}
|
||||
</Td>
|
||||
<Td align="right">{count(request.qty)}</Td>
|
||||
<Td align="left">
|
||||
<Badge
|
||||
variant={request.status === 'Received' ? 'success' : 'neutral'}
|
||||
label={request.status === 'Received' ? 'Stock added' : request.status}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stock ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Stock levels and movement.
|
||||
*
|
||||
* Opening → in → out → closing is the whole vocabulary the ledger has.
|
||||
* `productstocks.stocktype` is `in`/`out` with no reason column, so a sale, a
|
||||
* branch transfer and a stock-take correction are indistinguishable once
|
||||
* written. The spec's seven movement types are not shown, because showing seven
|
||||
* columns sourced from two would be a fabrication.
|
||||
*/
|
||||
function StockPanel() {
|
||||
const { scoped, tenantid } = useBranchScope();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// One branch at a time: the statement endpoint requires a single locationid.
|
||||
const branch = scoped[0];
|
||||
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
|
||||
keyword: keyword.trim() || undefined,
|
||||
pagesize: 100,
|
||||
});
|
||||
|
||||
const rows = statement.data ?? [];
|
||||
const value = rows.reduce(
|
||||
(sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0),
|
||||
0,
|
||||
);
|
||||
const low = rows.filter((row) => (row.closing ?? 0) > 0 && (row.closing ?? 0) <= 5).length;
|
||||
const out = rows.filter((row) => (row.closing ?? 0) <= 0).length;
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Stock value"
|
||||
value={money(value)}
|
||||
note={`${count(rows.length)} products`}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Low stock"
|
||||
value={count(low)}
|
||||
note="5 units or fewer"
|
||||
tone={low > 0 ? 'warning' : 'neutral'}
|
||||
icon={<AlertTriangle size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Out of stock"
|
||||
value={count(out)}
|
||||
note="nothing on the shelf"
|
||||
tone={out > 0 ? 'error' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={branch ? `Stock at ${branch.locationname}` : 'Stock'}
|
||||
note="opening → in → out → closing"
|
||||
action={
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Search stock"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{scoped.length > 1 ? (
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Showing <strong>{branch?.locationname}</strong>. The stock statement is scoped to one
|
||||
branch by the backend — pick a branch in the header to switch.
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
{statement.isLoading ? (
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading stock…
|
||||
</Text>
|
||||
</VStack>
|
||||
) : rows.length === 0 ? (
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No stock recorded
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Products reach a shelf through an approved stock request, not by being in the
|
||||
catalogue.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Product</Th>
|
||||
<Th align="right">Opening</Th>
|
||||
<Th align="right">In</Th>
|
||||
<Th align="right">Out</Th>
|
||||
<Th align="right">Closing</Th>
|
||||
<Th align="right">Value</Th>
|
||||
<Th align="left">Status</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const closing = row.closing ?? 0;
|
||||
return (
|
||||
<tr key={row.productid}>
|
||||
<Td align="left" isStrong>
|
||||
{row.productname ?? `Product ${row.productid}`}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.opening)}
|
||||
</Td>
|
||||
<Td align="right">{count(row.credit)}</Td>
|
||||
<Td align="right">{count(row.debit)}</Td>
|
||||
<Td align="right" isStrong>
|
||||
{count(closing)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{money(closing * (row.retailprice ?? 0))}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Badge
|
||||
variant={closing <= 0 ? 'error' : closing <= 5 ? 'warning' : 'success'}
|
||||
label={closing <= 0 ? 'Out' : closing <= 5 ? 'Low' : 'In stock'}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Catalogue ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The catalogue tiers and how a product moves between them.
|
||||
*
|
||||
* Deliberately an explanation plus the two entry points rather than a second
|
||||
* copy of the Nearle Admin's catalogue browser: the import UI already exists
|
||||
* and works, and duplicating it here is how two screens drift apart.
|
||||
*/
|
||||
function CataloguePanel({ branchCount }: { branchCount: number }) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="How a product reaches a shelf" note="four steps, two entry points" />
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<div className="form-grid">
|
||||
<Step
|
||||
n={1}
|
||||
title="Into your catalogue"
|
||||
body="Import from the global catalogue, or upload your own spreadsheet. Only you can do either — a store manager cannot."
|
||||
/>
|
||||
<Step
|
||||
n={2}
|
||||
title="Price it"
|
||||
body="The global catalogue carries a price range, not a price. Yours is the one customers pay, and it is set per branch."
|
||||
/>
|
||||
<Step
|
||||
n={3}
|
||||
title="Publish"
|
||||
body={`Publishing releases the product to all ${branchCount} branch${branchCount === 1 ? '' : 'es'}. Until then it is yours alone and no store can see it.`}
|
||||
/>
|
||||
<Step
|
||||
n={4}
|
||||
title="Stock it"
|
||||
body="A published product is not stock. A store raises a request, you approve it, and only then does anything reach the shelf."
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Add products" note="both paths end in the same catalogue" />
|
||||
<div className="card-grid">
|
||||
<EntryCard
|
||||
icon={<PackageSearch size={18} />}
|
||||
title="From the global catalogue"
|
||||
body="1,267 FMCG products with images and pack sizes. Pick what you sell and set your own price."
|
||||
action="Browse catalogue"
|
||||
/>
|
||||
<EntryCard
|
||||
icon={<Send size={18} />}
|
||||
title="From a spreadsheet"
|
||||
body="Your own product list. Validated before anything is created — you see valid, duplicate and error rows, and can download the failures to fix and re-upload."
|
||||
action="Upload a sheet"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
|
||||
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Two things the spec asks for that the backend cannot do yet
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<strong>Per-branch assignment.</strong> Publishing releases a product to every
|
||||
branch — `PublishProduct` takes a tenant and a product, with no location list. The
|
||||
per-branch controls are built to your spec and will start working the day that
|
||||
parameter lands; nothing here pretends the selection is in force today.
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<strong>Duplicate detection by SKU.</strong> Across the live catalogue 6,245 products
|
||||
share 93 distinct SKU values and 154 are blank, so a SKU cannot decide that two rows
|
||||
are the same product. Upload validation warns on repeats but will not merge on them,
|
||||
and there is no barcode column anywhere in Fiesta to fall back to.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, title, body }: { n: number; title: string; body: string }) {
|
||||
return (
|
||||
<VStack gap={0.5}>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryCard({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
action: string;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<span
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<Button label={action} variant="secondary" size="sm" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-warning, #b7860b)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '11px 14px',
|
||||
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,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
321
src/features/store-admin/pages/OnboardBranchPage.tsx
Normal file
321
src/features/store-admin/pages/OnboardBranchPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useMemo, 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 { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { TimeInput } from '@astryxdesign/core/TimeInput';
|
||||
import { createISOTimeString, type ISOTimeString } from '@astryxdesign/core/utils';
|
||||
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, CheckCircle2, Clock, MapPin, Store } from 'lucide-react';
|
||||
import { tenantsApi, type CreateBranchRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useTenants } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
interface FormState {
|
||||
tenantid: string;
|
||||
locationname: string;
|
||||
email: string;
|
||||
contactno: string;
|
||||
address: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
opentime: ISOTimeString;
|
||||
closetime: ISOTimeString;
|
||||
deliveryradius: number;
|
||||
deliverymins: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `createISOTimeString` returns null for anything it cannot parse. These two are
|
||||
* compile-time literals that are known-good, so a failure here is a typo in this
|
||||
* file rather than bad input — fail loudly instead of defaulting silently.
|
||||
*/
|
||||
function isoTime(value: string): ISOTimeString {
|
||||
const parsed = createISOTimeString(value);
|
||||
if (!parsed) throw new Error(`Invalid default time: ${value}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
tenantid: '',
|
||||
locationname: '',
|
||||
email: '',
|
||||
contactno: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '',
|
||||
opentime: isoTime('08:00'),
|
||||
closetime: isoTime('22:00'),
|
||||
deliveryradius: 5000,
|
||||
deliverymins: 30,
|
||||
};
|
||||
|
||||
/**
|
||||
* Commission a branch under an existing tenant.
|
||||
*
|
||||
* Sets the store's parameters and delivery thresholds, and spawns a placeholder
|
||||
* branch-manager account. The delivery radius and estimated speed are what the
|
||||
* customer app uses to decide whether an address is servable, so they are
|
||||
* required rather than optional-with-a-default-nobody-checks.
|
||||
*
|
||||
* ── PARKED, NOT DEAD ────────────────────────────────────────────────────────
|
||||
* Branch onboarding belongs to the Store Admin, not to us: a merchant opens
|
||||
* their own outlets. This file is therefore unrouted until the Store Admin
|
||||
* workspace exists, and it lives here rather than under `nearle-admin/` so its
|
||||
* eventual owner is obvious from the path.
|
||||
*
|
||||
* Two things must change when it is wired up:
|
||||
* 1. Drop the tenant Selector. A Store Admin has exactly one tenant and it
|
||||
* comes from the session, so asking them to pick it is asking them to
|
||||
* confirm something they cannot get wrong.
|
||||
* 2. Take `tenantid` from `useAuth()` instead of form state, and let the
|
||||
* backend reject a mismatch rather than trusting the client's number.
|
||||
*/
|
||||
export function OnboardBranchPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: tenants, isLoading: isLoadingTenants } = useTenants();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** One option per tenant — `getalltenants` returns a row per branch. */
|
||||
const tenantOptions = useMemo(() => {
|
||||
if (!tenants) return [];
|
||||
const seen = new Map<number, string>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
|
||||
}, [tenants]);
|
||||
|
||||
function set<K extends keyof FormState>(key: K) {
|
||||
return (value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: CreateBranchRequest) => tenantsApi.createBranch(body),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
form.tenantid !== '' &&
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantid: Number(form.tenantid),
|
||||
locationname: form.locationname.trim(),
|
||||
email: form.email.trim(),
|
||||
contactno: form.contactno.trim(),
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
postcode: form.postcode.trim(),
|
||||
opentime: form.opentime,
|
||||
closetime: form.closetime,
|
||||
deliveryradius: form.deliveryradius,
|
||||
deliverymins: form.deliverymins,
|
||||
status: 'Active',
|
||||
});
|
||||
}
|
||||
|
||||
if (mutation.isSuccess) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader title="Branch commissioned" description="The outlet is live and can take orders." />
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.locationname} is commissioned
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
A placeholder branch-manager account was created with it. The branch has no catalogue
|
||||
yet — products are published to it per store.
|
||||
</Text>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Commission another"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
mutation.reset();
|
||||
setForm({ ...EMPTY, tenantid: form.tenantid });
|
||||
}}
|
||||
/>
|
||||
<Button label="Back to stores" variant="primary" onClick={() => navigate('/nearle/stores')} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard branch"
|
||||
description="Commission a new outlet under an existing tenant and set its delivery thresholds."
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack gap={3}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Owner and identity"
|
||||
note="which tenant this branch belongs to"
|
||||
action={<Store size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Owner tenant <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
options={tenantOptions}
|
||||
value={form.tenantid}
|
||||
onChange={set('tenantid')}
|
||||
placeholder={isLoadingTenants ? 'Loading tenants…' : 'Select tenant…'}
|
||||
isLoading={isLoadingTenants}
|
||||
hasSearch
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. RS Puram Super Hub"
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set('email')}
|
||||
placeholder="rspuram@kaveri.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet contact"
|
||||
value={form.contactno}
|
||||
onChange={set('contactno')}
|
||||
placeholder="9876543222"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Location"
|
||||
note="where the outlet sits"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 240, DB Road"
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. RS Puram" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.postcode} onChange={set('postcode')} />
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Hours and delivery"
|
||||
note="what the customer app uses to decide if an address is servable"
|
||||
action={<Clock size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TimeInput
|
||||
label="Open time"
|
||||
value={form.opentime}
|
||||
onChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, opentime: value ?? prev.opentime }))
|
||||
}
|
||||
/>
|
||||
<TimeInput
|
||||
label="Close time"
|
||||
value={form.closetime}
|
||||
onChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, closetime: value ?? prev.closetime }))
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Delivery radius (m)"
|
||||
value={form.deliveryradius}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, deliveryradius: value ?? 0 }))}
|
||||
min={0}
|
||||
step={500}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Est. delivery (mins)"
|
||||
value={form.deliverymins}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, deliverymins: value ?? 0 }))}
|
||||
min={0}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="end">
|
||||
<Button
|
||||
label={mutation.isPending ? 'Commissioning…' : 'Commission branch'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
576
src/features/store-admin/pages/ReportsPage.tsx
Normal file
576
src/features/store-admin/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
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 { BarChart3, Boxes, IndianRupee, Info } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
type Tab = 'revenue' | 'sales' | 'inventory';
|
||||
|
||||
/**
|
||||
* Reports — revenue, sales and stock.
|
||||
*
|
||||
* The same rule as the Console: the two channels are reported side by side and
|
||||
* never summed. Every comparison on this page is either within a channel or
|
||||
* between branches, both of which are sound; a figure that mixed `orders` and
|
||||
* `posorders` would not be.
|
||||
*/
|
||||
export function ReportsPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('revenue');
|
||||
const [preset, setPreset] = useState<RangePreset>('month');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
const posSales = usePosSalesByBranch(branchIds, range);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
scoped.map((branch, index) => {
|
||||
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
|
||||
const pos = posSales[index]?.data;
|
||||
return {
|
||||
branch,
|
||||
appRevenue: order?.revenue ?? 0,
|
||||
appOrders: order?.totalorders ?? 0,
|
||||
cancelled: order?.cancelled ?? 0,
|
||||
delivered: order?.delivered ?? 0,
|
||||
counterRevenue: pos?.grosssales ?? 0,
|
||||
counterBills: pos?.billcount ?? 0,
|
||||
tax: pos?.taxcollected ?? 0,
|
||||
discount: pos?.discountgiven ?? 0,
|
||||
byDay: pos?.byday ?? [],
|
||||
};
|
||||
}),
|
||||
[scoped, orders.data, posSales],
|
||||
);
|
||||
|
||||
const totals = rows.reduce(
|
||||
(acc, row) => ({
|
||||
appRevenue: acc.appRevenue + row.appRevenue,
|
||||
appOrders: acc.appOrders + row.appOrders,
|
||||
cancelled: acc.cancelled + row.cancelled,
|
||||
delivered: acc.delivered + row.delivered,
|
||||
counterRevenue: acc.counterRevenue + row.counterRevenue,
|
||||
counterBills: acc.counterBills + row.counterBills,
|
||||
tax: acc.tax + row.tax,
|
||||
discount: acc.discount + row.discount,
|
||||
}),
|
||||
{
|
||||
appRevenue: 0,
|
||||
appOrders: 0,
|
||||
cancelled: 0,
|
||||
delivered: 0,
|
||||
counterRevenue: 0,
|
||||
counterBills: 0,
|
||||
tax: 0,
|
||||
discount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Revenue and stock over a period you choose, compared where a comparison is sound."
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Revenue"
|
||||
icon={<IndianRupee size={14} />}
|
||||
isActive={tab === 'revenue'}
|
||||
onClick={() => setTab('revenue')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Sales"
|
||||
icon={<BarChart3 size={14} />}
|
||||
isActive={tab === 'sales'}
|
||||
onClick={() => setTab('sales')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Inventory"
|
||||
icon={<Boxes size={14} />}
|
||||
isActive={tab === 'inventory'}
|
||||
onClick={() => setTab('inventory')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{tab === 'revenue' ? (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="App revenue"
|
||||
value={money(totals.appRevenue)}
|
||||
note={`${count(totals.appOrders)} orders`}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter revenue"
|
||||
value={money(totals.counterRevenue)}
|
||||
note={`${count(totals.counterBills)} bills`}
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tax collected"
|
||||
value={money(totals.tax)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Discount given"
|
||||
value={money(totals.discount)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelSplit app={totals.appRevenue} counter={totals.counterRevenue} />
|
||||
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="By branch" note="within a channel, so the comparison holds" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="right">App revenue</Th>
|
||||
<Th align="right">Counter revenue</Th>
|
||||
<Th align="right">App orders</Th>
|
||||
<Th align="right">Counter bills</Th>
|
||||
<Th align="right">Avg bill</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.branch.locationid}>
|
||||
<Td align="left" isStrong>
|
||||
{branchLabel(row.branch.locationname)}
|
||||
</Td>
|
||||
<Td align="right">{money(row.appRevenue)}</Td>
|
||||
<Td align="right">{money(row.counterRevenue)}</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.appOrders)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.counterBills)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{row.counterBills > 0
|
||||
? money(row.counterRevenue / row.counterBills)
|
||||
: '—'}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : tab === 'sales' ? (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Fulfilled"
|
||||
value={count(totals.delivered)}
|
||||
note={`of ${count(totals.appOrders)} app orders`}
|
||||
tone="success"
|
||||
fill={share(totals.delivered, totals.appOrders) ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={percent(share(totals.cancelled, totals.appOrders))}
|
||||
note={`${count(totals.cancelled)} orders`}
|
||||
tone={
|
||||
(share(totals.cancelled, totals.appOrders) ?? 0) > 0.1 ? 'error' : 'neutral'
|
||||
}
|
||||
fill={share(totals.cancelled, totals.appOrders) ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter bills"
|
||||
value={count(totals.counterBills)}
|
||||
note="rung up at a till"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average bill"
|
||||
value={
|
||||
totals.counterBills > 0
|
||||
? money(totals.counterRevenue / totals.counterBills)
|
||||
: '—'
|
||||
}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DailyTrend rows={rows} />
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
|
||||
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Fast and slow movers need one more read
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Product-level performance comes from the stock statement, not from the sales
|
||||
summary — movement out of stock is what makes a product fast or slow. It is on the
|
||||
Inventory tab, per branch, because the statement endpoint is scoped to a single
|
||||
location.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : (
|
||||
<InventoryReport />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The channel split, as a bar rather than a pie.
|
||||
*
|
||||
* Two quantities that must not be added still compare fine as lengths, and a
|
||||
* bar makes "counter is two thirds of the business" readable without implying a
|
||||
* whole they sum to.
|
||||
*/
|
||||
function ChannelSplit({ app, counter }: { app: number; counter: number }) {
|
||||
const max = Math.max(app, counter, 1);
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="App versus counter" note="compared, not combined" />
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
|
||||
<Bar
|
||||
label="Counter"
|
||||
value={counter}
|
||||
max={max}
|
||||
color="var(--color-success, #10b981)"
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
max: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={0.5}>
|
||||
<HStack justify="between" align="center">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{label}
|
||||
</Text>
|
||||
<Text type="label" size="sm" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{money(value)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-surface-sunken)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.max(2, (value / max) * 100)}%`,
|
||||
height: '100%',
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
transition: 'width .4s cubic-bezier(.16,1,.3,1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
/** Counter takings per trading day, straight from `PosSalesSummary.byday`. */
|
||||
function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }[] }[] }) {
|
||||
const chartData = useMemo(() => {
|
||||
const totals = new Array(7).fill(0);
|
||||
let hasData = false;
|
||||
for (const row of rows) {
|
||||
for (const entry of row.byDay) {
|
||||
if (!entry.day) continue;
|
||||
const d = new Date(entry.day);
|
||||
if (!isNaN(d.getTime())) {
|
||||
const dayIndex = (d.getDay() + 6) % 7;
|
||||
totals[dayIndex] += (entry.amount ?? 0);
|
||||
hasData = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasData ? WEEKDAYS.map((name, index) => ({ name, amount: totals[index] })) : [];
|
||||
}, [rows]);
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Counter takings by day" note="every till, combined" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
|
||||
<div style={{ minWidth: 400, height: '100%' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 13, fill: 'var(--color-slate-500)', fontWeight: 500 }}
|
||||
dy={10}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'var(--color-surface-sunken)', radius: 6 }}
|
||||
formatter={(value: any) => [money(value as number), 'Takings']}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-low)',
|
||||
background: 'var(--color-background-surface)',
|
||||
padding: '8px 12px'
|
||||
}}
|
||||
itemStyle={{ color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
<RechartsBar
|
||||
dataKey="amount"
|
||||
fill="var(--color-brand)"
|
||||
radius={[6, 6, 6, 6]}
|
||||
barSize={36}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory reporting, per branch.
|
||||
*
|
||||
* Fast and slow movers come from the stock statement's `debit` — what actually
|
||||
* left the shelf — rather than from a sales table, because that is the one
|
||||
* figure that counts both channels without double-counting either.
|
||||
*/
|
||||
function InventoryReport() {
|
||||
const { scoped, tenantid } = useBranchScope();
|
||||
const branch = scoped[0];
|
||||
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
|
||||
pagesize: 200,
|
||||
});
|
||||
|
||||
const rows = statement.data ?? [];
|
||||
const moving = [...rows].sort((a, b) => (b.debit ?? 0) - (a.debit ?? 0));
|
||||
const fast = moving.slice(0, 5);
|
||||
const slow = moving.filter((row) => (row.closing ?? 0) > 0).slice(-5).reverse();
|
||||
const value = rows.reduce((sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0), 0);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Inventory value"
|
||||
value={money(value)}
|
||||
note={branch?.locationname ?? '—'}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard label="Products" value={count(rows.length)} note="with a stock record" tone="neutral" />
|
||||
<KpiCard
|
||||
label="Units moved"
|
||||
value={count(rows.reduce((sum, row) => sum + (row.debit ?? 0), 0))}
|
||||
note="off the shelf this period"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<MoverList title="Fast moving" note="most units off the shelf" rows={fast} />
|
||||
<MoverList title="Slow moving" note="in stock, barely selling" rows={slow} />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MoverList({
|
||||
title,
|
||||
note,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
note: string;
|
||||
rows: { productid: number; productname?: string; debit?: number; closing?: number }[];
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title={title} note={note} />
|
||||
<Card padding={0} elevation="low">
|
||||
{rows.length === 0 ? (
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Not enough movement to rank yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack>
|
||||
{rows.map((row, index) => (
|
||||
<HStack
|
||||
key={row.productid}
|
||||
justify="between"
|
||||
align="center"
|
||||
gap={1}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: index === 0 ? undefined : '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<Text type="body" size="sm" maxLines={1}>
|
||||
{row.productname ?? `Product ${row.productid}`}
|
||||
</Text>
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}
|
||||
>
|
||||
{count(row.debit)} sold
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '11px 14px',
|
||||
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,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
1176
src/features/store-admin/pages/SalesPage.tsx
Normal file
1176
src/features/store-admin/pages/SalesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
566
src/features/store-admin/pages/TerminalsPage.tsx
Normal file
566
src/features/store-admin/pages/TerminalsPage.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, EyeOff, Pencil } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel } from '../format';
|
||||
import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||
import { BUCKET_COLOR, BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
||||
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
|
||||
|
||||
/**
|
||||
* Counters — the till board, written for the person who actually opens it.
|
||||
*
|
||||
* That person is a shop supervisor who knows the shop and nothing about
|
||||
* networks. The previous version of this page was accurate and unusable by
|
||||
* them: it led with a terminal code and a word like "Stale", put the money in
|
||||
* the smallest grey type at the bottom, and expected the reader to translate
|
||||
* severity into action.
|
||||
*
|
||||
* So: three plainly-named groups, one problem per card, the stake in the
|
||||
* headline, one instruction in bold, and every technical reading — version,
|
||||
* battery, storage, exact timestamps — behind a Details toggle, because that is
|
||||
* support information and support is not who is looking.
|
||||
*
|
||||
* A shop with nothing wrong sees a nearly empty page that says so. That is the
|
||||
* correct output, and it is the one the old design could not produce.
|
||||
*/
|
||||
export function TerminalsPage() {
|
||||
const { branches, scoped, selected, current } = useBranchScope();
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
const [showFine, setShowFine] = useState(false);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const labels = useCounterLabels();
|
||||
|
||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
||||
|
||||
const needs = board.now.length + board.look.length;
|
||||
const summary = useMemo(() => {
|
||||
if (board.isLoading && board.total === 0) return 'Checking your counters…';
|
||||
if (board.total === 0) return undefined;
|
||||
if (needs === 0) {
|
||||
return `All ${board.total} counter${board.total === 1 ? '' : 's'} are fine — everything they’ve sold has reached us. Each one is listed by the code printed on your receipts, until you rename it.`;
|
||||
}
|
||||
const stuck = board.strandedBills;
|
||||
const n = board.now.length > 0 ? board.now.length : needs;
|
||||
const head = `${n} counter${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention.`;
|
||||
const tail = ' Counters are listed by the code printed on your receipts, until you rename them.';
|
||||
return stuck > 0
|
||||
? `${head} ${stuck} sale${stuck === 1 ? '' : 's'} haven’t reached us yet.${tail}`
|
||||
: `${head}${tail}`;
|
||||
}, [board, needs]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Counters"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
{...(summary ? { description: summary } : {})}
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* A failed read is not an answer. Kept visually distinct from "this shop
|
||||
has no counters", because only one of the two means nothing is wrong. */}
|
||||
{board.failed.length > 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" padding={2}>
|
||||
<span style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 2 }}>
|
||||
<AlertTriangle size={16} />
|
||||
</span>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
We couldn’t check {board.failed.map(branchLabel).join(', ')}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
This is a problem reaching our own system, not a problem with your counters —
|
||||
they may be trading normally.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{board.now.length > 0 ? (
|
||||
<Group bucket="now" count={board.now.length}>
|
||||
{board.now.map((card) => (
|
||||
<ProblemCard key={card.key} card={card} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.look.length > 0 ? (
|
||||
<Group bucket="look" count={board.look.length}>
|
||||
{board.look.map((card) => (
|
||||
<ProblemCard key={card.key} card={card} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.fine.length > 0 ? (
|
||||
<Group
|
||||
bucket="fine"
|
||||
count={board.fine.length}
|
||||
isCollapsible
|
||||
isOpen={showFine}
|
||||
onToggle={() => setShowFine((open) => !open)}
|
||||
>
|
||||
{board.fine.map((counter) => (
|
||||
<FineRow key={counter.key} counter={counter} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.total === 0 && !board.isLoading ? <Empty checked={board.checked} /> : null}
|
||||
|
||||
{labels.hiddenCount > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((open) => !open)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
alignSelf: 'flex-start',
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
fontSize: 12.5,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{showHidden ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
Hidden counters ({labels.hiddenCount})
|
||||
</button>
|
||||
{showHidden ? <HiddenList labels={labels} /> : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Groups ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Group({
|
||||
bucket,
|
||||
count,
|
||||
children,
|
||||
isCollapsible,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
bucket: Bucket;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
isCollapsible?: boolean;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<HStack align="center" gap={1}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: BUCKET_COLOR[bucket],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
|
||||
>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{count}
|
||||
</Text>
|
||||
{isCollapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isOpen ? 'Hide' : 'Show'}
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</HStack>
|
||||
{!isCollapsible || isOpen ? <VStack gap={1.5}>{children}</VStack> : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The card ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One counter, one problem.
|
||||
*
|
||||
* Full width and stacked rather than in a grid: each card is a short paragraph
|
||||
* now, and a paragraph wants a line length. Three columns of prose is the
|
||||
* layout that made the last version unreadable.
|
||||
*/
|
||||
function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const { problem } = card;
|
||||
const rail = BUCKET_COLOR[problem.bucket];
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ display: 'flex', minWidth: 0 }}>
|
||||
<span aria-hidden style={{ width: 3, flex: 'none', background: rail }} />
|
||||
<VStack gap={1} padding={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<CounterName card={card} labels={labels} isRenaming={isRenaming} setIsRenaming={setIsRenaming} />
|
||||
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{problem.headline}
|
||||
</Text>
|
||||
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
{problem.detail}
|
||||
</Text>
|
||||
|
||||
{problem.action ? (
|
||||
<Text type="body" size="sm" weight="semibold" style={{ lineHeight: 1.6 }}>
|
||||
{problem.action}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* The reassurance. On a stuck-sales card this is the most important
|
||||
sentence on the page: it is the difference between a supervisor
|
||||
phoning head office and a supervisor checking a router. */}
|
||||
{problem.comfort ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
|
||||
{problem.comfort}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Everything else wrong with this counter at this urgency. One line
|
||||
each, under the instruction — the same walk fixes them, and three
|
||||
full cards with the same name at the top read as a broken page. */}
|
||||
{card.also.length > 0 ? (
|
||||
<VStack gap={0} style={{ paddingTop: 2 }}>
|
||||
{card.also.map((extra) => (
|
||||
<Text key={extra.code} type="body" size="xsm" color="secondary">
|
||||
Also: {extra.headline.toLowerCase()}
|
||||
{extra.action ? ` — ${extra.action.toLowerCase()}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
|
||||
<SmallButton
|
||||
label={showDetails ? 'Hide details' : 'Details'}
|
||||
onClick={() => setShowDetails((open) => !open)}
|
||||
/>
|
||||
<SmallButton label="Rename" icon={<Pencil size={11} />} onClick={() => setIsRenaming(true)} />
|
||||
{problem.code === 'never-used' || problem.code === 'no-status' ? (
|
||||
<SmallButton
|
||||
label="Hide this counter"
|
||||
icon={<EyeOff size={11} />}
|
||||
onClick={() => labels.hide(card.terminalId)}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{showDetails ? <Details card={card} /> : null}
|
||||
</VStack>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The counter's name, and the code underneath it.
|
||||
*
|
||||
* The code stays visible even after renaming, because `models/poshealth.go`
|
||||
* calls it "the short code printed on invoices — the thing a support call
|
||||
* starts with". Someone reading a receipt needs to match it to a card.
|
||||
*/
|
||||
function CounterName({
|
||||
card,
|
||||
labels,
|
||||
isRenaming,
|
||||
setIsRenaming,
|
||||
}: {
|
||||
card: BoardCard | HealthyCounter;
|
||||
labels: CounterLabels;
|
||||
isRenaming: boolean;
|
||||
setIsRenaming: (value: boolean) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => labels.nameFor(card.terminalId));
|
||||
const named = labels.isNamed(card.terminalId);
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Name this counter"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder="Counter 2, by the door…"
|
||||
/>
|
||||
</div>
|
||||
<SmallButton
|
||||
label="Save"
|
||||
icon={<Check size={11} />}
|
||||
onClick={() => {
|
||||
labels.rename(card.terminalId, draft);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
/>
|
||||
<SmallButton label="Cancel" onClick={() => setIsRenaming(false)} />
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{card.terminalId} is the code printed on your receipts. Saved on this browser only.
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{labels.nameFor(card.terminalId)}
|
||||
</Text>
|
||||
{named && card.terminalId !== UNASSIGNED ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
style={{ color: 'var(--color-ink-4)', fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{card.terminalId}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(card.branchName)}
|
||||
</Text>
|
||||
{/* The "printed on your receipts" hint is NOT here. It is true of every
|
||||
unnamed counter, so putting it on every card printed it ten times down
|
||||
one page and stopped being read on the second. It is said once, under
|
||||
the page title, and again where it is actually needed — inside Rename,
|
||||
where someone is deciding what to call this thing. */}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The support view.
|
||||
*
|
||||
* Everything the previous design put on the face of the card. It is all true
|
||||
* and all useful — to whoever eventually rings the supplier, which is not the
|
||||
* person deciding whether to walk to the counter.
|
||||
*/
|
||||
function Details({ card }: { card: BoardCard }) {
|
||||
const status = card.status;
|
||||
const rows: [string, string][] = [
|
||||
['Counter code', card.terminalId],
|
||||
['Sold in this period', `${card.periodBills} · ${rupees(card.periodAmount)}`],
|
||||
];
|
||||
|
||||
if (status) {
|
||||
rows.push(['Today on the till', `${status.todayBills} · ${rupees(status.todayAmount)}`]);
|
||||
rows.push(['Last sale', clockTime(status.lastBillAt)]);
|
||||
rows.push([
|
||||
'Last heard from',
|
||||
status.receivedAt
|
||||
? `${clockTime(status.receivedAt)} (${spokenAge(status.silentForMs ?? 0)} ago)`
|
||||
: 'never',
|
||||
]);
|
||||
rows.push(['Waiting to send', String(status.pendingBills)]);
|
||||
if (status.appVersion) rows.push(['Till app version', status.appVersion]);
|
||||
if (status.batteryLevel !== null) {
|
||||
rows.push([
|
||||
'Battery',
|
||||
`${status.batteryLevel}%${status.batteryCharging ? ' (charging)' : ''}`,
|
||||
]);
|
||||
}
|
||||
if (status.storageFreeMb !== null) rows.push(['Free space', `${status.storageFreeMb} MB`]);
|
||||
if (status.printerReachable !== null) {
|
||||
rows.push(['Printer', status.printerReachable ? 'responding' : 'not responding']);
|
||||
}
|
||||
if (status.drawerStatus) rows.push(['Cash drawer', status.drawerStatus]);
|
||||
if (status.clockDriftSeconds !== null) {
|
||||
rows.push(['Clock difference', `${status.clockDriftSeconds}s vs our server`]);
|
||||
}
|
||||
if (status.reason) rows.push(['Reported reason', status.reason]);
|
||||
} else {
|
||||
rows.push(['Status reporting', 'this counter has never reported']);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={0} style={{ paddingTop: 4 }}>
|
||||
{rows.map(([label, value]) => (
|
||||
<HStack key={label} justify="between" align="center" gap={2} style={{ padding: '4px 0' }}>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The fine list ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** A counter with nothing wrong gets one line. Anything more is noise. */
|
||||
function FineRow({ counter, labels }: { counter: HealthyCounter; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} padding={2} wrap="wrap">
|
||||
<CounterName
|
||||
card={counter}
|
||||
labels={labels}
|
||||
isRenaming={isRenaming}
|
||||
setIsRenaming={setIsRenaming}
|
||||
/>
|
||||
{!isRenaming ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{counter.periodBills} sales · {rupees(counter.periodAmount)}
|
||||
{counter.status.lastBillAt ? ` · last sale ${clockTime(counter.status.lastBillAt)}` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hidden ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function HiddenList({ labels }: { labels: CounterLabels }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
{labels.hidden.map((terminalId) => (
|
||||
<HStack key={terminalId} justify="between" align="center" gap={2}>
|
||||
<Text type="body" size="xsm" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{labels.nameFor(terminalId)}
|
||||
</Text>
|
||||
<SmallButton label="Bring back" onClick={() => labels.unhide(terminalId)} />
|
||||
</HStack>
|
||||
))}
|
||||
<div style={{ height: 1, background: 'var(--color-line)' }} />
|
||||
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
Hidden here only, on this browser. There’s no way to remove a counter from our system
|
||||
once it has been used, so this just keeps your list tidy.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Empty({ checked }: { checked: string[] }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No counter has reported yet
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
A counter appears here the first time it is used — nobody has to register one. So an
|
||||
empty list means no till has ever sent us anything, not that sales are missing.
|
||||
</Text>
|
||||
{checked.length > 0 ? (
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
Checked: {checked.map(branchLabel).join(', ')}.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bits ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function SmallButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
608
src/features/store-admin/pages/UsersPage.tsx
Normal file
608
src/features/store-admin/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
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 { Info, Monitor, Plus, UserCog, Users } from 'lucide-react';
|
||||
import type { PosUser, StaffInfo } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
usePosRoles,
|
||||
usePosUsersByBranch,
|
||||
useStaff,
|
||||
useStaffShifts,
|
||||
} from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchLabel } from '../format';
|
||||
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
|
||||
|
||||
type Group = 'store' | 'till';
|
||||
|
||||
/**
|
||||
* Users & access — the two account systems, side by side but never merged.
|
||||
*
|
||||
* A till account is NOT a Nearle Daily user. The backend excludes roles 7 and 8
|
||||
* from every application lookup *inside the query itself* — deliberately, so a
|
||||
* cashier is "not found" rather than "refused". Merging the two into one table
|
||||
* would list people in a directory they cannot sign in to, and would invite an
|
||||
* edit that moves someone between two systems that do not share a login.
|
||||
*
|
||||
* Neither group offers a delete. Both delete endpoints are hard deletes with no
|
||||
* cascade, and `status` already carries deactivation.
|
||||
*/
|
||||
export function UsersPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [group, setGroup] = useState<Group>('store');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [editingPerson, setEditingPerson] = useState<StaffInfo | 'new' | null>(null);
|
||||
const [editingTill, setEditingTill] = useState<{ row: PosUser | 'new'; locationid: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
|
||||
const staff = useStaff(tenantid || undefined);
|
||||
const posUserPages = usePosUsersByBranch(tenantid || undefined, branchIds);
|
||||
const roles = usePosRoles();
|
||||
const shifts = useStaffShifts(tenantid || undefined, scoped[0]?.locationid);
|
||||
|
||||
const staffRows = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase();
|
||||
return (staff.data ?? []).filter((person) => {
|
||||
if (roleFilter && person.rolename !== roleFilter) return false;
|
||||
if (selected !== null && person.locationid !== selected) return false;
|
||||
if (!term) return true;
|
||||
return `${person.fullname} ${person.email} ${person.contactno}`.toLowerCase().includes(term);
|
||||
});
|
||||
}, [staff.data, keyword, roleFilter, selected]);
|
||||
|
||||
const tillRows = useMemo(() => {
|
||||
const out: { person: PosUser; branch: string; locationid: number }[] = [];
|
||||
posUserPages.forEach((page, index) => {
|
||||
const locationid = branchIds[index];
|
||||
if (locationid === undefined) return;
|
||||
const branch = branches.find((entry) => entry.locationid === locationid);
|
||||
for (const person of page.data ?? []) {
|
||||
out.push({
|
||||
person,
|
||||
branch: branch?.locationname ?? `Branch ${locationid}`,
|
||||
locationid,
|
||||
});
|
||||
}
|
||||
});
|
||||
const term = keyword.trim().toLowerCase();
|
||||
return out.filter(({ person }) => {
|
||||
if (roleFilter && person.role?.toLowerCase() !== roleFilter.toLowerCase()) return false;
|
||||
if (!term) return true;
|
||||
return `${person.full_name} ${person.contactno}`.toLowerCase().includes(term);
|
||||
});
|
||||
}, [posUserPages, branchIds, branches, keyword, roleFilter]);
|
||||
|
||||
const staffRoles = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const person of staff.data ?? []) if (person.rolename) seen.add(person.rolename);
|
||||
return [...seen].sort();
|
||||
}, [staff.data]);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
group === 'store'
|
||||
? [
|
||||
{ value: '', label: 'All roles' },
|
||||
...staffRoles.map((name) => ({ value: name, label: name })),
|
||||
]
|
||||
: [
|
||||
{ value: '', label: 'All roles' },
|
||||
...(roles.data ?? []).map((role) => ({ value: role.role, label: role.label })),
|
||||
],
|
||||
[group, staffRoles, roles.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Users & access"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Who can sign in to the console, and who can open a till."
|
||||
actions={
|
||||
<Button
|
||||
label={group === 'store' ? 'Add person' : 'Add till account'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => {
|
||||
if (group === 'store') setEditingPerson('new');
|
||||
else {
|
||||
const locationid = selected ?? scoped[0]?.locationid;
|
||||
if (locationid) setEditingTill({ row: 'new', locationid });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Store users"
|
||||
icon={<UserCog size={14} />}
|
||||
badge={staff.data?.length || undefined}
|
||||
isActive={group === 'store'}
|
||||
onClick={() => {
|
||||
setGroup('store');
|
||||
setRoleFilter('');
|
||||
}}
|
||||
/>
|
||||
<TabButton
|
||||
label="Till accounts"
|
||||
icon={<Monitor size={14} />}
|
||||
badge={tillRows.length || undefined}
|
||||
isActive={group === 'till'}
|
||||
onClick={() => {
|
||||
setGroup('till');
|
||||
setRoleFilter('');
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={group === 'store' ? 'Back office' : 'Counter staff'}
|
||||
note={
|
||||
group === 'store'
|
||||
? 'signs in to this console'
|
||||
: 'signs in at a till — no console login'
|
||||
}
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 170 }}>
|
||||
<Selector
|
||||
label="Role"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={roleFilter}
|
||||
onChange={setRoleFilter}
|
||||
placeholder="All roles"
|
||||
options={roleOptions}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 220 }}>
|
||||
<TextInput
|
||||
label="Search people"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Name, email, mobile…"
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{group === 'store' ? (
|
||||
<StaffTable
|
||||
rows={staffRows}
|
||||
isLoading={staff.isLoading}
|
||||
showBranch={selected === null}
|
||||
branches={branches}
|
||||
onEdit={setEditingPerson}
|
||||
/>
|
||||
) : (
|
||||
<TillTable
|
||||
rows={tillRows}
|
||||
isLoading={posUserPages.some((page) => page.isLoading)}
|
||||
showBranch={selected === null}
|
||||
onEdit={(row, locationid) => setEditingTill({ row, locationid })}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Stated once, under whichever list is showing. */}
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{group === 'store' ? (
|
||||
<>
|
||||
There is no delete here. Removing an account is a hard delete with no cascade in
|
||||
Fiesta, so anything that account touched would be left pointing at nothing —
|
||||
deactivating keeps the history and stops the login. Passwords are not managed from
|
||||
this console either, because they are stored in clear; adding a person creates the
|
||||
account, and issuing its password is a backend job until that changes.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
A till account has no console login — the backend leaves roles 7 and 8 out of every
|
||||
application lookup, so a cashier is simply not found rather than refused. The PIN is
|
||||
shown once when the account is created and never read back; if it is lost, reissue
|
||||
rather than look it up.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{editingPerson ? (
|
||||
<PersonDrawer
|
||||
row={editingPerson === 'new' ? null : editingPerson}
|
||||
tenantid={tenantid}
|
||||
branches={branches}
|
||||
onClose={() => setEditingPerson(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{editingTill ? (
|
||||
<TillDrawer
|
||||
row={editingTill.row === 'new' ? null : editingTill.row}
|
||||
tenantid={tenantid}
|
||||
locationid={editingTill.locationid}
|
||||
roles={roles.data ?? []}
|
||||
shifts={shifts.data ?? []}
|
||||
onClose={() => setEditingTill(null)}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function StaffTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
branches,
|
||||
onEdit,
|
||||
}: {
|
||||
rows: StaffInfo[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
branches: { locationid: number; locationname: string }[];
|
||||
onEdit: (row: StaffInfo) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="the directory" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Users size={22} />}
|
||||
title="Nobody here"
|
||||
body="Nothing matches this role or search. Add a person to give them a console login."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Name</Th>
|
||||
<Th align="left">Role</Th>
|
||||
<Th align="left">Email</Th>
|
||||
<Th align="left">Mobile</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((person) => {
|
||||
const branch = branches.find((entry) => entry.locationid === person.locationid);
|
||||
return (
|
||||
<tr key={person.userid}>
|
||||
<Td align="left" isStrong>
|
||||
{person.fullname || `${person.firstname ?? ''} ${person.lastname ?? ''}`.trim() || '—'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
{/* Server-resolved. Never mapped from roleid here — most
|
||||
accounts carry an id that is absent from `app_roles`. */}
|
||||
<Chip label={person.rolename} tone="brand" />
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{person.email || '—'}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{person.contactno || '—'}
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(branch?.locationname ?? person.locationname)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left">
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TillTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
onEdit,
|
||||
}: {
|
||||
rows: { person: PosUser; branch: string; locationid: number }[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
onEdit: (row: PosUser, locationid: number) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="till accounts" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Monitor size={22} />}
|
||||
title="No till accounts"
|
||||
body="Create a Supervisor first — they open the till on the device, and the terminal appears on the Console board once it reports in."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Name</Th>
|
||||
<Th align="left">Role</Th>
|
||||
<Th align="left">Mobile</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Shift</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ person, branch, locationid }) => (
|
||||
<tr key={person.user_id}>
|
||||
<Td align="left" isStrong>
|
||||
{person.full_name || '—'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Chip
|
||||
label={person.role}
|
||||
tone={person.role?.toLowerCase() === 'supervisor' ? 'violet' : 'slate'}
|
||||
/>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{person.contactno || '—'}
|
||||
</span>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left" isMuted>
|
||||
{person.shift_name ? (
|
||||
<>
|
||||
<span style={{ display: 'block' }}>{person.shift_name}</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}>
|
||||
{person.shift_start}–{person.shift_end}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
'Any'
|
||||
)}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Button
|
||||
label="Edit"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(person, locationid)}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
brand: 'var(--color-brand)',
|
||||
violet: '#8b5cf6',
|
||||
slate: 'var(--color-ink-3)',
|
||||
};
|
||||
|
||||
function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof TONE }) {
|
||||
const colour = TONE[tone] ?? TONE.slate!;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: 999,
|
||||
padding: '2px 9px',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
whiteSpace: 'nowrap',
|
||||
color: colour,
|
||||
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
|
||||
border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{label || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Active is quiet, anything else is loud — an inactive account is the exception. */
|
||||
function StatusChip({ status }: { status: string | undefined }) {
|
||||
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
|
||||
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: isActive ? 'var(--color-ink-2)' : 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
|
||||
/>
|
||||
{isActive ? 'Active' : (status ?? 'Inactive')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: isActive ? '#fff' : 'var(--color-ink-3)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading({ what }: { what: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading {what}…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 12px',
|
||||
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,
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
294
src/features/store-admin/posStatus.test.ts
Normal file
294
src/features/store-admin/posStatus.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { PosSalesSummary, PosTerminalHealth } from '@/api/types';
|
||||
import {
|
||||
readTerminal,
|
||||
readTerminalId,
|
||||
STALE_THRESHOLD_MS,
|
||||
summariseBranch,
|
||||
UNASSIGNED,
|
||||
} from './posStatus';
|
||||
import { isClosed, problemsFor, type ProblemContext } from './terminalProblems';
|
||||
|
||||
/**
|
||||
* Fixtures built from the BACKEND's shapes, not from mine.
|
||||
*
|
||||
* The last round of tests passed while the code was wrong, because I wrote the
|
||||
* fixture from the same misreading as the implementation — both used
|
||||
* `terminal_id` on the sales split where Fiesta returns `terminalid`. A test
|
||||
* written from my own reading of an API only proves I was consistent with
|
||||
* myself. So the field names below are taken from the Go structs:
|
||||
* `models/poshealth.go`, `repositories/posPresence.go`, `models/posorder.go`.
|
||||
*/
|
||||
const NOW = Date.parse('2026-08-24T14:00:00.000Z');
|
||||
const ago = (ms: number) => new Date(NOW - ms).toISOString();
|
||||
|
||||
/** A live till. Every value is a string: Redis hashes have no other type. */
|
||||
const till = (overrides: PosTerminalHealth): PosTerminalHealth => ({
|
||||
terminal_id: 'T4A9',
|
||||
location_id: '1',
|
||||
store_name: 'R mart — RS Puram',
|
||||
app_version: '2.4.1',
|
||||
status: 'online',
|
||||
pending_bills: '0',
|
||||
pending_registrations: '0',
|
||||
today_bills: '12',
|
||||
today_amount: '4800',
|
||||
last_bill_at: ago(120_000),
|
||||
reported_at: ago(5_000),
|
||||
received_at: ago(5_000),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* The expired-key stub, exactly as `posPresence.go` synthesises it.
|
||||
* Four fields. Nothing else — that absence is the whole tell.
|
||||
*/
|
||||
const stub: PosTerminalHealth = {
|
||||
terminal_id: 'T5EDD',
|
||||
location_id: '1',
|
||||
status: 'offline',
|
||||
reason: 'no heartbeat within 90s',
|
||||
};
|
||||
|
||||
const context = (overrides: Partial<ProblemContext> = {}): ProblemContext => ({
|
||||
now: NOW,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const codes = (list: { code: string }[]) => list.map((problem) => problem.code);
|
||||
|
||||
/* ── Presence ────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a fresh till with an empty queue is online and synced', () => {
|
||||
const status = readTerminal(till({}), NOW);
|
||||
assert.equal(status.presence, 'online');
|
||||
assert.equal(status.state, 'synced');
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug this file exists to prevent coming back.
|
||||
*
|
||||
* Age must be measured from `received_at` — when the server stamped it — never
|
||||
* from `reported_at`, which the till writes about itself. A device an hour fast
|
||||
* reports a FUTURE timestamp; measured from the till's clock that computes a
|
||||
* negative age, clamps to zero, and renders "seen just now" on a counter nobody
|
||||
* has heard from since morning.
|
||||
*/
|
||||
test('a till with a fast clock cannot make itself look fresh', () => {
|
||||
const status = readTerminal(
|
||||
till({
|
||||
reported_at: new Date(NOW + 3_600_000).toISOString(), // an hour in the future
|
||||
received_at: ago(3_600_000), // actually last heard an hour ago
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(status.presence, 'stale', 'must not read as online');
|
||||
assert.equal(status.silentForMs, 3_600_000, 'age comes from the server stamp');
|
||||
assert.equal(status.clockDriftSeconds, 7200);
|
||||
});
|
||||
|
||||
test('a till just inside the stale threshold is still online', () => {
|
||||
const status = readTerminal(till({ received_at: ago(STALE_THRESHOLD_MS - 5_000) }), NOW);
|
||||
assert.equal(status.presence, 'online');
|
||||
});
|
||||
|
||||
test('a till that declared itself offline is offline, not vanished', () => {
|
||||
const status = readTerminal(till({ status: 'offline', reason: 'shift end' }), NOW);
|
||||
assert.equal(status.presence, 'offline');
|
||||
});
|
||||
|
||||
test('the four-field stub is no-heartbeat', () => {
|
||||
const status = readTerminal(stub, NOW);
|
||||
assert.equal(status.presence, 'no-heartbeat');
|
||||
});
|
||||
|
||||
/**
|
||||
* An MQTT Last Will merges over the previous heartbeat (`HSet`; only device
|
||||
* fields are `HDel`'d), so a till that lost power keeps its old timestamps.
|
||||
* That is a declared offline, not a vanished one — and testing for absent
|
||||
* heartbeat fields rather than the documented stub shape would confuse them.
|
||||
*/
|
||||
test('a Last Will record keeps its old timestamps and is still just offline', () => {
|
||||
const status = readTerminal(
|
||||
till({ status: 'offline', reason: 'connection lost', received_at: ago(90_000) }),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(status.presence, 'offline');
|
||||
});
|
||||
|
||||
test('device readings that were never collected stay null, never zero', () => {
|
||||
const status = readTerminal(till({}), NOW);
|
||||
assert.equal(status.batteryLevel, null);
|
||||
assert.equal(status.storageFreeMb, null);
|
||||
assert.equal(status.printerReachable, null);
|
||||
const reported = readTerminal(till({ battery_level: '0', printer_reachable: 'false' }), NOW);
|
||||
assert.equal(reported.batteryLevel, 0, 'a reported zero is a real reading');
|
||||
assert.equal(reported.printerReachable, false);
|
||||
});
|
||||
|
||||
/* ── The two spellings, and the nameless bucket ──────────────────────────── */
|
||||
|
||||
/**
|
||||
* Presence says `terminal_id`; sales says `terminalid`. Reading the sales split
|
||||
* with the presence spelling yields `undefined` for every row, which is exactly
|
||||
* what silently disabled the billed-but-absent reconciliation.
|
||||
*/
|
||||
test('the sales split spells it terminalid, without an underscore', () => {
|
||||
const summary: PosSalesSummary = {
|
||||
byterminal: [{ terminalid: 'T4A9', billcount: 46, amount: 18240 }],
|
||||
};
|
||||
const entry = summary.byterminal?.[0];
|
||||
assert.equal(readTerminalId(entry?.terminalid), 'T4A9');
|
||||
});
|
||||
|
||||
/**
|
||||
* `posSalesRepository.go` groups on `COALESCE(terminalid,'')`, so bills that
|
||||
* arrived before terminal codes were recorded collapse into one empty-id row.
|
||||
* A blank card title reads as a bug; a named bucket reads as the truth.
|
||||
*/
|
||||
test('bills with no terminal code get a named bucket', () => {
|
||||
assert.equal(readTerminalId(''), UNASSIGNED);
|
||||
assert.equal(readTerminalId(' '), UNASSIGNED);
|
||||
assert.equal(readTerminalId(undefined), UNASSIGNED);
|
||||
assert.equal(readTerminalId(' T4A9 '), 'T4A9');
|
||||
});
|
||||
|
||||
/* ── Roll-up ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a branch takes its worst till, and stale outranks delayed', () => {
|
||||
const summary = summariseBranch(
|
||||
[
|
||||
till({ terminal_id: 'A' }),
|
||||
till({ terminal_id: 'B', pending_bills: '9', oldest_pending_at: ago(20 * 60_000) }),
|
||||
till({ terminal_id: 'C', received_at: ago(60 * 60_000) }),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
assert.equal(summary.state, 'stale');
|
||||
assert.equal(summary.pendingBills, 9);
|
||||
});
|
||||
|
||||
test('the online count excludes stale tills', () => {
|
||||
const summary = summariseBranch(
|
||||
[till({ terminal_id: 'A' }), till({ terminal_id: 'B', received_at: ago(60 * 60_000) })],
|
||||
NOW,
|
||||
);
|
||||
assert.equal(summary.online, 1);
|
||||
assert.equal(summary.total, 2);
|
||||
});
|
||||
|
||||
test('an empty board is no-heartbeat, never all-synced', () => {
|
||||
assert.equal(summariseBranch([], NOW).state, 'no-heartbeat');
|
||||
});
|
||||
|
||||
/* ── Problems ────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a healthy selling till raises nothing at all', () => {
|
||||
assert.deepEqual(codes(problemsFor(readTerminal(till({}), NOW), context())), []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Money on a counter nobody can see. It absorbs the presence problem — a card
|
||||
* saying both "sales stuck" and "gone quiet" would be one card asking for the
|
||||
* same walk to the same counter, twice.
|
||||
*/
|
||||
test('an absent till holding sales raises one problem, not two', () => {
|
||||
const status = readTerminal(
|
||||
till({ status: 'offline', pending_bills: '41', oldest_pending_at: ago(3 * 3_600_000) }),
|
||||
NOW,
|
||||
);
|
||||
const list = problemsFor(status, context());
|
||||
assert.deepEqual(codes(list), ['money-stuck']);
|
||||
assert.equal(list[0]?.bucket, 'now');
|
||||
assert.ok(list[0]?.comfort?.includes('aren’t lost'), 'must carry the reassurance');
|
||||
});
|
||||
|
||||
/** Estimated from the till's own average sale, and never from no data. */
|
||||
test('the value estimate needs something to average over', () => {
|
||||
const withHistory = readTerminal(
|
||||
till({ status: 'offline', pending_bills: '10', today_bills: '10', today_amount: '5000' }),
|
||||
NOW,
|
||||
);
|
||||
assert.ok(
|
||||
problemsFor(withHistory, context())[0]?.detail.includes('₹5,000'),
|
||||
'ten pending at a ₹500 average is roughly ₹5,000',
|
||||
);
|
||||
|
||||
const noHistory = readTerminal(
|
||||
till({ status: 'offline', pending_bills: '10', today_bills: '0', today_amount: '0' }),
|
||||
NOW,
|
||||
);
|
||||
const detail = problemsFor(noHistory, context())[0]?.detail ?? '';
|
||||
assert.ok(!detail.includes('₹'), 'no average to work from means no figure invented');
|
||||
});
|
||||
|
||||
test('a counter dark during trading hours needs someone now', () => {
|
||||
const status = readTerminal(till({ status: 'offline' }), NOW);
|
||||
const [problem] = problemsFor(status, context());
|
||||
assert.equal(problem?.code, 'gone-quiet');
|
||||
assert.equal(problem?.bucket, 'now');
|
||||
});
|
||||
|
||||
test('the same counter outside trading hours is only worth a look', () => {
|
||||
const status = readTerminal(till({ status: 'offline' }), NOW);
|
||||
const hour = new Date(NOW).getHours();
|
||||
const shut = context({
|
||||
opentime: `${String((hour + 2) % 24).padStart(2, '0')}:00`,
|
||||
closetime: `${String((hour + 4) % 24).padStart(2, '0')}:00`,
|
||||
});
|
||||
const [problem] = problemsFor(status, shut);
|
||||
assert.equal(problem?.bucket, 'look');
|
||||
assert.equal(problem?.headline, 'Switched off');
|
||||
});
|
||||
|
||||
test('unparseable trading hours never silence an alert', () => {
|
||||
assert.equal(isClosed(context({ opentime: 'morning', closetime: 'late' })), false);
|
||||
assert.equal(isClosed(context({})), false);
|
||||
});
|
||||
|
||||
test('a shop trading past midnight has a wrapping open window', () => {
|
||||
const at = (hour: number) => Date.parse('2026-08-24T00:00:00') + hour * 3_600_000;
|
||||
const hours = { opentime: '18:00', closetime: '02:00' };
|
||||
assert.equal(isClosed({ now: at(20), ...hours }), false, '20:00 is inside');
|
||||
assert.equal(isClosed({ now: at(1), ...hours }), false, '01:00 is inside');
|
||||
assert.equal(isClosed({ now: at(12), ...hours }), true, 'midday is outside');
|
||||
});
|
||||
|
||||
test('a spare that never sold is not an incident', () => {
|
||||
const spare = readTerminal(
|
||||
till({ status: 'offline', today_bills: '0', last_bill_at: undefined }),
|
||||
NOW,
|
||||
);
|
||||
const list = problemsFor(spare, context());
|
||||
assert.deepEqual(codes(list), ['never-used']);
|
||||
assert.equal(list[0]?.bucket, 'look');
|
||||
});
|
||||
|
||||
test('a connected till that has rung nothing today says so', () => {
|
||||
const status = readTerminal(till({ today_bills: '0', last_bill_at: ago(20 * 3_600_000) }), NOW);
|
||||
assert.ok(codes(problemsFor(status, context())).includes('not-selling'));
|
||||
});
|
||||
|
||||
test('clock drift past the threshold is raised in plain words', () => {
|
||||
const status = readTerminal(till({ reported_at: ago(-600_000), received_at: ago(0) }), NOW);
|
||||
const problem = problemsFor(status, context()).find((p) => p.code === 'wrong-date');
|
||||
assert.ok(problem);
|
||||
assert.ok(problem.detail.includes('wrong day'));
|
||||
});
|
||||
|
||||
test('problems come back worst bucket first', () => {
|
||||
const status = readTerminal(
|
||||
till({
|
||||
status: 'offline',
|
||||
pending_bills: '9',
|
||||
oldest_pending_at: ago(20 * 60_000),
|
||||
printer_reachable: 'false',
|
||||
drawer_status: 'open',
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
const list = problemsFor(status, context());
|
||||
assert.equal(list[0]?.bucket, 'now');
|
||||
assert.equal(list.at(-1)?.bucket, 'look');
|
||||
});
|
||||
345
src/features/store-admin/posStatus.ts
Normal file
345
src/features/store-admin/posStatus.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import type { PosTerminalHealth } from '@/api/types';
|
||||
|
||||
/**
|
||||
* Till state, on two axes.
|
||||
*
|
||||
* PRESENCE — can we see this counter? online / stale / offline / no-heartbeat
|
||||
* SYNC — is its queue draining? synced / syncing / delayed
|
||||
*
|
||||
* Sync only means anything while the till is present: a queue we cannot watch
|
||||
* drain is not a queue that is draining. Presence wins on the badge.
|
||||
*
|
||||
* Every threshold below is derived from Fiesta's own constants rather than
|
||||
* chosen here — see `models/poshealth.go` and `repositories/posPresence.go`.
|
||||
*/
|
||||
export type PresenceState = 'online' | 'stale' | 'offline' | 'no-heartbeat';
|
||||
export type SyncState = 'synced' | 'syncing' | 'delayed';
|
||||
|
||||
/** What the badge shows: presence, unless the till is present, then sync. */
|
||||
export type TerminalState = SyncState | Exclude<PresenceState, 'online'>;
|
||||
|
||||
/** `models/poshealth.go`: "what a till reports about itself every 30 seconds". */
|
||||
export const HEARTBEAT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* `repositories/posPresence.go`: `posPresenceTTL = 90 * time.Second`.
|
||||
*
|
||||
* The comment there is the reasoning: "Three missed heartbeats. Two would make
|
||||
* an ordinary GPRS hiccup look like a dead till; five would take two and a half
|
||||
* minutes to notice a real one."
|
||||
*/
|
||||
export const PRESENCE_TTL_MS = 3 * HEARTBEAT_MS;
|
||||
|
||||
/**
|
||||
* How old a reading may be before the counter counts as stale.
|
||||
*
|
||||
* One missed heartbeat plus one poll interval of our own. Below that a live
|
||||
* till simply has no fresh reading yet and is not in trouble; above it, we have
|
||||
* genuinely missed a beat. It sits under {@link PRESENCE_TTL_MS} on purpose, so
|
||||
* "stale" is the warning that precedes the key expiring rather than a synonym
|
||||
* for it.
|
||||
*/
|
||||
export const STALE_THRESHOLD_MS = HEARTBEAT_MS * 2;
|
||||
|
||||
/** How long a bill may sit unsent before the queue counts as delayed. */
|
||||
export const DELAY_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How far a till's clock may disagree with the server's before it matters.
|
||||
*
|
||||
* `models/poshealth.go` on why this is surfaced at all: "a till whose clock is
|
||||
* wrong writes bills under the wrong business date."
|
||||
*/
|
||||
export const CLOCK_DRIFT_THRESHOLD_S = 120;
|
||||
|
||||
/** Bills that arrived with no terminal code at all. See `readTerminalId`. */
|
||||
export const UNASSIGNED = 'Unassigned';
|
||||
|
||||
export interface TerminalStatus {
|
||||
terminalId: string;
|
||||
storeName: string;
|
||||
presence: PresenceState;
|
||||
sync: SyncState;
|
||||
state: TerminalState;
|
||||
pendingBills: number;
|
||||
/** How long the oldest unsent bill has been waiting, in ms. 0 when none. */
|
||||
oldestPendingMs: number;
|
||||
oldestPendingAt: Date | null;
|
||||
/**
|
||||
* How long since the SERVER last stamped a reading from this till.
|
||||
*
|
||||
* Measured from `received_at`, never `reported_at` — see {@link readTerminal}.
|
||||
*/
|
||||
silentForMs: number | null;
|
||||
todayBills: number;
|
||||
todayAmount: number;
|
||||
lastBillAt: Date | null;
|
||||
reportedAt: Date | null;
|
||||
receivedAt: Date | null;
|
||||
/** Positive: the till's clock runs ahead of the server's. null when unknowable. */
|
||||
clockDriftSeconds: number | null;
|
||||
reason: string;
|
||||
appVersion: string;
|
||||
batteryLevel: number | null;
|
||||
batteryCharging: boolean | null;
|
||||
storageFreeMb: number | null;
|
||||
printerReachable: boolean | null;
|
||||
drawerStatus: string;
|
||||
}
|
||||
|
||||
const num = (value: string | undefined): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nullable device readings.
|
||||
*
|
||||
* `models/poshealth.go` makes these pointers deliberately: "writing an
|
||||
* uncollected reading as 0 would show a board full of terminals on a flat
|
||||
* battery with an unreachable printer. A nil field is skipped entirely." So
|
||||
* absent must stay absent here too, never coerced.
|
||||
*/
|
||||
const maybeNum = (value: string | undefined): number | null => {
|
||||
if (value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const maybeBool = (value: string | undefined): boolean | null => {
|
||||
if (value === undefined || value === '') return null;
|
||||
return value === 'true' || value === '1';
|
||||
};
|
||||
|
||||
const date = (value: string | undefined): Date | null => {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* A terminal code, or the named bucket for bills that carry none.
|
||||
*
|
||||
* `posSalesRepository.go` groups on `COALESCE(terminalid,'')`, so every bill
|
||||
* that arrived before terminal codes were recorded collapses into one row with
|
||||
* an empty id. Rendering that as a blank card title reads as a bug; naming it
|
||||
* reads as what it is. The old console does the same.
|
||||
*/
|
||||
export function readTerminalId(raw: string | undefined): string {
|
||||
const trimmed = (raw ?? '').trim();
|
||||
return trimmed === '' ? UNASSIGNED : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this the expired-key stub rather than a real record?
|
||||
*
|
||||
* `posPresence.go` synthesises exactly four fields when a presence key has
|
||||
* expired — `terminal_id`, `location_id`, `status: "offline"`, `reason` — and
|
||||
* nothing else. Testing for that documented shape rather than for absent
|
||||
* heartbeat fields matters, because an MQTT Last Will record MERGES over the
|
||||
* previous heartbeat (`HSet`; only device fields are `HDel`'d), so a till that
|
||||
* lost power keeps stale timestamps from before and would otherwise be
|
||||
* mistaken for a vanished one.
|
||||
*/
|
||||
function isExpiredStub(raw: PosTerminalHealth): boolean {
|
||||
return raw.status === 'offline' && (raw.reason ?? '') !== '' && !raw.received_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one till's reported hash into something a screen can render.
|
||||
*
|
||||
* Age is computed from `received_at` — when the SERVER stamped the reading —
|
||||
* and never from `reported_at`, which the till writes about itself. The old
|
||||
* console is explicit about why: "a till with a wrong clock must not be able to
|
||||
* make itself look fresh." A device an hour fast would otherwise report a
|
||||
* future timestamp, compute a negative age, and render as freshly seen while
|
||||
* nobody had heard from it since morning.
|
||||
*
|
||||
* `now` is a parameter rather than a call to `Date.now()` so a whole board is
|
||||
* derived against a single instant.
|
||||
*/
|
||||
export function readTerminal(raw: PosTerminalHealth, now: number): TerminalStatus {
|
||||
const pendingBills = num(raw.pending_bills) + num(raw.pending_registrations);
|
||||
const oldestPendingAt = date(raw.oldest_pending_at);
|
||||
const oldestPendingMs = oldestPendingAt
|
||||
? Math.max(0, now - oldestPendingAt.getTime())
|
||||
: 0;
|
||||
|
||||
const reportedAt = date(raw.reported_at);
|
||||
const receivedAt = date(raw.received_at);
|
||||
const silentForMs = receivedAt ? Math.max(0, now - receivedAt.getTime()) : null;
|
||||
|
||||
let presence: PresenceState;
|
||||
if (isExpiredStub(raw)) presence = 'no-heartbeat';
|
||||
else if (raw.status !== 'online') presence = 'offline';
|
||||
else if (silentForMs === null || silentForMs > STALE_THRESHOLD_MS) presence = 'stale';
|
||||
else presence = 'online';
|
||||
|
||||
let sync: SyncState;
|
||||
if (pendingBills === 0) sync = 'synced';
|
||||
else if (oldestPendingMs > DELAY_THRESHOLD_MS) sync = 'delayed';
|
||||
else sync = 'syncing';
|
||||
|
||||
// Both timestamps must be present for the subtraction to mean anything — an
|
||||
// absent `received_at` is not a drift of zero.
|
||||
const clockDriftSeconds =
|
||||
reportedAt && receivedAt
|
||||
? Math.round((reportedAt.getTime() - receivedAt.getTime()) / 1000)
|
||||
: null;
|
||||
|
||||
return {
|
||||
terminalId: readTerminalId(raw.terminal_id),
|
||||
storeName: raw.store_name ?? '',
|
||||
presence,
|
||||
sync,
|
||||
state: presence === 'online' ? sync : presence,
|
||||
pendingBills,
|
||||
oldestPendingMs,
|
||||
oldestPendingAt,
|
||||
silentForMs,
|
||||
todayBills: num(raw.today_bills),
|
||||
todayAmount: num(raw.today_amount),
|
||||
lastBillAt: date(raw.last_bill_at),
|
||||
reportedAt,
|
||||
receivedAt,
|
||||
clockDriftSeconds,
|
||||
reason: raw.reason ?? '',
|
||||
appVersion: raw.app_version ?? '',
|
||||
batteryLevel: maybeNum(raw.battery_level),
|
||||
batteryCharging: maybeBool(raw.battery_charging),
|
||||
storageFreeMb: maybeNum(raw.storage_free_mb),
|
||||
printerReachable: maybeBool(raw.printer_reachable),
|
||||
drawerStatus: raw.drawer_status ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export interface BranchSyncSummary {
|
||||
state: TerminalState;
|
||||
online: number;
|
||||
total: number;
|
||||
pendingBills: number;
|
||||
oldestPendingMs: number;
|
||||
lastBillAt: Date | null;
|
||||
terminals: TerminalStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity order, worst last.
|
||||
*
|
||||
* `stale` outranks `delayed` deliberately. A delayed queue is a known quantity —
|
||||
* we can see it, and we know how much is in it. A stale till is an unknown one:
|
||||
* its last word was "fine", and everything since is guesswork.
|
||||
*/
|
||||
const RANK: Record<TerminalState, number> = {
|
||||
synced: 0,
|
||||
syncing: 1,
|
||||
delayed: 2,
|
||||
stale: 3,
|
||||
offline: 4,
|
||||
'no-heartbeat': 5,
|
||||
};
|
||||
|
||||
export function rankOf(state: TerminalState): number {
|
||||
return RANK[state];
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a branch's tills into one status.
|
||||
*
|
||||
* The branch takes its WORST till's state, not an average. A shop with four
|
||||
* healthy counters and one stranded for an hour is a shop with a problem, and
|
||||
* averaging is exactly how that hour goes unnoticed.
|
||||
*/
|
||||
export function summariseBranch(raw: PosTerminalHealth[], now: number): BranchSyncSummary {
|
||||
const terminals = raw.map((entry) => readTerminal(entry, now));
|
||||
|
||||
const worst = terminals.reduce<TerminalState>(
|
||||
(acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc),
|
||||
'synced',
|
||||
);
|
||||
|
||||
const lastBillAt = terminals.reduce<Date | null>((acc, terminal) => {
|
||||
if (!terminal.lastBillAt) return acc;
|
||||
return !acc || terminal.lastBillAt > acc ? terminal.lastBillAt : acc;
|
||||
}, null);
|
||||
|
||||
return {
|
||||
state: terminals.length === 0 ? 'no-heartbeat' : worst,
|
||||
// Counts only tills we can currently see. A stale till is not online —
|
||||
// counting it there is how 3/3 stays green through an outage. Fiesta's own
|
||||
// `online` figure counts `status == "online"`, which cannot tell a fresh
|
||||
// heartbeat from one about to expire, so it is recomputed here.
|
||||
online: terminals.filter((terminal) => terminal.presence === 'online').length,
|
||||
total: terminals.length,
|
||||
pendingBills: terminals.reduce((sum, terminal) => sum + terminal.pendingBills, 0),
|
||||
oldestPendingMs: terminals.reduce(
|
||||
(max, terminal) => Math.max(max, terminal.oldestPendingMs),
|
||||
0,
|
||||
),
|
||||
lastBillAt,
|
||||
terminals,
|
||||
};
|
||||
}
|
||||
|
||||
/** "4m", "2h" — short enough to sit in a table cell. Zero reads as an em-dash. */
|
||||
export function shortAge(ms: number): string {
|
||||
if (ms <= 0) return '—';
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A duration in words, for a sentence rather than a cell.
|
||||
*
|
||||
* "about 3 hours", not "3h". The board reads as prose now, and an abbreviation
|
||||
* in the middle of a sentence is a small stumble for someone who does not read
|
||||
* dashboards for a living.
|
||||
*/
|
||||
export function spokenAge(ms: number): string {
|
||||
const mins = Math.round(ms / 60000);
|
||||
if (mins < 1) return 'less than a minute';
|
||||
if (mins === 1) return 'a minute';
|
||||
if (mins < 60) return `${mins} minutes`;
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours === 1) return 'about an hour';
|
||||
if (hours < 24) return `about ${hours} hours`;
|
||||
const days = Math.round(hours / 24);
|
||||
return days === 1 ? 'about a day' : `about ${days} days`;
|
||||
}
|
||||
|
||||
/** "14:32" — a wall-clock time a person can compare against their own day. */
|
||||
export function clockTime(value: Date | null): string {
|
||||
if (!value) return '—';
|
||||
return `${String(value.getHours()).padStart(2, '0')}:${String(value.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export const SYNC_LABEL: Record<TerminalState, string> = {
|
||||
synced: 'Synced',
|
||||
syncing: 'Syncing',
|
||||
delayed: 'Delayed',
|
||||
stale: 'Stale',
|
||||
offline: 'Offline',
|
||||
'no-heartbeat': 'No heartbeat',
|
||||
};
|
||||
|
||||
export const SYNC_TONE: Record<TerminalState, 'success' | 'info' | 'warning' | 'error'> = {
|
||||
synced: 'success',
|
||||
syncing: 'info',
|
||||
delayed: 'error',
|
||||
stale: 'warning',
|
||||
offline: 'warning',
|
||||
'no-heartbeat': 'error',
|
||||
};
|
||||
|
||||
export const SYNC_COLOR: Record<TerminalState, string> = {
|
||||
synced: 'var(--color-success, #10b981)',
|
||||
syncing: 'var(--color-brand)',
|
||||
delayed: 'var(--color-error, #d64545)',
|
||||
stale: 'var(--color-warning, #b7860b)',
|
||||
offline: 'var(--color-warning, #b7860b)',
|
||||
'no-heartbeat': 'var(--color-error, #d64545)',
|
||||
};
|
||||
318
src/features/store-admin/terminalProblems.ts
Normal file
318
src/features/store-admin/terminalProblems.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
CLOCK_DRIFT_THRESHOLD_S,
|
||||
clockTime,
|
||||
spokenAge,
|
||||
type TerminalStatus,
|
||||
} from './posStatus';
|
||||
|
||||
/**
|
||||
* What a counter has to say, in words a shopkeeper uses.
|
||||
*
|
||||
* This replaces a severity ladder — critical/warning/info over codes like
|
||||
* `queue-stranded` and `clock-drift`. That vocabulary is borrowed from
|
||||
* monitoring tools and means nothing to the person who actually opens this
|
||||
* screen: a supervisor who knows the shop and nothing about networks. "Stale"
|
||||
* is not a word about a cash register.
|
||||
*
|
||||
* So the model is now: a counter produces zero or more PROBLEMS, each of which
|
||||
* is one plain sentence, one bucket, and one instruction. One problem, one
|
||||
* card. A counter with two problems appears twice — which is the point, because
|
||||
* a card carrying two instructions carries neither.
|
||||
*/
|
||||
export type Bucket = 'now' | 'look' | 'fine';
|
||||
|
||||
export type ProblemCode =
|
||||
| 'money-stuck'
|
||||
| 'gone-quiet'
|
||||
| 'not-selling'
|
||||
| 'printer-down'
|
||||
| 'wrong-date'
|
||||
| 'storage-low'
|
||||
| 'battery-low'
|
||||
| 'drawer-open'
|
||||
| 'never-used'
|
||||
| 'no-status';
|
||||
|
||||
export interface Problem {
|
||||
code: ProblemCode;
|
||||
bucket: Bucket;
|
||||
/** The line in large type. Leads with what is at stake, not what is broken. */
|
||||
headline: string;
|
||||
/** One sentence of what is actually true. */
|
||||
detail: string;
|
||||
/** What a person should do. Rendered in bold; absent when there is nothing. */
|
||||
action?: string;
|
||||
/** A reassurance, when the honest answer is "this looks worse than it is". */
|
||||
comfort?: string;
|
||||
/** How long it has been true, in ms. null when we cannot know. */
|
||||
forMs: number | null;
|
||||
}
|
||||
|
||||
export const BUCKET_LABEL: Record<Bucket, string> = {
|
||||
now: 'Needs someone now',
|
||||
look: 'Worth a look',
|
||||
fine: 'All fine',
|
||||
};
|
||||
|
||||
export const BUCKET_RANK: Record<Bucket, number> = { fine: 0, look: 1, now: 2 };
|
||||
|
||||
export const BUCKET_COLOR: Record<Bucket, string> = {
|
||||
now: 'var(--color-error, #d64545)',
|
||||
look: 'var(--color-warning, #b7860b)',
|
||||
fine: 'var(--color-success, #10b981)',
|
||||
};
|
||||
|
||||
export interface ProblemContext {
|
||||
/** Evaluation instant, so a whole board is judged against one clock. */
|
||||
now: number;
|
||||
/** The branch's trading hours, if known — `"09:00"` / `"21:30"`. */
|
||||
opentime?: string;
|
||||
closetime?: string;
|
||||
/** Counters confirmed stale on the PREVIOUS read. See the `gone-quiet` note. */
|
||||
wasStale?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the branch shut right now?
|
||||
*
|
||||
* Anything unparseable answers "open", on purpose. This flag only ever
|
||||
* DOWNGRADES a problem, so failing to parse keeps the alert at full strength —
|
||||
* the safe direction to be wrong in.
|
||||
*/
|
||||
export function isClosed(context: ProblemContext): boolean {
|
||||
const open = parseClock(context.opentime);
|
||||
const close = parseClock(context.closetime);
|
||||
if (open === null || close === null) return false;
|
||||
|
||||
const at = new Date(context.now);
|
||||
const mins = at.getHours() * 60 + at.getMinutes();
|
||||
// A close time earlier than the open time means the shop trades past
|
||||
// midnight, so the open window wraps rather than being empty.
|
||||
return close < open ? mins >= close && mins < open : mins < open || mins >= close;
|
||||
}
|
||||
|
||||
function parseClock(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const mins = Number(match[2]);
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(mins)) return null;
|
||||
if (hours > 23 || mins > 59) return null;
|
||||
return hours * 60 + mins;
|
||||
}
|
||||
|
||||
/** ₹ with Indian grouping and no decimals. Whole rupees are what people say. */
|
||||
export function rupees(amount: number): string {
|
||||
return `₹${Math.round(amount).toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roughly what the unsent bills are worth.
|
||||
*
|
||||
* The health payload carries a COUNT of pending bills and no value — Fiesta
|
||||
* simply does not report one. But "41 sales haven't reached us" gives a
|
||||
* supervisor no sense of whether that is four thousand rupees or four hundred
|
||||
* thousand, and the difference decides whether they walk over now.
|
||||
*
|
||||
* So this estimates from the till's OWN average sale today, and every caller
|
||||
* must render it with the word "roughly" attached. It returns null rather than
|
||||
* guessing when the till has sold nothing today to average over — an estimate
|
||||
* from no data is a fabrication, not an estimate.
|
||||
*/
|
||||
export function estimatePendingValue(terminal: TerminalStatus): number | null {
|
||||
if (terminal.todayBills <= 0 || terminal.todayAmount <= 0) return null;
|
||||
if (terminal.pendingBills <= 0) return null;
|
||||
return (terminal.todayAmount / terminal.todayBills) * terminal.pendingBills;
|
||||
}
|
||||
|
||||
const sales = (n: number) => `${n} sale${n === 1 ? '' : 's'}`;
|
||||
|
||||
/**
|
||||
* Everything one counter has to say, worst first.
|
||||
*/
|
||||
export function problemsFor(
|
||||
terminal: TerminalStatus,
|
||||
context: ProblemContext,
|
||||
): Problem[] {
|
||||
const problems: Problem[] = [];
|
||||
const closed = isClosed(context);
|
||||
const absent = terminal.presence !== 'online';
|
||||
const neverSold = terminal.todayBills === 0 && terminal.lastBillAt === null;
|
||||
const pending = terminal.pendingBills;
|
||||
|
||||
/**
|
||||
* Money on a counter nobody can see.
|
||||
*
|
||||
* The single worst thing this board reports, and the reason it exists. From
|
||||
* `models/poshealth.go`: "A shop quietly accumulating unsynced takings looks
|
||||
* completely normal from the shop floor, and this is the only thing that
|
||||
* makes it visible before someone reconciles a till and finds a day missing."
|
||||
*
|
||||
* It absorbs the presence problem — a card that said both "money stuck" and
|
||||
* "gone quiet" would be one card asking for the same walk to the same
|
||||
* counter, twice.
|
||||
*/
|
||||
if (absent && pending > 0) {
|
||||
const worth = estimatePendingValue(terminal);
|
||||
problems.push({
|
||||
code: 'money-stuck',
|
||||
bucket: 'now',
|
||||
headline: `${sales(pending)} haven’t reached us`,
|
||||
detail: worth
|
||||
? `Roughly ${rupees(worth)}, judging by this counter’s average sale today. The oldest has been waiting since ${clockTime(terminal.oldestPendingAt)}, and we can’t currently see this counter to watch them send.`
|
||||
: `The oldest has been waiting since ${clockTime(terminal.oldestPendingAt)}, and we can’t currently see this counter to watch them send.`,
|
||||
action: 'Check the counter is switched on and its internet is working.',
|
||||
// The most important sentence on the page. It is the difference between
|
||||
// a supervisor phoning head office and a supervisor checking a router.
|
||||
comfort:
|
||||
'The sales aren’t lost — the till keeps its own copy and sends them as soon as it reconnects.',
|
||||
forMs: terminal.oldestPendingMs || terminal.silentForMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark, with nothing held.
|
||||
*
|
||||
* Urgent only when the shop is open and this counter has traded before.
|
||||
* Outside trading hours it is a shop that shut; on a counter that has never
|
||||
* sold it is almost certainly a spare, and `never-used` says so better.
|
||||
*/
|
||||
if (absent && pending === 0 && !neverSold) {
|
||||
const urgent = !closed;
|
||||
problems.push({
|
||||
code: 'gone-quiet',
|
||||
bucket: urgent ? 'now' : 'look',
|
||||
headline: closed ? 'Switched off' : 'Gone quiet',
|
||||
detail: closed
|
||||
? `We haven’t heard from this counter since ${clockTime(terminal.receivedAt)}. The shop is outside its opening hours, so this is probably just closing up.`
|
||||
: terminal.silentForMs === null
|
||||
? 'We can’t see this counter at all, and the shop is open.'
|
||||
: `We haven’t heard from it for ${spokenAge(terminal.silentForMs)}, and the shop is open.`,
|
||||
...(urgent
|
||||
? {
|
||||
action:
|
||||
'Check it’s switched on and connected. If it’s been turned off on purpose, you can ignore this.',
|
||||
}
|
||||
: {}),
|
||||
forMs: terminal.silentForMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaching us, but nothing rung.
|
||||
*
|
||||
* `models/poshealth.go` again: "A till that is connected but has rung nothing
|
||||
* in three hours usually means a jammed printer or an absent cashier, and
|
||||
* neither shows up in a plain online/offline board."
|
||||
*/
|
||||
if (!absent && !closed && terminal.todayBills === 0 && terminal.lastBillAt !== null) {
|
||||
problems.push({
|
||||
code: 'not-selling',
|
||||
bucket: 'look',
|
||||
headline: 'Hasn’t sold anything today',
|
||||
detail: `It’s switched on and reaching us, but nothing has been rung up. Its last sale was ${clockTime(terminal.lastBillAt)}.`,
|
||||
action: 'Usually a jammed printer, or nobody on the counter.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (terminal.printerReachable === false) {
|
||||
problems.push({
|
||||
code: 'printer-down',
|
||||
bucket: 'look',
|
||||
headline: 'Printer isn’t responding',
|
||||
detail: 'Staff can still ring up sales on this counter, but it can’t print a receipt.',
|
||||
action: 'Check the printer is switched on, connected and has paper.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
terminal.clockDriftSeconds !== null &&
|
||||
Math.abs(terminal.clockDriftSeconds) > CLOCK_DRIFT_THRESHOLD_S
|
||||
) {
|
||||
const drift = Math.abs(terminal.clockDriftSeconds) * 1000;
|
||||
problems.push({
|
||||
code: 'wrong-date',
|
||||
bucket: 'look',
|
||||
headline: 'This counter’s clock is wrong',
|
||||
detail: `It’s set ${spokenAge(drift)} ${terminal.clockDriftSeconds > 0 ? 'ahead of' : 'behind'} the real time. Sales rung here can be recorded against the wrong day, which shows up later as a day that doesn’t add up.`,
|
||||
action: 'Set the device’s date and time to update automatically.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (terminal.storageFreeMb !== null && terminal.storageFreeMb < 200) {
|
||||
problems.push({
|
||||
code: 'storage-low',
|
||||
bucket: 'look',
|
||||
headline: 'Running out of space',
|
||||
detail: `Only ${terminal.storageFreeMb} MB left. If this counter goes offline it needs room to hold sales until it reconnects.`,
|
||||
action: 'Ask whoever set the device up to clear some space on it.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (terminal.batteryLevel !== null && terminal.batteryLevel < 20 && !terminal.batteryCharging) {
|
||||
problems.push({
|
||||
code: 'battery-low',
|
||||
bucket: 'look',
|
||||
headline: `Battery at ${terminal.batteryLevel}%`,
|
||||
detail: 'It isn’t charging, so this counter will shut down on its own before long.',
|
||||
action: 'Plug it in.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (terminal.drawerStatus === 'open') {
|
||||
problems.push({
|
||||
code: 'drawer-open',
|
||||
bucket: 'look',
|
||||
headline: 'Cash drawer is open',
|
||||
detail: 'This counter is reporting its cash drawer as open.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Never rung a sale.
|
||||
*
|
||||
* Said out loud rather than left silent. From the old console: "almost always
|
||||
* a commissioning probe or a till set up and never put into service — worth
|
||||
* saying, because 'offline' on such a terminal is not an incident." A board
|
||||
* that shows spares as permanent emergencies is a board people stop reading.
|
||||
*/
|
||||
if (neverSold) {
|
||||
problems.push({
|
||||
code: 'never-used',
|
||||
bucket: 'look',
|
||||
headline: 'Never used',
|
||||
detail:
|
||||
'This counter has never rung a sale. It’s most likely a spare, or one that was set up and never put into service.',
|
||||
action: 'If you don’t use it, hide it to keep this list tidy.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
return problems.sort((a, b) => BUCKET_RANK[b.bucket] - BUCKET_RANK[a.bucket]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The problem for a counter that sold but reports no status at all.
|
||||
*
|
||||
* These exist only in the sales figures — there is no presence record to build
|
||||
* a card from, so without this they are invisible on the board while sitting
|
||||
* plainly in the day's takings.
|
||||
*/
|
||||
export function noStatusProblem(bills: number, amount: number): Problem {
|
||||
return {
|
||||
code: 'no-status',
|
||||
bucket: 'look',
|
||||
headline: 'Not reporting its status',
|
||||
detail: `This counter sold ${rupees(amount)} across ${sales(bills)}, but it doesn’t tell us whether it’s switched on or how it’s doing.`,
|
||||
action: 'Usually an older version of the till app. Worth having it updated.',
|
||||
comfort: 'Those sales are safely recorded — this is only about status.',
|
||||
forMs: null,
|
||||
};
|
||||
}
|
||||
272
src/features/store-admin/useTerminalBoard.ts
Normal file
272
src/features/store-admin/useTerminalBoard.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
import { usePosHealthByBranch, usePosSalesByBranch } from '@/queries/hooks';
|
||||
import { readTerminal, readTerminalId, type TerminalStatus } from './posStatus';
|
||||
import {
|
||||
BUCKET_RANK,
|
||||
noStatusProblem,
|
||||
problemsFor,
|
||||
type Bucket,
|
||||
type Problem,
|
||||
type ProblemContext,
|
||||
} from './terminalProblems';
|
||||
|
||||
/**
|
||||
* One card on the board: a COUNTER, within one bucket.
|
||||
*
|
||||
* The split is by urgency, not by problem. A till that is out of paper AND low
|
||||
* on space AND on a flat battery is one counter someone walks to once — three
|
||||
* consecutive cards with the same name at the top read as a fault in the page,
|
||||
* not as three things to do.
|
||||
*
|
||||
* But a counter that is BOTH holding unsent sales and low on storage appears
|
||||
* twice, once in each bucket, because those are genuinely different errands
|
||||
* with different urgency. `problem` is the one that decides the headline and
|
||||
* the instruction; `also` is everything else in the same bucket, listed short.
|
||||
*/
|
||||
export interface BoardCard {
|
||||
key: string;
|
||||
branchId: number;
|
||||
branchName: string;
|
||||
terminalId: string;
|
||||
problem: Problem;
|
||||
also: Problem[];
|
||||
status: TerminalStatus | null;
|
||||
/** Trading in the selected period, from the sales split. */
|
||||
periodBills: number;
|
||||
periodAmount: number;
|
||||
}
|
||||
|
||||
/** A counter with nothing wrong. One line, no card. */
|
||||
export interface HealthyCounter {
|
||||
key: string;
|
||||
branchId: number;
|
||||
branchName: string;
|
||||
terminalId: string;
|
||||
status: TerminalStatus;
|
||||
periodBills: number;
|
||||
periodAmount: number;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
now: BoardCard[];
|
||||
look: BoardCard[];
|
||||
fine: HealthyCounter[];
|
||||
/** Every counter id in scope, including hidden ones. */
|
||||
total: number;
|
||||
/**
|
||||
* Sales sitting on counters we CANNOT currently see.
|
||||
*
|
||||
* Not every unsent sale. A till that is online with three bills in flight is
|
||||
* sending them right now and will be done before anyone reads this; counting
|
||||
* those in the page headline turns a normal second into an alarm, and a
|
||||
* headline that cries wolf is the one thing this page cannot afford.
|
||||
*/
|
||||
strandedBills: number;
|
||||
isLoading: boolean;
|
||||
/** Branches whose health read FAILED — not branches with no counters. */
|
||||
failed: string[];
|
||||
/** Names of every branch actually asked about, for the empty state. */
|
||||
checked: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember which counters were stale on the PREVIOUS read.
|
||||
*
|
||||
* Hysteresis. The till heartbeats every 30s, so one on a weak connection
|
||||
* crosses the staleness line and comes back on alternate reads. Requiring two
|
||||
* consecutive misses costs one refresh of latency and buys a board that does
|
||||
* not flicker — and a board that flickers is one people learn to ignore, which
|
||||
* is the failure mode that actually matters.
|
||||
*
|
||||
* Keyed on the query's `dataUpdatedAt` rather than the data, which gets a fresh
|
||||
* identity each poll whether or not anything changed.
|
||||
*/
|
||||
function usePreviousStale(current: ReadonlySet<string>, updatedAt: number): ReadonlySet<string> {
|
||||
const previous = useRef<ReadonlySet<string>>(new Set<string>());
|
||||
const snapshot = useRef<ReadonlySet<string>>(new Set<string>());
|
||||
const seenAt = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedAt === seenAt.current) return;
|
||||
seenAt.current = updatedAt;
|
||||
previous.current = snapshot.current;
|
||||
snapshot.current = current;
|
||||
}, [updatedAt, current]);
|
||||
|
||||
return previous.current;
|
||||
}
|
||||
|
||||
export interface BoardOptions {
|
||||
branches: readonly TenantLocation[];
|
||||
range: DateRange;
|
||||
/** Counters the operator has hidden. See `counterLabels.ts`. */
|
||||
isHidden: (terminalId: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the board.
|
||||
*
|
||||
* Two reads per branch — health and the sales split — because the second is the
|
||||
* only way to find a counter that is billing while reporting nothing. Both fan
|
||||
* out per location: there is no tenant-wide POS read in Fiesta.
|
||||
*/
|
||||
export function useTerminalBoard({ branches, range, isHidden }: BoardOptions): Board {
|
||||
const branchIds = useMemo(() => branches.map((branch) => branch.locationid), [branches]);
|
||||
const health = usePosHealthByBranch(branchIds);
|
||||
const sales = usePosSalesByBranch(branchIds, range);
|
||||
|
||||
const now = Date.now();
|
||||
const updatedAt = health.reduce((max, query) => Math.max(max, query.dataUpdatedAt ?? 0), 0);
|
||||
|
||||
const read = useMemo(
|
||||
() =>
|
||||
branches.map((branch, index) => ({
|
||||
branch,
|
||||
query: health[index],
|
||||
terminals: (health[index]?.data ?? []).map((raw) => readTerminal(raw, now)),
|
||||
})),
|
||||
// `now` is excluded deliberately: including it re-derives the whole board
|
||||
// on every render rather than on every read, and the ages on screen do not
|
||||
// need sub-poll precision.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[branches, health, updatedAt],
|
||||
);
|
||||
|
||||
const currentlyStale = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
read.flatMap(({ terminals }) =>
|
||||
terminals.filter((t) => t.presence === 'stale').map((t) => t.terminalId),
|
||||
),
|
||||
),
|
||||
[read],
|
||||
);
|
||||
const wasStale = usePreviousStale(currentlyStale, updatedAt);
|
||||
|
||||
return useMemo(() => {
|
||||
const cards: BoardCard[] = [];
|
||||
const fine: HealthyCounter[] = [];
|
||||
const failed: string[] = [];
|
||||
let total = 0;
|
||||
let strandedBills = 0;
|
||||
|
||||
read.forEach(({ branch, query, terminals }, index) => {
|
||||
if (query?.isError) failed.push(branch.locationname);
|
||||
|
||||
const split = sales[index]?.data?.byterminal ?? [];
|
||||
// `terminalid`, not `terminal_id` — the sales and presence payloads
|
||||
// disagree on the spelling. See `PosSalesSummary` in `api/types.ts`.
|
||||
const byId = new Map(
|
||||
split.map((entry) => [readTerminalId(entry.terminalid), entry] as const),
|
||||
);
|
||||
|
||||
const context: ProblemContext = {
|
||||
now,
|
||||
wasStale,
|
||||
...(branch.opentime ? { opentime: branch.opentime } : {}),
|
||||
...(branch.closetime ? { closetime: branch.closetime } : {}),
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const status of terminals) {
|
||||
seen.add(status.terminalId);
|
||||
if (isHidden(status.terminalId)) continue;
|
||||
total += 1;
|
||||
if (status.presence !== 'online') strandedBills += status.pendingBills;
|
||||
|
||||
const sale = byId.get(status.terminalId);
|
||||
const periodBills = sale?.billcount ?? 0;
|
||||
const periodAmount = sale?.amount ?? 0;
|
||||
const problems = problemsFor(status, context);
|
||||
|
||||
if (problems.length === 0) {
|
||||
fine.push({
|
||||
key: `${branch.locationid}:${status.terminalId}`,
|
||||
branchId: branch.locationid,
|
||||
branchName: branch.locationname,
|
||||
terminalId: status.terminalId,
|
||||
status,
|
||||
periodBills,
|
||||
periodAmount,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// One card per bucket this counter has problems in, not one per
|
||||
// problem. `problemsFor` already returns worst first, so the first in
|
||||
// each bucket is the one that earns the headline.
|
||||
for (const bucket of ['now', 'look'] as const) {
|
||||
const inBucket = problems.filter((problem) => problem.bucket === bucket);
|
||||
const [primary, ...also] = inBucket;
|
||||
if (!primary) continue;
|
||||
cards.push({
|
||||
key: `${branch.locationid}:${status.terminalId}:${bucket}`,
|
||||
branchId: branch.locationid,
|
||||
branchName: branch.locationname,
|
||||
terminalId: status.terminalId,
|
||||
problem: primary,
|
||||
also,
|
||||
status,
|
||||
periodBills,
|
||||
periodAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Counters known only from the sales figures. They have no presence
|
||||
// record to build a card from, so without this they are invisible here
|
||||
// while sitting plainly in the day's takings.
|
||||
for (const entry of split) {
|
||||
const id = readTerminalId(entry.terminalid);
|
||||
if (seen.has(id) || isHidden(id)) continue;
|
||||
total += 1;
|
||||
const problem = noStatusProblem(entry.billcount ?? 0, entry.amount ?? 0);
|
||||
cards.push({
|
||||
key: `${branch.locationid}:${id}:${problem.bucket}`,
|
||||
branchId: branch.locationid,
|
||||
branchName: branch.locationname,
|
||||
terminalId: id,
|
||||
problem,
|
||||
also: [],
|
||||
status: null,
|
||||
periodBills: entry.billcount ?? 0,
|
||||
periodAmount: entry.amount ?? 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cards.sort(compareCards);
|
||||
fine.sort((a, b) => b.periodAmount - a.periodAmount);
|
||||
|
||||
const inBucket = (bucket: Bucket) => cards.filter((card) => card.problem.bucket === bucket);
|
||||
|
||||
return {
|
||||
now: inBucket('now'),
|
||||
look: inBucket('look'),
|
||||
fine,
|
||||
total,
|
||||
strandedBills,
|
||||
isLoading: health.some((query) => query.isLoading),
|
||||
failed,
|
||||
checked: branches.map((branch) => branch.locationname),
|
||||
};
|
||||
}, [read, sales, wasStale, health, branches, isHidden, now]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Worst first, then longest-running, then by counter.
|
||||
*
|
||||
* Not alphabetical, and not grouped by branch. Noticing should not require
|
||||
* scanning: the counter that needs someone is the one the eye lands on, without
|
||||
* being looked for. The branch is named on every card instead.
|
||||
*/
|
||||
function compareCards(a: BoardCard, b: BoardCard): number {
|
||||
const bucket = BUCKET_RANK[b.problem.bucket] - BUCKET_RANK[a.problem.bucket];
|
||||
if (bucket !== 0) return bucket;
|
||||
const duration = (b.problem.forMs ?? 0) - (a.problem.forMs ?? 0);
|
||||
if (duration !== 0) return duration;
|
||||
return a.terminalId.localeCompare(b.terminalId);
|
||||
}
|
||||
45
src/hooks/useIsMobile.ts
Normal file
45
src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* The one viewport threshold the layout needs in JavaScript.
|
||||
*
|
||||
* 768 — Tailwind's `md`, and deliberately not `lg`: a tablet already lays the
|
||||
* two-column workspace out acceptably, so only phones stack. Matching the CSS
|
||||
* breakpoint exactly matters, because a JS threshold that disagrees with a
|
||||
* media query produces a band of widths where the two halves of the layout
|
||||
* contradict each other.
|
||||
*/
|
||||
export const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
const list = window.matchMedia(QUERY);
|
||||
list.addEventListener('change', onChange);
|
||||
return () => list.removeEventListener('change', onChange);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return window.matchMedia(QUERY).matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server snapshot. There is no SSR here, but `useSyncExternalStore` requires
|
||||
* one and `false` is the safer default: rendering the desktop layout for a
|
||||
* frame is a smaller error than rendering the phone layout on a desktop.
|
||||
*/
|
||||
function getServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True below 768px.
|
||||
*
|
||||
* Use this ONLY for behaviour that CSS cannot express — whether a navigation
|
||||
* sheet exists, whether a resize handle is mounted. Anything that is purely
|
||||
* visual belongs in a media query in `index.css`, because JS-driven layout
|
||||
* flashes the wrong state on first paint.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
613
src/index.css
Normal file
613
src/index.css
Normal file
@@ -0,0 +1,613 @@
|
||||
/* ── Layer order ─────────────────────────────────────────────────────────────
|
||||
Prescribed verbatim by the Usage block at the top of
|
||||
@astryxdesign/core/tailwind-theme.css — not our invention. The ordering
|
||||
matters: `utilities` sits last so Tailwind utilities keep winning over
|
||||
`astryx-base`, which is what makes a half-migrated file behave.
|
||||
|
||||
Note `astryx.css` loads BEFORE the theme package: the theme supplies the
|
||||
token values that the component CSS references. */
|
||||
@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
|
||||
|
||||
/* Sora for display, Inter for body — the pairing read out of KROW's own
|
||||
stylesheet (--font-display / --font-body). JetBrains Mono stays for the
|
||||
monospace runs: ids, SKUs, PINs, invoice numbers. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
|
||||
@import 'tailwindcss/theme.css' layer(theme);
|
||||
@import 'tailwindcss/preflight.css' layer(base);
|
||||
@import '@astryxdesign/core/reset.css';
|
||||
@import '@astryxdesign/core/astryx.css';
|
||||
/* Generated by `npx astryx theme build src/theme/nearle.ts` — do not hand-edit. */
|
||||
@import './theme/nearle.css';
|
||||
@import '@astryxdesign/core/tailwind-theme.css';
|
||||
@import 'tailwindcss/utilities.css' layer(utilities);
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Sora', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
/* ── Brand ──────────────────────────────────────────────────────────────
|
||||
#662582 is the Nearle purple, taken from the app mark. It scores 9.76:1
|
||||
on white. `--color-brand-strong` is the pressed/hover step. */
|
||||
--color-brand: #662582;
|
||||
--color-brand-strong: #531b6e;
|
||||
--color-brand-soft: #f2ecf6;
|
||||
/* The tint behind an active nav item and an unread row. KROW's
|
||||
`krow-blue-tint`, restated in purple. */
|
||||
--color-brand-tint: #f4eef8;
|
||||
|
||||
/* The 4-step ink scale every component reads from.
|
||||
ink-1 headings/values · ink-2 body · ink-3 secondary · ink-4 meta */
|
||||
--color-ink-1: #0f172a;
|
||||
--color-ink-2: #414b5a;
|
||||
--color-ink-3: #657081;
|
||||
--color-ink-4: #97a1b0;
|
||||
|
||||
/* Surfaces. `subtle` sits under a resting input, `sunken` under a hover. */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-subtle: #fafbfc;
|
||||
--color-surface-sunken: #f2f4f7;
|
||||
--color-line: #e0e4ea;
|
||||
|
||||
/* Neutral ramp — the ink scale every surface and label reads from. */
|
||||
--color-slate-50: #f7f7f7;
|
||||
--color-slate-100: #eff1f4;
|
||||
--color-slate-200: #e1e5ea;
|
||||
--color-slate-300: #cbd2db;
|
||||
--color-slate-400: #97a1b0;
|
||||
--color-slate-500: #657081;
|
||||
--color-slate-600: #4a5464;
|
||||
--color-slate-900: #0f172a;
|
||||
|
||||
/* Ambient canvas stops — see body::before below. */
|
||||
--color-canvas-violet: #f2ecf6;
|
||||
--color-canvas-mid: #f8f8f8;
|
||||
--color-canvas-warm: #f8f6f1;
|
||||
|
||||
--container-page: 80rem;
|
||||
--container-wide: 108rem;
|
||||
--container-admin: 102rem;
|
||||
}
|
||||
|
||||
/* ── The ambient canvas ──────────────────────────────────────────────────────
|
||||
KROW's single most distinctive visual trait, restated in Nearle purple: a
|
||||
fixed, viewport-sized horizontal gradient painted behind the entire app.
|
||||
|
||||
Three implementation notes, each of which is a design rule in its own right:
|
||||
|
||||
- Fixed, not on `body` — the gradient runs left-to-right and must be sized
|
||||
to the VIEWPORT, not the document, or it stretches on long pages.
|
||||
- `z-index: -1` — it never enters layout, so no shell needs a wrapper.
|
||||
- The stops sit close together in lightness on purpose. The result should
|
||||
read as *softer*, not as *there is a gradient*. */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-canvas-violet) 0%,
|
||||
#f5f2f7 28%,
|
||||
#f7f6f7 45%,
|
||||
var(--color-canvas-mid) 65%,
|
||||
#f8f7f4 86%,
|
||||
var(--color-canvas-warm) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Every application shell is transparent so the canvas reads through. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Focus is never the browser default. Declared once, globally, rather than
|
||||
per component. */
|
||||
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Reduced motion handled once, globally. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Numeric columns line up only with tabular figures. Used by every KPI value
|
||||
and every money/count cell in a table. */
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The hamburger and the mobile sheet only exist below the tabs breakpoint;
|
||||
above it the text tabs are visible and a second nav affordance is noise. */
|
||||
.lg-hidden {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.lg-hidden {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus treatment for the auth inputs, which are hand-styled rather than
|
||||
Astryx controls — the auth page is the one screen the reference also builds
|
||||
outside the component layer. */
|
||||
input:focus-visible {
|
||||
border-color: var(--color-brand) !important;
|
||||
background: var(--color-surface) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
RESPONSIVE LAYER
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Breakpoints are Tailwind's stock set, which is what the reference uses — it
|
||||
declares no `screens` key, so the defaults apply:
|
||||
|
||||
base 0–639 phone
|
||||
sm 640 large phone / small tablet
|
||||
md 768 tablet · small laptop
|
||||
lg 1024 laptop
|
||||
xl 1280 small desktop
|
||||
2xl 1536 large desktop
|
||||
|
||||
Mobile-first throughout: the base rule is the phone state and every override
|
||||
is a min-width query. Written as classes rather than inline styles because a
|
||||
media query cannot live in a style attribute, and because layout that depends
|
||||
on JS measuring the viewport flashes the wrong thing on first paint.
|
||||
═════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Gutters ───────────────────────────────────────────────────────────────
|
||||
16px on a phone, 24px from lg.
|
||||
|
||||
The shell is fluid up to `--container-admin` (1632px of content) and then
|
||||
stops and centres. Fluid-forever was wrong: on a 2560px monitor it stretched
|
||||
a six-column table to 1660px, opening a dead 600px gap between "Tenant" and
|
||||
"City" and stranding three KPI tiles at the far left of a 2100px row. A row
|
||||
the eye cannot track from label to value is not more information, it is less.
|
||||
|
||||
The cap lands on BOTH the header row and the body, because they share this
|
||||
class — nav, page title and table all stay on the same left edge at every
|
||||
width. `calc(... + 48px)` is the content cap plus the two gutters, so the
|
||||
measured content box is exactly `--container-admin`. */
|
||||
.app-gutter {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
max-width: calc(var(--container-admin) + 32px);
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.app-gutter {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
max-width: calc(var(--container-admin) + 48px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Visibility ─────────────────────────────────────────────────────────────
|
||||
`display` is set here rather than inline, so an inline style cannot win over
|
||||
the query. Each class names the breakpoint it turns on at. */
|
||||
.show-from-md {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.show-from-md {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.show-from-lg {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.show-from-lg {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.lg-hidden {
|
||||
display: inline-flex;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.lg-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Admin body ────────────────────────────────────────────────────────────
|
||||
A column on a phone so the assistant stacks under the page; a row from md,
|
||||
where a tablet already lays the inline column out acceptably. Only phones
|
||||
stack — that threshold is md (768), not lg, on purpose. */
|
||||
.admin-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.admin-body {
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Assistant column ──────────────────────────────────────────────────────
|
||||
Stacked and fixed-height below md; a sticky 380px column above it, sized so
|
||||
the page beside it still has a usable measure. */
|
||||
.assistant {
|
||||
width: 100%;
|
||||
height: 32rem;
|
||||
padding-top: 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.assistant {
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
width: 340px;
|
||||
flex: none;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
height: calc(100dvh - 56px);
|
||||
margin-right: -16px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.assistant {
|
||||
margin-right: -24px;
|
||||
}
|
||||
}
|
||||
/* 380px only once the page beside it can afford it. At 1280 the split was
|
||||
828/380 and the tenant table's last column got clipped mid-word; holding
|
||||
Buddy at 340 until 1536 gives the page the width back where it is scarce and
|
||||
the extra 40px where it is not. */
|
||||
@media (min-width: 1536px) {
|
||||
.assistant {
|
||||
width: 380px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Page header ───────────────────────────────────────────────────────────
|
||||
22px on a phone, 24px from sm. */
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tables ────────────────────────────────────────────────────────────────
|
||||
A table is the one thing that genuinely cannot reflow: columns have meaning
|
||||
and dropping them silently loses data. So it scrolls sideways inside its own
|
||||
container, and the page never does. */
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
/* A scroll shadow, so a clipped column reads as "there is more this way"
|
||||
rather than as a rendering bug. The two `local` layers are opaque covers
|
||||
that travel WITH the content; the two `scroll` layers are the shadows
|
||||
pinned to the container. When the content is flush to an edge its cover
|
||||
sits over that shadow and hides it, so the hint appears only on the side
|
||||
that actually has more to show — no JS, no scroll listener. */
|
||||
background:
|
||||
linear-gradient(to right, var(--color-surface) 30%, rgb(255 255 255 / 0)) left center / 28px 100%
|
||||
no-repeat,
|
||||
linear-gradient(to left, var(--color-surface) 30%, rgb(255 255 255 / 0)) right center / 28px 100%
|
||||
no-repeat,
|
||||
radial-gradient(farthest-side at 0 50%, rgb(16 24 32 / 0.1), rgb(16 24 32 / 0)) left center /
|
||||
12px 100% no-repeat,
|
||||
radial-gradient(farthest-side at 100% 50%, rgb(16 24 32 / 0.1), rgb(16 24 32 / 0)) right center /
|
||||
12px 100% no-repeat;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
.table-scroll > table {
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
/* ── Toolbars and filter rows ──────────────────────────────────────────────
|
||||
Stacked on a phone so a search field gets a full row, inline from sm. */
|
||||
.toolbar-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.toolbar-stack {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Login ─────────────────────────────────────────────────────────────────
|
||||
One column below md, two from md — the reference splits at exactly that
|
||||
point. Padding relaxes at sm so a phone is not spending 80px on margins. */
|
||||
.login-shell {
|
||||
padding: 12px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.login-shell {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.login-split {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
border-right: 0;
|
||||
gap: 20px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.login-brand {
|
||||
padding: 32px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.login-brand {
|
||||
padding: 40px;
|
||||
gap: 32px;
|
||||
border-bottom: 0;
|
||||
border-right: 1px solid var(--color-line);
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 24px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.login-form {
|
||||
padding: 32px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.login-form {
|
||||
padding: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The hero image earns its space on a laptop; on a phone it is the thing
|
||||
pushing the form below the fold, so it goes. */
|
||||
.login-hero {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.login-hero {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dialogs ───────────────────────────────────────────────────────────────
|
||||
Full-bleed on a phone; the width cap engages from sm. */
|
||||
.dialog-panel {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.dialog-panel {
|
||||
width: 640px;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch targets: a 32px control is fine with a mouse and small with a thumb,
|
||||
so coarse pointers get the comfortable minimum. */
|
||||
@media (pointer: coarse) {
|
||||
button,
|
||||
[role='button'],
|
||||
a[href] {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── KPI rows ──────────────────────────────────────────────────────────────
|
||||
Two up on a phone, three from md, then natural-width tiles from lg.
|
||||
|
||||
`auto-fill` rather than `auto-fit` above lg is deliberate: `auto-fit`
|
||||
collapses the empty tracks and stretches three tiles across a 1400px row,
|
||||
which makes a five-word label sit in the middle of a billboard. `auto-fill`
|
||||
keeps the empty tracks, so a tile stays tile-sized however few there are. */
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Form rows ─────────────────────────────────────────────────────────────
|
||||
One field per row on a phone, two from sm, three from lg. A three-across
|
||||
form on a 768px tablet puts a postcode field at 200px, which is narrower
|
||||
than the value it holds. */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Address rows want four short fields, so they get a tighter ladder. */
|
||||
.form-grid-4 {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.form-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Product / catalogue cards ─────────────────────────────────────────────
|
||||
Two up on a phone — a product grid at one card per row reads as a list and
|
||||
loses the visual comparison that makes a grid worth having. */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Container queries for content grids ───────────────────────────────────
|
||||
The reference uses viewport queries throughout, and for the shell that is
|
||||
right. For the grids INSIDE the page it is wrong, and the assistant column is
|
||||
what exposes it: at 768px the viewport says "tablet, two columns please"
|
||||
while the main column is actually 390px wide because a 340px panel is sitting
|
||||
beside it. The result is a two-column form at phone width, with labels
|
||||
wrapping onto three lines.
|
||||
|
||||
A grid does not care how wide the window is. It cares how wide its own
|
||||
container is. So `main` becomes a query container and these grids ask it
|
||||
directly. The viewport rules above stay as the fallback for anything that
|
||||
cannot do container queries; where both apply, these win by cascade order. */
|
||||
main {
|
||||
container-type: inline-size;
|
||||
container-name: page;
|
||||
}
|
||||
|
||||
@container page (min-width: 520px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@container page (min-width: 860px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@container page (max-width: 519px) {
|
||||
.form-grid,
|
||||
.form-grid-4 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@container page (min-width: 520px) {
|
||||
.form-grid-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@container page (min-width: 860px) {
|
||||
.form-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@container page (min-width: 380px) {
|
||||
.kpi-grid,
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@container page (min-width: 620px) {
|
||||
.kpi-grid,
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@container page (min-width: 900px) {
|
||||
/* `auto-fit`, not `auto-fill`. `auto-fill` keeps the empty tracks it makes
|
||||
room for, so a four-tile row on a wide screen renders four tiles and a
|
||||
ragged hole where a fifth would have gone. `auto-fit` collapses those
|
||||
tracks and the tiles divide the row evenly.
|
||||
|
||||
The reason to have preferred `auto-fill` — three tiles stretching across a
|
||||
very wide row — no longer applies: the shell caps content at
|
||||
`--container-admin`, so the widest a page column ever gets is 1228px, and
|
||||
three tiles across that is a legible 400px each rather than an absurd 700. */
|
||||
.kpi-grid,
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Terminal cards ────────────────────────────────────────────────────────
|
||||
Its own grid rather than `.card-grid`, because these cards carry sentences,
|
||||
not figures: a chip row, a line of what is actually true, and a line of what
|
||||
to do about it. `.card-grid` opens at two columns even on a phone, which is
|
||||
right for a branch tile of four numbers and wrong here — 180px of width turns
|
||||
"Bills stranded" into four wrapped lines.
|
||||
|
||||
So: one column until there is room for prose, then `auto-fit` on a 280px
|
||||
floor. Against the 1228px content cap that tops out at four across. */
|
||||
.terminal-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@container page (min-width: 560px) {
|
||||
.terminal-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
49
src/main.tsx
Normal file
49
src/main.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Theme } from '@astryxdesign/core';
|
||||
import { App } from './App';
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
import { nearleTheme } from './theme/nearle';
|
||||
import './index.css';
|
||||
|
||||
/**
|
||||
* One shared query client.
|
||||
*
|
||||
* Polling is NOT set globally here. The 30-second cadence belongs to the screens
|
||||
* that watch a trading day — counter sales, dispatch, stock — and is applied per
|
||||
* query in `queries/hooks.ts`. A tenant-onboarding form has no business
|
||||
* re-fetching every half minute, and a global default would make it.
|
||||
*/
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('#root is missing from index.html');
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
{/*
|
||||
Theme wraps the app rather than sitting beside it: it stamps the
|
||||
`data-astryx-theme` attribute the compiled stylesheet is scoped to, so
|
||||
anything rendered outside it gets Astryx's defaults instead of Nearle's.
|
||||
*/}
|
||||
<Theme theme={nearleTheme} mode="light">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</Theme>
|
||||
</StrictMode>,
|
||||
);
|
||||
312
src/queries/hooks.ts
Normal file
312
src/queries/hooks.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* TanStack Query hooks.
|
||||
*
|
||||
* The 30-second cadence is the CONSOLE's refetch interval, not a terminal push
|
||||
* rate: the tills push over MQTT as bills happen, and this is how often the
|
||||
* screen goes back and asks. It is applied per-query rather than globally so
|
||||
* that a provisioning form is not re-polling a tenant list it will never see
|
||||
* change mid-edit.
|
||||
*/
|
||||
|
||||
import { useQueries, useQuery, type UseQueryOptions } from '@tanstack/react-query';
|
||||
import { catalogueApi, type CatalogueQuery } from '@/api/catalogue';
|
||||
import { insightsApi, type DateRange, type OrderQuery } from '@/api/insights';
|
||||
import { productsApi } from '@/api/products';
|
||||
import { posUsersApi, staffApi } from '@/api/people';
|
||||
import { stockApi, type StockRequestQuery } from '@/api/stock';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
import { queryKeys } from './keys';
|
||||
import { LIVE_REFETCH_MS } from './interval';
|
||||
|
||||
/** How often live-operations screens go back to the backend. */
|
||||
export { LIVE_REFETCH_MS };
|
||||
|
||||
/**
|
||||
* Shared options for anything that changes during a trading day.
|
||||
*
|
||||
* `refetchIntervalInBackground: false` pauses polling while the tab is hidden
|
||||
* and resumes with an immediate refetch when it comes back — a console left
|
||||
* open on a second monitor should not spend the afternoon talking to the API.
|
||||
* `staleTime` sits below the interval so focus and mount refetches aren't
|
||||
* skipped as already-fresh.
|
||||
*/
|
||||
const live = {
|
||||
refetchInterval: LIVE_REFETCH_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
staleTime: 15_000,
|
||||
} satisfies Partial<UseQueryOptions>;
|
||||
|
||||
/** Reference data that only changes when someone changes it. */
|
||||
const stable = {
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
} satisfies Partial<UseQueryOptions>;
|
||||
|
||||
/* ── Tenants ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function useTenants() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.tenants.list(),
|
||||
queryFn: () => tenantsApi.listAll(),
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTenantLocations(tenantid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.tenants.locations(tenantid ?? 0),
|
||||
queryFn: () => tenantsApi.locations(tenantid as number),
|
||||
// Never fire without the scoping id — the backend would 400, and a 400 in
|
||||
// the cache reads to the user as a broken page rather than a missing input.
|
||||
enabled: typeof tenantid === 'number' && tenantid > 0,
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Global catalogue ────────────────────────────────────────────────────── */
|
||||
|
||||
export function useCatalogueProducts(query: CatalogueQuery) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.catalogue.products(query as Record<string, unknown>),
|
||||
queryFn: () => catalogueApi.products(query),
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCatalogueBrands() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.catalogue.brands(),
|
||||
queryFn: () => catalogueApi.brands(),
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportedRefs(tenantid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.catalogue.importedRefs(tenantid ?? 0),
|
||||
queryFn: () => catalogueApi.importedRefs(tenantid as number),
|
||||
enabled: typeof tenantid === 'number' && tenantid > 0,
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Products ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function useProductCategories(tenantid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.products.categories(tenantid ?? 0),
|
||||
queryFn: () => productsApi.categories(tenantid as number),
|
||||
enabled: typeof tenantid === 'number' && tenantid > 0,
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLocationProducts(
|
||||
tenantid: number | undefined,
|
||||
locationid: number | undefined,
|
||||
page = 0,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.products.byLocation(tenantid ?? 0, locationid ?? 0, page),
|
||||
queryFn: () =>
|
||||
productsApi.locationProducts({
|
||||
tenantid: tenantid as number,
|
||||
locationid: locationid as number,
|
||||
pageno: page,
|
||||
}),
|
||||
enabled: Boolean(tenantid) && Boolean(locationid),
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Performance ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export function useLocationSummary(tenantid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insights.locations(tenantid ?? 0),
|
||||
queryFn: () => insightsApi.locationSummary(tenantid as number),
|
||||
enabled: typeof tenantid === 'number' && tenantid > 0,
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrderSummary(tenantid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insights.orders(tenantid ?? 0),
|
||||
queryFn: () => insightsApi.orderSummary(tenantid as number),
|
||||
enabled: typeof tenantid === 'number' && tenantid > 0,
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Till health for one outlet.
|
||||
*
|
||||
* One request per branch — `locationid` is required and singular on the POS
|
||||
* endpoints, so there is no tenant-wide call to reach for.
|
||||
*/
|
||||
export function usePosHealth(locationid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insights.posHealth(locationid ?? 0),
|
||||
queryFn: () => insightsApi.posHealth(locationid as number),
|
||||
enabled: typeof locationid === 'number' && locationid > 0,
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Store Admin: per-branch fan-out ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Till health for several branches at once.
|
||||
*
|
||||
* `useQueries` rather than a loop of `useQuery`, because the number of branches
|
||||
* is data — a merchant can commission a fourth outlet while this screen is open,
|
||||
* and a hook count that changes between renders is a crash, not a re-render.
|
||||
*
|
||||
* One request per branch is not a choice: `/pos/health/location` takes a single
|
||||
* `location_id` and there is no tenant-wide POS endpoint anywhere in Fiesta.
|
||||
*/
|
||||
export function usePosHealthByBranch(locationIds: readonly number[]) {
|
||||
return useQueries({
|
||||
queries: locationIds.map((locationid) => ({
|
||||
queryKey: queryKeys.insights.posHealth(locationid),
|
||||
queryFn: () => insightsApi.posHealth(locationid),
|
||||
...live,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** Counter-sales totals for several branches at once. Same fan-out, same reason. */
|
||||
export function usePosSalesByBranch(locationIds: readonly number[], range: DateRange = {}) {
|
||||
return useQueries({
|
||||
queries: locationIds.map((locationid) => ({
|
||||
queryKey: queryKeys.insights.posSalesSummary(locationid, { ...range }),
|
||||
queryFn: () => insightsApi.posSalesSummary(locationid, range),
|
||||
...live,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Store Admin: stock ──────────────────────────────────────────────────── */
|
||||
|
||||
export function useStockRequests(query: StockRequestQuery | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.stock.requests((query ?? {}) as Record<string, unknown>),
|
||||
queryFn: () => stockApi.requests(query as StockRequestQuery),
|
||||
enabled: Boolean(query?.tenantid),
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStockStatement(
|
||||
tenantid: number | undefined,
|
||||
locationid: number | undefined,
|
||||
params: { keyword?: string; pagesize?: number } = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.stock.statement(tenantid ?? 0, locationid ?? 0, params),
|
||||
queryFn: () =>
|
||||
stockApi.statement({
|
||||
tenantid: tenantid as number,
|
||||
locationid: locationid as number,
|
||||
...params,
|
||||
}),
|
||||
enabled: Boolean(tenantid) && Boolean(locationid),
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Store Admin: order and bill rows ────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The order rows.
|
||||
*
|
||||
* One call for every branch or for one, decided by whether `locationid` is
|
||||
* present — the backend routes on that, so there is nothing to fan out here.
|
||||
*/
|
||||
export function useOrders(query: OrderQuery | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insights.orderList((query ?? {}) as unknown as Record<string, unknown>),
|
||||
queryFn: () => insightsApi.orders(query as OrderQuery),
|
||||
enabled: Boolean(query?.tenantid),
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter bills, per branch.
|
||||
*
|
||||
* Fanned out because `/pos/sales` takes a single `locationid` and there is no
|
||||
* tenant-wide POS read — the same constraint as the health and summary calls.
|
||||
*/
|
||||
export function usePosBillsByBranch(
|
||||
locationIds: readonly number[],
|
||||
params: DateRange & { pagesize?: number } = {},
|
||||
) {
|
||||
return useQueries({
|
||||
queries: locationIds.map((locationid) => ({
|
||||
queryKey: queryKeys.insights.posBills(locationid, { ...params }),
|
||||
queryFn: () => insightsApi.posSales(locationid, params),
|
||||
...live,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** The delivery jobs — its own read, its own row shape, its own status ladder. */
|
||||
export function useDeliveries(query: OrderQuery | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insights.deliveryList((query ?? {}) as unknown as Record<string, unknown>),
|
||||
queryFn: () => insightsApi.deliveries(query as OrderQuery),
|
||||
enabled: Boolean(query?.tenantid),
|
||||
...live,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── People ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The back-office directory. Role names arrive resolved; never map ids here. */
|
||||
export function useStaff(tenantid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.people.staff(tenantid ?? 0),
|
||||
queryFn: () => staffApi.list(tenantid as number),
|
||||
enabled: Boolean(tenantid),
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Till accounts, per branch.
|
||||
*
|
||||
* Fanned out because `getposusers` takes a single `locationid` — the same
|
||||
* constraint as every other POS read.
|
||||
*/
|
||||
export function usePosUsersByBranch(tenantid: number | undefined, locationIds: readonly number[]) {
|
||||
return useQueries({
|
||||
queries: locationIds.map((locationid) => ({
|
||||
queryKey: queryKeys.people.posUsers(tenantid ?? 0, locationid),
|
||||
queryFn: () => posUsersApi.list(tenantid as number, locationid),
|
||||
enabled: Boolean(tenantid),
|
||||
...stable,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** The role picker's source. Cached hard — it changes when the product changes. */
|
||||
export function usePosRoles() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.people.posRoles(),
|
||||
queryFn: () => posUsersApi.roles(),
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStaffShifts(tenantid: number | undefined, locationid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.people.shifts(tenantid ?? 0, locationid ?? 0),
|
||||
queryFn: () => posUsersApi.shifts(tenantid as number, locationid as number),
|
||||
enabled: Boolean(tenantid) && Boolean(locationid),
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
9
src/queries/interval.ts
Normal file
9
src/queries/interval.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* How often live data is re-read.
|
||||
*
|
||||
* It lives alone in this file rather than in `hooks.ts` because the derivation
|
||||
* layer needs it too — `posStatus.ts` sets its staleness threshold relative to
|
||||
* the poll interval — and pulling the whole React Query layer into a module of
|
||||
* pure functions to read one number is how those modules stop being testable.
|
||||
*/
|
||||
export const LIVE_REFETCH_MS = 30_000;
|
||||
77
src/queries/keys.ts
Normal file
77
src/queries/keys.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Query keys.
|
||||
*
|
||||
* Every Fiesta list endpoint requires a scoping id and 400s without one, so the
|
||||
* scoping id is part of the key by construction. That is not a caching nicety:
|
||||
* it is what stops one tenant's cached rows being served to another after a
|
||||
* switch.
|
||||
*/
|
||||
|
||||
export const queryKeys = {
|
||||
tenants: {
|
||||
all: ['tenants'] as const,
|
||||
list: () => [...queryKeys.tenants.all, 'list'] as const,
|
||||
locations: (tenantid: number) =>
|
||||
[...queryKeys.tenants.all, 'locations', tenantid] as const,
|
||||
search: (keyword: string) => [...queryKeys.tenants.all, 'search', keyword] as const,
|
||||
},
|
||||
|
||||
catalogue: {
|
||||
all: ['catalogue'] as const,
|
||||
products: (params: Record<string, unknown>) =>
|
||||
[...queryKeys.catalogue.all, 'products', params] as const,
|
||||
brands: () => [...queryKeys.catalogue.all, 'brands'] as const,
|
||||
importedRefs: (tenantid: number) =>
|
||||
[...queryKeys.catalogue.all, 'imported', tenantid] as const,
|
||||
},
|
||||
|
||||
products: {
|
||||
all: ['products'] as const,
|
||||
byLocation: (tenantid: number, locationid: number, page: number) =>
|
||||
[...queryKeys.products.all, 'location', tenantid, locationid, page] as const,
|
||||
categories: (tenantid: number) =>
|
||||
[...queryKeys.products.all, 'categories', tenantid] as const,
|
||||
subCategories: (tenantid: number, categoryid: number) =>
|
||||
[...queryKeys.products.all, 'subcategories', tenantid, categoryid] as const,
|
||||
count: (tenantid: number) => [...queryKeys.products.all, 'count', tenantid] as const,
|
||||
},
|
||||
|
||||
insights: {
|
||||
all: ['insights'] as const,
|
||||
orders: (tenantid: number) => [...queryKeys.insights.all, 'orders', tenantid] as const,
|
||||
locations: (tenantid: number) =>
|
||||
[...queryKeys.insights.all, 'locations', tenantid] as const,
|
||||
deliveries: (tenantid: number) =>
|
||||
[...queryKeys.insights.all, 'deliveries', tenantid] as const,
|
||||
posSales: (locationid: number) =>
|
||||
[...queryKeys.insights.all, 'pos-sales', locationid] as const,
|
||||
posHealth: (locationid: number) =>
|
||||
[...queryKeys.insights.all, 'pos-health', locationid] as const,
|
||||
posSalesSummary: (locationid: number, range: Record<string, unknown>) =>
|
||||
[...queryKeys.insights.all, 'pos-summary', locationid, range] as const,
|
||||
posBills: (locationid: number, params: Record<string, unknown>) =>
|
||||
[...queryKeys.insights.all, 'pos-bills', locationid, params] as const,
|
||||
orderList: (params: Record<string, unknown>) =>
|
||||
[...queryKeys.insights.all, 'order-list', params] as const,
|
||||
deliveryList: (params: Record<string, unknown>) =>
|
||||
[...queryKeys.insights.all, 'delivery-list', params] as const,
|
||||
},
|
||||
|
||||
people: {
|
||||
all: ['people'] as const,
|
||||
staff: (tenantid: number) => [...queryKeys.people.all, 'staff', tenantid] as const,
|
||||
posUsers: (tenantid: number, locationid: number) =>
|
||||
[...queryKeys.people.all, 'pos-users', tenantid, locationid] as const,
|
||||
posRoles: () => [...queryKeys.people.all, 'pos-roles'] as const,
|
||||
shifts: (tenantid: number, locationid: number) =>
|
||||
[...queryKeys.people.all, 'shifts', tenantid, locationid] as const,
|
||||
},
|
||||
|
||||
stock: {
|
||||
all: ['stock'] as const,
|
||||
requests: (params: Record<string, unknown>) =>
|
||||
[...queryKeys.stock.all, 'requests', params] as const,
|
||||
statement: (tenantid: number, locationid: number, params: Record<string, unknown>) =>
|
||||
[...queryKeys.stock.all, 'statement', tenantid, locationid, params] as const,
|
||||
},
|
||||
} as const;
|
||||
141
src/theme/nearle.css
Normal file
141
src/theme/nearle.css
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/nearle.ts
|
||||
* Command: astryx theme build src/theme/nearle.ts
|
||||
* CLI: @astryxdesign/cli@0.4.5
|
||||
* Core: @astryxdesign/core@0.4.5
|
||||
*/
|
||||
|
||||
@layer reset {
|
||||
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
|
||||
:where(h1, h2, h3, h4, h5, h6) {
|
||||
font-family: var(--font-family-heading);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
:where(h1) {
|
||||
font-size: var(--text-heading-1-size);
|
||||
font-weight: var(--text-heading-1-weight);
|
||||
line-height: var(--text-heading-1-leading);
|
||||
}
|
||||
|
||||
:where(h2) {
|
||||
font-size: 20px;
|
||||
font-weight: var(--text-heading-2-weight);
|
||||
line-height: var(--text-heading-2-leading);
|
||||
}
|
||||
|
||||
:where(h3) {
|
||||
font-size: 17px;
|
||||
font-weight: var(--text-heading-3-weight);
|
||||
line-height: var(--text-heading-3-leading);
|
||||
}
|
||||
|
||||
:where(h4) {
|
||||
font-size: var(--text-heading-4-size);
|
||||
font-weight: var(--text-heading-4-weight);
|
||||
line-height: var(--text-heading-4-leading);
|
||||
}
|
||||
|
||||
:where(h5) {
|
||||
font-size: var(--text-heading-5-size);
|
||||
font-weight: var(--text-heading-5-weight);
|
||||
line-height: var(--text-heading-5-leading);
|
||||
}
|
||||
|
||||
:where(h6) {
|
||||
font-size: var(--text-heading-6-size);
|
||||
font-weight: var(--text-heading-6-weight);
|
||||
line-height: var(--text-heading-6-leading);
|
||||
}
|
||||
|
||||
:where(p) {
|
||||
font-family: var(--font-family-body);
|
||||
font-size: 14px;
|
||||
font-weight: var(--text-body-weight);
|
||||
line-height: var(--text-body-leading);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
:where(small) {
|
||||
font-size: var(--text-supporting-size);
|
||||
font-weight: var(--text-supporting-weight);
|
||||
line-height: var(--text-supporting-leading);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
:where(code, pre) {
|
||||
font-family: var(--font-family-code);
|
||||
font-size: var(--text-code-size);
|
||||
line-height: var(--text-code-leading);
|
||||
}
|
||||
|
||||
:where(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer astryx-theme {
|
||||
:root { color-scheme: light dark; }
|
||||
html[data-theme="light"] { color-scheme: light; }
|
||||
html[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
|
||||
:scope {
|
||||
--font-family-body: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-family-heading: Sora, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
--radius-element: 12px;
|
||||
--radius-container: 16px;
|
||||
--radius-page: 16px;
|
||||
--radius-chat: 16px;
|
||||
--text-body-size: 14px;
|
||||
--text-label-size: 14px;
|
||||
--text-large-size: 17px;
|
||||
--text-heading-3-size: 17px;
|
||||
--text-heading-2-size: 20px;
|
||||
--color-accent: light-dark(var(--color-brand), #B57FD0);
|
||||
--color-on-accent: light-dark(#FFFFFF, #1B1420);
|
||||
--color-accent-muted: light-dark(#66258218, #B57FD024);
|
||||
--color-text-accent: light-dark(var(--color-brand), #B57FD0);
|
||||
--color-icon-accent: light-dark(var(--color-brand), #B57FD0);
|
||||
--color-text-primary: light-dark(var(--color-slate-900), #ECE6F1);
|
||||
--color-text-secondary: light-dark(var(--color-slate-500), #B3A8BD);
|
||||
--color-text-disabled: light-dark(var(--color-slate-400), #85798F);
|
||||
--color-background-surface: light-dark(#FFFFFF, #1C1724);
|
||||
--color-background-body: light-dark(transparent, #14101A);
|
||||
--color-background-card: light-dark(#FFFFFF, #1C1724);
|
||||
--color-background-popover: light-dark(#FFFFFF, #241D2E);
|
||||
--color-background-muted: light-dark(var(--color-slate-100), #241D2E);
|
||||
--color-border: light-dark(var(--color-slate-200), #352C40);
|
||||
--color-border-emphasized: light-dark(var(--color-slate-300), #4A3E58);
|
||||
--color-skeleton: light-dark(var(--color-slate-100), #241D2E);
|
||||
--color-success: light-dark(#10b981, #34D399);
|
||||
--color-error: light-dark(#ef4444, #F87171);
|
||||
--color-warning: light-dark(#f59e0b, #FBBF24);
|
||||
--shadow-low: light-dark(0 2px 10px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(0, 0, 0, 0.45));
|
||||
--shadow-med: light-dark(0 4px 16px -2px rgba(15, 23, 42, 0.06), 0 14px 40px rgba(0, 0, 0, 0.55));
|
||||
--shadow-high: light-dark(0 12px 28px -6px rgba(15, 23, 42, 0.12), 0 18px 50px rgba(0, 0, 0, 0.65));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer astryx-theme {
|
||||
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
|
||||
[data-astryx-media="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-text-primary: var(--color-on-dark);
|
||||
--color-icon-primary: var(--color-on-dark);
|
||||
--color-accent: var(--color-on-dark);
|
||||
}
|
||||
|
||||
[data-astryx-media="light"] {
|
||||
color-scheme: light;
|
||||
--color-text-primary: var(--color-on-light);
|
||||
--color-icon-primary: var(--color-on-light);
|
||||
--color-accent: var(--color-on-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/theme/nearle.d.ts
vendored
Normal file
10
src/theme/nearle.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/nearle.ts
|
||||
* Command: astryx theme build src/theme/nearle.ts
|
||||
* CLI: @astryxdesign/cli@0.4.5
|
||||
* Core: @astryxdesign/core@0.4.5
|
||||
*/
|
||||
|
||||
import type { DefinedTheme } from '@astryxdesign/core/theme';
|
||||
export declare const nearleTheme: DefinedTheme;
|
||||
72
src/theme/nearle.js
Normal file
72
src/theme/nearle.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/nearle.ts
|
||||
* Command: astryx theme build src/theme/nearle.ts
|
||||
* CLI: @astryxdesign/cli@0.4.5
|
||||
* Core: @astryxdesign/core@0.4.5
|
||||
*/
|
||||
|
||||
/**
|
||||
* nearle theme — built by `npx astryx theme build`
|
||||
* Import the CSS file alongside this module:
|
||||
*
|
||||
* import { nearleTheme } from './nearle';
|
||||
* import './nearle.css';
|
||||
*/
|
||||
export const nearleTheme = {
|
||||
name: 'nearle',
|
||||
__built: true,
|
||||
tokens: {
|
||||
"--font-family-body": "Inter, ui-sans-serif, system-ui, sans-serif",
|
||||
"--font-family-heading": "Sora, ui-sans-serif, system-ui, sans-serif",
|
||||
"--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
|
||||
"--radius-element": "12px",
|
||||
"--radius-container": "16px",
|
||||
"--radius-page": "16px",
|
||||
"--radius-chat": "16px",
|
||||
"--text-body-size": "14px",
|
||||
"--text-label-size": "14px",
|
||||
"--text-large-size": "17px",
|
||||
"--text-heading-3-size": "17px",
|
||||
"--text-heading-2-size": "20px",
|
||||
"--color-accent": "light-dark(var(--color-brand), #B57FD0)",
|
||||
"--color-on-accent": "light-dark(#FFFFFF, #1B1420)",
|
||||
"--color-accent-muted": "light-dark(#66258218, #B57FD024)",
|
||||
"--color-text-accent": "light-dark(var(--color-brand), #B57FD0)",
|
||||
"--color-icon-accent": "light-dark(var(--color-brand), #B57FD0)",
|
||||
"--color-text-primary": "light-dark(var(--color-slate-900), #ECE6F1)",
|
||||
"--color-text-secondary": "light-dark(var(--color-slate-500), #B3A8BD)",
|
||||
"--color-text-disabled": "light-dark(var(--color-slate-400), #85798F)",
|
||||
"--color-background-surface": "light-dark(#FFFFFF, #1C1724)",
|
||||
"--color-background-body": "light-dark(transparent, #14101A)",
|
||||
"--color-background-card": "light-dark(#FFFFFF, #1C1724)",
|
||||
"--color-background-popover": "light-dark(#FFFFFF, #241D2E)",
|
||||
"--color-background-muted": "light-dark(var(--color-slate-100), #241D2E)",
|
||||
"--color-border": "light-dark(var(--color-slate-200), #352C40)",
|
||||
"--color-border-emphasized": "light-dark(var(--color-slate-300), #4A3E58)",
|
||||
"--color-skeleton": "light-dark(var(--color-slate-100), #241D2E)",
|
||||
"--color-success": "light-dark(#10b981, #34D399)",
|
||||
"--color-error": "light-dark(#ef4444, #F87171)",
|
||||
"--color-warning": "light-dark(#f59e0b, #FBBF24)",
|
||||
"--shadow-low": "light-dark(0 2px 10px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(0, 0, 0, 0.45))",
|
||||
"--shadow-med": "light-dark(0 4px 16px -2px rgba(15, 23, 42, 0.06), 0 14px 40px rgba(0, 0, 0, 0.55))",
|
||||
"--shadow-high": "light-dark(0 12px 28px -6px rgba(15, 23, 42, 0.12), 0 18px 50px rgba(0, 0, 0, 0.65))"
|
||||
},
|
||||
__onDark: {
|
||||
"tokens": {
|
||||
"color-scheme": "dark",
|
||||
"--color-text-primary": "var(--color-on-dark)",
|
||||
"--color-icon-primary": "var(--color-on-dark)",
|
||||
"--color-accent": "var(--color-on-dark)"
|
||||
}
|
||||
},
|
||||
__onLight: {
|
||||
"tokens": {
|
||||
"color-scheme": "light",
|
||||
"--color-text-primary": "var(--color-on-light)",
|
||||
"--color-icon-primary": "var(--color-on-light)",
|
||||
"--color-accent": "var(--color-on-light)"
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
121
src/theme/nearle.ts
Normal file
121
src/theme/nearle.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* The Nearle theme for Astryx.
|
||||
*
|
||||
* Ported from `daily_merchant_web/src/theme/nearle.ts` — every value below
|
||||
* already existed there, and the reasoning recorded in that file is preserved
|
||||
* because it is still the reason each number is what it is.
|
||||
*
|
||||
* TOKEN VALUES take one of two forms (see defineTheme's contract):
|
||||
* 'string' — same in both modes
|
||||
* ['light', 'dark'] — compiled to CSS light-dark()
|
||||
*
|
||||
* HOW MODE IS CHOSEN. Astryx switches on a `data-theme` attribute that the
|
||||
* `<Theme>` provider stamps, so `mode` on the provider is what connects this
|
||||
* to the OS setting — the stylesheet alone will not do it.
|
||||
*
|
||||
* Regenerate the compiled stylesheet after editing:
|
||||
* npx astryx theme build src/theme/nearle.ts
|
||||
* `nearle.css` is generated and must not be hand-edited.
|
||||
*/
|
||||
|
||||
import { defineTheme } from '@astryxdesign/core';
|
||||
|
||||
/**
|
||||
* The dark accent is the one value here that is not lifted from an existing
|
||||
* constant, and it is the one that needs a human's approval.
|
||||
*
|
||||
* `--color-brand` (#662582) scores 9.76:1 on white — comfortably AA. On
|
||||
* Astryx's dark surface it is unreadable, and the soft brand tint only reaches
|
||||
* 3.22:1, below the 4.5:1 threshold. Measured candidates:
|
||||
*
|
||||
* brand-soft 3.22:1 fails AA
|
||||
* #A96FC4 4.51:1 passes by 0.01 — no headroom
|
||||
* #B57FD0 5.41:1 passes with room ← chosen
|
||||
* #C48FDD 6.51:1 passes, drifts pastel
|
||||
*
|
||||
* #B57FD0 is the lightest value that still reads as the Nearle purple rather
|
||||
* than as lilac. BRAND OWNER TO CONFIRM before dark mode ships.
|
||||
*/
|
||||
const BRAND_DARK = '#B57FD0';
|
||||
|
||||
export const nearleTheme = defineTheme({
|
||||
name: 'nearle',
|
||||
|
||||
typography: {
|
||||
// Both families are pulled in by the Google Fonts @import at the top of
|
||||
// index.css. Astryx does not load fonts itself — the consumer must.
|
||||
body: { family: 'Inter', fallbacks: 'ui-sans-serif, system-ui, sans-serif' },
|
||||
heading: { family: 'Sora', fallbacks: 'ui-sans-serif, system-ui, sans-serif' },
|
||||
code: { family: 'JetBrains Mono', fallbacks: 'ui-monospace, SFMono-Regular, monospace' },
|
||||
},
|
||||
|
||||
tokens: {
|
||||
// ── Radius, matched to KROW ──────────────────────────────────────────
|
||||
// KROW uses exactly three radii: 16px on cards, 12px on controls, full on
|
||||
// pills. Astryx defaults to 8px on controls and 12px on cards, with
|
||||
// page/chat at 28px — that 28px is where the odd oversized surfaces in the
|
||||
// old console came from. `--radius-inner` (4px) is left alone: KROW does
|
||||
// use a 4px radius on its smallest inline markers.
|
||||
'--radius-element': '12px',
|
||||
'--radius-container': '16px',
|
||||
'--radius-page': '16px',
|
||||
'--radius-chat': '16px',
|
||||
|
||||
// Control heights are NOT overridden: Astryx's `--size-element-md` is
|
||||
// already 32px, which is exactly KROW's control height.
|
||||
|
||||
// ── Type scale, taken from KROW ──────────────────────────────────────
|
||||
// font-size xs 10 · sm 12 · base 14 · lg 17 · xl 20 · 2xl 24
|
||||
// body = base/400 · label = base/500 · large = lg/600
|
||||
// heading-3 = lg/600 · heading-2 = xl/600 · heading-1 = 2xl/600
|
||||
'--text-body-size': '14px',
|
||||
'--text-label-size': '14px',
|
||||
'--text-large-size': '17px',
|
||||
'--text-heading-3-size': '17px',
|
||||
'--text-heading-2-size': '20px',
|
||||
|
||||
// ── Brand ────────────────────────────────────────────────────────────
|
||||
'--color-accent': ['var(--color-brand)', BRAND_DARK],
|
||||
'--color-on-accent': ['#FFFFFF', '#1B1420'],
|
||||
// The alpha suffix mirrors the old console's `soft()` helper (18 ≈ 9%),
|
||||
// which every tinted brand surface in the app was built from.
|
||||
'--color-accent-muted': ['#66258218', `${BRAND_DARK}24`],
|
||||
'--color-text-accent': ['var(--color-brand)', BRAND_DARK],
|
||||
'--color-icon-accent': ['var(--color-brand)', BRAND_DARK],
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────
|
||||
'--color-text-primary': ['var(--color-slate-900)', '#ECE6F1'],
|
||||
'--color-text-secondary': ['var(--color-slate-500)', '#B3A8BD'],
|
||||
'--color-text-disabled': ['var(--color-slate-400)', '#85798F'],
|
||||
|
||||
// ── Surfaces ─────────────────────────────────────────────────────────
|
||||
// The dark greys carry a slight violet bias so they sit under the accent
|
||||
// as a chosen neutral rather than a stock grey.
|
||||
'--color-background-surface': ['#FFFFFF', '#1C1724'],
|
||||
'--color-background-body': ['transparent', '#14101A'],
|
||||
'--color-background-card': ['#FFFFFF', '#1C1724'],
|
||||
'--color-background-popover': ['#FFFFFF', '#241D2E'],
|
||||
'--color-background-muted': ['var(--color-slate-100)', '#241D2E'],
|
||||
|
||||
// ── Lines ────────────────────────────────────────────────────────────
|
||||
'--color-border': ['var(--color-slate-200)', '#352C40'],
|
||||
'--color-border-emphasized': ['var(--color-slate-300)', '#4A3E58'],
|
||||
'--color-skeleton': ['var(--color-slate-100)', '#241D2E'],
|
||||
|
||||
// ── Semantic ─────────────────────────────────────────────────────────
|
||||
// Dark variants are lightened for the same contrast reason as the accent —
|
||||
// the source values are tuned for white.
|
||||
'--color-success': ['#10b981', '#34D399'],
|
||||
'--color-error': ['#ef4444', '#F87171'],
|
||||
'--color-warning': ['#f59e0b', '#FBBF24'],
|
||||
|
||||
// ── Elevation ────────────────────────────────────────────────────────
|
||||
// A shadow tuned for a white ground is invisible on a dark one, so the
|
||||
// dark alphas are raised rather than reused.
|
||||
'--shadow-low': ['0 2px 10px rgba(15, 23, 42, 0.04)', '0 8px 24px rgba(0, 0, 0, 0.45)'],
|
||||
'--shadow-med': ['0 4px 16px -2px rgba(15, 23, 42, 0.06)', '0 14px 40px rgba(0, 0, 0, 0.55)'],
|
||||
'--shadow-high': ['0 12px 28px -6px rgba(15, 23, 42, 0.12)', '0 18px 50px rgba(0, 0, 0, 0.65)'],
|
||||
},
|
||||
});
|
||||
|
||||
export default nearleTheme;
|
||||
Reference in New Issue
Block a user