- Confirm Collection locks while saving ("Saving…") and the page drops a
second collect for an item already in flight. Repeat taps on slow data
re-ran the save and re-uploaded the photo (7 identical copies on one
item on 2026-10-16).
- PO headers (and a single-PO supplier's list) get an Invoice button:
photograph the supplier invoice once per PO, then "add pg 2", "pg 3".
Pages go onto one customer line of the PO as invoice-<PO>-p<N>.jpg;
WFDASH Collection Confirm already gathers photos per PO card. The page
count is also kept on the device so it survives lines dropping off.
- PO button opens the PO PDF in one tap (shared buildPoFileUrl in
lib/poDocs.ts, also used by the item detail modal).
- Collect modal says whether the PO's invoice is already uploaded, so
the per-item photo is only for problems with the item.
- Photos now upload at 2000px / q0.8 (was 1200px / q0.7) so small print
on invoices stays readable for the AI checks.
- Supplier header counts and detailed view now include items in PO
grouping mode (they showed 0 items before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
985 lines
No EOL
42 KiB
TypeScript
985 lines
No EOL
42 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef } 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 { countInvoicePages, invoicePhotoTarget, uploadInvoicePage } from '@/lib/poDocs';
|
|
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());
|
|
|
|
// Mirror items.length into a ref so loadItems can decide whether to
|
|
// flash the loading skeleton WITHOUT making items.length a callback
|
|
// dependency. If items.length WERE a dep, every successful collect
|
|
// (which filters the collected item out of state) would rebuild
|
|
// loadItems, the auto-fire useEffect below would re-run loadItems,
|
|
// and the setExpandedSuppliers(new Set()) call inside it would
|
|
// collapse the supplier the driver is currently working through —
|
|
// forcing them to re-expand for every single item. The ref lets us
|
|
// keep the "don't flash a skeleton when items are already on screen"
|
|
// behavior while breaking the collapse loop.
|
|
const itemsLengthRef = useRef(0);
|
|
useEffect(() => {
|
|
itemsLengthRef.current = items.length;
|
|
}, [items.length]);
|
|
|
|
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. Read items.length from the ref so this stays a
|
|
// current-value check, not a subscription — see the comment on
|
|
// itemsLengthRef above for why.
|
|
if (showLoading && itemsLengthRef.current === 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);
|
|
// Default to ALL groups collapsed. Drivers see a flat list of
|
|
// stops (suppliers / area clusters / POs) on first load and tap
|
|
// one to drill into its items — matches the "open the app,
|
|
// see your day" mental model. The "Show all" toolbar action
|
|
// (`expandAll`) is still available for the rare case where a
|
|
// driver wants everything expanded at once.
|
|
setExpandedSuppliers(new Set());
|
|
setExpandedAreas(new Set());
|
|
setExpandedPOs(new Set());
|
|
} catch (err) {
|
|
console.error('Failed to load collections:', err);
|
|
setError('Failed to load collections. Pull down to retry.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, [statusFilter]);
|
|
|
|
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);
|
|
};
|
|
|
|
// Item ids whose collect is still in flight. The modal disables its
|
|
// button while saving, but this is the backstop: a second call for
|
|
// the same item is dropped instead of re-saving and re-uploading.
|
|
const collectingRef = useRef<Set<string>>(new Set());
|
|
|
|
const handleCollect = async (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => {
|
|
if (collectingRef.current.has(item.id)) return;
|
|
collectingRef.current.add(item.id);
|
|
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.
|
|
//
|
|
// The item is cleared off the list either way. An earlier
|
|
// version returned early on upload failure, which left a
|
|
// successfully-collected item sitting on screen still looking
|
|
// un-collected; the driver re-collected it, re-attempted the
|
|
// same oversized upload, and one record picked up 29 failed
|
|
// attempts in a single morning on 2026-10-12.
|
|
let photoFailed = false;
|
|
let photoFailure = '';
|
|
if (photo) {
|
|
try {
|
|
await uploadOrderItemAttachment({
|
|
orderItemId: updated.id,
|
|
file: photo,
|
|
source: 'driver_proof',
|
|
notes: notes || undefined,
|
|
});
|
|
} catch (uploadErr) {
|
|
// The collection itself succeeded, so this is a warning,
|
|
// not a failure — but the driver must still know the photo
|
|
// did NOT persist. A success toast here would be a lie.
|
|
//
|
|
// (The "Known PB bug: image/* mime detection rejects camera
|
|
// JPEGs" note that used to live here was wrong: PB's own
|
|
// logs show every attempt failing as `400 unexpected EOF`
|
|
// — the multipart body truncated in transit, not a format
|
|
// rejection. `order_item_attachements` has mimeTypes=null,
|
|
// so nothing was checking the type at all. Fixed by
|
|
// compressing the photo client-side in lib/image.ts.)
|
|
photoFailed = true;
|
|
photoFailure = uploadErr instanceof Error ? uploadErr.message : 'unknown error';
|
|
console.error('Photo upload failed:', uploadErr);
|
|
}
|
|
}
|
|
|
|
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
|
setShowCollectModal(false);
|
|
if (photoFailed) {
|
|
showToast(
|
|
`Collected, but the photo didn't upload (${photoFailure}) — add it from the office.`,
|
|
'warning',
|
|
);
|
|
} else {
|
|
showToast(
|
|
photo
|
|
? 'Collection completed · photo uploaded'
|
|
: 'Collection completed successfully',
|
|
);
|
|
}
|
|
} catch (err) {
|
|
showToast('Failed to update collection', 'error');
|
|
} finally {
|
|
collectingRef.current.delete(item.id);
|
|
}
|
|
};
|
|
|
|
// One invoice photo per PO, taken from the PO header. Page N goes on
|
|
// the same line as page 1 so the office sees the pages together.
|
|
const handleAddInvoicePage = async (poItems: OrderItem[], file: File) => {
|
|
const target = invoicePhotoTarget(poItems);
|
|
if (!target) return;
|
|
const poNumber = target.expand?.purchase_order?.po_number ?? '';
|
|
const page = countInvoicePages(poItems) + 1;
|
|
try {
|
|
const files = await uploadInvoicePage({ target, poNumber, page, file });
|
|
setItems((prev) =>
|
|
prev.map((i) => (i.id === target.id ? { ...i, order_item_attachements: files } : i)),
|
|
);
|
|
showToast(`Invoice page ${page} uploaded${poNumber ? ` · PO ${poNumber}` : ''}`);
|
|
} catch (err) {
|
|
console.error('Invoice photo upload failed:', err);
|
|
const reason = err instanceof Error ? err.message : 'unknown error';
|
|
showToast(`Invoice photo didn't upload (${reason}) — try again.`, 'error');
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
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);
|
|
// Item is now in status='collection_pool' on PB, so it dropped
|
|
// off the Assigned tab server-side as well. Toast signposts the
|
|
// pool handoff so the driver knows the item will be re-attempted
|
|
// rather than just disappearing.
|
|
showToast('Problem reported — item sent back to the pool', 'warning');
|
|
} catch (err) {
|
|
// PB puts the actual reason in `response.data` keyed by field
|
|
// name — the top-level `message` is always the useless
|
|
// "Something went wrong while processing your request." A 400 here
|
|
// is almost always a select-field value the schema doesn't allow
|
|
// (this cost us every problem report on 2026-10-14: problem_type
|
|
// was missing out_of_stock/partial_stock/quality_issue/wrong_item).
|
|
// Logging only the message hid that for months.
|
|
const detail = (err as { response?: { data?: Record<string, { message?: string }> } })
|
|
?.response?.data;
|
|
console.error(
|
|
'Failed to report problem:',
|
|
item.id,
|
|
problemType,
|
|
detail ? JSON.stringify(detail) : 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'} · sent back to the pool · ${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}
|
|
onAddInvoicePage={handleAddInvoicePage}
|
|
/>
|
|
);
|
|
});
|
|
})()
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Modals */}
|
|
<CollectModal
|
|
item={selectedItem}
|
|
isOpen={showCollectModal}
|
|
onClose={() => { setShowCollectModal(false); setSelectedItem(null); }}
|
|
onConfirm={handleCollect}
|
|
invoicePages={
|
|
selectedItem?.expand?.purchase_order
|
|
? countInvoicePages(
|
|
items.filter((i) => i.expand?.purchase_order?.id === selectedItem.expand?.purchase_order?.id),
|
|
)
|
|
: 0
|
|
}
|
|
/>
|
|
<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>
|
|
);
|
|
} |