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 StoreDetailView from './components/StoreDetailView';
import DispatchHubView from './components/DispatchHubView'; import DispatchHubView from './components/DispatchHubView';
import PosConsoleView from './components/PosConsoleView'; import PosConsoleView from './components/PosConsoleView';
import PosErrorBoundary from './components/PosErrorBoundary';
import LoginView from './components/LoginView'; import LoginView from './components/LoginView';
import UserStorePage from './components/UserStorePage'; import UserStorePage from './components/UserStorePage';
import SuperAdminPage from './components/SuperAdminPage'; 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 {/* Tenant-wide POS: no locationid, so the view fans out over every
outlet under the tenant. */} outlet under the tenant. */}
<Route path="pos" element={ <Route path="pos" element={
<PosConsoleView tenantId={tenantId} /> <PosErrorBoundary><PosConsoleView tenantId={tenantId} /></PosErrorBoundary>
} /> } />
</Routes> </Routes>

View File

@@ -216,6 +216,8 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
moduleid: 2, moduleid: 2,
applocationid: 1, applocationid: 1,
partnerid: 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, roleid: 4,
}); });
setStoreSuccess(res); setStoreSuccess(res);
@@ -232,8 +234,13 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
return; return;
} }
try { try {
// Create base user with roleid = 5 (Rider) and configid = 1, matching the // configid = 1 matches what login (auth.ts) queries against.
// convention 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({ const res = await createUserMut.mutateAsync({
firstname: riderForm.firstname, firstname: riderForm.firstname,
lastname: riderForm.lastname, lastname: riderForm.lastname,

View File

@@ -68,7 +68,9 @@ import {
type PosSalesSummary, type PosSalesSummary,
type PosTerminal, type PosTerminal,
} from '../services/posApi'; } from '../services/posApi';
import PosStaffPanel from './PosStaffPanel';
import { import {
type PosOutletHealth,
usePosCatalogue, usePosCatalogue,
usePosFleetHealth, usePosFleetHealth,
usePosSaleDetail, usePosSaleDetail,
@@ -111,14 +113,14 @@ interface PosConsoleViewProps {
storeName?: string; 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 }> = [ const TABS: Array<{ id: Tab; label: string; icon: React.ReactNode }> = [
{ id: 'overview', label: 'Overview', icon: <TrendingUp size={13} /> }, { id: 'overview', label: 'Overview', icon: <TrendingUp size={13} /> },
{ id: 'terminals', label: 'Terminals', icon: <Monitor size={13} /> }, { id: 'terminals', label: 'Terminals', icon: <Monitor size={13} /> },
{ id: 'bills', label: 'Bills', icon: <Receipt size={13} /> }, { id: 'bills', label: 'Bills', icon: <Receipt size={13} /> },
{ id: 'catalogue', label: 'Catalogue', icon: <Package 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 }> = [ 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 tillsOnline = allTerminals.filter((t) => t.state === 'online').length;
const pendingTotal = allTerminals.reduce((s, t) => s + t.pendingBills, 0); 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 ──────────────────────────────────────────────────────── // ── Bill list scope ────────────────────────────────────────────────────────
// /sales takes exactly one locationid, so the tenant view needs a picker. // /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 // 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); const resetPaging = () => setPageno(0);
// ── Exceptions ───────────────────────────────────────────────────────────── // How many tills are holding unsynced bills. Kept after the Attention tab was
const exceptions = useMemo(() => { // removed, because the banner it feeds is the one warning on this page worth
const stranded = allTerminals.filter((t) => t.pendingBills > 0); // interrupting somebody for — a shop accumulating takings it has not sent
const idle = allTerminals.filter((t) => t.state === 'online' && t.todayBills === 0); // looks entirely normal from the floor.
const dark = allTerminals.filter( const strandedTills = allTerminals.filter((t) => t.pendingBills > 0).length;
(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;
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
@@ -374,7 +379,7 @@ export default function PosConsoleView({
<AlertTriangle size={16} style={{ color: '#f59e0b' }} className="mt-0.5 shrink-0" /> <AlertTriangle size={16} style={{ color: '#f59e0b' }} className="mt-0.5 shrink-0" />
<div className="text-[13px]" style={{ color: TEXT }}> <div className="text-[13px]" style={{ color: TEXT }}>
<strong>{pendingTotal}</strong> bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '} <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 }}> <button onClick={() => setTab('terminals')} className="ml-2 underline font-semibold cursor-pointer" style={{ color: BRAND }}>
See which See which
</button> </button>
@@ -389,9 +394,8 @@ export default function PosConsoleView({
<React.Fragment key={t.id}> <React.Fragment key={t.id}>
<Pill <Pill
active={tab === t.id} active={tab === t.id}
color={t.id === 'exceptions' && attentionCount > 0 ? '#f59e0b' : BRAND} color={BRAND}
onClick={() => setTab(t.id)} onClick={() => setTab(t.id)}
count={t.id === 'exceptions' && attentionCount > 0 ? attentionCount : undefined}
> >
{t.icon} {t.icon}
{t.label} {t.label}
@@ -424,6 +428,7 @@ export default function PosConsoleView({
outletName={outletName} outletName={outletName}
isStoreScope={isStoreScope} isStoreScope={isStoreScope}
loading={healthQ.isLoading} loading={healthQ.isLoading}
billedTerminals={billedTerminals}
/> />
)} )}
@@ -478,14 +483,40 @@ export default function PosConsoleView({
/> />
)} )}
{tab === 'exceptions' && ( {tab === 'staff' && (
<ExceptionsTab <>
exceptions={exceptions} {!isStoreScope && (
outletName={outletName} <FilterBar>
loading={healthQ.isLoading} <div className="flex items-center gap-2 flex-wrap">
onOpenBill={(b) => setOpenRef(b.terminalorderid || String(b.posorderid))} <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> </div>
<SlideDrawer isOpen={Boolean(openRef)} onClose={() => setOpenRef('')} title="Bill"> <SlideDrawer isOpen={Boolean(openRef)} onClose={() => setOpenRef('')} title="Bill">
@@ -666,33 +697,55 @@ function TerminalsTab({
outletName, outletName,
isStoreScope, isStoreScope,
loading, loading,
billedTerminals,
}: { }: {
health: Array<{ locationid: number; total: number; online: number; terminals: PosTerminal[] }>; health: PosOutletHealth[];
outletName: Map<number, string>; outletName: Map<number, string>;
isStoreScope: boolean; isStoreScope: boolean;
loading: boolean; loading: boolean;
/** terminalid → bills in the current range. Empty means never sold here. */
billedTerminals: Map<string, number>;
}) { }) {
if (loading) return <SkeletonPanel lines={4} />; if (loading) return <SkeletonPanel lines={4} />;
const withTills = health.filter((h) => h.total > 0); const withTills = health.filter((h) => h.total > 0);
const without = 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) { 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 ( return (
<>
{failed.length > 0 && <HealthErrorNotice failed={failed} outletName={outletName} />}
<EmptyState <EmptyState
icon={<Monitor size={30} />} icon={<Monitor size={30} />}
title="No terminals registered" title={failed.length ? 'Terminal status unavailable' : 'No terminals registered'}
body={ 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.' ? '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 ( return (
<> <>
{failed.length > 0 && <HealthErrorNotice failed={failed} outletName={outletName} />}
{withTills.map((h) => ( {withTills.map((h) => (
<div key={h.locationid}> <div key={h.locationid}>
{!isStoreScope && ( {!isStoreScope && (
@@ -709,13 +762,34 @@ function TerminalsTab({
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{h.terminals.map((t) => ( {h.terminals.map((t) => (
<React.Fragment key={`${h.locationid}-${t.terminalId}`}> <React.Fragment key={`${h.locationid}-${t.terminalId}`}>
<TerminalCard t={t} /> <TerminalCard t={t} bills={billedTerminals.get(t.terminalId) ?? 0} />
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
</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 && ( {without.length > 0 && !isStoreScope && (
<Card className="p-4"> <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" /> <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 color = TERMINAL_STATE_COLOR[t.state];
const drift = t.clockDriftSeconds != null && Math.abs(t.clockDriftSeconds) > 120; 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 ( return (
<Card className="p-4"> <Card className="p-4">
@@ -753,12 +863,16 @@ function TerminalCard({ t }: { t: PosTerminal }) {
</div> </div>
)} )}
</div> </div>
<div className="flex flex-col items-end gap-1 shrink-0">
<StatusChip label={TERMINAL_STATE_LABEL[t.state]} color={color} /> <StatusChip label={TERMINAL_STATE_LABEL[t.state]} color={color} />
{neverSold && <StatusChip label="Never sold" color={TEXT_3} />}
</div>
</div> </div>
{t.state === 'offline_vanished' ? ( {t.state === 'offline_vanished' ? (
<p className="text-[11.5px] mt-3" style={{ color: TEXT_2 }}> <p className="text-[11.5px] mt-3" style={{ color: TEXT_2 }}>
{t.reason || 'No heartbeat inside the presence window.'} {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> </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 ────────────────────────────────────────────────────── // ── 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); .filter((r) => r.id > 0);
if (mapped.length) return mapped; 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]); }, [rolesQ.data]);
return ( return (

View File

@@ -38,6 +38,7 @@ import DeliveryReportsView from './DeliveryReportsView';
import StoreQRView from './StoreQRView'; import StoreQRView from './StoreQRView';
import PosView from './PosView'; import PosView from './PosView';
import PosConsoleView from './PosConsoleView'; import PosConsoleView from './PosConsoleView';
import PosErrorBoundary from './PosErrorBoundary';
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar'; import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
import ComparisonModal from './ComparisonModal'; import ComparisonModal from './ComparisonModal';
interface UserStorePageProps { 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, Check,
SlidersHorizontal, SlidersHorizontal,
Coins, Coins,
ShieldCheck,
Store, Store,
Bike, Bike,
Clock Clock
@@ -46,13 +47,21 @@ const USER_AVATARS = [
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80', '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 }> = { 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' }, 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: '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' }, 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' }, 4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Manager' },
5: { bg: 'bg-purple-50/75', text: 'text-purple-700', border: 'border-purple-100', label: 'Rider' }, 5: { bg: 'bg-sky-50/75', text: 'text-sky-700', border: 'border-sky-100', label: 'Admin' },
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' }, 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. */ /** 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' }, 1: { icon: ShieldAlert, desc: 'Full business access' },
2: { icon: Shield, desc: 'Operations control' }, 2: { icon: Shield, desc: 'Operations control' },
3: { icon: SlidersHorizontal, desc: 'Manage store settings' }, 3: { icon: SlidersHorizontal, desc: 'Manage store settings' },
4: { icon: User, desc: 'Standard staff duties' }, 4: { icon: User, desc: 'Runs a store day to day' },
6: { icon: Coins, desc: 'Checkout & registers' }, 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. */ /** Fallback role choices when the app-roles API returns nothing. */
const FALLBACK_ROLE_CHOICES = [ const FALLBACK_ROLE_CHOICES = [
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert }, { id: 1, label: 'Super admin', desc: 'Full business access', icon: ShieldAlert },
{ id: 2, label: 'Admin', desc: 'Operations control', icon: Shield }, { id: 2, label: 'Operations', desc: 'Operations control', icon: Shield },
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal }, { id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User }, { id: 4, label: 'Manager', desc: 'Runs a store day to day', icon: User },
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
]; ];
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) { 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 endpoints filter on (auth.ts hardcodes configid: 1 for every
// login attempt), not the unrelated Hasura "role config" id. Any other // login attempt), not the unrelated Hasura "role config" id. Any other
// value means the account can never be found on login. // 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 }, { 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 // 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. // merchant_web create form). Shifts come from the live partners/getridershifts.
const selectedRole = roleChoices.find((r) => r.id === newUser.roleid); 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 shiftsQ = useFiestaRiderShifts();
const shiftOptions = (shiftsQ.data ?? []) 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)}` })) .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) || '—', email: fstr(u.email) || fstr(u.authname) || '—',
contact: fstr(u.contactno) || '—', contact: fstr(u.contactno) || '—',
roleid: Number(u.roleid), 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 : '—', shift: shift && shift !== '-' ? shift : '—',
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore', location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
status: fstr(u.status) || 'Active', 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}`}> <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 === 1 && <ShieldAlert size={10} />}
{u.roleid === 2 && <Shield size={10} />} {u.roleid === 2 && <Shield size={10} />}
{u.roleid === 3 && <SlidersHorizontal size={10} />} {(u.roleid === 3 || u.roleid === 5) && <SlidersHorizontal size={10} />}
{u.roleid === 4 && <User size={10} />} {(u.roleid === 4 || u.roleid === 6) && <User size={10} />}
{u.roleid === 5 && <Bike size={10} />} {u.roleid === 7 && <ShieldCheck size={10} />}
{u.roleid === 6 && <Coins size={10} />} {u.roleid === 8 && <Coins size={10} />}
{roleInfo.label} {roleInfo.label}
</span> </span>
{u.roleid !== 1 && ( {u.roleid !== 1 && (

View File

@@ -13,6 +13,9 @@
--spacing-gutter: 20px; --spacing-gutter: 20px;
--spacing-container-margin: 24px; --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 /* Intermediate color shades used across the app. Tailwind's default palette
only ships shades in steps of 100, but the UI references finer steps 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 (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); --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 */ /* Custom scrollbar utility */
@layer utilities { @layer utilities {
.custom-scrollbar::-webkit-scrollbar { .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. * 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]); 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). */ /** 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', [-1]: 'Unassigned',
0: 'Unassigned', 0: 'Unassigned',
1: 'Owner', 1: 'Super admin',
2: 'Manager', 2: 'Operations',
3: 'Admin', 3: 'Admin',
4: 'Staff', 4: 'Manager',
5: 'Rider', 5: 'Admin',
6: 'Cashier', 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. */ /** /users/getallusers?roleid=&tenantid=&pageno=&pagesize=&keyword= — staff/users under a tenant. */
export async function getAllUsers(opts: { export async function getAllUsers(opts: {
tenantid: number; tenantid: number;
@@ -1650,3 +1675,189 @@ export async function getGlobalProducts(opts: {
export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> { export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> {
return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku })); 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, getProductStocks,
getProductLocations, getProductLocations,
createProductLocations, createProductLocations,
getPosRoles,
getPosStaff,
createPosStaff,
updatePosStaff,
deactivatePosStaff,
getMasterCatalog, getMasterCatalog,
getProductCategories, getProductCategories,
getProductSubcategories, 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. * 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>; type QueryParams = Record<string, string | number | undefined | null>;
@@ -136,6 +149,48 @@ export interface PosSalesFilter {
pagesize?: number; 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 ──────────────────────────────────────────────────────────────── // ── Transport ────────────────────────────────────────────────────────────────
async function posGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> { 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)); if (v !== undefined && v !== null && v !== '') qs.append(k, String(v));
}); });
const query = qs.toString(); 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' }, headers: { Accept: 'application/json' },
}); });
if (!res.ok) { if (!res.ok) {

View File

@@ -66,6 +66,17 @@ export interface PosOutletHealth {
total: number; total: number;
online: number; online: number;
terminals: PosTerminal[]; 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, online: terminals.filter((t) => t.state === 'online').length,
terminals, terminals,
}; };
} catch { } catch (e) {
// One outlet's Redis being unreachable must not blank the board for // One outlet's read failing must not blank the board for every other
// every other shop. // shop — but it must not be reported as "no terminals" either.
return { locationid, total: 0, online: 0, terminals: [] }; return {
locationid,
total: 0,
online: 0,
terminals: [],
error: (e as Error)?.message || 'could not be reached',
};
} }
}); });
}, },