Files
GSB-Construction/Modules/MaterialLogistics/resources/js/Pages/Requisitions/MaterialCatalogModal.tsx

288 lines
17 KiB
TypeScript

import { useState, useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
import { Search, Package, Plus, Layers, SearchIcon } from 'lucide-react';
import { Badge } from "@/Components/ui/badge";
export interface MaterialOption {
id: number;
ulid: string;
name: string;
sku: string;
category: string;
unit: string;
unit_cost: string;
}
export interface MaterialGroupOption {
id: number;
ulid: string;
name: string;
description: string;
status: string;
materials: (MaterialOption & { pivot: { quantity: number } })[];
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
materials: MaterialOption[];
materialGroups: MaterialGroupOption[];
onSelectMaterials: (materials: MaterialOption[]) => void;
onAddKit: (group: MaterialGroupOption, quantityMultiplier: number) => void;
}
export function MaterialCatalogModal({
open,
onOpenChange,
materials,
materialGroups,
onSelectMaterials,
onAddKit
}: Props) {
const [search, setSearch] = useState('');
const [kitQuantity, setKitQuantity] = useState<Record<string, string>>({});
const [selectedMaterials, setSelectedMaterials] = useState<MaterialOption[]>([]);
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) setSelectedMaterials([]);
onOpenChange(isOpen);
};
const toggleSelection = (item: MaterialOption) => {
if (selectedMaterials.some(m => m.id === item.id)) {
setSelectedMaterials(selectedMaterials.filter(m => m.id !== item.id));
} else {
setSelectedMaterials([...selectedMaterials, item]);
}
};
const filteredMaterials = useMemo(() => {
if (!search) return materials;
const lowerSearch = search.toLowerCase();
return materials.filter(m =>
m.name.toLowerCase().includes(lowerSearch) ||
(m.sku && m.sku.toLowerCase().includes(lowerSearch)) ||
(m.category && m.category.toLowerCase().includes(lowerSearch))
);
}, [materials, search]);
// Group materials by category for display
const groupedMaterials = useMemo(() => {
const groups: Record<string, MaterialOption[]> = {};
filteredMaterials.forEach(m => {
const cat = m.category || 'Uncategorized';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(m);
});
// Sort keys
return Object.keys(groups).sort().reduce((acc, key) => {
acc[key] = groups[key];
return acc;
}, {} as Record<string, MaterialOption[]>);
}, [filteredMaterials]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[85vw] max-w-[95vw] max-h-[92vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle className="flex items-center gap-2 text-xl">
<Package className="h-5 w-5 text-indigo-600" />
Material Catalog
</DialogTitle>
<DialogDescription>
Browse individual materials or quickly add entire predefined assemblies.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="items" className="flex-1 flex flex-col min-h-0">
<div className="px-6 py-2 border-b bg-gray-50/50 flex justify-between items-center">
<TabsList>
<TabsTrigger value="items" className="flex items-center gap-2">
<SearchIcon className="h-4 w-4" /> Single Items
</TabsTrigger>
<TabsTrigger value="kits" className="flex items-center gap-2">
<Layers className="h-4 w-4" /> Assemblies & Kits
</TabsTrigger>
</TabsList>
<div className="relative w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search Name, SKU, Category..."
className="h-9 pl-9 bg-white"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<TabsContent value="items" className="m-0 p-6">
{Object.keys(groupedMaterials).length === 0 ? (
<div className="text-center py-12 text-gray-500">
No materials found matching "{search}".
</div>
) : (
<div className="space-y-8">
{Object.entries(groupedMaterials).map(([category, items]) => (
<div key={category} className="space-y-3">
<h3 className="font-semibold text-lg text-gray-900 border-b pb-1">
{category} <span className="text-gray-400 text-sm ml-2 font-normal">({items.length})</span>
</h3>
<div className="border rounded-lg overflow-hidden relative shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-gray-50 text-gray-600 font-medium border-b">
<tr>
<th className="px-4 py-3 w-10"></th>
<th className="px-4 py-3 w-32 whitespace-nowrap">SKU</th>
<th className="px-4 py-3 min-w-[200px] w-full">Material Name</th>
<th className="px-4 py-3 w-28 whitespace-nowrap">Unit</th>
<th className="px-4 py-3 w-32 whitespace-nowrap text-right">Unit Cost</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map(item => {
const isSelected = selectedMaterials.some(m => m.id === item.id);
return (
<tr
key={item.id}
className={`transition-colors group cursor-pointer ${isSelected ? 'bg-indigo-50/70 hover:bg-indigo-100/70' : 'hover:bg-gray-50/60'}`}
onClick={() => toggleSelection(item)}
>
<td className="px-4 py-2.5">
<input
type="checkbox"
checked={isSelected}
readOnly
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 focus:ring-offset-0 cursor-pointer"
/>
</td>
<td className="px-4 py-2.5">
{item.sku ? <Badge variant="secondary" className="font-mono text-[11px] text-gray-600 bg-gray-100 hover:bg-gray-200">{item.sku}</Badge> : <span className="text-gray-300">-</span>}
</td>
<td className="px-4 py-2.5">
<span className="font-medium text-gray-900">{item.name}</span>
</td>
<td className="px-4 py-2.5 text-gray-600">
{item.unit}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-gray-700">
{parseFloat(item.unit_cost) > 0 ? `${parseFloat(item.unit_cost).toFixed(2)}` : <span className="text-gray-300">-</span>}
</td>
</tr>
)})}
</tbody>
</table>
</div>
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="kits" className="m-0 p-6">
{(!materialGroups || materialGroups.length === 0) ? (
<div className="text-center py-12">
<Layers className="h-12 w-12 text-gray-300 mx-auto mb-3" />
<h4 className="text-gray-600 font-medium">No Assembly Kits Found</h4>
<p className="text-sm text-gray-500 mt-1">Assemblies are created by estimators to quickly populate requisitions.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{materialGroups.map(group => (
<div key={group.id} className="border rounded-lg p-4 bg-white flex flex-col">
<div className="mb-3">
<h3 className="font-semibold text-gray-900">{group.name}</h3>
{group.description && <p className="text-sm text-gray-500 line-clamp-2">{group.description}</p>}
</div>
<div className="bg-gray-50 rounded-md p-3 mb-4 flex-1">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Includes:</h4>
<ul className="text-sm space-y-1">
{group.materials?.length > 0 ? (
group.materials.slice(0, 4).map(m => (
<li key={m.id} className="flex justify-between">
<span className="text-gray-700 line-clamp-1">{m.name}</span>
<span className="text-gray-500 ml-2 whitespace-nowrap">{m.pivot?.quantity || 1} {m.unit}</span>
</li>
))
) : (
<li className="text-gray-400 italic">No materials assigned</li>
)}
{group.materials?.length > 4 && (
<li className="text-indigo-600 text-xs mt-1">+ {group.materials.length - 4} more items</li>
)}
</ul>
</div>
<div className="flex items-center gap-2 mt-auto pt-2 border-t">
<div className="flex-1">
<label className="text-xs text-gray-500 block mb-1">Quantity Multiplier</label>
<Input
type="number"
min="1"
step="1"
value={kitQuantity[group.ulid] || '1'}
onChange={e => setKitQuantity({...kitQuantity, [group.ulid]: e.target.value})}
className="h-8"
/>
</div>
<Button
size="sm"
className="mt-5"
onClick={() => {
const multi = parseFloat(kitQuantity[group.ulid] || '1');
if (multi > 0 && group.materials?.length > 0) {
onAddKit(group, multi);
onOpenChange(false);
}
}}
disabled={!group.materials || group.materials.length === 0}
>
<Plus className="h-4 w-4 mr-1" /> Add Assembly
</Button>
</div>
</div>
))}
</div>
)}
</TabsContent>
</div>
</Tabs>
{selectedMaterials.length > 0 && (
<div className="bg-white border-t px-6 py-4 flex items-center justify-between shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
<div className="flex items-center gap-3">
<Badge variant="secondary" className="bg-indigo-100 text-indigo-700 text-sm py-1 px-3">
{selectedMaterials.length} Selected
</Badge>
<span className="text-sm text-gray-600">materials ready to be added</span>
</div>
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" onClick={() => setSelectedMaterials([])}>
Clear Selection
</Button>
<Button
type="button"
className="bg-indigo-600 hover:bg-indigo-700"
onClick={() => {
onSelectMaterials(selectedMaterials);
handleOpenChange(false);
}}
>
<Plus className="mr-2 h-4 w-4" /> Add {selectedMaterials.length} Items to Requisition
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}