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:
parent
b9a112881d
commit
050b49e75c
3 changed files with 65 additions and 141 deletions
109
app/page.tsx
109
app/page.tsx
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue