diff --git a/src/App.tsx b/src/App.tsx
index 5716a1a..1c8b415 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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. */}
+
} />
diff --git a/src/components/AdminConsole.tsx b/src/components/AdminConsole.tsx
index 7330e67..5996292 100644
--- a/src/components/AdminConsole.tsx
+++ b/src/components/AdminConsole.tsx
@@ -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,
diff --git a/src/components/PosConsoleView.tsx b/src/components/PosConsoleView.tsx
index 80be0fd..7b003b9 100644
--- a/src/components/PosConsoleView.tsx
+++ b/src/components/PosConsoleView.tsx
@@ -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: },
{ id: 'terminals', label: 'Terminals', icon: },
{ id: 'bills', label: 'Bills', icon: },
{ id: 'catalogue', label: 'Catalogue', icon: },
- { id: 'exceptions', label: 'Attention', icon: },
+ { id: 'staff', label: 'Staff', icon: },
];
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();
+ 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({
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;
isStoreScope: boolean;
loading: boolean;
+ /** terminalid → bills in the current range. Empty means never sold here. */
+ billedTerminals: Map;
}) {
if (loading) return ;
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 (
- }
- title="No terminals registered"
- body={
- 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.`
- }
- />
+ <>
+ {failed.length > 0 && }
+ }
+ title={failed.length ? 'Terminal status unavailable' : 'No terminals registered'}
+ body={
+ 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.'
+ : `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 && }
{withTills.map((h) => (
{!isStoreScope && (
@@ -709,13 +762,34 @@ function TerminalsTab({
{h.terminals.map((t) => (
-
+
))}
))}
+ {billedButAbsent.length > 0 && (
+
+ }
+ 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"
+ />
+
+
+ )}
+
{without.length > 0 && !isStoreScope && (
} 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;
+}) {
+ return (
+
+
+
+
+ Could not read terminal status for {failed.length} outlet{failed.length === 1 ? '' : 's'}.
+ {' '}
+ This is a failure to ask, not an answer — tills at these shops may well be running.
+
+ );
+}
+
+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 (
@@ -753,12 +863,16 @@ function TerminalCard({ t }: { t: PosTerminal }) {
)}
-
+
+
+ {neverSold && }
+
{t.state === 'offline_vanished' ? (
{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.'}
- {exceptions.legacy.length} 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.
-
- )}
- {exceptions.unassigned.length > 0 && (
-
- {exceptions.unassigned.length} bill
- {exceptions.unassigned.length === 1 ? '' : 's'} have no terminal id — ingested before the fallback landed.
- They group under “Unassigned”.
-
-
- );
-}
-
// ── Small shared pieces ──────────────────────────────────────────────────────
/**
diff --git a/src/components/PosErrorBoundary.tsx b/src/components/PosErrorBoundary.tsx
new file mode 100644
index 0000000..62fd224
--- /dev/null
+++ b/src/components/PosErrorBoundary.tsx
@@ -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 {
+ // 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 (
+
+
+
+
+
+
+ The POS screen hit a problem
+
+
+ Counter sales are unaffected — bills keep syncing from the tills whatever this page
+ does. Only the display failed.
+
+
+ {this.state.error.message}
+
+
+
+
+ );
+ }
+}
diff --git a/src/components/PosStaffPanel.tsx b/src/components/PosStaffPanel.tsx
new file mode 100644
index 0000000..dcdda2f
--- /dev/null
+++ b/src/components/PosStaffPanel.tsx
@@ -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(null);
+ const [copied, setCopied] = useState(false);
+ const [confirmOff, setConfirmOff] = useState(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(null);
+ const [editName, setEditName] = useState('');
+ const [editRole, setEditRole] = useState('');
+ const [editPin, setEditPin] = useState('');
+ const [editError, setEditError] = useState('');
+ const [confirmReset, setConfirmReset] = useState(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 (
+ <>
+
+
+
+
+
+
+ Till staff{outletName ? ` · ${outletName}` : ''}
+
+
+ {activeCount} active. These accounts sign in at a counter only — they have no
+ Nearle Daily login.
+
+ )}
+
+ {/* Edit — name, role and PIN. Username stays put; it is what a supervisor
+ already types at the counter. */}
+ {editing && (
+ setEditing(null)} title={`Edit ${editing.full_name}`}>
+
+ 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.
+
+
+
+
+
+
+ )}
+
+ {/* One-time credentials. The password is not recoverable after this. */}
+ {issued && (
+
+
setIssued(null)} />
+
+
+
+
Account created
+
+
+ This password is shown once and cannot be looked up again. Copy it now.
+
+
+
+
+
+
+
+
+
+
+ {issued.authname && (
+
+ The username is generated. If this outlet already had one, it continues the series —
+ use exactly what is shown above.
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {/* 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 && (
+ setConfirmOff(null)} title={`Deactivate ${confirmOff.full_name}?`}>
+
+ They will be refused at the till straight away. The account is kept rather than
+ deleted, because past bills carry their name.
+
+ );
+}
+
+function IconBtn({
+ title,
+ color,
+ onClick,
+ children,
+}: {
+ title: string;
+ color: string;
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+/** 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 (
+
+
+
+ {/* Names come from user input and can be long and unbroken. */}
+
+ {title}
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx
index 72e12a6..802ca1d 100644
--- a/src/components/SettingsView.tsx
+++ b/src/components/SettingsView.tsx
@@ -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 (
diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx
index f78df67..acfc012 100644
--- a/src/components/UserStorePage.tsx
+++ b/src/components/UserStorePage.tsx
@@ -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 ;
+ return (
+
+
+
+ );
}
diff --git a/src/components/UsersPanel.tsx b/src/components/UsersPanel.tsx
index 5ee7745..64b9501 100644
--- a/src/components/UsersPanel.tsx
+++ b/src/components/UsersPanel.tsx
@@ -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 = {
- 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 = {
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).shiftid), label: fstr((s as Record).shiftname) || `Shift ${fnum((s as Record).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
{u.roleid === 1 && }
{u.roleid === 2 && }
- {u.roleid === 3 && }
- {u.roleid === 4 && }
- {u.roleid === 5 && }
- {u.roleid === 6 && }
+ {(u.roleid === 3 || u.roleid === 5) && }
+ {(u.roleid === 4 || u.roleid === 6) && }
+ {u.roleid === 7 && }
+ {u.roleid === 8 && }
{roleInfo.label}
{u.roleid !== 1 && (
diff --git a/src/index.css b/src/index.css
index 8a62910..ac82ccb 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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- 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 {
diff --git a/src/services/auth.ts b/src/services/auth.ts
index 869e2bd..0cc9b93 100644
--- a/src/services/auth.ts
+++ b/src/services/auth.ts
@@ -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([1, 3]);
diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts
index 7a7734a..6e42f9e 100644
--- a/src/services/fiestaApi.ts
+++ b/src/services/fiestaApi.ts
@@ -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). */
+/**
+ * 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 = {
+ [-1]: 'Unassigned',
+ 0: 'Unassigned',
+ 1: 'Super admin',
+ 2: 'Operations',
+ 3: 'Admin',
+ 4: 'Manager',
+ 5: 'Admin',
+ 6: 'Manager',
+ // Till-only. These accounts have no Nearle Daily login at all — the backend
+ // excludes them from every web login lookup — so they are never the role of
+ // a signed-in console user. Listed because the POS staff screens render them.
+ 7: 'Supervisor',
+ 8: 'Cashier',
+};
+
export function roleName(roleid: number): string {
- const map: Record = {
- [-1]: 'Unassigned',
- 0: 'Unassigned',
- 1: 'Owner',
- 2: 'Manager',
- 3: 'Admin',
- 4: 'Staff',
- 5: 'Rider',
- 6: 'Cashier',
- };
- return map[roleid] || `Role ${roleid}`;
+ 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 {
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 {
+ return toRows(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 {
+ 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 {
+ const res = await fiestaSend<{ details?: PosStaffUser }>(
+ 'tenants/createposuser',
+ 'POST',
+ input,
+ );
+ const row = firstRow(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 {
+ return fiestaSend('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 {
+ 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;
+}
diff --git a/src/services/fiestaQueries.ts b/src/services/fiestaQueries.ts
index ef97031..ac9e10b 100644
--- a/src/services/fiestaQueries.ts
+++ b/src/services/fiestaQueries.ts
@@ -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) });
+ },
+ });
+}
diff --git a/src/services/posApi.ts b/src/services/posApi.ts
index 5e6dcbc..5be4134 100644
--- a/src/services/posApi.ts
+++ b/src/services/posApi.ts
@@ -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;
@@ -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 | null = null;
+
+/** Resolves the base once per session; concurrent callers share one probe. */
+async function posBase(): Promise {
+ 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(endpoint: string, params: QueryParams = {}): Promise {
@@ -144,7 +199,8 @@ async function posGet(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) {
diff --git a/src/services/posQueries.ts b/src/services/posQueries.ts
index 229708e..a6a60c0 100644
--- a/src/services/posQueries.ts
+++ b/src/services/posQueries.ts
@@ -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',
+ };
}
});
},