ui improvement

This commit is contained in:
2026-09-17 17:57:21 +05:30
parent 2cd048e6f0
commit 115a06a02c
79 changed files with 3574 additions and 2186 deletions

View File

@@ -4,12 +4,13 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Card } from '@astryxdesign/core/Card';
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 { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react';
import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SearchInput } from '@/components/SearchInput';
import { Tab, TabBar } from '@/components/TabBar';
import {
useDeliveries,
useOrders,
@@ -37,6 +38,7 @@ import { shortAge } from '../posStatus';
import { AssignBar } from '../AssignBar';
import { assignability, assignedFrom } from '../assignDelivery';
import { useSelection } from '@/components/useSelection';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { isCounterSale } from '../branchStats';
@@ -44,6 +46,21 @@ import './deliveries.css';
type Tab = 'orders' | 'deliveries' | 'counter';
/**
* What the one search box matches, per view.
*
* The page used to draw three separate boxes, one inside each view, and the
* only thing that differed between them was this line — the value behind them
* was already a single `keyword`, filtering orders and deliveries through the
* query and counter bills client-side. So the boxes were three renderings of
* one control, and the placeholder is what survives of them.
*/
const SEARCH_HINT: Record<Tab, string> = {
orders: 'Order ID, customer…',
deliveries: 'Order ID, rider…',
counter: 'Invoice, till, cashier…',
};
/**
* The bill number a counter sale was imported under.
*
@@ -287,43 +304,58 @@ export function SalesPage() {
<VStack gap={3}>
<PageHeader
title="Sales"
/* Tabs left, search right, on ONE row — see `isTabsInline`. */
isTabsInline
actions={
<SearchInput
label="Search sales"
value={keyword}
onChange={setKeyword}
placeholder={SEARCH_HINT[tab]}
/>
}
tabs={
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Online Orders"
/* THREE views, not four.
There used to be an "Online Orders" tab in front of these carrying
the combined count — 86 beside Orders 85 and Deliveries 1. It was
never a view of its own: it reported active whenever the page was
NOT on Counter sales, and clicking it from either of the two tabs
it contained did nothing at all. In the old flat pills that passed
unnoticed. In a segmented control it does not: a segmented control
means "one of these", and Online Orders and Orders were both drawn
selected at the same time, which is simply a broken control.
Nothing is lost by dropping it. Its count was the sum of the two
beside it, and the combined figure is already on the KPI row above.
Counter sales keeps the divider: Orders and Deliveries are two
halves of the app's own trade and the till is a different channel,
which is the grouping "Online Orders" was there to express. */
<TabBar aria-label="Sales views">
<Tab
label="Orders"
icon={<ShoppingCart size={14} />}
badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined}
isActive={tab !== 'counter'}
onClick={() => setTab(tab === 'counter' ? 'orders' : tab)}
count={allOrders.length || undefined}
isActive={tab === 'orders'}
onClick={() => setTab('orders')}
/>
{tab !== 'counter' ? (
<>
<TabButton
label="Orders"
icon={<ShoppingCart size={14} />}
badge={allOrders.length || undefined}
isActive={tab === 'orders'}
onClick={() => setTab('orders')}
/>
<TabButton
label="Deliveries"
icon={<Truck size={14} />}
badge={allDeliveries.length || undefined}
isActive={tab === 'deliveries'}
onClick={() => setTab('deliveries')}
/>
</>
) : null}
<TabButton
<Tab
label="Deliveries"
icon={<Truck size={14} />}
count={allDeliveries.length || undefined}
isActive={tab === 'deliveries'}
onClick={() => setTab('deliveries')}
/>
<Tab
label="Counter sales"
icon={<Receipt size={14} />}
badge={billRows.length || undefined}
count={billRows.length || undefined}
isActive={tab === 'counter'}
onClick={() => setTab('counter')}
isGroupStart
/>
</HStack>
</TabBar>
}
/>
@@ -367,9 +399,6 @@ export function SalesPage() {
status={status}
counts={tabCounts}
onStatus={setStatus}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />
}
/>
{/* Above the table, not below it and not in a drawer.
Assigning is something you do WHILE reading the day's orders —
@@ -451,9 +480,6 @@ export function SalesPage() {
status={status}
counts={tabCounts}
onStatus={setStatus}
search={
<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />
}
/>
<DeliveriesTable
rows={deliveryRows}
@@ -514,11 +540,9 @@ export function SalesPage() {
</div>
<VStack gap={1.5}>
{/* No status strip here: a counter bill is rung and done, so it
has no lifecycle to filter by. The search keeps the row. */}
<HStack justify="end" wrap="wrap">
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
</HStack>
{/* No status strip and no search row: a counter bill is rung and
done, so it has no lifecycle to filter by, and the search is in
the page header with the tabs — one box for all three views. */}
<BillsTable
rows={billRows}
queryKey={queryKey}
@@ -580,35 +604,46 @@ function freshRow(
* in view, the filters decide what is in view, and the table shows it. Reading
* downward now follows that order.
*/
/**
* The status ladder, under the view tabs.
*
* The SECONDARY size of the same control the views use — no track, smaller
* type, a brand tint on the selected one. That subordination is the point: two
* strips of tabs at equal weight on one screen read as two equally important
* choices, when this one only narrows whatever the strip above it selected.
*
* It used to be a private `StatusTab` — a 30px chip with a 12px radius, a
* hairline when idle and a solid brand fill when selected — which was a fourth
* treatment of "tab" on a page that now has one.
*
* The wrapper it sat in is gone with it. It was an `HStack justify="between"`
* holding the tabs and the search; the search moved to the page header, so the
* row was a space-between with nothing on the right of it.
*/
function FilterRow({
tabs,
status,
counts,
onStatus,
search,
}: {
/** Which ladder to show — orders and deliveries do not share one. */
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
status: StatusKey;
counts: Record<StatusKey, number>;
onStatus: (key: StatusKey) => void;
search: React.ReactNode;
}) {
return (
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack gap={0.5} wrap="wrap">
{tabs.map((entry) => (
<StatusTab
key={entry.key}
label={entry.label}
n={counts[entry.key]}
isActive={status === entry.key}
onClick={() => onStatus(entry.key)}
/>
))}
</HStack>
{search}
</HStack>
<TabBar size="sm" aria-label="Filter by status">
{tabs.map((entry) => (
<Tab
key={entry.key}
label={entry.label}
count={counts[entry.key]}
isActive={status === entry.key}
onClick={() => onStatus(entry.key)}
/>
))}
</TabBar>
);
}
@@ -649,7 +684,12 @@ function OrdersTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Orders"
count={paged.total}
countLabel="orders"
footer={<TablePager paged={paged} label="orders" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -773,8 +813,7 @@ function OrdersTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="orders" />
</Card>
</Panel>
);
}
@@ -807,7 +846,12 @@ function DeliveriesTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Deliveries"
count={paged.total}
countLabel="deliveries"
footer={<TablePager paged={paged} label="deliveries" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -948,8 +992,7 @@ function DeliveriesTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="deliveries" />
</Card>
</Panel>
);
}
@@ -980,7 +1023,12 @@ function BillsTable({
}
return (
<Card padding={0} elevation="low">
<Panel
title="Counter sales"
count={paged.total}
countLabel="bills"
footer={<TablePager paged={paged} label="bills" />}
>
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -1062,8 +1110,7 @@ function BillsTable({
</tbody>
</table>
</div>
<TablePager paged={paged} label="bills" />
</Card>
</Panel>
);
}
@@ -1094,29 +1141,8 @@ function clockOf(value: string | number | undefined): string {
});
}
function Search({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (next: string) => void;
placeholder: string;
}) {
return (
<div style={{ width: 240 }}>
<TextInput
label="Search"
isLabelHidden
size="sm"
value={value}
onChange={onChange}
placeholder={placeholder}
hasClear
/>
</div>
);
}
/* The page-local search box lived here. It is `components/SearchInput`
now, in the header beside the tabs, and there is one of it. */
/** A status chip in the lifecycle's own colour. */
function Chip({ label, color }: { label: string | undefined; color: string }) {
@@ -1222,117 +1248,15 @@ function TotalsBar({
);
}
function StatusTab({
label,
n,
isActive,
onClick,
}: {
label: string;
n: number;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 30,
padding: '0 11px',
borderRadius: 12,
border: `1px solid ${isActive ? 'transparent' : 'var(--color-line)'}`,
// Transparent when idle, not white. A row of white chips on a tinted
// page reads as six raised slabs; only the selected one should carry a
// fill, and the hairline is enough to keep the rest legible as chips.
background: isActive ? 'var(--color-brand)' : 'transparent',
color: isActive ? '#fff' : 'var(--color-ink-2)',
fontSize: 12.5,
fontWeight: 500,
cursor: 'pointer',
transition: 'background .2s, color .2s, border-color .2s',
}}
>
{label}
<span
style={{
fontVariantNumeric: 'tabular-nums',
fontWeight: 700,
opacity: isActive ? 0.85 : 0.55,
}}
>
{n}
</span>
</button>
);
}
/* The page-local `StatusTab` lived here — it is the `sm` size of
`components/TabBar` now. */
function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: React.ReactNode;
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',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: isActive ? '#fff' : 'var(--color-ink-3)',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
fontVariantNumeric: 'tabular-nums',
}}
>
{badge}
</span>
) : null}
</button>
);
}
/* The page-local `TabButton` lived here. It is `components/TabBar` now —
one control for the six pages that had each grown their own. */
function Loading({ what }: { what: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading {what}…
@@ -1344,7 +1268,7 @@ function Loading({ what }: { what: string }) {
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
<Text type="label" size="sm" weight="semibold">