feat(floor): the shop floor, and a sale recorded against a visit

Two surfaces a merchant currently has no way to reach, and the BFF routes
behind them.

/floor is the only screen in this console aimed at somebody standing behind
a counter: who walked in, who is being served, and by whom. A visit is
claimed with attend, handed back with release, and closed with complete.
Claiming is single-winner — the platform decides, and a second person
tapping the same customer gets 409 CUSTOMER_ALREADY_TAKEN rather than a
silent overwrite. That was verified against a live platform: eight
concurrent claims, exactly one winner.

Naming a walk-in posts to /api/customers, which is the same permission as
PUT /api/visitors/{id}/profile upstream — attaching a name and a phone
number to a face is the floor's job, not a manager's.

Sale entry sits on the floor because that is where a sale happens. Lines
carry an intent, purchased or enquired, so a shop can record what somebody
asked about and did not buy; only purchased lines are billed. Money is
integer paise end to end and formatPaise is the only place it becomes
rupees — a float round-trip through the BFF was rejected once already and
must not come back.

Every write carries an idempotency key minted per dialog, so a double tap,
a timeout retry and a resubmit collapse into one sale rather than three.
Proven live: a replayed key returns 200 already_processed with the original
sale id.

/commerce gains the real list of those sales, replacing nothing invented —
it reads GET /api/sales and opens a detail view per row.

── What this does NOT do ────────────────────────────────────────────────
No mock, demo or placeholder data anywhere in it. Every figure comes off a
payload; an empty floor renders an empty state and says so.

── Known: the deployed backend does not serve these yet ─────────────────
/api/floor/visits, /api/customers, /api/sales and the three visit actions
all answer 404 on mcp.loyaly.ai today, which runs a build older than this
repository's first commit. Until that backend ships, /floor and the sales
panel will show error states, and the Floor nav entry points at a page that
cannot load its data. Committed deliberately so the two halves can be
deployed together rather than drifting further apart.

staff -> /floor has been the committed destination since 697b0d9; this is
the page it was always pointing at.

Verified: tsc clean, production build clean, lint unchanged at the existing
baseline. Exercised against a live local platform for all three roles —
attend/release/steal-prevention, customer creation, sale creation and
idempotent replay, and two-tenant isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
This commit is contained in:
2026-09-18 11:12:04 +05:30
parent 781757d377
commit dccb1beda5
22 changed files with 1481 additions and 2 deletions

View File

@@ -1,11 +1,20 @@
'use client';
import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {List, ListItem} from '@astryxdesign/core/List';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {useSales} from '@/features/commerce/hooks/useSales';
import {SaleDetailDialog} from '@/features/commerce/components/SaleDetailDialog';
import {formatPaise} from '@/features/commerce/services/money';
import {CHART} from '@/shared/components/charts/palette';
import {useConversionReport} from '@/features/dashboard/hooks/useReports';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
@@ -26,7 +35,9 @@ import {formatInrCompact} from '@/shared/utils/format';
* invented inventory as though a merchant could act on it.
*/
export default function CommercePage() {
const [openSale, setOpenSale] = useState<string | null>(null);
const conversion = useConversionReport({bucket: 'day'});
const sales = useSales();
const scopeLabel = useScopeLabel();
return (
@@ -55,9 +66,56 @@ export default function CommercePage() {
)}
</ChartCard>
<PanelCard
title="Recent sales"
subtitle="Every sale recorded against a visit"
resource={sales}
loading={<SkeletonRows count={5} />}
empty={
<EmptyPanel
icon="commerce"
title="No sales recorded yet"
description="Sales appear here as staff record them in the merchant app."
/>
}
>
{/*
List/Item rather than Table: these rows open a detail view, and
Astryx's Table has no per-row action or custom cell renderer. Both
are approved dense-data patterns — this is the one that can be
clicked, so it is the one that fits.
*/}
{(rows) => (
<List density="balanced">
{rows.map((sale) => (
<ListItem
key={sale.id}
onClick={() => setOpenSale(sale.id)}
label={sale.customerLabel ?? sale.customerRef ?? 'Not identified'}
description={[
sale.invoiceNo,
sale.staffName ? `Served by ${sale.staffName}` : null,
`${sale.purchasedLines} purchased`,
// Only mentioned when there were any: "0 enquiries" on
// every row is noise that hides the ones that had some.
sale.enquiryLines > 0 ? `${sale.enquiryLines} enquiries` : null,
]
.filter(Boolean)
.join(' · ')}
endContent={formatPaise(sale.totalPaise)}
/>
))}
</List>
)}
</PanelCard>
{openSale ? (
<SaleDetailDialog saleId={openSale} onClose={() => setOpenSale(null)} />
) : null}
<FeatureUnavailable
title="Orders, products and payments"
description="Sales are recorded as a total against a visit, so revenue and basket size are real. What is not recorded yet is the detail behind them — the individual orders, the product catalogue and stock, how customers paid, and refunds. Those sections stay empty rather than being filled with sample data."
title="Products, payments and refunds"
description="Individual sales are listed above. What is still not recorded anywhere is the product catalogue and stock, how customers paid, and refunds — so those sections stay empty rather than being filled with sample data."
/>
</VStack>
);

View File

@@ -0,0 +1,197 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Card} from '@astryxdesign/core/Card';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {NameCustomerDialog} from '@/features/floor/components/NameCustomerDialog';
import {SaleEntryDialog} from '@/features/commerce/components/SaleEntryDialog';
import {useFloor} from '@/features/floor/hooks/useFloor';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import type {FloorVisit} from '@/features/floor/types/floor';
/**
* The shop floor — who is here, and who is serving them. LOYALY.md §6/§7/§20.
*
* Every row is a visit the CAMERA created. This screen never invents an
* arrival, and it never decides ownership: a Take that loses a race comes back
* 409 from the platform and the list is re-read, because who holds a customer
* is a fact only the server has.
*/
function whenSeen(iso: string): string {
const mins = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000));
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} min ago`;
return `${Math.floor(mins / 60)} h ago`;
}
export default function FloorPage() {
const {resource, act, pending, conflict} = useFloor();
const scopeLabel = useScopeLabel();
const [naming, setNaming] = useState<FloorVisit | null>(null);
const [selling, setSelling] = useState<FloorVisit | null>(null);
return (
<VStack gap={5}>
<PageHeader
eyebrow="Live"
title="Floor"
description={`Customers in ${scopeLabel} right now.`}
controls={<ScopeControls />}
/>
{/* The platform's own refusal, shown verbatim — it names who holds the
customer, which is the part staff need. */}
{conflict ? <Banner status="warning" title={conflict.message} /> : null}
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={3} height={190} />}
empty={
<EmptyPanel
icon="visitors"
title="Nobody on the floor"
description="Customers appear here the moment a camera sees them."
/>
}
>
{(rows) => (
<Grid columns={{minWidth: 300, repeat: 'fit'}} gap={4}>
{rows.map((v) => {
const unknown = v.visitorId === null;
const heldByOther = v.attendedBy !== null && !v.attendedByMe;
return (
<Card key={v.visitId}>
<VStack gap={3}>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>
{v.label ?? 'Unrecognised customer'}
</Heading>
{v.customerRef ? (
<Text size="xsm" color="secondary" className="font-mono">
{v.customerRef}
</Text>
) : null}
</HStack>
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={v.status === 'attending' ? 'warning' : 'success'}
label={v.status}
/>
<Text size="xsm" color="secondary">
{v.status === 'attending' && v.attendedByName
? `With ${v.attendedByName}`
: 'Waiting'}
{' · '}
{whenSeen(v.detectedAt)}
</Text>
</HStack>
{/* Real profile data only. An unrecognised arrival says so
and offers the form; it never shows a placeholder name. */}
{unknown ? (
<Text size="sm" color="secondary">
The cameras have not seen this person before.
</Text>
) : (
<Text size="sm" color="secondary">
{v.previousVisits === 0
? 'First visit'
: `${v.previousVisits} previous ${
v.previousVisits === 1 ? 'visit' : 'visits'
}`}
{v.phone ? ` · ${v.phone}` : ''}
</Text>
)}
<HStack gap={2}>
{v.attendedByMe ? (
<>
<Button
variant="secondary"
isDisabled={pending === v.visitId}
onClick={() => void act(v.visitId, 'release')}
label="Release"
/>
<Button
isDisabled={pending === v.visitId}
onClick={() => void act(v.visitId, 'complete')}
label="Complete"
/>
</>
) : (
<Button
// Not hidden when somebody else holds them: pressing
// it returns the platform's own refusal naming who,
// which is more useful than a control that vanishes.
variant={heldByOther ? 'secondary' : 'primary'}
isDisabled={pending === v.visitId}
onClick={() => void act(v.visitId, 'attend')}
label={heldByOther ? 'Taken' : 'Take'}
/>
)}
{/* Sale entry is offered only to whoever holds the
customer — recording a sale against somebody else's
customer would attribute it to the wrong person. */}
{v.attendedByMe ? (
<Button
variant="ghost"
onClick={() => setSelling(v)}
label="Record sale"
/>
) : null}
{unknown ? (
<Button
variant="ghost"
onClick={() => setNaming(v)}
label="Add customer"
/>
) : null}
</HStack>
</VStack>
</Card>
);
})}
</Grid>
)}
</AsyncBoundary>
{/* The visit is NOT completed automatically after a sale. LOYALY.md §9
says a visit is completed when the merchant finishes the interaction,
which is not the same moment as recording a sale — a customer often
buys and then keeps browsing. Completing here would clear them off
the floor while they are still standing in the shop. */}
{selling ? (
<SaleEntryDialog
visit={selling}
onClose={() => setSelling(null)}
onSaved={() => {
setSelling(null);
resource.refetch();
}}
/>
) : null}
{naming ? (
<NameCustomerDialog
visit={naming}
onClose={() => setNaming(null)}
onSaved={() => {
setNaming(null);
resource.refetch();
}}
/>
) : null}
</VStack>
);
}