feat(estimate): redesign estimate builder UI and fix theme conflicts

- Restructure section/group/item hierarchy to match reference designs:
  collapse chevron, cost + green price box, ellipsis menu per section;
  trash on hover + cost/price per group; inline trash, orange qty=0
  border, read-only cost/unit, SRS warning rows per item
- Add compact horizontal margin slider with orange accent
- Add section total banner with white/30 bordered price box
- Rewrite MaterialDetailsModal to match reference (3-row formula layout:
  Measurement @ Waste = Qty, Cost/Unit with conversion note, Total formula)
- Fix MaterialDetailsModal always rendering in light mode: add dark: variants
- Fix MeasurementsModal always rendering in light mode: add dark: variants
- Fix Financial Summary sidebar always rendering in dark mode: base bg-white
  with dark: gradient, all inner elements get proper dark: variants
- Add new estimate component files: MaterialDetailsModal, MeasurementsModal,
  ImageUploadModal, InitialChoiceModal, TemplateSelectionModal
This commit is contained in:
Satyam
2026-03-13 00:29:13 +05:30
parent d5c7c17cd8
commit a0af994614
6 changed files with 1847 additions and 177 deletions
+619 -177
View File
@@ -1,17 +1,30 @@
import React, { useState, useEffect } from 'react';
import {
Plus, Trash2, Calculator, Settings, AlertCircle, Save, ArrowLeft,
FileText, User, MapPin, Phone, Mail, CheckCircle2, MoreHorizontal, ChevronDown, DollarSign, Maximize, Minimize
FileText, User, MapPin, Phone, Mail, CheckCircle2, MoreHorizontal, ChevronDown, ChevronRight, DollarSign, Maximize, Minimize, UploadCloud, GripVertical, Tag
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import ImageUploadModal from '../components/estimates/ImageUploadModal';
import TemplateSelectionModal from '../components/estimates/TemplateSelectionModal';
import MeasurementsModal from '../components/estimates/MeasurementsModal';
import MaterialDetailsModal from '../components/estimates/MaterialDetailsModal';
import InitialChoiceModal from '../components/estimates/InitialChoiceModal';
const generateId = () => Math.random().toString(36).substr(2, 9);
const UOM_OPTIONS = ['SQ', 'EA', 'BD', 'RL', 'LF', 'HR', 'LS'];
const UOM_OPTIONS = ['SQ', 'EA', 'BD', 'RL', 'LF', 'HR', 'LS', 'PC', 'BX'];
export default function EstimateBuilder() {
const navigate = useNavigate();
// --- Zone E: Workflow State ---
// Workflow: 'upload' → 'initialChoice' → 'template' (or 'none') → 'measurements' → 'none'
const [workflowStep, setWorkflowStep] = useState('upload');
const [selectedImage, setSelectedImage] = useState(null);
const [selectedTemplate, setSelectedTemplate] = useState(null);
const [measurementsData, setMeasurementsData] = useState(null);
const [activeDetailsItem, setActiveDetailsItem] = useState(null);
// --- Zone A: Client Info ---
const [clientInfo, setClientInfo] = useState({
name: '',
@@ -60,6 +73,8 @@ export default function EstimateBuilder() {
{
id: generateId(),
name: 'Roofing Section',
description: [],
marginPercent: 30,
groups: [
{
id: generateId(),
@@ -79,6 +94,12 @@ export default function EstimateBuilder() {
}
]);
// Collapsed state for section descriptions
const [collapsedDescriptions, setCollapsedDescriptions] = useState({});
const toggleDescription = (sectionId) => {
setCollapsedDescriptions(prev => ({ ...prev, [sectionId]: !prev[sectionId] }));
};
// --- Zone D: Financials ---
const [financials, setFinancials] = useState({
taxRate: 0,
@@ -97,11 +118,131 @@ export default function EstimateBuilder() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFullViewMode]);
// --- Workflow Handlers ---
const handleImageSelect = (imageName) => {
setSelectedImage(imageName);
setWorkflowStep('initialChoice');
};
const handleTemplateSelect = (template) => {
setSelectedTemplate(template);
setWorkflowStep('measurements');
};
const handleMeasurementsNext = (measurements) => {
setMeasurementsData(measurements);
// Auto-generate sections based on template
if (selectedTemplate) {
const newSection = {
id: generateId(),
name: `${selectedTemplate.name} Section`,
description: selectedTemplate.description || [],
marginPercent: 30,
groups: [
{
id: generateId(),
name: 'Materials',
items: selectedTemplate.materials.map(mat => {
let baseQty = 0;
let conversionFactor = 1;
let measurementUom = mat.uom;
// Logic to handle different UOMs based on typical roofing measurements
if (mat.uom === 'SQ') {
baseQty = measurements.totalRoofArea || 0;
measurementUom = 'SQ';
} else if (mat.uom === 'BD') {
if (mat.desc.toLowerCase().includes('ridge')) {
baseQty = measurements.ridges || 0;
conversionFactor = 30; // 30 LF per Bundle of Ridge
} else {
baseQty = measurements.eaves || 0;
conversionFactor = 100; // 100 LF per Bundle of Starter
}
measurementUom = 'LF';
} else if (mat.uom === 'RL') {
if (mat.desc.toLowerCase().includes('ice')) {
baseQty = measurements.eaves || 0;
conversionFactor = 65; // 65 LF per Roll of Ice & Water
measurementUom = 'LF';
} else {
baseQty = measurements.totalRoofArea || 0;
conversionFactor = 10; // 10 SQ per Roll of Synthetic
measurementUom = 'SQ';
}
} else if (mat.uom === 'BX') {
baseQty = 1;
measurementUom = 'BX';
} else if (mat.uom === 'EA') {
baseQty = 1;
measurementUom = 'EA';
} else if (mat.uom === 'PC') {
baseQty = 1;
measurementUom = 'PC';
} else {
baseQty = measurements.totalRoofArea || 0;
}
const calculatedPreCeil = (baseQty * 1.05) / conversionFactor; // 5% flat waste for initial generation
const finalQty = Math.ceil(calculatedPreCeil);
return {
id: generateId(),
desc: mat.desc,
uom: mat.uom,
unitCost: mat.baseUnitCost,
qty: finalQty,
clientPrice: finalQty * mat.baseUnitCost * 1.428, // ~30% margin default
baseMeasurement: baseQty,
wastePercent: 5,
conversionFactor: conversionFactor,
marginPercent: 30,
measurementUom: measurementUom
};
})
}
]
};
// Replace the dummy default section with the generated one
setSections([newSection]);
}
setWorkflowStep('none');
};
const handleItemClick = (sectionId, groupId, item) => {
setActiveDetailsItem({ sectionId, groupId, item });
};
const handleSaveMaterialDetails = (updatedItem) => {
if (!activeDetailsItem) return;
const { sectionId, groupId } = activeDetailsItem;
setSections(sections.map(s => {
if (s.id === sectionId) {
return {
...s,
groups: s.groups.map(g => {
if (g.id === groupId) {
return {
...g,
items: g.items.map(i => i.id === updatedItem.id ? updatedItem : i)
};
}
return g;
})
};
}
return s;
}));
};
// Handlers for Zone C
const addSection = () => {
setSections([...sections, {
id: generateId(),
name: `New Section ${sections.length + 1}`,
description: [],
marginPercent: 30,
groups: []
}]);
};
@@ -185,6 +326,27 @@ export default function EstimateBuilder() {
setSections(sections.map(s => s.id === sectionId ? { ...s, name } : s));
};
const updateSectionMargin = (sectionId, newMarginPercent) => {
setSections(sections.map(s => {
if (s.id === sectionId) {
return {
...s,
margin: newMarginPercent,
marginPercent: newMarginPercent,
groups: s.groups.map(g => ({
...g,
items: g.items.map(i => {
const cost = i.qty * i.unitCost;
const clientPrice = newMarginPercent < 100 ? cost / (1 - (newMarginPercent / 100)) : cost;
return { ...i, marginPercent: newMarginPercent, clientPrice };
})
}))
};
}
return s;
}));
};
const updateGroupName = (sectionId, groupId, name) => {
setSections(sections.map(s => {
if (s.id === sectionId) {
@@ -213,9 +375,6 @@ export default function EstimateBuilder() {
const ohpAmount = subtotal * (financials.ohp / 100);
const grandTotal = subtotal + tax + ohpAmount;
// Profit margin calculation (based on Revenue - Cost)
// Here we consider total revenue as the Subtotal + OHP.
// If OHP is part of revenue, Final Gross Margin % = ((GrandTotal(excluding tax) - InternalCost) / GrandTotal(excluding tax)) * 100
const totalRevenueNoTax = subtotal + ohpAmount;
const netProfit = totalRevenueNoTax - internalCost;
const grossMarginPercent = totalRevenueNoTax > 0 ? (netProfit / totalRevenueNoTax) * 100 : 0;
@@ -236,6 +395,46 @@ export default function EstimateBuilder() {
return { cost, rev };
};
// Helper calculation for an entire section
const calcSectionTotals = (section) => {
let cost = 0;
let rev = 0;
section.groups.forEach(g => {
g.items.forEach(i => {
cost += (i.qty * i.unitCost);
rev += i.clientPrice;
});
});
const margin = rev > 0 ? ((rev - cost) / rev) * 100 : 0;
const profitAmount = rev - cost;
return { cost, rev, margin, profitAmount };
};
// Bottom action bar component
const BottomActionBar = () => (
<div className={`flex items-center gap-3 px-4 py-3 bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-white/10 ${isFullViewMode ? 'shrink-0' : 'rounded-b-2xl'}`}>
<button
onClick={() => navigate(-1)}
className="flex items-center px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white border border-zinc-200 dark:border-zinc-700 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-1.5" /> Quit
</button>
<button className="flex items-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
<Save className="w-4 h-4 mr-1.5" /> Save
</button>
<div className="flex-1" />
<button className="flex items-center px-5 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors shadow-sm">
Preview
</button>
<button
onClick={addSection}
className="flex items-center px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<Plus className="w-4 h-4 mr-1.5" /> Add Section
</button>
</div>
);
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-zinc-100 p-6 md:p-10 space-y-10 w-full max-w-[2400px] mx-auto">
@@ -252,6 +451,9 @@ export default function EstimateBuilder() {
<p className="text-zinc-500 dark:text-zinc-400 text-sm mt-1">Create and configure project estimates for clients.</p>
</div>
<div className="flex items-center gap-4">
<button onClick={() => setWorkflowStep('upload')} className="flex items-center px-4 py-2.5 bg-zinc-800 dark:bg-zinc-100 hover:bg-zinc-700 dark:hover:bg-white text-white dark:text-zinc-900 border border-transparent rounded-xl transition-all shadow-md text-sm font-medium">
<UploadCloud className="w-4 h-4 mr-2" /> Start from Image
</button>
<button className="flex items-center px-4 py-2.5 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl hover:bg-zinc-100 dark:hover:bg-white/5 transition-all text-sm font-medium">
<Settings className="w-4 h-4 mr-2" /> Options
</button>
@@ -362,13 +564,13 @@ export default function EstimateBuilder() {
)}
<section className={`transition-[inset,padding,border-radius,background-color] duration-300 ease-out border border-zinc-200 dark:border-white/10 ${isFullViewMode ?
`fixed inset-4 md:inset-10 z-50 bg-white dark:bg-[#0a0a0c] rounded-3xl p-6 md:p-10 shadow-[0_0_100px_rgba(0,0,0,0.5)] overflow-hidden flex flex-col`
: `bg-white dark:bg-zinc-900/50 rounded-2xl p-8 shadow-sm relative overflow-hidden`
`fixed inset-4 md:inset-10 z-50 bg-white dark:bg-[#0a0a0c] rounded-3xl shadow-[0_0_100px_rgba(0,0,0,0.5)] overflow-hidden flex flex-col`
: `bg-white dark:bg-zinc-900/50 rounded-2xl shadow-sm relative overflow-hidden`
}`}>
{/* Blueprint background for construction feel */}
<div className="absolute inset-0 blueprint-fine opacity-30 dark:opacity-[0.05] pointer-events-none" />
<div className="flex items-center justify-between mb-8 relative z-10 shrink-0">
<div className={`flex items-center justify-between relative z-10 shrink-0 ${isFullViewMode ? 'p-6 md:p-8 pb-4' : 'p-8 pb-6'}`}>
<div className="flex items-center gap-4">
<div className="p-3 bg-emerald-100 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-xl shadow-inner">
<Calculator className="w-5 h-5" />
@@ -398,161 +600,368 @@ export default function EstimateBuilder() {
</div>
</div>
<div className={`space-y-10 relative z-10 ${isFullViewMode ? 'overflow-y-auto custom-scrollbar flex-1 pr-2' : ''}`}>
{sections.map(section => (
<div key={section.id} className="border-l-2 border-zinc-200 dark:border-zinc-700 pl-4 py-2">
<div className="flex items-center gap-3 mb-4 group">
<input
value={section.name}
onChange={(e) => updateSectionName(section.id, e.target.value)}
className="text-lg font-bold bg-transparent border-b border-transparent focus:border-emerald-500 outline-none text-zinc-900 dark:text-white pb-1 focus:ring-0"
/>
<button onClick={() => setSections(sections.filter(s => s.id !== section.id))} className="opacity-0 group-hover:opacity-100 p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-md transition-all">
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className={`space-y-0 relative z-10 ${isFullViewMode ? 'overflow-y-auto custom-scrollbar flex-1 px-6 md:px-8 pb-4' : 'px-8 pb-6'}`}>
{sections.map((section, sectionIndex) => {
const sectionTotals = calcSectionTotals(section);
const currentMargin = section.marginPercent !== undefined ? section.marginPercent : sectionTotals.margin;
const isDescCollapsed = collapsedDescriptions[section.id] !== false; // default collapsed
<div className="space-y-4">
{section.groups.map(group => {
const groupTotals = calcGroupTotals(group);
return (
<div key={group.id} className="bg-zinc-50 dark:bg-zinc-800/40 border border-zinc-200 dark:border-white/5 rounded-xl overflow-hidden">
{/* Group Header */}
<div className="flex items-center justify-between px-4 py-3 bg-zinc-100/50 dark:bg-zinc-800/80 border-b border-zinc-200 dark:border-white/5 group/header">
<div className="flex items-center gap-2">
<input
value={group.name}
onChange={(e) => updateGroupName(section.id, group.id, e.target.value)}
className="font-semibold text-sm bg-transparent border-b border-transparent focus:border-emerald-500 outline-none w-48 text-zinc-800 dark:text-zinc-200 pb-0.5"
/>
return (
<div key={section.id} className="mb-10">
{/* Section Header */}
<div className="flex items-center gap-3 mb-3 group py-2 border-b border-zinc-200 dark:border-zinc-700">
<button
onClick={() => toggleDescription(section.id)}
className="p-0.5 text-zinc-400 hover:text-zinc-600 transition-colors shrink-0"
>
{isDescCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<input
value={section.name}
onChange={(e) => updateSectionName(section.id, e.target.value)}
className="text-base font-bold bg-transparent border-b border-transparent focus:border-emerald-500 outline-none text-zinc-900 dark:text-white pb-0.5 focus:ring-0 flex-1"
/>
<span className="text-xs font-mono text-zinc-500 shrink-0">${sectionTotals.cost.toFixed(2)}</span>
<div className="flex items-center gap-1 border border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-900/20 rounded px-2 py-0.5 shrink-0">
<span className="text-xs text-zinc-500">$</span>
<span className="text-sm font-bold text-emerald-700 dark:text-emerald-400 font-mono">{sectionTotals.rev.toFixed(2)}</span>
</div>
<button
onClick={() => setSections(sections.filter(s => s.id !== section.id))}
className="opacity-0 group-hover:opacity-100 p-1 text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-md transition-all shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
<button className="p-1 text-zinc-400 hover:text-zinc-600 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</button>
</div>
{/* Section Description Block (collapsible) */}
{section.description && section.description.length > 0 && !isDescCollapsed && (
<div className="mb-4 pl-2 py-3 bg-zinc-50 dark:bg-zinc-800/30 rounded-lg border border-zinc-100 dark:border-zinc-800">
<ul className="space-y-1.5 pl-4">
{section.description.map((line, i) => (
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-400 flex items-start gap-2">
<span className="text-zinc-400 mt-0.5">-</span>
<span>{line}</span>
</li>
))}
</ul>
</div>
)}
{/* Groups */}
<div className="space-y-4">
{section.groups.map(group => {
const groupTotals = calcGroupTotals(group);
return (
<div key={group.id} className="bg-zinc-50 dark:bg-zinc-800/40 border border-zinc-200 dark:border-white/5 rounded-xl overflow-hidden">
{/* Group Header */}
<div className="flex items-center justify-between px-4 py-2.5 bg-zinc-50 dark:bg-zinc-800/60 border-b border-zinc-200 dark:border-white/5 group/header">
<div className="flex items-center gap-2">
<button
onClick={() => setSections(sections.map(s => s.id === section.id ? { ...s, groups: s.groups.filter(g => g.id !== group.id) } : s))}
className="p-1 text-zinc-300 hover:text-red-500 opacity-0 group-hover/header:opacity-100 transition-all rounded"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<input
value={group.name}
onChange={(e) => updateGroupName(section.id, group.id, e.target.value)}
className="font-semibold text-sm bg-transparent border-b border-transparent focus:border-emerald-500 outline-none text-zinc-700 dark:text-zinc-300 pb-0.5 focus:ring-0"
/>
</div>
<div className="flex items-center gap-4">
<span className="text-xs font-mono text-zinc-500">${groupTotals.cost.toFixed(2)}</span>
<div className="flex items-center gap-1 border border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-900/20 rounded px-2 py-0.5">
<span className="text-xs text-zinc-400">$</span>
<span className="text-sm font-bold text-emerald-700 dark:text-emerald-400 font-mono">{groupTotals.rev.toFixed(2)}</span>
</div>
</div>
</div>
<button onClick={() => setSections(sections.map(s => s.id === section.id ? { ...s, groups: s.groups.filter(g => g.id !== group.id) } : s))} className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-md transition-all opacity-0 group-hover/header:opacity-100">
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Items Table - Responsive horizontal scroll container */}
<div className="w-full overflow-x-auto custom-scrollbar">
<table className="w-full text-base text-left whitespace-nowrap">
<thead className="text-sm text-zinc-500 dark:text-zinc-400 bg-white/50 dark:bg-zinc-900/50 border-b border-zinc-200 dark:border-white/5 uppercase font-semibold tracking-wider">
<tr>
<th className="px-6 py-4 w-12 text-center">Sr No.</th>
<th className="px-6 py-4 min-w-[300px]">Description</th>
<th className="px-6 py-4 w-28">Qty</th>
<th className="px-6 py-4 w-28">UOM</th>
<th className="px-6 py-4 w-36">Unit Cost ($)</th>
<th className="px-6 py-4 w-36 text-zinc-400">Total Cost</th>
<th className="px-6 py-4 w-44 font-bold text-zinc-900 dark:text-white">Client Price ($)</th>
<th className="px-6 py-4 w-12 text-center text-zinc-400">Act</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200/50 dark:divide-white/5">
{group.items.map((item, index) => (
<tr key={item.id} className="hover:bg-zinc-100/30 dark:hover:bg-white/5 transition-colors group/row">
<td className="px-6 py-4 text-center font-mono text-zinc-400 dark:text-zinc-500">
{index + 1}
</td>
<td className="px-6 py-4">
<input
value={item.desc}
onChange={(e) => updateItem(section.id, group.id, item.id, 'desc', e.target.value)}
className="w-full bg-transparent border-none focus:ring-2 focus:ring-emerald-500 rounded-lg px-3 py-2 outline-none text-zinc-800 dark:text-zinc-200 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
placeholder="Item description..."
/>
</td>
<td className="px-6 py-4">
<input
type="number"
value={item.qty || ''}
onChange={(e) => updateItem(section.id, group.id, item.id, 'qty', e.target.value)}
className="w-full bg-transparent border border-zinc-200 dark:border-zinc-700 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500 rounded-lg px-3 py-2 outline-none font-mono text-zinc-800 dark:text-zinc-200 hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors"
/>
</td>
<td className="px-6 py-4">
<div className="relative">
<select
value={item.uom}
onChange={(e) => updateItem(section.id, group.id, item.id, 'uom', e.target.value)}
className="w-full appearance-none bg-transparent border border-zinc-200 dark:border-zinc-700 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500 rounded-lg pl-3 pr-8 py-2 outline-none font-mono text-zinc-800 dark:text-zinc-200 hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors"
>
{UOM_OPTIONS.map(opt => <option key={opt} value={opt} className="bg-white dark:bg-zinc-900">{opt}</option>)}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400 pointer-events-none" />
</div>
</td>
<td className="px-6 py-4">
<input
type="number"
value={item.unitCost || ''}
onChange={(e) => updateItem(section.id, group.id, item.id, 'unitCost', e.target.value)}
className="w-full bg-transparent border border-zinc-200 dark:border-zinc-700 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500 rounded-lg px-3 py-2 outline-none font-mono text-zinc-800 dark:text-zinc-200 hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors"
/>
</td>
<td className="px-6 py-4 font-mono text-zinc-500 dark:text-zinc-400">
${(item.qty * item.unitCost).toFixed(2)}
</td>
<td className="px-6 py-4">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 font-mono">$</span>
<input
type="number"
value={item.clientPrice || ''}
onChange={(e) => updateItem(section.id, group.id, item.id, 'clientPrice', e.target.value)}
className="w-full bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200 dark:border-emerald-800/30 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500 rounded-lg pl-8 pr-3 py-2 outline-none font-mono font-bold text-emerald-900 dark:text-emerald-100 transition-colors"
/>
</div>
</td>
<td className="px-6 py-4 flex justify-center">
<button
onClick={() => removeItem(section.id, group.id, item.id)}
className="text-zinc-400 hover:text-red-500 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors opacity-0 group-hover/row:opacity-100"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
{/* Items Table */}
<div className="w-full overflow-x-auto custom-scrollbar">
<table className="w-full text-base text-left whitespace-nowrap">
<thead className="text-xs text-zinc-500 dark:text-zinc-400 bg-white/50 dark:bg-zinc-900/50 border-b border-zinc-200 dark:border-white/5 uppercase font-semibold tracking-wider">
<tr>
<th className="px-3 py-3 w-8 text-center"></th>
<th className="px-4 py-3 min-w-[260px]">Description</th>
<th className="px-4 py-3 w-44">Unit</th>
<th className="px-4 py-3 w-32">Cost/Unit</th>
<th className="px-4 py-3 w-32 text-zinc-400">Cost</th>
<th className="px-4 py-3 w-40 font-bold text-zinc-700 dark:text-zinc-200">Price</th>
<th className="px-4 py-3 w-10 text-center">···</th>
</tr>
))}
{/* Add Item Row Button */}
<tr>
<td colSpan="8" className="px-6 py-4">
<button
onClick={() => addItem(section.id, group.id)}
className="flex items-center text-sm font-medium text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300 transition-colors py-2 px-3 hover:bg-emerald-50 dark:hover:bg-emerald-500/10 rounded-lg focus:outline-none"
>
<Plus className="w-5 h-5 mr-2" /> Add Line Item
</button>
</td>
</tr>
</tbody>
</table>
</div>
{/* Group Subtotal Footer */}
<div className="bg-white/80 dark:bg-zinc-900/80 px-4 py-2 flex justify-end gap-8 border-t border-zinc-200 dark:border-white/5 text-xs">
<div className="flex items-center gap-2">
<span className="text-zinc-500">Group Cost:</span>
<span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">${groupTotals.cost.toFixed(2)}</span>
</thead>
<tbody className="divide-y divide-zinc-200/50 dark:divide-white/5">
{group.items.map((item, index) => (
<React.Fragment key={item.id}>
<tr className={`hover:bg-zinc-100/30 dark:hover:bg-white/5 transition-colors group/row ${item.srsUnavailable ? 'bg-red-50/30 dark:bg-red-900/10' : ''}`}>
{/* Drag handle */}
<td className="px-3 py-3 text-center">
<GripVertical className="w-4 h-4 text-zinc-300 dark:text-zinc-600 cursor-grab mx-auto" />
</td>
{/* Description + inline trash */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<input
id={`desc-${item.id}`}
name={`desc-${item.id}`}
value={item.desc}
onChange={(e) => updateItem(section.id, group.id, item.id, 'desc', e.target.value)}
className="flex-1 min-w-[150px] bg-transparent border-none focus:ring-2 focus:ring-emerald-500 rounded-lg px-3 py-2 outline-none text-zinc-800 dark:text-zinc-200 hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-sm"
placeholder="Item description..."
/>
<button
onClick={() => removeItem(section.id, group.id, item.id)}
className="text-zinc-300 hover:text-red-500 p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors opacity-0 group-hover/row:opacity-100 shrink-0"
title="Remove item"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
{/* Unit = Qty + UOM side by side */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<input
type="number"
id={`qty-${item.id}`}
name={`qty-${item.id}`}
value={item.qty || ''}
onChange={(e) => updateItem(section.id, group.id, item.id, 'qty', e.target.value)}
className={`w-20 bg-transparent border focus:ring-1 rounded-lg px-2 py-2 outline-none font-mono text-sm text-zinc-800 dark:text-zinc-200 transition-colors ${
!item.qty || item.qty === 0
? 'border-orange-400 focus:border-orange-500 focus:ring-orange-400'
: 'border-zinc-200 dark:border-zinc-700 focus:border-emerald-500 focus:ring-emerald-500 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
/>
<div className="relative">
<select
id={`uom-${item.id}`}
name={`uom-${item.id}`}
value={item.uom}
onChange={(e) => updateItem(section.id, group.id, item.id, 'uom', e.target.value)}
className="appearance-none bg-transparent border border-zinc-200 dark:border-zinc-700 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 rounded-lg pl-2 pr-7 py-2 outline-none font-mono text-sm text-zinc-800 dark:text-zinc-200 hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors w-16"
>
{UOM_OPTIONS.map(opt => <option key={opt} value={opt} className="bg-white dark:bg-zinc-900">{opt}</option>)}
</select>
<ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-zinc-400 pointer-events-none" />
</div>
</div>
</td>
{/* Cost/Unit (read-only computed display) */}
<td className="px-4 py-3 font-mono text-sm text-zinc-600 dark:text-zinc-400">
${(item.unitCost || 0).toFixed(2)}
</td>
{/* Cost (read-only calculated) */}
<td className="px-4 py-3 font-mono text-sm text-zinc-500 dark:text-zinc-400">
${(item.qty * item.unitCost).toFixed(2)}
</td>
{/* Price */}
<td className="px-4 py-3">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 font-mono text-sm">$</span>
<input
type="number"
id={`cprice-${item.id}`}
name={`cprice-${item.id}`}
value={item.clientPrice || ''}
onChange={(e) => updateItem(section.id, group.id, item.id, 'clientPrice', e.target.value)}
className="w-full min-w-[110px] bg-green-50 dark:bg-emerald-900/10 border border-emerald-200 dark:border-emerald-800/30 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 rounded-lg pl-7 pr-3 py-2 outline-none font-mono text-sm font-bold text-emerald-900 dark:text-emerald-100 transition-colors"
/>
</div>
</td>
{/* Actions ··· */}
<td className="px-4 py-3 text-center">
<button
onClick={() => handleItemClick(section.id, group.id, item)}
className="text-zinc-400 hover:text-blue-500 p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
title="Edit details"
>
<MoreHorizontal className="w-4 h-4" />
</button>
</td>
</tr>
{item.srsUnavailable && (
<tr className="bg-red-50 dark:bg-red-900/10">
<td colSpan={7} className="px-6 py-1.5 text-xs text-red-600 dark:text-red-400 font-medium">
This item is not available from SRS
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-500">Group Revenue:</span>
<span className="font-mono font-bold text-emerald-600 dark:text-emerald-400">${groupTotals.rev.toFixed(2)}</span>
{/* Add Item to Group button */}
<div className="px-4 py-3 border-t border-zinc-200 dark:border-white/5">
<button
onClick={() => addItem(section.id, group.id)}
className="flex items-center text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors py-1.5 px-3 border border-dashed border-blue-300 dark:border-blue-500/40 rounded-lg hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-500/10 focus:outline-none w-full justify-center"
>
<Plus className="w-4 h-4 mr-1.5" /> ADD MATERIAL OR LABOR TO GROUP
</button>
</div>
</div>
);
})}
</div>
{/* Add Item to Section search bar area */}
<div className="mt-4 flex items-center gap-3">
<div className="flex-1 relative">
<input
type="text"
placeholder="Search to add item to section or group..."
className="w-full bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-lg px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500 text-zinc-700 dark:text-zinc-300 placeholder-zinc-400"
/>
</div>
<button className="flex items-center px-4 py-2.5 bg-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700 rounded-lg text-sm font-medium transition-colors">
BROWSE
</button>
<button
onClick={() => addGroup(section.id)}
className="flex items-center text-sm font-medium text-zinc-500 hover:text-zinc-800 dark:hover:text-white transition-colors px-3 py-2"
>
<Plus className="w-4 h-4 mr-1" /> Add Group
</button>
</div>
{/* Section Profit Margin Slider */}
<div className="mt-4 flex items-center gap-2 px-3 py-2.5 bg-white dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<div className="w-1 h-8 bg-orange-500 rounded-full mr-1 shrink-0" />
<AlertCircle className="w-4 h-4 text-amber-500 shrink-0" />
<span className="text-sm font-semibold text-zinc-700 dark:text-zinc-300 mr-2 whitespace-nowrap">Section Profit Margin</span>
<input
type="range" min="0" max="60" step="1"
value={currentMargin}
onChange={(e) => updateSectionMargin(section.id, parseFloat(e.target.value))}
className="flex-1 h-2 rounded-lg appearance-none cursor-pointer accent-emerald-500"
style={{ background: `linear-gradient(to right, #10b981 0%, #10b981 ${(currentMargin/60)*100}%, #e4e4e7 ${(currentMargin/60)*100}%, #e4e4e7 100%)` }}
/>
<span className="text-sm font-mono text-blue-600 dark:text-blue-400 font-semibold ml-2 whitespace-nowrap">
${sectionTotals.profitAmount.toFixed(2)}
</span>
<div className="flex items-center border border-zinc-200 dark:border-zinc-600 rounded px-2 py-1 bg-zinc-50 dark:bg-zinc-900/50 ml-1">
<input
type="number"
value={currentMargin}
onChange={(e) => updateSectionMargin(section.id, parseFloat(e.target.value) || 0)}
className="w-10 bg-transparent outline-none font-mono text-sm text-amber-600 dark:text-amber-500 text-right"
/>
<span className="text-zinc-500 text-xs ml-0.5">%</span>
</div>
<span className="text-xs font-mono text-zinc-500 ml-2">${sectionTotals.cost.toFixed(2)}</span>
<div className="flex items-center border border-emerald-200 dark:border-emerald-800 rounded px-2 py-0.5 bg-emerald-50 dark:bg-emerald-900/20">
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400 font-mono">${sectionTotals.rev.toFixed(2)}</span>
</div>
</div>
{/* Taxes + OH&P row */}
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-zinc-400 mt-1">
<span className="font-medium text-zinc-500">Taxes</span>
<span></span>
<span className="font-medium text-zinc-500">OH &amp; P</span>
<span className="ml-auto font-mono">${(sectionTotals.rev * (financials.taxRate/100) + sectionTotals.rev * (financials.ohp/100)).toFixed(2)}</span>
</div>
{/* Section Total Banner */}
<div className="mt-1 flex items-center justify-between px-5 py-3 bg-[#787440] text-white rounded-lg">
<span className="uppercase tracking-widest text-xs font-bold opacity-90">{section.name.toUpperCase()} TOTAL</span>
<div className="flex items-center gap-3">
<span className="text-white/60 text-xs font-mono">Cost: ${sectionTotals.cost.toFixed(2)}</span>
<div className="flex items-center gap-1 border border-white/30 rounded px-2 py-0.5">
<span className="text-white/70 text-xs">$</span>
<span className="font-bold font-mono text-sm">{sectionTotals.rev.toFixed(2)}</span>
</div>
);
})}
</div>
</div>
</div>
);
})}
<button
onClick={() => addGroup(section.id)}
className="flex items-center text-sm font-medium text-zinc-500 hover:text-zinc-800 dark:hover:text-white transition-colors"
>
<Plus className="w-4 h-4 mr-1" /> Add Group
</button>
{/* Add New Section Button */}
<div className="pt-2 pb-4">
<button
onClick={addSection}
className="flex items-center justify-center w-full py-4 border-2 border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500 hover:text-emerald-600 hover:border-emerald-500 dark:hover:text-emerald-400 dark:hover:border-emerald-500 transition-colors font-medium text-sm"
>
<Plus className="w-5 h-5 mr-2" /> Add New Section
</button>
</div>
{/* Add Discount */}
<div className="flex justify-center pb-4">
<button className="flex items-center gap-1.5 text-sm text-blue-500 hover:text-blue-600 hover:underline font-medium transition-colors">
<Tag className="w-4 h-4" /> Add Discount
</button>
</div>
{/* Estimate Total Banner */}
<div className="flex items-center justify-between px-5 py-4 bg-[#787440] text-white rounded-xl font-bold tracking-wide shadow-md">
<span className="uppercase tracking-widest text-sm opacity-90">Estimate Total</span>
<div className="flex items-center gap-8 font-mono">
<span className="text-white/70 text-sm">Cost: ${totals.internalCost.toFixed(2)}</span>
<span className="text-xl">${totals.grandTotal.toFixed(2)}</span>
</div>
</div>
{/* Summary grid after estimate total */}
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
{/* Total OH&P */}
<div className="bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
<div className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Total OH&P</div>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Overhead</span>
<span className="font-mono">${totals.ohpAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Profit</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400">$0.00</span>
</div>
</div>
</div>
))}
{/* Taxes & Discounts */}
<div className="bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
<div className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Taxes & Discounts</div>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Taxes</span>
<span className="font-mono">${totals.tax.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Discounts</span>
<span className="font-mono">$0.00</span>
</div>
</div>
</div>
{/* Profit */}
<div className="bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
<div className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Profit</div>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Net Profit</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400">+${totals.netProfit.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Total Margin</span>
<span className="font-mono font-bold">{totals.grossMarginPercent.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Action Bar (inside section, sticky when full view) */}
<BottomActionBar />
</section>
</div>
@@ -562,24 +971,24 @@ export default function EstimateBuilder() {
{/* --- Zone D: Financial Summary & Profit Analysis --- */}
<div className="sticky top-6">
<section className="bg-gradient-to-b from-zinc-800 to-zinc-950 dark:from-[#111115] dark:to-[#09090b] rounded-2xl shadow-xl overflow-hidden text-white border border-zinc-700 dark:border-white/10">
<section className="bg-white dark:bg-gradient-to-b dark:from-zinc-800 dark:to-zinc-950 rounded-2xl shadow-xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
<div className="p-6 border-b border-white/10">
<h3 className="text-lg font-bold flex items-center gap-2 mb-4">
<div className="p-6 border-b border-zinc-200 dark:border-white/10">
<h3 className="text-lg font-bold flex items-center gap-2 mb-4 text-zinc-900 dark:text-white">
<DollarSign className="w-5 h-5 text-amber-500" />
Financial Summary
</h3>
<div className="space-y-4">
<div className="flex justify-between items-center text-sm">
<span className="text-zinc-400">Subtotal (Client Price)</span>
<span className="font-mono font-medium">${totals.subtotal.toFixed(2)}</span>
<span className="text-zinc-500 dark:text-zinc-400">Subtotal (Client Price)</span>
<span className="font-mono font-medium text-zinc-800 dark:text-white">${totals.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-sm group">
<span className="text-zinc-400 flex items-center gap-1">
<span className="text-zinc-500 dark:text-zinc-400 flex items-center gap-1">
Tax Rate
<span className="opacity-0 group-hover:opacity-100 transition-opacity text-xs bg-white/10 px-1.5 rounded text-zinc-300">%</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity text-xs bg-zinc-100 dark:bg-white/10 px-1.5 rounded text-zinc-500 dark:text-zinc-300">%</span>
</span>
<div className="flex flex-col items-end">
<div className="relative w-20">
@@ -587,17 +996,17 @@ export default function EstimateBuilder() {
type="number"
value={financials.taxRate || ''}
onChange={(e) => setFinancials({ ...financials, taxRate: parseFloat(e.target.value) || 0 })}
className="w-full bg-white/5 border border-white/10 rounded px-2 py-1 text-right text-sm font-mono focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none"
className="w-full bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/10 rounded px-2 py-1 text-right text-sm font-mono text-zinc-800 dark:text-white focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none"
/>
</div>
<span className="font-mono text-xs text-zinc-500 mt-1">${totals.tax.toFixed(2)}</span>
<span className="font-mono text-xs text-zinc-400 mt-1">${totals.tax.toFixed(2)}</span>
</div>
</div>
<div className="flex justify-between items-center text-sm group">
<span className="text-zinc-400 flex items-center gap-1">
<span className="text-zinc-500 dark:text-zinc-400 flex items-center gap-1">
OH&P Modifier
<span className="opacity-0 group-hover:opacity-100 transition-opacity text-xs bg-white/10 px-1.5 rounded text-zinc-300">%</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity text-xs bg-zinc-100 dark:bg-white/10 px-1.5 rounded text-zinc-500 dark:text-zinc-300">%</span>
</span>
<div className="flex flex-col items-end">
<div className="relative w-20">
@@ -605,20 +1014,20 @@ export default function EstimateBuilder() {
type="number"
value={financials.ohp || ''}
onChange={(e) => setFinancials({ ...financials, ohp: parseFloat(e.target.value) || 0 })}
className="w-full bg-white/5 border border-white/10 rounded px-2 py-1 text-right text-sm font-mono focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none"
className="w-full bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/10 rounded px-2 py-1 text-right text-sm font-mono text-zinc-800 dark:text-white focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none"
/>
</div>
<span className="font-mono text-xs text-zinc-500 mt-1">${totals.ohpAmount.toFixed(2)}</span>
<span className="font-mono text-xs text-zinc-400 mt-1">${totals.ohpAmount.toFixed(2)}</span>
</div>
</div>
</div>
</div>
{/* Grand Total */}
<div className="p-6 bg-amber-500/10 border-b border-amber-500/20">
<div className="p-6 bg-amber-50 dark:bg-amber-500/10 border-b border-amber-200 dark:border-amber-500/20">
<div className="flex justify-between items-end">
<span className="text-sm font-semibold text-amber-400 uppercase tracking-widest">Grand Total</span>
<span className="text-3xl font-display font-bold text-white tracking-tight">
<span className="text-sm font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-widest">Grand Total</span>
<span className="text-3xl font-display font-bold text-zinc-900 dark:text-white tracking-tight">
${totals.grandTotal.toFixed(2)}
</span>
</div>
@@ -626,43 +1035,42 @@ export default function EstimateBuilder() {
{/* Profit Analysis Gauge */}
<div className="p-6">
<h4 className="text-sm font-medium text-zinc-300 mb-4 flex items-center gap-2">
<Calculator className="w-4 h-4 text-emerald-400" />
<h4 className="text-sm font-medium text-zinc-600 dark:text-zinc-300 mb-4 flex items-center gap-2">
<Calculator className="w-4 h-4 text-emerald-500 dark:text-emerald-400" />
Profit Analysis
</h4>
<div className="space-y-4">
<div className="flex justify-between items-center text-sm">
<span className="text-zinc-400">Net Profit</span>
<span className="font-mono font-medium text-emerald-400">+${totals.netProfit.toFixed(2)}</span>
<span className="text-zinc-500 dark:text-zinc-400">Net Profit</span>
<span className="font-mono font-medium text-emerald-600 dark:text-emerald-400">+${totals.netProfit.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-sm mb-1">
<span className="text-zinc-400">Gross Margin</span>
<span className="font-mono font-bold text-white bg-white/10 px-2 py-0.5 rounded">
<span className="text-zinc-500 dark:text-zinc-400">Gross Margin</span>
<span className="font-mono font-bold text-zinc-800 dark:text-white bg-zinc-100 dark:bg-white/10 px-2 py-0.5 rounded">
{totals.grossMarginPercent.toFixed(1)}%
</span>
</div>
{/* Gauge Bar */}
<div className="h-3 w-full bg-white/5 rounded-full overflow-hidden relative">
<div className="h-3 w-full bg-zinc-100 dark:bg-white/5 rounded-full overflow-hidden relative">
<div
className={`h-full transition-all duration-500 ease-out ${totals.grossMarginPercent > 30 ? 'bg-emerald-500' :
totals.grossMarginPercent > 15 ? 'bg-amber-400' : 'bg-red-500'
}`}
style={{ width: `${Math.min(Math.max(totals.grossMarginPercent, 0), 100)}%` }}
/>
{/* Tick Marks for aesthetics */}
<div className="absolute inset-0 flex justify-between px-1">
{[...Array(9)].map((_, i) => (
<div key={i} className="w-px h-full bg-black/20" />
<div key={i} className="w-px h-full bg-black/10 dark:bg-black/20" />
))}
</div>
</div>
{/* Warning message if margin is low */}
{totals.grossMarginPercent < 20 && totals.grandTotal > 0 && (
<div className="flex items-start gap-2 text-xs text-amber-400/80 bg-amber-400/10 p-2.5 rounded-lg border border-amber-400/20">
<div className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400/80 bg-amber-50 dark:bg-amber-400/10 p-2.5 rounded-lg border border-amber-200 dark:border-amber-400/20">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<p>Margin is under 20%. Please review costs or markup strategy.</p>
</div>
@@ -676,6 +1084,40 @@ export default function EstimateBuilder() {
</div>
</div>
{/* --- Modals --- */}
<ImageUploadModal
isOpen={workflowStep === 'upload'}
onClose={() => setWorkflowStep('none')}
onSelectImage={handleImageSelect}
/>
<InitialChoiceModal
isOpen={workflowStep === 'initialChoice'}
onSelectTemplate={() => setWorkflowStep('template')}
onStartScratch={() => setWorkflowStep('none')}
/>
<TemplateSelectionModal
isOpen={workflowStep === 'template'}
onClose={() => setWorkflowStep('none')}
onSelectTemplate={handleTemplateSelect}
/>
<MeasurementsModal
isOpen={workflowStep === 'measurements'}
onCancel={() => setWorkflowStep('none')}
onNext={handleMeasurementsNext}
selectedImageId={selectedImage}
/>
<MaterialDetailsModal
isOpen={!!activeDetailsItem}
onClose={() => setActiveDetailsItem(null)}
item={activeDetailsItem?.item}
onSave={handleSaveMaterialDetails}
/>
</div>
);
}