whole ui update
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Switch } from '@astryxdesign/core/Switch';
|
||||
@@ -60,6 +61,8 @@ export interface CatalogueBrowserProps {
|
||||
isReadOnly?: boolean;
|
||||
/** Why importing is unavailable, if it is. */
|
||||
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,
|
||||
blockedReason,
|
||||
isReadOnly,
|
||||
alignWithTabs,
|
||||
}: CatalogueBrowserProps) {
|
||||
const client = useQueryClient();
|
||||
|
||||
@@ -132,6 +136,13 @@ export function CatalogueBrowser({
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -461,33 +472,56 @@ export function CatalogueBrowser({
|
||||
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'}
|
||||
icon={<Funnel size={15} />}
|
||||
variant={isFiltersOpen ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<SearchInput
|
||||
label="Search the catalogue"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const controls = (
|
||||
<>
|
||||
<div style={{ pointerEvents: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<IconButton
|
||||
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
|
||||
icon={<Funnel size={15} />}
|
||||
variant={isFiltersOpen ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||
/>
|
||||
<div style={{ width: 216, minWidth: 0 }}>
|
||||
<SearchInput
|
||||
label="Search the catalogue"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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} />
|
||||
) : null}
|
||||
</HStack>
|
||||
<div style={{ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{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} />
|
||||
) : 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'}>
|
||||
{isFiltersOpen ? (
|
||||
@@ -507,7 +541,7 @@ export function CatalogueBrowser({
|
||||
/>
|
||||
) : 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
|
||||
the page size restated — a number that never changes and answers a
|
||||
question nobody asked. Where the page belongs in the whole is what
|
||||
|
||||
@@ -31,6 +31,7 @@ export function CataloguePanel() {
|
||||
{...(!tenantid || !locationid
|
||||
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
|
||||
: {})}
|
||||
alignWithTabs
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function StoreOpenControl({
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
label="Keep open"
|
||||
variant="ghost"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
@@ -175,7 +175,7 @@ export function StoreOpenControl({
|
||||
/>
|
||||
<Button
|
||||
label={save.isPending ? 'Closing…' : 'Close the branch'}
|
||||
variant="destructive"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={save.isPending}
|
||||
isDisabled={save.isPending}
|
||||
|
||||
@@ -22,9 +22,7 @@ import {
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||
import { matchesWindow, type WindowFilterKey } from '../deliveryWindow';
|
||||
import { groupByWindow } from '../dispatchModel';
|
||||
import { WindowFilterSelect } from '../WindowFilterSelect';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { StickyRow } from '@/components/StickyRow';
|
||||
@@ -101,15 +99,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
||||
const [detail, setDetail] = useState<Stop | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
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`. */
|
||||
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
|
||||
narrows the day and never drops a row.
|
||||
*/
|
||||
const filteredDeliveries = useMemo(
|
||||
() =>
|
||||
windowFilter === 'all'
|
||||
? rawDeliveries
|
||||
: rawDeliveries.filter((row) => matchesWindow(windowFilter, row)),
|
||||
[rawDeliveries, windowFilter],
|
||||
);
|
||||
const filteredDeliveries = rawDeliveries;
|
||||
|
||||
const isOneDay = Boolean(
|
||||
dates.range.fromdate && dates.range.fromdate === dates.range.todate,
|
||||
@@ -347,13 +331,6 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
||||
/>
|
||||
</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>
|
||||
</StickyRow>
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export function InventoryPage() {
|
||||
"N awaiting approval" badge went with it, because the Stock requests
|
||||
tab already carries that count and a number stated twice on one screen
|
||||
invites the reader to check whether the two agree. */
|
||||
actions={<div id="inventory-header-actions" style={{ display: 'flex', alignItems: 'center' }} />}
|
||||
tabs={
|
||||
/* 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 —
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
|
||||
@@ -78,9 +78,7 @@ export function UploadsPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PageHeader
|
||||
title="Uploads"
|
||||
/>
|
||||
|
||||
<UploadsPanel
|
||||
tenantid={user?.tenantid ?? 0}
|
||||
range={dates.range}
|
||||
|
||||
@@ -407,7 +407,7 @@ function RiderTable({
|
||||
<Th>Partner</Th>
|
||||
<Th>On shift</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -445,7 +445,7 @@ function RiderTable({
|
||||
tone={(rider.status ?? 'Active') === 'Active' ? 'success' : 'warning'}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Td align="right">
|
||||
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(rider)} />
|
||||
</Td>
|
||||
</tr>
|
||||
@@ -502,7 +502,7 @@ function StaffTable({
|
||||
<Th>Mobile</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -551,7 +551,7 @@ function StaffTable({
|
||||
<StatusChip status={person.status} />
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Td align="right">
|
||||
<span style={{ display: 'inline-flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
{person.issetup === false ? <ResendInvite userid={person.userid} /> : null}
|
||||
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
|
||||
@@ -608,7 +608,7 @@ function TillTable({
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Shift</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -648,7 +648,7 @@ function TillTable({
|
||||
<Td>
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td>
|
||||
<Td align="right">
|
||||
<Button
|
||||
label="Edit"
|
||||
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 (
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px 12px',
|
||||
textAlign: align,
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -778,20 +781,22 @@ function Td({
|
||||
children,
|
||||
isMuted,
|
||||
isStrong,
|
||||
align = 'left',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px 12px',
|
||||
textAlign: align,
|
||||
padding: '14px 16px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
verticalAlign: 'top',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
|
||||
@@ -33,9 +33,7 @@ export function StoreUploadsPage() {
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Uploads"
|
||||
/>
|
||||
|
||||
<UploadsPanel
|
||||
tenantid={user?.tenantid ?? 0}
|
||||
locationid={locationid}
|
||||
|
||||
@@ -33,9 +33,11 @@ import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
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 { SearchInput } from '@/components/SearchInput';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import {
|
||||
fetchBatch,
|
||||
@@ -332,45 +334,50 @@ export function UploadsPanel({
|
||||
|
||||
return (
|
||||
<VStack gap={2}>
|
||||
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
|
||||
<div className="up-toolbar">
|
||||
<TabBar size="sm" aria-label="Filter uploads">
|
||||
{TABS.map((entry) => (
|
||||
<Tab
|
||||
key={entry.id}
|
||||
label={entry.label}
|
||||
count={counts[entry.id]}
|
||||
isActive={tab === entry.id}
|
||||
onClick={() => setTab(entry.id)}
|
||||
{/* ── Headers ──────────────────────────────────────────────────────── */}
|
||||
<PageHeader
|
||||
isTabsInline
|
||||
title="Uploads"
|
||||
actions={
|
||||
onUpload ? (
|
||||
<Button
|
||||
label="Upload spreadsheet"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<UploadCloud size={14} />}
|
||||
onClick={onUpload}
|
||||
/>
|
||||
))}
|
||||
</TabBar>
|
||||
) : null
|
||||
}
|
||||
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" />
|
||||
|
||||
<SearchInput
|
||||
label="Search by file name"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="File name…"
|
||||
width={230}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Upload history"
|
||||
action={
|
||||
<SearchInput
|
||||
label="Search by file name"
|
||||
value={search}
|
||||
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 ─────────────────────────────────────────────────────── */}
|
||||
{shown.length === 0 ? (
|
||||
@@ -488,6 +495,7 @@ export function UploadsPanel({
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{open ? (
|
||||
<UploadDetailDrawer
|
||||
@@ -514,11 +522,11 @@ export function UploadsPanel({
|
||||
}
|
||||
|
||||
/** The tabs, and the order they read in. */
|
||||
const TABS: { id: UploadTab; label: string }[] = [
|
||||
{ id: 'all', label: 'All uploads' },
|
||||
{ id: 'done', label: 'Successful' },
|
||||
{ id: 'failed', label: 'Failed' },
|
||||
{ id: 'running', label: 'In progress' },
|
||||
const TABS: { id: UploadTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'all', label: 'All uploads', icon: <Files size={14} /> },
|
||||
{ id: 'done', label: 'Successful', icon: <CheckCircle2 size={14} /> },
|
||||
{ id: 'failed', label: 'Failed', icon: <XCircle size={14} /> },
|
||||
{ id: 'running', label: 'In progress', icon: <Loader2 size={14} /> },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -833,6 +833,11 @@ main {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.catalogue-main {
|
||||
container-type: inline-size;
|
||||
container-name: cataloguemain;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
/* 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. */
|
||||
@@ -1288,15 +1293,18 @@ main {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (min-width: 560px) {
|
||||
@container cataloguemain (min-width: 440px) {
|
||||
.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)); }
|
||||
}
|
||||
@media (min-width: 1400px) {
|
||||
@container cataloguemain (min-width: 780px) {
|
||||
.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;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user