Stock pickups live in stock_purchase_items (status="assigned_to_driver"), not order_items. Previously getMyAssignedItems() only queried order_items, so a driver with a stock pickup assigned to them saw nothing in the Assigned tab or Map. - getMyAssignedStockItems() fetches stock_purchase_items with the matching drv_id/status filter and expands purchase.supplier + stock_item. A toOrderItemFromStock() reshaper puts each row onto the OrderItem shape so the existing card/modal/map components render stock pickups unchanged; a kind:'pickup' discriminator tags them for the write side. - handleCollect / handleProblem / handleBulkCollect / handleBulkProblem branch on item.kind to PATCH stock_purchase_items instead of order_items (status="collected" + quantity_received on collect, problem_flag + problem_type + problem_notes on problem; mirrors the order_items quantity-mismatch auto-notes). - Same merge on app/map/page.tsx so the stock stop lands on the map. - scripts/check-pb-schema.mjs now covers the stock filter so a future PB rename is caught at prebuild.
251 lines
No EOL
11 KiB
TypeScript
251 lines
No EOL
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, Suspense, Component } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import dynamic from 'next/dynamic';
|
|
import { OrderItem } from '@/lib/types';
|
|
import { useAuth } from '@/components/AuthProvider';
|
|
import BottomNav from '@/components/BottomNav';
|
|
import { getMyAssignedItems, getMyAssignedStockItems, getPoolItems, getProblemItems, isAuthenticated } from '@/lib/pocketbase';
|
|
import { getCached, setCached } from '@/lib/itemsCache';
|
|
import { cn } from '@/lib/utils';
|
|
import { RefreshCw, MapPin, AlertCircle, Package, Inbox, AlertTriangle } from 'lucide-react';
|
|
|
|
// Sync the map's dataset with the list page's selected tab via
|
|
// localStorage so a driver who flips to "Pool" on the list and then
|
|
// taps Map sees the same pool stops, not just their assigned list.
|
|
const MAP_TAB_KEY = '1driver_map_tab';
|
|
type MapTab = 'assigned' | 'collection_pool' | 'problem';
|
|
|
|
const CollectionMap = dynamic(() => import('@/components/CollectionMap'), {
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="h-full flex items-center justify-center bg-[#f2f1ed]">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-8 h-8 border-[3px] border-[#f54e00] border-t-transparent rounded-full animate-spin" />
|
|
<p className="text-sm text-[rgba(38,37,30,0.55)]">Loading map...</p>
|
|
</div>
|
|
</div>
|
|
),
|
|
});
|
|
|
|
class MapErrorBoundary extends Component<{ children: React.ReactNode }, { hasError: boolean; error: string }> {
|
|
constructor(props: { children: React.ReactNode }) {
|
|
super(props);
|
|
this.state = { hasError: false, error: '' };
|
|
}
|
|
static getDerivedStateFromError(error: Error) {
|
|
return { hasError: true, error: error.message };
|
|
}
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<div className="h-full flex flex-col items-center justify-center bg-[#f2f1ed] p-4">
|
|
<div className="w-16 h-16 bg-[#fbeaef] rounded-2xl flex items-center justify-center mb-4">
|
|
<AlertCircle size={32} className="text-[#b3224a]" />
|
|
</div>
|
|
<p className="text-[#26251e] text-sm mb-4 text-center">Map failed to load.</p>
|
|
<button onClick={() => this.setState({ hasError: false })} className="px-5 py-2.5 bg-[#f54e00] text-white rounded-xl text-sm font-medium active:bg-[#d44200]">
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
export default function MapPage() {
|
|
const router = useRouter();
|
|
const { isLoggedIn, isLoading: authLoading } = useAuth();
|
|
// Seed from cache so bottom-nav → Map renders last-known stops
|
|
// instantly. The re-fetch in the useEffect below updates the map
|
|
// with fresh data when it lands, but the driver never sees an
|
|
// empty map flash.
|
|
const [items, setItems] = useState<OrderItem[]>(() => {
|
|
if (typeof window === 'undefined') return [];
|
|
return getCached('assigned') ?? [];
|
|
});
|
|
const [isLoading, setIsLoading] = useState<boolean>(items.length === 0);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [tab, setTab] = useState<MapTab>('assigned');
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = window.localStorage.getItem(MAP_TAB_KEY);
|
|
if (saved === 'assigned' || saved === 'collection_pool' || saved === 'problem') {
|
|
setTab(saved);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !isLoggedIn) router.push('/login/');
|
|
}, [isLoggedIn, authLoading, router]);
|
|
|
|
const loadItems = useCallback(async (showLoading = true) => {
|
|
if (!isAuthenticated()) return;
|
|
// Only show the loading skeleton on the first-ever load. After
|
|
// that the cache guarantees we have something on screen, so
|
|
// the re-fetch is silent.
|
|
if (showLoading && items.length === 0) setIsLoading(true);
|
|
setError('');
|
|
try {
|
|
let data: OrderItem[] = [];
|
|
if (tab === 'assigned') {
|
|
// Merge order_items + stock_purchase_items (the "My stops"
|
|
// tab is the same scope as the Assigned list on page.tsx).
|
|
const [orders, stock] = await Promise.all([
|
|
getMyAssignedItems(),
|
|
getMyAssignedStockItems(),
|
|
]);
|
|
data = [...orders, ...stock];
|
|
} else if (tab === 'collection_pool') {
|
|
data = await getPoolItems();
|
|
} else {
|
|
data = await getProblemItems();
|
|
}
|
|
setItems(data);
|
|
setCached(tab, data);
|
|
} catch (err) {
|
|
console.error('Failed to load collections:', err);
|
|
setError('Failed to load collections');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, [tab, items.length]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') window.localStorage.setItem(MAP_TAB_KEY, tab);
|
|
// Seed from cache BEFORE the re-fetch lands — same pattern as
|
|
// the list page, so tab/filter switches show last-known data
|
|
// instantly instead of an empty map.
|
|
const cached = getCached(tab);
|
|
if (cached) setItems(cached);
|
|
loadItems();
|
|
}, [tab, loadItems]);
|
|
|
|
const handleTabChange = (next: MapTab) => {
|
|
setTab(next);
|
|
};
|
|
|
|
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');
|
|
};
|
|
|
|
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="h-screen flex flex-col bg-[#f2f1ed]">
|
|
<header className="bg-[#e6e5e0] text-[#26251e] safe-top z-40 border-b border-[rgba(38,37,30,0.1)] flex-shrink-0">
|
|
<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 items-center gap-2.5 min-w-0">
|
|
<div className="w-8 h-8 bg-[#ebeae5] rounded-xl flex items-center justify-center flex-shrink-0">
|
|
<MapPin size={18} className="text-[#26251e]" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="text-lg font-bold leading-tight text-[#26251e]">Map</h1>
|
|
<p className="text-[11px] text-[rgba(38,37,30,0.55)]">{items.length} stop{items.length !== 1 ? 's' : ''} on map</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => { setIsRefreshing(true); loadItems(false); }}
|
|
disabled={isRefreshing}
|
|
aria-label={isRefreshing ? 'Refreshing map' : 'Refresh map'}
|
|
className="p-2.5 rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-all touch-manipulation flex-shrink-0"
|
|
>
|
|
<RefreshCw size={18} className={cn('text-[#26251e]', isRefreshing && 'animate-spin')} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab switcher — same semantics as the list page so the map
|
|
shows whatever scope the driver is currently working on. */}
|
|
<div className="mt-2.5 flex items-center gap-1.5 overflow-x-auto no-scrollbar -mx-1" role="tablist" aria-label="Map scope">
|
|
{([
|
|
{ key: 'assigned', label: 'My stops', Icon: Package },
|
|
{ key: 'collection_pool', label: 'Pool', Icon: Inbox },
|
|
{ key: 'problem', label: 'Problems', Icon: AlertTriangle },
|
|
] as const).map(({ key, label, Icon }) => {
|
|
const active = tab === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
role="tab"
|
|
aria-selected={active}
|
|
onClick={() => handleTabChange(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)]'
|
|
)}
|
|
>
|
|
<Icon size={11} aria-hidden="true" />
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 relative">
|
|
{isLoading ? (
|
|
<div className="h-full flex items-center justify-center bg-[#f2f1ed]">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-8 h-8 border-[3px] border-[#f54e00] border-t-transparent rounded-full animate-spin" />
|
|
<p className="text-sm text-[rgba(38,37,30,0.55)]">Loading map...</p>
|
|
</div>
|
|
</div>
|
|
) : error ? (
|
|
<div className="h-full flex flex-col items-center justify-center bg-[#f2f1ed] p-4">
|
|
<div className="w-16 h-16 bg-[#fbeaef] rounded-2xl flex items-center justify-center mb-4">
|
|
<AlertCircle size={32} className="text-[#b3224a]" />
|
|
</div>
|
|
<p className="text-[#26251e] text-sm mb-4 text-center">{error}</p>
|
|
<button onClick={() => loadItems()} className="px-5 py-2.5 bg-[#f54e00] text-white rounded-xl text-sm font-medium active:bg-[#d44200] touch-manipulation press-feedback">
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="h-full flex flex-col items-center justify-center bg-[#f2f1ed] p-4">
|
|
<div className="w-16 h-16 bg-[#ebeae5] rounded-2xl flex items-center justify-center mb-4">
|
|
<MapPin size={32} className="text-[rgba(38,37,30,0.4)]" />
|
|
</div>
|
|
<p className="text-[#26251e] font-semibold">No collections to show</p>
|
|
<p className="text-[rgba(38,37,30,0.55)] text-sm mt-1">All collections completed!</p>
|
|
</div>
|
|
) : (
|
|
<Suspense fallback={
|
|
<div className="h-full flex items-center justify-center bg-[#f2f1ed]">
|
|
<div className="w-8 h-8 border-[3px] border-[#f54e00] border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
}>
|
|
<MapErrorBoundary>
|
|
<CollectionMap items={items} onNavigate={handleNavigate} />
|
|
</MapErrorBoundary>
|
|
</Suspense>
|
|
)}
|
|
</main>
|
|
|
|
<BottomNav />
|
|
</div>
|
|
);
|
|
} |