From 31d5535b72a8d78566abe4bdbe2b51c426c59383 Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 10 Aug 2026 18:14:52 +0530 Subject: [PATCH] pos implemented --- src/App.tsx | 3 +- src/components/AdminConsole.tsx | 11 +- src/components/PosConsoleView.tsx | 420 +++++++----------- src/components/PosErrorBoundary.tsx | 82 ++++ src/components/PosStaffPanel.tsx | 656 ++++++++++++++++++++++++++++ src/components/SettingsView.tsx | 5 +- src/components/UserStorePage.tsx | 7 +- src/components/UsersPanel.tsx | 56 ++- src/index.css | 37 ++ src/services/auth.ts | 13 +- src/services/fiestaApi.ts | 233 +++++++++- src/services/fiestaQueries.ts | 66 +++ src/services/posApi.ts | 60 ++- src/services/posQueries.ts | 25 +- 14 files changed, 1373 insertions(+), 301 deletions(-) create mode 100644 src/components/PosErrorBoundary.tsx create mode 100644 src/components/PosStaffPanel.tsx 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({
{pendingTotal} 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. @@ -389,9 +394,8 @@ export default function PosConsoleView({ 0 ? '#f59e0b' : BRAND} + color={BRAND} onClick={() => setTab(t.id)} - count={t.id === 'exceptions' && attentionCount > 0 ? attentionCount : undefined} > {t.icon} {t.label} @@ -424,6 +428,7 @@ export default function PosConsoleView({ outletName={outletName} isStoreScope={isStoreScope} loading={healthQ.isLoading} + billedTerminals={billedTerminals} /> )} @@ -478,14 +483,40 @@ export default function PosConsoleView({ /> )} - {tab === 'exceptions' && ( - setOpenRef(b.terminalorderid || String(b.posorderid))} - /> + {tab === 'staff' && ( + <> + {!isStoreScope && ( + +
+ + Store + + {outlets.map((o) => ( + + setPickedOutlet(o.locationid)} + > + {o.name} + + + ))} +
+
+ )} + + )} +
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" + /> +
+ {billedButAbsent.map(([id, count]) => ( +
+ {id} + + {count} bill{count === 1 ? '' : 's'} + +
+ ))} +
+
+ )} + {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. +
    + {failed.slice(0, 5).map((h) => ( +
  • + {outletName.get(h.locationid) || `Store ${h.locationid}`} — {h.error} +
  • + ))} +
+
+
+ ); +} + +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.'}

) : ( <> @@ -1318,218 +1432,6 @@ function CatalogueTab({ ); } -// ── Exceptions ─────────────────────────────────────────────────────────────── - -function ExceptionsTab({ - exceptions, - outletName, - loading, - onOpenBill, -}: { - exceptions: { - stranded: Array; - idle: Array; - dark: Array; - stale: Array; - drifting: Array; - noTills: Array<{ locationid: number; name: string }>; - delayed: PosBill[]; - legacy: PosBill[]; - unassigned: PosBill[]; - }; - outletName: Map; - loading: boolean; - onOpenBill: (b: PosBill) => void; -}) { - if (loading) return ; - - 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 ( - } - title="Nothing needs attention" - body="Every till is reporting, nothing is queued unsynced, and no bill took an unusual time to arrive." - /> - ); - } - - return ( - <> - ({ - 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, - }))} - /> - - ({ - key: `${t.locationid}-${t.terminalId}`, - primary: t.terminalId, - secondary: where(t), - value: TERMINAL_STATE_LABEL[t.state], - note: t.reason || undefined, - }))} - /> - - ({ - 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', - }))} - /> - - ({ - key: `${t.locationid}-${t.terminalId}`, - primary: t.terminalId, - secondary: where(t), - value: t.ageSeconds != null ? `${humanDuration(t.ageSeconds)} ago` : 'unknown', - }))} - /> - - ({ - 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 && ( - - } - 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" - /> -
- {exceptions.delayed.slice(0, 12).map((b) => { - const lag = syncLag(b); - return ( - - ); - })} -
-
- )} - - {(exceptions.legacy.length > 0 || exceptions.unassigned.length > 0) && ( - - } title="Known legacy data" hint="Not alerts — recorded so the panels above read honestly" /> -
    - {exceptions.legacy.length > 0 && ( -
  • - {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”. -
  • - )} -
-
- )} - - {exceptions.noTills.length > 0 && ( - - } title={`${exceptions.noTills.length} outlet${exceptions.noTills.length === 1 ? '' : 's'} with no till`} /> -
- {exceptions.noTills.map((o) => ( - - {o.name} - - ))} -
-
- )} - - ); -} - -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 ( - - } title={`${title} (${rows.length})`} hint={hint} color={tone} /> -
- {rows.map((r) => ( -
-
-
{r.primary || '—'}
-
{r.secondary}
-
-
-
{r.value}
- {r.note &&
{r.note}
} -
-
- ))} -
-
- ); -} - // ── 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. +

+
+
+ { setShowForm(!showForm); setFormError(''); }}> + + {cashierOnly ? 'Add cashier' : 'Add staff'} + +
+ + {showForm && ( +
+
+ 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 }} + /> + + 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 }} + /> +
+ +

+ A username and password are generated. The password is shown once, on the next screen. +

+ + {formError && ( +

{formError}

+ )} + +
+ + +
+
+ )} +
+ + {staffQ.isLoading ? ( + +
+ + ) : staff.length === 0 ? ( + +
+
+ +
+

No till staff yet

+

+ Nobody can sign in at a counter here. Add a person and the system issues their + username, password and PIN. +

+
+
+ ) : ( +
+
+ + + + + + + + + + + + + + {staff.map((s) => { + const inactive = (s.status || '').toLowerCase() === 'inactive'; + return ( + + + + + + + + + + ); + })} + +
NameRoleUsernamePINPasswordStatus 
{s.full_name || `#${s.user_id}`} + + {s.authname || '—'}{s.pin || '—'} + {s.has_password ? ( + + Set + + ) : ( + + Not set + + )} + + + +
+ {inactive ? ( + + ) : ( + <> + openEdit(s)}> + + + setConfirmReset(s)}> + + + setConfirmOff(s)}> + + + + )} +
+
+
+
+ )} + + {rowError && ( +

{rowError}

+ )} + + {/* 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}`}> +
+ 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 }} + /> + + 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 }} + /> +
+ {editError &&

{editError}

} +
+ + +
+
+ )} + + {confirmReset && ( + setConfirmReset(null)} title={`Reset password for ${confirmReset.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 Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +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', + }; } }); },