whole ui update
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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); } }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user