feat: update collection and supplier handling across components

This commit is contained in:
SJCoetz 2026-05-24 22:31:35 +02:00
parent 0363ebeb75
commit 27b5b9fc78
9 changed files with 163 additions and 143 deletions

View file

@ -85,9 +85,9 @@ export default function CollectionsPage() {
filtered = filtered.filter((c) => {
const desc = c.is_manual
? c.description
: c.expand?.order_item?.description || '';
const supplier = c.expand?.supplier?.suppliername || '';
const orderNum = c.expand?.order_item?.expand?.order?.order_number || '';
: c.expand?.orders_orderitem?.description || '';
const supplier = c.expand?.wfdash_suppliers?.suppliername || '';
const orderNum = c.expand?.orders_orderitem?.expand?.orders_order?.order_number || '';
return (
desc.toLowerCase().includes(query) ||
supplier.toLowerCase().includes(query) ||
@ -180,7 +180,7 @@ export default function CollectionsPage() {
// Group by supplier
const groupedBySupplier = filteredCollections.reduce((acc, collection) => {
const supplier = collection.expand?.supplier;
const supplier = collection.expand?.wfdash_suppliers;
if (!supplier) return acc;
const key = supplier.id;
@ -189,7 +189,7 @@ export default function CollectionsPage() {
}
acc[key].items.push(collection);
return acc;
}, {} as Record<string, { supplier: NonNullable<Collection['expand']>['supplier']; items: Collection[] }>);
}, {} as Record<string, { supplier: NonNullable<Collection['expand']>['wfdash_suppliers']; items: Collection[] }>);
const groupedArray = Object.values(groupedBySupplier).sort((a, b) =>
(a.supplier?.suppliername || '').localeCompare(b.supplier?.suppliername || '')
@ -358,9 +358,9 @@ export default function CollectionsPage() {
<span className="bg-orange-100 text-orange-700 text-[10px] px-2 py-0.5 rounded-full font-medium">
{items.length}
</span>
{supplier!.address && (
{supplier!.supplieraddress && (
<span className="text-xs text-gray-400 truncate flex-1 text-right">
{supplier!.address}
{supplier!.supplieraddress}
</span>
)}
</div>

View file

@ -24,7 +24,7 @@ export default function CollectModal({
if (!isOpen || !collection) return null;
const expectedQty = collection.quantity || collection.expand?.order_item?.quantity || 0;
const expectedQty = collection.quantity || collection.expand?.orders_orderitem?.quantity || 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@ -68,14 +68,14 @@ export default function CollectModal({
<p className="text-sm font-medium truncate">
{collection.is_manual
? collection.description
: collection.expand?.order_item?.description || 'Unknown item'}
: collection.expand?.orders_orderitem?.description || 'Unknown item'}
</p>
<p className="text-xs text-gray-500 mt-1">
Expected: {expectedQty} {collection.unit || collection.expand?.order_item?.unit || 'units'}
Expected: {expectedQty} {collection.unit || collection.expand?.orders_orderitem?.unit || 'units'}
</p>
{collection.expand?.order_item?.selling_price && (
{collection.expand?.orders_orderitem?.selling_price && (
<p className="text-xs text-green-600 mt-1">
Unit price: {formatCurrency(collection.expand.order_item.selling_price)}
Unit price: {formatCurrency(collection.expand.orders_orderitem.selling_price)}
</p>
)}
</div>

View file

@ -2,7 +2,7 @@
import { useState } from 'react';
import { Collection } from '@/lib/types';
import { cn, formatDate, formatCurrency } from '@/lib/utils';
import { cn, formatDate, formatCurrency, parseCoordinates } from '@/lib/utils';
import {
Check,
AlertTriangle,
@ -38,8 +38,8 @@ function getItemType(collection: Collection): {
};
}
const orderNumber = collection.expand?.order_item?.expand?.order?.order_number || '';
const desc = collection.description || collection.expand?.order_item?.description || '';
const orderNumber = collection.expand?.orders_orderitem?.expand?.orders_order?.order_number || '';
const desc = collection.description || collection.expand?.orders_orderitem?.description || '';
if (orderNumber.toLowerCase().includes('breakdown')) {
return {
@ -83,13 +83,14 @@ export default function CollectionItemCard({
onNavigate,
}: CollectionItemProps) {
const [expanded, setExpanded] = useState(false);
const [showActions, setShowActions] = useState(false);
const supplier = collection.expand?.supplier;
const orderItem = collection.expand?.order_item;
const supplier = collection.expand?.wfdash_suppliers;
const orderItem = collection.expand?.orders_orderitem;
const itemType = getItemType(collection);
const hasCoords = supplier?.latitude && supplier?.longitude;
// Parse coordinates from text field
const coords = parseCoordinates(supplier?.coordinates);
const hasCoords = coords !== null;
const description = collection.is_manual
? collection.description
@ -98,8 +99,8 @@ export default function CollectionItemCard({
const quantity = collection.quantity || orderItem?.quantity || 0;
const unit = collection.unit || orderItem?.unit || 'units';
const orderNumber = orderItem?.expand?.order?.order_number || '';
const poNumber = orderItem?.expand?.purchase_order?.po_number || '';
const orderNumber = orderItem?.expand?.orders_order?.order_number || '';
const poNumber = orderItem?.expand?.orders_purchaseorder?.po_number || '';
return (
<div
@ -178,9 +179,9 @@ export default function CollectionItemCard({
</div>
)}
{supplier?.address && (
{supplier?.supplieraddress && (
<p className="text-xs text-gray-500 mb-3">
<strong>Address:</strong> {supplier.address}
<strong>Address:</strong> {supplier.supplieraddress}
</p>
)}
@ -216,11 +217,11 @@ export default function CollectionItemCard({
<span>Problem</span>
</button>
{hasCoords && supplier && (
{hasCoords && coords && supplier && (
<button
onClick={(e) => {
e.stopPropagation();
onNavigate(supplier.latitude!, supplier.longitude!, supplier.suppliername);
onNavigate(coords.latitude, coords.longitude, supplier.suppliername);
}}
className={cn(
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',

View file

@ -3,8 +3,8 @@
import { useEffect, useMemo, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import { Collection, Supplier } from '@/lib/types';
import { formatCurrency, calculateDistance } from '@/lib/utils';
import { Navigation, Check, AlertTriangle } from 'lucide-react';
import { formatCurrency, calculateDistance, parseCoordinates } from '@/lib/utils';
import { Navigation, AlertTriangle } from 'lucide-react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
@ -45,8 +45,11 @@ function MapBounds({ collections }: { collections: Collection[] }) {
useEffect(() => {
const validPoints = collections
.filter((c) => c.expand?.supplier?.latitude && c.expand?.supplier?.longitude)
.map((c) => [c.expand!.supplier!.latitude!, c.expand!.supplier!.longitude!] as [number, number]);
.map((c) => {
const coords = parseCoordinates(c.expand?.wfdash_suppliers?.coordinates);
return coords ? [coords.latitude, coords.longitude] as [number, number] : null;
})
.filter((point): point is [number, number] => point !== null);
if (validPoints.length > 0) {
const bounds = L.latLngBounds(validPoints);
@ -59,7 +62,6 @@ function MapBounds({ collections }: { collections: Collection[] }) {
export default function CollectionMap({ collections, onNavigate }: MapViewProps) {
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
const [selectedSupplier, setSelectedSupplier] = useState<string | null>(null);
useEffect(() => {
if ('geolocation' in navigator) {
@ -67,34 +69,27 @@ export default function CollectionMap({ collections, onNavigate }: MapViewProps)
(pos) => {
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
},
(error) => console.error('Could not get location:', error),
{ enableHighAccuracy: true, timeout: 10000 }
);
const watchId = navigator.geolocation.watchPosition(
(pos) => {
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
() => {
// Silently fail - desktop may not have GPS
},
(error) => console.error('Watch error:', error),
{ enableHighAccuracy: true }
{ enableHighAccuracy: false, timeout: 30000 }
);
return () => navigator.geolocation.clearWatch(watchId);
}
}, []);
// Group collections by supplier
const suppliers = useMemo(() => {
const map = new Map<string, { supplier: Supplier; collections: Collection[] }>();
const map = new Map<string, { supplier: Supplier; collections: Collection[]; coords: { latitude: number; longitude: number } }>();
collections.forEach((collection) => {
if (!collection.expand?.supplier) return;
const supplier = collection.expand.supplier;
if (!supplier.latitude || !supplier.longitude) return;
if (!collection.expand?.wfdash_suppliers) return;
const supplier = collection.expand.wfdash_suppliers;
const coords = parseCoordinates(supplier.coordinates);
if (!coords) return;
const key = supplier.id;
if (!map.has(key)) {
map.set(key, { supplier, collections: [] });
map.set(key, { supplier, collections: [], coords });
}
map.get(key)!.collections.push(collection);
});
@ -105,13 +100,13 @@ export default function CollectionMap({ collections, onNavigate }: MapViewProps)
const defaultCenter: [number, number] = [-33.9249, 18.4241]; // Cape Town default
const center = userLocation || defaultCenter;
const getSupplierDistance = (supplier: Supplier) => {
if (!userLocation || !supplier.latitude || !supplier.longitude) return null;
const getSupplierDistance = (coords: { latitude: number; longitude: number }) => {
if (!userLocation) return null;
return calculateDistance(
userLocation[0],
userLocation[1],
supplier.latitude,
supplier.longitude
coords.latitude,
coords.longitude
);
};
@ -138,23 +133,20 @@ export default function CollectionMap({ collections, onNavigate }: MapViewProps)
</Marker>
)}
{suppliers.map(({ supplier, collections: supplierCollections }) => {
const distance = getSupplierDistance(supplier);
{suppliers.map(({ supplier, collections: supplierCollections, coords }) => {
const distance = getSupplierDistance(coords);
const hasProblem = supplierCollections.some((c) => c.status === 'problem');
return (
<Marker
key={supplier.id}
position={[supplier.latitude!, supplier.longitude!]}
position={[coords.latitude, coords.longitude]}
icon={defaultIcon}
eventHandlers={{
click: () => setSelectedSupplier(supplier.id),
}}
>
<Popup>
<div className="min-w-[200px] max-w-[250px]">
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
<p className="text-xs text-gray-500 mb-2">{supplier.address}</p>
<p className="text-xs text-gray-500 mb-2">{supplier.supplieraddress}</p>
{distance && (
<p className="text-xs text-orange-600 mb-2 font-medium">
@ -175,11 +167,11 @@ export default function CollectionMap({ collections, onNavigate }: MapViewProps)
<span className="font-medium truncate">
{c.is_manual
? c.description
: c.expand?.order_item?.description || 'Unknown'}
: c.expand?.orders_orderitem?.description || 'Unknown'}
</span>
</div>
<span className="text-gray-400">
Qty: {c.quantity} {c.unit || c.expand?.order_item?.unit || 'units'}
Qty: {c.quantity} {c.unit || c.expand?.orders_orderitem?.unit || 'units'}
</span>
</div>
))}
@ -187,9 +179,9 @@ export default function CollectionMap({ collections, onNavigate }: MapViewProps)
<button
onClick={() =>
onNavigate(supplier.latitude!, supplier.longitude!, supplier.suppliername)
onNavigate(coords.latitude, coords.longitude, supplier.suppliername)
}
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-blue-500 text-white rounded-lg text-sm font-medium active:bg-orange-600 transition-colors"
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-orange-500 text-white rounded-lg text-sm font-medium active:bg-orange-600 transition-colors"
>
<Navigation size={14} />
Navigate
@ -206,12 +198,12 @@ export default function CollectionMap({ collections, onNavigate }: MapViewProps)
<p className="text-xs font-medium text-gray-700 mb-2">Legend</p>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500 border-2 border-white shadow-sm" />
<div className="w-3 h-3 rounded-full bg-orange-500 border-2 border-white shadow-sm" />
<span className="text-[10px] text-gray-600">Your Location</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 relative">
<div className="absolute inset-0 bg-blue-500 rounded-sm rotate-45 transform origin-bottom" />
<div className="absolute inset-0 bg-orange-500 rounded-sm rotate-45 transform origin-bottom" />
</div>
<span className="text-[10px] text-gray-600">Collection Stop</span>
</div>

View file

@ -5,19 +5,26 @@ import { getSettings, saveDriverLocation, addToQueue, isAuthenticated } from '@/
export default function LocationTracker() {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const watchRef = useRef<number | null>(null);
const lastSentRef = useRef<number>(0);
const hasPermissionRef = useRef<boolean | null>(null);
const sendLocation = useCallback(async (position: GeolocationPosition) => {
if (!isAuthenticated()) return;
const user = JSON.parse(localStorage.getItem('pocketbase_auth') || '{}')?.model;
const user = getCurrentUser();
if (!user) return;
// Skip if accuracy is too poor (based on settings)
const settings = getSettings();
if (position.coords.accuracy && position.coords.accuracy > settings.minimumAccuracy) {
return;
}
const locationData = {
driver: user.id,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
accuracy: position.coords.accuracy || undefined,
altitude: position.coords.altitude || undefined,
speed: position.coords.speed || undefined,
heading: position.coords.heading || undefined,
@ -26,8 +33,8 @@ export default function LocationTracker() {
try {
await saveDriverLocation(locationData);
lastSentRef.current = Date.now();
} catch (error) {
console.error('Failed to save location:', error);
// Queue for sync when back online
addToQueue({
type: 'status_update',
@ -38,74 +45,56 @@ export default function LocationTracker() {
useEffect(() => {
if (!('geolocation' in navigator)) {
console.warn('Geolocation not supported');
return;
}
const settings = getSettings();
const intervalMs = Math.max(10000, settings.locationInterval * 1000);
const intervalMs = Math.max(30000, settings.locationInterval * 1000);
const startTracking = () => {
// Use watchPosition for more frequent updates
watchRef.current = navigator.geolocation.watchPosition(
sendLocation,
const trackLocation = () => {
navigator.geolocation.getCurrentPosition(
(position) => {
hasPermissionRef.current = true;
sendLocation(position);
},
(error) => {
console.error('Geolocation error:', error);
// Silently handle errors - don't spam console
if (error.code === error.PERMISSION_DENIED) {
hasPermissionRef.current = false;
}
// TIMEOUT and POSITION_UNAVAILABLE are expected on desktop
},
{
enableHighAccuracy: settings.enableHighAccuracy,
maximumAge: intervalMs,
timeout: 10000,
timeout: 30000,
}
);
// Also send on interval as backup
intervalRef.current = setInterval(() => {
navigator.geolocation.getCurrentPosition(
sendLocation,
(error) => console.error('Interval geolocation error:', error),
{
enableHighAccuracy: settings.enableHighAccuracy,
timeout: 10000,
}
);
}, intervalMs);
};
// Start tracking if authenticated
if (isAuthenticated()) {
startTracking();
}
// Send immediately on mount
trackLocation();
// Listen for auth changes
const handleAuthChange = () => {
if (isAuthenticated()) {
startTracking();
} else {
stopTracking();
}
};
// Then on interval
intervalRef.current = setInterval(trackLocation, intervalMs);
// Check periodically for auth state
const authCheckInterval = setInterval(handleAuthChange, 5000);
const stopTracking = () => {
if (watchRef.current !== null) {
navigator.geolocation.clearWatch(watchRef.current);
watchRef.current = null;
}
return () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
return () => {
stopTracking();
clearInterval(authCheckInterval);
};
}, [sendLocation]);
// This component doesn't render anything
return null;
}
function getCurrentUser() {
try {
const auth = JSON.parse(localStorage.getItem('pocketbase_auth') || '{}');
return auth?.model;
} catch {
return null;
}
}

View file

@ -64,10 +64,10 @@ export default function ProblemModal({
<p className="text-sm font-medium truncate">
{collection.is_manual
? collection.description
: collection.expand?.order_item?.description || 'Unknown item'}
: collection.expand?.orders_orderitem?.description || 'Unknown item'}
</p>
<p className="text-xs text-gray-500 mt-1">
{collection.expand?.supplier?.suppliername || 'Unknown Supplier'}
{collection.expand?.wfdash_suppliers?.suppliername || 'Unknown Supplier'}
</p>
</div>

View file

@ -5,6 +5,11 @@ const POCKETBASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocket
export const pb = new PocketBase(POCKETBASE_URL);
// Collection names in PocketBase (matching Django table names)
const COLLECTIONS_TABLE = 'driver_list_collection';
const PROBLEMS_TABLE = 'driver_list_collectionproblem';
const LOCATIONS_TABLE = 'driver_locations';
// Auth helpers
export const isAuthenticated = () => pb.authStore.isValid;
@ -18,7 +23,12 @@ export const logout = () => {
};
export const login = async (email: string, password: string) => {
return await pb.collection('users').authWithPassword(email, password);
// Try 'users' first (PocketBase default), fallback to 'auth_user'
try {
return await pb.collection('users').authWithPassword(email, password);
} catch {
return await pb.collection('auth_user').authWithPassword(email, password);
}
};
// Collection data helpers
@ -31,20 +41,20 @@ export const getAssignedCollections = async () => {
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 2);
const records = await pb.collection('collections').getFullList({
filter: `status = "assigned" && driver = "${user.id}" && planned_date <= "${tomorrow.toISOString()}"`,
expand: 'order_item,supplier',
sort: 'supplier.suppliername',
const records = await pb.collection(COLLECTIONS_TABLE).getFullList({
filter: `status = "assigned" && auth_user = "${user.id}" && planned_date <= "${tomorrow.toISOString()}"`,
expand: 'orders_orderitem,wfdash_suppliers',
sort: 'wfdash_suppliers.suppliername',
});
return records as unknown as Collection[];
};
export const getPendingCollections = async () => {
const records = await pb.collection('collections').getFullList({
const records = await pb.collection(COLLECTIONS_TABLE).getFullList({
filter: 'status = "pending" || status = "problem"',
expand: 'order_item,supplier',
sort: 'supplier.suppliername',
expand: 'orders_orderitem,wfdash_suppliers',
sort: 'wfdash_suppliers.suppliername',
});
return records as unknown as Collection[];
@ -55,7 +65,7 @@ export const updateCollectionStatus = async (
status: string,
data: Partial<Collection>
) => {
return await pb.collection('collections').update(id, {
return await pb.collection(COLLECTIONS_TABLE).update(id, {
status,
...data,
});
@ -67,22 +77,28 @@ export const reportProblem = async (
description: string
) => {
// Update collection status
await pb.collection('collections').update(collectionId, {
await pb.collection(COLLECTIONS_TABLE).update(collectionId, {
status: 'problem',
notes: description,
});
// Create problem record
return await pb.collection('collection_problems').create({
collection: collectionId,
return await pb.collection(PROBLEMS_TABLE).create({
driver_list_collection: collectionId,
problem_type: problemType,
description,
reported_at: new Date().toISOString(),
});
};
// Location tracking
export const saveDriverLocation = async (location: Omit<DriverLocation, 'id'>) => {
return await pb.collection('driver_locations').create(location);
try {
return await pb.collection(LOCATIONS_TABLE).create(location);
} catch (err) {
// If driver_locations table doesn't exist yet, silently fail
console.warn('Could not save location - table may not exist:', err);
}
};
// Settings helpers

View file

@ -1,10 +1,10 @@
export interface Supplier {
id: string;
suppliername: string;
address: string;
latitude: number;
longitude: number;
collection?: string;
supplieraddress: string;
coordinates?: string; // Raw coordinates text (e.g. "-33.9249, 18.4241")
latitude?: number;
longitude?: number;
}
export interface OrderItem {
@ -14,20 +14,18 @@ export interface OrderItem {
order_qty: number;
unit: string;
selling_price?: number;
order?: string;
purchase_order?: string;
item_status: string;
expand?: {
order?: { order_number: string };
purchase_order?: { po_number: string };
orders_order?: { order_number: string };
orders_purchaseorder?: { po_number: string };
};
}
export interface Collection {
id: string;
order_item: string;
supplier: string;
driver: string;
orders_orderitem: string;
wfdash_suppliers: string;
auth_user: string;
status: 'pending' | 'assigned' | 'collected' | 'problem';
quantity: number;
planned_date: string;
@ -39,17 +37,17 @@ export interface Collection {
is_manual: boolean;
description: string;
unit: string;
// Expanded fields
// Expanded fields use the relation field names from PocketBase
expand?: {
order_item?: OrderItem;
supplier?: Supplier;
driver?: Driver;
orders_orderitem?: OrderItem;
wfdash_suppliers?: Supplier;
auth_user?: Driver;
};
}
export interface CollectionProblem {
id: string;
collection: string;
driver_list_collection: string;
problem_type: 'out_of_stock' | 'partial_stock' | 'quality_issue' | 'wrong_item' | 'other';
description: string;
photo?: string;

View file

@ -84,3 +84,27 @@ export function calculateDistance(
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Parse coordinates from a text field.
* Supports formats like:
* "-33.9249, 18.4241"
* "-33.9249 18.4241"
* "lat: -33.9249, lng: 18.4241"
* Returns null if parsing fails.
*/
export function parseCoordinates(coordString: string | undefined): { latitude: number; longitude: number } | null {
if (!coordString) return null;
// Try to extract two numbers that look like lat/lng
const matches = coordString.match(/-?\d+\.?\d*/g);
if (matches && matches.length >= 2) {
const lat = parseFloat(matches[0]);
const lng = parseFloat(matches[1]);
if (!isNaN(lat) && !isNaN(lng)) {
return { latitude: lat, longitude: lng };
}
}
return null;
}