ui/ux on catalogue
This commit is contained in:
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
35
src/features/store-admin/format.test.ts
Normal file
35
src/features/store-admin/format.test.ts
Normal 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');
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user