Add full PWA support (icons, manifest, service worker, pwa lib and bridge) and introduce install/offline UI. Generate app icons via a new scripts/generate-icons.js and include multiple icon sizes in public/icons; update manifest and layout head meta/preconnect. Revamp global styles (globals.css) with new utilities, animations, spacing for bottom nav, card shadows, skeletons and accessibility tweaks. Polish major screens (login, collections, map, settings) with improved headers, loading states, touch/press feedback, and layout adjustments; add new components (InstallPrompt, OfflineIndicator, PWABridge) and update various components to match new visual/UX patterns. Update Dockerfile to generate icons and use Next.js static export output (/out) for nginx, and bump package setup to support icon generation.
342 lines
No EOL
13 KiB
TypeScript
342 lines
No EOL
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { OrderItem } 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 } from '@/lib/utils';
|
|
import {
|
|
RefreshCw,
|
|
LogOut,
|
|
MapPin,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Search,
|
|
X,
|
|
Package,
|
|
ListFilter,
|
|
} from 'lucide-react';
|
|
|
|
export default function CollectionsPage() {
|
|
const router = useRouter();
|
|
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
|
|
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 [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);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !isLoggedIn) router.push('/login/');
|
|
}, [isLoggedIn, authLoading, router]);
|
|
|
|
const loadItems = useCallback(async (showLoading = true) => {
|
|
if (!isAuthenticated()) return;
|
|
if (showLoading) setIsLoading(true);
|
|
setError('');
|
|
|
|
try {
|
|
const data = await getAssignedCollections();
|
|
setItems(data);
|
|
setFilteredItems(data);
|
|
} catch (err) {
|
|
console.error('Failed to load collections:', err);
|
|
setError('Failed to load collections. Pull down to retry.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { loadItems(); }, [loadItems]);
|
|
|
|
useEffect(() => {
|
|
let filtered = items;
|
|
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
filtered = filtered.filter((i) => {
|
|
const desc = i.description || '';
|
|
const supplier = i.expand?.supplier?.suppliername || '';
|
|
const orderNum = i.expand?.order?.order_number || '';
|
|
return (
|
|
desc.toLowerCase().includes(query) ||
|
|
supplier.toLowerCase().includes(query) ||
|
|
orderNum.toLowerCase().includes(query)
|
|
);
|
|
});
|
|
}
|
|
|
|
setFilteredItems(filtered);
|
|
}, [items, searchQuery]);
|
|
|
|
const handleRefresh = async () => {
|
|
setIsRefreshing(true);
|
|
await loadItems(false);
|
|
};
|
|
|
|
const showToast = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
|
setToast({ message, type });
|
|
setTimeout(() => setToast(null), 3000);
|
|
};
|
|
|
|
const handleCollect = async (item: OrderItem, receivedQty: number, notes: string) => {
|
|
try {
|
|
await updateCollectionStatus(item.id, 'collected', {
|
|
qty_collected: receivedQty,
|
|
notes,
|
|
collected_date: new Date().toISOString(),
|
|
});
|
|
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
|
setShowCollectModal(false);
|
|
showToast('Collection completed successfully');
|
|
} catch (err) {
|
|
console.error('Failed to collect:', err);
|
|
showToast('Failed to update collection', 'error');
|
|
}
|
|
};
|
|
|
|
const handleProblem = async (item: OrderItem, problemType: string, description: string) => {
|
|
try {
|
|
await reportProblem(item.id, problemType, description);
|
|
setItems((prev) => prev.filter((i) => i.id !== item.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);
|
|
const url = isIOS
|
|
? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`
|
|
: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
|
window.open(url, '_blank');
|
|
};
|
|
|
|
// Group by supplier
|
|
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(item);
|
|
return acc;
|
|
}, {} 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 || '')
|
|
);
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-10 h-10 border-[3px] border-orange-500 border-t-transparent rounded-full animate-spin" />
|
|
<p className="text-sm text-gray-400">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isLoggedIn) return null;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 pb-nav">
|
|
<LocationTracker />
|
|
|
|
{/* Header */}
|
|
<header className="bg-orange-600 text-white safe-top sticky top-0 z-40 shadow-lg shadow-orange-600/20">
|
|
<div className="px-4 pt-2 pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-xl font-bold tracking-tight">Collections</h1>
|
|
<p className="text-[13px] text-orange-100 truncate">{user?.name || user?.email}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="relative">
|
|
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-green-400 rounded-full border-2 border-orange-600 animate-pulse" />
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={isRefreshing}
|
|
className="p-2.5 rounded-xl bg-white/15 backdrop-blur-sm active:bg-white/25 transition-all touch-manipulation"
|
|
>
|
|
<RefreshCw size={18} className={cn(isRefreshing && 'animate-spin')} />
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => logout()}
|
|
className="p-2.5 rounded-xl bg-white/15 backdrop-blur-sm active:bg-white/25 transition-all touch-manipulation"
|
|
>
|
|
<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-white/50" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search by item, supplier, order..."
|
|
className="w-full pl-9 pr-9 py-2.5 bg-white/15 backdrop-blur-sm border border-white/20 rounded-xl text-sm text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 focus:bg-white/20 transition-all"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 active:text-white"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats pill */}
|
|
<div className="px-4 pb-2 flex items-center gap-2">
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/15 text-[11px] font-medium">
|
|
<Package size={12} />
|
|
{items.length} item{items.length !== 1 ? 's' : ''}
|
|
</span>
|
|
{groupedArray.length > 0 && (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/15 text-[11px] font-medium">
|
|
<MapPin size={12} />
|
|
{groupedArray.length} stop{groupedArray.length !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content */}
|
|
<main className="p-4 custom-scrollbar">
|
|
{isLoading ? (
|
|
<div className="space-y-3">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="bg-white rounded-2xl p-4 card-shadow stagger-1">
|
|
<div className="skeleton h-4 w-3/4 rounded-lg mb-3" />
|
|
<div className="skeleton h-3 w-1/2 rounded-lg mb-2" />
|
|
<div className="skeleton h-3 w-2/3 rounded-lg" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-16 px-4">
|
|
<div className="w-16 h-16 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<AlertCircle size={32} className="text-red-400" />
|
|
</div>
|
|
<p className="text-gray-600 font-medium mb-1">{error}</p>
|
|
<p className="text-gray-400 text-sm mb-6">Check your connection and try again</p>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="inline-flex items-center gap-2 px-6 py-3 bg-orange-500 text-white rounded-xl text-sm font-semibold active:bg-orange-600 transition-colors touch-manipulation press-feedback"
|
|
>
|
|
<RefreshCw size={16} />
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
) : groupedArray.length === 0 ? (
|
|
<div className="text-center py-16 px-4">
|
|
<div className="w-16 h-16 bg-green-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<CheckCircle2 size={32} className="text-green-400" />
|
|
</div>
|
|
<p className="text-gray-700 font-semibold text-lg mb-1">All caught up!</p>
|
|
<p className="text-gray-400 text-sm">
|
|
{searchQuery ? 'No collections match your search' : 'No assigned collections right now'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{groupedArray.map(({ supplier, items: supplierItems }, groupIdx) => (
|
|
<div key={supplier!.id} className="animate-fade-in" style={{ animationDelay: `${groupIdx * 0.05}s` }}>
|
|
{/* Supplier header */}
|
|
<div className="flex items-center gap-2.5 mb-3 px-1">
|
|
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
<MapPin size={14} className="text-orange-600" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h2 className="font-semibold text-sm text-gray-900 truncate">{supplier!.suppliername}</h2>
|
|
{supplier!.supplieraddress && (
|
|
<p className="text-[11px] text-gray-400 truncate">{supplier!.supplieraddress}</p>
|
|
)}
|
|
</div>
|
|
<span className="bg-orange-500 text-white text-[10px] px-2 py-0.5 rounded-full font-bold">
|
|
{supplierItems.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Items */}
|
|
<div className="space-y-2.5">
|
|
{supplierItems.map((item) => (
|
|
<CollectionItemCard
|
|
key={item.id}
|
|
item={item}
|
|
onCollect={(i) => { setSelectedItem(i); setShowCollectModal(true); }}
|
|
onProblem={(i) => { setSelectedItem(i); setShowProblemModal(true); }}
|
|
onNavigate={handleNavigate}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Modals */}
|
|
<CollectModal
|
|
item={selectedItem}
|
|
isOpen={showCollectModal}
|
|
onClose={() => { setShowCollectModal(false); setSelectedItem(null); }}
|
|
onConfirm={handleCollect}
|
|
/>
|
|
|
|
<ProblemModal
|
|
item={selectedItem}
|
|
isOpen={showProblemModal}
|
|
onClose={() => { setShowProblemModal(false); setSelectedItem(null); }}
|
|
onConfirm={handleProblem}
|
|
/>
|
|
|
|
{/* Toast */}
|
|
{toast && (
|
|
<div className="fixed top-4 left-4 right-4 z-[70] animate-slide-down safe-top">
|
|
<div className={cn(
|
|
'px-4 py-3 rounded-2xl shadow-lg text-sm font-medium text-white text-center press-feedback-sm',
|
|
toast.type === 'success' && 'bg-green-500',
|
|
toast.type === 'error' && 'bg-red-500',
|
|
toast.type === 'warning' && 'bg-amber-500'
|
|
)}>
|
|
{toast.message}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<BottomNav />
|
|
</div>
|
|
);
|
|
} |