734 lines
32 KiB
TypeScript
734 lines
32 KiB
TypeScript
import React, { useState, useMemo, useEffect } from 'react';
|
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
|
import { Head, router, usePage } from '@inertiajs/react';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Check, AlertTriangle, Sparkles } from 'lucide-react';
|
|
import { MaterialCatalogModal, MaterialOption } from '@modules/MaterialLogistics/resources/js/Pages/Requisitions/MaterialCatalogModal';
|
|
import LaborLookupModal from '../../Components/LaborLookupModal';
|
|
import EquipmentLookupModal from '../../Components/EquipmentLookupModal';
|
|
import { ProjectWizardProps, Labor, Equipment } from '../../types/project-wizard';
|
|
import Step1Details from './Wizard/Step1Details';
|
|
import Step2Tasks from './Wizard/Step2Tasks';
|
|
import Step3Materials from './Wizard/Step3Materials';
|
|
import Step4Manpower from './Wizard/Step4Manpower';
|
|
import Step5Equipment from './Wizard/Step5Equipment';
|
|
import Step6Estimation from './Wizard/Step6Estimation';
|
|
import Step7Submit from './Wizard/Step7Submit';
|
|
|
|
export default function Wizard({
|
|
project,
|
|
step: currentStep,
|
|
employees,
|
|
projects = [],
|
|
classifications = [],
|
|
materials = [],
|
|
materialGroups = [],
|
|
labors = [],
|
|
equipments = [],
|
|
teams = []
|
|
}: ProjectWizardProps) {
|
|
const { errors } = usePage<any>().props;
|
|
const [step, setStep] = useState(currentStep);
|
|
const [catalogModalOpen, setCatalogModalOpen] = useState(false);
|
|
|
|
const [laborModalOpen, setLaborModalOpen] = useState(false);
|
|
const [activeLaborRowIdx, setActiveLaborRowIdx] = useState<number | null>(null);
|
|
|
|
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
|
const [activeEquipmentRowIdx, setActiveEquipmentRowIdx] = useState<number | null>(null);
|
|
|
|
// Step 1 Local State (Project Details)
|
|
const [step1Details, setStep1Details] = useState<any>(() => ({
|
|
name: project.name || '',
|
|
project_type: project.project_type || 'standard',
|
|
classifications: project.classifications || [],
|
|
is_unprofitable: project.is_unprofitable || false,
|
|
location: project.location || '',
|
|
client_name: project.client_name || '',
|
|
start_date: (project as any).start_date || '',
|
|
target_end_date: (project as any).target_end_date || '',
|
|
contract_duration: (project as any).contract_duration || '',
|
|
description: (project as any).description || '',
|
|
pm_id: (project as any).pm_id ? String((project as any).pm_id) : '',
|
|
contract_value: project.contract_value || '',
|
|
parent_project_id: project.parent_project_id ? String(project.parent_project_id) : '',
|
|
}));
|
|
|
|
// Step 2 Local States (Tasks & Milestones)
|
|
const [localMilestones, setLocalMilestones] = useState<any[]>(() => {
|
|
return project.milestones.length > 0 ? project.milestones.map(m => ({
|
|
ulid: m.ulid,
|
|
name: m.name,
|
|
weight_percentage: String(m.weight_percentage)
|
|
})) : [];
|
|
});
|
|
const [localTasks, setLocalTasks] = useState<any[]>(() => {
|
|
return project.tasks.length > 0 ? project.tasks.map(t => ({
|
|
ulid: t.ulid,
|
|
name: t.name,
|
|
description: t.description || '',
|
|
milestone_ulid: t.milestone?.ulid || '',
|
|
start_date: t.start_date || '',
|
|
end_date: t.end_date || ''
|
|
})) : [];
|
|
});
|
|
const [prepopulateMilestones, setPrepopulateMilestones] = useState(false);
|
|
|
|
// Step 3 Local States (Material Estimates)
|
|
const [localEstimates, setLocalEstimates] = useState<any[]>(() => {
|
|
return project.materials_estimates.length > 0 ? project.materials_estimates.map(e => ({
|
|
material_ulid: e.material.ulid,
|
|
material_name: e.material.name,
|
|
unit: e.material.unit,
|
|
estimated_qty: String(e.estimated_qty),
|
|
unit_cost: String(e.unit_cost)
|
|
})) : [];
|
|
});
|
|
|
|
// Step 4 Local States (Manpower/Labor allocations)
|
|
const [localLabor, setLocalLabor] = useState<any[]>(() => {
|
|
const list: any[] = [];
|
|
project.tasks.forEach(t => {
|
|
if (t.task_labors && t.task_labors.length > 0) {
|
|
const bundleGroups: { [key: string]: any[] } = {};
|
|
|
|
t.task_labors.forEach((tl: any) => {
|
|
if (tl.allocation_type === 'bundle' && tl.bundle_name) {
|
|
const bKey = `${t.ulid}_${tl.bundle_name}`;
|
|
if (!bundleGroups[bKey]) {
|
|
bundleGroups[bKey] = [];
|
|
}
|
|
bundleGroups[bKey].push(tl);
|
|
} else {
|
|
list.push({
|
|
type: 'trade',
|
|
task_ulid: t.ulid,
|
|
task_name: t.name,
|
|
labor_ulid: tl.labor?.ulid || '',
|
|
name: tl.labor?.name || 'Labor Trade',
|
|
category: tl.labor?.category || 'skilled',
|
|
unit: 'hrs',
|
|
unit_rate: Number(tl.labor?.hourly_rate || 0),
|
|
quantity: String(tl.estimated_hours),
|
|
estimated_hours: String(tl.estimated_hours)
|
|
});
|
|
}
|
|
});
|
|
|
|
Object.values(bundleGroups).forEach(group => {
|
|
const first = group[0];
|
|
list.push({
|
|
type: 'bundle',
|
|
task_ulid: t.ulid,
|
|
task_name: t.name,
|
|
name: first.bundle_name,
|
|
unit: first.bundle_unit || 'm²',
|
|
quantity: String(first.bundle_quantity || 10),
|
|
unit_rate: Number(first.bundle_unit_rate || 0),
|
|
bundle_items: group.map((tl: any) => ({
|
|
labor_name: tl.labor?.name || 'Trade',
|
|
category: tl.labor?.category || 'skilled',
|
|
rate_per_sqm: Number(tl.bundle_unit_rate || 0) / group.length,
|
|
estimated_hourly_rate: Number(tl.labor?.hourly_rate || 0)
|
|
}))
|
|
});
|
|
});
|
|
}
|
|
});
|
|
return list;
|
|
});
|
|
|
|
// Step 5 Local States (Equipment allocations)
|
|
const [localEquipment, setLocalEquipment] = useState<any[]>(() => {
|
|
const list: any[] = [];
|
|
project.tasks.forEach(t => {
|
|
if (t.task_equipments && t.task_equipments.length > 0) {
|
|
t.task_equipments.forEach((te: any) => {
|
|
list.push({
|
|
task_ulid: t.ulid,
|
|
task_name: t.name,
|
|
equipment_ulid: te.equipment?.ulid || '',
|
|
estimated_hours: String(te.estimated_hours)
|
|
});
|
|
});
|
|
}
|
|
});
|
|
return list;
|
|
});
|
|
|
|
// Step 7 Local States (Approval Submission)
|
|
const [submissionNotes, setSubmissionNotes] = useState('');
|
|
|
|
// Step 4 Team Roster / Personnel Pool Local States
|
|
const [selectedUserUlids, setSelectedUserUlids] = useState<string[]>(() => {
|
|
return project.personnel ? project.personnel.map((p: any) => p.ulid) : [];
|
|
});
|
|
|
|
const [selectedTeamUlids, setSelectedTeamUlids] = useState<string[]>(() => {
|
|
const activePersonnelUlids = project.personnel ? project.personnel.map((p: any) => p.ulid) : [];
|
|
return teams.filter(t => t.users && t.users.length > 0 && t.users.every((u: any) => activePersonnelUlids.includes(u.ulid))).map(t => t.ulid);
|
|
});
|
|
|
|
// Prepopulate default milestones locally when toggle checked
|
|
useEffect(() => {
|
|
if (prepopulateMilestones && localMilestones.length === 0) {
|
|
setLocalMilestones([
|
|
{ name: 'Mobilization', weight_percentage: '5' },
|
|
{ name: 'Earthworks & Foundation', weight_percentage: '15' },
|
|
{ name: 'Structural Works', weight_percentage: '25' },
|
|
{ name: 'Roofing & Waterproofing', weight_percentage: '15' },
|
|
{ name: 'Architectural Finishing', weight_percentage: '20' },
|
|
{ name: 'MEP Rough-In', weight_percentage: '10' },
|
|
{ name: 'Final Inspection & Punch List', weight_percentage: '5' },
|
|
{ name: 'Turnover', weight_percentage: '5' }
|
|
]);
|
|
}
|
|
}, [prepopulateMilestones]);
|
|
|
|
// Financial Summaries
|
|
const formatCurrency = (v: string | number) => {
|
|
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
|
|
};
|
|
|
|
const calculatedLaborCost = useMemo(() => {
|
|
return localLabor.reduce((sum, item) => {
|
|
if (item.type === 'bundle') {
|
|
const rate = Number(item.unit_rate || 0);
|
|
const area = Number(item.quantity || 0);
|
|
return sum + (area * rate);
|
|
} else {
|
|
const rateObj = labors.find(r => r.ulid === item.labor_ulid);
|
|
const rate = rateObj ? Number(rateObj.hourly_rate) : Number(item.unit_rate || 0);
|
|
const hours = Number(item.quantity || item.estimated_hours || 0);
|
|
return sum + (hours * rate);
|
|
}
|
|
}, 0);
|
|
}, [localLabor, labors]);
|
|
|
|
const calculatedEquipmentCost = useMemo(() => {
|
|
return localEquipment.reduce((sum, item) => {
|
|
const rateObj = equipments.find(r => r.ulid === item.equipment_ulid);
|
|
const rate = rateObj ? Number(rateObj.hourly_rate) : 0;
|
|
return sum + (Number(item.estimated_hours) * rate);
|
|
}, 0);
|
|
}, [localEquipment, equipments]);
|
|
|
|
const calculatedMaterialsCost = useMemo(() => {
|
|
return localEstimates.reduce((sum, item) => {
|
|
return sum + (Number(item.estimated_qty) * Number(item.unit_cost));
|
|
}, 0);
|
|
}, [localEstimates]);
|
|
|
|
const totalEstimatedCost = calculatedMaterialsCost + calculatedLaborCost + calculatedEquipmentCost;
|
|
const contractValueNum = Number(project.contract_value);
|
|
const grossMarginVal = contractValueNum - totalEstimatedCost;
|
|
const grossMarginPct = contractValueNum > 0 ? (grossMarginVal / contractValueNum) * 100 : 0;
|
|
const isUnprofitableEst = grossMarginVal < 0 || project.is_unprofitable;
|
|
|
|
// Stepper Titles
|
|
const stepsList = [
|
|
{ id: 1, label: 'Details' },
|
|
{ id: 2, label: 'Tasks' },
|
|
{ id: 3, label: 'Materials' },
|
|
{ id: 4, label: 'Manpower' },
|
|
{ id: 5, label: 'Equipment' },
|
|
{ id: 6, label: 'Estimation' },
|
|
{ id: 7, label: 'Submit' }
|
|
];
|
|
|
|
// Navigation and Saves
|
|
const handleBack = () => {
|
|
if (step > 1) {
|
|
router.get(route('projects.wizard', [project.ulid, { step: step - 1 }]));
|
|
}
|
|
};
|
|
|
|
const handleSaveTasks = () => {
|
|
router.post(route('projects.wizard.tasks', project.ulid), {
|
|
prepopulate_milestones: prepopulateMilestones,
|
|
milestones: localMilestones,
|
|
tasks: localTasks
|
|
}, {
|
|
onSuccess: () => setStep(3)
|
|
});
|
|
};
|
|
|
|
const handleSaveEstimates = () => {
|
|
router.post(route('projects.wizard.estimates', project.ulid), {
|
|
estimates: localEstimates
|
|
}, {
|
|
onSuccess: () => setStep(4)
|
|
});
|
|
};
|
|
|
|
const handleSaveLabor = () => {
|
|
const flattenedLabor: any[] = [];
|
|
|
|
localLabor.forEach(item => {
|
|
const targetTaskUlid = (item.task_ulid && item.task_ulid !== 'general')
|
|
? item.task_ulid
|
|
: (project.tasks[0]?.ulid || '');
|
|
|
|
if (!targetTaskUlid) return;
|
|
|
|
if (item.type === 'bundle') {
|
|
const area = Number(item.quantity || 0);
|
|
const bundleItems = item.bundle_items || [];
|
|
const bundleRate = Number(item.unit_rate || 0);
|
|
|
|
bundleItems.forEach((bItem: any) => {
|
|
const matched = labors.find(l =>
|
|
l.name.toLowerCase().includes(bItem.labor_name.toLowerCase()) ||
|
|
bItem.labor_name.toLowerCase().includes(l.name.toLowerCase())
|
|
) || (labors.find(l => l.category === bItem.category) || labors[0]);
|
|
|
|
if (matched) {
|
|
const itemRatePerSqm = Number(bItem.rate_per_sqm || bItem.estimated_hourly_rate || 50);
|
|
const itemCost = itemRatePerSqm * area;
|
|
const hourlyRate = Number(matched.hourly_rate) || 100;
|
|
const hours = itemCost / hourlyRate;
|
|
|
|
flattenedLabor.push({
|
|
task_ulid: targetTaskUlid,
|
|
labor_ulid: matched.ulid,
|
|
allocation_type: 'bundle',
|
|
bundle_name: item.name,
|
|
bundle_unit: 'm²',
|
|
bundle_quantity: String(area),
|
|
bundle_unit_rate: String(bundleRate),
|
|
estimated_hours: (Math.round(hours * 100) / 100).toFixed(2)
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
if (item.labor_ulid) {
|
|
const rateObj = labors.find(r => r.ulid === item.labor_ulid);
|
|
flattenedLabor.push({
|
|
task_ulid: targetTaskUlid,
|
|
labor_ulid: item.labor_ulid,
|
|
allocation_type: 'trade',
|
|
bundle_name: null,
|
|
bundle_unit: 'hrs',
|
|
bundle_quantity: String(item.quantity || item.estimated_hours || 0),
|
|
bundle_unit_rate: String(rateObj ? rateObj.hourly_rate : (item.unit_rate || 0)),
|
|
estimated_hours: String(item.quantity || item.estimated_hours || 0)
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
router.post(route('projects.wizard.labor', project.ulid), {
|
|
labor: flattenedLabor,
|
|
team_ulids: selectedTeamUlids,
|
|
user_ulids: selectedUserUlids,
|
|
}, {
|
|
onSuccess: () => setStep(5)
|
|
});
|
|
};
|
|
|
|
const handleSaveEquipment = () => {
|
|
router.post(route('projects.wizard.equipment', project.ulid), {
|
|
equipment: localEquipment
|
|
}, {
|
|
onSuccess: () => setStep(6)
|
|
});
|
|
};
|
|
|
|
const handleSubmitApproval = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
router.post(route('projects.wizard.submit', project.ulid), {
|
|
notes: submissionNotes
|
|
});
|
|
};
|
|
|
|
const handleCatalogSelect = (selectedMaterials: MaterialOption[]) => {
|
|
const mapped = selectedMaterials.map(m => ({
|
|
material_ulid: m.ulid,
|
|
material_name: m.name,
|
|
unit: m.unit,
|
|
estimated_qty: '1',
|
|
unit_cost: String(m.unit_cost || 0)
|
|
}));
|
|
const filtered = mapped.filter(m => !localEstimates.some(e => e.material_ulid === m.material_ulid));
|
|
setLocalEstimates([...localEstimates, ...filtered]);
|
|
setCatalogModalOpen(false);
|
|
};
|
|
|
|
const handleCatalogAddKit = (group: any, quantityMultiplier: number) => {
|
|
if (!group.materials) return;
|
|
const mapped = group.materials.map((m: any) => ({
|
|
material_ulid: m.ulid,
|
|
material_name: m.name,
|
|
unit: m.unit,
|
|
estimated_qty: String((m.pivot?.quantity || 1) * quantityMultiplier),
|
|
unit_cost: String(m.unit_cost || 0)
|
|
}));
|
|
const updatedEstimates = [...localEstimates];
|
|
mapped.forEach((newItem: any) => {
|
|
const existingIdx = updatedEstimates.findIndex(e => e.material_ulid === newItem.material_ulid);
|
|
if (existingIdx > -1) {
|
|
const existingQty = Number(updatedEstimates[existingIdx].estimated_qty) || 0;
|
|
updatedEstimates[existingIdx].estimated_qty = String(existingQty + Number(newItem.estimated_qty));
|
|
} else {
|
|
updatedEstimates.push(newItem);
|
|
}
|
|
});
|
|
setLocalEstimates(updatedEstimates);
|
|
setCatalogModalOpen(false);
|
|
};
|
|
|
|
const handleSelectLabor = (labor: Labor) => {
|
|
if (activeLaborRowIdx !== null) {
|
|
setLocalLabor(prev => prev.map((item, i) => i === activeLaborRowIdx ? {
|
|
...item,
|
|
type: 'trade',
|
|
labor_ulid: labor.ulid,
|
|
name: labor.name,
|
|
category: labor.category,
|
|
unit: 'hrs',
|
|
unit_rate: Number(labor.hourly_rate),
|
|
quantity: item.quantity || item.estimated_hours || '40',
|
|
estimated_hours: item.quantity || item.estimated_hours || '40'
|
|
} : item));
|
|
} else {
|
|
setLocalLabor(prev => [
|
|
...prev,
|
|
{
|
|
type: 'trade',
|
|
task_ulid: 'general',
|
|
labor_ulid: labor.ulid,
|
|
name: labor.name,
|
|
category: labor.category,
|
|
unit: 'hrs',
|
|
unit_rate: Number(labor.hourly_rate),
|
|
quantity: '40',
|
|
estimated_hours: '40'
|
|
}
|
|
]);
|
|
}
|
|
setLaborModalOpen(false);
|
|
setActiveLaborRowIdx(null);
|
|
};
|
|
|
|
const handleAddLaborBundle = (bundle: any, crewMultiplier: number) => {
|
|
const ratePerSqm = bundle.items.reduce((sum: number, item: any) => sum + (item.rate_per_sqm || item.estimated_hourly_rate || 0), 0);
|
|
const sqmArea = crewMultiplier || 10;
|
|
|
|
const newBundleData = {
|
|
type: 'bundle',
|
|
bundle_id: bundle.id,
|
|
name: bundle.name,
|
|
job_type: bundle.job_type,
|
|
category: bundle.category,
|
|
unit: 'm²',
|
|
unit_rate: ratePerSqm,
|
|
quantity: String(sqmArea),
|
|
bundle_items: bundle.items
|
|
};
|
|
|
|
if (activeLaborRowIdx !== null) {
|
|
setLocalLabor(prev => prev.map((item, i) => i === activeLaborRowIdx ? {
|
|
...item,
|
|
...newBundleData,
|
|
task_ulid: item.task_ulid || 'general'
|
|
} : item));
|
|
} else {
|
|
setLocalLabor(prev => [
|
|
...prev,
|
|
{
|
|
...newBundleData,
|
|
task_ulid: 'general'
|
|
}
|
|
]);
|
|
}
|
|
setLaborModalOpen(false);
|
|
setActiveLaborRowIdx(null);
|
|
};
|
|
|
|
const handleSelectEquipment = (equipment: Equipment) => {
|
|
if (activeEquipmentRowIdx !== null) {
|
|
updateEquipmentAllocation(activeEquipmentRowIdx, 'equipment_ulid', equipment.ulid);
|
|
}
|
|
setEquipmentModalOpen(false);
|
|
setActiveEquipmentRowIdx(null);
|
|
};
|
|
|
|
// Task & Milestone Helpers
|
|
const addMilestone = () => {
|
|
setLocalMilestones([...localMilestones, { name: '', weight_percentage: '0' }]);
|
|
};
|
|
|
|
const removeMilestone = (idx: number) => {
|
|
setLocalMilestones(localMilestones.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const updateMilestone = (idx: number, field: string, val: string) => {
|
|
setLocalMilestones(localMilestones.map((m, i) => i === idx ? { ...m, [field]: val } : m));
|
|
};
|
|
|
|
const addTask = () => {
|
|
setLocalTasks([...localTasks, { name: '', description: '', milestone_ulid: '', start_date: '', end_date: '' }]);
|
|
};
|
|
|
|
const removeTask = (idx: number) => {
|
|
setLocalTasks(localTasks.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const updateTask = (idx: number, field: string, val: string) => {
|
|
setLocalTasks(localTasks.map((t, i) => i === idx ? { ...t, [field]: val } : t));
|
|
};
|
|
|
|
// Allocations Helpers
|
|
const addLaborAllocation = () => {
|
|
setLocalLabor([...localLabor, {
|
|
type: 'trade',
|
|
task_ulid: 'general',
|
|
labor_ulid: '',
|
|
name: '',
|
|
unit: 'hrs',
|
|
unit_rate: 0,
|
|
quantity: '40',
|
|
estimated_hours: '40'
|
|
}]);
|
|
};
|
|
|
|
const removeLaborAllocation = (idx: number) => {
|
|
setLocalLabor(localLabor.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const updateLaborAllocation = (idx: number, field: string, val: string) => {
|
|
setLocalLabor(localLabor.map((item, i) => {
|
|
if (i !== idx) return item;
|
|
const updated = { ...item, [field]: val };
|
|
if (field === 'quantity') {
|
|
updated.estimated_hours = val;
|
|
}
|
|
return updated;
|
|
}));
|
|
};
|
|
|
|
const addEquipmentAllocation = () => {
|
|
setLocalEquipment([...localEquipment, { task_ulid: '', equipment_ulid: '', estimated_hours: '0' }]);
|
|
};
|
|
|
|
const removeEquipmentAllocation = (idx: number) => {
|
|
setLocalEquipment(localEquipment.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const updateEquipmentAllocation = (idx: number, field: string, val: string) => {
|
|
setLocalEquipment(localEquipment.map((item, i) => i === idx ? { ...item, [field]: val } : item));
|
|
};
|
|
|
|
return (
|
|
<AuthenticatedLayout
|
|
header={
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-bold leading-tight text-slate-800 flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-emerald-500" /> Project Wizard: {project.name}
|
|
</h2>
|
|
<p className="text-xs text-slate-500 mt-0.5">{project.code} · Guided Project Estimation Setup</p>
|
|
</div>
|
|
<Badge className={
|
|
project.status === 'planning' ? 'bg-amber-50 text-amber-700 border-amber-100' : 'bg-emerald-50 text-emerald-700 border-emerald-100'
|
|
}>
|
|
{project.status.replace(/_/g, ' ')}
|
|
</Badge>
|
|
</div>
|
|
}
|
|
>
|
|
<Head title={`Setup Wizard: ${project.name}`} />
|
|
|
|
<div className="py-6 bg-slate-50/50 min-h-[calc(100vh-65px)]">
|
|
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 space-y-6">
|
|
{/* Stepper Header */}
|
|
<div className="bg-white px-6 py-4 rounded-xl border border-slate-200/80 shadow-xs">
|
|
<div className="flex justify-between items-center relative">
|
|
<div className="absolute left-6 right-6 top-4 -translate-y-1/2 h-[2px] bg-slate-100 z-0 hidden md:block" />
|
|
|
|
{stepsList.map((s) => {
|
|
const isActive = step === s.id;
|
|
const isCompleted = step > s.id || project.current_wizard_step > s.id;
|
|
return (
|
|
<div key={s.id} className="flex flex-col items-center z-10 relative">
|
|
<div className={`h-8 w-8 rounded-full flex items-center justify-center font-bold text-xs border transition-all duration-200 ${
|
|
isActive
|
|
? 'bg-emerald-600 border-emerald-600 text-white shadow-xs'
|
|
: isCompleted
|
|
? 'bg-emerald-50 border-emerald-300 text-emerald-700'
|
|
: 'bg-white border-slate-200 text-slate-400'
|
|
}`}>
|
|
{isCompleted ? <Check className="h-4 w-4 stroke-[2.5px]" /> : s.id}
|
|
</div>
|
|
<span className={`text-[11px] mt-1.5 font-medium transition-all ${
|
|
isActive ? 'text-emerald-700 font-bold' : isCompleted ? 'text-slate-700' : 'text-slate-400'
|
|
}`}>{s.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step Content Container */}
|
|
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs overflow-visible">
|
|
{Object.keys(errors || {}).length > 0 && (
|
|
<div className="p-4 bg-rose-50 border-b border-rose-100 text-rose-800 text-xs space-y-1">
|
|
<p className="font-bold text-rose-900 flex items-center gap-1.5">
|
|
<AlertTriangle className="h-4 w-4 text-rose-600 animate-pulse" />
|
|
Please correct the following errors before proceeding:
|
|
</p>
|
|
<ul className="list-disc pl-5 mt-1 space-y-0.5 font-medium">
|
|
{Object.entries(errors).map(([key, val]) => (
|
|
<li key={key}>{String(val)}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{step === 1 && (
|
|
<Step1Details
|
|
project={project}
|
|
step1Details={step1Details}
|
|
setStep1Details={setStep1Details}
|
|
errors={errors}
|
|
employees={employees}
|
|
projects={projects}
|
|
classifications={classifications}
|
|
onNext={() => setStep(2)}
|
|
/>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<Step2Tasks
|
|
localMilestones={localMilestones}
|
|
localTasks={localTasks}
|
|
prepopulateMilestones={prepopulateMilestones}
|
|
setPrepopulateMilestones={setPrepopulateMilestones}
|
|
addMilestone={addMilestone}
|
|
removeMilestone={removeMilestone}
|
|
updateMilestone={updateMilestone}
|
|
addTask={addTask}
|
|
removeTask={removeTask}
|
|
updateTask={updateTask}
|
|
handleBack={handleBack}
|
|
handleSaveTasks={handleSaveTasks}
|
|
/>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<Step3Materials
|
|
localEstimates={localEstimates}
|
|
setLocalEstimates={setLocalEstimates}
|
|
calculatedMaterialsCost={calculatedMaterialsCost}
|
|
formatCurrency={formatCurrency}
|
|
onOpenCatalog={() => setCatalogModalOpen(true)}
|
|
handleBack={handleBack}
|
|
handleSaveEstimates={handleSaveEstimates}
|
|
/>
|
|
)}
|
|
|
|
{step === 4 && (
|
|
<Step4Manpower
|
|
project={project}
|
|
localLabor={localLabor}
|
|
labors={labors}
|
|
employees={employees}
|
|
teams={teams}
|
|
selectedTeamUlids={selectedTeamUlids}
|
|
setSelectedTeamUlids={setSelectedTeamUlids}
|
|
selectedUserUlids={selectedUserUlids}
|
|
setSelectedUserUlids={setSelectedUserUlids}
|
|
addLaborAllocation={addLaborAllocation}
|
|
removeLaborAllocation={removeLaborAllocation}
|
|
updateLaborAllocation={updateLaborAllocation}
|
|
calculatedLaborCost={calculatedLaborCost}
|
|
formatCurrency={formatCurrency}
|
|
onOpenLaborCatalog={(idx) => {
|
|
setActiveLaborRowIdx(idx !== undefined ? idx : null);
|
|
setLaborModalOpen(true);
|
|
}}
|
|
handleBack={handleBack}
|
|
handleSaveLabor={handleSaveLabor}
|
|
/>
|
|
)}
|
|
|
|
{step === 5 && (
|
|
<Step5Equipment
|
|
project={project}
|
|
localEquipment={localEquipment}
|
|
equipments={equipments}
|
|
addEquipmentAllocation={addEquipmentAllocation}
|
|
removeEquipmentAllocation={removeEquipmentAllocation}
|
|
updateEquipmentAllocation={updateEquipmentAllocation}
|
|
calculatedEquipmentCost={calculatedEquipmentCost}
|
|
formatCurrency={formatCurrency}
|
|
onOpenEquipmentLookup={(idx) => {
|
|
setActiveEquipmentRowIdx(idx);
|
|
setEquipmentModalOpen(true);
|
|
}}
|
|
handleBack={handleBack}
|
|
handleSaveEquipment={handleSaveEquipment}
|
|
/>
|
|
)}
|
|
|
|
{step === 6 && (
|
|
<Step6Estimation
|
|
project={project}
|
|
isUnprofitableEst={isUnprofitableEst}
|
|
calculatedMaterialsCost={calculatedMaterialsCost}
|
|
calculatedLaborCost={calculatedLaborCost}
|
|
calculatedEquipmentCost={calculatedEquipmentCost}
|
|
totalEstimatedCost={totalEstimatedCost}
|
|
grossMarginVal={grossMarginVal}
|
|
grossMarginPct={grossMarginPct}
|
|
contractValueNum={contractValueNum}
|
|
formatCurrency={formatCurrency}
|
|
handleBack={handleBack}
|
|
onNext={() => setStep(7)}
|
|
/>
|
|
)}
|
|
|
|
{step === 7 && (
|
|
<Step7Submit
|
|
project={project}
|
|
errors={errors}
|
|
submissionNotes={submissionNotes}
|
|
setSubmissionNotes={setSubmissionNotes}
|
|
handleBack={handleBack}
|
|
handleSubmitApproval={handleSubmitApproval}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
<MaterialCatalogModal
|
|
open={catalogModalOpen}
|
|
onOpenChange={setCatalogModalOpen}
|
|
onSelectMaterials={handleCatalogSelect}
|
|
onAddKit={handleCatalogAddKit}
|
|
materials={materials}
|
|
materialGroups={materialGroups}
|
|
/>
|
|
|
|
<LaborLookupModal
|
|
open={laborModalOpen}
|
|
onOpenChange={setLaborModalOpen}
|
|
labors={labors}
|
|
onSelectLabor={handleSelectLabor}
|
|
onAddLaborBundle={handleAddLaborBundle}
|
|
selectedLaborUlid={activeLaborRowIdx !== null ? localLabor[activeLaborRowIdx]?.labor_ulid : undefined}
|
|
/>
|
|
|
|
<EquipmentLookupModal
|
|
open={equipmentModalOpen}
|
|
onOpenChange={setEquipmentModalOpen}
|
|
equipments={equipments}
|
|
onSelectEquipment={handleSelectEquipment}
|
|
selectedEquipmentUlid={activeEquipmentRowIdx !== null ? localEquipment[activeEquipmentRowIdx]?.equipment_ulid : undefined}
|
|
/>
|
|
</AuthenticatedLayout>
|
|
);
|
|
}
|