pos implemented
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
82
src/components/PosErrorBoundary.tsx
Normal file
82
src/components/PosErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
656
src/components/PosStaffPanel.tsx
Normal file
656
src/components/PosStaffPanel.tsx
Normal 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"> </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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
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',
|
||||
};
|
||||
return map[roleid] || `Role ${roleid}`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user