WFDASHDRIVER-APP/app/page.tsx
SJCoetz b738bc7495 feat: initialize project with Next.js, TypeScript, and Tailwind CSS
- Added package.json with dependencies for Next.js, React, and Tailwind CSS.
- Created postcss.config.js for Tailwind CSS and autoprefixer integration.
- Added SVG icon for the application in public/icons/icon.svg.
- Created manifest.json for PWA support with app details and icon.
- Configured Tailwind CSS in tailwind.config.js with custom colors and animations.
- Set up TypeScript configuration in tsconfig.json for strict type checking and module resolution.
2026-05-24 21:51:29 +02:00

432 lines
15 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Collection } from '@/lib/types';
import { useAuth } from '@/components/AuthProvider';
import BottomNav from '@/components/BottomNav';
import LocationTracker from '@/components/LocationTracker';
import CollectionItemCard from '@/components/CollectionItemCard';
import CollectModal from '@/components/CollectModal';
import ProblemModal from '@/components/ProblemModal';
import {
getAssignedCollections,
updateCollectionStatus,
reportProblem,
isAuthenticated,
logout,
} from '@/lib/pocketbase';
import { cn, formatDate } from '@/lib/utils';
import {
RefreshCw,
LogOut,
MapPin,
AlertCircle,
CheckCircle2,
Clock,
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 [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 [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');
// Redirect if not authenticated
useEffect(() => {
if (!authLoading && !isLoggedIn) {
router.push('/login/');
}
}, [isLoggedIn, authLoading, router]);
const loadCollections = useCallback(async (showLoading = true) => {
if (!isAuthenticated()) return;
if (showLoading) setIsLoading(true);
setError('');
try {
const data = await getAssignedCollections();
setCollections(data);
setFilteredCollections(data);
} catch (err) {
console.error('Failed to load collections:', err);
setError('Failed to load collections. Please try again.');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
loadCollections();
}, [loadCollections]);
// Filter collections
useEffect(() => {
let filtered = collections;
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
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 || '';
return (
desc.toLowerCase().includes(query) ||
supplier.toLowerCase().includes(query) ||
orderNum.toLowerCase().includes(query)
);
});
}
// 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]);
const handleRefresh = async () => {
setIsRefreshing(true);
await loadCollections(false);
};
const showToast = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleCollect = async (collection: Collection, receivedQty: number, notes: string) => {
try {
await updateCollectionStatus(collection.id, 'collected', {
received_qty: receivedQty,
notes,
actual_date: new Date().toISOString(),
});
// Remove from list
setCollections((prev) => prev.filter((c) => c.id !== collection.id));
setShowCollectModal(false);
showToast('Collection completed successfully');
} catch (err) {
console.error('Failed to collect:', err);
showToast('Failed to update collection', 'error');
}
};
const handleProblem = async (
collection: Collection,
problemType: string,
description: string,
photo?: File
) => {
try {
await reportProblem(collection.id, problemType, description);
setCollections((prev) => prev.filter((c) => c.id !== collection.id));
setShowProblemModal(false);
showToast('Problem reported successfully', 'warning');
} catch (err) {
console.error('Failed to report problem:', err);
showToast('Failed to report problem', 'error');
}
};
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}`;
}
window.open(url, '_blank');
};
// Group by supplier
const groupedBySupplier = filteredCollections.reduce((acc, collection) => {
const supplier = collection.expand?.supplier;
if (!supplier) return acc;
const key = supplier.id;
if (!acc[key]) {
acc[key] = { supplier, items: [] };
}
acc[key].items.push(collection);
return acc;
}, {} as Record<string, { supplier: NonNullable<Collection['expand']>['supplier']; items: Collection[] }>);
const groupedArray = Object.values(groupedBySupplier).sort((a, b) =>
(a.supplier?.suppliername || '').localeCompare(b.supplier?.suppliername || '')
);
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!isLoggedIn) {
return null; // Will redirect
}
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>
</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>
<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" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search collections..."
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"
>
<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'
)}
>
<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>
</button>
))}
</div>
</header>
{/* Content */}
<main className="p-4">
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-white rounded-xl p-4 card-shadow">
<div className="skeleton h-4 w-3/4 rounded mb-2" />
<div className="skeleton h-3 w-1/2 rounded mb-1" />
<div className="skeleton h-3 w-2/3 rounded" />
</div>
))}
</div>
) : error ? (
<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"
>
Try Again
</button>
</div>
) : groupedArray.length === 0 ? (
<div className="text-center py-12">
<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'}
</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>
{supplier!.address && (
<span className="text-xs text-gray-400 truncate flex-1 text-right">
{supplier!.address}
</span>
)}
</div>
{/* Items */}
<div className="space-y-2">
{items.map((collection) => (
<CollectionItemCard
key={collection.id}
collection={collection}
onCollect={(c) => {
setSelectedCollection(c);
setShowCollectModal(true);
}}
onProblem={(c) => {
setSelectedCollection(c);
setShowProblemModal(true);
}}
onNavigate={handleNavigate}
/>
))}
</div>
</div>
))}
</div>
)}
</main>
{/* Modals */}
<CollectModal
collection={selectedCollection}
isOpen={showCollectModal}
onClose={() => {
setShowCollectModal(false);
setSelectedCollection(null);
}}
onConfirm={handleCollect}
/>
<ProblemModal
collection={selectedCollection}
isOpen={showProblemModal}
onClose={() => {
setShowProblemModal(false);
setSelectedCollection(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'
)}
>
{toast.message}
</div>
</div>
)}
<BottomNav />
</div>
);
}