pos implemented

This commit is contained in:
2026-08-10 18:14:52 +05:30
parent 14e4529613
commit 31d5535b72
14 changed files with 1373 additions and 301 deletions

View File

@@ -53,6 +53,7 @@ import SettingsView from './components/SettingsView';
import StoreDetailView from './components/StoreDetailView';
import DispatchHubView from './components/DispatchHubView';
import PosConsoleView from './components/PosConsoleView';
import PosErrorBoundary from './components/PosErrorBoundary';
import LoginView from './components/LoginView';
import UserStorePage from './components/UserStorePage';
import SuperAdminPage from './components/SuperAdminPage';
@@ -676,7 +677,7 @@ export default function App() {
{/* Tenant-wide POS: no locationid, so the view fans out over every
outlet under the tenant. */}
<Route path="pos" element={
<PosConsoleView tenantId={tenantId} />
<PosErrorBoundary><PosConsoleView tenantId={tenantId} /></PosErrorBoundary>
} />
</Routes>

View File

@@ -216,6 +216,8 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
moduleid: 2,
applocationid: 1,
partnerid: 1,
// 4 is "Manager" in app_roles — the person who runs this store. It was
// labelled "Staff" here, which no role has ever been called.
roleid: 4,
});
setStoreSuccess(res);
@@ -232,8 +234,13 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
return;
}
try {
// Create base user with roleid = 5 (Rider) and configid = 1, matching the
// convention login (auth.ts) queries against.
// configid = 1 matches what login (auth.ts) queries against.
//
// roleid 5 is "Admin" in app_roles, not "Rider" — no Rider role exists.
// It is kept because nothing keys off it: riders are read through
// partners/getriders by applocationid/partnerid, and partnerRepository
// never references roleid. Changing it would rewrite existing rows for
// no functional gain. Worth a real Rider row in app_roles eventually.
const res = await createUserMut.mutateAsync({
firstname: riderForm.firstname,
lastname: riderForm.lastname,

View File

@@ -68,7 +68,9 @@ import {
type PosSalesSummary,
type PosTerminal,
} from '../services/posApi';
import PosStaffPanel from './PosStaffPanel';
import {
type PosOutletHealth,
usePosCatalogue,
usePosFleetHealth,
usePosSaleDetail,
@@ -111,14 +113,14 @@ interface PosConsoleViewProps {
storeName?: string;
}
type Tab = 'overview' | 'terminals' | 'bills' | 'catalogue' | 'exceptions';
type Tab = 'overview' | 'terminals' | 'bills' | 'catalogue' | 'staff';
const TABS: Array<{ id: Tab; label: string; icon: React.ReactNode }> = [
{ id: 'overview', label: 'Overview', icon: <TrendingUp size={13} /> },
{ id: 'terminals', label: 'Terminals', icon: <Monitor size={13} /> },
{ id: 'bills', label: 'Bills', icon: <Receipt size={13} /> },
{ id: 'catalogue', label: 'Catalogue', icon: <Package size={13} /> },
{ id: 'exceptions', label: 'Attention', icon: <AlertTriangle size={13} /> },
{ id: 'staff', label: 'Staff', icon: <Users size={13} /> },
];
const RANGES: Array<{ id: string; label: string; from: () => string }> = [
@@ -191,6 +193,26 @@ export default function PosConsoleView({
const tillsOnline = allTerminals.filter((t) => t.state === 'online').length;
const pendingTotal = allTerminals.reduce((s, t) => s + t.pendingBills, 0);
// Terminal presence and terminal sales are two different populations, and
// until now the page showed each in its own tab without ever connecting them.
//
// Presence lives in Redis and only exists while a till is heartbeating; sales
// live in Postgres and are permanent. So a till can appear on the fleet board
// having never sold anything (a commissioning probe, a terminal set up and
// never used), and a till can appear in the sales split with no board entry at
// all (its key expired, or it billed before health reporting existed).
//
// Reconciling them is what turns "three tills are dark" into "one till is
// dark, two were never in service".
const billedTerminals = useMemo(() => {
const m = new Map<string, number>();
for (const t of merged?.byterminal ?? []) {
const id = (t.terminalid || '').trim();
if (id) m.set(id, t.billcount);
}
return m;
}, [merged]);
// ── Bill list scope ────────────────────────────────────────────────────────
// /sales takes exactly one locationid, so the tenant view needs a picker.
// It defaults to the outlet that actually has bills rather than the first
@@ -239,28 +261,11 @@ export default function PosConsoleView({
const resetPaging = () => setPageno(0);
// ── Exceptions ─────────────────────────────────────────────────────────────
const exceptions = useMemo(() => {
const stranded = allTerminals.filter((t) => t.pendingBills > 0);
const idle = allTerminals.filter((t) => t.state === 'online' && t.todayBills === 0);
const dark = allTerminals.filter(
(t) => t.state === 'offline_declared' || t.state === 'offline_vanished',
);
const stale = allTerminals.filter((t) => t.state === 'stale');
const drifting = allTerminals.filter(
(t) => t.clockDriftSeconds != null && Math.abs(t.clockDriftSeconds) > 120,
);
const noTills = outlets.filter(
(o) => !(healthQ.data ?? []).some((h) => h.locationid === o.locationid && h.total > 0),
);
const delayed = bills.filter((b) => syncLag(b).kind === 'delayed');
const legacy = bills.filter((b) => syncLag(b).kind === 'legacy');
const unassigned = bills.filter((b) => !b.terminalid?.trim());
return { stranded, idle, dark, stale, drifting, noTills, delayed, legacy, unassigned };
}, [allTerminals, outlets, healthQ.data, bills]);
const attentionCount =
exceptions.stranded.length + exceptions.idle.length + exceptions.dark.length + exceptions.drifting.length;
// How many tills are holding unsynced bills. Kept after the Attention tab was
// removed, because the banner it feeds is the one warning on this page worth
// interrupting somebody for — a shop accumulating takings it has not sent
// looks entirely normal from the floor.
const strandedTills = allTerminals.filter((t) => t.pendingBills > 0).length;
// ── Render ─────────────────────────────────────────────────────────────────
@@ -374,7 +379,7 @@ export default function PosConsoleView({
<AlertTriangle size={16} style={{ color: '#f59e0b' }} className="mt-0.5 shrink-0" />
<div className="text-[13px]" style={{ color: TEXT }}>
<strong>{pendingTotal}</strong> bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '}
{exceptions.stranded.length} till{exceptions.stranded.length === 1 ? '' : 's'} and not yet synced.
{strandedTills} till{strandedTills === 1 ? '' : 's'} and not yet synced.
<button onClick={() => setTab('terminals')} className="ml-2 underline font-semibold cursor-pointer" style={{ color: BRAND }}>
See which
</button>
@@ -389,9 +394,8 @@ export default function PosConsoleView({
<React.Fragment key={t.id}>
<Pill
active={tab === t.id}
color={t.id === 'exceptions' && attentionCount > 0 ? '#f59e0b' : BRAND}
color={BRAND}
onClick={() => setTab(t.id)}
count={t.id === 'exceptions' && attentionCount > 0 ? attentionCount : undefined}
>
{t.icon}
{t.label}
@@ -424,6 +428,7 @@ export default function PosConsoleView({
outletName={outletName}
isStoreScope={isStoreScope}
loading={healthQ.isLoading}
billedTerminals={billedTerminals}
/>
)}
@@ -478,14 +483,40 @@ export default function PosConsoleView({
/>
)}
{tab === 'exceptions' && (
<ExceptionsTab
exceptions={exceptions}
outletName={outletName}
loading={healthQ.isLoading}
onOpenBill={(b) => setOpenRef(b.terminalorderid || String(b.posorderid))}
/>
{tab === 'staff' && (
<>
{!isStoreScope && (
<FilterBar>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-extrabold uppercase tracking-wide" style={{ color: TEXT_3 }}>
Store
</span>
{outlets.map((o) => (
<React.Fragment key={o.locationid}>
<Pill
active={billOutlet === o.locationid}
color={BRAND}
onClick={() => setPickedOutlet(o.locationid)}
>
{o.name}
</Pill>
</React.Fragment>
))}
</div>
</FilterBar>
)}
<PosStaffPanel
tenantId={tenantId}
locationid={billOutlet}
outletName={outletName.get(billOutlet) || storeName}
// A store user provisions counter staff only. A supervisor runs
// the terminal and creates their own cashiers there, so issuing
// one is an admin act.
cashierOnly={isStoreScope}
/>
</>
)}
</div>
<SlideDrawer isOpen={Boolean(openRef)} onClose={() => setOpenRef('')} title="Bill">
@@ -666,33 +697,55 @@ function TerminalsTab({
outletName,
isStoreScope,
loading,
billedTerminals,
}: {
health: Array<{ locationid: number; total: number; online: number; terminals: PosTerminal[] }>;
health: PosOutletHealth[];
outletName: Map<number, string>;
isStoreScope: boolean;
loading: boolean;
/** terminalid → bills in the current range. Empty means never sold here. */
billedTerminals: Map<string, number>;
}) {
if (loading) return <SkeletonPanel lines={4} />;
const withTills = health.filter((h) => h.total > 0);
const without = health.filter((h) => h.total === 0);
// Terminals that rang bills but have no presence record at all. They cannot
// appear as cards below, so they would otherwise be invisible on this tab
// while sitting plainly in the sales split.
const present = new Set(health.flatMap((h) => h.terminals.map((t) => t.terminalId)));
const billedButAbsent = [...billedTerminals.entries()].filter(([id]) => !present.has(id));
const failed = health.filter((h) => h.error);
if (withTills.length === 0) {
// Name the outlets actually asked about. "No terminals registered" on its
// own is indistinguishable from looking at the wrong tenant — and the two
// are easy to confuse here, because the outlet that has tills (1135)
// shares its number with a different tenant that has none.
const checked = health.map((h) => outletName.get(h.locationid) || `Store ${h.locationid}`);
return (
<>
{failed.length > 0 && <HealthErrorNotice failed={failed} outletName={outletName} />}
<EmptyState
icon={<Monitor size={30} />}
title="No terminals registered"
title={failed.length ? 'Terminal status unavailable' : 'No terminals registered'}
body={
isStoreScope
failed.length
? 'The terminal board could not be read for the outlets below, so this is not a statement that no tills exist.'
: isStoreScope
? 'No till has ever reported from this store. A terminal appears here the first time it sends a heartbeat.'
: `None of these ${health.length} outlets has a till reporting. A terminal appears the first time it sends a heartbeat.`
: `Checked ${checked.length} outlet${checked.length === 1 ? '' : 's'} — ${checked.slice(0, 6).join(', ')}${checked.length > 6 ? `, and ${checked.length - 6} more` : ''}. None has a till reporting. A terminal appears the first time it sends a heartbeat.`
}
/>
</>
);
}
return (
<>
{failed.length > 0 && <HealthErrorNotice failed={failed} outletName={outletName} />}
{withTills.map((h) => (
<div key={h.locationid}>
{!isStoreScope && (
@@ -709,13 +762,34 @@ function TerminalsTab({
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{h.terminals.map((t) => (
<React.Fragment key={`${h.locationid}-${t.terminalId}`}>
<TerminalCard t={t} />
<TerminalCard t={t} bills={billedTerminals.get(t.terminalId) ?? 0} />
</React.Fragment>
))}
</div>
</div>
))}
{billedButAbsent.length > 0 && (
<Card className="p-4">
<PanelTitle
icon={<AlertTriangle size={14} />}
title={`${billedButAbsent.length} terminal${billedButAbsent.length === 1 ? '' : 's'} sold but never reported`}
hint="Bills exist under these codes, but no heartbeat has ever been recorded — presence is kept in Redis under a TTL, so it does not survive a terminal that stopped reporting"
color="#f59e0b"
/>
<div className="mt-3 divide-y" style={{ borderColor: DIVIDER }}>
{billedButAbsent.map(([id, count]) => (
<div key={id} className="flex items-center justify-between py-2">
<span className="font-mono font-bold text-[12.5px]" style={{ color: TEXT }}>{id}</span>
<span className="text-[12px]" style={{ color: TEXT_2 }}>
{count} bill{count === 1 ? '' : 's'}
</span>
</div>
))}
</div>
</Card>
)}
{without.length > 0 && !isStoreScope && (
<Card className="p-4">
<PanelTitle icon={<Store size={14} />} title={`${without.length} outlet${without.length === 1 ? '' : 's'} with no till`} hint="Never registered a terminal" />
@@ -736,9 +810,45 @@ function TerminalsTab({
);
}
function TerminalCard({ t }: { t: PosTerminal }) {
/** Outlets whose terminal board could not be read. Kept visually distinct from
* "no terminals", which is a fact rather than a failure. */
function HealthErrorNotice({
failed,
outletName,
}: {
failed: Array<{ locationid: number; error?: string }>;
outletName: Map<number, string>;
}) {
return (
<div
className="rounded-2xl border px-4 py-3 flex items-start gap-3"
style={{ borderColor: edge('#ef4444'), background: tint('#ef4444') }}
>
<AlertTriangle size={16} style={{ color: '#ef4444' }} className="mt-0.5 shrink-0" />
<div className="text-[13px] min-w-0" style={{ color: TEXT }}>
<strong>
Could not read terminal status for {failed.length} outlet{failed.length === 1 ? '' : 's'}.
</strong>{' '}
This is a failure to ask, not an answer — tills at these shops may well be running.
<ul className="mt-1.5 space-y-0.5">
{failed.slice(0, 5).map((h) => (
<li key={h.locationid} className="text-[11.5px]" style={{ color: TEXT_2 }}>
{outletName.get(h.locationid) || `Store ${h.locationid}`} — {h.error}
</li>
))}
</ul>
</div>
</div>
);
}
function TerminalCard({ t, bills }: { t: PosTerminal; bills: number }) {
const color = TERMINAL_STATE_COLOR[t.state];
const drift = t.clockDriftSeconds != null && Math.abs(t.clockDriftSeconds) > 120;
// Heartbeating (or once did) but has never filed a bill in this range. 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.
const neverSold = bills === 0;
return (
<Card className="p-4">
@@ -753,12 +863,16 @@ function TerminalCard({ t }: { t: PosTerminal }) {
</div>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<StatusChip label={TERMINAL_STATE_LABEL[t.state]} color={color} />
{neverSold && <StatusChip label="Never sold" color={TEXT_3} />}
</div>
</div>
{t.state === 'offline_vanished' ? (
<p className="text-[11.5px] mt-3" style={{ color: TEXT_2 }}>
{t.reason || 'No heartbeat inside the presence window.'}
{neverSold && ' It has never filed a bill here, so this is most likely a terminal that was never put into service rather than one that failed.'}
</p>
) : (
<>
@@ -1318,218 +1432,6 @@ function CatalogueTab({
);
}
// ── Exceptions ───────────────────────────────────────────────────────────────
function ExceptionsTab({
exceptions,
outletName,
loading,
onOpenBill,
}: {
exceptions: {
stranded: Array<PosTerminal & { locationid: number }>;
idle: Array<PosTerminal & { locationid: number }>;
dark: Array<PosTerminal & { locationid: number }>;
stale: Array<PosTerminal & { locationid: number }>;
drifting: Array<PosTerminal & { locationid: number }>;
noTills: Array<{ locationid: number; name: string }>;
delayed: PosBill[];
legacy: PosBill[];
unassigned: PosBill[];
};
outletName: Map<number, string>;
loading: boolean;
onOpenBill: (b: PosBill) => void;
}) {
if (loading) return <SkeletonPanel lines={4} />;
const where = (t: { locationid: number }) => outletName.get(t.locationid) || `Store ${t.locationid}`;
const nothing =
exceptions.stranded.length === 0 &&
exceptions.idle.length === 0 &&
exceptions.dark.length === 0 &&
exceptions.stale.length === 0 &&
exceptions.drifting.length === 0 &&
exceptions.delayed.length === 0;
if (nothing) {
return (
<EmptyState
icon={<Wifi size={30} />}
title="Nothing needs attention"
body="Every till is reporting, nothing is queued unsynced, and no bill took an unusual time to arrive."
/>
);
}
return (
<>
<ExceptionGroup
tone="#ef4444"
title="Unsynced takings"
hint="A shop accumulating these looks completely normal from the floor"
rows={exceptions.stranded.map((t) => ({
key: `${t.locationid}-${t.terminalId}`,
primary: t.terminalId,
secondary: where(t),
value: `${t.pendingBills} bill${t.pendingBills === 1 ? '' : 's'}`,
note: t.oldestPendingAt ? `oldest ${billTime(t.oldestPendingAt)}` : undefined,
}))}
/>
<ExceptionGroup
tone="#ef4444"
title="Tills not reporting"
hint="Declared offline by the broker's last will, or the heartbeat simply expired"
rows={exceptions.dark.map((t) => ({
key: `${t.locationid}-${t.terminalId}`,
primary: t.terminalId,
secondary: where(t),
value: TERMINAL_STATE_LABEL[t.state],
note: t.reason || undefined,
}))}
/>
<ExceptionGroup
tone="#f59e0b"
title="Online but selling nothing"
hint="Usually a jammed printer or an absent cashier — invisible on a plain online/offline board"
rows={exceptions.idle.map((t) => ({
key: `${t.locationid}-${t.terminalId}`,
primary: t.terminalId,
secondary: where(t),
value: '0 bills today',
note: t.lastBillAt ? `last bill ${billTime(t.lastBillAt)}` : 'no bill recorded',
}))}
/>
<ExceptionGroup
tone="#f59e0b"
title="Heartbeat going stale"
hint="No reading for over 40s. Not yet offline — the presence window is 90s"
rows={exceptions.stale.map((t) => ({
key: `${t.locationid}-${t.terminalId}`,
primary: t.terminalId,
secondary: where(t),
value: t.ageSeconds != null ? `${humanDuration(t.ageSeconds)} ago` : 'unknown',
}))}
/>
<ExceptionGroup
tone="#f59e0b"
title="Clock drift"
hint="A till with a wrong clock files bills under the wrong business date"
rows={exceptions.drifting.map((t) => ({
key: `${t.locationid}-${t.terminalId}`,
primary: t.terminalId,
secondary: where(t),
value: humanDuration(t.clockDriftSeconds as number),
note: (t.clockDriftSeconds as number) > 0 ? 'ahead of server' : 'behind server',
}))}
/>
{exceptions.delayed.length > 0 && (
<Card className="p-4">
<PanelTitle
icon={<Clock size={14} />}
title={`${exceptions.delayed.length} bill${exceptions.delayed.length === 1 ? '' : 's'} arrived late`}
hint="Over 15 minutes between being rung and reaching us — an outage backlog"
/>
<div className="mt-3 space-y-1.5">
{exceptions.delayed.slice(0, 12).map((b) => {
const lag = syncLag(b);
return (
<button
key={b.posorderid}
onClick={() => onOpenBill(b)}
className="w-full flex items-center justify-between text-[12.5px] py-1 cursor-pointer hover:opacity-70"
>
<span className="font-mono font-bold" style={{ color: TEXT }}>{b.invoicenumber || `#${b.posorderid}`}</span>
<span style={{ color: '#f59e0b' }}>
{lag.kind === 'delayed' ? humanDuration(lag.seconds) : '—'}
</span>
</button>
);
})}
</div>
</Card>
)}
{(exceptions.legacy.length > 0 || exceptions.unassigned.length > 0) && (
<Card className="p-4">
<PanelTitle icon={<AlertTriangle size={14} />} title="Known legacy data" hint="Not alerts — recorded so the panels above read honestly" />
<ul className="mt-3 space-y-1.5 text-[12px]" style={{ color: TEXT_2 }}>
{exceptions.legacy.length > 0 && (
<li>
<strong style={{ color: TEXT }}>{exceptions.legacy.length}</strong> bill
{exceptions.legacy.length === 1 ? '' : 's'} on this page carry a pre-fix timestamp (local time stamped as
UTC), so their sync lag is not measurable. Business dates are unaffected.
</li>
)}
{exceptions.unassigned.length > 0 && (
<li>
<strong style={{ color: TEXT }}>{exceptions.unassigned.length}</strong> bill
{exceptions.unassigned.length === 1 ? '' : 's'} have no terminal id — ingested before the fallback landed.
They group under “Unassigned”.
</li>
)}
</ul>
</Card>
)}
{exceptions.noTills.length > 0 && (
<Card className="p-4">
<PanelTitle icon={<Store size={14} />} title={`${exceptions.noTills.length} outlet${exceptions.noTills.length === 1 ? '' : 's'} with no till`} />
<div className="flex flex-wrap gap-1.5 mt-3">
{exceptions.noTills.map((o) => (
<span
key={o.locationid}
className="rounded-full px-2.5 py-1 text-[11px] font-semibold"
style={{ background: SURFACE_ALT, color: TEXT_2, border: `1px solid ${BORDER}` }}
>
{o.name}
</span>
))}
</div>
</Card>
)}
</>
);
}
function ExceptionGroup({
tone,
title,
hint,
rows,
}: {
tone: string;
title: string;
hint?: string;
rows: Array<{ key: string; primary: string; secondary: string; value: string; note?: string }>;
}) {
if (rows.length === 0) return null;
return (
<Card className="p-4">
<PanelTitle icon={<AlertTriangle size={14} />} title={`${title} (${rows.length})`} hint={hint} color={tone} />
<div className="mt-3 divide-y" style={{ borderColor: DIVIDER }}>
{rows.map((r) => (
<div key={r.key} className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<div className="font-mono font-bold text-[12.5px]" style={{ color: TEXT }}>{r.primary || '—'}</div>
<div className="text-[11px] truncate" style={{ color: TEXT_3 }}>{r.secondary}</div>
</div>
<div className="text-right shrink-0">
<div className="font-extrabold text-[12.5px]" style={{ color: tone }}>{r.value}</div>
{r.note && <div className="text-[10.5px]" style={{ color: TEXT_3 }}>{r.note}</div>}
</div>
</div>
))}
</div>
</Card>
);
}
// ── Small shared pieces ──────────────────────────────────────────────────────
/**

View File

@@ -0,0 +1,82 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Catches a render-time crash inside the POS console.
*
* The page reads several loosely-typed surfaces — a Redis hash where every
* value is a string, three JSON columns parsed out of bill rows, and a
* catalogue whose optional fields are absent rather than null. Query failures
* are already handled, but a shape nobody anticipated throws during render, and
* without a boundary React unmounts the whole tree: the operator gets a blank
* page with no indication that anything happened.
*
* Deliberately scoped to this page rather than the app, so a crash here cannot
* take out the surrounding console, and the retry re-mounts only this subtree.
*/
import React from 'react';
import { AlertTriangle } from 'lucide-react';
interface Props {
children: React.ReactNode;
}
interface State {
error: Error | null;
}
export default class PosErrorBoundary extends React.Component<Props, State> {
// This project has no `@types/react` installed, so `React.Component`'s
// inherited members are not typed and `this.props` / `this.setState` do not
// resolve. (It is the same gap that makes `key` unassignable on a custom
// component, which is why the codebase wraps keyed elements in fragments.)
// Re-declared here rather than cast at each use — `declare` emits nothing and
// keeps the bodies below honest. Delete once the types are added.
declare props: Props;
declare setState: (state: State) => void;
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// Logged rather than reported: there is no error sink in this app yet, and
// swallowing it silently would make the boundary worse than the crash.
console.error('[POS console] render failed:', error, info.componentStack);
}
render() {
if (!this.state.error) return this.props.children;
return (
<div className="flex items-center justify-center py-16">
<div className="bg-white border border-slate-200/70 p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)] rounded-2xl">
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
<AlertTriangle size={30} />
</div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">
The POS screen hit a problem
</h1>
<p className="text-[15px] text-slate-500 leading-relaxed mb-2">
Counter sales are unaffected — bills keep syncing from the tills whatever this page
does. Only the display failed.
</p>
<p className="text-[12px] text-slate-400 font-mono break-words mb-6">
{this.state.error.message}
</p>
<button
onClick={() => this.setState({ error: null })}
className="px-5 py-2.5 bg-[#662582] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
>
Try again
</button>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,656 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Till staff — the supervisors and cashiers who sign in at a counter.
*
* Deliberately not part of Users & Access. A till account is not a Nearle
* Daily user: it has no app login (the backend excludes roleid 7 and 8 from
* every web login lookup), its username and password are minted server-side,
* and it carries a PIN. Listing the two together would imply an
* interchangeability that does not exist, and creating one through
* `users/create` produces a row that cannot open a till.
*
* The one thing this screen must get right: **the password exists exactly
* once**, in the answer to a create. It is not stored anywhere the console can
* read back and the listing endpoint omits it. If it is not copied from that
* modal it is gone, and the only remedy is to issue a new account.
*/
import React, { useMemo, useState } from 'react';
import {
AlertTriangle,
Check,
Copy,
KeyRound,
Pencil,
Plus,
ShieldCheck,
Trash2,
UserPlus,
Users,
} from 'lucide-react';
import {
POS_ROLE_CASHIER,
generatePosPassword,
type PosRoleOption,
type PosStaffUser,
} from '../services/fiestaApi';
import {
useCreatePosStaff,
useDeactivatePosStaff,
usePosRoles,
usePosStaff,
useUpdatePosStaff,
} from '../services/fiestaQueries';
import {
BORDER,
BRAND,
Card,
DIVIDER,
Pill,
StatusChip,
SURFACE_ALT,
TEXT,
TEXT_2,
TEXT_3,
TH_STYLE,
edge,
tint,
} from './consoleUi';
interface PosStaffPanelProps {
tenantId: number;
/** Resolved outlet. Callers pick it; this panel never guesses one. */
locationid: number;
outletName?: string;
/**
* Store workspace. Restricts creation to Cashier — a supervisor runs the
* terminal and creates counter staff themselves, so provisioning one is an
* admin act.
*/
cashierOnly?: boolean;
}
/** Fallback only. The picker is driven by `/tenants/posroles` so a till role
* added later needs no frontend release; this covers the API being down. */
const FALLBACK_ROLES: PosRoleOption[] = [
{ role_id: 8, role: 'cashier', label: 'Cashier', description: 'Billing only.' },
{ role_id: 7, role: 'supervisor', label: 'Supervisor', description: 'Runs the terminal.' },
];
export default function PosStaffPanel({
tenantId,
locationid,
outletName,
cashierOnly,
}: PosStaffPanelProps) {
const staffQ = usePosStaff(tenantId, locationid);
const rolesQ = usePosRoles();
const createMut = useCreatePosStaff();
const updateMut = useUpdatePosStaff();
const deactivateMut = useDeactivatePosStaff();
const [showForm, setShowForm] = useState(false);
const [fullName, setFullName] = useState('');
const [role, setRole] = useState('');
const [pin, setPin] = useState('');
const [formError, setFormError] = useState('');
const [issued, setIssued] = useState<PosStaffUser | null>(null);
const [copied, setCopied] = useState(false);
const [confirmOff, setConfirmOff] = useState<PosStaffUser | null>(null);
// Editing an existing person. Name, role and PIN only — the username is left
// alone because it is what a supervisor already types at the counter, and
// changing it silently locks them out.
const [editing, setEditing] = useState<PosStaffUser | null>(null);
const [editName, setEditName] = useState('');
const [editRole, setEditRole] = useState('');
const [editPin, setEditPin] = useState('');
const [editError, setEditError] = useState('');
const [confirmReset, setConfirmReset] = useState<PosStaffUser | null>(null);
const [rowError, setRowError] = useState('');
const roles = useMemo(() => {
const rows = rolesQ.data?.length ? rolesQ.data : FALLBACK_ROLES;
return cashierOnly ? rows.filter((r) => r.role_id === POS_ROLE_CASHIER) : rows;
}, [rolesQ.data, cashierOnly]);
const staff = staffQ.data ?? [];
const activeCount = staff.filter((s) => (s.status || '').toLowerCase() !== 'inactive').length;
const resetForm = () => {
setFullName('');
setRole('');
setPin('');
setFormError('');
};
const submit = () => {
const name = fullName.trim();
const chosen = role || (roles.length === 1 ? roles[0].role : '');
if (!name) return setFormError('Enter the person’s name.');
if (!chosen) return setFormError('Choose a role.');
if (!/^\d{4}$/.test(pin)) return setFormError('The PIN must be exactly 4 digits.');
// Checked here rather than left to the server: two people sharing a PIN at
// one counter both appear on the till's sign-in list and neither can be
// told apart on a bill.
if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
return setFormError('Another active person at this outlet already uses that PIN.');
}
setFormError('');
createMut.mutate(
{ tenantid: tenantId, locationid, full_name: name, role: chosen, pin },
{
onSuccess: (created) => {
setIssued(created);
setCopied(false);
setShowForm(false);
resetForm();
},
onError: (e: unknown) => setFormError((e as Error)?.message || 'Could not create the account.'),
},
);
};
const openEdit = (s: PosStaffUser) => {
setEditing(s);
setEditName(s.full_name || '');
setEditRole(s.role_id === POS_ROLE_CASHIER ? 'cashier' : 'supervisor');
setEditPin(s.pin || '');
setEditError('');
};
const saveEdit = () => {
if (!editing) return;
const name = editName.trim();
if (!name) return setEditError('Enter the person’s name.');
if (editPin && !/^\d{4}$/.test(editPin)) return setEditError('The PIN must be exactly 4 digits.');
updateMut.mutate(
{
tenantid: tenantId,
locationid,
user_id: editing.user_id,
full_name: name,
// Only sent when it actually changed — the server treats any non-empty
// role as a change, and a no-op write still moves the row.
role: editRole && editRole !== (editing.role || '').toLowerCase() ? editRole : undefined,
pin: editPin && editPin !== editing.pin ? editPin : undefined,
},
{
onSuccess: () => setEditing(null),
// The server enforces PIN uniqueness under an advisory lock, so a clash
// surfaces here rather than being prevented in the browser.
onError: (e: unknown) => setEditError((e as Error)?.message || 'Could not save the changes.'),
},
);
};
/**
* Issue a replacement password.
*
* There is nothing to "recover" — the original was shown once and stored
* nowhere readable. A reset generates a new one in the browser, writes it,
* and shows it under the same once-only rules as a create.
*/
const resetPassword = (s: PosStaffUser) => {
const password = generatePosPassword();
updateMut.mutate(
{ tenantid: tenantId, locationid, user_id: s.user_id, password },
{
onSuccess: () => {
setConfirmReset(null);
setIssued({ ...s, password, has_password: true });
setCopied(false);
},
onError: (e: unknown) => {
setConfirmReset(null);
setRowError((e as Error)?.message || 'Could not reset the password.');
},
},
);
};
const reactivate = (s: PosStaffUser) => {
setRowError('');
updateMut.mutate(
{ tenantid: tenantId, locationid, user_id: s.user_id, status: 'Active' },
{ onError: (e: unknown) => setRowError((e as Error)?.message || 'Could not reactivate.') },
);
};
const copyCredentials = () => {
if (!issued) return;
const text = [
`Outlet: ${outletName || locationid}`,
`Name: ${issued.full_name}`,
`Role: ${issued.role}`,
`Username: ${issued.authname ?? ''}`,
`Password: ${issued.password ?? ''}`,
`PIN: ${issued.pin ?? ''}`,
].join('\n');
navigator.clipboard?.writeText(text).then(
() => setCopied(true),
() => setCopied(false),
);
};
return (
<>
<Card className="p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-start gap-2">
<Users size={14} style={{ color: TEXT_2 }} className="mt-0.5" />
<div>
<h3 className="text-[13px] font-extrabold leading-tight" style={{ color: TEXT }}>
Till staff{outletName ? ` · ${outletName}` : ''}
</h3>
<p className="text-[11px] mt-0.5" style={{ color: TEXT_3 }}>
{activeCount} active. These accounts sign in at a counter only — they have no
Nearle Daily login.
</p>
</div>
</div>
<Pill active={showForm} color={BRAND} onClick={() => { setShowForm(!showForm); setFormError(''); }}>
<Plus size={12} />
{cashierOnly ? 'Add cashier' : 'Add staff'}
</Pill>
</div>
{showForm && (
<div className="mt-4 rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Full name"
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
/>
<select
value={role || (roles.length === 1 ? roles[0].role : '')}
onChange={(e) => setRole(e.target.value)}
disabled={roles.length === 1}
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer disabled:cursor-default"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
>
{roles.length !== 1 && <option value="">Choose a role…</option>}
{roles.map((r) => (
<option key={r.role_id} value={r.role}>{r.label}</option>
))}
</select>
<input
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="4-digit PIN"
inputMode="numeric"
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono tracking-widest"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
/>
</div>
<p className="text-[11px] mt-2" style={{ color: TEXT_3 }}>
A username and password are generated. The password is shown once, on the next screen.
</p>
{formError && (
<p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{formError}</p>
)}
<div className="flex flex-wrap items-center gap-2 mt-3">
<button
onClick={submit}
disabled={createMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
style={{ background: BRAND }}
>
{createMut.isPending ? 'Creating…' : 'Create account'}
</button>
<button
onClick={() => { setShowForm(false); resetForm(); }}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
</div>
</div>
)}
</Card>
{staffQ.isLoading ? (
<Card className="p-4">
<div className="animate-pulse rounded-lg" style={{ height: 18, width: '60%', background: SURFACE_ALT }} />
</Card>
) : staff.length === 0 ? (
<Card className="w-full min-w-0">
<div className="mx-auto flex flex-col items-center text-center px-6 py-12" style={{ maxWidth: 420 }}>
<div
className="h-16 w-16 rounded-2xl flex items-center justify-center mb-5"
style={{ background: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}` }}
>
<UserPlus size={30} />
</div>
<h3 className="text-[17px] font-extrabold mb-2" style={{ color: TEXT }}>No till staff yet</h3>
<p className="text-[13px] leading-relaxed" style={{ color: TEXT_2 }}>
Nobody can sign in at a counter here. Add a person and the system issues their
username, password and PIN.
</p>
</div>
</Card>
) : (
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm w-full min-w-0" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto">
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth: 720 }}>
<thead style={TH_STYLE}>
<tr>
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left">Username</th>
<th className="px-4 py-3 text-left">PIN</th>
<th className="px-4 py-3 text-left">Password</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-right">&nbsp;</th>
</tr>
</thead>
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
{staff.map((s) => {
const inactive = (s.status || '').toLowerCase() === 'inactive';
return (
<tr key={s.user_id} className="hover:bg-slate-50/70" style={{ opacity: inactive ? 0.55 : 1 }}>
<td className="px-4 py-3 font-bold" style={{ color: TEXT }}>{s.full_name || `#${s.user_id}`}</td>
<td className="px-4 py-3">
<StatusChip
label={s.role || String(s.role_id)}
color={s.role_id === POS_ROLE_CASHIER ? BRAND : '#0ea5e9'}
/>
</td>
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: TEXT_2 }}>{s.authname || '—'}</td>
<td className="px-4 py-3 font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</td>
<td className="px-4 py-3">
{s.has_password ? (
<span className="inline-flex items-center gap-1 text-[11.5px] font-semibold" style={{ color: '#10b981' }}>
<ShieldCheck size={12} /> Set
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11.5px] font-semibold" style={{ color: '#f59e0b' }}>
<AlertTriangle size={12} /> Not set
</span>
)}
</td>
<td className="px-4 py-3">
<StatusChip label={inactive ? 'Inactive' : 'Active'} color={inactive ? TEXT_3 : '#10b981'} />
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1.5">
{inactive ? (
<button
onClick={() => reactivate(s)}
disabled={updateMut.isPending}
className="rounded-full px-3 py-1 text-[11.5px] font-bold cursor-pointer disabled:opacity-50"
style={{ background: tint('#10b981'), color: '#10b981', border: `1px solid ${edge('#10b981')}` }}
>
Reactivate
</button>
) : (
<>
<IconBtn title="Edit" color={BRAND} onClick={() => openEdit(s)}>
<Pencil size={13} />
</IconBtn>
<IconBtn title="Reset password" color="#0ea5e9" onClick={() => setConfirmReset(s)}>
<KeyRound size={13} />
</IconBtn>
<IconBtn title="Deactivate" color="#ef4444" onClick={() => setConfirmOff(s)}>
<Trash2 size={13} />
</IconBtn>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{rowError && (
<p className="text-[12px] font-semibold px-1" style={{ color: '#ef4444' }}>{rowError}</p>
)}
{/* Edit — name, role and PIN. Username stays put; it is what a supervisor
already types at the counter. */}
{editing && (
<Modal onClose={() => setEditing(null)} title={`Edit ${editing.full_name}`}>
<div className="space-y-2">
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Full name"
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
/>
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
disabled={cashierOnly}
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none cursor-pointer disabled:cursor-default"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
>
{roles.map((r) => (
<option key={r.role_id} value={r.role}>{r.label}</option>
))}
</select>
<input
value={editPin}
onChange={(e) => setEditPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="4-digit PIN"
inputMode="numeric"
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono tracking-widest"
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
/>
</div>
{editError && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{editError}</p>}
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
onClick={saveEdit}
disabled={updateMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
style={{ background: BRAND }}
>
{updateMut.isPending ? 'Saving…' : 'Save'}
</button>
<button
onClick={() => setEditing(null)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
</div>
</Modal>
)}
{confirmReset && (
<Modal onClose={() => setConfirmReset(null)} title={`Reset password for ${confirmReset.full_name}?`}>
<p className="text-[12.5px] leading-relaxed" style={{ color: TEXT_2 }}>
A new password is generated and shown once. The old one stops working immediately, so
anyone signed in at a counter with it will be refused on their next sign-in.
</p>
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
onClick={() => resetPassword(confirmReset)}
disabled={updateMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
style={{ background: '#0ea5e9' }}
>
{updateMut.isPending ? 'Resetting…' : 'Reset password'}
</button>
<button
onClick={() => setConfirmReset(null)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
</div>
</Modal>
)}
{/* One-time credentials. The password is not recoverable after this. */}
{issued && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/45" onClick={() => setIssued(null)} />
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6" style={{ border: `1px solid ${BORDER}`, minWidth: 'min(20rem, 100%)' }}>
<div className="flex items-center gap-2 mb-1">
<KeyRound size={16} style={{ color: BRAND }} />
<h3 className="text-[16px] font-extrabold" style={{ color: TEXT }}>Account created</h3>
</div>
<p className="text-[12.5px] mb-4" style={{ color: '#b45309' }}>
This password is shown once and cannot be looked up again. Copy it now.
</p>
<div className="rounded-xl p-3 space-y-2" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<Field label="Name" value={issued.full_name} />
<Field label="Role" value={issued.role} />
<Field label="Username" value={issued.authname || '—'} mono />
<Field label="Password" value={issued.password || '—'} mono />
<Field label="PIN" value={issued.pin || '—'} mono />
</div>
{issued.authname && (
<p className="text-[11px] mt-2" style={{ color: TEXT_3 }}>
The username is generated. If this outlet already had one, it continues the series —
use exactly what is shown above.
</p>
)}
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
onClick={copyCredentials}
className="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer"
style={{ background: BRAND }}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied' : 'Copy all'}
</button>
<button
onClick={() => setIssued(null)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
{copied ? 'Done' : 'Close without copying'}
</button>
</div>
</div>
</div>
)}
{/* Uses the same Modal as edit and reset. It was a hand-rolled copy of
the same markup, which is how it ended up being the one place the
width bug was noticed rather than one of three. */}
{confirmOff && (
<Modal onClose={() => setConfirmOff(null)} title={`Deactivate ${confirmOff.full_name}?`}>
<p className="text-[12.5px] mb-4 leading-relaxed" style={{ color: TEXT_2 }}>
They will be refused at the till straight away. The account is kept rather than
deleted, because past bills carry their name.
</p>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() =>
deactivateMut.mutate(
{ tenantid: tenantId, locationid, userid: confirmOff.user_id },
{ onSettled: () => setConfirmOff(null) },
)
}
disabled={deactivateMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50 whitespace-nowrap"
style={{ background: '#ef4444' }}
>
{deactivateMut.isPending ? 'Deactivating…' : 'Deactivate'}
</button>
<button
onClick={() => setConfirmOff(null)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer whitespace-nowrap"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
</div>
</Modal>
)}
</>
);
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-3 text-[12.5px]">
<span style={{ color: TEXT_3 }}>{label}</span>
<span className={mono ? 'font-mono font-bold' : 'font-semibold'} style={{ color: TEXT }}>{value}</span>
</div>
);
}
function IconBtn({
title,
color,
onClick,
children,
}: {
title: string;
color: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
title={title}
className="inline-flex items-center justify-center rounded-full cursor-pointer transition-colors"
style={{ width: 28, height: 28, background: tint(color), color, border: `1px solid ${edge(color)}` }}
>
{children}
</button>
);
}
/** Centred dialog. Local rather than SlideDrawer: these are short confirmations,
* and a full-height panel sliding in for a two-line question reads as heavier
* than the decision actually is. */
function Modal({
title,
onClose,
children,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/45" onClick={onClose} />
<div
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6"
// A floor as well as a ceiling. `max-w-sm` was resolving to 8px until
// the utility was repaired in index.css, which collapsed this card to
// its min-content width; the floor means a future theme change cannot
// reproduce that, and `min(24rem, 100%)` still lets it shrink on a
// narrow phone rather than forcing a horizontal scroll.
style={{ border: `1px solid ${BORDER}`, minWidth: 'min(20rem, 100%)' }}
>
{/* Names come from user input and can be long and unbroken. */}
<h3 className="text-[16px] font-extrabold mb-3 break-words" style={{ color: TEXT }}>
{title}
</h3>
{children}
</div>
</div>
);
}

View File

@@ -251,7 +251,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
})
.filter((r) => r.id > 0);
if (mapped.length) return mapped;
return [1, 2, 3, 4, 6].map((id) => ({ id, name: roleName(id) }));
// app_roles ids that are Nearle Daily roles. 7/8 are till-only and are
// excluded from every web login lookup server-side, so they never belong
// in a console role picker.
return [1, 2, 3, 4, 5, 6].map((id) => ({ id, name: roleName(id) }));
}, [rolesQ.data]);
return (

View File

@@ -38,6 +38,7 @@ import DeliveryReportsView from './DeliveryReportsView';
import StoreQRView from './StoreQRView';
import PosView from './PosView';
import PosConsoleView from './PosConsoleView';
import PosErrorBoundary from './PosErrorBoundary';
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
import ComparisonModal from './ComparisonModal';
interface UserStorePageProps {
@@ -489,5 +490,9 @@ function PosStoreSection({
);
}
return <PosConsoleView tenantId={tenantId} locationid={locationid} storeName={storeName} />;
return (
<PosErrorBoundary>
<PosConsoleView tenantId={tenantId} locationid={locationid} storeName={storeName} />
</PosErrorBoundary>
);
}

View File

@@ -25,6 +25,7 @@ import {
Check,
SlidersHorizontal,
Coins,
ShieldCheck,
Store,
Bike,
Clock
@@ -46,13 +47,21 @@ const USER_AVATARS = [
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80',
];
// Labels follow `app_roles`, which is the only place role names actually live.
// 3 and 5 are both "Admin" and 4 and 6 are both "Manager" — the same role under
// two configids (15 and 14). Preserved rather than tidied, because the ids are
// what the database and the backend both key on.
const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; label: string }> = {
1: { bg: 'bg-rose-50/75', text: 'text-rose-700', border: 'border-rose-100', label: 'Owner' },
2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Admin' },
1: { bg: 'bg-rose-50/75', text: 'text-rose-700', border: 'border-rose-100', label: 'Super admin' },
2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Operations' },
3: { bg: 'bg-blue-50/75', text: 'text-blue-700', border: 'border-blue-100', label: 'Admin' },
4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Staff' },
5: { bg: 'bg-purple-50/75', text: 'text-purple-700', border: 'border-purple-100', label: 'Rider' },
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Manager' },
5: { bg: 'bg-sky-50/75', text: 'text-sky-700', border: 'border-sky-100', label: 'Admin' },
6: { bg: 'bg-teal-50/75', text: 'text-teal-700', border: 'border-teal-100', label: 'Manager' },
// Till-only. Never returned by getallusers — the backend excludes them from
// every web login lookup — so these are here for completeness, not display.
7: { bg: 'bg-purple-50/75', text: 'text-purple-700', border: 'border-purple-100', label: 'Supervisor' },
8: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
};
/** Cosmetic icon + blurb per role id, used to keep the add-user role cards styled. */
@@ -60,17 +69,17 @@ const ROLE_META: Record<number, { icon: typeof ShieldAlert; desc: string }> = {
1: { icon: ShieldAlert, desc: 'Full business access' },
2: { icon: Shield, desc: 'Operations control' },
3: { icon: SlidersHorizontal, desc: 'Manage store settings' },
4: { icon: User, desc: 'Standard staff duties' },
6: { icon: Coins, desc: 'Checkout & registers' },
4: { icon: User, desc: 'Runs a store day to day' },
5: { icon: SlidersHorizontal, desc: 'Manage store settings' },
6: { icon: User, desc: 'Runs a store day to day' },
};
/** Fallback role choices when the app-roles API returns nothing. */
const FALLBACK_ROLE_CHOICES = [
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
{ id: 2, label: 'Admin', desc: 'Operations control', icon: Shield },
{ id: 1, label: 'Super admin', desc: 'Full business access', icon: ShieldAlert },
{ id: 2, label: 'Operations', desc: 'Operations control', icon: Shield },
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
{ id: 4, label: 'Manager', desc: 'Runs a store day to day', icon: User },
];
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
@@ -109,7 +118,12 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
// login endpoints filter on (auth.ts hardcodes configid: 1 for every
// login attempt), not the unrelated Hasura "role config" id. Any other
// value means the account can never be found on login.
{ id: 4, label: 'Staff', desc: 'Standard store staff duties', icon: User, configid: 1 },
{ id: 4, label: 'Manager', desc: 'Runs a store day to day', icon: User, configid: 1 },
// Riders are NOT identified by roleid — they are read through
// partners/getriders by applocationid/partnerid, and partnerRepository
// never references roleid at all. Roleid 5 on a rider is therefore a
// label only; app_roles calls it "Admin". The id is kept as-is because
// changing it would alter existing rows for no functional gain.
{ id: 5, label: 'Rider', desc: 'Delivery fleet rider', icon: Bike, configid: 1 },
];
}, []);
@@ -164,7 +178,10 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
// Rider-shift picker — shown only when a rider role is selected (parity with the
// merchant_web create form). Shifts come from the live partners/getridershifts.
const selectedRole = roleChoices.find((r) => r.id === newUser.roleid);
const isRiderRole = (selectedRole?.label || '').toLowerCase().includes('rider') || newUser.roleid === 5;
// Keyed off the chosen card's label, not the roleid. Roleid 5 is "Admin" in
// app_roles — it only *means* rider because the rider card sends it — so
// testing the id would show the shift field for genuine Admins too.
const isRiderRole = (selectedRole?.label || '').toLowerCase().includes('rider');
const shiftsQ = useFiestaRiderShifts();
const shiftOptions = (shiftsQ.data ?? [])
.map((s) => ({ id: fnum((s as Record<string, unknown>).shiftid), label: fstr((s as Record<string, unknown>).shiftname) || `Shift ${fnum((s as Record<string, unknown>).shiftid)}` }))
@@ -197,7 +214,10 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
email: fstr(u.email) || fstr(u.authname) || '—',
contact: fstr(u.contactno) || '—',
roleid: Number(u.roleid),
role: roleName(Number(u.roleid)) === 'Manager' ? 'Admin' : roleName(Number(u.roleid)),
// Was remapped 'Manager' → 'Admin' to paper over a role map that called
// roleid 2 "Manager". app_roles calls it "Operations"; the map is now
// correct, so the name is used as-is.
role: roleName(Number(u.roleid)),
shift: shift && shift !== '-' ? shift : '—',
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
status: fstr(u.status) || 'Active',
@@ -421,10 +441,10 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-[8px] text-[10px] font-extrabold uppercase border ${roleInfo.bg} ${roleInfo.text} ${roleInfo.border}`}>
{u.roleid === 1 && <ShieldAlert size={10} />}
{u.roleid === 2 && <Shield size={10} />}
{u.roleid === 3 && <SlidersHorizontal size={10} />}
{u.roleid === 4 && <User size={10} />}
{u.roleid === 5 && <Bike size={10} />}
{u.roleid === 6 && <Coins size={10} />}
{(u.roleid === 3 || u.roleid === 5) && <SlidersHorizontal size={10} />}
{(u.roleid === 4 || u.roleid === 6) && <User size={10} />}
{u.roleid === 7 && <ShieldCheck size={10} />}
{u.roleid === 8 && <Coins size={10} />}
{roleInfo.label}
</span>
{u.roleid !== 1 && (

View File

@@ -13,6 +13,9 @@
--spacing-gutter: 20px;
--spacing-container-margin: 24px;
/* NOTE: these named keys collide with Tailwind v4's container scale, which
uses the same names. See the `max-w-*` repair below the theme block. */
/* Intermediate color shades used across the app. Tailwind's default palette
only ships shades in steps of 100, but the UI references finer steps
(e.g. -450, -650, -755, -505). Each is interpolated in OKLab from its
@@ -83,6 +86,40 @@
--color-orange-850: color-mix(in oklab, #9a3412, #7c2d12);
}
/* ── max-w-* repair ─────────────────────────────────────────────────────────
Tailwind v4 resolves max-w-<name> against its container scale, but where a
spacing key of the same name exists the spacing value wins. The @theme block
above defines --spacing-xs/sm/md/lg/xl, which collide with the container
scale's xs/sm/md/lg/xl — so the framework compiled:
.max-w-sm { max-width: var(--spacing-sm) } -> 8px
.max-w-md { max-width: var(--spacing-md) } -> 16px
Anything carrying one collapsed to its min-content width. Modal cards
rendered as ~100px vertical strips: titles broken one word per line, body
text a vertical column, buttons overflowing the card. It affects 16 call
sites across AdminConsole, UserStorePage, UsersPanel, StoreQRView,
PosStaffPanel and PosErrorBoundary — not one component.
Declaring --container-* does NOT fix it; the framework does not even emit
those custom properties once the spacing keys shadow them. Renaming the
spacing keys would fix it but breaks every p-md / gap-lg / space-y-lg /
p-container-margin in the app.
So the four affected utilities are restated here with Tailwind's own default
widths. Deliberately unlayered: unlayered rules beat anything inside a
cascade layer, so these win over the generated utilities regardless of source
order. max-w-2xl and larger are untouched — they have no spacing twin and
already resolve correctly.
Remove this block if the spacing scale is ever renamed to numeric keys. */
.max-w-xs { max-width: 20rem; }
.max-w-sm { max-width: 24rem; }
.max-w-md { max-width: 28rem; }
.max-w-lg { max-width: 32rem; }
.max-w-xl { max-width: 36rem; }
/* Custom scrollbar utility */
@layer utilities {
.custom-scrollbar::-webkit-scrollbar {

View File

@@ -77,8 +77,17 @@ const RESPONSE_FIELDS = {
/**
* roleids that land on the ADMIN dashboard; everyone else lands on the user page.
* From fiestaApi.roleName(): 1 = Owner, 2 = Manager, 3 = Admin, 4 = Staff,
* 5 = Rider, 6 = Cashier.
*
* Names come from `app_roles`: 1 Super admin, 2 Operations, 3 Admin, 4 Manager,
* 5 Admin, 6 Manager. (3/5 and 4/6 are the same role under two configids.)
*
* Note 5 is also called "Admin" but is deliberately NOT in this set — adding it
* would grant the admin console to accounts that do not have it today, which is
* a privilege change rather than a naming fix.
*
* Roleids 7 (Supervisor) and 8 (Cashier) must never appear here: they are till
* accounts with no Nearle Daily login at all, and the backend excludes them
* from every web login lookup.
*/
const ADMIN_ROLE_IDS = new Set<number>([1, 3]);

View File

@@ -1058,20 +1058,45 @@ export async function getProductSubcategories(opts: {
// ════════════════════════════════════════════════════════════════════════════
/** Best-effort role label from the numeric roleid (roles aren't fully resolvable for every config). */
export function roleName(roleid: number): string {
const map: Record<number, string> = {
/**
* Role names exactly as `app_roles` holds them.
*
* These were wrong for five of six ids until 2026-08-07 — the old map said
* 1 Owner / 2 Manager / 4 Staff / 5 Rider / 6 Cashier, none of which the
* database agrees with. Anything deriving behaviour from a *name* was
* therefore deriving it from fiction, which is how `isRiderRole` came to key
* off roleid 5 ("Admin").
*
* Note 3 and 5 are both "Admin", and 4 and 6 are both "Manager" — they are the
* same role under different configids (15 and 14). That is the schema's shape,
* not a transcription error, so it is preserved here rather than tidied.
*/
const ROLE_NAMES: Record<number, string> = {
[-1]: 'Unassigned',
0: 'Unassigned',
1: 'Owner',
2: 'Manager',
1: 'Super admin',
2: 'Operations',
3: 'Admin',
4: 'Staff',
5: 'Rider',
6: 'Cashier',
};
return map[roleid] || `Role ${roleid}`;
4: 'Manager',
5: 'Admin',
6: 'Manager',
// Till-only. These accounts have no Nearle Daily login at all — the backend
// excludes them from every web login lookup — so they are never the role of
// a signed-in console user. Listed because the POS staff screens render them.
7: 'Supervisor',
8: 'Cashier',
};
export function roleName(roleid: number): string {
return ROLE_NAMES[roleid] || `Role ${roleid}`;
}
/** Till roles. Created through `tenants/createposuser`, never `users/create`. */
export const POS_ROLE_SUPERVISOR = 7;
export const POS_ROLE_CASHIER = 8;
export const isPosRole = (roleid: number): boolean =>
roleid === POS_ROLE_SUPERVISOR || roleid === POS_ROLE_CASHIER;
/** /users/getallusers?roleid=&tenantid=&pageno=&pagesize=&keyword= — staff/users under a tenant. */
export async function getAllUsers(opts: {
tenantid: number;
@@ -1650,3 +1675,189 @@ export async function getGlobalProducts(opts: {
export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> {
return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku }));
}
// ════════════════════════════════════════════════════════════════════════════
// POS staff — supervisors and cashiers
// ════════════════════════════════════════════════════════════════════════════
//
// A separate surface from `users/create` on purpose. A till account has no
// Nearle Daily login (the backend excludes roleid 7 and 8 from every web login
// lookup), and its username, password and PIN are all minted server-side. Sent
// through `users/create` it would land as a row with none of those and be
// unable to open a till.
//
// Consequently these people never appear in `getallusers` either — the two
// lists are disjoint by design, not by omission.
/** One person who signs in at a till. */
export interface PosStaffUser {
user_id: number;
full_name: string;
first_name?: string;
last_name?: string;
authname?: string;
contactno?: string;
role_id: number;
role: string;
pin?: string;
has_password: boolean;
/** Present ONLY in the answer to a create or a reset. Never in a listing. */
password?: string;
location_id: number;
status: string;
}
export interface PosRoleOption {
role_id: number;
/** The wire value `createposuser` expects — "supervisor" | "cashier". */
role: string;
label: string;
description: string;
}
/** GET /tenants/posroles — the roles a till account may hold. Asked for rather
* than hardcoded, so adding a till role later needs no frontend release. */
export async function getPosRoles(): Promise<PosRoleOption[]> {
return toRows<PosRoleOption>(await fiestaGet('tenants/posroles'));
}
/**
* GET /tenants/getposusers — till accounts at one outlet. Never returns a password.
*
* Note the envelope: `details` is an OBJECT — `{location_id, users: [...]}` —
* not the bare array most Fiesta reads return. Verified against the live
* response. Passing it through `toRows` yields one wrapper object rather than
* the people, so the array is taken explicitly.
*/
export async function getPosStaff(opts: {
tenantid: number;
locationid: number;
}): Promise<PosStaffUser[]> {
const json = await fiestaGet<{ details?: { users?: PosStaffUser[] } | PosStaffUser[] }>(
'tenants/getposusers',
{
tenantid: opts.tenantid,
locationid: opts.locationid,
// Always asked for. The endpoint hides deactivated people by default,
// which reads sensibly for a till but not for the screen that manages
// them: a person deactivated by mistake would vanish with no way back,
// and their PIN would silently stay reserved. The panel shows them
// greyed with a Reactivate action instead.
include_inactive: 'true',
},
);
const d = json?.details;
if (Array.isArray(d)) return d;
return d?.users ?? [];
}
export interface CreatePosStaffInput {
tenantid: number;
locationid: number;
full_name: string;
/** "supervisor" | "cashier" — from getPosRoles, not a hardcoded literal. */
role: string;
pin: string;
/** Optional. Omitted, the server generates one and walks past collisions. */
authname?: string;
contactno?: string;
}
/**
* POST /tenants/createposuser
*
* The response is the ONLY time the password exists — it is not stored
* anywhere the console can read back, and the listing endpoint omits it. Show
* it once and mean it.
*
* `authname` may come back different from anything sent or expected: a shop
* that already has `supervisor.1179@` gets `supervisor2.1179@` rather than an
* error, so the returned value is the one to display.
*/
export async function createPosStaff(input: CreatePosStaffInput): Promise<PosStaffUser> {
const res = await fiestaSend<{ details?: PosStaffUser }>(
'tenants/createposuser',
'POST',
input,
);
const row = firstRow<PosStaffUser>(res);
if (!row) throw new Error('The server accepted the request but returned no account.');
return row;
}
/**
* PUT /tenants/updateposuser
*
* Every field is optional and only non-empty ones are written, so this doubles
* as the reactivate path (`status: 'Active'`) and the password reset.
*
* Unlike create, the password here is **caller-supplied** — the server writes
* what it is given rather than minting one. A console offering a reset has to
* generate it (see `generatePosPassword`) and show it once, because there is
* still nowhere to read it back from.
*
* PIN uniqueness is enforced server-side under an advisory lock, so a clash
* comes back as an error rather than two people sharing a PIN.
*/
export async function updatePosStaff(input: {
tenantid: number;
locationid: number;
user_id: number;
full_name?: string;
role?: string;
pin?: string;
authname?: string;
contactno?: string;
password?: string;
status?: string;
}): Promise<Row> {
return fiestaSend<Row>('tenants/updateposuser', 'PUT', input);
}
/**
* A replacement password for a till account.
*
* Generated in the browser from `crypto.getRandomValues` — the update endpoint
* writes whatever it is handed, so the strength of a reset password is entirely
* this function's responsibility. Fourteen characters from a 62-symbol alphabet,
* matching what the server mints on create.
*
* Ambiguous glyphs are kept rather than stripped: these are copied, not read
* aloud, and shrinking the alphabet costs entropy for no real gain.
*/
export function generatePosPassword(length = 14): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = new Uint32Array(length);
crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length];
return out;
}
/**
* DELETE /tenants/deleteposuser
*
* Deactivates rather than removes — bills carry the cashier's name, and a
* deleted row would orphan every one of them. The till refuses a deactivated
* account immediately.
*/
export async function deactivatePosStaff(opts: {
tenantid: number;
locationid: number;
userid: number;
}): Promise<Row> {
const qs = new URLSearchParams({
tenantid: String(opts.tenantid),
locationid: String(opts.locationid),
userid: String(opts.userid),
});
const res = await fetch(`${FIESTA_BASE}/tenants/deleteposuser?${qs}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
const json = (await res.json().catch(() => null)) as { message?: string; status?: boolean } | null;
if (!res.ok || (json && json.status === false)) {
throw new Error(json?.message || `Could not deactivate the account (${res.status}).`);
}
return json as Row;
}

View File

@@ -48,6 +48,11 @@ import {
getProductStocks,
getProductLocations,
createProductLocations,
getPosRoles,
getPosStaff,
createPosStaff,
updatePosStaff,
deactivatePosStaff,
getMasterCatalog,
getProductCategories,
getProductSubcategories,
@@ -1055,3 +1060,64 @@ export function useFiestaDeleteProductLocation() {
},
});
}
// ── POS staff (supervisors and cashiers) ────────────────────────────────────
//
// Separate from useFiestaUsers: the backend excludes roleid 7 and 8 from
// `getallusers`, so these people are invisible to that hook by design. Do not
// "fix" that by merging the two lists — a till account and a console account
// are different things with different creation paths.
export const posStaffKeys = {
roles: () => ['fiesta', 'posroles'] as const,
staff: (tenantid: number, locationid: number) =>
['fiesta', 'posstaff', tenantid, locationid] as const,
};
/** The till roles the backend offers. Cached hard — this changes with a deploy. */
export function usePosRoles() {
return useQuery({
queryKey: posStaffKeys.roles(),
queryFn: getPosRoles,
staleTime: 30 * 60_000,
});
}
export function usePosStaff(tenantid: number, locationid: number) {
return useQuery({
queryKey: posStaffKeys.staff(tenantid, locationid),
queryFn: () => getPosStaff({ tenantid, locationid }),
enabled: Boolean(tenantid) && Boolean(locationid),
});
}
/** Create a till account. The resolved value carries the one-time password. */
export function useCreatePosStaff() {
const qc = useQueryClient();
return useMutation({
mutationFn: createPosStaff,
onSuccess: (_res, vars) => {
qc.invalidateQueries({ queryKey: posStaffKeys.staff(vars.tenantid, vars.locationid) });
},
});
}
export function useUpdatePosStaff() {
const qc = useQueryClient();
return useMutation({
mutationFn: updatePosStaff,
onSuccess: (_res, vars) => {
qc.invalidateQueries({ queryKey: posStaffKeys.staff(vars.tenantid, vars.locationid) });
},
});
}
export function useDeactivatePosStaff() {
const qc = useQueryClient();
return useMutation({
mutationFn: deactivatePosStaff,
onSuccess: (_res, vars) => {
qc.invalidateQueries({ queryKey: posStaffKeys.staff(vars.tenantid, vars.locationid) });
},
});
}

View File

@@ -23,7 +23,20 @@
* Components call the hooks in `./posQueries`, not these functions.
*/
const POS_BASE = import.meta.env.VITE_FIESTA_POS_URL || 'https://fiesta.nearle.app/live/api/v1/pos';
/**
* The console reads counter sales from `/v1/web/pos`, not `/v1/pos`.
*
* `/v1/pos` is the terminal group and sits behind `PosAuth`, which pins a
* request to the outlet inside a till's token. A browser has no such token and
* cannot get one — `/pos/login` refuses any account that is not a till account.
* So the day `POS_AUTH_REQUIRED=true` is set, every read on that group would
* 401 and this page would go blank.
*
* `/v1/web/pos` serves the same five handlers outside the guard. Identical
* responses — they read their scope from query parameters and never touch the
* session — so nothing else in this client changes.
*/
const POS_BASE = import.meta.env.VITE_FIESTA_POS_URL || 'https://fiesta.nearle.app/live/api/v1/web/pos';
type QueryParams = Record<string, string | number | undefined | null>;
@@ -136,6 +149,48 @@ export interface PosSalesFilter {
pagesize?: number;
}
/**
* The terminal group, used only until the console base exists on a server.
*
* TRANSITIONAL. The frontend and the backend deploy separately and the backend
* is currently behind, so shipping this build first would point every read at a
* route that answers 404 and blank the page. Rather than make the two deploys
* ordered — a constraint nobody would remember in six months — the base is
* resolved once at runtime: try the console group, and if the server is old
* enough not to have it, fall back to the terminal group for this session.
*
* Safe to delete once every environment is past the deploy that added
* `/v1/web/pos`. It is a no-op there: the first probe succeeds and the fallback
* is never reached.
*/
const POS_BASE_LEGACY = POS_BASE.replace('/v1/web/pos', '/v1/pos').replace('/v1/mob/pos', '/v1/pos');
let resolvedBase: string | null = POS_BASE === POS_BASE_LEGACY ? POS_BASE : null;
let baseProbe: Promise<string> | null = null;
/** Resolves the base once per session; concurrent callers share one probe. */
async function posBase(): Promise<string> {
if (resolvedBase) return resolvedBase;
if (!baseProbe) {
baseProbe = (async () => {
try {
// `sales/summary` needs no data to exist — a missing route 404s, a
// present one answers 200 even for an outlet with no bills.
const res = await fetch(`${POS_BASE}/sales/summary?locationid=0`, {
headers: { Accept: 'application/json' },
});
resolvedBase = res.status === 404 ? POS_BASE_LEGACY : POS_BASE;
} catch {
// A network failure says nothing about which routes exist; assume the
// current one and let the real request surface the error.
resolvedBase = POS_BASE;
}
return resolvedBase;
})();
}
return baseProbe;
}
// ── Transport ────────────────────────────────────────────────────────────────
async function posGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
@@ -144,7 +199,8 @@ async function posGet<T = unknown>(endpoint: string, params: QueryParams = {}):
if (v !== undefined && v !== null && v !== '') qs.append(k, String(v));
});
const query = qs.toString();
const res = await fetch(`${POS_BASE}/${endpoint}${query ? `?${query}` : ''}`, {
const base = await posBase();
const res = await fetch(`${base}/${endpoint}${query ? `?${query}` : ''}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {

View File

@@ -66,6 +66,17 @@ export interface PosOutletHealth {
total: number;
online: number;
terminals: PosTerminal[];
/**
* Set when this outlet's read failed.
*
* Previously a failure was caught and returned as an empty terminal list,
* which made "this shop has no tills" and "we could not ask" render
* identically — the board said *No terminals registered* whether Redis was
* down, the route had moved, or the shop genuinely had none. That is the
* worst possible answer for a screen whose entire job is telling you which
* counters are dark.
*/
error?: string;
}
/**
@@ -95,10 +106,16 @@ export function usePosFleetHealth(locationIds: number[]) {
online: terminals.filter((t) => t.state === 'online').length,
terminals,
};
} catch {
// One outlet's Redis being unreachable must not blank the board for
// every other shop.
return { locationid, total: 0, online: 0, terminals: [] };
} catch (e) {
// One outlet's read failing must not blank the board for every other
// shop — but it must not be reported as "no terminals" either.
return {
locationid,
total: 0,
online: 0,
terminals: [],
error: (e as Error)?.message || 'could not be reached',
};
}
});
},