e2e changes

This commit is contained in:
2026-09-16 17:12:30 +05:30
parent 5cb9872037
commit f25b3fcf65
15 changed files with 1057 additions and 583 deletions

View File

@@ -92,8 +92,11 @@ export function ConsolePage() {
onlineOrders: order.orders,
cancelled: order.cancelled,
delivered: order.delivered,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
// Both routes to a counter sale — a till that synced, and a till
// that was offline whose day was imported as OFFLINE-tagged orders.
// See the note in the Store Admin console, which had the same gap.
counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue,
counterBills: (pos?.billcount ?? 0) + order.counterOrders,
health: summariseBranch(posHealth[index]?.data ?? [], now),
pendingRequests: (requests.data ?? []).filter(
(entry) => entry.locationid === branch.locationid,

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import type { DateRange } from '@/api/insights';
import { useOrders } from '@/queries/hooks';
import type { PosSalesSummary } from '@/api/types';
import { isCounterSale } from '@/features/store-admin/branchStats';
/**
* The day-by-day series behind the chart and the donut.
@@ -63,6 +64,20 @@ export function useConsoleSeries(
const amount = amountOf(order);
const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled';
// A counter bill imported from a till sheet is in `orders` like any
// other row, tagged OFFLINE by the backend, and counting it here put
// the shop's counter takings on the app line of this chart and in the
// "Online Sales" figure above it. The POS loop below adds the tills
// that synced; these are the same money arriving the other way.
if (isCounterSale(order)) {
if (day && !isCancelled) {
const row = at(day);
row.counter += amount;
row.counterBills += 1;
}
continue;
}
onlineOrders += 1;
if (isCancelled) cancelled += 1;
else onlineRevenue += amount;

View File

@@ -82,8 +82,13 @@ export function StoreDetailPage() {
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
status: branch.status ?? 'Unknown',
orders: stats.orders,
revenue: stats.revenue,
// Both channels, because this page answers "how is this merchant
// doing" rather than "through which door". `branchOrderStats` splits
// app orders from imported counter bills — a split the Store Admin
// console needs and this page does not — so they are added back here
// rather than left half-counted.
orders: stats.orders + stats.counterOrders,
revenue: stats.revenue + stats.counterRevenue,
};
});
}, [locations, summary.data]);

View File

@@ -1,489 +1,506 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Building2, Plus, Store, Users } from 'lucide-react';
import { DataState } from '@/components/DataState';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types';
/** A tenant, with its branches folded in. */
interface TenantRow extends Record<string, unknown> {
tenantid: number;
tenantname: string;
companyname: string;
city: string;
branches: number;
status: string;
primaryemail: string;
}
/**
* The Nearle Admin's home: every tenant on the platform, and how many branches
* sit under each.
*
* `getalltenants` returns one row per tenant-location pair, so the rows are
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
* branches reads as six tenants.
*/
type Tab = 'directory' | 'pending';
/** Rows per page. One more than this is fetched, to know whether there is a next. */
const PAGE_SIZE = 50;
export function StoresPage() {
const [tab, setTab] = useState<Tab>('directory');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
/**
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
* and has no total, so paging is "ask for one more than we show and see if it
* comes back". A platform list read whole is fine at twenty tenants and not
* at two thousand.
*/
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
/**
* The queue of merchants awaiting approval.
*
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
* `getalltenants` at all, so without this they are invisible. Nothing here
* can approve one — `approved` is writable only at creation — so this lists
* and says so.
*/
const pending = useTenantsByApproval('pending');
const rows = useMemo<TenantRow[]>(() => {
if (!data) return [];
const grouped = new Map<number, TenantRow>();
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
const existing = grouped.get(tenant.tenantid);
if (existing) {
existing.branches += 1;
continue;
}
grouped.set(tenant.tenantid, {
tenantid: tenant.tenantid,
tenantname: tenant.tenantname,
companyname: tenant.companyname ?? '',
city: tenant.city ?? '',
branches: 1,
status: tenant.status ?? 'Unknown',
primaryemail: tenant.primaryemail ?? '',
});
}
const all = [...grouped.values()];
const term = search.trim().toLowerCase();
if (!term) return all;
return all.filter(
(row) =>
row.tenantname.toLowerCase().includes(term) ||
row.companyname.toLowerCase().includes(term) ||
row.city.toLowerCase().includes(term),
);
}, [data, search]);
const totals = useMemo(() => {
const tenants = rows.length;
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
return { tenants, branches, active };
}, [rows]);
const columns: TableColumn<TenantRow>[] = [
{
key: 'tenantname',
header: 'Tenant',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.tenantname}
</Text>
<Text type="body" size="xsm" color="secondary">
{row.companyname || '—'}
</Text>
</VStack>
),
},
{
key: 'city',
header: 'City',
width: { type: 'proportional', value: 1.5 },
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
},
{
key: 'branches',
header: 'Branches',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
{row.branches}
</Text>
),
},
{
key: 'primaryemail',
header: 'Primary admin',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="xsm" color="secondary">
{row.primaryemail || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
key: 'actions',
header: '',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Link
to={`/nearle/stores/${row.tenantid}`}
style={{
color: 'var(--color-brand)',
fontWeight: 600,
fontSize: 13,
textDecoration: 'none',
}}
>
Open →
</Link>
),
},
];
return (
<VStack gap={3}>
{/* No `isLive` here. Both of this page's reads — `useTenants` and
`useTenantsByApproval` — use the `stable` query options: a 5-minute
staleTime and no refetchInterval. The pill claimed a freshness the page
does not have. The pages that keep it (Console, Sales, Counters, store
detail) poll on a real interval. */}
<PageHeader
title="Stores"
actions={
<Button
label="Onboard tenant"
variant="primary"
icon={<Plus size={15} />}
href="/nearle/onboard/tenant"
as={Link}
/>
}
/>
<div className="kpi-grid">
<KpiCard
label="Tenants"
value={String(totals.tenants)}
note={`${totals.active} active`}
tone="accent"
icon={<Building2 size={15} />}
fill={totals.tenants ? totals.active / totals.tenants : 0}
/>
<KpiCard
label="Branches"
value={String(totals.branches)}
note="across the network"
tone="neutral"
icon={<Store size={15} />}
/>
<KpiCard
label="Avg branches"
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
note="per tenant"
tone="neutral"
icon={<Users size={15} />}
/>
</div>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Directory"
isActive={tab === 'directory'}
onClick={() => setTab('directory')}
/>
<TabButton
label="Awaiting approval"
isActive={tab === 'pending'}
badge={pending.data?.length || undefined}
onClick={() => setTab('pending')}
/>
</HStack>
{tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : (
<VStack gap={1.5}>
<SectionHeader
title="Tenant directory"
note={`${rows.length} shown`}
action={
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
<TextInput
label="Search tenants"
isLabelHidden
value={search}
onChange={setSearch}
placeholder="Search by name, company or city…"
hasClear
size="sm"
/>
</div>
}
/>
<Card padding={0} elevation="low">
<DataState
isLoading={isLoading}
error={error}
isEmpty={rows.length === 0}
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
emptyDescription={
search
? 'Try a different name, company or city.'
: 'Onboard the first merchant group to get started.'
}
emptyAction={
search ? undefined : (
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
)
}
>
{/* Columns carry meaning, so the table scrolls sideways rather
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<TenantRow>
data={rows}
columns={columns}
idKey="tenantid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</DataState>
</Card>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
</VStack>
)}
</VStack>
);
}
/**
* Merchants that have been created but never approved.
*
* Read-only, and it says why on the panel rather than offering a button that
* cannot work: `tenants.approved` is written once, at creation, and there is no
* update or approve endpoint anywhere in the backend. Until there is, this is
* the only place these rows are visible at all — `getalltenants` cannot see
* them.
*/
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the approval queue…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every merchant on the platform has been approved.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
These merchants exist but are not approved, so they do not appear in the directory.
Approving is a database change today — the backend has no endpoint for it.
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Merchant</Th>
<Th>Company</Th>
<Th>Contact</Th>
<Th>City</Th>
<Th>Tenant id</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.tenantid}-${row.locationid}`}>
<Td isStrong>{row.tenantname}</Td>
<Td isMuted>{row.companyname || '—'}</Td>
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
<Td isMuted>{row.city || '—'}</Td>
<Td isMuted>{row.tenantid}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
function TabButton({
label,
isActive,
onClick,
badge,
}: {
label: string;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
}}
>
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Th({ children }: { children?: React.ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
children: React.ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
}}
>
{children}
</td>
);
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Building2, Plus, Store, Users } from 'lucide-react';
import { DataState } from '@/components/DataState';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types';
/** A tenant, with its branches folded in. */
interface TenantRow extends Record<string, unknown> {
tenantid: number;
tenantname: string;
companyname: string;
city: string;
branches: number;
status: string;
primaryemail: string;
}
/**
* The Nearle Admin's home: every tenant on the platform, and how many branches
* sit under each.
*
* ── The branch count comes from the server ──────────────────────────────────
*
* This file used to say `getalltenants` returns one row per tenant-location
* pair, and counted repeated tenantids to get the number of branches. That was
* never true: the query is `SELECT … FROM tenants`, with no join to
* tenantlocations anywhere in it, so a tenantid has never appeared twice — and
* the count was therefore 1 for every merchant on the platform, however many
* shops they ran. The "Branches" and "Avg branches" tiles above this list were
* the tenant count under two other names, and a tenant's own detail page
* contradicted the row that opened it.
*
* The endpoint now returns `branchcount` and this reads it. The grouping by
* tenantid is kept: it costs nothing, and it is the one thing standing between
* a future join on that query and six rows for a six-branch merchant.
*/
type Tab = 'directory' | 'pending';
/** Rows per page. One more than this is fetched, to know whether there is a next. */
const PAGE_SIZE = 50;
export function StoresPage() {
const [tab, setTab] = useState<Tab>('directory');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
/**
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
* and has no total, so paging is "ask for one more than we show and see if it
* comes back". A platform list read whole is fine at twenty tenants and not
* at two thousand.
*/
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
/**
* The queue of merchants awaiting approval.
*
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
* `getalltenants` at all, so without this they are invisible. Nothing here
* can approve one — `approved` is writable only at creation — so this lists
* and says so.
*/
const pending = useTenantsByApproval('pending');
const rows = useMemo<TenantRow[]>(() => {
if (!data) return [];
const grouped = new Map<number, TenantRow>();
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
const existing = grouped.get(tenant.tenantid);
if (existing) {
// Only reachable if the endpoint ever starts joining locations. Then
// the rows ARE per-pair and counting them is right again.
existing.branches += 1;
continue;
}
grouped.set(tenant.tenantid, {
tenantid: tenant.tenantid,
tenantname: tenant.tenantname,
companyname: tenant.companyname ?? '',
city: tenant.city ?? '',
// `?? 1` rather than `?? 0`: against a backend that does not send the
// field yet, one branch is the safer guess — every merchant has at
// least the outlet they were onboarded with, and showing 0 next to a
// shop that plainly exists reads as broken rather than as unknown.
branches: tenant.branchcount ?? 1,
status: tenant.status ?? 'Unknown',
primaryemail: tenant.primaryemail ?? '',
});
}
const all = [...grouped.values()];
const term = search.trim().toLowerCase();
if (!term) return all;
return all.filter(
(row) =>
row.tenantname.toLowerCase().includes(term) ||
row.companyname.toLowerCase().includes(term) ||
row.city.toLowerCase().includes(term),
);
}, [data, search]);
const totals = useMemo(() => {
const tenants = rows.length;
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
return { tenants, branches, active };
}, [rows]);
const columns: TableColumn<TenantRow>[] = [
{
key: 'tenantname',
header: 'Tenant',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.tenantname}
</Text>
<Text type="body" size="xsm" color="secondary">
{row.companyname || '—'}
</Text>
</VStack>
),
},
{
key: 'city',
header: 'City',
width: { type: 'proportional', value: 1.5 },
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
},
{
key: 'branches',
header: 'Branches',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
{row.branches}
</Text>
),
},
{
key: 'primaryemail',
header: 'Primary admin',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="xsm" color="secondary">
{row.primaryemail || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
key: 'actions',
header: '',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Link
to={`/nearle/stores/${row.tenantid}`}
style={{
color: 'var(--color-brand)',
fontWeight: 600,
fontSize: 13,
textDecoration: 'none',
}}
>
Open →
</Link>
),
},
];
return (
<VStack gap={3}>
{/* No `isLive` here. Both of this page's reads — `useTenants` and
`useTenantsByApproval` — use the `stable` query options: a 5-minute
staleTime and no refetchInterval. The pill claimed a freshness the page
does not have. The pages that keep it (Console, Sales, Counters, store
detail) poll on a real interval. */}
<PageHeader
title="Stores"
actions={
<Button
label="Onboard tenant"
variant="primary"
icon={<Plus size={15} />}
href="/nearle/onboard/tenant"
as={Link}
/>
}
/>
<div className="kpi-grid">
<KpiCard
label="Tenants"
value={String(totals.tenants)}
note={`${totals.active} active`}
tone="accent"
icon={<Building2 size={15} />}
fill={totals.tenants ? totals.active / totals.tenants : 0}
/>
<KpiCard
label="Branches"
value={String(totals.branches)}
note="across the network"
tone="neutral"
icon={<Store size={15} />}
/>
<KpiCard
label="Avg branches"
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
note="per tenant"
tone="neutral"
icon={<Users size={15} />}
/>
</div>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Directory"
isActive={tab === 'directory'}
onClick={() => setTab('directory')}
/>
<TabButton
label="Awaiting approval"
isActive={tab === 'pending'}
badge={pending.data?.length || undefined}
onClick={() => setTab('pending')}
/>
</HStack>
{tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : (
<VStack gap={1.5}>
<SectionHeader
title="Tenant directory"
note={`${rows.length} shown`}
action={
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
<TextInput
label="Search tenants"
isLabelHidden
value={search}
onChange={setSearch}
placeholder="Search by name, company or city…"
hasClear
size="sm"
/>
</div>
}
/>
<Card padding={0} elevation="low">
<DataState
isLoading={isLoading}
error={error}
isEmpty={rows.length === 0}
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
emptyDescription={
search
? 'Try a different name, company or city.'
: 'Onboard the first merchant group to get started.'
}
emptyAction={
search ? undefined : (
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
)
}
>
{/* Columns carry meaning, so the table scrolls sideways rather
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<TenantRow>
data={rows}
columns={columns}
idKey="tenantid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</DataState>
</Card>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
</VStack>
)}
</VStack>
);
}
/**
* Merchants that have been created but never approved.
*
* Read-only, and it says why on the panel rather than offering a button that
* cannot work: `tenants.approved` is written once, at creation, and there is no
* update or approve endpoint anywhere in the backend. Until there is, this is
* the only place these rows are visible at all — `getalltenants` cannot see
* them.
*/
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the approval queue…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every merchant on the platform has been approved.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
These merchants exist but are not approved, so they do not appear in the directory.
Approving is a database change today — the backend has no endpoint for it.
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Merchant</Th>
<Th>Company</Th>
<Th>Contact</Th>
<Th>City</Th>
<Th>Tenant id</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.tenantid}-${row.locationid}`}>
<Td isStrong>{row.tenantname}</Td>
<Td isMuted>{row.companyname || '—'}</Td>
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
<Td isMuted>{row.city || '—'}</Td>
<Td isMuted>{row.tenantid}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
function TabButton({
label,
isActive,
onClick,
badge,
}: {
label: string;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
}}
>
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Th({ children }: { children?: React.ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
children: React.ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
}}
>
{children}
</td>
);
}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
import { productsApi } from '@/api/products';
import type { Product } from '@/api/types';
import type { CatalogueProduct, Product } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
@@ -30,20 +30,29 @@ import {
STATE_COLOR,
STATE_LABEL,
stateOf,
catalogueFactsOf,
} from './productState';
/**
* One product, in full.
*
* The import copies seven of the global catalogue's nineteen fields — name,
* description, sku, brand, catalogueid, size and images — and leaves the rest
* behind: highlights, nutrition, FSSAI licence, providers, price range,
* variant key. Those are exactly what someone needs when deciding what to
* charge, so this drawer goes back to the catalogue for them.
* ── Where the catalogue panel gets its data ─────────────────────────────────
*
* That lookup often finds nothing, and that is expected rather than an error:
* the tenant's product is a SNAPSHOT, so it outlives its source row whenever a
* re-scrape retires a variant. The panel says so instead of showing a blank.
* The import used to copy eight of the global catalogue's eighteen fields and
* leave the rest behind — highlights, nutrition, the FSSAI licence, providers,
* the price range, the variant key. Those are exactly what someone needs when
* deciding what to charge, so this drawer went back to the catalogue for them
* on every open.
*
* That lookup could not be relied on, and the failure was silent and permanent:
* a tenant's product is a SNAPSHOT and outlives its source row, so the first
* re-scrape to retire a variant took the licence number and the nutrition panel
* off a product the shop was still selling, with no way to get them back.
*
* The import now keeps them, in `products.cataloguefacts`, and this reads that
* first. The lookup remains as the fallback for the two cases with nothing
* stored: products imported before that landed, and sheet-imported products,
* which never had a catalogue row but are still matched on brand and SKU.
*/
export function ProductDrawer({
product,
@@ -74,7 +83,23 @@ export function ProductDrawer({
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
null;
const source = useCatalogueProduct(product.productbrand, product.productsku);
/*
What the catalogue said, preferred from the product's own copy.
The import now keeps these fields on the product, so the usual case needs no
request at all and — the point of storing them — still reads correctly after
a re-scrape has retired the source row.
The live lookup stays for everything with nothing stored: products imported
before this landed, and sheet-imported products, which never had a catalogue
row but are still matched on brand and SKU. Both are queried the same way as
before, so neither loses anything.
*/
const stored = catalogueFactsOf(product);
const source = useCatalogueProduct(
stored ? undefined : product.productbrand,
stored ? undefined : product.productsku,
);
const images = imagesOf(product);
const state = stateOf(product);
const reason = blockedReason(product);
@@ -225,16 +250,19 @@ export function ProductDrawer({
</DrawerCard>
</Section>
{/* ── The fields the import left behind ─────────────────────────────── */}
{source.isLoading ? (
{/* ── The catalogue's reference data ────────────────────────────────── */}
{stored ? (
<CatalogueExtras data={stored} />
) : source.isLoading ? (
<Note>Looking up the full catalogue entry…</Note>
) : source.data ? (
<CatalogueExtras data={source.data} />
) : (
<Section title="Catalogue entry">
<Note>
Not available — this item has been revised in the global catalogue since you imported
it. Your copy is unaffected; only the extra reference details are gone.
Not available — this item did not come from the global catalogue, or it has been
revised there since you imported it. Your copy is unaffected; only the extra reference
details are missing.
</Note>
</Section>
)}
@@ -282,7 +310,15 @@ export function ProductDrawer({
function CatalogueExtras({
data,
}: {
data: NonNullable<ReturnType<typeof useCatalogueProduct>['data']>;
/**
* Partial, because the two sources carry different amounts.
*
* A live lookup returns the whole catalogue row; the copy kept at import
* holds only the fields worth keeping, and omits anything the catalogue did
* not state. Everything below is already written to skip what is absent, so
* the wider type costs nothing and is the honest one.
*/
data: Partial<CatalogueProduct>;
}) {
const lines: [string, string][] = [];
if (data.category) lines.push(['Catalogue category', data.category]);

View File

@@ -93,5 +93,73 @@ test('falls through to the money field the row actually has', () => {
test('no rows is an empty map, and callers have a zero to fall back on', () => {
assert.equal(branchOrderStats([]).size, 0);
assert.deepEqual(NO_ORDERS, { revenue: 0, orders: 0, delivered: 0, cancelled: 0 });
assert.deepEqual(NO_ORDERS, {
revenue: 0,
orders: 0,
counterRevenue: 0,
counterOrders: 0,
counterTax: 0,
delivered: 0,
cancelled: 0,
});
});
test('tax on an imported bill is counted, since no till ever saw it', () => {
const stats = branchOrderStats([
row({ ordervalue: 1185, taxamount: 56.43, deliverytype: 'OFFLINE' }),
row({ ordervalue: 200, taxamount: 10 }),
]);
assert.equal(stats.get(1185)?.counterTax, 56.43);
});
/* ── Counter bills are not app sales ────────────────────────────────────────
*
* `uploadofflinesales` writes imported till bills into `orders`, tagged
* `deliverytype = 'OFFLINE'`. Nothing here read that tag, so a merchant who
* imported their counter sheet saw the day's takings reported as app revenue
* with "Counter Sales ₹0" printed beside it.
*/
test('an OFFLINE row is counter revenue, not app revenue', () => {
const stats = branchOrderStats([
row({ ordervalue: 400 }),
row({ ordervalue: 1185, deliverytype: 'OFFLINE' }),
]);
assert.equal(stats.get(1185)?.revenue, 400);
assert.equal(stats.get(1185)?.orders, 1);
assert.equal(stats.get(1185)?.counterRevenue, 1185);
assert.equal(stats.get(1185)?.counterOrders, 1);
});
test('the tag is read however the column is cased or padded', () => {
// `deliverytype` is free text, like every other status column read in this
// folder, and the import is not the only thing that has ever written it.
const stats = branchOrderStats([
row({ ordervalue: 10, deliverytype: ' offline ' }),
row({ ordervalue: 20, deliverytype: 'Offline' }),
]);
assert.equal(stats.get(1185)?.counterRevenue, 30);
assert.equal(stats.get(1185)?.revenue, 0);
});
test('a counter bill is not counted as a delivery', () => {
// Imported bills arrive already `delivered` — they were handed over before
// anyone uploaded them — so counting them would report deliveries that never
// went out and dilute the cancellation rate shown next to them.
const stats = branchOrderStats([
row({ ordervalue: 500, orderstatus: 'delivered', deliverytype: 'OFFLINE' }),
row({ ordervalue: 100, orderstatus: 'delivered' }),
]);
assert.equal(stats.get(1185)?.delivered, 1);
});
test('the ordinary delivery types are still app orders', () => {
// 'B' and 'C' are what the dispatch pipeline selects on; neither is counter.
const stats = branchOrderStats([
row({ ordervalue: 60, deliverytype: 'B' }),
row({ ordervalue: 40, deliverytype: 'C' }),
row({ ordervalue: 25, deliverytype: '' }),
]);
assert.equal(stats.get(1185)?.revenue, 125);
assert.equal(stats.get(1185)?.counterRevenue, 0);
});

View File

@@ -29,9 +29,40 @@ import { matchesStatus, orderValue } from './orderStatus';
* from the same rows and agree.
*/
export interface BranchOrderStats {
/** Sum of `orderValue` over the branch's rows, in rupees. */
/**
* Sum of `orderValue` over the branch's APP orders, in rupees.
*
* Counter bills are not in here — see `counterRevenue`. This used to be
* every row, which meant a bill rung up at a till and imported from a
* spreadsheet was reported as an app sale.
*/
revenue: number;
orders: number;
/**
* Counter bills that reached `orders` through the offline-sales import.
*
* `uploadofflinesales` writes into `orders`, not into `pos_orders`, and tags
* each row `deliverytype = 'OFFLINE'` — the backend's own constant, set so
* these stay out of the dispatch pipeline while remaining visible to revenue
* reporting. Nothing on this side read the tag, so the console's counter
* figure came only from the POS tables and a merchant who imported their
* till sheet saw the whole day's takings attributed to the app, with
* "Counter Sales ₹0" beside it.
*
* Kept separate from the POS figure rather than merged here: these two
* arrive by different routes, and a caller that wants the counter total adds
* them itself, where the addition is visible.
*/
counterRevenue: number;
counterOrders: number;
/**
* Tax on those imported bills.
*
* Carried because Reports prints a "Tax collected" figure for the counter
* channel and takes it from the POS summary, which by definition cannot see
* a sale that never went through a till.
*/
counterTax: number;
delivered: number;
cancelled: number;
}
@@ -39,10 +70,27 @@ export interface BranchOrderStats {
export const NO_ORDERS: BranchOrderStats = {
revenue: 0,
orders: 0,
counterRevenue: 0,
counterOrders: 0,
counterTax: 0,
delivered: 0,
cancelled: 0,
};
/**
* The backend's tag for a bill rung up at a counter.
*
* `offlineDeliveryType` in `repositories/orderRepository.go`. Compared
* case-insensitively and trimmed for the same reason every other status read
* in this folder is: the column is free text.
*/
const OFFLINE_DELIVERY_TYPE = 'offline';
/** True when this row is an imported counter bill rather than an app order. */
export function isCounterSale(row: OrderRow): boolean {
return String(row.deliverytype ?? '').trim().toLowerCase() === OFFLINE_DELIVERY_TYPE;
}
/**
* Group order rows by branch.
*
@@ -61,13 +109,30 @@ export function branchOrderStats(rows: readonly OrderRow[]): Map<number, BranchO
if (!locationid) continue;
const stats = byBranch.get(locationid) ?? { ...NO_ORDERS };
stats.revenue += orderValue(row);
stats.orders += 1;
// `orderstatus`, not `deliverystatus`: the latter is an empty string on
// every one of the 181 rows tenant 1147 has, so counting on it would report
// nothing delivered and nothing cancelled for every shop on the platform.
if (matchesStatus('delivered', row.orderstatus)) stats.delivered += 1;
if (matchesStatus('cancelled', row.orderstatus)) stats.cancelled += 1;
if (isCounterSale(row)) {
stats.counterRevenue += orderValue(row);
stats.counterOrders += 1;
stats.counterTax += Number(row.taxamount ?? 0);
} else {
stats.revenue += orderValue(row);
stats.orders += 1;
// Counted for app orders only, and deliberately inside this branch.
//
// A counter bill is written `delivered` on arrival — it was handed over
// before it was ever imported — so counting these would add a delivery
// that never went out, and the cancellation RATE shown beside them
// ("0% of app orders") would be diluted by sales that could not be
// cancelled in the first place.
//
// `orderstatus`, not `deliverystatus`: the latter is an empty string on
// every one of the 181 rows tenant 1147 has, so counting on it would
// report nothing delivered and nothing cancelled for every shop on the
// platform.
if (matchesStatus('delivered', row.orderstatus)) stats.delivered += 1;
if (matchesStatus('cancelled', row.orderstatus)) stats.cancelled += 1;
}
byBranch.set(locationid, stats);
}

View File

@@ -0,0 +1,79 @@
/**
* What the catalogue said, kept on the product.
*
* The import copied eight of the catalogue's eighteen fields and left the rest
* behind — the FSSAI licence, nutrition, highlights, providers, the price range
* and the variant key. The drawer covered for it by asking the catalogue again
* on every open, which works right up until a re-scrape retires the source row:
* a tenant's product is a snapshot and outlives it, so the licence number came
* off a product the shop was still selling and could not be got back.
*
* They are stored now. These are about reading them back safely — the value is
* a jsonb blob written by another process, so every shape it could arrive in
* has to degrade to "show the rest of the drawer" rather than throw.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { Product } from '@/api/types';
import { catalogueFactsOf } from './productState';
const product = (over: Partial<Product> = {}): Product =>
({ productid: 1, productname: 'Test Rice 5kg', ...over }) as Product;
test('the stored facts are read back as the catalogue stated them', () => {
const facts = catalogueFactsOf(
product({
cataloguefacts: JSON.stringify({
fssai_license: '12345678901234',
highlights: ['Aged 12 months'],
nutrients: ['Energy 350kcal', 'Protein 7g'],
providers: ['bigbasket'],
price_range: '380-420',
variant_key: 'rice-5kg',
}),
}),
);
assert.equal(facts?.fssai_license, '12345678901234');
assert.deepEqual(facts?.nutrients, ['Energy 350kcal', 'Protein 7g']);
assert.deepEqual(facts?.providers, ['bigbasket']);
assert.equal(facts?.price_range, '380-420');
});
test('pack size comes from the column, not the blob', () => {
// `size` already has a home — the import writes it to `productunit` — so
// storing it twice would be the one field able to disagree with itself.
const facts = catalogueFactsOf(
product({ productunit: '5 kg', cataloguefacts: '{"fssai_license":"1"}' }),
);
assert.equal(facts?.size, '5 kg');
});
test('a product that never came from the catalogue has no facts', () => {
// A sheet-imported product. Null is the drawer's signal to try the live
// lookup instead, so this must not be an empty object.
assert.equal(catalogueFactsOf(product()), null);
assert.equal(catalogueFactsOf(product({ cataloguefacts: '' })), null);
assert.equal(catalogueFactsOf(product({ cataloguefacts: ' ' })), null);
});
test('an empty object reads the same as nothing stored', () => {
// `{}` is what the backend writes when the catalogue stated none of them,
// and a panel with no rows in it is worse than the fallback.
assert.equal(catalogueFactsOf(product({ cataloguefacts: '{}' })), null);
});
test('a malformed value degrades to none rather than throwing', () => {
// The column is written by another process and read here. A drawer that
// throws on it takes the whole product with it.
assert.equal(catalogueFactsOf(product({ cataloguefacts: 'not json' })), null);
assert.equal(catalogueFactsOf(product({ cataloguefacts: '{"unclosed":' })), null);
});
test('a JSON value that is not an object is refused', () => {
// Valid JSON, wrong shape — an array would spread into numeric keys and
// render a row per character index.
assert.equal(catalogueFactsOf(product({ cataloguefacts: '["a","b"]' })), null);
assert.equal(catalogueFactsOf(product({ cataloguefacts: 'null' })), null);
assert.equal(catalogueFactsOf(product({ cataloguefacts: '42' })), null);
});

View File

@@ -93,8 +93,14 @@ export function ConsolePage() {
onlineOrders: order.orders,
cancelled: order.cancelled,
delivered: order.delivered,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
// Counter takings arrive by two routes and this is the only place
// that sees both: tills that synced write `pos_orders`, and tills
// that were offline have their day imported from a spreadsheet into
// `orders` tagged OFFLINE. Only the first was counted here, so an
// imported bill showed up under Online Sales and the counter tile
// read ₹0 next to it.
counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue,
counterBills: (pos?.billcount ?? 0) + order.counterOrders,
health,
pendingRequests: (requests.data ?? []).filter(
(entry) => entry.locationid === branch.locationid,

View File

@@ -65,9 +65,12 @@ export function ReportsPage() {
appOrders: order.orders,
cancelled: order.cancelled,
delivered: order.delivered,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
tax: pos?.taxcollected ?? 0,
// Synced tills and imported spreadsheets are both counter sales.
// Reading only the POS tables put every imported bill on the app
// side of the App-versus-counter split this page is built around.
counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue,
counterBills: (pos?.billcount ?? 0) + order.counterOrders,
tax: (pos?.taxcollected ?? 0) + order.counterTax,
discount: pos?.discountgiven ?? 0,
byDay: pos?.byday ?? [],
};

View File

@@ -39,10 +39,25 @@ import { assignability, assignedFrom } from '../assignDelivery';
import { useSelection } from '@/components/useSelection';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { isCounterSale } from '../branchStats';
import './deliveries.css';
type Tab = 'orders' | 'deliveries' | 'counter';
/**
* The bill number a counter sale was imported under.
*
* The importer records it twice — `remarks` as `OFFLINE:<billno>`, and
* `ordernotes` as prose — but `getorders` returns only `ordernotes`, so that
* is what this reads. The shop knows the sale by the number printed on the
* slip, and matching it back to their own sheet is the whole point of showing
* it; the platform's `orderid` means nothing to them and is only the fallback.
*/
function offlineBillNumber(order: OrderRow): string {
const found = /\(bill\s+(.+?)\)\s*$/i.exec(String(order.ordernotes ?? ''));
return found?.[1]?.trim() || String(order.orderid ?? '');
}
/**
* Sales — app orders, delivery jobs and counter bills.
*
@@ -94,7 +109,18 @@ export function SalesPage() {
const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 });
const posSummary = usePosSalesByBranch(branchIds, dates.range);
const allOrders = orders.data ?? [];
/**
* App orders and imported counter bills, told apart.
*
* `uploadofflinesales` writes a till's day into `orders` like any other row,
* tagged `deliverytype = 'OFFLINE'`. Everything on this page treated the
* whole response as online, so an imported bill was counted under "Online
* Orders", added to Order value and Average order, and was missing from the
* Counter sales tab that exists to show it.
*/
const everyOrder = orders.data ?? [];
const allOrders = useMemo(() => everyOrder.filter((row) => !isCounterSale(row)), [everyOrder]);
const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]);
const allDeliveries = deliveries.data ?? [];
const orderRows = useMemo(
@@ -115,6 +141,31 @@ export function SalesPage() {
out.push({ bill, branch: branch?.locationname ?? `Branch ${branchId}` });
}
});
/* Imported bills belong in this table too — they are counter sales that
reached us on a spreadsheet instead of over MQTT.
`terminalid` and `cashiername` are left undefined rather than filled
with a placeholder: a sheet does not say which till rang the sale or who
was on it, and inventing either would put a name against a transaction
that has none. The table already renders a dash for what is missing. */
for (const order of importedBills) {
const branch = branches.find((entry) => entry.locationid === Number(order.locationid));
out.push({
bill: {
locationid: Number(order.locationid ?? 0),
invoicenumber: offlineBillNumber(order),
billedat: order.orderdate,
businessdate: order.orderdate,
total: orderValue(order),
taxamount: Number(order.taxamount ?? 0),
itemcount: Number(order.itemcount ?? 0),
customername: order.deliverycustomer ?? '',
customermobile: order.deliverycontactno ?? '',
},
branch: branch?.locationname ?? order.locationname ?? `Branch ${order.locationid}`,
});
}
const term = keyword.trim().toLowerCase();
return out
.filter(({ bill }) =>
@@ -129,7 +180,7 @@ export function SalesPage() {
adrift of the rest, so newest-first was newest-first only within a
terminal. */
.sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0));
}, [billPages, branchIds, branches, keyword]);
}, [billPages, branchIds, branches, keyword, importedBills]);
/**
* The tab strip for whichever sub-tab is open.
@@ -152,18 +203,28 @@ export function SalesPage() {
) as Record<StatusKey, number>;
}, [tab, allOrders, allDeliveries]);
const counterTotals = useMemo(
() =>
posSummary.reduce(
(acc, page) => ({
bills: acc.bills + (page.data?.billcount ?? 0),
gross: acc.gross + (page.data?.grosssales ?? 0),
tax: acc.tax + (page.data?.taxcollected ?? 0),
}),
{ bills: 0, gross: 0, tax: 0 },
),
[posSummary],
);
const counterTotals = useMemo(() => {
const fromTills = posSummary.reduce(
(acc, page) => ({
bills: acc.bills + (page.data?.billcount ?? 0),
gross: acc.gross + (page.data?.grosssales ?? 0),
tax: acc.tax + (page.data?.taxcollected ?? 0),
}),
{ bills: 0, gross: 0, tax: 0 },
);
// Plus the days that arrived on a spreadsheet. The POS summary cannot see
// these — they were never in `pos_orders` — so a shop that imports rather
// than syncs had "Counter revenue ₹0" over a table of its own bills.
return importedBills.reduce(
(acc, order) => ({
bills: acc.bills + 1,
gross: acc.gross + orderValue(order),
tax: acc.tax + Number(order.taxamount ?? 0),
}),
fromTills,
);
}, [posSummary, importedBills]);
const orderTotals = useMemo(() => {
const value = orderRows.reduce((sum, row) => sum + orderValue(row), 0);

View File

@@ -1,4 +1,4 @@
import type { Product } from '@/api/types';
import type { CatalogueProduct, Product } from '@/api/types';
/**
* Where a product actually sits in the flow.
@@ -101,3 +101,43 @@ export function imagesOf(product: Product): string[] {
return first;
}
}
/**
* What the global catalogue said about this product, kept at import time.
*
* Shaped as a partial `CatalogueProduct` on purpose: the backend writes the
* catalogue's own wire names into `cataloguefacts`, so the same renderer works
* whether the facts were stored at import or fetched live just now, with no
* translation between the two.
*
* Returns null when there is nothing stored — a sheet-imported product has no
* catalogue record — which is the caller's signal to fall back to the lookup.
* A malformed value is treated as absent rather than thrown: the drawer is
* worth showing without its reference panel.
*/
export function catalogueFactsOf(product: Product): Partial<CatalogueProduct> | null {
const raw = (product.cataloguefacts ?? '').trim();
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
const facts = parsed as Partial<CatalogueProduct>;
// `{}` is what the backend writes when the catalogue said nothing, so an
// empty object means the same as no object: there is nothing to show.
if (Object.keys(facts).length === 0) return null;
/*
`size` is not in the stored object because it already has a column.
The import puts the catalogue's `size` into `productunit` — "1 L", "5 kg" —
so storing it twice would be the one field that could disagree with itself.
Filled in here so the renderer sees the same shape either way.
*/
return product.productunit ? { size: product.productunit, ...facts } : facts;
}