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

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