Assigned tab was previously unfiltered by date — every assigned row in the system was shown, including items scheduled for the future (e.g. operator pre-assigns for tomorrow, driver sees them today). Live PB probe (2026-08-11) shows `assigned_at` is set to the SCHEDULED pickup date, not the moment of assignment: 15 currently assigned rows split evenly across Aug 18/19/20. New filter: `assigned_at` must fall within a rolling 2-day window in driver-local time (yesterday 00:00 → today 23:59:59.999). This covers today's work plus any carryover from yesterday that didn't get completed. Items scheduled for tomorrow or later stay hidden until their day rolls around. Window boundaries are computed in the driver's local timezone (this is a browser app, so `new Date()` is local) then converted to UTC ISO for the PB filter — works as long as driver + operator share a timezone, which is the case for the current single-region deployment. Empty-state copy updated so drivers understand why nothing might show even when they know there are upcoming pickups. check-pb-schema probe updated to match the new clauses so future regressions fail the build instead of silently showing everything.
899 lines
No EOL
38 KiB
TypeScript
899 lines
No EOL
38 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { OrderItem } from '@/lib/types';
|
|
import { useAuth } from '@/components/AuthProvider';
|
|
import BottomNav from '@/components/BottomNav';
|
|
import CollectionItemCard from '@/components/CollectionItemCard';
|
|
import SupplierGroup, { POSubGroup } from '@/components/SupplierGroup';
|
|
import AreaGroup from '@/components/AreaGroup';
|
|
import CollectModal from '@/components/CollectModal';
|
|
import ProblemModal from '@/components/ProblemModal';
|
|
import ItemDetailModal from '@/components/ItemDetailModal';
|
|
import {
|
|
getMyAssignedItems,
|
|
getMyAssignedStockItems,
|
|
getPoolItems,
|
|
getProblemItems,
|
|
updateCollectionStatus,
|
|
updateStockCollectionStatus,
|
|
uploadOrderItemAttachment,
|
|
reportProblem,
|
|
reportStockProblem,
|
|
isAuthenticated,
|
|
logout,
|
|
getItemDisplayQty,
|
|
bulkCollect,
|
|
bulkProblem,
|
|
bulkCollectStock,
|
|
bulkProblemStock,
|
|
} from '@/lib/pocketbase';
|
|
import { getCached, setCached } from '@/lib/itemsCache';
|
|
import { cn, clusterByProximity } from '@/lib/utils';
|
|
import {
|
|
RefreshCw,
|
|
LogOut,
|
|
MapPin,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Search,
|
|
X,
|
|
Package,
|
|
Layers,
|
|
FileText,
|
|
CheckCheck,
|
|
AlertTriangle,
|
|
} from 'lucide-react';
|
|
|
|
// Cursor-harmonized supplier color palette: same warm cream surface
|
|
// for every group, with the *name* color living in a single muted
|
|
// tile (not a saturated left-border stripe). Each group gets a
|
|
// distinct hue but all read as part of the same family.
|
|
const SUPPLIER_COLORS = [
|
|
{ dot: 'bg-[#f54e00]', border: 'border-l-[#f54e00]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#f54e00]/30' }, // orange (signature)
|
|
{ dot: 'bg-[#18714f]', border: 'border-l-[#18714f]', light: 'bg-[#e6f1ec]', text: 'text-[#125a3e]', ring: 'ring-[#18714f]/30' }, // emerald
|
|
{ dot: 'bg-[#6c5a99]', border: 'border-l-[#6c5a99]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // violet
|
|
{ dot: 'bg-[#cf2d56]', border: 'border-l-[#cf2d56]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // rose
|
|
{ dot: 'bg-[#8a5a02]', border: 'border-l-[#8a5a02]', light: 'bg-[#fcf3d8]', text: 'text-[#8a5a02]', ring: 'ring-[#d49103]/30' }, // amber
|
|
{ dot: 'bg-[#26251e]', border: 'border-l-[#26251e]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#26251e]/30' }, // ink
|
|
{ dot: 'bg-[#6b6760]', border: 'border-l-[#6b6760]', light: 'bg-[#ebeae5]', text: 'text-[#4a4742]', ring: 'ring-[#6b6760]/30' }, // warm gray
|
|
{ dot: 'bg-[#0d9488]', border: 'border-l-[#0d9488]', light: 'bg-[#e6f5f3]', text: 'text-[#0a7a70]', ring: 'ring-[#0d9488]/30' }, // teal
|
|
{ dot: 'bg-[#8a1b3a]', border: 'border-l-[#8a1b3a]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // crimson
|
|
{ dot: 'bg-[#4a3d6b]', border: 'border-l-[#4a3d6b]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // indigo
|
|
];
|
|
|
|
export default function CollectionsPage() {
|
|
const router = useRouter();
|
|
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
|
|
// Hydrate initial state from the in-memory cache so tab switching
|
|
// shows last-known data instantly (no loading skeleton) while the
|
|
// background re-fetch lands. Without this, every bottom-nav click
|
|
// shows a blank-then-skeleton-then-content flash, which is the
|
|
// "feels sluggish on PC" symptom drivers reported.
|
|
const [items, setItems] = useState<OrderItem[]>(() => {
|
|
if (typeof window === 'undefined') return [];
|
|
return getCached('assigned') ?? [];
|
|
});
|
|
const [filteredItems, setFilteredItems] = useState<OrderItem[]>(items);
|
|
// Only show the loading skeleton on first mount when we have
|
|
// absolutely nothing to show. Subsequent loads (returning to
|
|
// the tab, switching tabs) get the cached items immediately
|
|
// and the re-fetch happens silently in the background.
|
|
const [isLoading, setIsLoading] = useState<boolean>(items.length === 0);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedItem, setSelectedItem] = useState<OrderItem | null>(null);
|
|
const [showCollectModal, setShowCollectModal] = useState(false);
|
|
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');
|
|
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/');
|
|
}, [isLoggedIn, authLoading, router]);
|
|
|
|
// Each tab maps to a different PocketBase fetch — pool and problems
|
|
// are SYSTEM-WIDE (any driver can see them), assigned is the current
|
|
// driver's personal list only. This is the contract the brief asked
|
|
// for: "pool must show all items in collection_pool, same with
|
|
// problems; default when you open the app should be assigned".
|
|
//
|
|
// The cache write on success is what makes subsequent tab switches
|
|
// instant — the next page mount reads the cache and renders the
|
|
// last-known list with no skeleton flash.
|
|
const loadItems = useCallback(async (showLoading = true) => {
|
|
if (!isAuthenticated()) return;
|
|
// Only show the loading skeleton when we genuinely have nothing
|
|
// to render. If cached data is on screen, the re-fetch happens
|
|
// silently and the user sees fresh data appear without a flash.
|
|
if (showLoading && items.length === 0) setIsLoading(true);
|
|
setError('');
|
|
try {
|
|
let data: OrderItem[] = [];
|
|
if (statusFilter === 'assigned') {
|
|
// Merge order_items (the original Assigned scope) with
|
|
// stock_purchase_items (status="assigned_to_driver"). Both
|
|
// get reshaped onto the same OrderItem shape with a `kind`
|
|
// discriminator so the existing card / modal / map code
|
|
// renders them identically — only the WRITE side branches
|
|
// on kind to hit the right PB collection.
|
|
//
|
|
// Pool + Problems stay unchanged: pool is system-wide open
|
|
// claims, problems are operator-side audit flags. Stock
|
|
// pickups don't have an open-claim state.
|
|
const [orders, stock] = await Promise.all([
|
|
getMyAssignedItems(),
|
|
getMyAssignedStockItems(),
|
|
]);
|
|
data = [...orders, ...stock];
|
|
} else if (statusFilter === 'collection_pool') {
|
|
data = await getPoolItems();
|
|
} else if (statusFilter === 'problem') {
|
|
data = await getProblemItems();
|
|
}
|
|
setItems(data);
|
|
setFilteredItems(data);
|
|
setCached(statusFilter, data);
|
|
const allSupplierIds = new Set<string>();
|
|
data.forEach(i => {
|
|
if (i.expand?.supplier?.id) allSupplierIds.add(i.expand.supplier.id);
|
|
});
|
|
setExpandedSuppliers(allSupplierIds);
|
|
const allAreaIds = new Set<string>();
|
|
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.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, [statusFilter, items.length]);
|
|
|
|
useEffect(() => { loadItems(); }, [loadItems]);
|
|
|
|
// Seed items from cache the moment the filter changes, BEFORE the
|
|
// network re-fetch resolves. This is what kills the
|
|
// skeleton-flash on tab/filter switches — driver taps Pool, the
|
|
// page immediately renders whatever was last cached for pool, and
|
|
// the re-fetch swaps in fresh data when it lands.
|
|
useEffect(() => {
|
|
const cached = getCached(statusFilter);
|
|
if (cached) {
|
|
setItems(cached);
|
|
setFilteredItems(cached);
|
|
}
|
|
// intentionally not depending on `items` — we only want this to
|
|
// fire when statusFilter changes (or on mount).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [statusFilter]);
|
|
|
|
useEffect(() => {
|
|
let filtered = items;
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
filtered = filtered.filter((i) => {
|
|
const desc = i.description || '';
|
|
const supplier = i.expand?.supplier?.suppliername || '';
|
|
const orderNum = i.expand?.order?.order_number || '';
|
|
return (
|
|
desc.toLowerCase().includes(query) ||
|
|
supplier.toLowerCase().includes(query) ||
|
|
orderNum.toLowerCase().includes(query)
|
|
);
|
|
});
|
|
}
|
|
setFilteredItems(filtered);
|
|
}, [items, searchQuery, statusFilter]);
|
|
|
|
// Counts for the status chip strip — derived from the unfiltered
|
|
// list so chips stay stable as the user types in the search field.
|
|
// Each tab pulls from a different PB query (driver-scoped vs system-wide),
|
|
// so these counts aren't comparable across tabs; they each represent
|
|
// the size of the most recent fetch for THAT tab.
|
|
const statusCounts = useMemo(() => ({
|
|
assigned: items.filter((i) => i.item_status === 'assigned').length,
|
|
collection_pool: items.filter((i) => i.item_status === 'collection_pool').length,
|
|
problem: items.filter((i) => i.problem_flag).length,
|
|
}), [items]);
|
|
|
|
// Mirror the currently selected tab to localStorage so the Map page
|
|
// can pick it up on its next visit and show the same dataset the
|
|
// driver was last working with.
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem('1driver_map_tab', statusFilter);
|
|
}
|
|
}, [statusFilter]);
|
|
|
|
const handleRefresh = async () => {
|
|
setIsRefreshing(true);
|
|
await loadItems(false);
|
|
};
|
|
|
|
const showToast = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
|
setToast({ message, type });
|
|
setTimeout(() => setToast(null), 3000);
|
|
};
|
|
|
|
const handleCollect = async (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => {
|
|
try {
|
|
// expectedQty is computed from the same getItemDisplayQty the
|
|
// modal uses, so the comparison happens against the same number
|
|
// the driver just saw on screen.
|
|
const expectedQty = getItemDisplayQty(item);
|
|
|
|
if (item.kind === 'pickup') {
|
|
// Stock-pickup stop — PATCH stock_purchase_items. No photo
|
|
// upload on this side (stock_purchase_items has no
|
|
// attachments collection wired in PB).
|
|
await updateStockCollectionStatus(item.id, {
|
|
qty_collected: receivedQty,
|
|
notes,
|
|
expectedQty,
|
|
});
|
|
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
|
setShowCollectModal(false);
|
|
showToast('Stock pickup collected successfully');
|
|
return;
|
|
}
|
|
|
|
const updated = await updateCollectionStatus(item.id, 'collected', {
|
|
qty_collected: receivedQty,
|
|
notes,
|
|
photo: null, // legacy collection_photo is no longer the home
|
|
// for the new photo — see uploadOrderItemAttachment
|
|
// below. Keeping the param `null` avoids clearing
|
|
// any previously-stored photo on the row.
|
|
expectedQty,
|
|
collected_date: new Date().toISOString(),
|
|
});
|
|
|
|
// Upload the photo, if any, into the order_item_attachments
|
|
// collection so the operator can audit the pickup with a
|
|
// visual record. We do this AFTER the status flip so a
|
|
// failure here doesn't strand the item in `assigned` —
|
|
// the collection is the more important state change.
|
|
if (photo) {
|
|
try {
|
|
await uploadOrderItemAttachment({
|
|
orderItemId: updated.id,
|
|
file: photo,
|
|
source: 'driver_proof',
|
|
notes: notes || undefined,
|
|
});
|
|
} catch (uploadErr) {
|
|
// Status already changed. Surface the failure so the
|
|
// driver knows the photo did NOT persist (a success
|
|
// toast here would be a lie). Known PB bug: image/* mime
|
|
// detection rejects camera JPEGs — until that server
|
|
// side detection is fixed, photos will keep failing.
|
|
console.error('Photo upload failed:', uploadErr);
|
|
const reason =
|
|
uploadErr instanceof Error ? uploadErr.message : 'unknown error';
|
|
showToast(
|
|
`Item collected — but photo upload failed (${reason})`,
|
|
'warning'
|
|
);
|
|
return; // skip the success toast that follows
|
|
}
|
|
}
|
|
|
|
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
|
setShowCollectModal(false);
|
|
showToast(
|
|
photo
|
|
? 'Collection completed · photo uploaded'
|
|
: 'Collection completed successfully'
|
|
);
|
|
} catch (err) {
|
|
showToast('Failed to update collection', 'error');
|
|
}
|
|
};
|
|
|
|
const handleProblem = async (item: OrderItem, problemType: string, description: string) => {
|
|
try {
|
|
if (item.kind === 'pickup') {
|
|
await reportStockProblem(item.id, problemType, description);
|
|
} else {
|
|
await reportProblem(item.id, problemType, description);
|
|
}
|
|
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
|
setShowProblemModal(false);
|
|
showToast('Problem reported successfully', 'warning');
|
|
} catch (err) {
|
|
showToast('Failed to report problem', 'error');
|
|
}
|
|
};
|
|
|
|
// 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 {
|
|
// Split the input by kind so order rows and stock rows go to
|
|
// their respective PB collections in one round-trip each.
|
|
const [orderResult, stockResult] = await Promise.all([
|
|
bulkCollect(itemsToCollect.filter((i) => i.kind !== 'pickup')),
|
|
bulkCollectStock(itemsToCollect),
|
|
]);
|
|
const ok = orderResult.ok + stockResult.ok;
|
|
const failed = orderResult.failed + stockResult.failed;
|
|
const ids = new Set(itemsToCollect.map((i) => i.id));
|
|
setItems((prev) => prev.filter((i) => !ids.has(i.id)));
|
|
if (failed === 0) {
|
|
showToast(`Collected ${ok} item${ok === 1 ? '' : 's'} · ${poLabel}`, 'success');
|
|
} else {
|
|
showToast(
|
|
`Collected ${ok}, ${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 [orderResult, stockResult] = await Promise.all([
|
|
bulkProblem(itemsToFlag.filter((i) => i.kind !== 'pickup'), description),
|
|
bulkProblemStock(itemsToFlag, description),
|
|
]);
|
|
const ok = orderResult.ok + stockResult.ok;
|
|
const failed = orderResult.failed + stockResult.failed;
|
|
const ids = new Set(itemsToFlag.map((i) => i.id));
|
|
setItems((prev) => prev.filter((i) => !ids.has(i.id)));
|
|
if (failed === 0) {
|
|
showToast(`Flagged ${ok} item${ok === 1 ? '' : 's'} · ${poLabel}`, 'warning');
|
|
} else {
|
|
showToast(
|
|
`Flagged ${ok}, ${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
|
|
? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`
|
|
: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
|
window.open(url, '_blank');
|
|
};
|
|
|
|
const handleShowDetails = (item: OrderItem) => {
|
|
setSelectedItem(item);
|
|
setShowDetailModal(true);
|
|
};
|
|
|
|
const toggleSupplier = (supplierId: string) => {
|
|
setExpandedSuppliers(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(supplierId)) next.delete(supplierId);
|
|
else next.add(supplierId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleArea = (clusterId: string) => {
|
|
setExpandedAreas(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(clusterId)) next.delete(clusterId);
|
|
else next.add(clusterId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
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' || groupMode === 'po') {
|
|
const allIds = new Set<string>();
|
|
filteredItems.forEach(i => {
|
|
if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id);
|
|
});
|
|
setExpandedSuppliers(allIds);
|
|
} else {
|
|
const allIds = new Set<string>();
|
|
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' || 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"
|
|
const groupedArray = useMemo(() => {
|
|
const grouped = filteredItems.reduce((acc, item) => {
|
|
const supplier = item.expand?.supplier;
|
|
const key = supplier?.id || '__unknown__';
|
|
if (!acc[key]) acc[key] = { supplier, items: [] };
|
|
acc[key].items.push(item);
|
|
return acc;
|
|
}, {} as Record<string, { supplier: NonNullable<OrderItem['expand']>['supplier'] | null; items: OrderItem[] }>);
|
|
|
|
return Object.values(grouped).sort((a, b) =>
|
|
(a.supplier?.suppliername || 'zzz').localeCompare(b.supplier?.suppliername || 'zzz')
|
|
);
|
|
}, [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);
|
|
}, [filteredItems]);
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-[#f2f1ed]">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-10 h-10 border-[3px] border-[#f54e00] border-t-transparent rounded-full animate-spin" />
|
|
<p className="text-sm text-[rgba(38,37,30,0.55)]">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isLoggedIn) return null;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#f2f1ed] pb-nav">
|
|
{/* Header */}
|
|
<header
|
|
className="bg-[#e6e5e0] text-[#26251e] safe-top sticky top-0 z-40 border-b border-[rgba(38,37,30,0.1)] backdrop-blur-md"
|
|
role="banner"
|
|
style={{ boxShadow: '0 1px 0 rgba(38,37,30,0.06)' }}
|
|
>
|
|
<div className="brand-accent-top" aria-hidden="true" />
|
|
<div className="px-4 pt-2 pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-xl font-bold tracking-tight text-[#26251e]">Collections</h1>
|
|
<p className="text-[13px] text-[rgba(38,37,30,0.55)] truncate">{user?.name || user?.email}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="relative">
|
|
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-[#f54e00] rounded-full border-2 border-[#e6e5e0] animate-pulse" aria-hidden="true" />
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={isRefreshing}
|
|
aria-label={isRefreshing ? 'Refreshing collections' : 'Refresh collections'}
|
|
className="p-2.5 rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-all touch-manipulation"
|
|
>
|
|
<RefreshCw size={18} className={cn('text-[#26251e]', isRefreshing && 'animate-spin')} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => logout()}
|
|
aria-label="Sign out"
|
|
className="p-2.5 rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-all touch-manipulation"
|
|
>
|
|
<LogOut size={18} className="text-[#26251e]" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search bar */}
|
|
<div className="px-4 pb-2">
|
|
<div className="relative">
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[rgba(38,37,30,0.55)] pointer-events-none" aria-hidden="true" />
|
|
<label htmlFor="collections-search" className="sr-only">Search collections</label>
|
|
<input
|
|
id="collections-search"
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search by item, supplier, order..."
|
|
className="w-full pl-9 pr-9 py-2.5 bg-[#f2f1ed] border border-[rgba(38,37,30,0.1)] rounded-xl text-sm text-[#26251e] placeholder:text-[rgba(38,37,30,0.4)] focus:outline-none focus:ring-2 focus:ring-[#f54e00]/30 focus:border-[#f54e00] focus:bg-[#f7f7f4] transition-all"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
aria-label="Clear search"
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-[rgba(38,37,30,0.55)] active:text-[#26251e]"
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats + Controls bar — wraps onto two lines on narrow viewports
|
|
so the stat chips don't collide with the toggle groups. On
|
|
≥640px everything fits on one line again. */}
|
|
<div className="px-4 pb-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-2">
|
|
<div className="flex items-center gap-1.5 min-w-0 flex-wrap">
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#ebeae5] text-[11px] font-medium text-[#26251e]">
|
|
<Package size={12} aria-hidden="true" />
|
|
{items.length} item{items.length !== 1 ? 's' : ''}
|
|
</span>
|
|
{groupedArray.length > 0 && (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#ebeae5] text-[11px] font-medium text-[#26251e]">
|
|
<MapPin size={12} aria-hidden="true" />
|
|
{groupedArray.length} stop{groupedArray.length !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
</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
|
|
can see at a glance what's pending vs flagged. */}
|
|
<div
|
|
className="px-4 pb-3 flex items-center gap-1.5 overflow-x-auto no-scrollbar -mx-1"
|
|
role="tablist"
|
|
aria-label="Filter by status"
|
|
>
|
|
{([
|
|
{ key: 'assigned', label: 'Assigned' },
|
|
{ key: 'collection_pool', label: 'Pool' },
|
|
{ key: 'problem', label: 'Problems' },
|
|
] as const).map(({ key, label }) => {
|
|
const count = statusCounts[key];
|
|
const active = statusFilter === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
role="tab"
|
|
aria-selected={active}
|
|
onClick={() => setStatusFilter(key)}
|
|
className={cn(
|
|
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold transition-all touch-manipulation flex-shrink-0',
|
|
active
|
|
? 'bg-[#26251e] text-[#f2f1ed]'
|
|
: 'bg-[rgba(38,37,30,0.06)] text-[rgba(38,37,30,0.7)] active:bg-[rgba(38,37,30,0.12)]'
|
|
)}
|
|
>
|
|
{label}
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold',
|
|
active ? 'bg-[#f54e00] text-white' : 'bg-[rgba(38,37,30,0.08)] text-[rgba(38,37,30,0.6)]'
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content */}
|
|
<main className="p-4 custom-scrollbar" role="main" aria-live="polite">
|
|
{isLoading ? (
|
|
<div className="space-y-3" aria-label="Loading your collections" aria-busy="true">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="bg-[#e6e5e0] rounded-2xl p-4 border border-[rgba(38,37,30,0.1)]">
|
|
<div className="skeleton h-4 w-3/4 rounded-lg mb-3" />
|
|
<div className="skeleton h-3 w-1/2 rounded-lg mb-2" />
|
|
<div className="skeleton h-3 w-2/3 rounded-lg" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-16 px-4" role="alert">
|
|
<div className="w-16 h-16 bg-[#fbeaef] rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<AlertCircle size={32} className="text-[#b3224a]" aria-hidden="true" />
|
|
</div>
|
|
<p className="text-[#26251e] font-semibold mb-1">{error}</p>
|
|
<p className="text-[rgba(38,37,30,0.55)] text-sm mb-6">Check your connection and try again</p>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="inline-flex items-center gap-2 px-6 py-3 bg-[#f54e00] text-white rounded-xl text-sm font-semibold active:bg-[#d44200] transition-colors touch-manipulation press-feedback min-h-[44px]"
|
|
style={{ boxShadow: '0 4px 12px rgba(245,78,0,0.3)' }}
|
|
>
|
|
<RefreshCw size={16} aria-hidden="true" />
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
) : filteredItems.length === 0 ? (
|
|
<div className="text-center py-16 px-4">
|
|
<div className="w-16 h-16 bg-[#e6f1ec] rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<CheckCircle2 size={32} className="text-[#18714f]" aria-hidden="true" />
|
|
</div>
|
|
<p className="text-[#26251e] font-semibold text-lg mb-1">
|
|
{searchQuery ? 'Nothing matches' : 'All caught up'}
|
|
</p>
|
|
<p className="text-[rgba(38,37,30,0.55)] text-sm">
|
|
{searchQuery
|
|
? 'No collections match your search.'
|
|
: statusFilter === 'assigned'
|
|
? 'No pickups for today or yesterday. New ones appear here as the operator schedules them.'
|
|
: statusFilter === 'collection_pool'
|
|
? 'Collection pool is empty — nothing to claim right now.'
|
|
: 'No problems reported. Nice work.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{groupMode === 'area' ? (
|
|
areaClusters.map((cluster, clusterIdx) => {
|
|
const color = SUPPLIER_COLORS[clusterIdx % SUPPLIER_COLORS.length];
|
|
const isExpanded = expandedAreas.has(cluster.id);
|
|
|
|
return (
|
|
<AreaGroup
|
|
key={cluster.id}
|
|
clusterId={cluster.id}
|
|
label={cluster.label}
|
|
items={cluster.items}
|
|
center={cluster.center}
|
|
color={color}
|
|
isExpanded={isExpanded}
|
|
viewMode={viewMode}
|
|
onToggle={() => toggleArea(cluster.id)}
|
|
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
|
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
|
onNavigate={handleNavigate}
|
|
onShowDetails={handleShowDetails}
|
|
/>
|
|
);
|
|
})
|
|
) : (
|
|
// 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>
|
|
)}
|
|
</main>
|
|
|
|
{/* Modals */}
|
|
<CollectModal
|
|
item={selectedItem}
|
|
isOpen={showCollectModal}
|
|
onClose={() => { setShowCollectModal(false); setSelectedItem(null); }}
|
|
onConfirm={handleCollect}
|
|
/>
|
|
<ProblemModal
|
|
item={selectedItem}
|
|
isOpen={showProblemModal}
|
|
onClose={() => { setShowProblemModal(false); setSelectedItem(null); }}
|
|
onConfirm={handleProblem}
|
|
/>
|
|
<ItemDetailModal
|
|
item={selectedItem!}
|
|
purchaseOrder={selectedItem?.expand?.purchase_order ?? null}
|
|
isOpen={showDetailModal}
|
|
onClose={() => { setShowDetailModal(false); setSelectedItem(null); }}
|
|
/>
|
|
|
|
{/* Toast */}
|
|
{toast && (
|
|
<div
|
|
className="fixed top-4 left-4 right-4 z-[70] animate-slide-down safe-top pointer-events-none"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div className={cn(
|
|
'px-4 py-3 rounded-2xl text-sm font-medium text-white text-center pointer-events-auto',
|
|
toast.type === 'success' && 'bg-[#18714f]',
|
|
toast.type === 'error' && 'bg-[#cf2d56]',
|
|
toast.type === 'warning' && 'bg-[#d49103]'
|
|
)}
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18)' }}
|
|
>
|
|
{toast.message}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<BottomNav />
|
|
</div>
|
|
);
|
|
} |