feat: one layout — compact rows grouped by PO, no view switches

- Removed the Supplier/Invoice/Area and Compact/Detailed switches; the
  list is always compact and grouped supplier → PO. Six layouts behind
  small icons confused drivers.
- POs start open, so opening a supplier shows its items straight away.
- PO header: the full PO number and item count on line one; the PO /
  Invoice / Collect all / flag buttons on line two (bigger, 36px) only
  while the PO is open. The number used to be squeezed to "PO2026…" or
  hidden by the buttons.
- Collect all / flag-all only show when the PO has more than one item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-18 10:31:41 +02:00
parent b9a112881d
commit 050b49e75c
3 changed files with 65 additions and 141 deletions

View file

@ -41,8 +41,6 @@ import {
Search,
X,
Package,
Layers,
FileText,
CheckCheck,
AlertTriangle,
} from 'lucide-react';
@ -90,16 +88,19 @@ export default function CollectionsPage() {
const [showDetailModal, setShowDetailModal] = useState(false);
const [showProblemModal, setShowProblemModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact');
const [groupMode, setGroupMode] = useState<'supplier' | 'po' | 'area'>('supplier');
// One layout for every driver: compact rows, grouped supplier → PO.
// The supplier/invoice/area and compact/detailed switches were removed
// on 2026-10-18 — six layouts behind small icons just confused drivers.
const [viewMode] = useState<'compact' | 'detailed'>('compact');
const [groupMode] = useState<'supplier' | 'po' | 'area'>('po');
const [statusFilter, setStatusFilter] = useState<'assigned' | 'collection_pool' | 'problem'>('assigned');
// Track which supplier groups are expanded
const [expandedSuppliers, setExpandedSuppliers] = useState<Set<string>>(new Set());
const [expandedAreas, setExpandedAreas] = useState<Set<string>>(new Set());
// Per-PO expand state for 'po' groupMode — PO sub-headers can be
// collapsed independently of the supplier they're inside.
const [expandedPOs, setExpandedPOs] = useState<Set<string>>(new Set());
// POs the driver has folded away. POs start open, so opening a
// supplier shows its items straight away instead of one more tap per PO.
const [collapsedPOs, setCollapsedPOs] = useState<Set<string>>(new Set());
// Mirror items.length into a ref so loadItems can decide whether to
// flash the loading skeleton WITHOUT making items.length a callback
@ -171,7 +172,7 @@ const loadItems = useCallback(async (showLoading = true) => {
// driver wants everything expanded at once.
setExpandedSuppliers(new Set());
setExpandedAreas(new Set());
setExpandedPOs(new Set());
setCollapsedPOs(new Set());
} catch (err) {
console.error('Failed to load collections:', err);
setError('Failed to load collections. Pull down to retry.');
@ -497,7 +498,7 @@ const loadItems = useCallback(async (showLoading = true) => {
};
const togglePO = (poId: string) => {
setExpandedPOs(prev => {
setCollapsedPOs(prev => {
const next = new Set(prev);
if (next.has(poId)) next.delete(poId);
else next.add(poId);
@ -519,14 +520,7 @@ const loadItems = useCallback(async (showLoading = true) => {
}
// For PO mode, expandAll also opens every PO sub-header so the
// driver sees the full breakdown without an extra tap.
if (groupMode === 'po') {
const allPoIds = new Set<string>();
filteredItems.forEach(i => {
const po = i.expand?.purchase_order;
allPoIds.add(po?.id || '__no_po__');
});
setExpandedPOs(allPoIds);
}
if (groupMode === 'po') setCollapsedPOs(new Set());
};
const collapseAll = () => {
@ -536,7 +530,9 @@ const loadItems = useCallback(async (showLoading = true) => {
setExpandedAreas(new Set());
}
if (groupMode === 'po') {
setExpandedPOs(new Set());
const allPoIds = new Set<string>();
filteredItems.forEach(i => allPoIds.add(i.expand?.purchase_order?.id || '__no_po__'));
setCollapsedPOs(allPoIds);
}
};
@ -698,81 +694,6 @@ const loadItems = useCallback(async (showLoading = true) => {
)}
</div>
{/* View toggle — buttons stay icon-only on the smallest screens
(label hidden at <sm) to keep the row from jumbling. */}
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap">
{/* Group mode toggle */}
<div
className="flex items-center gap-0.5 bg-[rgba(38,37,30,0.06)] rounded-lg p-0.5"
role="group"
aria-label="Group by"
>
<button
onClick={() => setGroupMode('supplier')}
aria-pressed={groupMode === 'supplier'}
aria-label="Group by supplier"
className={cn(
'px-2 py-1 rounded-md text-[11px] font-semibold transition-all flex items-center gap-1',
groupMode === 'supplier' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
<MapPin size={10} aria-hidden="true" />
<span className="hidden xs:inline sm:inline">Supplier</span>
</button>
<button
onClick={() => setGroupMode('po')}
aria-pressed={groupMode === 'po'}
aria-label="Group by invoice / purchase order"
className={cn(
'px-2 py-1 rounded-md text-[11px] font-semibold transition-all flex items-center gap-1',
groupMode === 'po' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
<FileText size={10} aria-hidden="true" />
<span className="hidden xs:inline sm:inline">Invoice</span>
</button>
<button
onClick={() => setGroupMode('area')}
aria-pressed={groupMode === 'area'}
aria-label="Group by area"
className={cn(
'px-2 py-1 rounded-md text-[11px] font-semibold transition-all flex items-center gap-1',
groupMode === 'area' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
<Layers size={10} aria-hidden="true" />
<span className="hidden xs:inline sm:inline">Area</span>
</button>
</div>
{/* View density toggle */}
<div
className="flex items-center gap-1 bg-[rgba(38,37,30,0.06)] rounded-lg p-0.5"
role="group"
aria-label="Item density"
>
<button
onClick={() => setViewMode('compact')}
aria-pressed={viewMode === 'compact'}
className={cn(
'px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all',
viewMode === 'compact' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
Compact
</button>
<button
onClick={() => { setViewMode('detailed'); expandAll(); }}
aria-pressed={viewMode === 'detailed'}
className={cn(
'px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all',
viewMode === 'detailed' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
Detailed
</button>
</div>
</div>
</div>
{/* Status filter chips — show counts of each state so drivers
@ -918,7 +839,7 @@ const loadItems = useCallback(async (showLoading = true) => {
onNavigate={handleNavigate}
onShowDetails={handleShowDetails}
poGroups={isPoMode ? group.pos : undefined}
expandedPOs={isPoMode ? expandedPOs : undefined}
collapsedPOs={isPoMode ? collapsedPOs : undefined}
onTogglePO={isPoMode ? togglePO : undefined}
onBulkCollect={isPoMode ? handleBulkCollect : undefined}
onBulkProblem={isPoMode ? handleBulkProblem : undefined}

View file

@ -59,8 +59,8 @@ export default function PoActions({
const lg = size === 'lg';
const btn = lg
? 'h-12 w-full rounded-xl text-[15px] font-semibold flex items-center justify-center gap-2 transition-colors touch-manipulation'
: 'h-7 px-2 rounded-md text-[11px] font-semibold flex items-center gap-1 transition-colors touch-manipulation';
const icon = lg ? 18 : 11;
: 'h-9 px-3 rounded-lg text-[13px] font-semibold flex items-center gap-1.5 transition-colors touch-manipulation';
const icon = lg ? 18 : 14;
return (
<div className={cn(lg ? 'flex flex-col gap-2' : 'flex items-center gap-1 flex-shrink-0', className)}>

View file

@ -52,10 +52,10 @@ interface SupplierGroupProps {
onShowDetails: (item: OrderItem) => void;
// When provided, the supplier section renders its items bucketed by
// PO sub-group instead of as a single flat list. Each sub-group can
// be collapsed independently via expandedPOs, and bulk actions
// be collapsed independently via collapsedPOs, and bulk actions
// target the items inside the tapped sub-group.
poGroups?: POSubGroup[];
expandedPOs?: Set<string>;
collapsedPOs?: Set<string>;
onTogglePO?: (poId: string) => void;
onBulkCollect?: (poLabel: string, items: OrderItem[]) => void;
onBulkProblem?: (poLabel: string, items: OrderItem[]) => void;
@ -267,7 +267,7 @@ export default function SupplierGroup({
onNavigate,
onShowDetails,
poGroups,
expandedPOs,
collapsedPOs,
onTogglePO,
onBulkCollect,
onBulkProblem,
@ -407,67 +407,70 @@ export default function SupplierGroup({
<ul ref={listRef} className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
{showPoHeaders && poGroups && onTogglePO ? (
poGroups.flatMap((po, poIdx) => {
const poExpanded = expandedPOs?.has(po.poId) ?? true;
const poTotalQty = po.items.reduce(
(s, i) => s + (i.order_qty || i.quantity || 0),
0
);
const poExpanded = !collapsedPOs?.has(po.poId);
const poLabel = `PO ${po.poNumber}`;
const showBulk = poGroups.length > 1 && po.items.length > 1;
return [
<li
key={`po-header-${po.poId}`}
className="bg-[#ebeae5] border-y border-[rgba(38,37,30,0.06)]"
aria-label={`Invoice ${po.poNumber}`}
>
<div className="flex items-center gap-2 px-3 py-2">
{/* Line 1: the PO number in full (it used to be squeezed
to "PO2026…" or hidden by the buttons). Line 2, only
while the PO is open: the PO's buttons. */}
<div className="px-3 py-2">
<button
onClick={() => onTogglePO(po.poId)}
aria-expanded={poExpanded}
aria-controls={`po-${po.poId}-items`}
className="flex items-center gap-1.5 min-w-0 flex-1 text-left active:bg-[rgba(38,37,30,0.04)] rounded-md py-1 -mx-1 px-1"
className="w-full flex items-center gap-2 text-left active:bg-[rgba(38,37,30,0.04)] rounded-md py-1.5 -mx-1 px-1 touch-manipulation"
>
{poExpanded ? (
<ChevronUp size={14} className="text-[rgba(38,37,30,0.55)] flex-shrink-0" />
<ChevronUp size={16} className="text-[rgba(38,37,30,0.55)] flex-shrink-0" />
) : (
<ChevronDown size={14} className="text-[rgba(38,37,30,0.55)] flex-shrink-0" />
<ChevronDown size={16} className="text-[rgba(38,37,30,0.55)] flex-shrink-0" />
)}
<FileText size={12} className="text-[#6c5a99] flex-shrink-0" />
<span className="text-[12px] font-semibold text-[#26251e] font-mono truncate">
<FileText size={14} className="text-[#6c5a99] flex-shrink-0" />
<span className="text-[14px] font-semibold text-[#26251e] font-mono break-all">
{po.poNumber}
</span>
<span className="text-[11px] text-[rgba(38,37,30,0.55)] flex-shrink-0">
· {po.items.length} {po.items.length === 1 ? 'item' : 'items'}{poTotalQty > 0 ? ` · ${poTotalQty} total` : ''}
<span className="ml-auto text-[12px] text-[rgba(38,37,30,0.6)] flex-shrink-0 whitespace-nowrap">
{po.items.length} {po.items.length === 1 ? 'item' : 'items'}
</span>
</button>
{po.poId !== '__no_po__' && (
<PoActions
purchaseOrder={po.items[0]?.expand?.purchase_order ?? null}
items={po.items}
onAddInvoicePage={onAddInvoicePage ?? noUpload}
canPhoto={Boolean(onAddInvoicePage)}
/>
)}
{/* Per-PO bulk actions — only rendered when the
supplier actually has more than one PO (a
single-PO supplier renders the flat list
above, no point in redundant bulk buttons). */}
{poGroups.length > 1 && poExpanded && (
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => onBulkCollect?.(poLabel, po.items)}
aria-label={`Mark all ${po.items.length} items on ${poLabel} collected`}
className="h-7 px-2 rounded-md bg-[#1f8a65] text-white text-[11px] font-semibold flex items-center gap-1 active:bg-[#125a3e] transition-colors touch-manipulation"
>
<CheckCheck size={11} aria-hidden="true" />
<span>All</span>
</button>
<button
onClick={() => onBulkProblem?.(poLabel, po.items)}
aria-label={`Flag all ${po.items.length} items on ${poLabel}`}
className="h-7 w-7 rounded-md bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation"
>
<AlertTriangle size={12} aria-hidden="true" />
</button>
{poExpanded && (po.poId !== '__no_po__' || showBulk) && (
<div className="flex items-center justify-end flex-wrap gap-1.5 mt-1.5">
{po.poId !== '__no_po__' && (
<PoActions
purchaseOrder={po.items[0]?.expand?.purchase_order ?? null}
items={po.items}
onAddInvoicePage={onAddInvoicePage ?? noUpload}
canPhoto={Boolean(onAddInvoicePage)}
/>
)}
{/* Per-PO bulk actions — only when the supplier
has more than one PO and this PO has more than
one item (otherwise the row's own buttons do it). */}
{showBulk && (
<>
<button
onClick={() => onBulkCollect?.(poLabel, po.items)}
aria-label={`Mark all ${po.items.length} items on ${poLabel} collected`}
className="h-9 px-3 rounded-lg bg-[#1f8a65] text-white text-[13px] font-semibold flex items-center gap-1.5 active:bg-[#125a3e] transition-colors touch-manipulation"
>
<CheckCheck size={14} aria-hidden="true" />
<span>Collect all</span>
</button>
<button
onClick={() => onBulkProblem?.(poLabel, po.items)}
aria-label={`Flag all ${po.items.length} items on ${poLabel}`}
className="h-9 w-9 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation"
>
<AlertTriangle size={14} aria-hidden="true" />
</button>
</>
)}
</div>
)}
</div>