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:
@@ -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>
|
||||
);
|
||||
|
||||
197
src/app/(workspace)/floor/page.tsx
Normal file
197
src/app/(workspace)/floor/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user