Refactor collections to order items
Convert the app from using legacy Collection models to OrderItem-based data. Updated pages (app/page and app/map/page) and all related components (CollectionMap, CollectionItemCard, CollectModal, ProblemModal, LocationTracker) to use OrderItem and new field names (supplier, order, item_status, qty_collected/collected_date, problem_flag/notes, etc.). PocketBase client now queries/updates the order_items collection and maps status/field changes accordingly. Simplified location tracking logic and cleaned up map/navigation UI and Suspense fallbacks. Added import_suppliers.py script for migrating suppliers from NocoDB and included .DS_Store artifacts.
This commit is contained in:
parent
27b5b9fc78
commit
0d3272da46
12 changed files with 346 additions and 600 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
BIN
app/.DS_Store
vendored
Normal file
BIN
app/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -3,15 +3,14 @@
|
|||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Collection } from '@/lib/types';
|
||||
import { OrderItem } from '@/lib/types';
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import LocationTracker from '@/components/LocationTracker';
|
||||
import { getAssignedCollections, isAuthenticated } from '@/lib/pocketbase';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, parseCoordinates } from '@/lib/utils';
|
||||
import { RefreshCw, MapPin, AlertCircle } from 'lucide-react';
|
||||
|
||||
// Dynamic import for Leaflet to avoid SSR issues
|
||||
const CollectionMap = dynamic(() => import('@/components/CollectionMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
|
|
@ -24,25 +23,22 @@ const CollectionMap = dynamic(() => import('@/components/CollectionMap'), {
|
|||
export default function MapPage() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, isLoading: authLoading } = useAuth();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [items, setItems] = useState<OrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isLoggedIn) {
|
||||
router.push('/login/');
|
||||
}
|
||||
if (!authLoading && !isLoggedIn) router.push('/login/');
|
||||
}, [isLoggedIn, authLoading, router]);
|
||||
|
||||
const loadCollections = useCallback(async (showLoading = true) => {
|
||||
const loadItems = useCallback(async (showLoading = true) => {
|
||||
if (!isAuthenticated()) return;
|
||||
if (showLoading) setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await getAssignedCollections();
|
||||
setCollections(data);
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load collections:', err);
|
||||
setError('Failed to load collections');
|
||||
|
|
@ -52,20 +48,13 @@ export default function MapPage() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCollections();
|
||||
}, [loadCollections]);
|
||||
useEffect(() => { loadItems(); }, [loadItems]);
|
||||
|
||||
const handleNavigate = (lat: number, lng: number, name: string) => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
let url: string;
|
||||
|
||||
if (isIOS) {
|
||||
url = `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`;
|
||||
} else {
|
||||
url = `https://www.google.com/maps?q=${lat},${lng}`;
|
||||
}
|
||||
|
||||
const url = isIOS
|
||||
? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`
|
||||
: `https://www.google.com/maps?q=${lat},${lng}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
|
|
@ -77,15 +66,12 @@ export default function MapPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return null;
|
||||
}
|
||||
if (!isLoggedIn) return null;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-50">
|
||||
<LocationTracker />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-orange-600 text-white safe-top z-40 shadow-md flex-shrink-0">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -93,27 +79,18 @@ export default function MapPage() {
|
|||
<h1 className="text-lg font-bold">Collection Map</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-orange-700/50 px-2 py-1 rounded-full text-xs">
|
||||
{collections.length} stops
|
||||
</span>
|
||||
<span className="bg-orange-700/50 px-2 py-1 rounded-full text-xs">{items.length} stops</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRefreshing(true);
|
||||
loadCollections(false);
|
||||
}}
|
||||
onClick={() => { setIsRefreshing(true); loadItems(false); }}
|
||||
disabled={isRefreshing}
|
||||
className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={cn(isRefreshing && 'animate-spin')}
|
||||
/>
|
||||
<RefreshCw size={18} className={cn(isRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Map */}
|
||||
<main className="flex-1 relative">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center bg-gray-100">
|
||||
|
|
@ -123,31 +100,21 @@ export default function MapPage() {
|
|||
<div className="h-full flex flex-col items-center justify-center bg-gray-100 p-4">
|
||||
<AlertCircle size={48} className="text-red-400 mb-3" />
|
||||
<p className="text-gray-600 text-sm mb-4 text-center">{error}</p>
|
||||
<button
|
||||
onClick={() => loadCollections()}
|
||||
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button onClick={() => loadItems()} className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium">Try Again</button>
|
||||
</div>
|
||||
) : collections.length === 0 ? (
|
||||
) : items.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-gray-100 p-4">
|
||||
<MapPin size={48} className="text-gray-300 mb-3" />
|
||||
<p className="text-gray-600 font-medium">No collections to show</p>
|
||||
<p className="text-gray-400 text-sm mt-1">All collections completed!</p>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full flex items-center justify-center bg-gray-100">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CollectionMap
|
||||
collections={collections}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
<Suspense fallback={
|
||||
<div className="h-full flex items-center justify-center bg-gray-100">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<CollectionMap items={items} onNavigate={handleNavigate} />
|
||||
</Suspense>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
237
app/page.tsx
237
app/page.tsx
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Collection } from '@/lib/types';
|
||||
import { OrderItem } from '@/lib/types';
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import LocationTracker from '@/components/LocationTracker';
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
isAuthenticated,
|
||||
logout,
|
||||
} from '@/lib/pocketbase';
|
||||
import { cn, formatDate } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
|
|
@ -27,41 +27,38 @@ import {
|
|||
Package,
|
||||
Search,
|
||||
X,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [filteredCollections, setFilteredCollections] = useState<Collection[]>([]);
|
||||
const [items, setItems] = useState<OrderItem[]>([]);
|
||||
const [filteredItems, setFilteredItems] = useState<OrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCollection, setSelectedCollection] = useState<Collection | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<OrderItem | null>(null);
|
||||
const [showCollectModal, setShowCollectModal] = useState(false);
|
||||
const [showProblemModal, setShowProblemModal] = useState(false);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'today' | 'tomorrow'>('all');
|
||||
const [filter, setFilter] = useState<'all' | 'today' | 'all_date'>('all');
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isLoggedIn) {
|
||||
router.push('/login/');
|
||||
}
|
||||
}, [isLoggedIn, authLoading, router]);
|
||||
|
||||
const loadCollections = useCallback(async (showLoading = true) => {
|
||||
const loadItems = useCallback(async (showLoading = true) => {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
if (showLoading) setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await getAssignedCollections();
|
||||
setCollections(data);
|
||||
setFilteredCollections(data);
|
||||
setItems(data);
|
||||
setFilteredItems(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load collections:', err);
|
||||
setError('Failed to load collections. Please try again.');
|
||||
|
|
@ -72,22 +69,18 @@ export default function CollectionsPage() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCollections();
|
||||
}, [loadCollections]);
|
||||
loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
// Filter collections
|
||||
useEffect(() => {
|
||||
let filtered = collections;
|
||||
let filtered = items;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((c) => {
|
||||
const desc = c.is_manual
|
||||
? c.description
|
||||
: c.expand?.orders_orderitem?.description || '';
|
||||
const supplier = c.expand?.wfdash_suppliers?.suppliername || '';
|
||||
const orderNum = c.expand?.orders_orderitem?.expand?.orders_order?.order_number || '';
|
||||
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) ||
|
||||
|
|
@ -96,33 +89,12 @@ export default function CollectionsPage() {
|
|||
});
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (filter !== 'all') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
filtered = filtered.filter((c) => {
|
||||
if (!c.planned_date) return filter === 'today';
|
||||
const planned = new Date(c.planned_date);
|
||||
planned.setHours(0, 0, 0, 0);
|
||||
|
||||
if (filter === 'today') {
|
||||
return planned.getTime() === today.getTime();
|
||||
} else if (filter === 'tomorrow') {
|
||||
return planned.getTime() === tomorrow.getTime();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
setFilteredCollections(filtered);
|
||||
}, [collections, searchQuery, filter]);
|
||||
setFilteredItems(filtered);
|
||||
}, [items, searchQuery]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await loadCollections(false);
|
||||
await loadItems(false);
|
||||
};
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
||||
|
|
@ -130,16 +102,15 @@ export default function CollectionsPage() {
|
|||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const handleCollect = async (collection: Collection, receivedQty: number, notes: string) => {
|
||||
const handleCollect = async (item: OrderItem, receivedQty: number, notes: string) => {
|
||||
try {
|
||||
await updateCollectionStatus(collection.id, 'collected', {
|
||||
received_qty: receivedQty,
|
||||
await updateCollectionStatus(item.id, 'collected', {
|
||||
qty_collected: receivedQty,
|
||||
notes,
|
||||
actual_date: new Date().toISOString(),
|
||||
collected_date: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Remove from list
|
||||
setCollections((prev) => prev.filter((c) => c.id !== collection.id));
|
||||
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
||||
setShowCollectModal(false);
|
||||
showToast('Collection completed successfully');
|
||||
} catch (err) {
|
||||
|
|
@ -149,14 +120,14 @@ export default function CollectionsPage() {
|
|||
};
|
||||
|
||||
const handleProblem = async (
|
||||
collection: Collection,
|
||||
item: OrderItem,
|
||||
problemType: string,
|
||||
description: string,
|
||||
photo?: File
|
||||
) => {
|
||||
try {
|
||||
await reportProblem(collection.id, problemType, description);
|
||||
setCollections((prev) => prev.filter((c) => c.id !== collection.id));
|
||||
await reportProblem(item.id, problemType, description);
|
||||
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
||||
setShowProblemModal(false);
|
||||
showToast('Problem reported successfully', 'warning');
|
||||
} catch (err) {
|
||||
|
|
@ -167,29 +138,23 @@ export default function CollectionsPage() {
|
|||
|
||||
const handleNavigate = (lat: number, lng: number, name: string) => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
let url: string;
|
||||
|
||||
if (isIOS) {
|
||||
url = `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`;
|
||||
} else {
|
||||
url = `https://www.google.com/maps?q=${lat},${lng}`;
|
||||
}
|
||||
|
||||
const url = isIOS
|
||||
? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`
|
||||
: `https://www.google.com/maps?q=${lat},${lng}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
// Group by supplier
|
||||
const groupedBySupplier = filteredCollections.reduce((acc, collection) => {
|
||||
const supplier = collection.expand?.wfdash_suppliers;
|
||||
const groupedBySupplier = filteredItems.reduce((acc, item) => {
|
||||
const supplier = item.expand?.supplier;
|
||||
if (!supplier) return acc;
|
||||
|
||||
const key = supplier.id;
|
||||
if (!acc[key]) {
|
||||
acc[key] = { supplier, items: [] };
|
||||
}
|
||||
acc[key].items.push(collection);
|
||||
acc[key].items.push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, { supplier: NonNullable<Collection['expand']>['wfdash_suppliers']; items: Collection[] }>);
|
||||
}, {} as Record<string, { supplier: NonNullable<OrderItem['expand']>['supplier']; items: OrderItem[] }>);
|
||||
|
||||
const groupedArray = Object.values(groupedBySupplier).sort((a, b) =>
|
||||
(a.supplier?.suppliername || '').localeCompare(b.supplier?.suppliername || '')
|
||||
|
|
@ -203,48 +168,30 @@ export default function CollectionsPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
if (!isLoggedIn) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-20">
|
||||
<LocationTracker />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-orange-600 text-white safe-top sticky top-0 z-40 shadow-md">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Collections</h1>
|
||||
<p className="text-xs text-orange-100">
|
||||
{user?.name || user?.email}
|
||||
</p>
|
||||
<p className="text-xs text-orange-100">{user?.name || user?.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={cn(isRefreshing && 'animate-spin')}
|
||||
/>
|
||||
<button onClick={handleRefresh} disabled={isRefreshing} className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors">
|
||||
<RefreshCw size={18} className={cn(isRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
}}
|
||||
className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors"
|
||||
>
|
||||
<button onClick={() => logout()} className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="px-4 pb-3">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
|
|
@ -256,63 +203,33 @@ export default function CollectionsPage() {
|
|||
className="w-full pl-9 pr-9 py-2 bg-white/10 border border-white/20 rounded-xl text-sm text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/60"
|
||||
>
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-white/60">
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="px-4 pb-2 flex gap-2 overflow-x-auto no-scrollbar">
|
||||
{[
|
||||
{ key: 'all' as const, label: 'All', icon: Package },
|
||||
{ key: 'today' as const, label: 'Today', icon: Clock },
|
||||
{ key: 'tomorrow' as const, label: 'Tomorrow', icon: Clock },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors',
|
||||
filter === key
|
||||
? 'bg-white text-orange-600'
|
||||
: 'bg-orange-700/50 text-orange-100'
|
||||
filter === key ? 'bg-white text-orange-600' : 'bg-orange-700/50 text-orange-100'
|
||||
)}
|
||||
>
|
||||
<Icon size={12} />
|
||||
{label}
|
||||
<span className="bg-orange-800/50 px-1.5 py-0.5 rounded-full text-[10px]">
|
||||
{key === 'all'
|
||||
? collections.length
|
||||
: key === 'today'
|
||||
? collections.filter((c) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const planned = c.planned_date ? new Date(c.planned_date) : today;
|
||||
planned.setHours(0, 0, 0, 0);
|
||||
return planned.getTime() === today.getTime();
|
||||
}).length
|
||||
: collections.filter((c) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const planned = c.planned_date ? new Date(c.planned_date) : null;
|
||||
if (!planned) return false;
|
||||
planned.setHours(0, 0, 0, 0);
|
||||
return planned.getTime() === tomorrow.getTime();
|
||||
}).length}
|
||||
</span>
|
||||
<span className="bg-orange-800/50 px-1.5 py-0.5 rounded-full text-[10px]">{items.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="p-4">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -328,10 +245,7 @@ export default function CollectionsPage() {
|
|||
<div className="text-center py-12">
|
||||
<AlertCircle size={48} className="mx-auto text-red-400 mb-3" />
|
||||
<p className="text-gray-600 text-sm mb-4">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium active:bg-orange-600"
|
||||
>
|
||||
<button onClick={handleRefresh} className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium active:bg-orange-600">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -340,45 +254,28 @@ export default function CollectionsPage() {
|
|||
<CheckCircle2 size={48} className="mx-auto text-green-400 mb-3" />
|
||||
<p className="text-gray-600 font-medium">All caught up!</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{searchQuery
|
||||
? 'No collections match your search'
|
||||
: 'No assigned collections for this period'}
|
||||
{searchQuery ? 'No collections match your search' : 'No assigned collections'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groupedArray.map(({ supplier, items }) => (
|
||||
<div key={supplier!.id}>
|
||||
{/* Supplier Header */}
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<MapPin size={14} className="text-orange-500" />
|
||||
<h2 className="font-semibold text-sm text-gray-800">
|
||||
{supplier!.suppliername}
|
||||
</h2>
|
||||
<span className="bg-orange-100 text-orange-700 text-[10px] px-2 py-0.5 rounded-full font-medium">
|
||||
{items.length}
|
||||
</span>
|
||||
<h2 className="font-semibold text-sm text-gray-800">{supplier!.suppliername}</h2>
|
||||
<span className="bg-orange-100 text-orange-700 text-[10px] px-2 py-0.5 rounded-full font-medium">{items.length}</span>
|
||||
{supplier!.supplieraddress && (
|
||||
<span className="text-xs text-gray-400 truncate flex-1 text-right">
|
||||
{supplier!.supplieraddress}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 truncate flex-1 text-right">{supplier!.supplieraddress}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-2">
|
||||
{items.map((collection) => (
|
||||
{items.map((item) => (
|
||||
<CollectionItemCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onCollect={(c) => {
|
||||
setSelectedCollection(c);
|
||||
setShowCollectModal(true);
|
||||
}}
|
||||
onProblem={(c) => {
|
||||
setSelectedCollection(c);
|
||||
setShowProblemModal(true);
|
||||
}}
|
||||
key={item.id}
|
||||
item={item}
|
||||
onCollect={(i) => { setSelectedItem(i); setShowCollectModal(true); }}
|
||||
onProblem={(i) => { setSelectedItem(i); setShowProblemModal(true); }}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -389,38 +286,28 @@ export default function CollectionsPage() {
|
|||
)}
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
<CollectModal
|
||||
collection={selectedCollection}
|
||||
item={selectedItem}
|
||||
isOpen={showCollectModal}
|
||||
onClose={() => {
|
||||
setShowCollectModal(false);
|
||||
setSelectedCollection(null);
|
||||
}}
|
||||
onClose={() => { setShowCollectModal(false); setSelectedItem(null); }}
|
||||
onConfirm={handleCollect}
|
||||
/>
|
||||
|
||||
<ProblemModal
|
||||
collection={selectedCollection}
|
||||
item={selectedItem}
|
||||
isOpen={showProblemModal}
|
||||
onClose={() => {
|
||||
setShowProblemModal(false);
|
||||
setSelectedCollection(null);
|
||||
}}
|
||||
onClose={() => { setShowProblemModal(false); setSelectedItem(null); }}
|
||||
onConfirm={handleProblem}
|
||||
/>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed top-4 left-4 right-4 z-[60] animate-fade-in">
|
||||
<div
|
||||
className={cn(
|
||||
'px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white text-center',
|
||||
toast.type === 'success' && 'bg-green-500',
|
||||
toast.type === 'error' && 'bg-red-500',
|
||||
toast.type === 'warning' && 'bg-amber-500'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white text-center',
|
||||
toast.type === 'success' && 'bg-green-500',
|
||||
toast.type === 'error' && 'bg-red-500',
|
||||
toast.type === 'warning' && 'bg-amber-500'
|
||||
)}>
|
||||
{toast.message}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Collection } from '@/lib/types';
|
||||
import { OrderItem } from '@/lib/types';
|
||||
import { cn, formatCurrency } from '@/lib/utils';
|
||||
import { X, Minus, Plus } from 'lucide-react';
|
||||
|
||||
interface CollectModalProps {
|
||||
collection: Collection | null;
|
||||
item: OrderItem | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (collection: Collection, receivedQty: number, notes: string) => void;
|
||||
onConfirm: (item: OrderItem, receivedQty: number, notes: string) => void;
|
||||
}
|
||||
|
||||
export default function CollectModal({
|
||||
collection,
|
||||
item,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
|
|
@ -22,9 +22,9 @@ export default function CollectModal({
|
|||
const [notes, setNotes] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!isOpen || !collection) return null;
|
||||
if (!isOpen || !item) return null;
|
||||
|
||||
const expectedQty = collection.quantity || collection.expand?.orders_orderitem?.quantity || 0;
|
||||
const expectedQty = item.order_qty || item.quantity || 0;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -36,7 +36,7 @@ export default function CollectModal({
|
|||
return;
|
||||
}
|
||||
|
||||
onConfirm(collection, qty, notes);
|
||||
onConfirm(item, qty, notes);
|
||||
setReceivedQty('');
|
||||
setNotes('');
|
||||
setError('');
|
||||
|
|
@ -65,17 +65,18 @@ export default function CollectModal({
|
|||
</div>
|
||||
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{collection.is_manual
|
||||
? collection.description
|
||||
: collection.expand?.orders_orderitem?.description || 'Unknown item'}
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate">{item.description}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Expected: {expectedQty} {collection.unit || collection.expand?.orders_orderitem?.unit || 'units'}
|
||||
Expected: {expectedQty} units
|
||||
</p>
|
||||
{collection.expand?.orders_orderitem?.selling_price && (
|
||||
{item.expand?.order && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Order: {item.expand.order.order_number}
|
||||
</p>
|
||||
)}
|
||||
{item.selling_price && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Unit price: {formatCurrency(collection.expand.orders_orderitem.selling_price)}
|
||||
Unit price: {formatCurrency(item.selling_price)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Collection } from '@/lib/types';
|
||||
import { OrderItem } from '@/lib/types';
|
||||
import { cn, formatDate, formatCurrency, parseCoordinates } from '@/lib/utils';
|
||||
import {
|
||||
Check,
|
||||
|
|
@ -17,19 +17,19 @@ import {
|
|||
} from 'lucide-react';
|
||||
|
||||
interface CollectionItemProps {
|
||||
collection: Collection;
|
||||
onCollect: (collection: Collection) => void;
|
||||
onProblem: (collection: Collection) => void;
|
||||
item: OrderItem;
|
||||
onCollect: (item: OrderItem) => void;
|
||||
onProblem: (item: OrderItem) => void;
|
||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
}
|
||||
|
||||
function getItemType(collection: Collection): {
|
||||
function getItemType(item: OrderItem): {
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
label: string;
|
||||
} {
|
||||
if (collection.status === 'problem') {
|
||||
if (item.item_status === 'problem' || item.problem_flag) {
|
||||
return {
|
||||
type: 'problem',
|
||||
icon: <AlertTriangle size={16} />,
|
||||
|
|
@ -38,8 +38,7 @@ function getItemType(collection: Collection): {
|
|||
};
|
||||
}
|
||||
|
||||
const orderNumber = collection.expand?.orders_orderitem?.expand?.orders_order?.order_number || '';
|
||||
const desc = collection.description || collection.expand?.orders_orderitem?.description || '';
|
||||
const orderNumber = item.expand?.order?.order_number || '';
|
||||
|
||||
if (orderNumber.toLowerCase().includes('breakdown')) {
|
||||
return {
|
||||
|
|
@ -59,12 +58,12 @@ function getItemType(collection: Collection): {
|
|||
};
|
||||
}
|
||||
|
||||
if (collection.is_manual) {
|
||||
if (item.is_stock_item) {
|
||||
return {
|
||||
type: 'manual',
|
||||
type: 'stock',
|
||||
icon: <Package size={16} />,
|
||||
color: 'text-purple-500',
|
||||
label: 'Manual',
|
||||
label: 'Stock',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -77,38 +76,29 @@ function getItemType(collection: Collection): {
|
|||
}
|
||||
|
||||
export default function CollectionItemCard({
|
||||
collection,
|
||||
item,
|
||||
onCollect,
|
||||
onProblem,
|
||||
onNavigate,
|
||||
}: CollectionItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const supplier = collection.expand?.wfdash_suppliers;
|
||||
const orderItem = collection.expand?.orders_orderitem;
|
||||
const itemType = getItemType(collection);
|
||||
const supplier = item.expand?.supplier;
|
||||
const itemType = getItemType(item);
|
||||
|
||||
// Parse coordinates from text field
|
||||
const coords = parseCoordinates(supplier?.coordinates);
|
||||
const hasCoords = coords !== null;
|
||||
|
||||
const description = collection.is_manual
|
||||
? collection.description
|
||||
: orderItem?.description || 'Unknown item';
|
||||
|
||||
const quantity = collection.quantity || orderItem?.quantity || 0;
|
||||
const unit = collection.unit || orderItem?.unit || 'units';
|
||||
|
||||
const orderNumber = orderItem?.expand?.orders_order?.order_number || '';
|
||||
const poNumber = orderItem?.expand?.orders_purchaseorder?.po_number || '';
|
||||
const description = item.description || 'Unknown item';
|
||||
const expectedQty = item.order_qty || item.quantity || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white rounded-xl card-shadow mb-3 overflow-hidden animate-fade-in',
|
||||
collection.status === 'problem' && 'border-l-4 border-red-500',
|
||||
collection.status === 'assigned' && 'border-l-4 border-orange-500',
|
||||
collection.status === 'pending' && 'border-l-4 border-gray-300'
|
||||
(item.item_status === 'problem' || item.problem_flag) && 'border-l-4 border-red-500',
|
||||
item.item_status === 'assigned' && 'border-l-4 border-orange-500',
|
||||
item.driver_status === 'assigned' && 'border-l-4 border-orange-500'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -131,7 +121,7 @@ export default function CollectionItemCard({
|
|||
itemType.type === 'problem' && 'bg-red-100 text-red-700',
|
||||
itemType.type === 'breakdown' && 'bg-orange-100 text-orange-700',
|
||||
itemType.type === 'repair' && 'bg-amber-100 text-amber-700',
|
||||
itemType.type === 'manual' && 'bg-purple-100 text-purple-700',
|
||||
itemType.type === 'stock' && 'bg-purple-100 text-purple-700',
|
||||
itemType.type === 'normal' && 'bg-orange-100 text-orange-700'
|
||||
)}
|
||||
>
|
||||
|
|
@ -143,15 +133,15 @@ export default function CollectionItemCard({
|
|||
<p className="font-medium text-gray-700">
|
||||
{supplier?.suppliername || 'Unknown Supplier'}
|
||||
</p>
|
||||
<p>Expected: {quantity} {unit}</p>
|
||||
{orderNumber && <p>Order: {orderNumber}</p>}
|
||||
{poNumber && <p>PO: {poNumber}</p>}
|
||||
{collection.planned_date && (
|
||||
<p>Planned: {formatDate(collection.planned_date)}</p>
|
||||
<p>Expected: {expectedQty} units</p>
|
||||
{item.expand?.order?.order_number && <p>Order: {item.expand.order.order_number}</p>}
|
||||
{item.expand?.purchase_order?.po_number && <p>PO: {item.expand.purchase_order.po_number}</p>}
|
||||
{item.collection_date && (
|
||||
<p>Planned: {formatDate(item.collection_date)}</p>
|
||||
)}
|
||||
{orderItem?.selling_price && (
|
||||
{item.selling_price && (
|
||||
<p className="text-green-600 font-medium">
|
||||
{formatCurrency(orderItem.selling_price * quantity)}
|
||||
{formatCurrency(item.selling_price * expectedQty)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -170,11 +160,20 @@ export default function CollectionItemCard({
|
|||
{/* Expanded Details */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-100 pt-3">
|
||||
{collection.notes && (
|
||||
{item.notes && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 mb-3">
|
||||
<p className="text-xs text-yellow-800 flex items-start gap-1.5">
|
||||
<AlertCircle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<span>{collection.notes}</span>
|
||||
<span>{item.notes}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.problem_notes && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2 mb-3">
|
||||
<p className="text-xs text-red-700 flex items-start gap-1.5">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<span>{item.problem_notes}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -190,7 +189,7 @@ export default function CollectionItemCard({
|
|||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCollect(collection);
|
||||
onCollect(item);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',
|
||||
|
|
@ -205,7 +204,7 @@ export default function CollectionItemCard({
|
|||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProblem(collection);
|
||||
onProblem(item);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@
|
|||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import { Collection, Supplier } from '@/lib/types';
|
||||
import { OrderItem, Supplier } from '@/lib/types';
|
||||
import { formatCurrency, calculateDistance, parseCoordinates } from '@/lib/utils';
|
||||
import { Navigation, AlertTriangle } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// Fix Leaflet default markers in Next.js
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
|
|
@ -21,32 +20,23 @@ const defaultIcon = L.icon({
|
|||
|
||||
const currentLocationIcon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `
|
||||
<div style="
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #ea580c;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
"></div>
|
||||
`,
|
||||
html: `<div style="width:20px;height:20px;background:#ea580c;border:3px solid white;border-radius:50%;box-shadow:0 2px 6px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
});
|
||||
|
||||
interface MapViewProps {
|
||||
collections: Collection[];
|
||||
items: OrderItem[];
|
||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
}
|
||||
|
||||
function MapBounds({ collections }: { collections: Collection[] }) {
|
||||
function MapBounds({ items }: { items: OrderItem[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const validPoints = collections
|
||||
.map((c) => {
|
||||
const coords = parseCoordinates(c.expand?.wfdash_suppliers?.coordinates);
|
||||
const validPoints = items
|
||||
.map((i) => {
|
||||
const coords = parseCoordinates(i.expand?.supplier?.coordinates);
|
||||
return coords ? [coords.latitude, coords.longitude] as [number, number] : null;
|
||||
})
|
||||
.filter((point): point is [number, number] => point !== null);
|
||||
|
|
@ -55,136 +45,92 @@ function MapBounds({ collections }: { collections: Collection[] }) {
|
|||
const bounds = L.latLngBounds(validPoints);
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||
}
|
||||
}, [map, collections]);
|
||||
}, [map, items]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CollectionMap({ collections, onNavigate }: MapViewProps) {
|
||||
export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
||||
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
|
||||
},
|
||||
() => {
|
||||
// Silently fail - desktop may not have GPS
|
||||
},
|
||||
(pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]),
|
||||
() => {},
|
||||
{ enableHighAccuracy: false, timeout: 30000 }
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Group collections by supplier
|
||||
const suppliers = useMemo(() => {
|
||||
const map = new Map<string, { supplier: Supplier; collections: Collection[]; coords: { latitude: number; longitude: number } }>();
|
||||
const map = new Map<string, { supplier: Supplier; items: OrderItem[]; coords: { latitude: number; longitude: number } }>();
|
||||
|
||||
collections.forEach((collection) => {
|
||||
if (!collection.expand?.wfdash_suppliers) return;
|
||||
const supplier = collection.expand.wfdash_suppliers;
|
||||
items.forEach((item) => {
|
||||
if (!item.expand?.supplier) return;
|
||||
const supplier = item.expand.supplier;
|
||||
const coords = parseCoordinates(supplier.coordinates);
|
||||
if (!coords) return;
|
||||
|
||||
const key = supplier.id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { supplier, collections: [], coords });
|
||||
map.set(key, { supplier, items: [], coords });
|
||||
}
|
||||
map.get(key)!.collections.push(collection);
|
||||
map.get(key)!.items.push(item);
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [collections]);
|
||||
}, [items]);
|
||||
|
||||
const defaultCenter: [number, number] = [-33.9249, 18.4241]; // Cape Town default
|
||||
const defaultCenter: [number, number] = [-33.9249, 18.4241];
|
||||
const center = userLocation || defaultCenter;
|
||||
|
||||
const getSupplierDistance = (coords: { latitude: number; longitude: number }) => {
|
||||
const getDistance = (coords: { latitude: number; longitude: number }) => {
|
||||
if (!userLocation) return null;
|
||||
return calculateDistance(
|
||||
userLocation[0],
|
||||
userLocation[1],
|
||||
coords.latitude,
|
||||
coords.longitude
|
||||
);
|
||||
return calculateDistance(userLocation[0], userLocation[1], coords.latitude, coords.longitude);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] w-full relative">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={12}
|
||||
className="h-full w-full"
|
||||
zoomControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<MapBounds collections={collections} />
|
||||
<MapContainer center={center} zoom={12} className="h-full w-full" zoomControl={false}>
|
||||
<TileLayer attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<MapBounds items={items} />
|
||||
|
||||
{userLocation && (
|
||||
<Marker position={userLocation} icon={currentLocationIcon}>
|
||||
<Popup>
|
||||
<div className="text-sm font-medium">Your Location</div>
|
||||
</Popup>
|
||||
<Popup><div className="text-sm font-medium">Your Location</div></Popup>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{suppliers.map(({ supplier, collections: supplierCollections, coords }) => {
|
||||
const distance = getSupplierDistance(coords);
|
||||
const hasProblem = supplierCollections.some((c) => c.status === 'problem');
|
||||
{suppliers.map(({ supplier, items: supplierItems, coords }) => {
|
||||
const distance = getDistance(coords);
|
||||
const hasProblem = supplierItems.some((i) => i.item_status === 'problem' || i.problem_flag);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={supplier.id}
|
||||
position={[coords.latitude, coords.longitude]}
|
||||
icon={defaultIcon}
|
||||
>
|
||||
<Marker key={supplier.id} position={[coords.latitude, coords.longitude]} icon={defaultIcon}>
|
||||
<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.supplieraddress}</p>
|
||||
|
||||
{distance && (
|
||||
<p className="text-xs text-orange-600 mb-2 font-medium">
|
||||
{distance.toFixed(1)} km away
|
||||
</p>
|
||||
)}
|
||||
{distance && <p className="text-xs text-orange-600 mb-2 font-medium">{distance.toFixed(1)} km away</p>}
|
||||
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{supplierCollections.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="text-xs p-1.5 bg-gray-50 rounded"
|
||||
>
|
||||
{supplierItems.map((i) => (
|
||||
<div key={i.id} className="text-xs p-1.5 bg-gray-50 rounded">
|
||||
<div className="flex items-center gap-1">
|
||||
{c.status === 'problem' && (
|
||||
<AlertTriangle size={12} className="text-red-500" />
|
||||
)}
|
||||
<span className="font-medium truncate">
|
||||
{c.is_manual
|
||||
? c.description
|
||||
: c.expand?.orders_orderitem?.description || 'Unknown'}
|
||||
</span>
|
||||
{(i.item_status === 'problem' || i.problem_flag) && <AlertTriangle size={12} className="text-red-500" />}
|
||||
<span className="font-medium truncate">{i.description}</span>
|
||||
</div>
|
||||
<span className="text-gray-400">
|
||||
Qty: {c.quantity} {c.unit || c.expand?.orders_orderitem?.unit || 'units'}
|
||||
</span>
|
||||
<span className="text-gray-400">Qty: {i.order_qty || i.quantity || 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
onNavigate(coords.latitude, coords.longitude, supplier.suppliername)
|
||||
}
|
||||
onClick={() => onNavigate(coords.latitude, coords.longitude, supplier.suppliername)}
|
||||
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
|
||||
<Navigation size={14} /> Navigate
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
|
|
@ -193,7 +139,6 @@ export default function CollectionMap({ collections, onNavigate }: MapViewProps)
|
|||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend overlay */}
|
||||
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm rounded-xl p-3 shadow-lg z-[400]">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Legend</p>
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -1,81 +1,45 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { getSettings, saveDriverLocation, addToQueue, isAuthenticated } from '@/lib/pocketbase';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getSettings, saveDriverLocation, isAuthenticated } from '@/lib/pocketbase';
|
||||
|
||||
export default function LocationTracker() {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastSentRef = useRef<number>(0);
|
||||
const hasPermissionRef = useRef<boolean | null>(null);
|
||||
|
||||
const sendLocation = useCallback(async (position: GeolocationPosition) => {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
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 || undefined,
|
||||
altitude: position.coords.altitude || undefined,
|
||||
speed: position.coords.speed || undefined,
|
||||
heading: position.coords.heading || undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
await saveDriverLocation(locationData);
|
||||
lastSentRef.current = Date.now();
|
||||
} catch (error) {
|
||||
// Queue for sync when back online
|
||||
addToQueue({
|
||||
type: 'status_update',
|
||||
data: locationData,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!('geolocation' in navigator)) {
|
||||
return;
|
||||
}
|
||||
if (!('geolocation' in navigator)) return;
|
||||
|
||||
const settings = getSettings();
|
||||
const intervalMs = Math.max(30000, settings.locationInterval * 1000);
|
||||
|
||||
const trackLocation = () => {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
const auth = JSON.parse(localStorage.getItem('pocketbase_auth') || '{}');
|
||||
const user = auth?.model;
|
||||
if (!user) return;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
hasPermissionRef.current = true;
|
||||
sendLocation(position);
|
||||
if (position.coords.accuracy > settings.minimumAccuracy) return;
|
||||
|
||||
saveDriverLocation({
|
||||
driver: user.id,
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy || undefined,
|
||||
altitude: position.coords.altitude || undefined,
|
||||
speed: position.coords.speed || undefined,
|
||||
heading: position.coords.heading || undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
(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: 30000,
|
||||
}
|
||||
() => {},
|
||||
{ enableHighAccuracy: settings.enableHighAccuracy, maximumAge: intervalMs, timeout: 30000 }
|
||||
);
|
||||
};
|
||||
|
||||
// Send immediately on mount
|
||||
trackLocation();
|
||||
|
||||
// Then on interval
|
||||
intervalRef.current = setInterval(trackLocation, intervalMs);
|
||||
|
||||
return () => {
|
||||
|
|
@ -84,17 +48,7 @@ export default function LocationTracker() {
|
|||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Collection, PROBLEM_TYPES } from '@/lib/types';
|
||||
import { OrderItem, PROBLEM_TYPES } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { X, Camera } from 'lucide-react';
|
||||
|
||||
interface ProblemModalProps {
|
||||
collection: Collection | null;
|
||||
item: OrderItem | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (collection: Collection, problemType: string, description: string, photo?: File) => void;
|
||||
onConfirm: (item: OrderItem, problemType: string, description: string, photo?: File) => void;
|
||||
}
|
||||
|
||||
export default function ProblemModal({
|
||||
collection,
|
||||
item,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
|
|
@ -23,7 +23,7 @@ export default function ProblemModal({
|
|||
const [photo, setPhoto] = useState<File | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!isOpen || !collection) return null;
|
||||
if (!isOpen || !item) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -39,7 +39,7 @@ export default function ProblemModal({
|
|||
return;
|
||||
}
|
||||
|
||||
onConfirm(collection, problemType, description, photo || undefined);
|
||||
onConfirm(item, problemType, description, photo || undefined);
|
||||
setProblemType('');
|
||||
setDescription('');
|
||||
setPhoto(null);
|
||||
|
|
@ -61,13 +61,9 @@ export default function ProblemModal({
|
|||
</div>
|
||||
|
||||
<div className="mb-4 p-3 bg-red-50 rounded-lg">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{collection.is_manual
|
||||
? collection.description
|
||||
: collection.expand?.orders_orderitem?.description || 'Unknown item'}
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate">{item.description}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{collection.expand?.wfdash_suppliers?.suppliername || 'Unknown Supplier'}
|
||||
{item.expand?.supplier?.suppliername || 'Unknown Supplier'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
77
import_suppliers.py
Normal file
77
import_suppliers.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Import suppliers from NocoDB into PocketBase"""
|
||||
import requests, json, time, sys
|
||||
|
||||
PB_URL = "https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io"
|
||||
NOCODB_API = "https://nocodb.apphub.co.za/api/v3/data/putj028icfsplze/md9msyn5qco5ttu/records"
|
||||
TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3ODAyNjMwODcsImlkIjoiazA0NXlmZXplOTJja3g5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.4tUpls9uCAgcvwTvbWBrcl8_NLxgS__oaWZNRAEGsDo"
|
||||
|
||||
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
|
||||
|
||||
# Get existing supplier names
|
||||
try:
|
||||
existing = requests.get(f"{PB_URL}/api/collections/suppliers/records?perPage=500", headers=HEADERS).json()
|
||||
existing_names = {r.get("suppliername", "").strip() for r in existing.get("items", []) if r.get("suppliername")}
|
||||
except:
|
||||
existing_names = set()
|
||||
|
||||
print(f"Existing suppliers in PocketBase: {len(existing_names)}")
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
|
||||
# Use NocoDB MCP-style approach - manually construct records from known data
|
||||
# We'll fetch via direct API with the auth header
|
||||
page = 1
|
||||
while True:
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{NOCODB_API}?pageSize=100&page={page}&fields=suppliername,email,suppliernumber,supplieraddress,coordinates,closingtime,supply_tags",
|
||||
headers={"xc-token": "PUT_TOKEN_HERE"} # NocoDB needs its own token
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print(f"Page {page}: HTTP {resp.status_code} — stopping")
|
||||
break
|
||||
|
||||
data = resp.json()
|
||||
records = data.get("records", [])
|
||||
if not records:
|
||||
break
|
||||
|
||||
for r in records:
|
||||
f = r["fields"]
|
||||
name = (f.get("suppliername") or "").strip()
|
||||
if not name or name in existing_names:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
payload = {"suppliername": name}
|
||||
for k in ["supplieraddress", "coordinates", "email", "suppliernumber", "closingtime", "supply_tags"]:
|
||||
v = f.get(k)
|
||||
if v and str(v).strip():
|
||||
payload[k] = str(v).strip()
|
||||
|
||||
if len(payload) <= 1:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
create = requests.post(f"{PB_URL}/api/collections/suppliers/records", headers=HEADERS, json=payload)
|
||||
if create.status_code == 200:
|
||||
created += 1
|
||||
existing_names.add(name)
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
print(f"Page {page}: processed {len(records)} records ({created} created, {skipped} skipped so far)")
|
||||
page += 1
|
||||
|
||||
if not data.get("next"):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
break
|
||||
|
||||
print(f"\nDone! Created: {created}, Skipped (existing/no data): {skipped}")
|
||||
|
|
@ -1,15 +1,10 @@
|
|||
import PocketBase from 'pocketbase';
|
||||
import { AppSettings, DEFAULT_SETTINGS, Collection, DriverLocation } from './types';
|
||||
import { AppSettings, DEFAULT_SETTINGS, OrderItem, DriverLocation } from './types';
|
||||
|
||||
const POCKETBASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';
|
||||
|
||||
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;
|
||||
|
||||
|
|
@ -23,81 +18,55 @@ export const logout = () => {
|
|||
};
|
||||
|
||||
export const login = async (email: string, password: string) => {
|
||||
// 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);
|
||||
}
|
||||
return await pb.collection('users').authWithPassword(email, password);
|
||||
};
|
||||
|
||||
// Collection data helpers
|
||||
// Get assigned collections (order_items assigned to current driver)
|
||||
export const getAssignedCollections = async () => {
|
||||
const user = getCurrentUser();
|
||||
if (!user) return [];
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 2);
|
||||
|
||||
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',
|
||||
const records = await pb.collection('order_items').getFullList({
|
||||
filter: `driver = "${user.id}" && (item_status = "assigned" || item_status = "driver_pool")`,
|
||||
expand: 'supplier,order,purchase_order',
|
||||
sort: 'supplier.suppliername',
|
||||
});
|
||||
|
||||
return records as unknown as Collection[];
|
||||
};
|
||||
|
||||
export const getPendingCollections = async () => {
|
||||
const records = await pb.collection(COLLECTIONS_TABLE).getFullList({
|
||||
filter: 'status = "pending" || status = "problem"',
|
||||
expand: 'orders_orderitem,wfdash_suppliers',
|
||||
sort: 'wfdash_suppliers.suppliername',
|
||||
});
|
||||
|
||||
return records as unknown as Collection[];
|
||||
return records as unknown as OrderItem[];
|
||||
};
|
||||
|
||||
// Update order item status when collected
|
||||
export const updateCollectionStatus = async (
|
||||
id: string,
|
||||
status: string,
|
||||
data: Partial<Collection>
|
||||
data: { qty_collected?: number; notes?: string; collected_date?: string; problem_type?: string; problem_flag?: boolean }
|
||||
) => {
|
||||
return await pb.collection(COLLECTIONS_TABLE).update(id, {
|
||||
status,
|
||||
return await pb.collection('order_items').update(id, {
|
||||
item_status: status,
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
// Report a problem on an order item
|
||||
export const reportProblem = async (
|
||||
collectionId: string,
|
||||
orderItemId: string,
|
||||
problemType: string,
|
||||
description: string
|
||||
) => {
|
||||
// Update collection status
|
||||
await pb.collection(COLLECTIONS_TABLE).update(collectionId, {
|
||||
status: 'problem',
|
||||
notes: description,
|
||||
});
|
||||
|
||||
// Create problem record
|
||||
return await pb.collection(PROBLEMS_TABLE).create({
|
||||
driver_list_collection: collectionId,
|
||||
return await pb.collection('order_items').update(orderItemId, {
|
||||
item_status: 'problem',
|
||||
problem_type: problemType,
|
||||
description,
|
||||
reported_at: new Date().toISOString(),
|
||||
problem_flag: true,
|
||||
problem_notes: description,
|
||||
});
|
||||
};
|
||||
|
||||
// Location tracking
|
||||
// Location tracking - tries to save, fails silently if table doesn't exist
|
||||
export const saveDriverLocation = async (location: Omit<DriverLocation, 'id'>) => {
|
||||
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);
|
||||
return await pb.collection('driver_locations').create(location);
|
||||
} catch {
|
||||
// Silently fail - table may not exist yet
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -124,42 +93,3 @@ export const saveSettings = (settings: Partial<AppSettings>) => {
|
|||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
};
|
||||
|
||||
// Offline queue helpers
|
||||
const QUEUE_KEY = '1driver_offline_queue';
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
type: 'status_update' | 'problem_report';
|
||||
data: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export const addToQueue = (item: Omit<QueueItem, 'id' | 'timestamp'>) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const queue = getQueue();
|
||||
const newItem: QueueItem = {
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
queue.push(newItem);
|
||||
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
|
||||
};
|
||||
|
||||
export const getQueue = (): QueueItem[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const saved = localStorage.getItem(QUEUE_KEY);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
};
|
||||
|
||||
export const removeFromQueue = (id: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const queue = getQueue().filter((item) => item.id !== id);
|
||||
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
|
||||
};
|
||||
|
||||
export const clearQueue = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(QUEUE_KEY);
|
||||
};
|
||||
|
|
|
|||
88
lib/types.ts
88
lib/types.ts
|
|
@ -1,66 +1,57 @@
|
|||
export interface Supplier {
|
||||
id: string;
|
||||
suppliername: string;
|
||||
email: string;
|
||||
suppliernumber: string;
|
||||
supplieraddress: string;
|
||||
coordinates?: string; // Raw coordinates text (e.g. "-33.9249, 18.4241")
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
supply_tags: string;
|
||||
coordinates: string; // Raw coordinates text (e.g. "-33.9249, 18.4241")
|
||||
closingtime: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
order_number: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrder {
|
||||
id: string;
|
||||
po_number: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
order: string;
|
||||
supplier: string;
|
||||
driver: string;
|
||||
description: string;
|
||||
po_description: string;
|
||||
quantity: number;
|
||||
order_qty: number;
|
||||
unit: string;
|
||||
selling_price?: number;
|
||||
selling_price: number;
|
||||
cost_price: number;
|
||||
item_status: string;
|
||||
expand?: {
|
||||
orders_order?: { order_number: string };
|
||||
orders_purchaseorder?: { po_number: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
orders_orderitem: string;
|
||||
wfdash_suppliers: string;
|
||||
auth_user: string;
|
||||
status: 'pending' | 'assigned' | 'collected' | 'problem';
|
||||
quantity: number;
|
||||
planned_date: string;
|
||||
actual_date: string;
|
||||
received_qty: number;
|
||||
status: string;
|
||||
driver_status: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_manual: boolean;
|
||||
description: string;
|
||||
unit: string;
|
||||
// Expanded fields use the relation field names from PocketBase
|
||||
problem_type: string;
|
||||
problem_flag: boolean;
|
||||
problem_notes: string;
|
||||
qty_collected: number;
|
||||
collected_date: string;
|
||||
collection_date: string;
|
||||
is_stock_item: boolean;
|
||||
// Expanded fields
|
||||
expand?: {
|
||||
orders_orderitem?: OrderItem;
|
||||
wfdash_suppliers?: Supplier;
|
||||
auth_user?: Driver;
|
||||
supplier?: Supplier;
|
||||
order?: Order;
|
||||
purchase_order?: PurchaseOrder;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CollectionProblem {
|
||||
id: string;
|
||||
driver_list_collection: string;
|
||||
problem_type: 'out_of_stock' | 'partial_stock' | 'quality_issue' | 'wrong_item' | 'other';
|
||||
description: string;
|
||||
photo?: string;
|
||||
reported_at: string;
|
||||
}
|
||||
|
||||
export interface Driver {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DriverLocation {
|
||||
id: string;
|
||||
driver: string;
|
||||
|
|
@ -71,12 +62,11 @@ export interface DriverLocation {
|
|||
speed?: number;
|
||||
heading?: number;
|
||||
timestamp: string;
|
||||
battery_level?: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
locationInterval: number; // in seconds
|
||||
minimumAccuracy: number; // in meters
|
||||
locationInterval: number;
|
||||
minimumAccuracy: number;
|
||||
enableHighAccuracy: boolean;
|
||||
darkMode: boolean;
|
||||
fontSize: 'small' | 'normal' | 'large';
|
||||
|
|
|
|||
Loading…
Reference in a new issue