store user login
This commit is contained in:
@@ -10,7 +10,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ClipboardList,
|
||||
Info,
|
||||
Monitor,
|
||||
PackageX,
|
||||
Receipt,
|
||||
@@ -51,7 +50,16 @@ import {
|
||||
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
|
||||
*/
|
||||
export function ConsolePage() {
|
||||
const { branches, scoped, selected, current, tenantid, isLoading } = useBranchScope();
|
||||
const { branches, scoped, selected, current, tenantid, isLoading, isPinned } = useBranchScope();
|
||||
/**
|
||||
* Where this page's links point.
|
||||
*
|
||||
* The page is shared with the Store user workspace, pinned to one branch, and
|
||||
* a hardcoded `/admin/...` there sends a shop to a workspace its role cannot
|
||||
* enter — which the route guard answers with a redirect, so the alert silently
|
||||
* does nothing.
|
||||
*/
|
||||
const base = isPinned ? '/store' : '/admin';
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
|
||||
@@ -116,7 +124,7 @@ export function ConsolePage() {
|
||||
[rows],
|
||||
);
|
||||
|
||||
const alerts = useMemo(() => buildAlerts(rows), [rows]);
|
||||
const alerts = useMemo(() => buildAlerts(rows, base, isPinned), [rows, base, isPinned]);
|
||||
const cancelShare = share(totals.cancelled, totals.onlineOrders);
|
||||
|
||||
return (
|
||||
@@ -145,17 +153,7 @@ export function ConsolePage() {
|
||||
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Revenue"
|
||||
note="two ledgers, shown apart"
|
||||
action={
|
||||
<HStack gap={0.5} align="center">
|
||||
<Info size={12} style={{ color: 'var(--color-ink-4)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
App and counter sales are not added together
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
title="Revenue" />
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="App revenue"
|
||||
@@ -173,7 +171,7 @@ export function ConsolePage() {
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={percent(cancelShare)}
|
||||
value={percent(cancelShare ?? 0)}
|
||||
note={`${count(totals.cancelled)} of ${count(totals.onlineOrders)} app orders`}
|
||||
tone={cancelShare !== null && cancelShare > 0.1 ? 'error' : 'neutral'}
|
||||
icon={<TrendingDown size={15} />}
|
||||
@@ -187,21 +185,12 @@ export function ConsolePage() {
|
||||
icon={<Monitor size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The caveat that keeps the two figures honest, stated where the
|
||||
figures are rather than in a doc nobody opens at 9am. */}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
App revenue currently also contains any counter bills imported from a spreadsheet —
|
||||
Fiesta records the bill number on the order but does not return it, so the two cannot yet
|
||||
be told apart. Live till sales are counted separately and are never double-counted.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* ── Branch overview ─────────────────────────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={selected === null ? 'Branches' : 'Branch'}
|
||||
note="how each shop is trading"
|
||||
title={isPinned ? 'Your shop' : selected === null ? 'Branches' : 'Branch'}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -231,7 +220,7 @@ export function ConsolePage() {
|
||||
{/* ── Comparison, only when there is something to compare ─────────── */}
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Comparison" note="the same metrics, side by side" />
|
||||
<SectionHeader title="Comparison" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<ComparisonTable rows={rows} />
|
||||
@@ -244,7 +233,6 @@ export function ConsolePage() {
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Till sync"
|
||||
note="a queue nobody can see from the shop floor"
|
||||
action={
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Tills report every 30s
|
||||
@@ -260,7 +248,7 @@ export function ConsolePage() {
|
||||
|
||||
{/* ── Action required ─────────────────────────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Needs you" note="ranked by what costs money soonest" />
|
||||
<SectionHeader title="Needs you" />
|
||||
{alerts.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
@@ -466,27 +454,27 @@ function ComparisonTable({ rows }: { rows: Row[] }) {
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Metric</Th>
|
||||
<Th>Metric</Th>
|
||||
{rows.map((row) => (
|
||||
<Th key={row.branch.locationid} align="right">
|
||||
<Th key={row.branch.locationid}>
|
||||
{branchLabel(row.branch.locationname)}
|
||||
</Th>
|
||||
))}
|
||||
<Th align="right">All</Th>
|
||||
<Th>All</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.map((metric) => (
|
||||
<tr key={metric.label}>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{metric.label}
|
||||
</Td>
|
||||
{rows.map((row) => (
|
||||
<Td key={row.branch.locationid} align="right">
|
||||
<Td key={row.branch.locationid}>
|
||||
{metric.get(row)}
|
||||
</Td>
|
||||
))}
|
||||
<Td align="right" isStrong>
|
||||
<Td isStrong>
|
||||
{metric.total}
|
||||
</Td>
|
||||
</tr>
|
||||
@@ -541,39 +529,39 @@ function TillTable({ rows }: { rows: Row[] }) {
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Till</Th>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right">Queued</Th>
|
||||
<Th align="right">Oldest</Th>
|
||||
<Th align="right">Today</Th>
|
||||
<Th align="left">Notes</Th>
|
||||
<Th>Till</Th>
|
||||
<Th>Branch</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Queued</Th>
|
||||
<Th>Oldest</Th>
|
||||
<Th>Today</Th>
|
||||
<Th>Notes</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{terminals.map(({ branch, terminal }) => (
|
||||
<tr key={`${branch}:${terminal.terminalId}`}>
|
||||
<Td align="left" isStrong>
|
||||
<Td isStrong>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{terminal.terminalId}
|
||||
</span>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Badge variant={SYNC_TONE[terminal.state]} label={SYNC_LABEL[terminal.state]} />
|
||||
</Td>
|
||||
<Td align="right" isStrong={terminal.pendingBills > 0}>
|
||||
<Td isStrong={terminal.pendingBills > 0}>
|
||||
{terminal.pendingBills > 0 ? count(terminal.pendingBills) : '—'}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
{shortAge(terminal.oldestPendingMs)}
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Td>
|
||||
{count(terminal.todayBills)} · {money(terminal.todayAmount)}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{deviceNote(terminal)}
|
||||
</Td>
|
||||
</tr>
|
||||
@@ -602,7 +590,7 @@ interface Alert {
|
||||
* business cannot see, and it is the only item here that gets worse purely by
|
||||
* being ignored.
|
||||
*/
|
||||
function buildAlerts(rows: Row[]): Alert[] {
|
||||
function buildAlerts(rows: Row[], base: string, isPinned: boolean): Alert[] {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -622,7 +610,7 @@ function buildAlerts(rows: Row[]): Alert[] {
|
||||
icon: <Monitor size={15} />,
|
||||
title: `${count(bills)} bill${bills === 1 ? '' : 's'} stranded at ${row.branch.locationname}`,
|
||||
detail: `${stranded.length > 1 ? `${stranded.length} tills, oldest on ` : ''}${worst.terminalId}, waiting ${shortAge(oldest)}. Takings already collected that the business cannot see.`,
|
||||
to: '/admin/terminals',
|
||||
to: `${base}/terminals`,
|
||||
action: 'Open Terminals',
|
||||
});
|
||||
}
|
||||
@@ -651,22 +639,39 @@ function buildAlerts(rows: Row[]): Alert[] {
|
||||
.map(({ terminal }) => `${terminal.terminalId} (${SYNC_LABEL[terminal.state].toLowerCase()})`)
|
||||
.join(', ')
|
||||
.concat(branches.length > 1 ? ` across ${branches.length} branches` : ` at ${branches[0]}`),
|
||||
to: '/admin/terminals',
|
||||
to: `${base}/terminals`,
|
||||
action: 'See tills',
|
||||
});
|
||||
}
|
||||
|
||||
const waiting = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
|
||||
if (waiting > 0) {
|
||||
alerts.push({
|
||||
id: 'requests',
|
||||
tone: 'warning',
|
||||
icon: <ClipboardList size={15} />,
|
||||
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`,
|
||||
detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.',
|
||||
to: '/admin/inventory',
|
||||
action: 'Review requests',
|
||||
});
|
||||
// Same fact, opposite side of the counter. The merchant is the one holding
|
||||
// it up; the shop is the one waiting — and a shop told to "approve" a
|
||||
// request it raised itself would go looking for a button that is not there,
|
||||
// because the backend gives it to nobody but the tenant.
|
||||
alerts.push(
|
||||
isPinned
|
||||
? {
|
||||
id: 'requests',
|
||||
tone: 'warning',
|
||||
icon: <ClipboardList size={15} />,
|
||||
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} waiting on head office`,
|
||||
detail:
|
||||
'Your store administrator approves these. Confirm the goods here once they arrive.',
|
||||
to: `${base}/products`,
|
||||
action: 'Open Products',
|
||||
}
|
||||
: {
|
||||
id: 'requests',
|
||||
tone: 'warning',
|
||||
icon: <ClipboardList size={15} />,
|
||||
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`,
|
||||
detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.',
|
||||
to: '/admin/inventory',
|
||||
action: 'Review requests',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -678,7 +683,7 @@ function buildAlerts(rows: Row[]): Alert[] {
|
||||
icon: <PackageX size={15} />,
|
||||
title: `${percent(cancelRate)} of app orders cancelled at ${row.branch.locationname}`,
|
||||
detail: `${count(row.cancelled)} of ${count(row.onlineOrders)} orders. Usually stock the app thinks is on the shelf and is not.`,
|
||||
to: '/admin/sales',
|
||||
to: `${base}/sales`,
|
||||
action: 'Open Sales',
|
||||
});
|
||||
}
|
||||
@@ -738,11 +743,11 @@ function AlertRow({ alert }: { alert: Alert }) {
|
||||
|
||||
/* ── Table primitives ────────────────────────────────────────────────────── */
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
function Th({ children }: { children: React.ReactNode; }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
@@ -758,24 +763,22 @@ function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'r
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '11px 14px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -9,12 +9,17 @@ import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ClipboardList,
|
||||
DownloadCloud,
|
||||
UploadCloud,
|
||||
IndianRupee,
|
||||
Info,
|
||||
Layers,
|
||||
PackageSearch,
|
||||
Send,
|
||||
PackageX,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { stockApi } from '@/api/stock';
|
||||
@@ -25,9 +30,10 @@ import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { ProductsPanel } from '../ProductsPanel';
|
||||
import { branchLabel, count, money } from '../format';
|
||||
|
||||
type Tab = 'catalogue' | 'stock' | 'requests';
|
||||
type Tab = 'products' | 'stock' | 'requests';
|
||||
|
||||
/**
|
||||
* Inventory — catalogue, stock and the approval queue.
|
||||
@@ -45,8 +51,12 @@ type Tab = 'catalogue' | 'stock' | 'requests';
|
||||
* backend catch up. Nothing on screen claims the selection is in force.
|
||||
*/
|
||||
export function InventoryPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('requests');
|
||||
const { branches, selected, current, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('products');
|
||||
// Owned here so the button can live in the tab row while the drawer it opens
|
||||
// stays inside the panel that uses it.
|
||||
const [isImporting, setImporting] = useState(false);
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
const requests = useStockRequests(
|
||||
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
|
||||
@@ -63,13 +73,42 @@ export function InventoryPage() {
|
||||
title="Inventory"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="What you sell, where it is stocked, and what the shops are asking you for."
|
||||
/* The two product actions live here, not in the tab row, so their
|
||||
position is fixed — they no longer appear and disappear as the tab
|
||||
changes, and nothing below shifts sideways when they do.
|
||||
|
||||
The "N awaiting approval" badge that used to sit here is gone: the
|
||||
Stock requests tab already carries the same count, and a number
|
||||
stated twice on one screen invites the reader to check whether the
|
||||
two agree. */
|
||||
actions={
|
||||
pending.length > 0 ? (
|
||||
<Badge variant="warning" label={`${pending.length} awaiting approval`} />
|
||||
) : undefined
|
||||
<HStack gap={0.5} align="center" wrap="wrap">
|
||||
<BarAction
|
||||
label="Upload sheet"
|
||||
icon={<UploadCloud size={14} />}
|
||||
isDisabled={!tenantid}
|
||||
onClick={() => setUploading(true)}
|
||||
/>
|
||||
<BarAction
|
||||
label="Import products"
|
||||
icon={<DownloadCloud size={14} />}
|
||||
isDisabled={!tenantid}
|
||||
onClick={() => setImporting(true)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
tabs={
|
||||
/* The tab row carries the Products action on its right, rather than
|
||||
giving it a row of its own below. It only appears on that tab —
|
||||
an action for a panel you are not looking at is a misfire waiting
|
||||
to happen. */
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Products"
|
||||
icon={<PackageSearch size={14} />}
|
||||
isActive={tab === 'products'}
|
||||
onClick={() => setTab('products')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Stock requests"
|
||||
icon={<ClipboardList size={14} />}
|
||||
@@ -83,22 +122,21 @@ export function InventoryPage() {
|
||||
isActive={tab === 'stock'}
|
||||
onClick={() => setTab('stock')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Catalogue"
|
||||
icon={<PackageSearch size={14} />}
|
||||
isActive={tab === 'catalogue'}
|
||||
onClick={() => setTab('catalogue')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{tab === 'requests' ? (
|
||||
{tab === 'products' ? (
|
||||
<ProductsPanel
|
||||
isImporting={isImporting}
|
||||
onCloseImport={() => setImporting(false)}
|
||||
isUploading={isUploading}
|
||||
onCloseUpload={() => setUploading(false)}
|
||||
/>
|
||||
) : tab === 'requests' ? (
|
||||
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
||||
) : tab === 'stock' ? (
|
||||
<StockPanel />
|
||||
) : (
|
||||
<CataloguePanel branchCount={scoped.length} />
|
||||
<StockPanel />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
@@ -154,17 +192,19 @@ function RequestsPanel({
|
||||
value={count(pending.reduce((sum, entry) => sum + entry.qty, 0))}
|
||||
note="across open requests"
|
||||
tone="neutral"
|
||||
icon={<Boxes size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Settled"
|
||||
value={count(settled.length)}
|
||||
note="approved or rejected"
|
||||
tone="neutral"
|
||||
icon={<CheckCheck size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Open requests" note="a store cannot add its own stock" />
|
||||
<SectionHeader title="Open requests" />
|
||||
{isLoading ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
@@ -268,29 +308,29 @@ function RequestsPanel({
|
||||
|
||||
{settled.length > 0 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Decided" note="most recent first" />
|
||||
<SectionHeader title="Decided" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Product</Th>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="right">Qty</Th>
|
||||
<Th align="left">Outcome</Th>
|
||||
<Th>Product</Th>
|
||||
<Th>Branch</Th>
|
||||
<Th>Qty</Th>
|
||||
<Th>Outcome</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settled.slice(0, 25).map((request) => (
|
||||
<tr key={request.requestid}>
|
||||
<Td align="left" isStrong>
|
||||
<Td isStrong>
|
||||
{request.productname ?? `Product ${request.productid}`}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{branchLabel(request.locationname) || `Branch ${request.locationid}`}
|
||||
</Td>
|
||||
<Td align="right">{count(request.qty)}</Td>
|
||||
<Td align="left">
|
||||
<Td>{count(request.qty)}</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
variant={request.status === 'Received' ? 'success' : 'neutral'}
|
||||
label={request.status === 'Received' ? 'Stock added' : request.status}
|
||||
@@ -346,6 +386,7 @@ function StockPanel() {
|
||||
value={money(value)}
|
||||
note={`${count(rows.length)} products`}
|
||||
tone="accent"
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Low stock"
|
||||
@@ -359,13 +400,13 @@ function StockPanel() {
|
||||
value={count(out)}
|
||||
note="nothing on the shelf"
|
||||
tone={out > 0 ? 'error' : 'success'}
|
||||
icon={<PackageX size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={branch ? `Stock at ${branch.locationname}` : 'Stock'}
|
||||
note="opening → in → out → closing"
|
||||
action={
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
@@ -381,16 +422,6 @@ function StockPanel() {
|
||||
}
|
||||
/>
|
||||
|
||||
{scoped.length > 1 ? (
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Showing <strong>{branch?.locationname}</strong>. The stock statement is scoped to one
|
||||
branch by the backend — pick a branch in the header to switch.
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
{statement.isLoading ? (
|
||||
<VStack padding={3}>
|
||||
@@ -413,13 +444,13 @@ function StockPanel() {
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Product</Th>
|
||||
<Th align="right">Opening</Th>
|
||||
<Th align="right">In</Th>
|
||||
<Th align="right">Out</Th>
|
||||
<Th align="right">Closing</Th>
|
||||
<Th align="right">Value</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th>Product</Th>
|
||||
<Th>Opening</Th>
|
||||
<Th>In</Th>
|
||||
<Th>Out</Th>
|
||||
<Th>Closing</Th>
|
||||
<Th>Value</Th>
|
||||
<Th>Status</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -427,21 +458,21 @@ function StockPanel() {
|
||||
const closing = row.closing ?? 0;
|
||||
return (
|
||||
<tr key={row.productid}>
|
||||
<Td align="left" isStrong>
|
||||
<Td isStrong>
|
||||
{row.productname ?? `Product ${row.productid}`}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
{count(row.opening)}
|
||||
</Td>
|
||||
<Td align="right">{count(row.credit)}</Td>
|
||||
<Td align="right">{count(row.debit)}</Td>
|
||||
<Td align="right" isStrong>
|
||||
<Td>{count(row.credit)}</Td>
|
||||
<Td>{count(row.debit)}</Td>
|
||||
<Td isStrong>
|
||||
{count(closing)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
{money(closing * (row.retailprice ?? 0))}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Badge
|
||||
variant={closing <= 0 ? 'error' : closing <= 5 ? 'warning' : 'success'}
|
||||
label={closing <= 0 ? 'Out' : closing <= 5 ? 'Low' : 'In stock'}
|
||||
@@ -462,166 +493,58 @@ function StockPanel() {
|
||||
|
||||
/* ── Catalogue ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The catalogue tiers and how a product moves between them.
|
||||
*
|
||||
* Deliberately an explanation plus the two entry points rather than a second
|
||||
* copy of the Nearle Admin's catalogue browser: the import UI already exists
|
||||
* and works, and duplicating it here is how two screens drift apart.
|
||||
*/
|
||||
function CataloguePanel({ branchCount }: { branchCount: number }) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="How a product reaches a shelf" note="four steps, two entry points" />
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<div className="form-grid">
|
||||
<Step
|
||||
n={1}
|
||||
title="Into your catalogue"
|
||||
body="Import from the global catalogue, or upload your own spreadsheet. Only you can do either — a store manager cannot."
|
||||
/>
|
||||
<Step
|
||||
n={2}
|
||||
title="Price it"
|
||||
body="The global catalogue carries a price range, not a price. Yours is the one customers pay, and it is set per branch."
|
||||
/>
|
||||
<Step
|
||||
n={3}
|
||||
title="Publish"
|
||||
body={`Publishing releases the product to all ${branchCount} branch${branchCount === 1 ? '' : 'es'}. Until then it is yours alone and no store can see it.`}
|
||||
/>
|
||||
<Step
|
||||
n={4}
|
||||
title="Stock it"
|
||||
body="A published product is not stock. A store raises a request, you approve it, and only then does anything reach the shelf."
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Add products" note="both paths end in the same catalogue" />
|
||||
<div className="card-grid">
|
||||
<EntryCard
|
||||
icon={<PackageSearch size={18} />}
|
||||
title="From the global catalogue"
|
||||
body="1,267 FMCG products with images and pack sizes. Pick what you sell and set your own price."
|
||||
action="Browse catalogue"
|
||||
/>
|
||||
<EntryCard
|
||||
icon={<Send size={18} />}
|
||||
title="From a spreadsheet"
|
||||
body="Your own product list. Validated before anything is created — you see valid, duplicate and error rows, and can download the failures to fix and re-upload."
|
||||
action="Upload a sheet"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
|
||||
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Two things the spec asks for that the backend cannot do yet
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<strong>Per-branch assignment.</strong> Publishing releases a product to every
|
||||
branch — `PublishProduct` takes a tenant and a product, with no location list. The
|
||||
per-branch controls are built to your spec and will start working the day that
|
||||
parameter lands; nothing here pretends the selection is in force today.
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<strong>Duplicate detection by SKU.</strong> Across the live catalogue 6,245 products
|
||||
share 93 distinct SKU values and 154 are blank, so a SKU cannot decide that two rows
|
||||
are the same product. Upload validation warns on repeats but will not merge on them,
|
||||
and there is no barcode column anywhere in Fiesta to fall back to.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, title, body }: { n: number; title: string; body: string }) {
|
||||
return (
|
||||
<VStack gap={0.5}>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryCard({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
action: string;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<span
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<Button label={action} variant="secondary" size="sm" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* An action on the tab bar, drawn as the tabs are drawn.
|
||||
*
|
||||
* Same height, radius, type size and ink as `TabButton`, with no border and no
|
||||
* fill — a filled pill on the right of a row of flat tabs reads as a different
|
||||
* control system sitting in the same strip. It takes a hover tint instead of a
|
||||
* resting one, which is what separates an action from a tab that is simply not
|
||||
* selected.
|
||||
*/
|
||||
function BarAction({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: isDisabled ? 'default' : 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
@@ -680,11 +603,11 @@ function TabButton({
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
function Th({ children }: { children: React.ReactNode; }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
@@ -700,24 +623,22 @@ function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'r
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '11px 14px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -188,7 +188,6 @@ export function OnboardBranchPage() {
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Owner and identity"
|
||||
note="which tenant this branch belongs to"
|
||||
action={<Store size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
@@ -228,7 +227,6 @@ export function OnboardBranchPage() {
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Location"
|
||||
note="where the outlet sits"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -250,7 +248,6 @@ export function OnboardBranchPage() {
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Hours and delivery"
|
||||
note="what the customer app uses to decide if an address is servable"
|
||||
action={<Clock size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
|
||||
@@ -3,7 +3,22 @@ import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { BarChart3, Boxes, IndianRupee, Info } from 'lucide-react';
|
||||
import {
|
||||
ArrowDownUp,
|
||||
BadgePercent,
|
||||
BarChart3,
|
||||
Boxes,
|
||||
Calculator,
|
||||
CircleCheck,
|
||||
IndianRupee,
|
||||
Package,
|
||||
Percent,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
ShoppingCart,
|
||||
TrendingDown,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
@@ -126,24 +141,28 @@ export function ReportsPage() {
|
||||
value={money(totals.appRevenue)}
|
||||
note={`${count(totals.appOrders)} orders`}
|
||||
tone="accent"
|
||||
icon={<ShoppingCart size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter revenue"
|
||||
value={money(totals.counterRevenue)}
|
||||
note={`${count(totals.counterBills)} bills`}
|
||||
tone="success"
|
||||
icon={<Receipt size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tax collected"
|
||||
value={money(totals.tax)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
icon={<Percent size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Discount given"
|
||||
value={money(totals.discount)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
icon={<BadgePercent size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -151,35 +170,35 @@ export function ReportsPage() {
|
||||
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="By branch" note="within a channel, so the comparison holds" />
|
||||
<SectionHeader title="By branch" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="right">App revenue</Th>
|
||||
<Th align="right">Counter revenue</Th>
|
||||
<Th align="right">App orders</Th>
|
||||
<Th align="right">Counter bills</Th>
|
||||
<Th align="right">Avg bill</Th>
|
||||
<Th>Branch</Th>
|
||||
<Th>App revenue</Th>
|
||||
<Th>Counter revenue</Th>
|
||||
<Th>App orders</Th>
|
||||
<Th>Counter bills</Th>
|
||||
<Th>Avg bill</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.branch.locationid}>
|
||||
<Td align="left" isStrong>
|
||||
<Td isStrong>
|
||||
{branchLabel(row.branch.locationname)}
|
||||
</Td>
|
||||
<Td align="right">{money(row.appRevenue)}</Td>
|
||||
<Td align="right">{money(row.counterRevenue)}</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td>{money(row.appRevenue)}</Td>
|
||||
<Td>{money(row.counterRevenue)}</Td>
|
||||
<Td isMuted>
|
||||
{count(row.appOrders)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
{count(row.counterBills)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
{row.counterBills > 0
|
||||
? money(row.counterRevenue / row.counterBills)
|
||||
: '—'}
|
||||
@@ -201,6 +220,7 @@ export function ReportsPage() {
|
||||
value={count(totals.delivered)}
|
||||
note={`of ${count(totals.appOrders)} app orders`}
|
||||
tone="success"
|
||||
icon={<CircleCheck size={15} />}
|
||||
fill={share(totals.delivered, totals.appOrders) ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
@@ -210,6 +230,7 @@ export function ReportsPage() {
|
||||
tone={
|
||||
(share(totals.cancelled, totals.appOrders) ?? 0) > 0.1 ? 'error' : 'neutral'
|
||||
}
|
||||
icon={<TrendingDown size={15} />}
|
||||
fill={share(totals.cancelled, totals.appOrders) ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
@@ -217,37 +238,20 @@ export function ReportsPage() {
|
||||
value={count(totals.counterBills)}
|
||||
note="rung up at a till"
|
||||
tone="neutral"
|
||||
icon={<ReceiptText size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average bill"
|
||||
value={
|
||||
totals.counterBills > 0
|
||||
? money(totals.counterRevenue / totals.counterBills)
|
||||
: '—'
|
||||
}
|
||||
value={money(
|
||||
totals.counterBills > 0 ? totals.counterRevenue / totals.counterBills : 0,
|
||||
)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
icon={<Calculator size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DailyTrend rows={rows} />
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
|
||||
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Fast and slow movers need one more read
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Product-level performance comes from the stock statement, not from the sales
|
||||
summary — movement out of stock is what makes a product fast or slow. It is on the
|
||||
Inventory tab, per branch, because the statement endpoint is scoped to a single
|
||||
location.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : (
|
||||
<InventoryReport />
|
||||
@@ -269,7 +273,7 @@ function ChannelSplit({ app, counter }: { app: number; counter: number }) {
|
||||
const max = Math.max(app, counter, 1);
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="App versus counter" note="compared, not combined" />
|
||||
<SectionHeader title="App versus counter" />
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
|
||||
@@ -353,7 +357,7 @@ function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Counter takings by day" note="every till, combined" />
|
||||
<SectionHeader title="Counter takings by day" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
|
||||
<div style={{ minWidth: 400, height: '100%' }}>
|
||||
@@ -421,19 +425,27 @@ function InventoryReport() {
|
||||
value={money(value)}
|
||||
note={branch?.locationname ?? '—'}
|
||||
tone="accent"
|
||||
icon={<Warehouse size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Products"
|
||||
value={count(rows.length)}
|
||||
note="with a stock record"
|
||||
tone="neutral"
|
||||
icon={<Package size={15} />}
|
||||
/>
|
||||
<KpiCard label="Products" value={count(rows.length)} note="with a stock record" tone="neutral" />
|
||||
<KpiCard
|
||||
label="Units moved"
|
||||
value={count(rows.reduce((sum, row) => sum + (row.debit ?? 0), 0))}
|
||||
note="off the shelf this period"
|
||||
tone="neutral"
|
||||
icon={<ArrowDownUp size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<MoverList title="Fast moving" note="most units off the shelf" rows={fast} />
|
||||
<MoverList title="Slow moving" note="in stock, barely selling" rows={slow} />
|
||||
<MoverList title="Fast moving" rows={fast} />
|
||||
<MoverList title="Slow moving" rows={slow} />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
@@ -441,16 +453,14 @@ function InventoryReport() {
|
||||
|
||||
function MoverList({
|
||||
title,
|
||||
note,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
note: string;
|
||||
rows: { productid: number; productname?: string; debit?: number; closing?: number }[];
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title={title} note={note} />
|
||||
<SectionHeader title={title} />
|
||||
<Card padding={0} elevation="low">
|
||||
{rows.length === 0 ? (
|
||||
<VStack padding={3}>
|
||||
@@ -529,11 +539,11 @@ function TabButton({
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
function Th({ children }: { children: React.ReactNode; }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
@@ -549,24 +559,22 @@ function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'r
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '11px 14px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, MapPin, Package, Receipt, ShoppingCart, Truck } from 'lucide-react';
|
||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
syncLagMs,
|
||||
type StatusKey,
|
||||
} from '../orderStatus';
|
||||
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
||||
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||
import { shortAge } from '../posStatus';
|
||||
|
||||
@@ -52,7 +53,8 @@ type Tab = 'orders' | 'deliveries' | 'counter';
|
||||
* every row would be a column of dashes.
|
||||
*/
|
||||
export function SalesPage() {
|
||||
const { scoped, selected, current, branches, tenantid } = useBranchScope();
|
||||
const { scoped, selected, current, branches, tenantid, isPinned } = useBranchScope();
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('orders');
|
||||
const [status, setStatus] = useState<StatusKey>('all');
|
||||
const [preset, setPreset] = useState<RangePreset>('month');
|
||||
@@ -161,14 +163,25 @@ export function SalesPage() {
|
||||
description="Every order placed in the app, the delivery jobs riding on them, and every bill rung up at a till."
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
<HStack gap={0.5} align="center" wrap="wrap">
|
||||
{/* Counter sales that never touched a till. Sits beside the date
|
||||
range rather than under the Counter tab because it is how the
|
||||
figures on every tab get there for a shop with no POS. */}
|
||||
<BarAction
|
||||
label="Upload counter sales"
|
||||
icon={<Upload size={14} />}
|
||||
isDisabled={!tenantid}
|
||||
onClick={() => setUploading(true)}
|
||||
/>
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
@@ -239,9 +252,10 @@ export function SalesPage() {
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cash to collect"
|
||||
value={orderTotals.cod > 0 ? money(orderTotals.cod) : '—'}
|
||||
value={money(orderTotals.cod)}
|
||||
note="COD on these orders"
|
||||
tone={orderTotals.cod > 0 ? 'warning' : 'neutral'}
|
||||
icon={<Banknote size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Items"
|
||||
@@ -252,16 +266,16 @@ export function SalesPage() {
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average order"
|
||||
value={orderRows.length > 0 ? money(orderTotals.value / orderRows.length) : '—'}
|
||||
value={money(orderRows.length > 0 ? orderTotals.value / orderRows.length : 0)}
|
||||
note="in this view"
|
||||
tone="neutral"
|
||||
icon={<Calculator size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Orders"
|
||||
note="newest first"
|
||||
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />}
|
||||
/>
|
||||
<OrdersTable
|
||||
@@ -301,12 +315,14 @@ export function SalesPage() {
|
||||
value={count(deliveryTotals.unassigned)}
|
||||
note="no rider yet"
|
||||
tone={deliveryTotals.unassigned > 0 ? 'warning' : 'success'}
|
||||
icon={<UserX size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Job value"
|
||||
value={money(deliveryTotals.value)}
|
||||
note="what these deliveries carry"
|
||||
tone="neutral"
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Rider charges"
|
||||
@@ -320,7 +336,6 @@ export function SalesPage() {
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Deliveries"
|
||||
note="planned against actual"
|
||||
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />}
|
||||
/>
|
||||
<DeliveriesTable
|
||||
@@ -355,17 +370,17 @@ export function SalesPage() {
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average bill"
|
||||
value={
|
||||
counterTotals.bills > 0 ? money(counterTotals.gross / counterTotals.bills) : '—'
|
||||
}
|
||||
value={money(counterTotals.bills > 0 ? counterTotals.gross / counterTotals.bills : 0)}
|
||||
note="per transaction"
|
||||
tone="neutral"
|
||||
icon={<Calculator size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tax collected"
|
||||
value={money(counterTotals.tax)}
|
||||
note="on counter sales"
|
||||
tone="neutral"
|
||||
icon={<Percent size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Bills listed"
|
||||
@@ -376,13 +391,13 @@ export function SalesPage() {
|
||||
: 'all of them'
|
||||
}
|
||||
tone="neutral"
|
||||
icon={<ReceiptText size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Counter bills"
|
||||
note="as the tills reported them"
|
||||
action={
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
|
||||
}
|
||||
@@ -396,6 +411,15 @@ export function SalesPage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isUploading && tenantid ? (
|
||||
<CounterSalesDrawer
|
||||
tenantid={tenantid}
|
||||
{...(isPinned && current ? { locationid: current.locationid } : {})}
|
||||
branchIds={branches.map((branch) => branch.locationid)}
|
||||
onClose={() => setUploading(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detail ? (
|
||||
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
|
||||
) : null}
|
||||
@@ -433,16 +457,16 @@ function OrdersTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="right">#</Th>
|
||||
<Th align="left">Order</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Pickup</Th>
|
||||
<Th align="left">Drop</Th>
|
||||
<Th align="right">Qty</Th>
|
||||
<Th align="right">COD</Th>
|
||||
<Th align="right">Amount</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
<Th>#</Th>
|
||||
<Th>Order</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Pickup</Th>
|
||||
<Th>Drop</Th>
|
||||
<Th>Qty</Th>
|
||||
<Th>COD</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -453,17 +477,17 @@ function OrdersTable({
|
||||
const amount = orderValue(row);
|
||||
return (
|
||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{index + 1}</Mono>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{row.orderid || `#${row.orderheaderid}`}</Mono>
|
||||
</Primary>
|
||||
<Secondary>{stamp(row.orderdate)}</Secondary>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
@@ -482,29 +506,29 @@ function OrdersTable({
|
||||
) : null}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>{row.pickupcustomer || branchLabel(row.locationname)}</Primary>
|
||||
<Secondary>{row.pickupsuburb || row.pickupaddress}</Secondary>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>{row.deliverycustomer || '—'}</Primary>
|
||||
<Secondary>{row.deliverysuburb || row.deliveryaddress}</Secondary>
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{orderQuantity(row) || '—'}</Mono>
|
||||
</Td>
|
||||
{/* Only rendered when there is cash to collect — a COD column
|
||||
of dashes is a column that costs width and says nothing. */}
|
||||
<Td align="right" isStrong={cod > 0} isMuted={cod === 0}>
|
||||
<Td isStrong={cod > 0} isMuted={cod === 0}>
|
||||
<Mono>{cod > 0 ? moneyExact(cod) : '—'}</Mono>
|
||||
</Td>
|
||||
<Td align="right" isStrong={amount > 0} isMuted={amount === 0}>
|
||||
<Td isStrong={amount > 0} isMuted={amount === 0}>
|
||||
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Chip label={row.orderstatus} color={statusColor(ORDER_STATUS, row.orderstatus)} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Td>
|
||||
<ViewHint />
|
||||
</Td>
|
||||
</Row>
|
||||
@@ -547,15 +571,15 @@ function DeliveriesTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Order</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Pickup</Th>
|
||||
<Th align="left">Drop</Th>
|
||||
<Th align="left">Rider</Th>
|
||||
<Th align="right">KM</Th>
|
||||
<Th align="right">Charge / value</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="left">Notes</Th>
|
||||
<Th>Order</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Pickup</Th>
|
||||
<Th>Drop</Th>
|
||||
<Th>Rider</Th>
|
||||
<Th>KM</Th>
|
||||
<Th>Charge / value</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Notes</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -579,26 +603,26 @@ function DeliveriesTable({
|
||||
|
||||
return (
|
||||
<Row key={row.deliveryid} onClick={() => onOpen(row)}>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{row.orderid || `DLV-${row.deliveryid}`}</Mono>
|
||||
</Primary>
|
||||
<Secondary>{stamp(row.assigntime || row.deliverydate)}</Secondary>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{branchLabel(row.locationname)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary w={124}>{pickupName || pickupAddr || '—'}</Primary>
|
||||
<Secondary w={124}>{pickupName ? pickupAddr : ''}</Secondary>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary w={124}>{dropName || dropAddr || '—'}</Primary>
|
||||
<Secondary w={124}>{dropName ? dropAddr : ''}</Secondary>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
{row.ridername ? (
|
||||
<span
|
||||
style={{
|
||||
@@ -642,24 +666,24 @@ function DeliveriesTable({
|
||||
{/* Planned above, actual below — the pair is the point. A
|
||||
delivery that was quoted 3km and rode 9 is the row worth
|
||||
finding, and one number cannot show it. */}
|
||||
<Td align="right">
|
||||
<Td>
|
||||
<Pill color="#ef4444">{planned ? planned.toFixed(1) : '—'}</Pill>
|
||||
{actual > 0 ? <Pill color="#10b981">{actual.toFixed(1)}</Pill> : null}
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Td>
|
||||
{charge > 0 ? <Pill color="#ef4444">{moneyExact(charge)}</Pill> : null}
|
||||
{value > 0 ? <Pill color="#10b981">{moneyExact(value)}</Pill> : null}
|
||||
{charge === 0 && value === 0 ? (
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
|
||||
) : null}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Chip
|
||||
label={row.orderstatus}
|
||||
color={statusColor(DELIVERY_STATUS, row.orderstatus)}
|
||||
/>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
<span
|
||||
title={notes}
|
||||
style={{
|
||||
@@ -711,16 +735,16 @@ function BillsTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Invoice</Th>
|
||||
<Th align="left">Time</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Till</Th>
|
||||
<Th align="left">Cashier</Th>
|
||||
<Th align="left">Customer</Th>
|
||||
<Th align="left">Paid by</Th>
|
||||
<Th align="right">Items</Th>
|
||||
<Th align="right">Tax</Th>
|
||||
<Th align="right">Total</Th>
|
||||
<Th>Invoice</Th>
|
||||
<Th>Time</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Till</Th>
|
||||
<Th>Cashier</Th>
|
||||
<Th>Customer</Th>
|
||||
<Th>Paid by</Th>
|
||||
<Th>Items</Th>
|
||||
<Th>Tax</Th>
|
||||
<Th>Total</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -731,7 +755,7 @@ function BillsTable({
|
||||
const lag = syncLagMs(bill);
|
||||
return (
|
||||
<tr key={bill.terminalorderid ?? `${branch}:${bill.posorderid}`}>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{bill.invoicenumber || `#${bill.posorderid}`}</Mono>
|
||||
</Primary>
|
||||
@@ -748,36 +772,36 @@ function BillsTable({
|
||||
) : null}
|
||||
</Secondary>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{clockOf(bill.billedat)}
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{bill.terminalid || '—'}</Mono>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{bill.cashiername || '—'}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{bill.customername || bill.customermobile || 'Walk-in'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Chip
|
||||
label={bill.paymentmode || 'n/a'}
|
||||
color={bill.paymentmode ? 'var(--color-brand)' : 'var(--color-ink-4)'}
|
||||
/>
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{count(bill.itemcount)}</Mono>
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{moneyExact(bill.taxamount)}</Mono>
|
||||
</Td>
|
||||
<Td align="right" isStrong>
|
||||
<Td isStrong>
|
||||
<Mono>{moneyExact(bill.total)}</Mono>
|
||||
</Td>
|
||||
</tr>
|
||||
@@ -864,7 +888,8 @@ function Pill({ color, children }: { color: string; children: React.ReactNode })
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
// Centred with the rest of the table, not pushed to the cell's edge.
|
||||
margin: '0 auto',
|
||||
width: 'fit-content',
|
||||
minWidth: 62,
|
||||
textAlign: 'center',
|
||||
@@ -1130,11 +1155,11 @@ function Row({ children, onClick }: { children: React.ReactNode; onClick: () =>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
|
||||
function Th({ children }: { children?: React.ReactNode; }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
@@ -1150,19 +1175,17 @@ function Th({ children, align }: { children?: React.ReactNode; align: 'left' | '
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
@@ -1174,3 +1197,49 @@ function Td({
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A header action drawn like a tab, so the two rows read as one bar. Same shape
|
||||
* as Inventory's — copied rather than shared because the two pages own their
|
||||
* chrome, and a shared one would have to take a theme prop to stay identical.
|
||||
*/
|
||||
function BarAction({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Info, Monitor, Plus, UserCog, Users } from 'lucide-react';
|
||||
import { Monitor, Plus, UserCog, Users } from 'lucide-react';
|
||||
import type { PosUser, StaffInfo } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
@@ -153,11 +153,6 @@ export function UsersPage() {
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={group === 'store' ? 'Back office' : 'Counter staff'}
|
||||
note={
|
||||
group === 'store'
|
||||
? 'signs in to this console'
|
||||
: 'signs in at a till — no console login'
|
||||
}
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 170 }}>
|
||||
@@ -204,29 +199,6 @@ export function UsersPage() {
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Stated once, under whichever list is showing. */}
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{group === 'store' ? (
|
||||
<>
|
||||
There is no delete here. Removing an account is a hard delete with no cascade in
|
||||
Fiesta, so anything that account touched would be left pointing at nothing —
|
||||
deactivating keeps the history and stops the login. Passwords are not managed from
|
||||
this console either, because they are stored in clear; adding a person creates the
|
||||
account, and issuing its password is a backend job until that changes.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
A till account has no console login — the backend leaves roles 7 and 8 out of every
|
||||
application lookup, so a cashier is simply not found rather than refused. The PIN is
|
||||
shown once when the account is created and never read back; if it is lost, reissue
|
||||
rather than look it up.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{editingPerson ? (
|
||||
<PersonDrawer
|
||||
row={editingPerson === 'new' ? null : editingPerson}
|
||||
@@ -282,13 +254,13 @@ function StaffTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Name</Th>
|
||||
<Th align="left">Role</Th>
|
||||
<Th align="left">Email</Th>
|
||||
<Th align="left">Mobile</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Mobile</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -296,29 +268,29 @@ function StaffTable({
|
||||
const branch = branches.find((entry) => entry.locationid === person.locationid);
|
||||
return (
|
||||
<tr key={person.userid}>
|
||||
<Td align="left" isStrong>
|
||||
<Td isStrong>
|
||||
{person.fullname || `${person.firstname ?? ''} ${person.lastname ?? ''}`.trim() || '—'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
{/* Server-resolved. Never mapped from roleid here — most
|
||||
accounts carry an id that is absent from `app_roles`. */}
|
||||
<Chip label={person.rolename} tone="brand" />
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{person.email || '—'}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{person.contactno || '—'}
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{branchLabel(branch?.locationname ?? person.locationname)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Td>
|
||||
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
|
||||
</Td>
|
||||
</tr>
|
||||
@@ -359,38 +331,38 @@ function TillTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Name</Th>
|
||||
<Th align="left">Role</Th>
|
||||
<Th align="left">Mobile</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Shift</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Mobile</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Shift</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ person, branch, locationid }) => (
|
||||
<tr key={person.user_id}>
|
||||
<Td align="left" isStrong>
|
||||
<Td isStrong>
|
||||
{person.full_name || '—'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Chip
|
||||
label={person.role}
|
||||
tone={person.role?.toLowerCase() === 'supervisor' ? 'violet' : 'slate'}
|
||||
/>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{person.contactno || '—'}
|
||||
</span>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{person.shift_name ? (
|
||||
<>
|
||||
<span style={{ display: 'block' }}>{person.shift_name}</span>
|
||||
@@ -402,10 +374,10 @@ function TillTable({
|
||||
'Any'
|
||||
)}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Td>
|
||||
<Button
|
||||
label="Edit"
|
||||
variant="secondary"
|
||||
@@ -562,11 +534,11 @@ function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; bo
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
|
||||
function Th({ children }: { children?: React.ReactNode; }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
@@ -582,19 +554,17 @@ function Th({ children, align }: { children?: React.ReactNode; align: 'left' | '
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
|
||||
Reference in New Issue
Block a user