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>
|
||||
);
|
||||
}
|
||||
46
src/app/api/customers/route.ts
Normal file
46
src/app/api/customers/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/customers — name somebody the cameras could not identify.
|
||||
*
|
||||
* `visit_id` is what makes this the first-visit flow rather than a directory
|
||||
* entry: it links the new customer to the arrival that prompted the form, so
|
||||
* the face on the floor stops being anonymous.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: {name?: unknown; phone?: unknown; notes?: unknown; visitId?: unknown};
|
||||
try {
|
||||
body = (await req.json()) as typeof body;
|
||||
} catch {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Malformed request body.'}},
|
||||
{status: 400, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await withUpstream((token) =>
|
||||
floorApi.createCustomer(token, {
|
||||
name: typeof body.name === 'string' ? body.name : '',
|
||||
phone: typeof body.phone === 'string' ? body.phone : '',
|
||||
notes: typeof body.notes === 'string' ? body.notes : undefined,
|
||||
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
||||
}),
|
||||
);
|
||||
return Response.json(
|
||||
{data: {id: created.id, ref: created.ref, label: created.label}},
|
||||
{status: 201, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/app/api/floor/visits/route.ts
Normal file
47
src/app/api/floor/visits/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import type {ApiFloorVisit} from '@/services/api/types';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/floor/visits — who is in the shop now.
|
||||
*
|
||||
* Absent fields become NULL rather than empty strings, because the screen
|
||||
* branches on "is there a customer at all" and `''` would read as a customer
|
||||
* with a blank name.
|
||||
*/
|
||||
export function toFloorVisit(v: ApiFloorVisit): FloorVisit {
|
||||
return {
|
||||
visitId: v.visit_id,
|
||||
siteId: v.site_slug || v.site_id,
|
||||
detectedAt: v.detected_at,
|
||||
status: v.status,
|
||||
visitorId: v.visitor_id || null,
|
||||
customerRef: v.visitor_ref || null,
|
||||
label: v.label || null,
|
||||
phone: v.phone || null,
|
||||
previousVisits: v.previous_visits ?? 0,
|
||||
attendedBy: v.attended_by || null,
|
||||
attendedByName: v.attended_by_name || null,
|
||||
attendedByMe: v.attended_by_me ?? false,
|
||||
// Proxied so an <img> works without the Authorization header it cannot send.
|
||||
imageUrl:
|
||||
v.image?.available && v.image.url
|
||||
? v.image.url.startsWith('http')
|
||||
? v.image.url
|
||||
: `/api/faces?src=${encodeURIComponent(v.image.url)}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token, query) => floorApi.list(token, {site: toSiteParam(query.storeId)}),
|
||||
(page) => (page.items ?? []).map(toFloorVisit),
|
||||
);
|
||||
}
|
||||
33
src/app/api/sales/[id]/route.ts
Normal file
33
src/app/api/sales/[id]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {salesApi} from '@/services/api/salesApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import {toSale} from '@/app/api/sales/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/sales/{id} — one sale with its lines. §24.
|
||||
*
|
||||
* The list endpoint carries counts; this carries the lines themselves, so a
|
||||
* screen showing a whole day of sales does not pull every line of every one.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
try {
|
||||
const sale = await withUpstream((token) => salesApi.byId(token, id));
|
||||
return Response.json(
|
||||
{data: toSale(sale)},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/app/api/sales/route.ts
Normal file
136
src/app/api/sales/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {salesApi} from '@/services/api/salesApi';
|
||||
import {toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream, failureFrom} from '@/shared/services/bff';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import type {ApiSale} from '@/services/api/types';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/sales — the sale history the Sales screen reads.
|
||||
*
|
||||
* Money crosses this boundary as integer PAISE and is NOT converted. The
|
||||
* console formats paise for display and never holds rupees, so there is no
|
||||
* float round-trip and no component can disagree about the decimal point.
|
||||
*/
|
||||
export function toSale(s: ApiSale): Sale {
|
||||
return {
|
||||
id: s.id,
|
||||
invoiceNo: s.invoice_no ?? null,
|
||||
siteId: s.site_slug || s.site_id,
|
||||
customerRef: s.visitor_ref ?? null,
|
||||
customerLabel: s.customer_label || null,
|
||||
staffName: s.staff_name || null,
|
||||
totalPaise: s.total_paise ?? 0,
|
||||
currency: s.currency ?? 'INR',
|
||||
status: s.status,
|
||||
at: s.server_created_at,
|
||||
purchasedLines: s.purchased_lines ?? 0,
|
||||
enquiryLines: s.enquiry_lines ?? 0,
|
||||
lines: (s.lines ?? []).map((l) => ({
|
||||
productName: l.product_name,
|
||||
pricePaise: l.price_paise ?? 0,
|
||||
intent: l.intent,
|
||||
// Straight from the server. Re-deriving it here would put the
|
||||
// enquiry-is-not-revenue rule in a second place, which is how the two
|
||||
// start disagreeing.
|
||||
billablePaise: l.billable_paise ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token, query) =>
|
||||
salesApi.list(token, {
|
||||
site: toSiteParam(query.storeId),
|
||||
limit: 50,
|
||||
}),
|
||||
(page) => (page.items ?? []).map(toSale),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sales — record a sale.
|
||||
*
|
||||
* ── What this route does NOT do ──────────────────────────────────────────
|
||||
* It does not compute a total. §12 says the backend calculates it from the
|
||||
* lines and must not trust a client-supplied one, and the platform's own
|
||||
* request shape has no total field to send. It also does not carry a staff id:
|
||||
* the platform derives that from the session, so a request structurally cannot
|
||||
* attribute a sale to somebody else.
|
||||
*
|
||||
* Prices arrive as integer PAISE from the form and are forwarded unchanged.
|
||||
* This is the only place the console converts money at all, and it converts in
|
||||
* one direction: paise out of the platform become rupees for display (toSale
|
||||
* above). Nothing multiplies by 100 on the way in, because the form never held
|
||||
* rupees to begin with.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: {
|
||||
idempotencyKey?: unknown;
|
||||
visitId?: unknown;
|
||||
visitorId?: unknown;
|
||||
invoiceNo?: unknown;
|
||||
site?: unknown;
|
||||
lines?: unknown;
|
||||
};
|
||||
try {
|
||||
body = (await req.json()) as typeof body;
|
||||
} catch {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Malformed request body.'}},
|
||||
{status: 400, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
|
||||
const lines = Array.isArray(body.lines)
|
||||
? body.lines
|
||||
.map((l) => l as {productName?: unknown; pricePaise?: unknown; intent?: unknown})
|
||||
.filter((l) => typeof l.productName === 'string' && l.productName.trim() !== '')
|
||||
.map((l) => ({
|
||||
product_name: String(l.productName).trim(),
|
||||
// Already an integer. Rounded rather than trusted blindly so a
|
||||
// fractional paise from a hand-written request cannot reach a bigint
|
||||
// column and be rejected three layers down.
|
||||
price_paise: Math.max(0, Math.round(Number(l.pricePaise) || 0)),
|
||||
intent: l.intent === 'enquired' ? 'enquired' : 'purchased',
|
||||
}))
|
||||
: [];
|
||||
|
||||
try {
|
||||
const result = await withUpstream((token) =>
|
||||
salesApi.create(token, {
|
||||
idempotency_key:
|
||||
typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '',
|
||||
invoice_no: typeof body.invoiceNo === 'string' ? body.invoiceNo : '',
|
||||
site: typeof body.site === 'string' ? body.site : undefined,
|
||||
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
||||
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
|
||||
client_created_at: new Date().toISOString(),
|
||||
lines,
|
||||
}),
|
||||
);
|
||||
return Response.json(
|
||||
{
|
||||
data: {
|
||||
// "already_processed" is a SUCCESS carrying the original sale — a
|
||||
// replay after a double tap or a retry has done nothing wrong.
|
||||
status: result.status,
|
||||
saleId: result.sale_id,
|
||||
sale: result.sale ? toSale(result.sale) : null,
|
||||
},
|
||||
},
|
||||
{status: 201, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/visits/[id]/attend/route.ts
Normal file
37
src/app/api/visits/[id]/attend/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import {toFloorVisit} from '@/app/api/floor/visits/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/visits/{id}/attend
|
||||
*
|
||||
* The failure this route exists to pass through faithfully is 409: another
|
||||
* member of staff holds this customer. The screen must show that rather than
|
||||
* a generic error, and it must NOT be simulated client-side — only the
|
||||
* platform knows who actually won.
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
try {
|
||||
const visit = await withUpstream((token) => floorApi.attend(token, id));
|
||||
return Response.json(
|
||||
{data: toFloorVisit(visit)},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
|
||||
// so the screen branches on that rather than on prose.
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/visits/[id]/complete/route.ts
Normal file
37
src/app/api/visits/[id]/complete/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import {toFloorVisit} from '@/app/api/floor/visits/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/visits/{id}/complete
|
||||
*
|
||||
* The failure this route exists to pass through faithfully is 409: another
|
||||
* member of staff holds this customer. The screen must show that rather than
|
||||
* a generic error, and it must NOT be simulated client-side — only the
|
||||
* platform knows who actually won.
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
try {
|
||||
const visit = await withUpstream((token) => floorApi.complete(token, id));
|
||||
return Response.json(
|
||||
{data: toFloorVisit(visit)},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
|
||||
// so the screen branches on that rather than on prose.
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/visits/[id]/release/route.ts
Normal file
37
src/app/api/visits/[id]/release/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import {toFloorVisit} from '@/app/api/floor/visits/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/visits/{id}/release
|
||||
*
|
||||
* The failure this route exists to pass through faithfully is 409: another
|
||||
* member of staff holds this customer. The screen must show that rather than
|
||||
* a generic error, and it must NOT be simulated client-side — only the
|
||||
* platform knows who actually won.
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
try {
|
||||
const visit = await withUpstream((token) => floorApi.release(token, id));
|
||||
return Response.json(
|
||||
{data: toFloorVisit(visit)},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
|
||||
// so the screen branches on that rather than on prose.
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
127
src/features/commerce/components/SaleDetailDialog.tsx
Normal file
127
src/features/commerce/components/SaleDetailDialog.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {formatPaise} from '@/features/commerce/services/money';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
/**
|
||||
* One sale, with its lines. LOYALY.md §24.
|
||||
*
|
||||
* Renders only fields the API actually returns. Where the platform has nothing
|
||||
* — no invoice number, no named customer — the row says so rather than
|
||||
* inventing a placeholder, because a fabricated invoice number on a screen
|
||||
* somebody reconciles against is worse than a visible gap.
|
||||
*/
|
||||
export function SaleDetailDialog({
|
||||
saleId,
|
||||
onClose,
|
||||
}: {
|
||||
saleId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [sale, setSale] = useState<Sale | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sales/${encodeURIComponent(saleId)}`);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setError(body?.error?.message ?? 'Could not load this sale.');
|
||||
return;
|
||||
}
|
||||
setSale(body.data as Sale);
|
||||
} catch {
|
||||
if (!cancelled) setError('Could not reach the platform.');
|
||||
}
|
||||
})();
|
||||
// A dialog closed mid-request must not write into an unmounted component.
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [saleId]);
|
||||
|
||||
const purchased = sale?.lines.filter((l) => l.intent === 'purchased') ?? [];
|
||||
const enquiries = sale?.lines.filter((l) => l.intent === 'enquired') ?? [];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={520}
|
||||
aria-label="Sale detail"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={sale?.invoiceNo ?? 'Sale'}
|
||||
subtitle={sale ? new Date(sale.at).toLocaleString() : undefined}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
{!sale && !error ? <Text size="sm" color="secondary">Loading…</Text> : null}
|
||||
|
||||
{sale ? (
|
||||
<VStack gap={4}>
|
||||
<VStack gap={1}>
|
||||
<Text size="sm" color="secondary">
|
||||
Customer: {sale.customerLabel ?? sale.customerRef ?? 'Not identified'}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Served by: {sale.staffName ?? '—'}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Store: {sale.siteId}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{purchased.length > 0 ? (
|
||||
<VStack gap={2}>
|
||||
<Heading level={4}>Purchased</Heading>
|
||||
{purchased.map((l, i) => (
|
||||
<HStack key={`${l.productName}-${i}`} gap={2} hAlign="between">
|
||||
<Text size="sm">{l.productName}</Text>
|
||||
<Text size="sm">{formatPaise(l.billablePaise)}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{enquiries.length > 0 ? (
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Heading level={4}>Enquiries</Heading>
|
||||
<Badge label="not billed" />
|
||||
</HStack>
|
||||
{enquiries.map((l, i) => (
|
||||
<HStack key={`${l.productName}-${i}`} gap={2} hAlign="between">
|
||||
<Text size="sm" color="secondary">{l.productName}</Text>
|
||||
<Text size="sm" color="disabled">
|
||||
{l.pricePaise > 0
|
||||
? formatPaise(l.pricePaise)
|
||||
: 'no price'}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="sm" color="secondary">Total</Text>
|
||||
<Heading level={3}>{formatPaise(sale.totalPaise)}</Heading>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
329
src/features/commerce/components/SaleEntryDialog.tsx
Normal file
329
src/features/commerce/components/SaleEntryDialog.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useRef, useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {
|
||||
billablePaise,
|
||||
formatPaise,
|
||||
parseRupeesToPaise,
|
||||
} from '@/features/commerce/services/money';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
/**
|
||||
* Sale entry for one customer on the floor. LOYALY.md §10–§13, §17.
|
||||
*
|
||||
* ── The distinction this screen exists to make ───────────────────────────
|
||||
* Every line is PURCHASED or ENQUIRED, and an enquiry never reaches the bill.
|
||||
* §11 calls that the important rule, so the two are shown in separate blocks
|
||||
* rather than hidden behind a dropdown: a merchant must see at a glance what
|
||||
* they are charging for.
|
||||
*
|
||||
* ── There is no product catalogue, and that is correct ───────────────────
|
||||
* §11 records `product_name` and `price`; §33-E leaves `product_id` optional
|
||||
* and unresolved. Free text is the specified behaviour, not a placeholder for
|
||||
* a picker — so this form neither invents a catalogue nor claims one is
|
||||
* coming.
|
||||
*
|
||||
* Quantity is deliberately absent. The spec's line shape is name, price,
|
||||
* intent; a quantity field would be a business rule nobody wrote and a value
|
||||
* the backend cannot store.
|
||||
*/
|
||||
|
||||
interface DraftLine {
|
||||
key: string;
|
||||
productName: string;
|
||||
/** Integer paise, parsed once on entry. Never a float. */
|
||||
pricePaise: number;
|
||||
intent: 'purchased' | 'enquired';
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-sale key. `crypto.randomUUID` everywhere modern; the timestamp branch
|
||||
* is only for a non-secure context, where `crypto` may be absent entirely.
|
||||
*/
|
||||
function newIdempotencyKey(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `draft-${Date.now()}`;
|
||||
}
|
||||
|
||||
export function SaleEntryDialog({
|
||||
visit,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
visit: FloorVisit;
|
||||
onClose: () => void;
|
||||
onSaved: (saleId: string) => void;
|
||||
}) {
|
||||
/**
|
||||
* §13: minted when the DRAFT OPENS, not when Confirm is pressed.
|
||||
*
|
||||
* That is the whole mechanism. A double tap, a timeout retry and an app
|
||||
* restart all carry this same key, so the platform collapses them into one
|
||||
* sale. A key generated at submit time would be unique per attempt and every
|
||||
* retry would create another sale — which is the failure the key exists to
|
||||
* prevent.
|
||||
*
|
||||
* A ref rather than state: it must survive every re-render unchanged.
|
||||
*/
|
||||
const idempotencyKey = useRef<string | null>(null);
|
||||
|
||||
const [lines, setLines] = useState<DraftLine[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [invoiceNo, setInvoiceNo] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [done, setDone] = useState<{saleId: string; total: string} | null>(null);
|
||||
|
||||
const total = useMemo(() => billablePaise(lines), [lines]);
|
||||
const purchased = lines.filter((l) => l.intent === 'purchased');
|
||||
const enquiries = lines.filter((l) => l.intent === 'enquired');
|
||||
|
||||
function addLine(intent: 'purchased' | 'enquired') {
|
||||
setError(null);
|
||||
const productName = name.trim();
|
||||
if (productName === '') {
|
||||
setError('Give the item a name.');
|
||||
return;
|
||||
}
|
||||
const paise = price.trim() === '' ? 0 : parseRupeesToPaise(price);
|
||||
if (paise === null) {
|
||||
setError('That price is not a valid amount.');
|
||||
return;
|
||||
}
|
||||
// §17, mirrored here for UX only. The server enforces it too, and its
|
||||
// answer is the one that decides — this just saves a round trip.
|
||||
if (intent === 'purchased' && paise <= 0) {
|
||||
setError('A purchased item needs a price.');
|
||||
return;
|
||||
}
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
// Index-free and content-based, so removing a line cannot make two
|
||||
// remaining rows collide on a key.
|
||||
key: `${Date.now()}-${prev.length}-${productName}`,
|
||||
productName,
|
||||
pricePaise: paise,
|
||||
intent,
|
||||
},
|
||||
]);
|
||||
setName('');
|
||||
setPrice('');
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key));
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
/**
|
||||
* Minted here, on the first attempt, and kept in the ref for every one
|
||||
* after it.
|
||||
*
|
||||
* It used to be generated in `useRef(...)`, whose argument React
|
||||
* evaluates on EVERY render — so `crypto.randomUUID()` and `Date.now()`
|
||||
* ran on each keystroke in this dialog, and the lint rule that caught it
|
||||
* is right: `Date.now()` during render is impure and its result is
|
||||
* discarded anyway. An event handler is the correct place for both.
|
||||
*
|
||||
* The retry guarantee is unchanged, which is the part that matters: the
|
||||
* ref is only filled once, so a double tap, a timeout retry and a
|
||||
* resubmit all send the SAME key and the platform collapses them into
|
||||
* one sale. A fresh dialog is a fresh component, so the next sale gets a
|
||||
* fresh key.
|
||||
*/
|
||||
idempotencyKey.current ??= newIdempotencyKey();
|
||||
|
||||
const res = await fetch('/api/sales', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: idempotencyKey.current,
|
||||
// Taken from the floor context, never typed. §4: do not ask for a
|
||||
// visit id the flow already knows. Staff identity is not sent at
|
||||
// all — the platform derives it from the session.
|
||||
visitId: visit.visitId,
|
||||
visitorId: visit.visitorId ?? undefined,
|
||||
invoiceNo: invoiceNo.trim(),
|
||||
site: visit.siteId,
|
||||
lines: lines.map((l) => ({
|
||||
productName: l.productName,
|
||||
pricePaise: l.pricePaise,
|
||||
intent: l.intent,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(body?.error?.message ?? 'Could not record this sale.');
|
||||
return;
|
||||
}
|
||||
// The figure shown now is the SERVER's, computed by the database from
|
||||
// the purchased lines. The running total above is only what the merchant
|
||||
// watched while typing.
|
||||
const sale = body?.data?.sale;
|
||||
setDone({
|
||||
saleId: body?.data?.saleId ?? '',
|
||||
total:
|
||||
typeof sale?.totalPaise === 'number'
|
||||
? formatPaise(sale.totalPaise)
|
||||
: formatPaise(total),
|
||||
});
|
||||
} catch {
|
||||
setError('Could not reach the platform. The sale was not recorded.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const customerName = visit.label ?? 'Unrecognised customer';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={560}
|
||||
aria-label="Record a sale"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Record a sale"
|
||||
subtitle={`${customerName}${visit.customerRef ? ` · ${visit.customerRef}` : ''}`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{done ? (
|
||||
<VStack gap={4}>
|
||||
<Banner status="info" title={`Sale recorded — ${done.total}`} />
|
||||
<Text size="sm" color="secondary">
|
||||
The customer is still on the floor. Complete their visit when you
|
||||
have finished with them.
|
||||
</Text>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button onClick={() => onSaved(done.saleId)} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<>
|
||||
<HStack gap={2} vAlign="end">
|
||||
<TextInput
|
||||
label="Item"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="What did they look at?"
|
||||
/>
|
||||
<TextInput
|
||||
label="Price"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</HStack>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => addLine('enquired')}
|
||||
label="Add enquiry"
|
||||
/>
|
||||
<Button onClick={() => addLine('purchased')} label="Add purchase" />
|
||||
</HStack>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
{purchased.length > 0 ? (
|
||||
<Card>
|
||||
<VStack gap={2}>
|
||||
<Heading level={4}>Purchased</Heading>
|
||||
{purchased.map((l) => (
|
||||
<HStack key={l.key} gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="sm">{l.productName}</Text>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="sm">{formatPaise(l.pricePaise)}</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => removeLine(l.key)}
|
||||
label="Remove"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{enquiries.length > 0 ? (
|
||||
<Card>
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Heading level={4}>Enquiries</Heading>
|
||||
<Badge label="not billed" />
|
||||
</HStack>
|
||||
{enquiries.map((l) => (
|
||||
<HStack key={l.key} gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="sm" color="secondary">
|
||||
{l.productName}
|
||||
</Text>
|
||||
<HStack gap={2} vAlign="center">
|
||||
{/* A quoted price is kept for the record and shown in
|
||||
a muted tone: §11 allows an enquiry to carry one and
|
||||
requires that it never increase the bill. */}
|
||||
<Text size="sm" color="disabled">
|
||||
{l.pricePaise > 0 ? formatPaise(l.pricePaise) : 'no price'}
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => removeLine(l.key)}
|
||||
label="Remove"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Invoice number (optional)"
|
||||
value={invoiceNo}
|
||||
onChange={setInvoiceNo}
|
||||
placeholder="INV-…"
|
||||
/>
|
||||
|
||||
<HStack gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="sm" color="secondary">
|
||||
{purchased.length} purchased · {enquiries.length} enquiries
|
||||
</Text>
|
||||
<Heading level={3}>{formatPaise(total)}</Heading>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
// Disabled in flight so a double tap cannot fire twice. The
|
||||
// idempotency key is the real defence; this is the part the
|
||||
// user can see.
|
||||
isDisabled={saving || lines.length === 0}
|
||||
onClick={() => void confirm()}
|
||||
label={saving ? 'Recording…' : 'Confirm sale'}
|
||||
/>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
11
src/features/commerce/hooks/useSales.ts
Normal file
11
src/features/commerce/hooks/useSales.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {saleRepository} from '@/features/commerce/repositories/saleRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
export function useSales(): Resource<Sale[]> {
|
||||
return useResource(saleRepository.list(useScope()));
|
||||
}
|
||||
13
src/features/commerce/repositories/saleRepository.ts
Normal file
13
src/features/commerce/repositories/saleRepository.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
/**
|
||||
* Sales, addressed by the platform's own resource name.
|
||||
*
|
||||
* Scoped like every other read, so the store switcher and the range picker
|
||||
* change what this returns without the Sales screen knowing how.
|
||||
*/
|
||||
export const saleRepository = {
|
||||
list: (scope: Scope): Endpoint<Sale[]> => scopedEndpoint('/api/sales', scope, {}),
|
||||
};
|
||||
57
src/features/commerce/services/money.ts
Normal file
57
src/features/commerce/services/money.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Rupees ↔ paise, in one place.
|
||||
*
|
||||
* ── Why the form holds PAISE, not rupees ─────────────────────────────────
|
||||
* LOYALY.md §10: money is integer minor units and never a float. If the sale
|
||||
* form kept rupees it would add 18.1 + 240.05 in binary floating point and the
|
||||
* running total a merchant reads would drift from the one the server computes.
|
||||
* So a price is parsed to an integer ONCE, on entry, and every sum after that
|
||||
* is integer arithmetic.
|
||||
*
|
||||
* The backend stays authoritative regardless — `sales.total_paise` is written
|
||||
* by a database trigger from the purchased lines, and nothing the client sends
|
||||
* can set it. What this file protects is the number shown to the person typing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* "18", "18.5", "₹1,250.00" → paise. Returns null for anything that is not a
|
||||
* non-negative amount, so the caller can refuse rather than submit a NaN.
|
||||
*
|
||||
* Parsed by SPLITTING ON THE DECIMAL POINT rather than `Math.round(x * 100)`:
|
||||
* 19.99 * 100 is 1998.9999999999998 in IEEE 754, and rounding hides that only
|
||||
* until it does not.
|
||||
*/
|
||||
export function parseRupeesToPaise(input: string): number | null {
|
||||
const clean = input.replace(/[₹,\s]/g, '').trim();
|
||||
if (clean === '') return null;
|
||||
if (!/^\d+(\.\d{0,2})?$/.test(clean)) return null;
|
||||
|
||||
const [whole, frac = ''] = clean.split('.');
|
||||
const paise = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
|
||||
return Number.isSafeInteger(paise) ? paise : null;
|
||||
}
|
||||
|
||||
/** Paise → "₹1,250.00" for display. Integer division, never a float sum. */
|
||||
export function formatPaise(paise: number): string {
|
||||
const sign = paise < 0 ? '-' : '';
|
||||
const abs = Math.abs(Math.trunc(paise));
|
||||
const rupees = Math.trunc(abs / 100);
|
||||
const rest = abs % 100;
|
||||
return `${sign}₹${rupees.toLocaleString('en-IN')}.${String(rest).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bill: purchased lines only.
|
||||
*
|
||||
* This mirrors the database trigger deliberately and is NOT the source of
|
||||
* truth — the value shown after submission comes back from the server. It
|
||||
* exists so the running total a merchant watches while typing matches the one
|
||||
* they will be charged, and the rule is stated once here rather than in each
|
||||
* component that renders a subtotal.
|
||||
*/
|
||||
export function billablePaise(lines: {pricePaise: number; intent: string}[]): number {
|
||||
return lines.reduce(
|
||||
(sum, l) => (l.intent === 'purchased' ? sum + l.pricePaise : sum),
|
||||
0,
|
||||
);
|
||||
}
|
||||
35
src/features/commerce/types/sale.ts
Normal file
35
src/features/commerce/types/sale.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* A sale, as the console consumes it.
|
||||
*
|
||||
* Money stays INTEGER PAISE all the way to the pixel. The first version
|
||||
* converted to rupees in the BFF and every component converted back with
|
||||
* `Math.round(x * 100)` to format it — a float round-trip on every render, in
|
||||
* several places, which is precisely what LOYALY.md §10 forbids.
|
||||
*
|
||||
* Now nothing converts. `formatPaise` turns an integer into "₹1,899.50" for
|
||||
* display and that is the only place money changes shape.
|
||||
*/
|
||||
export interface SaleLine {
|
||||
productName: string;
|
||||
pricePaise: number;
|
||||
intent: 'purchased' | 'enquired';
|
||||
/** What this line put on the bill. Zero for every enquiry, always. */
|
||||
billablePaise: number;
|
||||
}
|
||||
|
||||
export interface Sale {
|
||||
id: string;
|
||||
invoiceNo: string | null;
|
||||
siteId: string;
|
||||
customerRef: string | null;
|
||||
customerLabel: string | null;
|
||||
staffName: string | null;
|
||||
totalPaise: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
at: string;
|
||||
/** Counts for the list. `lines` is populated only by the detail view. */
|
||||
purchasedLines: number;
|
||||
enquiryLines: number;
|
||||
lines: SaleLine[];
|
||||
}
|
||||
107
src/features/floor/components/NameCustomerDialog.tsx
Normal file
107
src/features/floor/components/NameCustomerDialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
/**
|
||||
* Naming somebody the cameras could not identify. LOYALY.md §18.
|
||||
*
|
||||
* The visit id travels with the request: that is what makes this the
|
||||
* first-visit flow rather than a directory entry, and it is what stops the
|
||||
* face on the floor staying anonymous.
|
||||
*
|
||||
* The platform requires a name OR a phone — a record with neither is not a
|
||||
* customer, and without that rule Save mints a blank "Visitor N" every time
|
||||
* somebody taps it.
|
||||
*/
|
||||
export function NameCustomerDialog({
|
||||
visit,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
visit: FloorVisit;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const canSave = name.trim() !== '' || phone.trim() !== '';
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/customers', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name, phone, visitId: visit.visitId}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
// The platform's own wording — including the 409 that says this
|
||||
// arrival was identified while the form was open.
|
||||
setError(body?.error?.message ?? 'Could not save this customer.');
|
||||
return;
|
||||
}
|
||||
onSaved();
|
||||
} catch {
|
||||
setError('Could not reach the platform. Try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={420}
|
||||
aria-label="Add customer"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Add customer"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
This person was seen just now and does not match anyone on record.
|
||||
Their details will be linked to this arrival.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="Full name"
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone"
|
||||
value={phone}
|
||||
onChange={setPhone}
|
||||
placeholder="+91…"
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
isDisabled={!canSave || saving}
|
||||
onClick={() => void save()}
|
||||
label={saving ? 'Saving…' : 'Save customer'}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
53
src/features/floor/hooks/useFloor.ts
Normal file
53
src/features/floor/hooks/useFloor.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback, useState} from 'react';
|
||||
import {floorRepository} from '@/features/floor/repositories/floorRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
|
||||
export type FloorAction = 'attend' | 'release' | 'complete';
|
||||
|
||||
/**
|
||||
* The floor, plus the three lifecycle calls.
|
||||
*
|
||||
* ── Why a conflict re-reads instead of patching local state ──────────────
|
||||
* Only the platform knows who actually won a race for a customer. When a Take
|
||||
* comes back 409 the screen must show the CURRENT truth — which staff member
|
||||
* holds them — and that is a fact this browser does not have. So every action,
|
||||
* success or conflict, is followed by a refetch. Nothing about ownership is
|
||||
* simulated here.
|
||||
*/
|
||||
export function useFloor() {
|
||||
const resource = useResource(floorRepository.list(useScope()));
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<{visitId: string; message: string} | null>(null);
|
||||
|
||||
const act = useCallback(
|
||||
async (visitId: string, action: FloorAction) => {
|
||||
setPending(visitId);
|
||||
setConflict(null);
|
||||
try {
|
||||
const res = await fetch(`/api/visits/${encodeURIComponent(visitId)}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
});
|
||||
if (res.status === 409) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setConflict({
|
||||
visitId,
|
||||
// The platform's own wording names who holds the customer.
|
||||
message: body?.error?.message ?? 'Somebody else is already serving this customer.',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
// Refetch on every path, including the conflict: the row the browser
|
||||
// is holding is now known to be stale.
|
||||
resource.refetch();
|
||||
}
|
||||
},
|
||||
[resource],
|
||||
);
|
||||
|
||||
return {resource, act, pending, conflict, dismissConflict: () => setConflict(null)};
|
||||
}
|
||||
9
src/features/floor/repositories/floorRepository.ts
Normal file
9
src/features/floor/repositories/floorRepository.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
/** The floor, scoped by the workspace's selected store. */
|
||||
export const floorRepository = {
|
||||
list: (scope: Scope): Endpoint<FloorVisit[]> =>
|
||||
scopedEndpoint('/api/floor/visits', scope, {}),
|
||||
};
|
||||
24
src/features/floor/types/floor.ts
Normal file
24
src/features/floor/types/floor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The shop floor, as the console consumes it.
|
||||
*
|
||||
* `visitorId === null` means the cameras saw somebody they could not identify.
|
||||
* That is not missing data — it is the state the add-customer flow exists for,
|
||||
* and it must stay distinguishable from a known customer with no name yet.
|
||||
*/
|
||||
export interface FloorVisit {
|
||||
visitId: string;
|
||||
siteId: string;
|
||||
detectedAt: string;
|
||||
status: 'waiting' | 'attending' | 'completed' | 'cancelled';
|
||||
visitorId: string | null;
|
||||
customerRef: string | null;
|
||||
label: string | null;
|
||||
phone: string | null;
|
||||
/** Times this customer was seen BEFORE this visit. 0 for a first arrival. */
|
||||
previousVisits: number;
|
||||
attendedBy: string | null;
|
||||
attendedByName: string | null;
|
||||
/** Decides whether the button says Take or Continue. */
|
||||
attendedByMe: boolean;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
47
src/services/api/floorApi.ts
Normal file
47
src/services/api/floorApi.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'server-only';
|
||||
import {upstreamRequest} from './apiClient';
|
||||
import type {ApiCustomerCreated, ApiFloorVisit} from './types';
|
||||
|
||||
/**
|
||||
* The shop floor and the customer form that goes with it.
|
||||
*
|
||||
* The lifecycle calls carry no staff id: the platform derives it from the
|
||||
* session, so a request structurally cannot claim a customer on somebody
|
||||
* else's behalf.
|
||||
*/
|
||||
export const floorApi = {
|
||||
list: (accessToken: string, query: Record<string, string | number | undefined>) =>
|
||||
upstreamRequest<{items: ApiFloorVisit[]}>({path: '/api/floor/visits', query, accessToken}),
|
||||
|
||||
attend: (accessToken: string, visitID: string) =>
|
||||
upstreamRequest<ApiFloorVisit>({
|
||||
path: `/api/visits/${encodeURIComponent(visitID)}/attend`,
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
release: (accessToken: string, visitID: string) =>
|
||||
upstreamRequest<ApiFloorVisit>({
|
||||
path: `/api/visits/${encodeURIComponent(visitID)}/release`,
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
complete: (accessToken: string, visitID: string) =>
|
||||
upstreamRequest<ApiFloorVisit>({
|
||||
path: `/api/visits/${encodeURIComponent(visitID)}/complete`,
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
createCustomer: (
|
||||
accessToken: string,
|
||||
body: {name: string; phone: string; notes?: string; visit_id?: string},
|
||||
) =>
|
||||
upstreamRequest<ApiCustomerCreated>({
|
||||
path: '/api/customers',
|
||||
method: 'POST',
|
||||
body,
|
||||
accessToken,
|
||||
}),
|
||||
};
|
||||
38
src/services/api/salesApi.ts
Normal file
38
src/services/api/salesApi.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'server-only';
|
||||
import {upstreamRequest} from './apiClient';
|
||||
import type {ApiSale, ApiSaleResult} from './types';
|
||||
|
||||
/**
|
||||
* Sales, from the merchant application's own domain.
|
||||
*
|
||||
* Distinct from `purchasesApi`, which writes the older `/api/purchases` record
|
||||
* the conversion report reads. Both exist on purpose: that one is still the
|
||||
* only writer the current mobile flow has, and migrating it would break a
|
||||
* working path while this domain has no writers yet.
|
||||
*/
|
||||
export const salesApi = {
|
||||
list: (accessToken: string, query: Record<string, string | number | undefined>) =>
|
||||
upstreamRequest<{items: ApiSale[]}>({path: '/api/sales', query, accessToken}),
|
||||
|
||||
create: (
|
||||
accessToken: string,
|
||||
body: {
|
||||
idempotency_key: string;
|
||||
invoice_no: string;
|
||||
site?: string;
|
||||
visit_id?: string;
|
||||
visitor_id?: string;
|
||||
client_created_at?: string;
|
||||
lines: {product_name: string; price_paise: number; intent: string}[];
|
||||
},
|
||||
) =>
|
||||
upstreamRequest<ApiSaleResult>({
|
||||
path: '/api/sales',
|
||||
method: 'POST',
|
||||
body,
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
byId: (accessToken: string, id: string) =>
|
||||
upstreamRequest<ApiSale>({path: `/api/sales/${encodeURIComponent(id)}`, accessToken}),
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export interface NavEntry {
|
||||
*/
|
||||
export const PRIMARY_NAV: NavEntry[] = [
|
||||
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
|
||||
{label: 'Floor', href: '/floor', icon: ICONS.visitors},
|
||||
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
|
||||
{label: 'Store', href: '/stores', icon: ICONS.stores},
|
||||
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
|
||||
|
||||
Reference in New Issue
Block a user