ui/ux on catalogue

This commit is contained in:
2026-09-19 11:25:41 +05:30
parent 93735e232a
commit 19ed3585ff
10 changed files with 90 additions and 36 deletions

View File

@@ -377,18 +377,11 @@ export function CatalogueBrowser({
It stays above both columns rather than inside the rail: the search It stays above both columns rather than inside the rail: the search
narrows the whole catalogue, and the rail only lists brands. */} narrows the whole catalogue, and the rail only lists brands. */}
{/* Pinned to the right-hand corner, and stated inline so it cannot be {/* Right-hand corner. The filter toggle and the search belong at the end
undone by a stylesheet. of the row, matching where search sits on Sales, Reports and Products,
rather than above the brand rail where they read as the rail's own
`justify="end"` was already here and already resolves to controls instead of the whole catalogue's. */}
`justify-content: flex-end` — HStack maps `justify` to the main axis, <HStack gap={1} align="center" wrap="wrap" justify="end" width="100%">
and the rule is in the bundle — yet the row was reported rendering at
the top LEFT of the platform catalogue. A utility class landing after
the component's own is the only thing that explains that, and an
inline declaration is the one thing such a class cannot outrank.
`width` is explicit for the same reason rather than relying on the
flex default. */}
<HStack gap={1} align="center" wrap="wrap" justify="start" width="100%">
<div style={{ width: 216, display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ width: 216, display: 'flex', gap: 8, alignItems: 'center' }}>
<IconButton <IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'} label={isFiltersOpen ? 'Hide filters' : 'Show filters'}

View File

@@ -6,7 +6,7 @@ import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Boxes, PackageSearch, Store } from 'lucide-react'; import { Boxes, PackageSearch, Store } from 'lucide-react';
import type { TenantInfo } from '@/api/types'; import type { TenantInfo } from '@/api/types';
import { count, money } from '@/features/store-admin/format'; import { money, plural } from '@/features/store-admin/format';
import type { BranchRow } from '../consoleModel'; import type { BranchRow } from '../consoleModel';
/** /**
@@ -78,8 +78,8 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
</HStack> </HStack>
<div className="shop-figures"> <div className="shop-figures">
<Figure label="Online" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} /> <Figure label="Online" value={money(row.onlineRevenue)} note={`${plural(row.onlineOrders, 'order')}`} />
<Figure label="Counter" value={money(row.counterRevenue)} note={`${count(row.counterBills)} bills`} /> <Figure label="Counter" value={money(row.counterRevenue)} note={`${plural(row.counterBills, 'bill')}`} />
<Figure label="Total" value={money(total)} note="this period" isStrong /> <Figure label="Total" value={money(total)} note="this period" isStrong />
</div> </div>

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react'; import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react';
import { count } from '@/features/store-admin/format'; import { count, plural } from '@/features/store-admin/format';
import type { BranchRow, ConsoleTotals, HealthTone } from '../consoleModel'; import type { BranchRow, ConsoleTotals, HealthTone } from '../consoleModel';
/** /**
@@ -96,7 +96,7 @@ export function StoreHealth({
detail={ detail={
productCount === 0 productCount === 0
? 'No products yet, so shoppers find the shop empty' ? 'No products yet, so shoppers find the shop empty'
: `${count(productCount)} products` : `${plural(productCount, 'product')}`
} }
tone={productCount === 0 ? 'attention' : withoutTill > 0 ? 'attention' : 'healthy'} tone={productCount === 0 ? 'attention' : withoutTill > 0 ? 'attention' : 'healthy'}
action="Manage" action="Manage"

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { plural } from './format';
test('one is singular, everything else is not', () => {
// The whole reason this exists: a shop whose first order just landed.
assert.equal(plural(1, 'order'), '1 order');
assert.equal(plural(0, 'order'), '0 orders');
assert.equal(plural(2, 'order'), '2 orders');
assert.equal(plural(234, 'order'), '234 orders');
});
test('a missing count keeps its noun', () => {
// `count` renders an em dash; "— orders" is the honest reading — the noun is
// known, the number is not.
assert.equal(plural(undefined, 'order'), '— orders');
assert.equal(plural(null, 'bill'), '— bills');
});
test('an irregular plural can be given', () => {
assert.equal(plural(1, 'entry', 'entries'), '1 entry');
assert.equal(plural(3, 'entry', 'entries'), '3 entries');
});
test('the nouns actually used on the console', () => {
assert.equal(plural(1, 'bill'), '1 bill');
assert.equal(plural(1, 'product'), '1 product');
assert.equal(plural(1, 'app order'), '1 app order');
assert.equal(plural(7, 'app order'), '7 app orders');
});
test('negative one reads as singular too', () => {
// Nothing produces one today, but "-1 orders" would be the same bug.
assert.equal(plural(-1, 'order'), '-1 order');
});

View File

@@ -38,6 +38,32 @@ export const moneyExact = (value: number | undefined | null): string =>
export const count = (value: number | undefined | null): string => export const count = (value: number | undefined | null): string =>
value === undefined || value === null || !Number.isFinite(value) ? '—' : COUNT.format(value); value === undefined || value === null || !Number.isFinite(value) ? '—' : COUNT.format(value);
/**
* A count and its noun, agreeing.
*
* Every tile note on the console was a template literal with the plural typed
* into it — `${count(n)} orders` — which is right for every number except one.
* A shop with a single order read "1 orders" on the Console, on Sales and on
* Reports. It is a small thing that makes a page look untended, and it only
* shows on the quietest shops: a merchant whose first order has just landed is
* exactly who sees it.
*
* The plural is derived rather than passed, since English regulars are the
* whole of this vocabulary — orders, bills, products, stops. `many` is there
* for when that stops being true.
*
* `count` still renders `—` for a missing value, and `— orders` is the right
* reading of that: the noun is known, the number is not.
*/
export function plural(
value: number | undefined | null,
singular: string,
many?: string,
): string {
const noun = value === 1 || value === -1 ? singular : (many ?? `${singular}s`);
return `${count(value)} ${noun}`;
}
/** /**
* A percentage of a total, guarded. * A percentage of a total, guarded.
* *

View File

@@ -11,7 +11,7 @@ import { Download, Upload } from 'lucide-react';
import { errorMessage } from '@/api/client'; import { errorMessage } from '@/api/client';
import { offlineSalesApi, type OfflineSalesUploadResponse } from '@/api/offlineSales'; import { offlineSalesApi, type OfflineSalesUploadResponse } from '@/api/offlineSales';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
import { count, money } from '../format'; import { count, money, plural } from '../format';
import { Drawer } from '../Drawer'; import { Drawer } from '../Drawer';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
@@ -314,7 +314,7 @@ export function CounterSalesDrawer({
}} }}
/> />
<Button <Button
label={upload.isPending ? 'Importing…' : `Import ${count(totals.bills)} bills`} label={upload.isPending ? 'Importing…' : `Import ${plural(totals.bills, 'bill')}`}
variant="primary" variant="primary"
size="sm" size="sm"
icon={<Upload size={13} />} icon={<Upload size={13} />}

View File

@@ -28,7 +28,7 @@ import {
} from '@/queries/hooks'; } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { branchOrderStats, NO_ORDERS } from '../branchStats'; import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, share } from '../format'; import { branchLabel, count, money, percent, plural, share } from '../format';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { import {
@@ -152,21 +152,21 @@ export function ConsolePage() {
<KpiCard <KpiCard
label="App revenue" label="App revenue"
value={money(totals.onlineRevenue)} value={money(totals.onlineRevenue)}
note={`${count(totals.onlineOrders)} orders`} note={`${plural(totals.onlineOrders, 'order')}`}
tone="accent" tone="accent"
icon={<ShoppingCart size={15} />} icon={<ShoppingCart size={15} />}
/> />
<KpiCard <KpiCard
label="Counter revenue" label="Counter revenue"
value={money(totals.counterRevenue)} value={money(totals.counterRevenue)}
note={`${count(totals.counterBills)} bills`} note={`${plural(totals.counterBills, 'bill')}`}
tone="success" tone="success"
icon={<Receipt size={15} />} icon={<Receipt size={15} />}
/> />
<KpiCard <KpiCard
label="Cancelled" label="Cancelled"
value={percent(cancelShare ?? 0)} value={percent(cancelShare ?? 0)}
note={`${count(totals.cancelled)} of ${count(totals.onlineOrders)} app orders`} note={`${count(totals.cancelled)} of ${plural(totals.onlineOrders, 'app order')}`}
tone={cancelShare !== null && cancelShare > 0.1 ? 'error' : 'neutral'} tone={cancelShare !== null && cancelShare > 0.1 ? 'error' : 'neutral'}
icon={<TrendingDown size={15} />} icon={<TrendingDown size={15} />}
fill={cancelShare ?? undefined} fill={cancelShare ?? undefined}
@@ -304,11 +304,11 @@ function BranchCard({ row }: { row: Row }) {
{/* Two channels, two rows, never a sum. */} {/* Two channels, two rows, never a sum. */}
<VStack gap={0.5}> <VStack gap={0.5}>
<ChannelRow label="App" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} /> <ChannelRow label="App" value={money(row.onlineRevenue)} note={`${plural(row.onlineOrders, 'order')}`} />
<ChannelRow <ChannelRow
label="Counter" label="Counter"
value={money(row.counterRevenue)} value={money(row.counterRevenue)}
note={`${count(row.counterBills)} bills`} note={`${plural(row.counterBills, 'bill')}`}
/> />
</VStack> </VStack>
@@ -681,7 +681,7 @@ function buildAlerts(rows: Row[], base: string, isPinned: boolean): Alert[] {
tone: 'error', tone: 'error',
icon: <PackageX size={15} />, icon: <PackageX size={15} />,
title: `${percent(cancelRate)} of app orders cancelled at ${row.branch.locationname}`, title: `${percent(cancelRate)} of app orders cancelled at ${row.branch.locationname}`,
detail: `${count(row.cancelled)} of ${count(row.onlineOrders)} orders. Usually stock the app thinks is on the shelf and is not.`, detail: `${count(row.cancelled)} of ${plural(row.onlineOrders, 'order')}. Usually stock the app thinks is on the shelf and is not.`,
to: `${base}/sales`, to: `${base}/sales`,
action: 'Open Sales', action: 'Open Sales',
}); });

View File

@@ -44,7 +44,7 @@ import { useLocationProducts } from '@/queries/hooks';
import { Panel } from '@/components/Panel'; import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { branchLabel, count, money } from '../format'; import { branchLabel, count, money, plural } from '../format';
type Tab = 'products' | 'catalogue' | 'stock' | 'requests'; type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
@@ -847,7 +847,7 @@ function StockPanel() {
<KpiCard <KpiCard
label="Total stock value" label="Total stock value"
value={money(value)} value={money(value)}
note={`${count(rows.length)} products tracked`} note={`${plural(rows.length, 'product')} tracked`}
tone="accent" tone="accent"
icon={<IndianRupee size={15} />} icon={<IndianRupee size={15} />}
/> />

View File

@@ -28,7 +28,7 @@ import { SectionHeader } from '@/components/SectionHeader';
import { useAllOrders, usePosSalesByBranch, useStockStatementByBranch } from '@/queries/hooks'; import { useAllOrders, usePosSalesByBranch, useStockStatementByBranch } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { branchOrderStats, NO_ORDERS } from '../branchStats'; import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, share } from '../format'; import { branchLabel, count, money, percent, plural, share } from '../format';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { Panel } from '@/components/Panel'; import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
@@ -147,14 +147,14 @@ export function ReportsPage() {
<KpiCard <KpiCard
label="App revenue" label="App revenue"
value={money(totals.appRevenue)} value={money(totals.appRevenue)}
note={`${count(totals.appOrders)} orders`} note={`${plural(totals.appOrders, 'order')}`}
tone="accent" tone="accent"
icon={<ShoppingCart size={15} />} icon={<ShoppingCart size={15} />}
/> />
<KpiCard <KpiCard
label="Counter revenue" label="Counter revenue"
value={money(totals.counterRevenue)} value={money(totals.counterRevenue)}
note={`${count(totals.counterBills)} bills`} note={`${plural(totals.counterBills, 'bill')}`}
tone="success" tone="success"
icon={<Receipt size={15} />} icon={<Receipt size={15} />}
/> />
@@ -230,7 +230,7 @@ export function ReportsPage() {
<KpiCard <KpiCard
label="Fulfilled" label="Fulfilled"
value={count(totals.delivered)} value={count(totals.delivered)}
note={`of ${count(totals.appOrders)} app orders`} note={`of ${plural(totals.appOrders, 'app order')}`}
tone="success" tone="success"
icon={<CircleCheck size={15} />} icon={<CircleCheck size={15} />}
fill={share(totals.delivered, totals.appOrders) ?? undefined} fill={share(totals.delivered, totals.appOrders) ?? undefined}
@@ -238,7 +238,7 @@ export function ReportsPage() {
<KpiCard <KpiCard
label="Cancelled" label="Cancelled"
value={percent(share(totals.cancelled, totals.appOrders))} value={percent(share(totals.cancelled, totals.appOrders))}
note={`${count(totals.cancelled)} orders`} note={`${plural(totals.cancelled, 'order')}`}
tone={ tone={
(share(totals.cancelled, totals.appOrders) ?? 0) > 0.1 ? 'error' : 'neutral' (share(totals.cancelled, totals.appOrders) ?? 0) > 0.1 ? 'error' : 'neutral'
} }

View File

@@ -18,7 +18,7 @@ import {
usePosSalesByBranch, usePosSalesByBranch,
} from '@/queries/hooks'; } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { branchLabel, count, money, moneyExact } from '../format'; import { branchLabel, count, money, moneyExact, plural } from '../format';
import { import {
DELIVERY_STATUS, DELIVERY_STATUS,
ORDER_STATUS, ORDER_STATUS,
@@ -366,7 +366,7 @@ export function SalesPage() {
<KpiCard <KpiCard
label="Order value" label="Order value"
value={money(orderTotals.value)} value={money(orderTotals.value)}
note={`${count(orderRows.length)} orders`} note={`${plural(orderRows.length, 'order')}`}
tone="accent" tone="accent"
icon={<ShoppingCart size={15} />} icon={<ShoppingCart size={15} />}
/> />
@@ -508,7 +508,7 @@ export function SalesPage() {
<KpiCard <KpiCard
label="Counter revenue" label="Counter revenue"
value={money(counterTotals.gross)} value={money(counterTotals.gross)}
note={`${count(counterTotals.bills)} bills`} note={`${plural(counterTotals.bills, 'bill')}`}
tone="success" tone="success"
icon={<Receipt size={15} />} icon={<Receipt size={15} />}
/> />