Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

152
src/App.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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
View 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
View 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
View 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
View 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
View 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;

View 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>
);
}

View 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" />;
}

View 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>
);
}

View 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>
);
}

View 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');
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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` : '—';
};

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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;
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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');
});

View 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)',
};

View 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,
};
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;