whole ui update

This commit is contained in:
2026-10-10 12:43:37 +05:30
parent de7a833678
commit af3e8ae53e
10 changed files with 150 additions and 120 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Switch } from '@astryxdesign/core/Switch'; import { Switch } from '@astryxdesign/core/Switch';
@@ -60,6 +61,8 @@ export interface CatalogueBrowserProps {
isReadOnly?: boolean; isReadOnly?: boolean;
/** Why importing is unavailable, if it is. */ /** Why importing is unavailable, if it is. */
blockedReason?: string; blockedReason?: string;
/** Whether to pull the search and filter controls up into the tab row. */
alignWithTabs?: boolean;
} }
/** /**
@@ -87,6 +90,7 @@ export function CatalogueBrowser({
scope, scope,
blockedReason, blockedReason,
isReadOnly, isReadOnly,
alignWithTabs,
}: CatalogueBrowserProps) { }: CatalogueBrowserProps) {
const client = useQueryClient(); const client = useQueryClient();
@@ -132,6 +136,13 @@ export function CatalogueBrowser({
*/ */
const [isFiltersOpen, setIsFiltersOpen] = useState(true); const [isFiltersOpen, setIsFiltersOpen] = useState(true);
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
useEffect(() => {
if (alignWithTabs) {
setPortalTarget(document.getElementById('inventory-header-actions'));
}
}, [alignWithTabs]);
/** /**
* 400ms, and it costs more than it looks to get wrong. * 400ms, and it costs more than it looks to get wrong.
* *
@@ -461,33 +472,56 @@ export function CatalogueBrowser({
of the row, matching where search sits on Sales, Reports and Products, 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 rather than above the brand rail where they read as the rail's own
controls instead of the whole catalogue's. */} 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' }}> const controls = (
<IconButton <>
label={isFiltersOpen ? 'Hide filters' : 'Show filters'} <div style={{ pointerEvents: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
icon={<Funnel size={15} />} <IconButton
variant={isFiltersOpen ? 'secondary' : 'ghost'} label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
size="sm" icon={<Funnel size={15} />}
onClick={() => setIsFiltersOpen((open) => !open)} variant={isFiltersOpen ? 'secondary' : 'ghost'}
/> size="sm"
<div style={{ flex: 1, minWidth: 0 }}> onClick={() => setIsFiltersOpen((open) => !open)}
<SearchInput />
label="Search the catalogue" <div style={{ width: 216, minWidth: 0 }}>
value={keyword} <SearchInput
onChange={setKeyword} label="Search the catalogue"
placeholder="Search products…" value={keyword}
width="full" onChange={setKeyword}
/> placeholder="Search products…"
</div> width="full"
</div> />
</div>
</div>
{activeFilters.map((filter) => ( <div style={{ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} /> {activeFilters.map((filter) => (
))} <Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
{activeFilters.length > 1 ? ( ))}
<Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} /> {activeFilters.length > 1 ? (
) : null} <Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} />
</HStack> ) : null}
</div>
</>
);
if (alignWithTabs && portalTarget) {
return createPortal(
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>{controls}</div>,
portalTarget
);
}
if (!alignWithTabs) {
return (
<HStack gap={1} align="center" wrap="wrap" justify="end" width="100%">
{controls}
</HStack>
);
}
return null;
})()}
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}> <div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
{isFiltersOpen ? ( {isFiltersOpen ? (
@@ -507,7 +541,7 @@ export function CatalogueBrowser({
/> />
) : null} ) : null}
<VStack gap={2}> <VStack gap={2} className="catalogue-main" style={{ minWidth: 0 }}>
{/* No result-count line. It said "24 products on this page", which is {/* No result-count line. It said "24 products on this page", which is
the page size restated — a number that never changes and answers a the page size restated — a number that never changes and answers a
question nobody asked. Where the page belongs in the whole is what question nobody asked. Where the page belongs in the whole is what

View File

@@ -31,6 +31,7 @@ export function CataloguePanel() {
{...(!tenantid || !locationid {...(!tenantid || !locationid
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' } ? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
: {})} : {})}
alignWithTabs
/> />
); );
} }

View File

@@ -166,7 +166,7 @@ export function StoreOpenControl({
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<Button <Button
label="Keep open" label="Keep open"
variant="ghost" variant="secondary"
size="sm" size="sm"
onClick={() => { onClick={() => {
setConfirming(false); setConfirming(false);
@@ -175,7 +175,7 @@ export function StoreOpenControl({
/> />
<Button <Button
label={save.isPending ? 'Closing…' : 'Close the branch'} label={save.isPending ? 'Closing…' : 'Close the branch'}
variant="destructive" variant="primary"
size="sm" size="sm"
isLoading={save.isPending} isLoading={save.isPending}
isDisabled={save.isPending} isDisabled={save.isPending}

View File

@@ -22,9 +22,7 @@ import {
TriangleAlert, TriangleAlert,
} from 'lucide-react'; } from 'lucide-react';
import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types'; import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { matchesWindow, type WindowFilterKey } from '../deliveryWindow';
import { groupByWindow } from '../dispatchModel'; import { groupByWindow } from '../dispatchModel';
import { WindowFilterSelect } from '../WindowFilterSelect';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { Tab, TabBar } from '@/components/TabBar'; import { Tab, TabBar } from '@/components/TabBar';
import { StickyRow } from '@/components/StickyRow'; import { StickyRow } from '@/components/StickyRow';
@@ -101,15 +99,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
const [detail, setDetail] = useState<Stop | null>(null); const [detail, setDetail] = useState<Stop | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all');
/*
Which delivery window the board is showing.
Applied to the DELIVERIES, before they become stops — so the map, the
totals, the rider groups and the list all narrow together. Filtering only
the visible list would leave the counters above it describing a different
day's work to the one on screen.
*/
const [windowFilter, setWindowFilter] = useState<WindowFilterKey>('all');
/* One id or the other, never both — see `DispatchScope`. */ /* One id or the other, never both — see `DispatchScope`. */
const isPartnerBoard = scope.kind === 'partner'; const isPartnerBoard = scope.kind === 'partner';
@@ -161,13 +151,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
vanished from every chip except "All". The date filter in the top bar already vanished from every chip except "All". The date filter in the top bar already
narrows the day and never drops a row. narrows the day and never drops a row.
*/ */
const filteredDeliveries = useMemo( const filteredDeliveries = rawDeliveries;
() =>
windowFilter === 'all'
? rawDeliveries
: rawDeliveries.filter((row) => matchesWindow(windowFilter, row)),
[rawDeliveries, windowFilter],
);
const isOneDay = Boolean( const isOneDay = Boolean(
dates.range.fromdate && dates.range.fromdate === dates.range.todate, dates.range.fromdate && dates.range.fromdate === dates.range.todate,
@@ -347,13 +331,6 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
/> />
</TabBar> </TabBar>
{/* Beside the board switcher, not down with the status pills.
It narrows every board — the groups, the counters above them and the
map — so it belongs with the controls that change what the whole
screen is about, rather than looking like one more status filter
over a single list. */}
<WindowFilterSelect value={windowFilter} onChange={setWindowFilter} align="end" />
</div> </div>
</StickyRow> </StickyRow>

View File

@@ -138,6 +138,7 @@ export function InventoryPage() {
"N awaiting approval" badge went with it, because the Stock requests "N awaiting approval" badge went with it, because the Stock requests
tab already carries that count and a number stated twice on one screen tab already carries that count and a number stated twice on one screen
invites the reader to check whether the two agree. */ invites the reader to check whether the two agree. */
actions={<div id="inventory-header-actions" style={{ display: 'flex', alignItems: 'center' }} />}
tabs={ tabs={
/* The tab row carries the Products action on its right, rather than /* The tab row carries the Products action on its right, rather than
giving it a row of its own below. It only appears on that tab — giving it a row of its own below. It only appears on that tab —

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useDateScope } from '@/components/shell/DateScope'; import { useDateScope } from '@/components/shell/DateScope';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { UploadsPanel } from '@/features/uploads/UploadsPanel'; import { UploadsPanel } from '@/features/uploads/UploadsPanel';
@@ -78,9 +78,7 @@ export function UploadsPage() {
/> />
) : null} ) : null}
<PageHeader
title="Uploads"
/>
<UploadsPanel <UploadsPanel
tenantid={user?.tenantid ?? 0} tenantid={user?.tenantid ?? 0}
range={dates.range} range={dates.range}

View File

@@ -407,7 +407,7 @@ function RiderTable({
<Th>Partner</Th> <Th>Partner</Th>
<Th>On shift</Th> <Th>On shift</Th>
<Th>Status</Th> <Th>Status</Th>
<Th /> <Th align="right" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -445,7 +445,7 @@ function RiderTable({
tone={(rider.status ?? 'Active') === 'Active' ? 'success' : 'warning'} tone={(rider.status ?? 'Active') === 'Active' ? 'success' : 'warning'}
/> />
</Td> </Td>
<Td> <Td align="right">
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(rider)} /> <Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(rider)} />
</Td> </Td>
</tr> </tr>
@@ -502,7 +502,7 @@ function StaffTable({
<Th>Mobile</Th> <Th>Mobile</Th>
{showBranch ? <Th>Branch</Th> : null} {showBranch ? <Th>Branch</Th> : null}
<Th>Status</Th> <Th>Status</Th>
<Th /> <Th align="right" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -551,7 +551,7 @@ function StaffTable({
<StatusChip status={person.status} /> <StatusChip status={person.status} />
)} )}
</Td> </Td>
<Td> <Td align="right">
<span style={{ display: 'inline-flex', gap: 6, justifyContent: 'flex-end' }}> <span style={{ display: 'inline-flex', gap: 6, justifyContent: 'flex-end' }}>
{person.issetup === false ? <ResendInvite userid={person.userid} /> : null} {person.issetup === false ? <ResendInvite userid={person.userid} /> : null}
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} /> <Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
@@ -608,7 +608,7 @@ function TillTable({
{showBranch ? <Th>Branch</Th> : null} {showBranch ? <Th>Branch</Th> : null}
<Th>Shift</Th> <Th>Shift</Th>
<Th>Status</Th> <Th>Status</Th>
<Th /> <Th align="right" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -648,7 +648,7 @@ function TillTable({
<Td> <Td>
<StatusChip status={person.status} /> <StatusChip status={person.status} />
</Td> </Td>
<Td> <Td align="right">
<Button <Button
label="Edit" label="Edit"
variant="secondary" variant="secondary"
@@ -756,17 +756,20 @@ function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; bo
); );
} }
function Th({ children }: { children?: React.ReactNode; }) { function Th({ children, align = 'left' }: { children?: React.ReactNode; align?: 'left' | 'center' | 'right' }) {
return ( return (
<th <th
style={{ style={{
textAlign: 'center', textAlign: align,
padding: '10px 12px', padding: '12px 16px',
borderBottom: '1px solid var(--color-line)', borderBottom: '1px solid var(--color-line)',
fontSize: 12, fontSize: 11.5,
fontWeight: 600, fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
color: 'var(--color-ink-3)', color: 'var(--color-ink-3)',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
backgroundColor: '#f8fafc',
}} }}
> >
{children} {children}
@@ -778,20 +781,22 @@ function Td({
children, children,
isMuted, isMuted,
isStrong, isStrong,
align = 'left',
}: { }: {
children: React.ReactNode; children: React.ReactNode;
isMuted?: boolean; isMuted?: boolean;
isStrong?: boolean; isStrong?: boolean;
align?: 'left' | 'center' | 'right';
}) { }) {
return ( return (
<td <td
style={{ style={{
textAlign: 'center', textAlign: align,
padding: '10px 12px', padding: '14px 16px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)', borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)', color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400, fontWeight: isStrong ? 600 : 400,
verticalAlign: 'top', verticalAlign: 'middle',
}} }}
> >
{children} {children}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useDateScope } from '@/components/shell/DateScope'; import { useDateScope } from '@/components/shell/DateScope';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '@/features/store-admin/BranchScope'; import { useBranchScope } from '@/features/store-admin/BranchScope';
import { UploadsPanel } from '@/features/uploads/UploadsPanel'; import { UploadsPanel } from '@/features/uploads/UploadsPanel';
@@ -33,9 +33,7 @@ export function StoreUploadsPage() {
return ( return (
<VStack gap={3}> <VStack gap={3}>
<PageHeader
title="Uploads"
/>
<UploadsPanel <UploadsPanel
tenantid={user?.tenantid ?? 0} tenantid={user?.tenantid ?? 0}
locationid={locationid} locationid={locationid}

View File

@@ -33,9 +33,11 @@ import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { FileSpreadsheet, UploadCloud } from 'lucide-react'; import { FileSpreadsheet, UploadCloud, Files, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { Tab, TabBar } from '@/components/TabBar'; import { Tab, TabBar } from '@/components/TabBar';
import { SearchInput } from '@/components/SearchInput'; import { SearchInput } from '@/components/SearchInput';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { errorMessage } from '@/api/client'; import { errorMessage } from '@/api/client';
import { import {
fetchBatch, fetchBatch,
@@ -332,45 +334,50 @@ export function UploadsPanel({
return ( return (
<VStack gap={2}> <VStack gap={2}>
{/* ── Toolbar ──────────────────────────────────────────────────────── */} {/* ── Headers ──────────────────────────────────────────────────────── */}
<div className="up-toolbar"> <PageHeader
<TabBar size="sm" aria-label="Filter uploads"> isTabsInline
{TABS.map((entry) => ( title="Uploads"
<Tab actions={
key={entry.id} onUpload ? (
label={entry.label} <Button
count={counts[entry.id]} label="Upload spreadsheet"
isActive={tab === entry.id} variant="primary"
onClick={() => setTab(entry.id)} size="sm"
icon={<UploadCloud size={14} />}
onClick={onUpload}
/> />
))} ) : null
</TabBar> }
tabs={
<TabBar aria-label="Filter uploads">
{TABS.map((entry) => (
<Tab
key={entry.id}
label={entry.label}
icon={entry.icon}
count={counts[entry.id]}
isActive={tab === entry.id}
onClick={() => setTab(entry.id)}
/>
))}
</TabBar>
}
/>
<div className="up-toolbar-spacer" /> <VStack gap={1.5}>
<SectionHeader
<SearchInput title="Upload history"
label="Search by file name" action={
value={search} <SearchInput
onChange={setSearch} label="Search by file name"
placeholder="File name…" value={search}
width={230} onChange={setSearch}
placeholder="File name…"
width={230}
/>
}
/> />
{/* "Check again" stood here. It is gone because the page reads the
moving rows by itself now — every fifteen seconds while something is
unfinished, and again the moment the tab comes forward. A refresh
button beside a list that refreshes itself is a button that never
changes anything, and pressing it to find out whether it did is
worse than not having it. */}
{onUpload ? (
<Button
label="Upload spreadsheet"
variant="primary"
size="sm"
icon={<UploadCloud size={14} />}
onClick={onUpload}
/>
) : null}
</div>
{/* ── The list ─────────────────────────────────────────────────────── */} {/* ── The list ─────────────────────────────────────────────────────── */}
{shown.length === 0 ? ( {shown.length === 0 ? (
@@ -488,6 +495,7 @@ export function UploadsPanel({
</div> </div>
</Panel> </Panel>
)} )}
</VStack>
{open ? ( {open ? (
<UploadDetailDrawer <UploadDetailDrawer
@@ -514,11 +522,11 @@ export function UploadsPanel({
} }
/** The tabs, and the order they read in. */ /** The tabs, and the order they read in. */
const TABS: { id: UploadTab; label: string }[] = [ const TABS: { id: UploadTab; label: string; icon: React.ReactNode }[] = [
{ id: 'all', label: 'All uploads' }, { id: 'all', label: 'All uploads', icon: <Files size={14} /> },
{ id: 'done', label: 'Successful' }, { id: 'done', label: 'Successful', icon: <CheckCircle2 size={14} /> },
{ id: 'failed', label: 'Failed' }, { id: 'failed', label: 'Failed', icon: <XCircle size={14} /> },
{ id: 'running', label: 'In progress' }, { id: 'running', label: 'In progress', icon: <Loader2 size={14} /> },
]; ];
/** /**

View File

@@ -833,6 +833,11 @@ main {
align-items: start; align-items: start;
} }
.catalogue-main {
container-type: inline-size;
container-name: cataloguemain;
}
@media (min-width: 900px) { @media (min-width: 900px) {
/* The rail is fixed, the grid takes what is left — a rail that grows with the /* The rail is fixed, the grid takes what is left — a rail that grows with the
window steals width from the photographs, which are the content. */ window steals width from the photographs, which are the content. */
@@ -1288,15 +1293,18 @@ main {
cursor: default; cursor: default;
} }
@media (min-width: 560px) { @container cataloguemain (min-width: 440px) {
.catalogue-layout .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .catalogue-layout .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (min-width: 1100px) { @container cataloguemain (min-width: 600px) {
.catalogue-layout .product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .catalogue-layout .product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
} }
@media (min-width: 1400px) { @container cataloguemain (min-width: 780px) {
.catalogue-layout .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } .catalogue-layout .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
} }
@container cataloguemain (min-width: 1000px) {
.catalogue-layout .product-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
/* ──────────────────────────────────────────────────────────────────────────── /* ────────────────────────────────────────────────────────────────────────────
@@ -2109,7 +2117,7 @@ main {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px; gap: 16px;
align-items: start; align-items: stretch;
} }
@media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } }