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

- Restructure section/group/item hierarchy to match reference designs:
  collapse chevron, cost + green price box, ellipsis menu per section;
  trash on hover + cost/price per group; inline trash, orange qty=0
  border, read-only cost/unit, SRS warning rows per item
- Add compact horizontal margin slider with orange accent
- Add section total banner with white/30 bordered price box
- Rewrite MaterialDetailsModal to match reference (3-row formula layout:
  Measurement @ Waste = Qty, Cost/Unit with conversion note, Total formula)
- Fix MaterialDetailsModal always rendering in light mode: add dark: variants
- Fix MeasurementsModal always rendering in light mode: add dark: variants
- Fix Financial Summary sidebar always rendering in dark mode: base bg-white
  with dark: gradient, all inner elements get proper dark: variants
- Add new estimate component files: MaterialDetailsModal, MeasurementsModal,
  ImageUploadModal, InitialChoiceModal, TemplateSelectionModal
This commit is contained in:
Satyam
2026-03-13 00:29:13 +05:30
parent d5c7c17cd8
commit a0af994614
6 changed files with 1847 additions and 177 deletions
@@ -0,0 +1,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>
);
}