store user login

This commit is contained in:
2026-08-25 18:00:16 +05:30
parent 1dc582ba07
commit 517a99d577
62 changed files with 8059 additions and 650 deletions

View File

@@ -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',
}}
>

View File

@@ -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',
}}
>

View File

@@ -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">

View File

@@ -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',
}}
>

View File

@@ -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>
);
}

View File

@@ -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)',