212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
|
|
import { Input } from '@/Components/ui/input';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Search, Users, Check } from 'lucide-react';
|
|
|
|
interface Skill {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
}
|
|
|
|
interface Labor {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
category: 'skilled' | 'unskilled';
|
|
hourly_rate: string;
|
|
skills: Skill[];
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
labors: Labor[];
|
|
onSelectLabor: (labor: Labor) => void;
|
|
selectedLaborUlid?: string;
|
|
}
|
|
|
|
export default function LaborLookupModal({
|
|
open,
|
|
onOpenChange,
|
|
labors,
|
|
onSelectLabor,
|
|
selectedLaborUlid
|
|
}: Props) {
|
|
const [search, setSearch] = useState('');
|
|
const [categoryFilter, setCategoryFilter] = useState<'all' | 'skilled' | 'unskilled'>('all');
|
|
|
|
const formatCurrency = (v: string | number) => {
|
|
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
|
};
|
|
|
|
const filteredLabors = useMemo(() => {
|
|
return labors.filter(labor => {
|
|
// Category filter
|
|
if (categoryFilter !== 'all' && labor.category !== categoryFilter) {
|
|
return false;
|
|
}
|
|
|
|
// Search filter
|
|
if (!search) return true;
|
|
const query = search.toLowerCase();
|
|
const matchesName = labor.name.toLowerCase().includes(query);
|
|
const matchesCategory = labor.category.toLowerCase().includes(query);
|
|
const matchesSkills = labor.skills && labor.skills.some(skill =>
|
|
skill.name.toLowerCase().includes(query)
|
|
);
|
|
|
|
return matchesName || matchesCategory || matchesSkills;
|
|
});
|
|
}, [labors, search, categoryFilter]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[70vw] max-w-[95vw] max-h-[85vh] flex flex-col p-0 gap-0 border-slate-200">
|
|
<DialogHeader className="px-6 py-4 border-b border-slate-100">
|
|
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-slate-800">
|
|
<Users className="h-5 w-5 text-emerald-600" />
|
|
Select Labor Record
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-slate-500">
|
|
Search and select a labor trade category to allocate to the project task.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="px-6 py-3 border-b border-slate-100 bg-slate-50/50 flex flex-col sm:flex-row gap-3 justify-between items-center">
|
|
{/* Category Tabs */}
|
|
<div className="flex bg-slate-100 p-0.5 rounded-lg border border-slate-200/40 w-full sm:w-auto">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCategoryFilter('all')}
|
|
className={`flex-1 sm:flex-initial 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 Categories
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCategoryFilter('skilled')}
|
|
className={`flex-1 sm:flex-initial px-3 py-1 text-xs font-semibold rounded-md transition-all ${
|
|
categoryFilter === 'skilled'
|
|
? 'bg-white text-slate-800 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-800'
|
|
}`}
|
|
>
|
|
Skilled
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCategoryFilter('unskilled')}
|
|
className={`flex-1 sm:flex-initial px-3 py-1 text-xs font-semibold rounded-md transition-all ${
|
|
categoryFilter === 'unskilled'
|
|
? 'bg-white text-slate-800 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-800'
|
|
}`}
|
|
>
|
|
Unskilled
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search Input */}
|
|
<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 by name, skills..."
|
|
className="h-9 pl-9 bg-white text-xs border-slate-200 focus-visible:ring-emerald-500 focus-visible:border-emerald-500"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
|
{filteredLabors.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-400 text-xs">
|
|
No labor records found matching "{search}".
|
|
</div>
|
|
) : (
|
|
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-sm">
|
|
<table className="w-full text-xs text-left border-collapse">
|
|
<thead className="bg-slate-55 border-b border-slate-100 text-slate-600 font-semibold">
|
|
<tr>
|
|
<th className="px-4 py-3 font-semibold">Labor Record</th>
|
|
<th className="px-4 py-3 font-semibold w-28">Category</th>
|
|
<th className="px-4 py-3 font-semibold">Skills</th>
|
|
<th className="px-4 py-3 font-semibold text-right w-32">Hourly Rate</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 bg-white">
|
|
{filteredLabors.map(labor => {
|
|
const isSelected = selectedLaborUlid === labor.ulid;
|
|
return (
|
|
<tr
|
|
key={labor.id}
|
|
className={`transition-all hover:bg-slate-50/50 cursor-pointer group ${
|
|
isSelected ? 'bg-emerald-50/40 hover:bg-emerald-50/60' : ''
|
|
}`}
|
|
onClick={() => onSelectLabor(labor)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-slate-800 group-hover:text-emerald-700 transition-colors">
|
|
{labor.name}
|
|
</span>
|
|
{isSelected && (
|
|
<Badge className="bg-emerald-100 text-emerald-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-3">
|
|
<Badge
|
|
variant="outline"
|
|
className={`capitalize font-normal text-[10px] ${
|
|
labor.category === 'skilled'
|
|
? 'bg-blue-50 text-blue-700 border-blue-100'
|
|
: 'bg-amber-50 text-amber-700 border-amber-100'
|
|
}`}
|
|
>
|
|
{labor.category}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex flex-wrap gap-1">
|
|
{labor.skills && labor.skills.length > 0 ? (
|
|
labor.skills.map(skill => (
|
|
<Badge
|
|
key={skill.id}
|
|
variant="secondary"
|
|
className="bg-slate-100 text-slate-650 hover:bg-slate-200 text-[10px] font-normal"
|
|
>
|
|
{skill.name}
|
|
</Badge>
|
|
))
|
|
) : (
|
|
<span className="text-slate-400 italic">No skills listed</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-mono font-medium text-slate-700">
|
|
{formatCurrency(labor.hourly_rate)}/hr
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|