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
|
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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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 =>
|
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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user