398 lines
25 KiB
TypeScript
398 lines
25 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { Dialog, DialogContent, 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, Check, Sparkles } 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 [categoryFilter, setCategoryFilter] = useState<string>('all');
|
|
const [kitQuantity, setKitQuantity] = useState<Record<string, string>>({});
|
|
const [selectedMaterials, setSelectedMaterials] = useState<MaterialOption[]>([]);
|
|
|
|
const formatCurrency = (v: string | number) => {
|
|
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
|
};
|
|
|
|
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]);
|
|
}
|
|
};
|
|
|
|
// Extract unique categories for pill filters
|
|
const uniqueCategories = useMemo(() => {
|
|
const cats = new Set<string>();
|
|
materials.forEach(m => {
|
|
if (m.category) cats.add(m.category);
|
|
});
|
|
return Array.from(cats).sort();
|
|
}, [materials]);
|
|
|
|
const filteredMaterials = useMemo(() => {
|
|
return materials.filter(m => {
|
|
const matchesSearch = !search || (
|
|
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
(m.sku && m.sku.toLowerCase().includes(search.toLowerCase())) ||
|
|
(m.category && m.category.toLowerCase().includes(search.toLowerCase()))
|
|
);
|
|
|
|
const matchesCategory = categoryFilter === 'all' || m.category === categoryFilter;
|
|
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
}, [materials, search, categoryFilter]);
|
|
|
|
// Group materials by category for display
|
|
const groupedMaterials = useMemo(() => {
|
|
const groups: Record<string, MaterialOption[]> = {};
|
|
filteredMaterials.forEach(m => {
|
|
const cat = m.category || 'General Materials';
|
|
if (!groups[cat]) groups[cat] = [];
|
|
groups[cat].push(m);
|
|
});
|
|
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-5xl max-w-[95vw] max-h-[88vh] h-[720px] flex flex-col p-0 overflow-hidden bg-white border-slate-200 shadow-2xl">
|
|
{/* Header */}
|
|
<div className="p-6 pb-4 bg-white border-b border-slate-100 flex flex-col gap-1">
|
|
<DialogTitle className="text-xl font-bold text-slate-800 flex items-center gap-2.5">
|
|
<div className="p-2 rounded-lg bg-indigo-50 border border-indigo-100 text-indigo-600">
|
|
<Package className="h-5 w-5" />
|
|
</div>
|
|
<span>Material Catalog & Assembly Kits</span>
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-slate-500">
|
|
Browse single material items or quickly add predefined BOM assembly kits to your requisition.
|
|
</DialogDescription>
|
|
</div>
|
|
|
|
<Tabs defaultValue="items" className="flex-1 flex flex-col min-h-0 bg-white">
|
|
{/* Navigation and Search Bar */}
|
|
<div className="px-6 py-2.5 border-b border-slate-100 bg-slate-50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
|
<TabsList className="bg-slate-100 p-1">
|
|
<TabsTrigger value="items" className="text-xs font-semibold data-[state=active]:bg-white data-[state=active]:text-slate-800 flex items-center gap-1.5">
|
|
<SearchIcon className="h-3.5 w-3.5" /> Single Items
|
|
</TabsTrigger>
|
|
<TabsTrigger value="kits" className="text-xs font-semibold data-[state=active]:bg-white data-[state=active]:text-slate-800 flex items-center gap-1.5">
|
|
<Layers className="h-3.5 w-3.5" /> Assemblies & Kits
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<div className="relative w-full sm:w-72">
|
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
|
<Input
|
|
type="search"
|
|
placeholder="Search Name, SKU, Category..."
|
|
className="h-9 pl-9 bg-white text-xs border-slate-200 focus-visible:ring-indigo-500 focus-visible:border-indigo-500"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* TAB 1: SINGLE ITEMS */}
|
|
<TabsContent value="items" className="flex-1 overflow-y-auto p-6 min-h-0 m-0 space-y-4">
|
|
{/* Category Filter Pills */}
|
|
{uniqueCategories.length > 0 && (
|
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
|
<span className="text-xs font-semibold text-slate-500 shrink-0">Category:</span>
|
|
<div className="flex bg-slate-100 p-0.5 rounded-lg border border-slate-200/40 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCategoryFilter('all')}
|
|
className={`px-3 py-1 text-xs font-semibold rounded-md transition-all ${
|
|
categoryFilter === 'all'
|
|
? 'bg-white text-slate-800 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-800'
|
|
}`}
|
|
>
|
|
All ({materials.length})
|
|
</button>
|
|
{uniqueCategories.map(cat => {
|
|
const count = materials.filter(m => m.category === cat).length;
|
|
return (
|
|
<button
|
|
key={cat}
|
|
type="button"
|
|
onClick={() => setCategoryFilter(cat)}
|
|
className={`px-3 py-1 text-xs font-semibold rounded-md transition-all ${
|
|
categoryFilter === cat
|
|
? 'bg-white text-slate-800 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-800'
|
|
}`}
|
|
>
|
|
{cat} ({count})
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{Object.keys(groupedMaterials).length === 0 ? (
|
|
<div className="text-center py-12 text-slate-400 text-xs">
|
|
No materials found matching "{search}".
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{Object.entries(groupedMaterials).map(([category, items]) => (
|
|
<div key={category} className="space-y-2">
|
|
<h3 className="font-bold text-xs uppercase tracking-wider text-slate-600 flex items-center justify-between border-b border-slate-200/80 pb-1.5">
|
|
<span>{category}</span>
|
|
<span className="text-slate-400 font-normal lowercase">({items.length} items)</span>
|
|
</h3>
|
|
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-xs bg-white">
|
|
<table className="w-full text-xs text-left border-collapse">
|
|
<thead className="bg-slate-50 border-b border-slate-100 text-slate-600 font-semibold">
|
|
<tr>
|
|
<th className="px-4 py-2.5 w-10"></th>
|
|
<th className="px-4 py-2.5 w-32 whitespace-nowrap">SKU</th>
|
|
<th className="px-4 py-2.5 min-w-[200px] w-full">Material Name</th>
|
|
<th className="px-4 py-2.5 w-28 whitespace-nowrap">Unit</th>
|
|
<th className="px-4 py-2.5 w-36 whitespace-nowrap text-right">Standard Unit Cost</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{items.map(item => {
|
|
const isSelected = selectedMaterials.some(m => m.id === item.id);
|
|
return (
|
|
<tr
|
|
key={item.id}
|
|
className={`transition-colors cursor-pointer ${
|
|
isSelected ? 'bg-indigo-50/70 hover:bg-indigo-100/70' : 'hover:bg-slate-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-slate-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-[10px] text-slate-600 bg-slate-100 hover:bg-slate-200">
|
|
{item.sku}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-slate-300">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-slate-800">{item.name}</span>
|
|
{isSelected && (
|
|
<Badge className="bg-indigo-100 text-indigo-800 border-none text-[10px] font-medium flex items-center gap-0.5">
|
|
<Check className="h-2.5 w-2.5" /> Selected
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-slate-600">
|
|
{item.unit}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right font-mono font-bold text-slate-800">
|
|
{parseFloat(item.unit_cost) > 0 ? formatCurrency(item.unit_cost) : <span className="text-slate-300 font-normal">-</span>}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* TAB 2: ASSEMBLIES & KITS */}
|
|
<TabsContent value="kits" className="flex-1 overflow-y-auto p-6 min-h-0 m-0 space-y-4">
|
|
<div className="bg-gradient-to-r from-indigo-900 to-slate-900 text-white p-4 rounded-xl shadow-sm border border-indigo-700/50 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
<div>
|
|
<h4 className="font-bold text-sm text-indigo-100 flex items-center gap-2">
|
|
<Layers className="h-4 w-4 text-indigo-400" /> Predefined Material Assembly Packages & Kits
|
|
</h4>
|
|
<p className="text-xs text-indigo-200/80 mt-0.5">
|
|
Instantly bundle standardized BOM material groups for fast bulk requisitioning.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{(!materialGroups || materialGroups.length === 0) ? (
|
|
<div className="text-center py-12 text-slate-400 text-xs">
|
|
<Layers className="h-12 w-12 text-slate-300 mx-auto mb-3" />
|
|
<h4 className="text-slate-600 font-medium text-sm">No Assembly Kits Found</h4>
|
|
<p className="text-xs text-slate-400 mt-1">Assemblies are configured in Master Data to populate requisitions quickly.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{materialGroups.map(group => {
|
|
const materialsCount = group.materials?.length || 0;
|
|
return (
|
|
<div
|
|
key={group.id}
|
|
className="bg-white border border-slate-200 rounded-xl p-4 shadow-xs hover:border-indigo-300 hover:shadow-md transition-all flex flex-col justify-between space-y-3"
|
|
>
|
|
<div>
|
|
<div className="flex items-center justify-between gap-2 mb-2">
|
|
<Badge variant="outline" className="text-[10px] font-semibold bg-indigo-50 text-indigo-700 border-indigo-100">
|
|
Assembly Kit
|
|
</Badge>
|
|
<span className="text-[11px] font-medium text-slate-500 font-mono">
|
|
{materialsCount} materials
|
|
</span>
|
|
</div>
|
|
|
|
<h4 className="font-bold text-sm text-slate-800">{group.name}</h4>
|
|
{group.description && (
|
|
<p className="text-xs text-slate-500 mt-1 line-clamp-2 leading-relaxed">{group.description}</p>
|
|
)}
|
|
|
|
<div className="mt-3 bg-slate-50 p-3 rounded-lg border border-slate-100 space-y-1.5">
|
|
<p className="text-[10px] font-bold text-slate-600 uppercase tracking-wider mb-1">
|
|
Included Materials:
|
|
</p>
|
|
<ul className="text-xs space-y-1">
|
|
{group.materials?.length > 0 ? (
|
|
group.materials.slice(0, 4).map(m => (
|
|
<li key={m.id} className="flex justify-between items-center text-slate-700">
|
|
<span className="truncate pr-2 flex items-center gap-1.5">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0"></span>
|
|
{m.name}
|
|
</span>
|
|
<span className="text-slate-500 font-mono text-[11px] shrink-0">{m.pivot?.quantity || 1} {m.unit}</span>
|
|
</li>
|
|
))
|
|
) : (
|
|
<li className="text-slate-400 italic">No materials assigned</li>
|
|
)}
|
|
{group.materials?.length > 4 && (
|
|
<li className="text-indigo-600 text-[11px] font-semibold pt-0.5">
|
|
+ {group.materials.length - 4} more materials
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 pt-3 border-t border-slate-100 mt-auto">
|
|
<div className="flex-1">
|
|
<label className="text-[10px] font-semibold text-slate-500 block mb-1">Qty 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 text-xs font-mono font-bold"
|
|
/>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
className="mt-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-xs h-8 px-3 shadow-xs"
|
|
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-3.5 w-3.5 mr-1" /> Add Kit
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* Floating Bottom Selection Bar */}
|
|
{selectedMaterials.length > 0 && (
|
|
<div className="bg-white border-t border-slate-200 px-6 py-3.5 flex items-center justify-between shadow-[0_-4px_12px_rgba(0,0,0,0.05)] z-20">
|
|
<div className="flex items-center gap-2.5">
|
|
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs py-1 px-3 font-semibold">
|
|
{selectedMaterials.length} Selected
|
|
</Badge>
|
|
<span className="text-xs text-slate-600">materials ready to add to requisition</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button type="button" variant="ghost" size="sm" className="text-xs text-slate-500 hover:text-slate-800" onClick={() => setSelectedMaterials([])}>
|
|
Clear Selection
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-xs h-8 px-4 shadow-xs"
|
|
onClick={() => {
|
|
onSelectMaterials(selectedMaterials);
|
|
handleOpenChange(false);
|
|
}}
|
|
>
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" /> Add {selectedMaterials.length} Items to Requisition
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|