474 lines
27 KiB
TypeScript
474 lines
27 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 { Badge } from '@/Components/ui/badge';
|
|
import { Search, Users, Check, Layers, Plus, HardHat, Wrench, Zap, Hammer, Paintbrush, Pipette } from 'lucide-react';
|
|
|
|
export interface Skill {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface Labor {
|
|
id: number;
|
|
ulid: string;
|
|
name: string;
|
|
category: 'skilled' | 'unskilled';
|
|
hourly_rate: string;
|
|
skills: Skill[];
|
|
}
|
|
|
|
export interface LaborBundleOption {
|
|
id: string;
|
|
name: string;
|
|
job_type: string;
|
|
description: string;
|
|
category: string;
|
|
badge_color?: string;
|
|
items: {
|
|
labor_name: string;
|
|
category: 'skilled' | 'unskilled';
|
|
default_hours?: number;
|
|
estimated_hourly_rate?: number;
|
|
rate_per_sqm: number;
|
|
}[];
|
|
}
|
|
|
|
export const PREDEFINED_LABOR_BUNDLES: LaborBundleOption[] = [
|
|
{
|
|
id: 'excavation-crew',
|
|
name: 'Excavation & Earthworks Crew Package',
|
|
job_type: 'Excavation & Earthworks',
|
|
category: 'Earthworks & Foundation',
|
|
badge_color: 'bg-amber-100 text-amber-800 border-amber-200',
|
|
description: 'Complete manpower package for site excavation, trenching, soil hauling, and ground leveling.',
|
|
items: [
|
|
{ labor_name: 'Excavator Operator', category: 'skilled', rate_per_sqm: 80 },
|
|
{ labor_name: 'Heavy Equipment Hand', category: 'skilled', rate_per_sqm: 40 },
|
|
{ labor_name: 'Earthwork Laborer', category: 'unskilled', rate_per_sqm: 30 },
|
|
{ labor_name: 'Flagman & Site Safety', category: 'unskilled', rate_per_sqm: 15 },
|
|
]
|
|
},
|
|
{
|
|
id: 'steelworks-crew',
|
|
name: 'Steelworks & Rebar Crew Package',
|
|
job_type: 'Steelworks & Structural Rebar',
|
|
category: 'Structural & Reinforcement',
|
|
badge_color: 'bg-slate-100 text-slate-800 border-slate-200',
|
|
description: 'Specialized crew for rebar cutting, bending, column/beam tying, and structural steel welding.',
|
|
items: [
|
|
{ labor_name: 'Master Welder', category: 'skilled', rate_per_sqm: 110 },
|
|
{ labor_name: 'Steel Fixer', category: 'skilled', rate_per_sqm: 95 },
|
|
{ labor_name: 'Rebar Craftsman', category: 'skilled', rate_per_sqm: 75 },
|
|
{ labor_name: 'Rigging Helper', category: 'unskilled', rate_per_sqm: 40 },
|
|
]
|
|
},
|
|
{
|
|
id: 'electricity-crew',
|
|
name: 'Electricity & MEP Rough-In Package',
|
|
job_type: 'Electrical & MEP',
|
|
category: 'Electrical & Utility Systems',
|
|
badge_color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
description: 'Crew for main feeder line installation, conduit laying, breaker panel wiring, and rough-in.',
|
|
items: [
|
|
{ labor_name: 'Master Electrician', category: 'skilled', rate_per_sqm: 90 },
|
|
{ labor_name: 'Journeyman Electrician', category: 'skilled', rate_per_sqm: 75 },
|
|
{ labor_name: 'Cable Puller', category: 'unskilled', rate_per_sqm: 45 },
|
|
{ labor_name: 'Conduit Installer', category: 'skilled', rate_per_sqm: 40 },
|
|
]
|
|
},
|
|
{
|
|
id: 'masonry-crew',
|
|
name: 'Masonry & Concrete Pouring Package',
|
|
job_type: 'Masonry & Civil Works',
|
|
category: 'Masonry & Concrete',
|
|
badge_color: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
|
description: 'Full manpower setup for CHB block laying, wall plastering, slab pouring, and concrete finishing.',
|
|
items: [
|
|
{ labor_name: 'Master Mason', category: 'skilled', rate_per_sqm: 110 },
|
|
{ labor_name: 'Concrete Finisher', category: 'skilled', rate_per_sqm: 85 },
|
|
{ labor_name: 'Masonry Helper', category: 'unskilled', rate_per_sqm: 55 },
|
|
{ labor_name: 'Mixer Attendant', category: 'unskilled', rate_per_sqm: 30 },
|
|
]
|
|
},
|
|
{
|
|
id: 'plumbing-crew',
|
|
name: 'Plumbing & Piping Package',
|
|
job_type: 'Plumbing & Sanitary',
|
|
category: 'Plumbing & Piping',
|
|
badge_color: 'bg-blue-100 text-blue-800 border-blue-200',
|
|
description: 'Specialized crew for potable water supply distribution, DWV sanitary piping, and fixture rough-in.',
|
|
items: [
|
|
{ labor_name: 'Master Plumber', category: 'skilled', rate_per_sqm: 95 },
|
|
{ labor_name: 'Pipefitter', category: 'skilled', rate_per_sqm: 75 },
|
|
{ labor_name: 'Drainage Installer', category: 'skilled', rate_per_sqm: 50 },
|
|
{ labor_name: 'Plumbing Helper', category: 'unskilled', rate_per_sqm: 30 },
|
|
]
|
|
},
|
|
{
|
|
id: 'carpentry-crew',
|
|
name: 'Carpentry & Formwork Package',
|
|
job_type: 'Carpentry & Formwork',
|
|
category: 'Formwork & Framing',
|
|
badge_color: 'bg-orange-100 text-orange-800 border-orange-200',
|
|
description: 'Team for column/beam formwork fabrication, scaffolding erection, and temporary shoring.',
|
|
items: [
|
|
{ labor_name: 'Master Carpenter', category: 'skilled', rate_per_sqm: 100 },
|
|
{ labor_name: 'Formwork Installer', category: 'skilled', rate_per_sqm: 85 },
|
|
{ labor_name: 'Scaffold Erector', category: 'skilled', rate_per_sqm: 55 },
|
|
{ labor_name: 'Carpentry Helper', category: 'unskilled', rate_per_sqm: 35 },
|
|
]
|
|
},
|
|
{
|
|
id: 'painting-crew',
|
|
name: 'Painting & Architectural Finishing Package',
|
|
job_type: 'Painting & Finishing',
|
|
category: 'Architectural & Finishing',
|
|
badge_color: 'bg-purple-100 text-purple-800 border-purple-200',
|
|
description: 'Crew for surface preparation, skimcoating, primer application, and topcoat architectural painting.',
|
|
items: [
|
|
{ labor_name: 'Lead Painter', category: 'skilled', rate_per_sqm: 85 },
|
|
{ labor_name: 'Surface Applicator', category: 'skilled', rate_per_sqm: 65 },
|
|
{ labor_name: 'Sanding & Skimcoat Helper', category: 'unskilled', rate_per_sqm: 40 },
|
|
]
|
|
}
|
|
];
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
labors: Labor[];
|
|
onSelectLabor: (labor: Labor) => void;
|
|
onAddLaborBundle?: (bundle: LaborBundleOption, crewMultiplier: number) => void;
|
|
selectedLaborUlid?: string;
|
|
}
|
|
|
|
export default function LaborLookupModal({
|
|
open,
|
|
onOpenChange,
|
|
labors,
|
|
onSelectLabor,
|
|
onAddLaborBundle,
|
|
selectedLaborUlid
|
|
}: Props) {
|
|
const [search, setSearch] = useState('');
|
|
const [categoryFilter, setCategoryFilter] = useState<'all' | 'skilled' | 'unskilled'>('all');
|
|
const [crewMultiplier, setCrewMultiplier] = useState<Record<string, number>>({});
|
|
|
|
const formatCurrency = (v: string | number) => {
|
|
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
|
};
|
|
|
|
const handleOpenChange = (isOpen: boolean) => {
|
|
if (!isOpen) {
|
|
setSearch('');
|
|
setCategoryFilter('all');
|
|
}
|
|
onOpenChange(isOpen);
|
|
};
|
|
|
|
const filteredLabors = useMemo(() => {
|
|
return labors.filter(labor => {
|
|
if (categoryFilter !== 'all' && labor.category !== categoryFilter) {
|
|
return false;
|
|
}
|
|
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]);
|
|
|
|
const filteredBundles = useMemo(() => {
|
|
if (!search) return PREDEFINED_LABOR_BUNDLES;
|
|
const query = search.toLowerCase();
|
|
return PREDEFINED_LABOR_BUNDLES.filter(b =>
|
|
b.name.toLowerCase().includes(query) ||
|
|
b.job_type.toLowerCase().includes(query) ||
|
|
b.description.toLowerCase().includes(query) ||
|
|
b.items.some(item => item.labor_name.toLowerCase().includes(query))
|
|
);
|
|
}, [search]);
|
|
|
|
const handleAddBundleClick = (bundle: LaborBundleOption) => {
|
|
const mult = crewMultiplier[bundle.id] || 1;
|
|
if (onAddLaborBundle) {
|
|
onAddLaborBundle(bundle, mult);
|
|
} else {
|
|
// Fallback: select first trade in bundle
|
|
const matched = labors.find(l => l.name.toLowerCase().includes(bundle.items[0]?.labor_name.toLowerCase() || ''));
|
|
if (matched) {
|
|
onSelectLabor(matched);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="sm:max-w-[80vw] max-w-[95vw] max-h-[90vh] flex flex-col p-0 gap-0 border-slate-200 bg-white shadow-2xl overflow-hidden">
|
|
<DialogHeader className="px-6 py-4 border-b border-slate-100 bg-slate-50">
|
|
<DialogTitle className="flex items-center gap-2 text-xl font-bold text-slate-800">
|
|
<Users className="h-5 w-5 text-emerald-600" />
|
|
Labor Manpower Catalog
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-slate-500">
|
|
Browse single laborer trades or select predefined job bundles (excavation, steelworks, electricity, masonry, plumbing, etc.) for quick crew allocation.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs defaultValue="items" className="flex-1 flex flex-col min-h-0">
|
|
<div className="px-6 py-3 border-b border-slate-100 flex flex-col sm:flex-row gap-3 justify-between items-center bg-white">
|
|
<TabsList className="bg-slate-100 p-1">
|
|
<TabsTrigger value="items" className="flex items-center gap-1.5 text-xs font-semibold">
|
|
<Users className="h-3.5 w-3.5 text-slate-600" />
|
|
Single Laborers & Trades ({filteredLabors.length})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="bundles" className="flex items-center gap-1.5 text-xs font-semibold">
|
|
<Layers className="h-3.5 w-3.5 text-indigo-600" />
|
|
Job Bundles & Crew Packages ({filteredBundles.length})
|
|
</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 trades, bundles, 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>
|
|
|
|
{/* TAB 1: SINGLE LABORERS */}
|
|
<TabsContent value="items" className="flex-1 overflow-y-auto p-6 min-h-0 m-0">
|
|
{/* Category Filters */}
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<span className="text-xs font-semibold text-slate-500">Filter Category:</span>
|
|
<div className="flex bg-slate-100 p-0.5 rounded-lg border border-slate-200/40">
|
|
<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 ({labors.length})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCategoryFilter('skilled')}
|
|
className={`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={`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>
|
|
</div>
|
|
|
|
{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-50 border-b border-slate-100 text-slate-600 font-semibold">
|
|
<tr>
|
|
<th className="px-4 py-3 font-semibold">Labor Trade Record</th>
|
|
<th className="px-4 py-3 font-semibold w-28">Category</th>
|
|
<th className="px-4 py-3 font-semibold">Skills / Specialties</th>
|
|
<th className="px-4 py-3 font-semibold text-right w-32">Hourly Rate</th>
|
|
<th className="px-4 py-3 font-semibold text-right w-28">Action</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 ${
|
|
isSelected ? 'bg-emerald-50/40 hover:bg-emerald-50/60' : ''
|
|
}`}
|
|
>
|
|
<td className="px-4 py-3 font-semibold text-slate-800">
|
|
<div className="flex items-center gap-2">
|
|
<HardHat className="h-4 w-4 text-emerald-600 shrink-0" />
|
|
<span>{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">Standard Construction Trade</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-mono font-bold text-slate-800">
|
|
{formatCurrency(labor.hourly_rate)}/hr
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<Button
|
|
size="sm"
|
|
className="bg-emerald-600 hover:bg-emerald-700 text-white font-medium text-xs h-7 px-3"
|
|
onClick={() => onSelectLabor(labor)}
|
|
>
|
|
Select Trade
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* TAB 2: JOB BUNDLES & CREW PACKAGES */}
|
|
<TabsContent value="bundles" 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 Job Manpower Assemblies & Crews
|
|
</h4>
|
|
<p className="text-xs text-indigo-200/80 mt-0.5">
|
|
Instantly allocate complete specialized teams for specific construction activities (excavation, steelworks, electricity, masonry, plumbing, etc.).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{filteredBundles.map(bundle => {
|
|
const sqmArea = crewMultiplier[bundle.id] || 10;
|
|
const ratePerSqm = bundle.items.reduce((sum, item) => sum + (item.rate_per_sqm || item.estimated_hourly_rate || 0), 0);
|
|
const totalEstCost = ratePerSqm * sqmArea;
|
|
|
|
return (
|
|
<div
|
|
key={bundle.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 ${bundle.badge_color || 'bg-indigo-50 text-indigo-700 border-indigo-100'}`}>
|
|
{bundle.job_type}
|
|
</Badge>
|
|
<span className="text-xs font-mono font-bold text-slate-800">
|
|
Est. {formatCurrency(ratePerSqm)} / m²
|
|
</span>
|
|
</div>
|
|
|
|
<h4 className="font-bold text-sm text-slate-800">{bundle.name}</h4>
|
|
<p className="text-xs text-slate-500 mt-1 leading-relaxed">{bundle.description}</p>
|
|
|
|
<div className="mt-3 bg-slate-50 p-3 rounded-lg border border-slate-100 space-y-1.5">
|
|
<p className="text-[11px] font-bold text-slate-600 uppercase tracking-wider mb-1 flex justify-between">
|
|
<span>Crew Breakdown ({bundle.items.length} Trades):</span>
|
|
<span className="text-indigo-600 font-mono">Total: {formatCurrency(totalEstCost)} ({sqmArea} m²)</span>
|
|
</p>
|
|
{bundle.items.map((item, idx) => (
|
|
<div key={idx} className="flex justify-between items-center text-xs">
|
|
<span className="text-slate-700 font-medium flex items-center gap-1.5">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0"></span>
|
|
{item.labor_name}
|
|
</span>
|
|
<span className="font-mono text-slate-600 text-[11px]">
|
|
{formatCurrency(item.rate_per_sqm || item.estimated_hourly_rate || 0)} / m²
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-slate-100 flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold text-slate-600">Target Area:</span>
|
|
<div className="flex items-center">
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
step="5"
|
|
value={sqmArea}
|
|
onChange={(e) => setCrewMultiplier({
|
|
...crewMultiplier,
|
|
[bundle.id]: Math.max(1, parseInt(e.target.value) || 1)
|
|
})}
|
|
className="w-20 h-8 text-center text-xs font-mono font-bold"
|
|
/>
|
|
<span className="text-xs font-medium text-slate-500 ml-1">m²</span>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-xs h-8 px-3.5 shadow-sm"
|
|
onClick={() => handleAddBundleClick(bundle)}
|
|
>
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" /> Add Bundle ({sqmArea} m²)
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|