a0af994614
- 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
272 lines
16 KiB
React
272 lines
16 KiB
React
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>
|
||
);
|
||
}
|