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.
256 lines
No EOL
8.9 KiB
TypeScript
256 lines
No EOL
8.9 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { OrderItem } from '@/lib/types';
|
|
import { cn, formatDate, formatCurrency, parseCoordinates } from '@/lib/utils';
|
|
import {
|
|
Check,
|
|
AlertTriangle,
|
|
Navigation,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Package,
|
|
Wrench,
|
|
Hammer,
|
|
ShoppingCart,
|
|
AlertCircle,
|
|
Clock,
|
|
MapPin,
|
|
} from 'lucide-react';
|
|
|
|
interface CollectionItemProps {
|
|
item: OrderItem;
|
|
onCollect: (item: OrderItem) => void;
|
|
onProblem: (item: OrderItem) => void;
|
|
onNavigate: (lat: number, lng: number, name: string) => void;
|
|
}
|
|
|
|
function getItemType(item: OrderItem): {
|
|
type: string;
|
|
icon: React.ReactNode;
|
|
color: string;
|
|
bgColor: string;
|
|
label: string;
|
|
} {
|
|
if (item.item_status === 'problem' || item.problem_flag) {
|
|
return {
|
|
type: 'problem',
|
|
icon: <AlertTriangle size={16} />,
|
|
color: 'text-red-600',
|
|
bgColor: 'bg-red-50',
|
|
label: 'Problem',
|
|
};
|
|
}
|
|
|
|
const orderNumber = item.expand?.order?.order_number || '';
|
|
|
|
if (orderNumber.toLowerCase().includes('breakdown')) {
|
|
return {
|
|
type: 'breakdown',
|
|
icon: <Hammer size={16} />,
|
|
color: 'text-orange-600',
|
|
bgColor: 'bg-orange-50',
|
|
label: 'Breakdown',
|
|
};
|
|
}
|
|
|
|
if (orderNumber.toLowerCase().includes('repair')) {
|
|
return {
|
|
type: 'repair',
|
|
icon: <Wrench size={16} />,
|
|
color: 'text-amber-600',
|
|
bgColor: 'bg-amber-50',
|
|
label: 'Repair',
|
|
};
|
|
}
|
|
|
|
if (item.is_stock_item) {
|
|
return {
|
|
type: 'stock',
|
|
icon: <Package size={16} />,
|
|
color: 'text-violet-600',
|
|
bgColor: 'bg-violet-50',
|
|
label: 'Stock',
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'normal',
|
|
icon: <ShoppingCart size={16} />,
|
|
color: 'text-orange-600',
|
|
bgColor: 'bg-orange-50',
|
|
label: 'Order',
|
|
};
|
|
}
|
|
|
|
export default function CollectionItemCard({
|
|
item,
|
|
onCollect,
|
|
onProblem,
|
|
onNavigate,
|
|
}: CollectionItemProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const supplier = item.expand?.supplier;
|
|
const itemType = getItemType(item);
|
|
const coords = parseCoordinates(supplier?.coordinates);
|
|
const hasCoords = coords !== null;
|
|
const description = item.description || 'Unknown item';
|
|
const expectedQty = item.order_qty || item.quantity || 0;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'bg-white rounded-2xl card-shadow card-press overflow-hidden',
|
|
(item.item_status === 'problem' || item.problem_flag) && 'ring-l-4 ring-red-400',
|
|
item.item_status === 'assigned' && 'border-l-[3px] border-orange-400'
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<button
|
|
className="w-full text-left p-4 active:bg-gray-50/50 transition-colors"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{/* Type icon */}
|
|
<div className={cn(
|
|
'w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5',
|
|
itemType.bgColor
|
|
)}>
|
|
<span className={itemType.color}>{itemType.icon}</span>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<h3 className="font-semibold text-[15px] text-gray-900 truncate leading-snug">
|
|
{description}
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<span className={cn(
|
|
'text-[10px] px-2 py-0.5 rounded-full font-semibold uppercase tracking-wide',
|
|
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 === 'stock' && 'bg-violet-100 text-violet-700',
|
|
itemType.type === 'normal' && 'bg-orange-100 text-orange-700'
|
|
)}>
|
|
{itemType.label}
|
|
</span>
|
|
<span className="text-xs text-gray-400">Qty: {expectedQty}</span>
|
|
{item.collection_date && (
|
|
<span className="text-xs text-gray-400 flex items-center gap-0.5">
|
|
<Clock size={10} />
|
|
{formatDate(item.collection_date)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{supplier?.suppliername && (
|
|
<div className="flex items-center gap-1 mt-1.5">
|
|
<MapPin size={12} className="text-gray-400 flex-shrink-0" />
|
|
<span className="text-xs text-gray-500 truncate">{supplier.suppliername}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{item.selling_price && expectedQty > 0 && (
|
|
<span className="text-xs font-semibold text-green-600">
|
|
{formatCurrency(item.selling_price * expectedQty)}
|
|
</span>
|
|
)}
|
|
{expanded ? (
|
|
<ChevronUp size={18} className="text-gray-400" />
|
|
) : (
|
|
<ChevronDown size={18} className="text-gray-400" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Expanded Details */}
|
|
{expanded && (
|
|
<div className="px-4 pb-4 border-t border-gray-100 animate-fade-in">
|
|
<div className="pt-3 space-y-3">
|
|
{/* Notes */}
|
|
{item.notes && (
|
|
<div className="bg-amber-50 border border-amber-100 rounded-xl p-3">
|
|
<p className="text-xs text-amber-800 flex items-start gap-2">
|
|
<AlertCircle size={14} className="flex-shrink-0 mt-0.5" />
|
|
<span className="selectable">{item.notes}</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{item.problem_notes && (
|
|
<div className="bg-red-50 border border-red-100 rounded-xl p-3">
|
|
<p className="text-xs text-red-700 flex items-start gap-2">
|
|
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
|
<span className="selectable">{item.problem_notes}</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Details grid */}
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
{item.expand?.order?.order_number && (
|
|
<div className="bg-gray-50 rounded-lg p-2">
|
|
<span className="text-gray-400 block">Order</span>
|
|
<span className="text-gray-700 font-medium">{item.expand.order.order_number}</span>
|
|
</div>
|
|
)}
|
|
{item.expand?.purchase_order?.po_number && (
|
|
<div className="bg-gray-50 rounded-lg p-2">
|
|
<span className="text-gray-400 block">PO</span>
|
|
<span className="text-gray-700 font-medium">{item.expand.purchase_order.po_number}</span>
|
|
</div>
|
|
)}
|
|
{supplier?.supplieraddress && (
|
|
<div className="bg-gray-50 rounded-lg p-2 col-span-2">
|
|
<span className="text-gray-400 block">Address</span>
|
|
<span className="text-gray-700 font-medium selectable">{supplier.supplieraddress}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="grid grid-cols-3 gap-2 pt-1">
|
|
<button
|
|
onClick={() => onCollect(item)}
|
|
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-green-500 text-white active:bg-green-600 transition-colors touch-manipulation press-feedback-sm"
|
|
>
|
|
<Check size={18} strokeWidth={2.5} />
|
|
<span className="text-xs font-semibold">Collect</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => onProblem(item)}
|
|
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-red-500 text-white active:bg-red-600 transition-colors touch-manipulation press-feedback-sm"
|
|
>
|
|
<AlertTriangle size={18} strokeWidth={2.5} />
|
|
<span className="text-xs font-semibold">Problem</span>
|
|
</button>
|
|
|
|
{hasCoords && coords && supplier ? (
|
|
<button
|
|
onClick={() => onNavigate(coords.latitude, coords.longitude, supplier.suppliername)}
|
|
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-orange-500 text-white active:bg-orange-600 transition-colors touch-manipulation press-feedback-sm"
|
|
>
|
|
<Navigation size={18} strokeWidth={2.5} />
|
|
<span className="text-xs font-semibold">Navigate</span>
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-gray-100 text-gray-400">
|
|
<Navigation size={18} />
|
|
<span className="text-xs font-medium">No GPS</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |