383 lines
25 KiB
TypeScript
383 lines
25 KiB
TypeScript
import React from 'react';
|
|
import { CardHeader, CardTitle, CardDescription } from '@/Components/ui/card';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { Input } from '@/Components/ui/input';
|
|
import { Label } from '@/Components/ui/label';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
|
import { Plus, Trash2, Users, Layers, HardHat, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { ProjectWizardData, Employee, Labor } from '../../../types/project-wizard';
|
|
|
|
interface Step4ManpowerProps {
|
|
project: ProjectWizardData;
|
|
localLabor: any[];
|
|
labors: Labor[];
|
|
employees: Employee[];
|
|
teams: any[];
|
|
selectedTeamUlids: string[];
|
|
setSelectedTeamUlids: React.Dispatch<React.SetStateAction<string[]>>;
|
|
selectedUserUlids: string[];
|
|
setSelectedUserUlids: React.Dispatch<React.SetStateAction<string[]>>;
|
|
addLaborAllocation: () => void;
|
|
removeLaborAllocation: (idx: number) => void;
|
|
updateLaborAllocation: (idx: number, field: string, val: string) => void;
|
|
calculatedLaborCost: number;
|
|
formatCurrency: (v: string | number) => string;
|
|
onOpenLaborCatalog: (idx?: number | null) => void;
|
|
handleBack: () => void;
|
|
handleSaveLabor: () => void;
|
|
}
|
|
|
|
export default function Step4Manpower({
|
|
project,
|
|
localLabor,
|
|
labors,
|
|
employees,
|
|
teams,
|
|
selectedTeamUlids,
|
|
setSelectedTeamUlids,
|
|
selectedUserUlids,
|
|
setSelectedUserUlids,
|
|
addLaborAllocation,
|
|
removeLaborAllocation,
|
|
updateLaborAllocation,
|
|
calculatedLaborCost,
|
|
formatCurrency,
|
|
onOpenLaborCatalog,
|
|
handleBack,
|
|
handleSaveLabor
|
|
}: Step4ManpowerProps) {
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<CardHeader className="p-0 pb-4 border-b border-slate-100 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="text-md font-semibold text-slate-800">Step 4: Manpower Rate Allocation</CardTitle>
|
|
<CardDescription className="text-xs">Allocate individual labor trades or predefined crew bundles and estimate total work hours required for scheduled tasks.</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
onClick={() => onOpenLaborCatalog(null)}
|
|
className="bg-indigo-600 hover:bg-indigo-700 text-white font-medium text-xs h-9 shadow-xs"
|
|
>
|
|
<Layers className="mr-1.5 h-4 w-4" /> Browse Trades & Crew Bundles
|
|
</Button>
|
|
<Button onClick={addLaborAllocation} variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50 font-medium text-xs h-9">
|
|
<Plus className="mr-1.5 h-4 w-4" /> Add Row
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
{/* Project Team Pool Selector */}
|
|
<div className="space-y-4 bg-slate-50/50 p-4 rounded-xl border border-slate-200/60">
|
|
<div className="flex flex-col gap-1">
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 flex items-center gap-1.5">
|
|
<Users className="h-4 w-4 text-emerald-600" /> 1. Project Team Pool
|
|
</h4>
|
|
<p className="text-[11px] text-slate-500">Select pre-defined master team rosters and individual members to allocate to this project's workforce pool.</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* Master Teams list */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-slate-700">Select Master Team Roster (Crews)</Label>
|
|
<div className="bg-white border border-slate-250/70 rounded-lg p-3 max-h-48 overflow-y-auto space-y-2">
|
|
{teams.length === 0 ? (
|
|
<p className="text-xs text-slate-450 italic">No master team rosters available.</p>
|
|
) : (
|
|
teams.map((t: any) => {
|
|
const isTeamChecked = selectedTeamUlids.includes(t.ulid);
|
|
return (
|
|
<div key={t.ulid} className="flex items-start gap-2.5 p-1 hover:bg-slate-55 rounded transition-colors">
|
|
<input
|
|
type="checkbox"
|
|
id={`team-${t.ulid}`}
|
|
checked={isTeamChecked}
|
|
onChange={() => {
|
|
let newTeams = [...selectedTeamUlids];
|
|
let newUsers = [...selectedUserUlids];
|
|
const teamUserUlids = t.users ? t.users.map((u: any) => u.ulid) : [];
|
|
|
|
if (isTeamChecked) {
|
|
newTeams = newTeams.filter(id => id !== t.ulid);
|
|
const otherTeamsUsers = teams
|
|
.filter((otherTeam: any) => otherTeam.ulid !== t.ulid && newTeams.includes(otherTeam.ulid))
|
|
.flatMap((otherTeam: any) => otherTeam.users ? otherTeam.users.map((u: any) => u.ulid) : []);
|
|
newUsers = newUsers.filter(ulid => !teamUserUlids.includes(ulid) || otherTeamsUsers.includes(ulid));
|
|
} else {
|
|
newTeams.push(t.ulid);
|
|
teamUserUlids.forEach((ulid: string) => {
|
|
if (!newUsers.includes(ulid)) {
|
|
newUsers.push(ulid);
|
|
}
|
|
});
|
|
}
|
|
setSelectedTeamUlids(newTeams);
|
|
setSelectedUserUlids(newUsers);
|
|
}}
|
|
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500 h-4 w-4 mt-0.5 cursor-pointer"
|
|
/>
|
|
<Label htmlFor={`team-${t.ulid}`} className="text-xs text-slate-700 cursor-pointer flex-1 select-none">
|
|
<span className="font-semibold block">{t.name}</span>
|
|
<span className="text-[10px] text-slate-500 block">{t.description || 'No description'} · {t.users?.length || 0} members</span>
|
|
</Label>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Individual Users List */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-slate-700">Add/Remove Individual Employees</Label>
|
|
<div className="bg-white border border-slate-250/70 rounded-lg p-3 max-h-48 overflow-y-auto space-y-2">
|
|
{employees.map(emp => {
|
|
const isUserChecked = selectedUserUlids.includes(emp.ulid);
|
|
return (
|
|
<label key={emp.ulid} className="flex items-center gap-2.5 p-1 hover:bg-slate-50 rounded cursor-pointer select-none transition-colors">
|
|
<input
|
|
type="checkbox"
|
|
checked={isUserChecked}
|
|
onChange={() => {
|
|
const newUsers = isUserChecked
|
|
? selectedUserUlids.filter(ulid => ulid !== emp.ulid)
|
|
: [...selectedUserUlids, emp.ulid];
|
|
setSelectedUserUlids(newUsers);
|
|
|
|
const newTeams = teams.filter((t: any) => t.users && t.users.length > 0 && t.users.every((u: any) => newUsers.includes(u.ulid))).map(t => t.ulid);
|
|
setSelectedTeamUlids(newTeams);
|
|
}}
|
|
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500 h-4 w-4"
|
|
/>
|
|
<span className="text-xs text-slate-700 flex items-center gap-1.5 flex-wrap">
|
|
<span className="font-medium">{emp.name}</span>
|
|
{emp.roles && emp.roles.length > 0 && (
|
|
<span className="text-[10px] bg-slate-100 text-slate-600 px-1.5 py-0.2 rounded font-normal">
|
|
{emp.roles[0].name === 'Main Contractor Admin' ? 'Contractor Admin' : emp.roles[0].name}
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] text-slate-400">({emp.email})</span>
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Selected Personnel summary badges */}
|
|
<div className="pt-2 border-t border-slate-200/60">
|
|
<Label className="text-[10px] uppercase font-bold text-slate-400 block mb-1.5">Project Personnel Pool ({selectedUserUlids.length} selected)</Label>
|
|
<div className="flex flex-wrap gap-1">
|
|
{selectedUserUlids.length === 0 ? (
|
|
<span className="text-xs italic text-slate-400">No personnel selected yet.</span>
|
|
) : (
|
|
selectedUserUlids.map(ulid => {
|
|
const emp = employees.find(e => e.ulid === ulid);
|
|
if (!emp) return null;
|
|
return (
|
|
<Badge key={ulid} variant="secondary" className="bg-emerald-50 text-emerald-700 hover:bg-emerald-50 border-emerald-100 font-normal py-0.5 px-2 text-[11px]">
|
|
{emp.name}
|
|
</Badge>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-slate-200/60 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
|
<div>
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 flex items-center gap-1">
|
|
2. Labor Trade Allocations
|
|
</h4>
|
|
<p className="text-[11px] text-slate-500">Allocate standard labor trades or assemble pre-built job crew packages for scheduled tasks.</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => onOpenLaborCatalog(null)}
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-indigo-200 text-indigo-700 hover:bg-indigo-50 text-xs font-semibold h-8"
|
|
>
|
|
<Layers className="mr-1.5 h-3.5 w-3.5" /> Select from Catalog / Bundles
|
|
</Button>
|
|
</div>
|
|
|
|
{localLabor.length === 0 ? (
|
|
<div className="py-12 text-center border border-dashed border-slate-200 rounded-xl bg-slate-50/50 space-y-3">
|
|
<Users className="mx-auto h-8 w-8 text-slate-400" />
|
|
<div>
|
|
<p className="text-xs font-medium text-slate-700">No manpower allocations added yet</p>
|
|
<p className="text-[11px] text-slate-400 mt-0.5">Browse single labor trades or allocate predefined crew packages for your tasks.</p>
|
|
</div>
|
|
<div className="flex items-center justify-center gap-2 pt-1">
|
|
<Button
|
|
onClick={() => onOpenLaborCatalog(null)}
|
|
size="sm"
|
|
className="bg-indigo-600 hover:bg-indigo-700 text-white text-xs"
|
|
>
|
|
<Layers className="mr-1.5 h-3.5 w-3.5" /> Browse Catalog & Bundles
|
|
</Button>
|
|
<Button
|
|
onClick={addLaborAllocation}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs"
|
|
>
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" /> Add Blank Row
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="border border-slate-200/80 rounded-xl overflow-hidden shadow-sm bg-white">
|
|
<Table>
|
|
<TableHeader className="bg-slate-50/60">
|
|
<TableRow>
|
|
<TableHead className="font-semibold text-slate-700">Target Task</TableHead>
|
|
<TableHead className="font-semibold text-slate-700 min-w-[220px]">Labor / Crew Allocation</TableHead>
|
|
<TableHead className="font-semibold text-slate-700 w-28">Type</TableHead>
|
|
<TableHead className="font-semibold text-slate-700 w-36">Unit Rate</TableHead>
|
|
<TableHead className="font-semibold text-slate-700 w-36">Estimated Scope</TableHead>
|
|
<TableHead className="font-semibold text-slate-700 text-right w-36">Total Cost</TableHead>
|
|
<TableHead className="text-right w-14"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{localLabor.map((item, idx) => {
|
|
const isBundle = item.type === 'bundle';
|
|
const rateObj = !isBundle ? labors.find(r => r.ulid === item.labor_ulid) : null;
|
|
const unitRate = isBundle
|
|
? Number(item.unit_rate || 0)
|
|
: (rateObj ? Number(rateObj.hourly_rate) : Number(item.unit_rate || 0));
|
|
const qty = Number(item.quantity || item.estimated_hours || 0);
|
|
const subtotal = qty * unitRate;
|
|
|
|
return (
|
|
<TableRow key={idx} className="hover:bg-slate-50/20 transition-colors">
|
|
<TableCell className="w-56 max-w-[220px]">
|
|
<Select
|
|
value={item.task_ulid || 'general'}
|
|
onValueChange={val => updateLaborAllocation(idx, 'task_ulid', val || '')}
|
|
items={[
|
|
{ value: 'general', label: 'General Project / Unassigned' },
|
|
...project.tasks.map((t: any) => ({ value: t.ulid, label: t.name }))
|
|
]}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs border-slate-200 w-full truncate">
|
|
<SelectValue placeholder="Select Task" className="truncate" />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-w-xs">
|
|
<SelectItem value="general">General Project / Unassigned</SelectItem>
|
|
{project.tasks.map((t: any) => (
|
|
<SelectItem key={t.ulid} value={t.ulid} className="truncate">{t.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
{isBundle ? (
|
|
<div className="space-y-1 py-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenLaborCatalog(idx)}
|
|
className="flex items-center gap-1.5 font-semibold text-slate-800 text-xs hover:text-indigo-600 transition-colors text-left group"
|
|
>
|
|
<Layers className="h-3.5 w-3.5 text-indigo-600 shrink-0 group-hover:scale-110 transition-transform" />
|
|
<span className="underline-offset-2 group-hover:underline">{item.name}</span>
|
|
</button>
|
|
{item.bundle_items && item.bundle_items.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{item.bundle_items.map((bTrade: any, bIdx: number) => (
|
|
<span key={bIdx} className="text-[10px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded border border-slate-200/50">
|
|
{bTrade.labor_name} ({formatCurrency(bTrade.rate_per_sqm || 0)}/m²)
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 w-full justify-start text-xs border-slate-200 font-normal hover:bg-slate-50 text-left"
|
|
onClick={() => onOpenLaborCatalog(idx)}
|
|
>
|
|
{rateObj ? (
|
|
<span className="truncate flex items-center gap-1.5">
|
|
<HardHat className="h-3.5 w-3.5 text-emerald-600 shrink-0" />
|
|
<span className="font-semibold text-slate-800">{rateObj.name}</span>
|
|
<span className="text-slate-400 text-[10px]">({rateObj.category})</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-450">Select Labor / Trade...</span>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{isBundle ? (
|
|
<Badge className="bg-indigo-50 text-indigo-700 border-indigo-100 hover:bg-indigo-50 font-semibold text-[10px] uppercase">
|
|
Crew Bundle
|
|
</Badge>
|
|
) : (
|
|
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-100 hover:bg-emerald-50 font-semibold text-[10px] uppercase">
|
|
Single Trade
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs text-slate-700 font-medium">
|
|
{isBundle ? (
|
|
<span>{formatCurrency(unitRate)} / m²</span>
|
|
) : (
|
|
<span>{rateObj ? `${formatCurrency(unitRate)} / hr` : '—'}</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="relative flex items-center">
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
value={item.quantity || item.estimated_hours || ''}
|
|
onChange={e => updateLaborAllocation(idx, 'quantity', e.target.value)}
|
|
className="h-8 text-xs border-slate-200 font-mono font-bold pr-9"
|
|
/>
|
|
<span className="absolute right-2 text-[11px] font-semibold text-slate-400 select-none pointer-events-none">
|
|
{isBundle ? 'm²' : 'hrs'}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="font-mono font-bold text-slate-900 text-right text-xs">
|
|
{formatCurrency(subtotal)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="icon" onClick={() => removeLaborAllocation(idx)} className="h-8 w-8 text-rose-500 hover:text-rose-700 hover:bg-rose-50">
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
<TableRow className="bg-slate-50/60 hover:bg-slate-50/60">
|
|
<TableCell colSpan={5} className="font-bold text-slate-800 text-xs">Total Manpower Cost Estimate</TableCell>
|
|
<TableCell className="font-mono font-bold text-slate-900 text-right text-sm">{formatCurrency(calculatedLaborCost)}</TableCell>
|
|
<TableCell></TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between pt-6 border-t border-slate-100">
|
|
<Button variant="outline" onClick={handleBack}>
|
|
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
|
</Button>
|
|
<Button onClick={handleSaveLabor} className="bg-emerald-600 hover:bg-emerald-700 text-white font-medium">
|
|
Save & Next <ChevronRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|