feat(grouping): add per-invoice grouping with bulk collect/problem
Drivers can now switch the header toggle to 'Invoice' to see stops bucketed by PO within each supplier. Each PO sub-header is collapsible and shows the PO number + item count + total qty. Per-PO bulk actions (visible only when the supplier has more than one invoice): - Green 'All' button marks every item under that PO as collected in one tap (no per-item modal flow, expectedQty is auto-stamped). - Red flag button flags every item with a generic problem note. Implementation: - New 'po' groupMode in app/page.tsx with its own groupedByPO memo and expandedPOs Set state (auto-expanded on load, controlled by expandAll/collapseAll alongside supplier expansion). - SupplierGroup now accepts optional poGroups + handlers and renders PO sub-headers in compact mode. Each PO is rendered as a flat list of CompactItemRow subcomponents (extracted to share the row markup between flat-list and per-PO render paths). - bulkCollect / bulkProblem helpers in lib/pocketbase.ts loop the patch; per-row failures are logged and excluded from the count so one bad row doesn't poison the batch. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
5ef212cdfa
commit
8a51f4851b
2 changed files with 528 additions and 223 deletions
210
app/page.tsx
210
app/page.tsx
|
|
@ -6,7 +6,7 @@ import { OrderItem } from '@/lib/types';
|
|||
import { useAuth } from '@/components/AuthProvider';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import CollectionItemCard from '@/components/CollectionItemCard';
|
||||
import SupplierGroup from '@/components/SupplierGroup';
|
||||
import SupplierGroup, { POSubGroup } from '@/components/SupplierGroup';
|
||||
import AreaGroup from '@/components/AreaGroup';
|
||||
import CollectModal from '@/components/CollectModal';
|
||||
import ProblemModal from '@/components/ProblemModal';
|
||||
|
|
@ -21,6 +21,8 @@ import {
|
|||
isAuthenticated,
|
||||
logout,
|
||||
getItemDisplayQty,
|
||||
bulkCollect,
|
||||
bulkProblem,
|
||||
} from '@/lib/pocketbase';
|
||||
import { getCached, setCached } from '@/lib/itemsCache';
|
||||
import { cn, clusterByProximity } from '@/lib/utils';
|
||||
|
|
@ -34,6 +36,9 @@ import {
|
|||
X,
|
||||
Package,
|
||||
Layers,
|
||||
FileText,
|
||||
CheckCheck,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Cursor-harmonized supplier color palette: same warm cream surface
|
||||
|
|
@ -80,12 +85,15 @@ export default function CollectionsPage() {
|
|||
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' | 'area'>('supplier');
|
||||
const [groupMode, setGroupMode] = useState<'supplier' | 'po' | 'area'>('supplier');
|
||||
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());
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isLoggedIn) router.push('/login/');
|
||||
|
|
@ -128,6 +136,14 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
const clusters = clusterByProximity(data, 2);
|
||||
clusters.forEach(c => allAreaIds.add(c.id));
|
||||
setExpandedAreas(allAreaIds);
|
||||
// Auto-expand every PO sub-group on first load — drivers want to
|
||||
// see all of a PO's items at once. Per-PO collapse is opt-in.
|
||||
const allPoIds = new Set<string>();
|
||||
data.forEach(i => {
|
||||
const po = i.expand?.purchase_order;
|
||||
allPoIds.add(po?.id || '__no_po__');
|
||||
});
|
||||
setExpandedPOs(allPoIds);
|
||||
} catch (err) {
|
||||
console.error('Failed to load collections:', err);
|
||||
setError('Failed to load collections. Pull down to retry.');
|
||||
|
|
@ -273,6 +289,53 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Bulk actions — used by the per-PO "Mark all" buttons in the Invoice
|
||||
// group mode. Skips the per-item modal flow and stamps every row in
|
||||
// the list straight to its terminal status. The driver is presumed
|
||||
// to have visually confirmed the items under the PO sub-header.
|
||||
//
|
||||
// On success the affected rows are removed from local state so the
|
||||
// Assigned-tab list shrinks immediately without waiting for the next
|
||||
// refetch — drivers want instant feedback that the action landed.
|
||||
const handleBulkCollect = async (poLabel: string, itemsToCollect: OrderItem[]) => {
|
||||
if (itemsToCollect.length === 0) return;
|
||||
try {
|
||||
const result = await bulkCollect(itemsToCollect);
|
||||
const ids = new Set(itemsToCollect.map((i) => i.id));
|
||||
setItems((prev) => prev.filter((i) => !ids.has(i.id)));
|
||||
if (result.failed === 0) {
|
||||
showToast(`Collected ${result.ok} item${result.ok === 1 ? '' : 's'} · ${poLabel}`, 'success');
|
||||
} else {
|
||||
showToast(
|
||||
`Collected ${result.ok}, ${result.failed} failed · ${poLabel}`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Bulk collect failed · ${poLabel}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkProblem = async (poLabel: string, itemsToFlag: OrderItem[]) => {
|
||||
if (itemsToFlag.length === 0) return;
|
||||
const description = `Bulk-flagged from ${poLabel}`;
|
||||
try {
|
||||
const result = await bulkProblem(itemsToFlag, description);
|
||||
const ids = new Set(itemsToFlag.map((i) => i.id));
|
||||
setItems((prev) => prev.filter((i) => !ids.has(i.id)));
|
||||
if (result.failed === 0) {
|
||||
showToast(`Flagged ${result.ok} item${result.ok === 1 ? '' : 's'} · ${poLabel}`, 'warning');
|
||||
} else {
|
||||
showToast(
|
||||
`Flagged ${result.ok}, ${result.failed} failed · ${poLabel}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Bulk flag failed · ${poLabel}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (lat: number, lng: number, name: string) => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const url = isIOS
|
||||
|
|
@ -304,8 +367,17 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
});
|
||||
};
|
||||
|
||||
const togglePO = (poId: string) => {
|
||||
setExpandedPOs(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(poId)) next.delete(poId);
|
||||
else next.add(poId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
if (groupMode === 'supplier') {
|
||||
if (groupMode === 'supplier' || groupMode === 'po') {
|
||||
const allIds = new Set<string>();
|
||||
filteredItems.forEach(i => {
|
||||
if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id);
|
||||
|
|
@ -316,14 +388,27 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
areaClusters.forEach(c => allIds.add(c.id));
|
||||
setExpandedAreas(allIds);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
if (groupMode === 'supplier') {
|
||||
if (groupMode === 'supplier' || groupMode === 'po') {
|
||||
setExpandedSuppliers(new Set());
|
||||
} else {
|
||||
setExpandedAreas(new Set());
|
||||
}
|
||||
if (groupMode === 'po') {
|
||||
setExpandedPOs(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// Group by supplier - items without supplier are grouped under "Unknown"
|
||||
|
|
@ -341,6 +426,51 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
);
|
||||
}, [filteredItems]);
|
||||
|
||||
// Group by supplier, then by PO within each supplier. Used by the
|
||||
// 'po' groupMode so the driver can see (and bulk-collect) one PO at
|
||||
// a time even when a single supplier has multiple invoices.
|
||||
//
|
||||
// Shape:
|
||||
// [{ supplier, pos: [{ poId, poNumber, items: [...] }, ...] }, ...]
|
||||
//
|
||||
// Items without a PO are bucketed under '__no_po__' so they still
|
||||
// surface — better to render "No PO" than to silently drop them.
|
||||
const groupedByPO = useMemo(() => {
|
||||
const supplierBuckets: Record<string, {
|
||||
supplier: NonNullable<OrderItem['expand']>['supplier'] | null;
|
||||
pos: Record<string, { poId: string; poNumber: string; items: OrderItem[] }>;
|
||||
}> = {};
|
||||
|
||||
filteredItems.forEach((item) => {
|
||||
const supplier = item.expand?.supplier ?? null;
|
||||
const supplierKey = supplier?.id || '__unknown__';
|
||||
if (!supplierBuckets[supplierKey]) {
|
||||
supplierBuckets[supplierKey] = { supplier, pos: {} };
|
||||
}
|
||||
const po = item.expand?.purchase_order;
|
||||
const poKey = po?.id || '__no_po__';
|
||||
if (!supplierBuckets[supplierKey].pos[poKey]) {
|
||||
supplierBuckets[supplierKey].pos[poKey] = {
|
||||
poId: poKey,
|
||||
poNumber: po?.po_number || 'No PO',
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
supplierBuckets[supplierKey].pos[poKey].items.push(item);
|
||||
});
|
||||
|
||||
return Object.values(supplierBuckets)
|
||||
.sort((a, b) =>
|
||||
(a.supplier?.suppliername || 'zzz').localeCompare(b.supplier?.suppliername || 'zzz')
|
||||
)
|
||||
.map((bucket) => ({
|
||||
supplier: bucket.supplier,
|
||||
pos: Object.values(bucket.pos).sort((a, b) =>
|
||||
a.poNumber.localeCompare(b.poNumber, undefined, { numeric: true })
|
||||
),
|
||||
}));
|
||||
}, [filteredItems]);
|
||||
|
||||
// Group by geographic proximity — items within 2km share a cluster color
|
||||
const areaClusters = useMemo(() => {
|
||||
return clusterByProximity(filteredItems, 2);
|
||||
|
|
@ -460,6 +590,18 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
<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'}
|
||||
|
|
@ -595,28 +737,7 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groupMode === 'supplier' ? (
|
||||
groupedArray.map(({ supplier, items: supplierItems }, groupIdx) => {
|
||||
const color = SUPPLIER_COLORS[groupIdx % SUPPLIER_COLORS.length];
|
||||
const isExpanded = expandedSuppliers.has(supplier!.id);
|
||||
|
||||
return (
|
||||
<SupplierGroup
|
||||
key={supplier!.id}
|
||||
supplier={supplier!}
|
||||
items={supplierItems}
|
||||
color={color}
|
||||
isExpanded={isExpanded}
|
||||
viewMode={viewMode}
|
||||
onToggle={() => toggleSupplier(supplier!.id)}
|
||||
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
||||
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
||||
onNavigate={handleNavigate}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
{groupMode === 'area' ? (
|
||||
areaClusters.map((cluster, clusterIdx) => {
|
||||
const color = SUPPLIER_COLORS[clusterIdx % SUPPLIER_COLORS.length];
|
||||
const isExpanded = expandedAreas.has(cluster.id);
|
||||
|
|
@ -639,6 +760,43 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Supplier + Invoice group modes share the SupplierGroup
|
||||
// component; the PO sub-group render only kicks in when
|
||||
// poGroups is non-empty (i.e. when groupMode === 'po').
|
||||
// For 'supplier' mode the group carries items; for 'po'
|
||||
// mode items is empty and pos carries the sub-buckets.
|
||||
(() => {
|
||||
const supplierGroups = groupMode === 'po'
|
||||
? groupedByPO.map((g) => ({ supplier: g.supplier, items: [] as OrderItem[], pos: g.pos }))
|
||||
: groupedArray.map((g) => ({ supplier: g.supplier, items: g.items, pos: [] as POSubGroup[] }));
|
||||
return supplierGroups.map((group, groupIdx) => {
|
||||
const color = SUPPLIER_COLORS[groupIdx % SUPPLIER_COLORS.length];
|
||||
const isExpanded = expandedSuppliers.has(group.supplier!.id);
|
||||
const isPoMode = groupMode === 'po';
|
||||
|
||||
return (
|
||||
<SupplierGroup
|
||||
key={group.supplier!.id}
|
||||
supplier={group.supplier!}
|
||||
items={group.items}
|
||||
color={color}
|
||||
isExpanded={isExpanded}
|
||||
viewMode={viewMode}
|
||||
onToggle={() => toggleSupplier(group.supplier!.id)}
|
||||
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
||||
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
||||
onNavigate={handleNavigate}
|
||||
onShowDetails={handleShowDetails}
|
||||
poGroups={isPoMode ? group.pos : undefined}
|
||||
expandedPOs={isPoMode ? expandedPOs : undefined}
|
||||
onTogglePO={isPoMode ? togglePO : undefined}
|
||||
onBulkCollect={isPoMode ? handleBulkCollect : undefined}
|
||||
onBulkProblem={isPoMode ? handleBulkProblem : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
FileText,
|
||||
MessageSquare,
|
||||
ExternalLink,
|
||||
CheckCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SupplierColor {
|
||||
|
|
@ -27,6 +28,15 @@ interface SupplierColor {
|
|||
ring: string;
|
||||
}
|
||||
|
||||
// PO sub-group rendered inside a supplier section when groupMode is
|
||||
// 'po'. Each sub-group is collapsible, has a "mark all" affordance,
|
||||
// and shows the items it owns as the standard compact rows.
|
||||
export interface POSubGroup {
|
||||
poId: string;
|
||||
poNumber: string;
|
||||
items: OrderItem[];
|
||||
}
|
||||
|
||||
interface SupplierGroupProps {
|
||||
supplier: Supplier;
|
||||
items: OrderItem[];
|
||||
|
|
@ -38,6 +48,15 @@ interface SupplierGroupProps {
|
|||
onProblem: (item: OrderItem) => void;
|
||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
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
|
||||
// target the items inside the tapped sub-group.
|
||||
poGroups?: POSubGroup[];
|
||||
expandedPOs?: Set<string>;
|
||||
onTogglePO?: (poId: string) => void;
|
||||
onBulkCollect?: (poLabel: string, items: OrderItem[]) => void;
|
||||
onBulkProblem?: (poLabel: string, items: OrderItem[]) => void;
|
||||
}
|
||||
|
||||
function getItemIcon(item: OrderItem) {
|
||||
|
|
@ -49,6 +68,227 @@ function getItemIcon(item: OrderItem) {
|
|||
return <ShoppingCart size={12} className="text-gray-400" />;
|
||||
}
|
||||
|
||||
// Compact row markup extracted so the flat-list render and the
|
||||
// per-PO sub-group render can both share it without duplicating ~180
|
||||
// lines of popover + tooltip + button code. Tooltip state lives one
|
||||
// level up so click-outside detection on the wrapping <ul> still works
|
||||
// when rows are nested inside multiple PO sub-groups.
|
||||
interface CompactItemRowProps {
|
||||
item: OrderItem;
|
||||
activeTooltip: { itemId: string; type: 'po' | 'notes' } | null;
|
||||
setActiveTooltip: (t: { itemId: string; type: 'po' | 'notes' } | null) => void;
|
||||
rowPlacement: 'down' | 'up';
|
||||
measureRowPlacement: (itemId: string, btn: HTMLElement | null) => void;
|
||||
onShowDetails: (item: OrderItem) => void;
|
||||
onCollect: (item: OrderItem) => void;
|
||||
onProblem: (item: OrderItem) => void;
|
||||
}
|
||||
|
||||
function CompactItemRow({
|
||||
item,
|
||||
activeTooltip,
|
||||
setActiveTooltip,
|
||||
rowPlacement,
|
||||
measureRowPlacement,
|
||||
onShowDetails,
|
||||
onCollect,
|
||||
onProblem,
|
||||
}: CompactItemRowProps) {
|
||||
const expectedQty = item.order_qty || item.quantity || 0;
|
||||
const po = item.expand?.purchase_order;
|
||||
const poNumber = po?.po_number;
|
||||
const orderNumber = item.expand?.order?.order_number;
|
||||
const hasNotes = Boolean(item.notes);
|
||||
const hasProblemNotes = Boolean(item.problem_notes);
|
||||
const hasAnyNotes = hasNotes || hasProblemNotes;
|
||||
const isPoOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'po';
|
||||
const isNotesOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'notes';
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'relative flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors',
|
||||
// z-[80] when this row holds the open popover so the popover
|
||||
// paints above the BottomNav (z-50) *and* above sibling rows.
|
||||
(isPoOpen || isNotesOpen) ? 'z-[80]' : 'z-10'
|
||||
)}
|
||||
>
|
||||
{/* Left: icon + description */}
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
{getItemIcon(item)}
|
||||
<span className="text-sm text-[#26251e] truncate">
|
||||
{item.description || 'Unknown item'}
|
||||
</span>
|
||||
{item.problem_flag && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[#fbeaef] text-[#8a1b3a] font-medium flex-shrink-0">
|
||||
Problem
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: qty + FileText + Collect/Problem */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
|
||||
<span className="text-sm font-semibold text-[rgba(38,37,30,0.7)]">{expectedQty}×</span>
|
||||
|
||||
{/* PO FileText button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
measureRowPlacement(item.id, e.currentTarget);
|
||||
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
|
||||
}}
|
||||
aria-label="Show PO details"
|
||||
aria-expanded={isPoOpen}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors"
|
||||
>
|
||||
<FileText size={11} aria-hidden="true" />
|
||||
</button>
|
||||
{/* PO tooltip popover */}
|
||||
{isPoOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 z-[60] w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3',
|
||||
rowPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[11px] font-semibold text-[#26251e] uppercase tracking-wide">Purchase Order</p>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); }}
|
||||
aria-label="Close"
|
||||
className="w-5 h-5 rounded flex items-center justify-center hover:bg-[rgba(38,37,30,0.08)]"
|
||||
>
|
||||
<span className="text-[rgba(38,37,30,0.4)] text-sm">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{po ? (
|
||||
<>
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[rgba(38,37,30,0.55)]">PO Number</dt>
|
||||
<dd className="text-[#26251e] font-medium font-mono">{po.po_number}</dd>
|
||||
</div>
|
||||
{po.status && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[rgba(38,37,30,0.55)]">Status</dt>
|
||||
<dd className="text-[#26251e] font-medium capitalize">{po.status}</dd>
|
||||
</div>
|
||||
)}
|
||||
{po.total_amount != null && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[rgba(38,37,30,0.55)]">Total</dt>
|
||||
<dd className="text-[#26251e] font-medium">R{(po.total_amount / 100).toFixed(2)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{orderNumber && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[rgba(38,37,30,0.55)]">Order</dt>
|
||||
<dd className="text-[#26251e] font-medium">{orderNumber}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); onShowDetails(item); }}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-[#6c5a99] text-white text-[12px] font-semibold active:bg-[#4a3d6b] transition-colors"
|
||||
>
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
View PO & Document
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-[rgba(38,37,30,0.55)]">No PO attached</p>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); onShowDetails(item); }}
|
||||
className="mt-2 text-[11px] text-[#6c5a99] font-medium hover:underline"
|
||||
>
|
||||
View details
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes MessageSquare button — only when notes exist */}
|
||||
{hasAnyNotes && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
measureRowPlacement(item.id, e.currentTarget);
|
||||
setActiveTooltip(isNotesOpen ? null : { itemId: item.id, type: 'notes' });
|
||||
}}
|
||||
aria-label="Show notes"
|
||||
aria-expanded={isNotesOpen}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors"
|
||||
>
|
||||
<MessageSquare size={11} aria-hidden="true" />
|
||||
</button>
|
||||
{/* Notes tooltip popover */}
|
||||
{isNotesOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 z-[60] w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3',
|
||||
rowPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[11px] font-semibold text-[#26251e] uppercase tracking-wide">Buyer Notes</p>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); }}
|
||||
aria-label="Close"
|
||||
className="w-5 h-5 rounded flex items-center justify-center hover:bg-[rgba(38,37,30,0.08)]"
|
||||
>
|
||||
<span className="text-[rgba(38,37,30,0.4)] text-sm">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{hasNotes ? (
|
||||
<>
|
||||
<p className="text-[11px] text-[#8a5a02] bg-[#fcf3d8] border border-[#f6e3a4] rounded-lg p-2 selectable leading-snug">
|
||||
{item.notes}
|
||||
</p>
|
||||
{hasProblemNotes && (
|
||||
<p className="text-[11px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-lg p-2 mt-2 selectable leading-snug">
|
||||
<span className="font-semibold">Problem: </span>{item.problem_notes}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : hasProblemNotes ? (
|
||||
<p className="text-[11px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-lg p-2 selectable leading-snug">
|
||||
{item.problem_notes}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[rgba(38,37,30,0.55)] text-center py-1">No notes</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collect + Problem buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onCollect(item)}
|
||||
aria-label={`Collect ${item.description || 'item'}`}
|
||||
className="w-10 h-10 rounded-lg bg-[#1f8a65] text-white flex items-center justify-center active:bg-[#125a3e] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProblem(item)}
|
||||
aria-label={`Report problem for ${item.description || 'item'}`}
|
||||
className="w-10 h-10 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SupplierGroup({
|
||||
supplier,
|
||||
items,
|
||||
|
|
@ -60,6 +300,11 @@ export default function SupplierGroup({
|
|||
onProblem,
|
||||
onNavigate,
|
||||
onShowDetails,
|
||||
poGroups,
|
||||
expandedPOs,
|
||||
onTogglePO,
|
||||
onBulkCollect,
|
||||
onBulkProblem,
|
||||
}: SupplierGroupProps) {
|
||||
const coords = parseCoordinates(supplier.coordinates);
|
||||
const hasCoords = coords !== null;
|
||||
|
|
@ -185,210 +430,112 @@ export default function SupplierGroup({
|
|||
className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
|
||||
>
|
||||
{viewMode === 'compact' ? (
|
||||
/* COMPACT MODE: simple list with FileText + MessageSquare buttons */
|
||||
/* COMPACT MODE — flat list when no PO grouping, otherwise
|
||||
nested PO sub-groups so the driver sees one invoice at a
|
||||
time. The single <ul ref={listRef}> wrapper is preserved
|
||||
in both cases so the click-outside-tooltip handler still
|
||||
finds every row inside it. */
|
||||
<ul ref={listRef} className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
|
||||
{items.map((item) => {
|
||||
const expectedQty = item.order_qty || item.quantity || 0;
|
||||
const po = item.expand?.purchase_order;
|
||||
const poNumber = po?.po_number;
|
||||
const orderNumber = item.expand?.order?.order_number;
|
||||
const hasNotes = Boolean(item.notes);
|
||||
const hasProblemNotes = Boolean(item.problem_notes);
|
||||
const hasAnyNotes = hasNotes || hasProblemNotes;
|
||||
const isPoOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'po';
|
||||
const isNotesOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'notes';
|
||||
// Read per-row placement from the top-level map (no
|
||||
// hooks inside .map — would break the Rules of Hooks
|
||||
// if items length changed). Default 'down' until the
|
||||
// driver clicks the button and we measure.
|
||||
const rowPlacement = rowPlacements[item.id] ?? 'down';
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'relative flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors',
|
||||
// z-[80] when this row holds the open popover so
|
||||
// the popover paints above the BottomNav (z-50)
|
||||
// *and* above sibling rows in this same list.
|
||||
(isPoOpen || isNotesOpen) ? 'z-[80]' : 'z-10'
|
||||
)}
|
||||
>
|
||||
{/* Left: icon + description */}
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
{getItemIcon(item)}
|
||||
<span className="text-sm text-[#26251e] truncate">
|
||||
{item.description || 'Unknown item'}
|
||||
</span>
|
||||
{item.problem_flag && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[#fbeaef] text-[#8a1b3a] font-medium flex-shrink-0">
|
||||
Problem
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: qty + FileText + Collect/Problem */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
|
||||
<span className="text-sm font-semibold text-[rgba(38,37,30,0.7)]">{expectedQty}×</span>
|
||||
|
||||
{/* PO FileText button */}
|
||||
<div className="relative">
|
||||
{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 poLabel = `PO ${po.poNumber}`;
|
||||
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">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
measureRowPlacement(item.id, e.currentTarget);
|
||||
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
|
||||
}}
|
||||
aria-label="Show PO details"
|
||||
aria-expanded={isPoOpen}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors"
|
||||
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"
|
||||
>
|
||||
<FileText size={11} aria-hidden="true" />
|
||||
{poExpanded ? (
|
||||
<ChevronUp size={14} 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" />
|
||||
)}
|
||||
<FileText size={12} className="text-[#6c5a99] flex-shrink-0" />
|
||||
<span className="text-[12px] font-semibold text-[#26251e] font-mono truncate">
|
||||
{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>
|
||||
</button>
|
||||
{/* PO tooltip popover */}
|
||||
{isPoOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 z-[60] w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3',
|
||||
rowPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[11px] font-semibold text-[#26251e] uppercase tracking-wide">Purchase Order</p>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); }}
|
||||
aria-label="Close"
|
||||
className="w-5 h-5 rounded flex items-center justify-center hover:bg-[rgba(38,37,30,0.08)]"
|
||||
>
|
||||
<span className="text-[rgba(38,37,30,0.4)] text-sm">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{po ? (
|
||||
<>
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[rgba(38,37,30,0.55)]">PO Number</dt>
|
||||
<dd className="text-[#26251e] font-medium font-mono">{po.po_number}</dd>
|
||||
</div>
|
||||
{po.status && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[rgba(38,37,30,0.55)]">Status</dt>
|
||||
<dd className="text-[#26251e] font-medium capitalize">{po.status}</dd>
|
||||
</div>
|
||||
)}
|
||||
{po.total_amount != null && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[rgba(38,37,30,0.55)]">Total</dt>
|
||||
<dd className="text-[#26251e] font-medium">R{(po.total_amount / 100).toFixed(2)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{orderNumber && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[rgba(38,37,30,0.55)]">Order</dt>
|
||||
<dd className="text-[#26251e] font-medium">{orderNumber}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); onShowDetails(item); }}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-[#6c5a99] text-white text-[12px] font-semibold active:bg-[#4a3d6b] transition-colors"
|
||||
>
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
View PO & Document
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-[rgba(38,37,30,0.55)]">No PO attached</p>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); onShowDetails(item); }}
|
||||
className="mt-2 text-[11px] text-[#6c5a99] font-medium hover:underline"
|
||||
>
|
||||
View details
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes MessageSquare button — only when notes exist */}
|
||||
{hasAnyNotes && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
measureRowPlacement(item.id, e.currentTarget);
|
||||
setActiveTooltip(isNotesOpen ? null : { itemId: item.id, type: 'notes' });
|
||||
}}
|
||||
aria-label="Show notes"
|
||||
aria-expanded={isNotesOpen}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors"
|
||||
>
|
||||
<MessageSquare size={11} aria-hidden="true" />
|
||||
</button>
|
||||
{/* Notes tooltip popover */}
|
||||
{isNotesOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 z-[60] w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3',
|
||||
rowPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[11px] font-semibold text-[#26251e] uppercase tracking-wide">Buyer Notes</p>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); }}
|
||||
aria-label="Close"
|
||||
className="w-5 h-5 rounded flex items-center justify-center hover:bg-[rgba(38,37,30,0.08)]"
|
||||
>
|
||||
<span className="text-[rgba(38,37,30,0.4)] text-sm">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{hasNotes ? (
|
||||
<>
|
||||
<p className="text-[11px] text-[#8a5a02] bg-[#fcf3d8] border border-[#f6e3a4] rounded-lg p-2 selectable leading-snug">
|
||||
{item.notes}
|
||||
</p>
|
||||
{hasProblemNotes && (
|
||||
<p className="text-[11px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-lg p-2 mt-2 selectable leading-snug">
|
||||
<span className="font-semibold">Problem: </span>{item.problem_notes}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : hasProblemNotes ? (
|
||||
<p className="text-[11px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-lg p-2 selectable leading-snug">
|
||||
{item.problem_notes}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[rgba(38,37,30,0.55)] text-center py-1">No notes</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collect + Problem buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onCollect(item)}
|
||||
aria-label={`Collect ${item.description || 'item'}`}
|
||||
className="w-10 h-10 rounded-lg bg-[#1f8a65] text-white flex items-center justify-center active:bg-[#125a3e] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProblem(item)}
|
||||
aria-label={`Report problem for ${item.description || 'item'}`}
|
||||
className="w-10 h-10 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</li>,
|
||||
poExpanded
|
||||
? po.items.map((item) => (
|
||||
<CompactItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
activeTooltip={activeTooltip}
|
||||
setActiveTooltip={setActiveTooltip}
|
||||
rowPlacement={rowPlacements[item.id] ?? 'down'}
|
||||
measureRowPlacement={measureRowPlacement}
|
||||
onShowDetails={onShowDetails}
|
||||
onCollect={onCollect}
|
||||
onProblem={onProblem}
|
||||
/>
|
||||
))
|
||||
: [],
|
||||
// Spacer between PO groups so they read as distinct
|
||||
// blocks rather than one continuous list.
|
||||
poIdx < poGroups.length - 1 ? (
|
||||
<li
|
||||
key={`po-spacer-${po.poId}`}
|
||||
className="h-2 bg-[#f2f1ed]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null,
|
||||
];
|
||||
})
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<CompactItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
activeTooltip={activeTooltip}
|
||||
setActiveTooltip={setActiveTooltip}
|
||||
rowPlacement={rowPlacements[item.id] ?? 'down'}
|
||||
measureRowPlacement={measureRowPlacement}
|
||||
onShowDetails={onShowDetails}
|
||||
onCollect={onCollect}
|
||||
onProblem={onProblem}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
/* DETAILED MODE: full item cards */
|
||||
|
|
|
|||
Loading…
Reference in a new issue