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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View 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'}},
);
}
}

View 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),
);
}

View 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
View 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'}},
);
}
}

View 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'}},
);
}
}

View 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'}},
);
}
}

View 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'}},
);
}
}

View 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>
);
}

View 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>
);
}

View 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()));
}

View 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, {}),
};

View 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,
);
}

View 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[];
}

View 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>
);
}

View 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)};
}

View 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, {}),
};

View 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;
}

View 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,
}),
};

View 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}),
};

View File

@@ -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},