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
@@ -0,0 +1,78 @@
import React from 'react';
import { X, UploadCloud, FileImage, FileText, CheckCircle2 } from 'lucide-react';
export default function ImageUploadModal({ isOpen, onClose, onSelectImage }) {
if (!isOpen) return null;
const dummyImages = [
{ id: 1, name: 'Measurement_Section_1.png', type: 'image' },
{ id: 2, name: 'Measurement_Section_2.png', type: 'image' },
{ id: 3, name: 'Measurement_Section_3.png', type: 'image' }
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div
className="bg-white dark:bg-zinc-900 w-full max-w-2xl rounded-2xl shadow-xl border border-zinc-200 dark:border-white/10 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-zinc-200 dark:border-white/10">
<div>
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">Upload Measurements</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">Upload an image or document, or select a sample to begin.</p>
</div>
<button
onClick={onClose}
className="p-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors focus:outline-none"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 md:p-8 space-y-8">
{/* Drag and drop zone */}
<div className="border-2 border-dashed border-zinc-300 dark:border-zinc-700 rounded-2xl p-8 flex flex-col items-center justify-center text-center hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/5 transition-colors cursor-pointer group">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<UploadCloud className="w-8 h-8" />
</div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white">Click to upload</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-2 max-w-sm mx-auto">
Drag and drop your measurement screenshots, aerial reports, or PDF documents here.
</p>
</div>
{/* Or Separator */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-zinc-200 dark:border-zinc-800"></div>
</div>
<div className="relative flex justify-center">
<span className="bg-white dark:bg-zinc-900 px-4 text-sm text-zinc-500 uppercase font-semibold tracking-wider">Or choose a mock example</span>
</div>
</div>
{/* Dummy Options */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{dummyImages.map((img) => (
<button
key={img.id}
onClick={() => onSelectImage(img.name)}
className="flex flex-col items-center p-4 border border-zinc-200 dark:border-zinc-800 rounded-xl hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-all text-center focus:outline-none focus:ring-2 focus:ring-blue-500 group"
>
<div className="w-12 h-12 bg-zinc-100 dark:bg-zinc-800 text-zinc-500 group-hover:text-blue-500 group-hover:bg-white dark:group-hover:bg-zinc-900 rounded-lg flex items-center justify-center mb-3 shadow-sm transition-colors">
<FileImage className="w-6 h-6" />
</div>
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 group-hover:text-blue-600 dark:group-hover:text-blue-400">{img.name}</span>
<span className="text-xs text-zinc-400 mt-1 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CheckCircle2 className="w-3 h-3" /> Select
</span>
</button>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import React from 'react';
import { PackageOpen, FileEdit } from 'lucide-react';
export default function InitialChoiceModal({ isOpen, onSelectTemplate, onStartScratch }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div
className="bg-white dark:bg-zinc-900 w-full max-w-xl rounded-2xl shadow-xl border border-zinc-200 dark:border-white/10 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex flex-col items-center justify-center p-8 border-b border-zinc-200 dark:border-white/10 text-center">
<h2 className="text-3xl font-display text-zinc-900 dark:text-white font-light tracking-tight">How would you like to proceed?</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-2 max-w-sm">Choose how to build this estimate from your uploaded image.</p>
</div>
{/* Content */}
<div className="p-8 space-y-4">
<button
onClick={onSelectTemplate}
className="w-full flex items-start gap-4 p-6 border-2 border-emerald-500/20 bg-emerald-50/50 hover:bg-emerald-50 dark:border-emerald-500/30 dark:bg-emerald-900/10 dark:hover:bg-emerald-900/20 rounded-xl transition-all group"
>
<div className="p-3 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 rounded-xl flex-shrink-0 group-hover:scale-110 transition-transform">
<PackageOpen className="w-6 h-6" />
</div>
<div className="text-left flex-1 text-zinc-900 dark:text-zinc-100">
<h3 className="font-semibold text-lg flex items-center gap-2">
Use Existing Template <span className="bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-full">Recommended</span>
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Select a pre-built roofing template to automatically calculate item quantities from your measurements.
</p>
</div>
</button>
<button
onClick={onStartScratch}
className="w-full flex items-start gap-4 p-6 border-2 border-zinc-200 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 rounded-xl transition-all group"
>
<div className="p-3 bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 rounded-xl flex-shrink-0 group-hover:scale-110 transition-transform">
<FileEdit className="w-6 h-6" />
</div>
<div className="text-left flex-1 text-zinc-900 dark:text-zinc-100">
<h3 className="font-semibold text-lg">Create from Scratch</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Open a blank estimate and manually add sections, groups, and line items.
</p>
</div>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,271 @@
import React, { useState, useEffect } from 'react';
import { X, Calculator, FileText } from 'lucide-react';
export default function MaterialDetailsModal({ isOpen, onClose, item, onSave }) {
const [measurement, setMeasurement] = useState(item?.baseMeasurement || 0);
const [wastePercent, setWastePercent] = useState(item?.wastePercent || 0);
const [conversionFactor, setConversionFactor] = useState(item?.conversionFactor || 1);
const [unitCost, setUnitCost] = useState(item?.unitCost || 0);
const [marginPercent, setMarginPercent] = useState(item?.marginPercent || 30);
const [pricePerUnitOverride, setPricePerUnitOverride] = useState(false);
const [manualPricePerUnit, setManualPricePerUnit] = useState(0);
const [calculatedQuantity, setCalculatedQuantity] = useState(0);
const [finalTotalUnits, setFinalTotalUnits] = useState(0);
const [totalCost, setTotalCost] = useState(0);
const [clientPrice, setClientPrice] = useState(0);
useEffect(() => {
if (item) {
setMeasurement(item.baseMeasurement || 0);
setWastePercent(item.wastePercent || 0);
setConversionFactor(item.conversionFactor || 1);
setUnitCost(item.unitCost || 0);
setMarginPercent(item.marginPercent || 30);
}
}, [item]);
useEffect(() => {
const withWaste = measurement * (1 + (wastePercent / 100));
const computedQty = conversionFactor > 0 ? (withWaste / conversionFactor) : withWaste;
setCalculatedQuantity(computedQty);
const finalUnits = Math.ceil(computedQty);
setFinalTotalUnits(finalUnits);
const cost = finalUnits * unitCost;
setTotalCost(cost);
if (marginPercent < 100) {
setClientPrice(cost / (1 - (marginPercent / 100)));
} else {
setClientPrice(cost);
}
}, [measurement, wastePercent, conversionFactor, unitCost, marginPercent]);
const computedPricePerUnit = finalTotalUnits > 0 ? clientPrice / finalTotalUnits : 0;
const handleSave = () => {
onSave({
...item,
baseMeasurement: measurement,
wastePercent,
conversionFactor,
unitCost,
marginPercent,
qty: finalTotalUnits,
clientPrice
});
onClose();
};
if (!isOpen || !item) return null;
const measurementUomLabel = item.measurementUom || item.uom;
const showConversion = conversionFactor > 1 || (item.measurementUom && item.measurementUom !== item.uom);
const conversionNote = showConversion
? `${conversionFactor} ${measurementUomLabel} = 1 ${item.uom}`
: `1 ${item.uom} = 1 ${item.uom}`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div
className="bg-white dark:bg-zinc-900 w-full max-w-3xl rounded-xl shadow-2xl border border-zinc-200 dark:border-zinc-700/50 overflow-hidden flex flex-col max-h-[92vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-zinc-200 dark:border-zinc-700/50 shrink-0">
<h2 className="text-3xl font-light tracking-tight text-zinc-400 dark:text-zinc-500" style={{ fontFamily: 'Georgia, serif' }}>Material Details</h2>
</div>
{/* Sub-header: item name + name input + add description */}
<div className="px-8 py-5 border-b border-zinc-200 dark:border-zinc-700/50 shrink-0 bg-zinc-50 dark:bg-zinc-800/40">
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3">{item.desc}</p>
<input
type="text"
placeholder="Estimate Item Name"
className="w-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded px-3 py-2.5 text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 text-zinc-600 dark:text-zinc-300 placeholder-zinc-300 dark:placeholder-zinc-600 transition-all"
defaultValue=""
/>
<button className="mt-2 text-blue-500 text-sm flex items-center gap-1.5 hover:text-blue-600 font-medium">
<FileText className="w-3.5 h-3.5" /> Add Description
</button>
</div>
{/* Main Content */}
<div className="p-8 overflow-y-auto flex-1 space-y-8 bg-white dark:bg-zinc-900">
{/* Row 1: Measurement @ Waste = Quantity */}
<div className="flex items-end gap-4 flex-wrap">
{/* Measurement */}
<div className="flex-1 min-w-[120px]">
<label className="block text-xs text-zinc-400 dark:text-zinc-500 mb-1.5">Measurement:</label>
<div className="flex items-center gap-2">
<input
type="number"
value={measurement || ''}
onChange={(e) => setMeasurement(parseFloat(e.target.value) || 0)}
className="w-24 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 rounded px-3 py-2 text-sm font-mono text-zinc-800 dark:text-zinc-200 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
/>
<span className="text-sm text-zinc-500 dark:text-zinc-400 font-medium">{measurementUomLabel}</span>
</div>
</div>
<span className="text-zinc-400 dark:text-zinc-500 text-lg mb-2">@</span>
{/* Waste */}
<div>
<label className="block text-xs text-zinc-400 dark:text-zinc-500 mb-1.5">Waste</label>
<div className="flex items-center gap-1 border-2 border-blue-300 dark:border-blue-500/60 rounded px-3 py-2 focus-within:border-blue-500">
<input
type="number"
value={wastePercent === 0 ? '' : wastePercent}
onChange={(e) => setWastePercent(parseFloat(e.target.value) || 0)}
placeholder="0"
className="w-12 bg-transparent outline-none text-sm font-mono text-zinc-800 dark:text-zinc-200 text-right"
/>
<span className="text-sm text-zinc-500 dark:text-zinc-400">%</span>
</div>
</div>
<span className="text-zinc-400 dark:text-zinc-500 text-lg mb-2">=</span>
{/* Quantity (read-only, post-waste pre-ceil) */}
<div>
<label className="block text-xs text-zinc-400 dark:text-zinc-500 mb-1.5">Quantity</label>
<div className="flex items-center gap-2">
<div className="w-24 border border-zinc-200 dark:border-zinc-700 rounded px-3 py-2 bg-zinc-50 dark:bg-zinc-800/50">
<span className="text-sm font-mono text-zinc-700 dark:text-zinc-300">{calculatedQuantity.toFixed(2)}</span>
</div>
<span className="text-sm text-zinc-500 dark:text-zinc-400 font-medium">{item.uom}</span>
</div>
</div>
</div>
<div className="border-t border-zinc-200 dark:border-zinc-700/50" />
{/* Row 2: Cost/Unit | Price/Unit */}
<div className="flex items-start gap-12 flex-wrap">
{/* Cost/Unit */}
<div>
<label className="block text-xs text-zinc-400 dark:text-zinc-500 mb-1.5">Cost / Unit</label>
<div className="flex items-center gap-2">
<div className="flex items-center border border-zinc-300 dark:border-zinc-600 rounded overflow-hidden focus-within:border-blue-400 focus-within:ring-1 focus-within:ring-blue-400">
<span className="px-2 py-2 text-sm text-zinc-500 dark:text-zinc-400 border-r border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800">$</span>
<input
type="number"
value={unitCost || ''}
onChange={(e) => setUnitCost(parseFloat(e.target.value) || 0)}
className="w-20 px-2 py-2 text-sm font-mono text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-900 outline-none text-right"
/>
</div>
<span className="text-sm text-zinc-500 dark:text-zinc-400">/ {item.uom}</span>
</div>
{/* Conversion note */}
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1.5">{conversionNote}</p>
{/* Editable conversion factor (small, subtle) */}
<div className="flex items-center gap-1 mt-1 text-xs text-zinc-400 dark:text-zinc-500">
<input
type="number"
value={conversionFactor}
onChange={(e) => setConversionFactor(parseFloat(e.target.value) || 1)}
className="w-10 border-b border-zinc-200 dark:border-zinc-600 bg-transparent outline-none text-center font-mono focus:border-blue-400 text-zinc-500 dark:text-zinc-400"
/>
<span>{measurementUomLabel} = 1 {item.uom}</span>
</div>
</div>
{/* Price/Unit checkbox */}
<div className="flex items-start gap-3">
<button
onClick={() => setPricePerUnitOverride(!pricePerUnitOverride)}
className={`w-5 h-5 mt-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${pricePerUnitOverride ? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20' : 'border-orange-400'}`}
>
{pricePerUnitOverride && <div className="w-2.5 h-2.5 bg-orange-500 rounded-sm" />}
</button>
<div>
<label className="block text-xs text-zinc-400 dark:text-zinc-500 mb-1.5">Price / Unit</label>
<div className="flex items-center gap-2">
<div className="flex items-center border border-zinc-200 dark:border-zinc-700 rounded overflow-hidden focus-within:border-blue-400">
<span className="px-2 py-2 text-sm text-zinc-400 dark:text-zinc-500 border-r border-zinc-100 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800">$</span>
{pricePerUnitOverride ? (
<input
type="number"
value={manualPricePerUnit || ''}
onChange={(e) => setManualPricePerUnit(parseFloat(e.target.value) || 0)}
className="w-20 px-2 py-2 text-sm font-mono text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-900 outline-none text-right"
/>
) : (
<span className="w-20 px-2 py-2 text-sm font-mono text-zinc-300 dark:text-zinc-600 text-right">{computedPricePerUnit.toFixed(2)}</span>
)}
</div>
<span className="text-sm text-zinc-500 dark:text-zinc-400">/ {item.uom}</span>
</div>
</div>
</div>
</div>
<div className="border-t border-zinc-200 dark:border-zinc-700/50" />
{/* Row 3: Total summary formula */}
<div className="flex items-end gap-5 flex-wrap">
{/* Total */}
<div>
<label className="block text-xs text-blue-500 dark:text-blue-400 font-medium mb-1">Total</label>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl font-bold text-zinc-800 dark:text-zinc-200">{finalTotalUnits}</span>
<span className="text-sm text-zinc-500 dark:text-zinc-400">{item.uom}</span>
</div>
</div>
<span className="text-zinc-300 dark:text-zinc-600 text-xl mb-1">×</span>
{/* Cost/Unit summary */}
<div>
<label className="block text-xs text-blue-500 dark:text-blue-400 font-medium mb-1">Cost/Unit</label>
<span className="text-base font-mono text-zinc-600 dark:text-zinc-400">${unitCost.toFixed(2)} / {item.uom}</span>
</div>
<span className="text-zinc-300 dark:text-zinc-600 text-xl mb-1">@</span>
{/* Section Margin */}
<div>
<label className="block text-xs text-zinc-400 dark:text-zinc-500 mb-1">Section Margin</label>
<div className="flex items-center gap-1">
<input
type="number"
value={marginPercent || ''}
onChange={(e) => setMarginPercent(parseFloat(e.target.value) || 0)}
className="w-14 border-b-2 border-zinc-300 dark:border-zinc-600 bg-transparent outline-none text-center font-mono text-base text-zinc-700 dark:text-zinc-300 focus:border-blue-500 pb-1"
/>
<span className="text-zinc-500 dark:text-zinc-400 text-sm">%</span>
</div>
</div>
<span className="text-zinc-300 dark:text-zinc-600 text-xl mb-1">=</span>
{/* Price result */}
<div className="bg-zinc-50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-lg px-6 py-3 text-center">
<label className="block text-xs text-zinc-400 dark:text-zinc-500 mb-1">Price</label>
<span className="text-2xl font-bold text-zinc-700 dark:text-zinc-200">${clientPrice.toFixed(2)}</span>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700/50 shrink-0 bg-white dark:bg-zinc-900/80">
<button
onClick={onClose}
className="px-5 py-2 text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors uppercase tracking-wide"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-10 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium text-sm transition-colors shadow-sm uppercase tracking-wide"
>
Done
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { ChevronDown, Info } from 'lucide-react';
const MOCK_MEASUREMENTS = {
'Measurement_Section_1.png': {
totalRoofArea: 87.87,
ridges: 189.09,
hips: 395.62,
valleys: 284.06,
rakes: 184.69,
eaves: 600.90,
stepFlashing: 112.00
},
'Measurement_Section_2.png': {
totalRoofArea: 33.91,
ridges: 73.62,
hips: 304.65,
valleys: 140.44,
rakes: 53.57,
eaves: 275.52,
stepFlashing: 39.48
},
'Measurement_Section_3.png': {
totalRoofArea: 81.79,
ridges: 161.85,
hips: 275.53,
valleys: 172.23,
rakes: 69.31,
eaves: 459.65,
stepFlashing: 6.49
}
};
export default function MeasurementsModal({ isOpen, onCancel, onNext, selectedImageId }) {
const [measurements, setMeasurements] = useState({
totalRoofArea: 0, ridges: 0, hips: 0, valleys: 0, rakes: 0, eaves: 0, stepFlashing: 0
});
useEffect(() => {
if (selectedImageId && MOCK_MEASUREMENTS[selectedImageId]) {
setMeasurements(MOCK_MEASUREMENTS[selectedImageId]);
}
}, [selectedImageId]);
const handleChange = (field, value) => {
setMeasurements(prev => ({ ...prev, [field]: parseFloat(value) || 0 }));
};
if (!isOpen) return null;
const fields = [
{ label: 'Ridges', key: 'ridges', uom: 'LF' },
{ label: 'Hips', key: 'hips', uom: 'LF' },
{ label: 'Valleys', key: 'valleys', uom: 'LF' },
{ label: 'Rakes', key: 'rakes', uom: 'LF' },
{ label: 'Eaves', key: 'eaves', uom: 'LF' },
{ label: 'Step Flashing', key: 'stepFlashing', uom: 'LF' },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div
className="bg-white dark:bg-zinc-900 w-full max-w-xl rounded-xl shadow-2xl border border-zinc-200 dark:border-zinc-700/50 overflow-hidden flex flex-col max-h-[92vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-zinc-200 dark:border-zinc-700/50 shrink-0 relative">
<h2 className="text-3xl font-light text-zinc-400 dark:text-zinc-500 tracking-tight" style={{ fontFamily: 'Georgia, serif' }}>Measurements</h2>
</div>
{/* Info */}
<div className="px-8 py-4 bg-zinc-50 dark:bg-zinc-800/30 border-b border-zinc-100 dark:border-zinc-700/50 shrink-0">
<p className="text-sm text-zinc-500 dark:text-zinc-400 flex items-start gap-2">
<Info className="w-4 h-4 text-zinc-400 dark:text-zinc-500 shrink-0 mt-0.5" />
The actual measurements below will be used to calculate your item quantities.
</p>
</div>
{/* Content */}
<div className="p-8 overflow-y-auto flex-1 space-y-6 bg-white dark:bg-zinc-900">
{/* Measurement Dropdown */}
<div className="border border-zinc-300 dark:border-zinc-600 rounded-lg px-4 py-2 relative focus-within:border-blue-400 focus-within:ring-1 focus-within:ring-blue-400 bg-white dark:bg-zinc-800/50">
<label className="block text-xs text-blue-500 font-semibold mb-0.5">Measurement:</label>
<div className="relative">
<select
className="w-full appearance-none bg-transparent outline-none text-zinc-800 dark:text-zinc-200 text-sm font-medium pr-6"
defaultValue="primary"
>
<option value="primary">Steep Slope Roofing Measurement</option>
</select>
<ChevronDown className="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500 pointer-events-none" />
</div>
</div>
{/* Measurements list */}
<div className="space-y-4">
<div className="flex justify-end pr-16">
<span className="text-xs text-zinc-400 dark:text-zinc-500 font-medium tracking-wide uppercase">Actual</span>
</div>
{/* Total Roof Area - prominent */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 w-36">Total Roof Area</span>
<div className="flex items-center gap-3">
<input
type="number"
value={measurements.totalRoofArea || ''}
onChange={(e) => handleChange('totalRoofArea', e.target.value)}
className="w-24 bg-white dark:bg-zinc-800 border-2 border-zinc-800 dark:border-zinc-300 rounded px-3 py-1.5 text-right font-mono text-sm text-zinc-900 dark:text-zinc-100 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<span className="text-sm text-blue-500 w-8 font-medium">SQ</span>
</div>
</div>
{fields.map(item => (
<div key={item.key} className="flex items-center justify-between">
<span className="text-sm text-blue-600 dark:text-blue-400 w-36">{item.label}</span>
<div className="flex items-center gap-3">
<input
type="number"
value={measurements[item.key] || ''}
onChange={(e) => handleChange(item.key, e.target.value)}
className="w-24 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded px-3 py-1.5 text-right font-mono text-sm text-zinc-700 dark:text-zinc-300 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 transition-all"
/>
<span className="text-sm text-blue-500 w-8 font-medium">{item.uom}</span>
</div>
</div>
))}
</div>
<button className="text-sm text-blue-500 hover:text-blue-600 hover:underline mt-2">
Show Measurements Not Included on Template
</button>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700/50 shrink-0 bg-white dark:bg-zinc-900/80">
<button
onClick={onCancel}
className="px-5 py-2 text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors uppercase tracking-wide"
>
Cancel
</button>
<button
onClick={() => onNext(measurements)}
className="px-8 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium text-sm transition-colors shadow-sm uppercase tracking-wide"
>
Next
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,669 @@
import React, { useState } from 'react';
const STUBBED_TEMPLATES = [
{
id: 'atlas_glassmaster',
name: 'Atlas GlassMaster',
category: 'Roofing',
brand: 'Atlas',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood. If bad or rotten wood is discovered, it will be replaced at a price of $50 per sheet.",
"Install 3' of Atlas WeatherMaster ice and water shield at all gutter lines, rakes edges, and valleys.",
"Install Atlas Summit 60 Synthetic underlayment.",
"Install Atlas Glassmaster AR Shingles."
],
materials: [
{ desc: 'Atlas Glassmaster AR', baseUnitCost: 37.67, uom: 'SQ' },
{ desc: 'Atlas Pro-Cut Hip & Ridge', baseUnitCost: 78.50, uom: 'BD' },
{ desc: 'Atlas 6X Pro-Cut Starter', baseUnitCost: 70.80, uom: 'BD' },
{ desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' },
{ desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'atlas_glassmaster_srs',
name: 'Atlas GlassMaster - SRS',
category: 'Roofing',
brand: 'SRS',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install 3' of Atlas WeatherMaster ice and water shield at all gutter lines, rakes edges, and valleys.",
"Install Atlas Summit 60 Synthetic underlayment.",
"Install Atlas Glassmaster AR Shingles (SRS distribution)."
],
materials: [
{ desc: 'Atlas Glassmaster AR', baseUnitCost: 37.67, uom: 'SQ' },
{ desc: 'Atlas Pro-Cut Hip & Ridge', baseUnitCost: 78.50, uom: 'BD' },
{ desc: 'Atlas 6X Pro-Cut Starter', baseUnitCost: 70.80, uom: 'BD' },
{ desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' },
{ desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'atlas_pinnacle_pristine',
name: 'Atlas Pinnacle Pristine',
category: 'Roofing',
brand: 'Atlas',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install 3' of Atlas WeatherMaster ice and water shield.",
"Install Atlas Summit 60 Synthetic underlayment.",
"Install Atlas Pinnacle Pristine Lifetime Dimensional Shingles."
],
materials: [
{ desc: 'Atlas Pinnacle Pristine', baseUnitCost: 42.50, uom: 'SQ' },
{ desc: 'Atlas Pro-Cut High Profile Hip & Ridge', baseUnitCost: 85.00, uom: 'BD' },
{ desc: 'Atlas Pro-Cut Starter', baseUnitCost: 72.00, uom: 'BD' },
{ desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' },
{ desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'atlas_pinnacle_pristine_srs',
name: 'Atlas Pinnacle Pristine - SRS',
category: 'Roofing',
brand: 'SRS',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install 3' of Atlas WeatherMaster ice and water shield.",
"Install Atlas Summit 60 Synthetic underlayment.",
"Install Atlas Pinnacle Pristine Lifetime Dimensional Shingles (SRS distribution)."
],
materials: [
{ desc: 'Atlas Pinnacle Pristine', baseUnitCost: 42.50, uom: 'SQ' },
{ desc: 'Atlas Pro-Cut High Profile Hip & Ridge', baseUnitCost: 85.00, uom: 'BD' },
{ desc: 'Atlas Pro-Cut Starter', baseUnitCost: 72.00, uom: 'BD' },
{ desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' },
{ desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'atlas_stormmaster_shake',
name: 'Atlas StormMaster Shake',
category: 'Roofing',
brand: 'Atlas',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install 3' of Atlas WeatherMaster ice and water shield at all eaves and valleys.",
"Install Atlas Summit 60 Synthetic underlayment.",
"Install Atlas StormMaster Shake Impact Resistant Shingles."
],
materials: [
{ desc: 'Atlas StormMaster Shake', baseUnitCost: 55.00, uom: 'SQ' },
{ desc: 'Atlas Pro-Cut Hip & Ridge', baseUnitCost: 78.50, uom: 'BD' },
{ desc: 'Atlas 6X Pro-Cut Starter', baseUnitCost: 70.80, uom: 'BD' },
{ desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' },
{ desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'certainteed_landmark',
name: 'CertainTeed Landmark AR',
category: 'Roofing',
brand: 'CertainTeed',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install CertainTeed WinterGuard ice and water shield.",
"Install CertainTeed RoofRunner Synthetic underlayment.",
"Install CertainTeed Landmark AR Shingles."
],
materials: [
{ desc: 'CertainTeed Landmark AR', baseUnitCost: 44.00, uom: 'SQ' },
{ desc: 'CertainTeed ShadowRidge', baseUnitCost: 82.00, uom: 'BD' },
{ desc: 'CertainTeed SwiftStart', baseUnitCost: 75.00, uom: 'BD' },
{ desc: 'CertainTeed RoofRunner', baseUnitCost: 125.00, uom: 'RL' },
{ desc: 'CertainTeed WinterGuard', baseUnitCost: 70.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'certainteed_landmark_srs',
name: 'CertainTeed Landmark AR - SRS',
category: 'Roofing',
brand: 'SRS',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install CertainTeed WinterGuard ice and water shield.",
"Install CertainTeed RoofRunner Synthetic underlayment.",
"Install CertainTeed Landmark AR Shingles (SRS distribution)."
],
materials: [
{ desc: 'CertainTeed Landmark AR', baseUnitCost: 44.00, uom: 'SQ' },
{ desc: 'CertainTeed ShadowRidge', baseUnitCost: 82.00, uom: 'BD' },
{ desc: 'CertainTeed SwiftStart', baseUnitCost: 75.00, uom: 'BD' },
{ desc: 'CertainTeed RoofRunner', baseUnitCost: 125.00, uom: 'RL' },
{ desc: 'CertainTeed WinterGuard', baseUnitCost: 70.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'certainteed_presidential_shake',
name: 'CertainTeed Presidential Shake TL',
category: 'Roofing',
brand: 'CertainTeed',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install CertainTeed WinterGuard ice and water shield at all eaves and valleys.",
"Install CertainTeed RoofRunner Synthetic underlayment.",
"Install CertainTeed Presidential Shake TL triple-layer shingles."
],
materials: [
{ desc: 'CertainTeed Presidential Shake TL', baseUnitCost: 72.00, uom: 'SQ' },
{ desc: 'CertainTeed Presidential Ridge', baseUnitCost: 92.00, uom: 'BD' },
{ desc: 'CertainTeed SwiftStart', baseUnitCost: 75.00, uom: 'BD' },
{ desc: 'CertainTeed RoofRunner', baseUnitCost: 125.00, uom: 'RL' },
{ desc: 'CertainTeed WinterGuard', baseUnitCost: 70.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'gaf_royal_sovereign',
name: 'GAF Royal Sovereign',
category: 'Roofing',
brand: 'GAF',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install GAF WeatherWatch ice and water shield at eaves and valleys.",
"Install GAF FeltBuster Synthetic underlayment.",
"Install GAF Royal Sovereign 3-tab shingles."
],
materials: [
{ desc: 'GAF Royal Sovereign', baseUnitCost: 32.00, uom: 'SQ' },
{ desc: 'GAF Seal-A-Ridge', baseUnitCost: 72.00, uom: 'BD' },
{ desc: 'GAF Pro-Start Starter Strip', baseUnitCost: 68.00, uom: 'BD' },
{ desc: 'GAF FeltBuster Synthetic Underlayment', baseUnitCost: 110.00, uom: 'RL' },
{ desc: 'GAF WeatherWatch Ice & Water', baseUnitCost: 68.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'gaf_timberline_hdz',
name: 'GAF Timberline HDZ',
category: 'Roofing',
brand: 'GAF',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install GAF WeatherWatch ice and water shield at eaves, rakes, and valleys.",
"Install GAF FeltBuster Synthetic underlayment.",
"Install GAF Timberline HDZ Laminated Architectural Shingles."
],
materials: [
{ desc: 'GAF Timberline HDZ', baseUnitCost: 45.00, uom: 'SQ' },
{ desc: 'GAF TimberTex Premium Ridge Cap', baseUnitCost: 85.00, uom: 'BD' },
{ desc: 'GAF Pro-Start Starter Strip', baseUnitCost: 68.00, uom: 'BD' },
{ desc: 'GAF FeltBuster Synthetic Underlayment', baseUnitCost: 110.00, uom: 'RL' },
{ desc: 'GAF WeatherWatch Ice & Water', baseUnitCost: 68.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'gaf_timberline_ultra_hd',
name: 'GAF Timberline Ultra HD',
category: 'Roofing',
brand: 'GAF',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install GAF WeatherWatch ice and water shield at eaves, rakes, and valleys.",
"Install GAF FeltBuster Synthetic underlayment.",
"Install GAF Timberline Ultra HD extra-thick dimensional shingles."
],
materials: [
{ desc: 'GAF Timberline Ultra HD', baseUnitCost: 52.00, uom: 'SQ' },
{ desc: 'GAF TimberTex Premium Ridge Cap', baseUnitCost: 85.00, uom: 'BD' },
{ desc: 'GAF Pro-Start Starter Strip', baseUnitCost: 68.00, uom: 'BD' },
{ desc: 'GAF FeltBuster Synthetic Underlayment', baseUnitCost: 110.00, uom: 'RL' },
{ desc: 'GAF WeatherWatch Ice & Water', baseUnitCost: 68.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'gaf_timberline_hdz_srs',
name: 'GAF Timberline HDZ - SRS',
category: 'Roofing',
brand: 'SRS',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install GAF WeatherWatch ice and water shield at eaves, rakes, and valleys.",
"Install GAF FeltBuster Synthetic underlayment.",
"Install GAF Timberline HDZ Laminated Architectural Shingles (SRS distribution)."
],
materials: [
{ desc: 'GAF Timberline HDZ', baseUnitCost: 45.00, uom: 'SQ' },
{ desc: 'GAF TimberTex Premium Ridge Cap', baseUnitCost: 85.00, uom: 'BD' },
{ desc: 'GAF Pro-Start Starter Strip', baseUnitCost: 68.00, uom: 'BD' },
{ desc: 'GAF FeltBuster Synthetic Underlayment', baseUnitCost: 110.00, uom: 'RL' },
{ desc: 'GAF WeatherWatch Ice & Water', baseUnitCost: 68.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'iko_armourshake',
name: 'IKO Armourshake',
category: 'Roofing',
brand: 'IKO',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install IKO ArmourGard ice and water shield at eaves and valleys.",
"Install IKO RoofGard-SB Synthetic underlayment.",
"Install IKO Armourshake heavyweight laminated shingles."
],
materials: [
{ desc: 'IKO Armourshake', baseUnitCost: 58.00, uom: 'SQ' },
{ desc: 'IKO Hip & Ridge 12', baseUnitCost: 80.00, uom: 'BD' },
{ desc: 'IKO Leading Edge Plus Starter Strip', baseUnitCost: 71.00, uom: 'BD' },
{ desc: 'IKO RoofGard-SB Synthetic Underlayment', baseUnitCost: 118.00, uom: 'RL' },
{ desc: 'IKO ArmourGard Ice & Water', baseUnitCost: 67.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'iko_armourshake_srs',
name: 'IKO Armourshake - SRS',
category: 'Roofing',
brand: 'SRS',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install IKO ArmourGard ice and water shield at eaves and valleys.",
"Install IKO RoofGard-SB Synthetic underlayment.",
"Install IKO Armourshake heavyweight laminated shingles (SRS distribution)."
],
materials: [
{ desc: 'IKO Armourshake', baseUnitCost: 58.00, uom: 'SQ' },
{ desc: 'IKO Hip & Ridge 12', baseUnitCost: 80.00, uom: 'BD' },
{ desc: 'IKO Leading Edge Plus Starter Strip', baseUnitCost: 71.00, uom: 'BD' },
{ desc: 'IKO RoofGard-SB Synthetic Underlayment', baseUnitCost: 118.00, uom: 'RL' },
{ desc: 'IKO ArmourGard Ice & Water', baseUnitCost: 67.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'iko_cambridge',
name: 'IKO Cambridge',
category: 'Roofing',
brand: 'IKO',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install IKO ArmourGard ice and water shield.",
"Install IKO RoofGard-SB Synthetic underlayment.",
"Install IKO Cambridge Architectural Shingles."
],
materials: [
{ desc: 'IKO Cambridge', baseUnitCost: 42.00, uom: 'SQ' },
{ desc: 'IKO Hip & Ridge 12', baseUnitCost: 80.00, uom: 'BD' },
{ desc: 'IKO Leading Edge Plus Starter Strip', baseUnitCost: 71.00, uom: 'BD' },
{ desc: 'IKO RoofGard-SB Synthetic Underlayment', baseUnitCost: 118.00, uom: 'RL' },
{ desc: 'IKO ArmourGard Ice & Water', baseUnitCost: 67.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'iko_dynasty',
name: 'IKO Dynasty',
category: 'Roofing',
brand: 'IKO',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install IKO ArmourGard ice and water shield at eaves, rakes, and valleys.",
"Install IKO RoofGard-SB Synthetic underlayment.",
"Install IKO Dynasty Impact Resistant shingles."
],
materials: [
{ desc: 'IKO Dynasty', baseUnitCost: 54.00, uom: 'SQ' },
{ desc: 'IKO Hip & Ridge 12', baseUnitCost: 80.00, uom: 'BD' },
{ desc: 'IKO Leading Edge Plus Starter Strip', baseUnitCost: 71.00, uom: 'BD' },
{ desc: 'IKO RoofGard-SB Synthetic Underlayment', baseUnitCost: 118.00, uom: 'RL' },
{ desc: 'IKO ArmourGard Ice & Water', baseUnitCost: 67.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'iko_dynasty_srs',
name: 'IKO Dynasty - SRS',
category: 'Roofing',
brand: 'SRS',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install IKO ArmourGard ice and water shield at eaves, rakes, and valleys.",
"Install IKO RoofGard-SB Synthetic underlayment.",
"Install IKO Dynasty Impact Resistant shingles (SRS distribution)."
],
materials: [
{ desc: 'IKO Dynasty', baseUnitCost: 54.00, uom: 'SQ' },
{ desc: 'IKO Hip & Ridge 12', baseUnitCost: 80.00, uom: 'BD' },
{ desc: 'IKO Leading Edge Plus Starter Strip', baseUnitCost: 71.00, uom: 'BD' },
{ desc: 'IKO RoofGard-SB Synthetic Underlayment', baseUnitCost: 118.00, uom: 'RL' },
{ desc: 'IKO ArmourGard Ice & Water', baseUnitCost: 67.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'iko_nordic_per_square',
name: 'IKO Nordic Per Square',
category: 'Roofing',
brand: 'IKO',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install IKO ArmourGard ice and water shield.",
"Install IKO RoofGard-SB Synthetic underlayment.",
"Install IKO Nordic Architectural Shingles (priced per square)."
],
materials: [
{ desc: 'IKO Nordic', baseUnitCost: 40.00, uom: 'SQ' },
{ desc: 'IKO Hip & Ridge 12', baseUnitCost: 80.00, uom: 'BD' },
{ desc: 'IKO Leading Edge Plus Starter Strip', baseUnitCost: 71.00, uom: 'BD' },
{ desc: 'IKO RoofGard-SB Synthetic Underlayment', baseUnitCost: 118.00, uom: 'RL' },
{ desc: 'IKO ArmourGard Ice & Water', baseUnitCost: 67.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'malarkey_vista_ar',
name: 'Malarkey Vista AR',
category: 'Roofing',
brand: 'Malarkey',
description: [
"Remove existing shingles down to deck.",
"Re-nail any loose wood.",
"Install Malarkey EverGuard ice and water shield at eaves and valleys.",
"Install Malarkey UDL 40 Synthetic underlayment.",
"Install Malarkey Vista AR Architectural Shingles."
],
materials: [
{ desc: 'Malarkey Vista AR', baseUnitCost: 46.00, uom: 'SQ' },
{ desc: 'Malarkey Ridglass Hip & Ridge', baseUnitCost: 83.00, uom: 'BD' },
{ desc: 'Malarkey Windsor Starter Strip', baseUnitCost: 73.00, uom: 'BD' },
{ desc: 'Malarkey UDL 40 Synthetic Underlayment', baseUnitCost: 122.00, uom: 'RL' },
{ desc: 'Malarkey EverGuard Ice & Water', baseUnitCost: 69.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'mulehide_epdm',
name: 'Mulehide .060 EPDM System',
category: 'Roofing',
brand: 'Mulehide',
description: [
"Remove existing flat roof membrane down to deck.",
"Inspect and repair decking as needed.",
"Install Mulehide .060 EPDM membrane fully adhered system.",
"Install Mulehide bonding adhesive.",
"Install drip edge and termination bars at perimeter."
],
materials: [
{ desc: 'Mulehide .060 EPDM Membrane', baseUnitCost: 85.00, uom: 'SQ' },
{ desc: 'Mulehide Bonding Adhesive', baseUnitCost: 48.00, uom: 'EA' },
{ desc: 'Mulehide EPDM Lap Sealant', baseUnitCost: 12.00, uom: 'EA' },
{ desc: 'Mulehide Termination Bar', baseUnitCost: 4.50, uom: 'LF' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'owens_corning_duration',
name: 'Owens Corning Duration',
category: 'Roofing',
brand: 'Owens Corning',
description: [
"Remove existing shingles down to deck.",
"Inspect and re-nail decking.",
"Install Owens Corning WeatherLock G ice and water shield.",
"Install Owens Corning ProArmor Synthetic underlayment.",
"Install Owens Corning TruDefinition Duration Shingles."
],
materials: [
{ desc: 'OC TruDefinition Duration', baseUnitCost: 46.50, uom: 'SQ' },
{ desc: 'OC ProEdge Hip & Ridge', baseUnitCost: 88.00, uom: 'BD' },
{ desc: 'OC Starter Strip Plus', baseUnitCost: 78.00, uom: 'BD' },
{ desc: 'OC ProArmor Underlayment', baseUnitCost: 130.00, uom: 'RL' },
{ desc: 'OC WeatherLock G', baseUnitCost: 75.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'owens_corning_duration_srs',
name: 'Owens Corning Duration - SRS',
category: 'Roofing',
brand: 'SRS',
description: [
"Remove existing shingles down to deck.",
"Inspect and re-nail decking.",
"Install Owens Corning WeatherLock G ice and water shield.",
"Install Owens Corning ProArmor Synthetic underlayment.",
"Install Owens Corning TruDefinition Duration Shingles (SRS distribution)."
],
materials: [
{ desc: 'OC TruDefinition Duration', baseUnitCost: 46.50, uom: 'SQ' },
{ desc: 'OC ProEdge Hip & Ridge', baseUnitCost: 88.00, uom: 'BD' },
{ desc: 'OC Starter Strip Plus', baseUnitCost: 78.00, uom: 'BD' },
{ desc: 'OC ProArmor Underlayment', baseUnitCost: 130.00, uom: 'RL' },
{ desc: 'OC WeatherLock G', baseUnitCost: 75.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
},
{
id: 'owens_corning_oakridge',
name: 'Owens Corning Oakridge',
category: 'Roofing',
brand: 'Owens Corning',
description: [
"Remove existing shingles down to deck.",
"Inspect and re-nail decking.",
"Install Owens Corning WeatherLock G ice and water shield.",
"Install Owens Corning ProArmor Synthetic underlayment.",
"Install Owens Corning Oakridge Architectural Shingles."
],
materials: [
{ desc: 'OC Oakridge', baseUnitCost: 38.00, uom: 'SQ' },
{ desc: 'OC ProEdge Hip & Ridge', baseUnitCost: 88.00, uom: 'BD' },
{ desc: 'OC Starter Strip Plus', baseUnitCost: 78.00, uom: 'BD' },
{ desc: 'OC ProArmor Underlayment', baseUnitCost: 130.00, uom: 'RL' },
{ desc: 'OC WeatherLock G', baseUnitCost: 75.00, uom: 'RL' },
{ desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' },
{ desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' },
{ desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' }
]
}
];
export default function TemplateSelectionModal({ isOpen, onClose, onSelectTemplate }) {
const [searchTerm, setSearchTerm] = useState('');
const [selected, setSelected] = useState(null);
const filtered = STUBBED_TEMPLATES.filter(t =>
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.category.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleNext = () => {
if (selected) {
onSelectTemplate(selected);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div
className="bg-white dark:bg-zinc-900 w-full max-w-3xl rounded-2xl shadow-xl border border-zinc-200 dark:border-white/10 overflow-hidden flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-zinc-200 dark:border-white/10 shrink-0">
<div>
<h2 className="text-2xl font-display text-zinc-900 dark:text-white font-light tracking-tight">Select a Template</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">Choose a base template to calculate your item quantities.</p>
</div>
</div>
{/* Search Bar */}
<div className="px-6 py-4 border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-zinc-900/50 shrink-0">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search for a Template..."
className="w-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-blue-500 transition-shadow text-zinc-800 dark:text-zinc-200"
/>
</div>
{/* Templates List */}
<div className="p-6 overflow-y-auto custom-scrollbar flex-1 bg-zinc-50/50 dark:bg-zinc-900/20">
<div className="bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/10 rounded-xl overflow-hidden">
{/* Category Header */}
<div className="px-4 py-3 bg-blue-50/50 dark:bg-blue-900/10 border-b border-zinc-200 dark:border-white/10">
<h3 className="text-sm font-semibold text-blue-600 dark:text-blue-400">
Roofing ({filtered.length})
</h3>
</div>
{/* List items */}
<div className="divide-y divide-zinc-200 dark:divide-white/10 text-left">
{filtered.map((template) => {
const isSelected = selected?.id === template.id;
return (
<button
key={template.id}
onClick={() => setSelected(template)}
className={`w-full flex flex-col md:flex-row items-start md:items-center p-4 transition-colors text-left focus:outline-none ${isSelected
? 'bg-blue-50 dark:bg-blue-500/10 border-l-4 border-l-blue-500'
: 'hover:bg-blue-50/30 dark:hover:bg-blue-500/5 focus:bg-blue-50/50 dark:focus:bg-blue-500/10'
}`}
>
{/* Content */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
{/* Radio circle visual */}
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${isSelected ? 'border-orange-500' : 'border-orange-300 hover:border-blue-400'}`}>
{isSelected && (
<div className="w-2.5 h-2.5 rounded-full bg-orange-500"></div>
)}
</div>
<h4 className={`text-base font-medium transition-colors ${isSelected ? 'text-blue-700 dark:text-blue-300' : 'text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300'}`}>
{template.name}
</h4>
</div>
<ul className="pl-8 space-y-0.5">
{template.description.slice(0, 3).map((descLine, i) => (
<li key={i} className="text-sm text-zinc-500 dark:text-zinc-400 truncate max-w-xl flex items-start gap-2">
<span className="text-zinc-300 dark:text-zinc-600 mt-1 shrink-0">-</span>
<span className="truncate">{descLine}</span>
</li>
))}
{template.description.length > 3 && (
<li className="text-sm text-zinc-400 dark:text-zinc-500 pl-4">...</li>
)}
</ul>
</div>
{/* Action Column */}
<div className="flex flex-col items-center justify-center ml-auto mt-4 md:mt-0 pl-8 md:pl-4 shrink-0 gap-2">
<span className="text-sm font-medium text-blue-500 hover:text-blue-600 hover:underline cursor-pointer">Preview</span>
<div className="text-xs font-bold text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-700 px-2 py-0.5 rounded">
{template.brand}
</div>
</div>
</button>
);
})}
{filtered.length === 0 && (
<div className="px-4 py-8 text-center text-zinc-400 text-sm">
No templates match your search.
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center gap-4 p-4 border-t border-zinc-200 dark:border-white/10 shrink-0 bg-white dark:bg-zinc-900 justify-start">
<button
onClick={onClose}
className="px-6 py-2.5 text-red-500 font-medium hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors text-sm"
>
CANCEL
</button>
<button
onClick={handleNext}
disabled={!selected}
className={`ml-auto px-8 py-2.5 rounded-lg font-medium text-sm transition-colors ${selected
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-sm cursor-pointer'
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-300 dark:text-blue-500 cursor-not-allowed opacity-60'
}`}
>
NEXT
</button>
</div>
</div>
</div>
);
}
+619 -177
View File
@@ -1,17 +1,30 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Plus, Trash2, Calculator, Settings, AlertCircle, Save, ArrowLeft, 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'; } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; 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 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() { export default function EstimateBuilder() {
const navigate = useNavigate(); 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 --- // --- Zone A: Client Info ---
const [clientInfo, setClientInfo] = useState({ const [clientInfo, setClientInfo] = useState({
name: '', name: '',
@@ -60,6 +73,8 @@ export default function EstimateBuilder() {
{ {
id: generateId(), id: generateId(),
name: 'Roofing Section', name: 'Roofing Section',
description: [],
marginPercent: 30,
groups: [ groups: [
{ {
id: generateId(), 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 --- // --- Zone D: Financials ---
const [financials, setFinancials] = useState({ const [financials, setFinancials] = useState({
taxRate: 0, taxRate: 0,
@@ -97,11 +118,131 @@ export default function EstimateBuilder() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFullViewMode]); }, [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 // Handlers for Zone C
const addSection = () => { const addSection = () => {
setSections([...sections, { setSections([...sections, {
id: generateId(), id: generateId(),
name: `New Section ${sections.length + 1}`, name: `New Section ${sections.length + 1}`,
description: [],
marginPercent: 30,
groups: [] groups: []
}]); }]);
}; };
@@ -185,6 +326,27 @@ export default function EstimateBuilder() {
setSections(sections.map(s => s.id === sectionId ? { ...s, name } : s)); 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) => { const updateGroupName = (sectionId, groupId, name) => {
setSections(sections.map(s => { setSections(sections.map(s => {
if (s.id === sectionId) { if (s.id === sectionId) {
@@ -213,9 +375,6 @@ export default function EstimateBuilder() {
const ohpAmount = subtotal * (financials.ohp / 100); const ohpAmount = subtotal * (financials.ohp / 100);
const grandTotal = subtotal + tax + ohpAmount; 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 totalRevenueNoTax = subtotal + ohpAmount;
const netProfit = totalRevenueNoTax - internalCost; const netProfit = totalRevenueNoTax - internalCost;
const grossMarginPercent = totalRevenueNoTax > 0 ? (netProfit / totalRevenueNoTax) * 100 : 0; const grossMarginPercent = totalRevenueNoTax > 0 ? (netProfit / totalRevenueNoTax) * 100 : 0;
@@ -236,6 +395,46 @@ export default function EstimateBuilder() {
return { cost, rev }; 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 ( 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"> <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> <p className="text-zinc-500 dark:text-zinc-400 text-sm mt-1">Create and configure project estimates for clients.</p>
</div> </div>
<div className="flex items-center gap-4"> <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"> <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 <Settings className="w-4 h-4 mr-2" /> Options
</button> </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 ? <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` `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 p-8 shadow-sm relative overflow-hidden` : `bg-white dark:bg-zinc-900/50 rounded-2xl shadow-sm relative overflow-hidden`
}`}> }`}>
{/* Blueprint background for construction feel */} {/* Blueprint background for construction feel */}
<div className="absolute inset-0 blueprint-fine opacity-30 dark:opacity-[0.05] pointer-events-none" /> <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="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"> <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" /> <Calculator className="w-5 h-5" />
@@ -398,161 +600,368 @@ export default function EstimateBuilder() {
</div> </div>
</div> </div>
<div className={`space-y-10 relative z-10 ${isFullViewMode ? 'overflow-y-auto custom-scrollbar flex-1 pr-2' : ''}`}> <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 => ( {sections.map((section, sectionIndex) => {
<div key={section.id} className="border-l-2 border-zinc-200 dark:border-zinc-700 pl-4 py-2"> const sectionTotals = calcSectionTotals(section);
<div className="flex items-center gap-3 mb-4 group"> const currentMargin = section.marginPercent !== undefined ? section.marginPercent : sectionTotals.margin;
<input const isDescCollapsed = collapsedDescriptions[section.id] !== false; // default collapsed
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-4"> return (
{section.groups.map(group => { <div key={section.id} className="mb-10">
const groupTotals = calcGroupTotals(group); {/* Section Header */}
return ( <div className="flex items-center gap-3 mb-3 group py-2 border-b border-zinc-200 dark:border-zinc-700">
<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"> <button
{/* Group Header */} onClick={() => toggleDescription(section.id)}
<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"> className="p-0.5 text-zinc-400 hover:text-zinc-600 transition-colors shrink-0"
<div className="flex items-center gap-2"> >
<input {isDescCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
value={group.name} </button>
onChange={(e) => updateGroupName(section.id, group.id, e.target.value)} <input
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" 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> </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 */} {/* Items Table */}
<div className="w-full overflow-x-auto custom-scrollbar"> <div className="w-full overflow-x-auto custom-scrollbar">
<table className="w-full text-base text-left whitespace-nowrap"> <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"> <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> <tr>
<th className="px-6 py-4 w-12 text-center">Sr No.</th> <th className="px-3 py-3 w-8 text-center"></th>
<th className="px-6 py-4 min-w-[300px]">Description</th> <th className="px-4 py-3 min-w-[260px]">Description</th>
<th className="px-6 py-4 w-28">Qty</th> <th className="px-4 py-3 w-44">Unit</th>
<th className="px-6 py-4 w-28">UOM</th> <th className="px-4 py-3 w-32">Cost/Unit</th>
<th className="px-6 py-4 w-36">Unit Cost ($)</th> <th className="px-4 py-3 w-32 text-zinc-400">Cost</th>
<th className="px-6 py-4 w-36 text-zinc-400">Total Cost</th> <th className="px-4 py-3 w-40 font-bold text-zinc-700 dark:text-zinc-200">Price</th>
<th className="px-6 py-4 w-44 font-bold text-zinc-900 dark:text-white">Client Price ($)</th> <th className="px-4 py-3 w-10 text-center">···</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>
</tr> </tr>
))} </thead>
<tbody className="divide-y divide-zinc-200/50 dark:divide-white/5">
{/* Add Item Row Button */} {group.items.map((item, index) => (
<tr> <React.Fragment key={item.id}>
<td colSpan="8" className="px-6 py-4"> <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' : ''}`}>
<button {/* Drag handle */}
onClick={() => addItem(section.id, group.id)} <td className="px-3 py-3 text-center">
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" <GripVertical className="w-4 h-4 text-zinc-300 dark:text-zinc-600 cursor-grab mx-auto" />
> </td>
<Plus className="w-5 h-5 mr-2" /> Add Line Item {/* Description + inline trash */}
</button> <td className="px-4 py-3">
</td> <div className="flex items-center gap-1">
</tr> <input
</tbody> id={`desc-${item.id}`}
</table> name={`desc-${item.id}`}
</div> value={item.desc}
onChange={(e) => updateItem(section.id, group.id, item.id, 'desc', e.target.value)}
{/* Group Subtotal Footer */} 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"
<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"> placeholder="Item description..."
<div className="flex items-center gap-2"> />
<span className="text-zinc-500">Group Cost:</span> <button
<span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">${groupTotals.cost.toFixed(2)}</span> 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>
<div className="flex items-center gap-2">
<span className="text-zinc-500">Group Revenue:</span> {/* Add Item to Group button */}
<span className="font-mono font-bold text-emerald-600 dark:text-emerald-400">${groupTotals.rev.toFixed(2)}</span> <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> </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>
</div>
);
})}
<button {/* Add New Section Button */}
onClick={() => addGroup(section.id)} <div className="pt-2 pb-4">
className="flex items-center text-sm font-medium text-zinc-500 hover:text-zinc-800 dark:hover:text-white transition-colors" <button
> onClick={addSection}
<Plus className="w-4 h-4 mr-1" /> Add Group 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"
</button> >
<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>
</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> </div>
{/* Bottom Action Bar (inside section, sticky when full view) */}
<BottomActionBar />
</section> </section>
</div> </div>
@@ -562,24 +971,24 @@ export default function EstimateBuilder() {
{/* --- Zone D: Financial Summary & Profit Analysis --- */} {/* --- Zone D: Financial Summary & Profit Analysis --- */}
<div className="sticky top-6"> <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"> <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"> <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" /> <DollarSign className="w-5 h-5 text-amber-500" />
Financial Summary Financial Summary
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center text-sm"> <div className="flex justify-between items-center text-sm">
<span className="text-zinc-400">Subtotal (Client Price)</span> <span className="text-zinc-500 dark:text-zinc-400">Subtotal (Client Price)</span>
<span className="font-mono font-medium">${totals.subtotal.toFixed(2)}</span> <span className="font-mono font-medium text-zinc-800 dark:text-white">${totals.subtotal.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between items-center text-sm group"> <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 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> </span>
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<div className="relative w-20"> <div className="relative w-20">
@@ -587,17 +996,17 @@ export default function EstimateBuilder() {
type="number" type="number"
value={financials.taxRate || ''} value={financials.taxRate || ''}
onChange={(e) => setFinancials({ ...financials, taxRate: parseFloat(e.target.value) || 0 })} 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> </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> </div>
<div className="flex justify-between items-center text-sm group"> <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 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> </span>
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<div className="relative w-20"> <div className="relative w-20">
@@ -605,20 +1014,20 @@ export default function EstimateBuilder() {
type="number" type="number"
value={financials.ohp || ''} value={financials.ohp || ''}
onChange={(e) => setFinancials({ ...financials, ohp: parseFloat(e.target.value) || 0 })} 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> </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>
</div> </div>
</div> </div>
{/* Grand Total */} {/* 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"> <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-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-white tracking-tight"> <span className="text-3xl font-display font-bold text-zinc-900 dark:text-white tracking-tight">
${totals.grandTotal.toFixed(2)} ${totals.grandTotal.toFixed(2)}
</span> </span>
</div> </div>
@@ -626,43 +1035,42 @@ export default function EstimateBuilder() {
{/* Profit Analysis Gauge */} {/* Profit Analysis Gauge */}
<div className="p-6"> <div className="p-6">
<h4 className="text-sm font-medium text-zinc-300 mb-4 flex items-center gap-2"> <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-400" /> <Calculator className="w-4 h-4 text-emerald-500 dark:text-emerald-400" />
Profit Analysis Profit Analysis
</h4> </h4>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center text-sm"> <div className="flex justify-between items-center text-sm">
<span className="text-zinc-400">Net Profit</span> <span className="text-zinc-500 dark:text-zinc-400">Net Profit</span>
<span className="font-mono font-medium text-emerald-400">+${totals.netProfit.toFixed(2)}</span> <span className="font-mono font-medium text-emerald-600 dark:text-emerald-400">+${totals.netProfit.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between items-center text-sm mb-1"> <div className="flex justify-between items-center text-sm mb-1">
<span className="text-zinc-400">Gross Margin</span> <span className="text-zinc-500 dark: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="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)}% {totals.grossMarginPercent.toFixed(1)}%
</span> </span>
</div> </div>
{/* Gauge Bar */} {/* 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 <div
className={`h-full transition-all duration-500 ease-out ${totals.grossMarginPercent > 30 ? 'bg-emerald-500' : className={`h-full transition-all duration-500 ease-out ${totals.grossMarginPercent > 30 ? 'bg-emerald-500' :
totals.grossMarginPercent > 15 ? 'bg-amber-400' : 'bg-red-500' totals.grossMarginPercent > 15 ? 'bg-amber-400' : 'bg-red-500'
}`} }`}
style={{ width: `${Math.min(Math.max(totals.grossMarginPercent, 0), 100)}%` }} style={{ width: `${Math.min(Math.max(totals.grossMarginPercent, 0), 100)}%` }}
/> />
{/* Tick Marks for aesthetics */}
<div className="absolute inset-0 flex justify-between px-1"> <div className="absolute inset-0 flex justify-between px-1">
{[...Array(9)].map((_, i) => ( {[...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>
</div> </div>
{/* Warning message if margin is low */} {/* Warning message if margin is low */}
{totals.grossMarginPercent < 20 && totals.grandTotal > 0 && ( {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" /> <AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<p>Margin is under 20%. Please review costs or markup strategy.</p> <p>Margin is under 20%. Please review costs or markup strategy.</p>
</div> </div>
@@ -676,6 +1084,40 @@ export default function EstimateBuilder() {
</div> </div>
</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> </div>
); );
} }