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