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
narrows the whole catalogue, and the rail only lists brands. */}
{/* Pinned to the right-hand corner, and stated inline so it cannot be
undone by a stylesheet.
`justify="end"` was already here and already resolves to
`justify-content: flex-end` — HStack maps `justify` to the main axis,
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%">
{/* Right-hand corner. The filter toggle and the search belong at the end
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
controls instead of the whole catalogue's. */}
<HStack gap={1} align="center" wrap="wrap" justify="end" width="100%">
<div style={{ width: 216, display: 'flex', gap: 8, alignItems: 'center' }}>
<IconButton
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 { Boxes, PackageSearch, Store } from 'lucide-react';
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';
/**
@@ -78,8 +78,8 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
</HStack>
<div className="shop-figures">
<Figure label="Online" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} />
<Figure label="Counter" value={money(row.counterRevenue)} note={`${count(row.counterBills)} bills`} />
<Figure label="Online" value={money(row.onlineRevenue)} note={`${plural(row.onlineOrders, 'order')}`} />
<Figure label="Counter" value={money(row.counterRevenue)} note={`${plural(row.counterBills, 'bill')}`} />
<Figure label="Total" value={money(total)} note="this period" isStrong />
</div>

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
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';
/**
@@ -96,7 +96,7 @@ export function StoreHealth({
detail={
productCount === 0
? 'No products yet, so shoppers find the shop empty'
: `${count(productCount)} products`
: `${plural(productCount, 'product')}`
}
tone={productCount === 0 ? 'attention' : withoutTill > 0 ? 'attention' : 'healthy'}
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 =>
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.
*

View File

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

View File

@@ -28,7 +28,7 @@ import {
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
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 { usePaged } from '@/components/usePaged';
import {
@@ -152,21 +152,21 @@ export function ConsolePage() {
<KpiCard
label="App revenue"
value={money(totals.onlineRevenue)}
note={`${count(totals.onlineOrders)} orders`}
note={`${plural(totals.onlineOrders, 'order')}`}
tone="accent"
icon={<ShoppingCart size={15} />}
/>
<KpiCard
label="Counter revenue"
value={money(totals.counterRevenue)}
note={`${count(totals.counterBills)} bills`}
note={`${plural(totals.counterBills, 'bill')}`}
tone="success"
icon={<Receipt size={15} />}
/>
<KpiCard
label="Cancelled"
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'}
icon={<TrendingDown size={15} />}
fill={cancelShare ?? undefined}
@@ -304,11 +304,11 @@ function BranchCard({ row }: { row: Row }) {
{/* Two channels, two rows, never a sum. */}
<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
label="Counter"
value={money(row.counterRevenue)}
note={`${count(row.counterBills)} bills`}
note={`${plural(row.counterBills, 'bill')}`}
/>
</VStack>
@@ -681,7 +681,7 @@ function buildAlerts(rows: Row[], base: string, isPinned: boolean): Alert[] {
tone: 'error',
icon: <PackageX size={15} />,
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`,
action: 'Open Sales',
});

View File

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

View File

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

View File

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