From dccb1beda546e805ebab38b76ea285dc8e41fec6 Mon Sep 17 00:00:00 2001 From: Aravind Date: Fri, 18 Sep 2026 11:12:04 +0530 Subject: [PATCH] feat(floor): the shop floor, and a sale recorded against a visit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK --- src/app/(workspace)/commerce/page.tsx | 62 +++- src/app/(workspace)/floor/page.tsx | 197 +++++++++++ src/app/api/customers/route.ts | 46 +++ src/app/api/floor/visits/route.ts | 47 +++ src/app/api/sales/[id]/route.ts | 33 ++ src/app/api/sales/route.ts | 136 ++++++++ src/app/api/visits/[id]/attend/route.ts | 37 ++ src/app/api/visits/[id]/complete/route.ts | 37 ++ src/app/api/visits/[id]/release/route.ts | 37 ++ .../commerce/components/SaleDetailDialog.tsx | 127 +++++++ .../commerce/components/SaleEntryDialog.tsx | 329 ++++++++++++++++++ src/features/commerce/hooks/useSales.ts | 11 + .../commerce/repositories/saleRepository.ts | 13 + src/features/commerce/services/money.ts | 57 +++ src/features/commerce/types/sale.ts | 35 ++ .../floor/components/NameCustomerDialog.tsx | 107 ++++++ src/features/floor/hooks/useFloor.ts | 53 +++ .../floor/repositories/floorRepository.ts | 9 + src/features/floor/types/floor.ts | 24 ++ src/services/api/floorApi.ts | 47 +++ src/services/api/salesApi.ts | 38 ++ src/shared/layouts/workspace/nav-config.ts | 1 + 22 files changed, 1481 insertions(+), 2 deletions(-) create mode 100644 src/app/(workspace)/floor/page.tsx create mode 100644 src/app/api/customers/route.ts create mode 100644 src/app/api/floor/visits/route.ts create mode 100644 src/app/api/sales/[id]/route.ts create mode 100644 src/app/api/sales/route.ts create mode 100644 src/app/api/visits/[id]/attend/route.ts create mode 100644 src/app/api/visits/[id]/complete/route.ts create mode 100644 src/app/api/visits/[id]/release/route.ts create mode 100644 src/features/commerce/components/SaleDetailDialog.tsx create mode 100644 src/features/commerce/components/SaleEntryDialog.tsx create mode 100644 src/features/commerce/hooks/useSales.ts create mode 100644 src/features/commerce/repositories/saleRepository.ts create mode 100644 src/features/commerce/services/money.ts create mode 100644 src/features/commerce/types/sale.ts create mode 100644 src/features/floor/components/NameCustomerDialog.tsx create mode 100644 src/features/floor/hooks/useFloor.ts create mode 100644 src/features/floor/repositories/floorRepository.ts create mode 100644 src/features/floor/types/floor.ts create mode 100644 src/services/api/floorApi.ts create mode 100644 src/services/api/salesApi.ts diff --git a/src/app/(workspace)/commerce/page.tsx b/src/app/(workspace)/commerce/page.tsx index c975ca6..90bb7ce 100644 --- a/src/app/(workspace)/commerce/page.tsx +++ b/src/app/(workspace)/commerce/page.tsx @@ -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(null); const conversion = useConversionReport({bucket: 'day'}); + const sales = useSales(); const scopeLabel = useScopeLabel(); return ( @@ -55,9 +66,56 @@ export default function CommercePage() { )} + } + empty={ + + } + > + {/* + 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) => ( + + {rows.map((sale) => ( + 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)} + /> + ))} + + )} + + + {openSale ? ( + setOpenSale(null)} /> + ) : null} + ); diff --git a/src/app/(workspace)/floor/page.tsx b/src/app/(workspace)/floor/page.tsx new file mode 100644 index 0000000..d9d8a49 --- /dev/null +++ b/src/app/(workspace)/floor/page.tsx @@ -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(null); + const [selling, setSelling] = useState(null); + + return ( + + } + /> + + {/* The platform's own refusal, shown verbatim — it names who holds the + customer, which is the part staff need. */} + {conflict ? : null} + + } + empty={ + + } + > + {(rows) => ( + + {rows.map((v) => { + const unknown = v.visitorId === null; + const heldByOther = v.attendedBy !== null && !v.attendedByMe; + return ( + + + + + {v.label ?? 'Unrecognised customer'} + + {v.customerRef ? ( + + {v.customerRef} + + ) : null} + + + + + + {v.status === 'attending' && v.attendedByName + ? `With ${v.attendedByName}` + : 'Waiting'} + {' · '} + {whenSeen(v.detectedAt)} + + + + {/* Real profile data only. An unrecognised arrival says so + and offers the form; it never shows a placeholder name. */} + {unknown ? ( + + The cameras have not seen this person before. + + ) : ( + + {v.previousVisits === 0 + ? 'First visit' + : `${v.previousVisits} previous ${ + v.previousVisits === 1 ? 'visit' : 'visits' + }`} + {v.phone ? ` · ${v.phone}` : ''} + + )} + + + {v.attendedByMe ? ( + <> +