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 ? ( + <> +