Compare commits
5 Commits
f2dcff7b71
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ac0feddf | |||
| 59c6211bb9 | |||
| cc0a1ad15d | |||
| 72135a11d2 | |||
| c2f384a105 |
@@ -5,6 +5,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet" />
|
||||||
<title>item-extractor-solid</title>
|
<title>item-extractor-solid</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface Entry {
|
|||||||
type: 'number' | 'field' | 'marker' | 'result';
|
type: 'number' | 'field' | 'marker' | 'result';
|
||||||
value?: number;
|
value?: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
markerName?: string;
|
||||||
|
markerTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Portal } from 'solid-js/web';
|
import { Portal } from 'solid-js/web';
|
||||||
@@ -17,6 +19,9 @@ const CalculatorPopover: Component = () => {
|
|||||||
const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
|
const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
|
||||||
const [currentInput, setCurrentInput] = createSignal('');
|
const [currentInput, setCurrentInput] = createSignal('');
|
||||||
const [isHovered, setIsHovered] = createSignal(false);
|
const [isHovered, setIsHovered] = createSignal(false);
|
||||||
|
const [copyToast, setCopyToast] = createSignal(false);
|
||||||
|
const [editingMarkerId, setEditingMarkerId] = createSignal<string | null>(null);
|
||||||
|
const [markerDraft, setMarkerDraft] = createSignal('');
|
||||||
|
|
||||||
// For simplicity in this version, we'll just track a running total
|
// For simplicity in this version, we'll just track a running total
|
||||||
const [runningTotal, setRunningTotal] = createSignal(0);
|
const [runningTotal, setRunningTotal] = createSignal(0);
|
||||||
@@ -90,7 +95,24 @@ const CalculatorPopover: Component = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createMarker = () => {
|
const createMarker = () => {
|
||||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', label: `Marker ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` }]);
|
const markerTime = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', markerName: 'Marker', markerTime }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditingMarker = (entry: Entry) => {
|
||||||
|
if (entry.type !== 'marker') return;
|
||||||
|
setEditingMarkerId(entry.id);
|
||||||
|
setMarkerDraft(entry.markerName || 'Marker');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMarkerName = (id: string) => {
|
||||||
|
const nextName = markerDraft().trim() || 'Marker';
|
||||||
|
setEntries(prev => prev.map(entry =>
|
||||||
|
entry.id === id && entry.type === 'marker'
|
||||||
|
? { ...entry, markerName: nextName }
|
||||||
|
: entry
|
||||||
|
));
|
||||||
|
setEditingMarkerId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearInput = () => {
|
const clearInput = () => {
|
||||||
@@ -110,6 +132,36 @@ const CalculatorPopover: Component = () => {
|
|||||||
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCopyValue = () => {
|
||||||
|
if (currentInput() !== '') {
|
||||||
|
return currentInput().replace(/,/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(runningTotal()).replace(/,/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyDisplayedValue = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(getCopyValue());
|
||||||
|
setCopyToast(true);
|
||||||
|
window.setTimeout(() => setCopyToast(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy calculator value', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyEntryValue = async (entry: Entry) => {
|
||||||
|
if (entry.type === 'marker' || entry.value === undefined) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(String(entry.value).replace(/,/g, ''));
|
||||||
|
setCopyToast(true);
|
||||||
|
window.setTimeout(() => setCopyToast(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy calculator history value', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Keyboard support
|
// Keyboard support
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isOpen()) return;
|
if (!isOpen()) return;
|
||||||
@@ -177,7 +229,7 @@ const CalculatorPopover: Component = () => {
|
|||||||
<div class="fixed bottom-6 right-6 z-[100] no-print">
|
<div class="fixed bottom-6 right-6 z-[100] no-print">
|
||||||
<Show when={isOpen()}>
|
<Show when={isOpen()}>
|
||||||
<div
|
<div
|
||||||
class={`absolute bottom-24 right-0 w-[400px] bg-card rounded-[3rem] shadow-elevated border border-border overflow-hidden flex flex-col transition-all animate-in slide-in-from-bottom-8 duration-500 ease-out ${isHovered() ? 'ring-4 ring-primary/20 bg-primary/5' : ''}`}
|
class={`absolute bottom-24 right-0 w-[400px] max-h-[calc(100vh-12rem)] bg-card rounded-[3rem] shadow-elevated border border-border overflow-hidden flex flex-col transition-all animate-in slide-in-from-bottom-8 duration-500 ease-out ${isHovered() ? 'ring-4 ring-primary/20 bg-primary/5' : ''}`}
|
||||||
onDragOver={(e) => { e.preventDefault(); setIsHovered(true); }}
|
onDragOver={(e) => { e.preventDefault(); setIsHovered(true); }}
|
||||||
onDragLeave={() => setIsHovered(false)}
|
onDragLeave={() => setIsHovered(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -201,10 +253,10 @@ const CalculatorPopover: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display */}
|
{/* Display */}
|
||||||
<div class="p-8 bg-muted text-foreground min-h-[220px] flex flex-col justify-end items-end relative overflow-hidden group/display border-b border-border">
|
<div class="p-8 bg-muted text-foreground min-h-[220px] flex-1 flex flex-col justify-end items-end relative overflow-hidden group/display border-b border-border">
|
||||||
<div class="absolute top-6 left-8 text-[10px] font-black text-muted-foreground/30 uppercase tracking-[0.3em]">Calculation History</div>
|
<div class="absolute top-6 left-8 text-[10px] font-black text-muted-foreground/30 uppercase tracking-[0.3em]">Calculation History</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-end gap-2 mb-4 max-h-56 overflow-y-auto w-full custom-scrollbar pr-2 scroll-smooth">
|
<div class="flex-1 flex flex-col items-end gap-2 mb-4 min-h-0 overflow-y-auto w-full custom-scrollbar pr-2 scroll-smooth pt-8">
|
||||||
<For each={entries()}>
|
<For each={entries()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<Show when={entry.type === 'marker'} fallback={
|
<Show when={entry.type === 'marker'} fallback={
|
||||||
@@ -212,19 +264,51 @@ const CalculatorPopover: Component = () => {
|
|||||||
<Show when={entry.label}>
|
<Show when={entry.label}>
|
||||||
<span class="text-[9px] font-black text-muted-foreground/40 uppercase tracking-wider truncate max-w-[200px] group-hover/entry:text-muted-foreground/60 transition-colors">{entry.label}</span>
|
<span class="text-[9px] font-black text-muted-foreground/40 uppercase tracking-wider truncate max-w-[200px] group-hover/entry:text-muted-foreground/60 transition-colors">{entry.label}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<div class={`px-3 py-1 rounded-xl text-xs font-black shadow-sm transition-transform group-hover/entry:scale-105 ${entry.type === 'field' ? 'bg-primary text-primary-foreground shadow-primary/20' :
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyEntryValue(entry)}
|
||||||
|
title="Copy value"
|
||||||
|
class={`px-3 py-1 rounded-xl text-xs font-black shadow-sm transition-transform group-hover/entry:scale-105 ${entry.type === 'field' ? 'bg-primary text-primary-foreground shadow-primary/20' :
|
||||||
entry.type === 'result' ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-background text-foreground/80 border border-border/60'
|
entry.type === 'result' ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-background text-foreground/80 border border-border/60'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{entry.type === 'result' && <span class="mr-2 opacity-60">=</span>}
|
{entry.type === 'result' && <span class="mr-2 opacity-60">=</span>}
|
||||||
{formatNumber(entry.value || 0)}
|
{formatNumber(entry.value || 0)}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<div class="w-full flex items-center gap-4 my-4 opacity-50">
|
<div class="w-full flex items-center gap-4 my-4 opacity-50">
|
||||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent to-border"></div>
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent to-border"></div>
|
||||||
<span class="text-[9px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] whitespace-nowrap bg-muted px-3 py-1 rounded-full border border-border italic">
|
<Show
|
||||||
{entry.label}
|
when={editingMarkerId() === entry.id}
|
||||||
</span>
|
fallback={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEditingMarker(entry)}
|
||||||
|
title="Rename marker"
|
||||||
|
class="text-[9px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] whitespace-nowrap bg-muted px-3 py-1 rounded-full border border-border italic"
|
||||||
|
>
|
||||||
|
{(entry.markerName || 'Marker')} {entry.markerTime}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={markerDraft()}
|
||||||
|
onInput={(e) => setMarkerDraft(e.currentTarget.value)}
|
||||||
|
onBlur={() => saveMarkerName(entry.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
saveMarkerName(entry.id);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditingMarkerId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="text-[9px] font-black text-muted-foreground/80 uppercase tracking-[0.2em] whitespace-nowrap bg-background px-3 py-1 rounded-full border border-primary/30 italic text-center outline-none"
|
||||||
|
aria-label="Marker name"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-border"></div>
|
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-border"></div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -235,12 +319,20 @@ const CalculatorPopover: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-4xl xl:text-5xl font-black tracking-tighter mt-2 flex items-baseline gap-3 group-hover/display:scale-105 transition-transform duration-500 origin-right">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyDisplayedValue}
|
||||||
|
title="Copy value"
|
||||||
|
class="text-4xl xl:text-5xl font-black tracking-tighter mt-2 flex items-baseline gap-3 group-hover/display:scale-105 transition-transform duration-500 origin-right text-right"
|
||||||
|
>
|
||||||
<Show when={currentInput() === '' && entries().length > 0 && entries()[entries().length - 1].type === 'result'}>
|
<Show when={currentInput() === '' && entries().length > 0 && entries()[entries().length - 1].type === 'result'}>
|
||||||
<span class="text-primary text-base font-bold uppercase tracking-widest opacity-60">Total</span>
|
<span class="text-primary text-base font-bold uppercase tracking-widest opacity-60">Total</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="text-primary text-2xl font-medium">$</span>
|
<span class="text-primary text-2xl font-medium">$</span>
|
||||||
{currentInput() || formatNumber(runningTotal())}
|
{currentInput() || formatNumber(runningTotal())}
|
||||||
|
</button>
|
||||||
|
<div class="mt-3 text-[10px] font-black uppercase tracking-[0.25em] text-primary/60">
|
||||||
|
{copyToast() ? 'Copied' : 'Click any number to copy'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Portal } from 'solid-js/web';
|
|||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
import type { Scope, SubScope, EstimateItem } from '../types';
|
import type { Scope, SubScope, EstimateItem } from '../types';
|
||||||
import type { ExtractedItem } from '../utils/csv-parser';
|
import type { ExtractedItem } from '../utils/csv-parser';
|
||||||
|
import { extractItemsFromCSV } from '../utils/csv-parser';
|
||||||
import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
|
import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
|
||||||
|
|
||||||
import PrintEstimate from './PrintEstimate';
|
import PrintEstimate from './PrintEstimate';
|
||||||
@@ -37,6 +38,8 @@ interface EstimateBuilderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||||
|
type PrintMode = 'finalized' | 'manager';
|
||||||
|
|
||||||
// 1. Initialize State
|
// 1. Initialize State
|
||||||
const { scopes, setScopes, subScopes, setSubScopes, estimateItems: items, setEstimateItems: setItems, clientInfo, jobInfo, generalEstimateNotes, setGeneralEstimateNotes, generalPreNotes, setGeneralPreNotes } = appStore;
|
const { scopes, setScopes, subScopes, setSubScopes, estimateItems: items, setEstimateItems: setItems, clientInfo, jobInfo, generalEstimateNotes, setGeneralEstimateNotes, generalPreNotes, setGeneralPreNotes } = appStore;
|
||||||
|
|
||||||
@@ -50,7 +53,12 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
const [showHistoryModal, setShowHistoryModal] = createSignal(false);
|
const [showHistoryModal, setShowHistoryModal] = createSignal(false);
|
||||||
const [showNewEstimateModal, setShowNewEstimateModal] = createSignal(false);
|
const [showNewEstimateModal, setShowNewEstimateModal] = createSignal(false);
|
||||||
const [showExportTableModal, setShowExportTableModal] = createSignal(false);
|
const [showExportTableModal, setShowExportTableModal] = createSignal(false);
|
||||||
|
const [showCsvImportModal, setShowCsvImportModal] = createSignal(false);
|
||||||
const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null);
|
const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null);
|
||||||
|
const [pendingCsvImportText, setPendingCsvImportText] = createSignal<string | null>(null);
|
||||||
|
const [excludedScopeIds, setExcludedScopeIds] = createSignal<string[]>([]);
|
||||||
|
const [excludedSubScopeIds, setExcludedSubScopeIds] = createSignal<string[]>([]);
|
||||||
|
const [printMode, setPrintMode] = createSignal<PrintMode>('finalized');
|
||||||
|
|
||||||
// 2. Initialize custom hooks/primitives
|
// 2. Initialize custom hooks/primitives
|
||||||
const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager();
|
const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager();
|
||||||
@@ -87,7 +95,19 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
const anyDescriptionIsLong = createMemo(() => items.some(item => (item.description || '').length > 120));
|
const anyDescriptionIsLong = createMemo(() => items.some(item => (item.description || '').length > 120));
|
||||||
|
|
||||||
const finalizeBid = () => {
|
const finalizeBid = () => {
|
||||||
window.print();
|
runPrint('finalized');
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportManagerInfo = () => {
|
||||||
|
runPrint('manager');
|
||||||
|
};
|
||||||
|
|
||||||
|
const runPrint = (mode: PrintMode) => {
|
||||||
|
setPrintMode(mode);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.print();
|
||||||
|
window.setTimeout(() => setPrintMode('finalized'), 0);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const addScope = () => {
|
const addScope = () => {
|
||||||
@@ -184,6 +204,151 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
|
|
||||||
const [copyToast, setCopyToast] = createSignal<string | null>(null);
|
const [copyToast, setCopyToast] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const normalizeDescription = (value: string) => value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
const parseImportedQuantity = (value: string) => {
|
||||||
|
const parsed = Number.parseFloat(value.replace(/,/g, '').trim());
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeCsvQuantities = (csvText: string, options?: { excludedScopeIds?: string[]; excludedSubScopeIds?: string[] }) => {
|
||||||
|
const { items: extracted, ignored } = extractItemsFromCSV(csvText);
|
||||||
|
appStore.setIgnoredLines(ignored);
|
||||||
|
|
||||||
|
if (extracted.length === 0) {
|
||||||
|
setCopyToast('No CSV items were found to import.');
|
||||||
|
window.setTimeout(() => setCopyToast(null), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedByDescription = new Map<string, { description: string; quantity: number }>();
|
||||||
|
for (const item of extracted) {
|
||||||
|
const key = normalizeDescription(item.description);
|
||||||
|
if (!key) continue;
|
||||||
|
|
||||||
|
const quantity = parseImportedQuantity(item.quantity);
|
||||||
|
const existing = importedByDescription.get(key);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += quantity;
|
||||||
|
} else {
|
||||||
|
importedByDescription.set(key, {
|
||||||
|
description: item.description.trim(),
|
||||||
|
quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedExistingCount = 0;
|
||||||
|
const matchedDescriptions = new Set<string>();
|
||||||
|
const excludedScopeIdSet = new Set(options?.excludedScopeIds ?? []);
|
||||||
|
const excludedSubScopeIdSet = new Set(options?.excludedSubScopeIds ?? []);
|
||||||
|
|
||||||
|
const nextItems = items.map(item => {
|
||||||
|
const isExcludedByScope = item.scopeId ? excludedScopeIdSet.has(item.scopeId) : false;
|
||||||
|
const isExcludedBySubScope = item.subScopeId ? excludedSubScopeIdSet.has(item.subScopeId) : false;
|
||||||
|
if (isExcludedByScope || isExcludedBySubScope) return item;
|
||||||
|
|
||||||
|
const key = normalizeDescription(item.description);
|
||||||
|
const imported = importedByDescription.get(key);
|
||||||
|
|
||||||
|
if (!imported) return item;
|
||||||
|
|
||||||
|
matchedDescriptions.add(key);
|
||||||
|
updatedExistingCount += 1;
|
||||||
|
return { ...item, quantity: imported.quantity };
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUnassignedItems: EstimateItem[] = [];
|
||||||
|
for (const [key, imported] of importedByDescription.entries()) {
|
||||||
|
if (matchedDescriptions.has(key)) continue;
|
||||||
|
|
||||||
|
newUnassignedItems.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
description: imported.description,
|
||||||
|
quantity: imported.quantity,
|
||||||
|
unitPrice: 0,
|
||||||
|
markup: 0,
|
||||||
|
markupType: 'percent',
|
||||||
|
contingency: 0,
|
||||||
|
contingencyType: 'percent'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems([...nextItems, ...newUnassignedItems]);
|
||||||
|
|
||||||
|
const ignoredCount = ignored.length;
|
||||||
|
const excludedCount = excludedScopeIdSet.size + excludedSubScopeIdSet.size;
|
||||||
|
const toastParts = [
|
||||||
|
`Updated ${updatedExistingCount} item${updatedExistingCount === 1 ? '' : 's'}`,
|
||||||
|
`added ${newUnassignedItems.length} unassigned`,
|
||||||
|
excludedCount > 0 ? `excluded ${excludedCount} selection${excludedCount === 1 ? '' : 's'}` : null,
|
||||||
|
ignoredCount > 0 ? `ignored ${ignoredCount} row${ignoredCount === 1 ? '' : 's'}` : null
|
||||||
|
].filter(Boolean);
|
||||||
|
setCopyToast(toastParts.join(' • '));
|
||||||
|
window.setTimeout(() => setCopyToast(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleScopeExclusion = (scopeId: string) => {
|
||||||
|
const isExcluded = excludedScopeIds().includes(scopeId);
|
||||||
|
if (isExcluded) {
|
||||||
|
setExcludedScopeIds(prev => prev.filter(id => id !== scopeId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExcludedScopeIds(prev => [...prev, scopeId]);
|
||||||
|
const subScopeIdsInScope = subScopes.filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
|
||||||
|
if (subScopeIdsInScope.length > 0) {
|
||||||
|
setExcludedSubScopeIds(prev => prev.filter(id => !subScopeIdsInScope.includes(id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSubScopeExclusion = (subScopeId: string) => {
|
||||||
|
const isExcluded = excludedSubScopeIds().includes(subScopeId);
|
||||||
|
if (isExcluded) {
|
||||||
|
setExcludedSubScopeIds(prev => prev.filter(id => id !== subScopeId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExcludedSubScopeIds(prev => [...prev, subScopeId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCsvImportModal = () => {
|
||||||
|
setShowCsvImportModal(false);
|
||||||
|
setPendingCsvImportText(null);
|
||||||
|
setExcludedScopeIds([]);
|
||||||
|
setExcludedSubScopeIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCsvImport = () => {
|
||||||
|
const csvText = pendingCsvImportText();
|
||||||
|
if (!csvText) return;
|
||||||
|
|
||||||
|
mergeCsvQuantities(csvText, {
|
||||||
|
excludedScopeIds: excludedScopeIds(),
|
||||||
|
excludedSubScopeIds: excludedSubScopeIds()
|
||||||
|
});
|
||||||
|
closeCsvImportModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportCsvQuantities = (e: Event) => {
|
||||||
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result;
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
setPendingCsvImportText(text);
|
||||||
|
setExcludedScopeIds([]);
|
||||||
|
setExcludedSubScopeIds([]);
|
||||||
|
setShowCsvImportModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
input.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex flex-col min-h-screen bg-white shadow-2xl relative print:shadow-none print:border-none print:rounded-none">
|
<div class="w-full flex flex-col min-h-screen bg-white shadow-2xl relative print:shadow-none print:border-none print:rounded-none">
|
||||||
<PrintEstimate
|
<PrintEstimate
|
||||||
@@ -197,6 +362,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
generalPreNotes={generalPreNotes()}
|
generalPreNotes={generalPreNotes()}
|
||||||
formatNumber={formatNumber}
|
formatNumber={formatNumber}
|
||||||
hideColumns={anyDescriptionIsLong()}
|
hideColumns={anyDescriptionIsLong()}
|
||||||
|
variant={printMode()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="no-print flex flex-col min-h-dvh">
|
<div class="no-print flex flex-col min-h-dvh">
|
||||||
@@ -205,6 +371,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
|
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
|
||||||
onAddScope={addScope}
|
onAddScope={addScope}
|
||||||
onShowNewEstimateModal={() => setShowNewEstimateModal(true)}
|
onShowNewEstimateModal={() => setShowNewEstimateModal(true)}
|
||||||
|
onImportCsvQuantities={handleImportCsvQuantities}
|
||||||
onImportEstimate={importEstimate}
|
onImportEstimate={importEstimate}
|
||||||
onShowExportTableModal={() => setShowExportTableModal(true)}
|
onShowExportTableModal={() => setShowExportTableModal(true)}
|
||||||
onExportEstimate={exportEstimate}
|
onExportEstimate={exportEstimate}
|
||||||
@@ -314,7 +481,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
|
|
||||||
<ValidationStatusBar validationStats={validationStats()} />
|
<ValidationStatusBar validationStats={validationStats()} />
|
||||||
|
|
||||||
<ExecutiveSummary totals={grandTotals()} onFinalizeBid={finalizeBid} />
|
<ExecutiveSummary totals={grandTotals()} onFinalizeBid={finalizeBid} onExportManagerInfo={exportManagerInfo} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,6 +543,102 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
|
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
|
||||||
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
|
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
|
||||||
<HistoryModal show={showHistoryModal()} onClose={() => setShowHistoryModal(false)} onCheckout={checkoutVersion} />
|
<HistoryModal show={showHistoryModal()} onClose={() => setShowHistoryModal(false)} onCheckout={checkoutVersion} />
|
||||||
|
<Show when={showCsvImportModal()}>
|
||||||
|
<Portal>
|
||||||
|
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div class="bg-card w-full max-w-2xl rounded-3xl shadow-premium border border-border animate-in zoom-in-95 duration-200 overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-border/50 flex items-center justify-between bg-muted/20">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-black">Import CSV Quantities</h3>
|
||||||
|
<p class="text-[10px] text-muted-foreground font-black uppercase tracking-widest mt-1">Choose scopes or sub-scopes to leave unchanged</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={closeCsvImportModal} class="p-2 hover:bg-muted rounded-xl transition-colors">
|
||||||
|
<X class="w-5 h-5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Matching items in excluded sections will keep their current quantities. Unassigned items are always eligible for updates.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={scopes.length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="rounded-2xl border border-border bg-muted/20 px-4 py-5 text-sm text-muted-foreground">
|
||||||
|
No scopes exist yet. Import will only update unassigned items and add unmatched rows to the unassigned list.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<For each={scopes}>
|
||||||
|
{(scope) => {
|
||||||
|
const scopeSubScopes = () => subScopes.filter(ss => ss.scopeId === scope.id);
|
||||||
|
const isScopeExcluded = () => excludedScopeIds().includes(scope.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="rounded-2xl border border-border bg-background overflow-hidden">
|
||||||
|
<label class="flex items-start gap-3 px-4 py-4 border-b border-border/50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isScopeExcluded()}
|
||||||
|
onChange={() => toggleScopeExclusion(scope.id)}
|
||||||
|
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-bold text-foreground">{scope.name}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">Exclude this entire scope from quantity replacement.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Show when={scopeSubScopes().length > 0}>
|
||||||
|
<div class={`px-4 py-3 space-y-2 ${isScopeExcluded() ? 'opacity-50' : ''}`}>
|
||||||
|
<div class="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Sub-scopes</div>
|
||||||
|
<For each={scopeSubScopes()}>
|
||||||
|
{(subScope) => (
|
||||||
|
<label class="flex items-start gap-3 rounded-xl px-3 py-2 hover:bg-muted/30 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={excludedSubScopeIds().includes(subScope.id)}
|
||||||
|
disabled={isScopeExcluded()}
|
||||||
|
onChange={() => toggleSubScopeExclusion(subScope.id)}
|
||||||
|
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-medium text-foreground">{subScope.name}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">Exclude only this sub-scope.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-muted/20 border-t border-border/50 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={closeCsvImportModal}
|
||||||
|
class="px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-background transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmCsvImport}
|
||||||
|
class="px-5 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-premium hover:shadow-premium-hover transition-all"
|
||||||
|
>
|
||||||
|
Import Quantities
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<NewEstimateModal
|
<NewEstimateModal
|
||||||
show={showNewEstimateModal()}
|
show={showNewEstimateModal()}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createSignal, Show, For, onMount } from 'solid-js';
|
|||||||
import { Portal } from 'solid-js/web';
|
import { Portal } from 'solid-js/web';
|
||||||
import { X, Table, Copy, Download, CheckCircle2 } from 'lucide-solid';
|
import { X, Table, Copy, Download, CheckCircle2 } from 'lucide-solid';
|
||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
|
import { getEffectiveItemPricing } from '../utils/pricing';
|
||||||
|
|
||||||
interface ExportTableModalProps {
|
interface ExportTableModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -93,22 +94,18 @@ const ExportTableModal: Component<ExportTableModalProps> = (props) => {
|
|||||||
filteredItems.forEach(item => {
|
filteredItems.forEach(item => {
|
||||||
const scope = scopes().find(s => s.id === item.scopeId);
|
const scope = scopes().find(s => s.id === item.scopeId);
|
||||||
const subScope = subScopes().find(ss => ss.id === item.subScopeId);
|
const subScope = subScopes().find(ss => ss.id === item.subScopeId);
|
||||||
|
const pricing = getEffectiveItemPricing(item, subScope?.modifiers || []);
|
||||||
const base = item.quantity * item.unitPrice;
|
|
||||||
const markupAmt = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
|
||||||
const contingencyAmt = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
|
||||||
const total = base + markupAmt + contingencyAmt;
|
|
||||||
|
|
||||||
dataRows.push({
|
dataRows.push({
|
||||||
scope: scope?.name || 'Unassigned',
|
scope: scope?.name || 'Unassigned',
|
||||||
subscope: subScope?.name || 'Scope Level',
|
subscope: subScope?.name || 'Scope Level',
|
||||||
description: item.description,
|
description: item.description,
|
||||||
quantity: item.quantity,
|
quantity: pricing.effectiveQuantity,
|
||||||
unitPrice: item.unitPrice,
|
unitPrice: pricing.effectiveUnitPrice,
|
||||||
subtotal: base,
|
subtotal: pricing.effectiveSubtotal,
|
||||||
markup: markupAmt,
|
markup: pricing.markupAmount,
|
||||||
contingency: contingencyAmt,
|
contingency: pricing.contingencyAmount,
|
||||||
total: total
|
total: pricing.total
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+74
-41
@@ -3,8 +3,9 @@ import { createMemo, createEffect, createSignal, Show, For, onCleanup } from 'so
|
|||||||
import { Portal } from 'solid-js/web';
|
import { Portal } from 'solid-js/web';
|
||||||
import { GripVertical, Trash2, Copy } from 'lucide-solid';
|
import { GripVertical, Trash2, Copy } from 'lucide-solid';
|
||||||
import NumericInput from './ui/NumericInput';
|
import NumericInput from './ui/NumericInput';
|
||||||
import type { EstimateItem, StandardPrice } from '../types';
|
import type { EstimateItem, Modifier, StandardPrice } from '../types';
|
||||||
import { pb, COLLECTIONS } from '../utils/db';
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
|
import { getEffectiveItemPricing } from '../utils/pricing';
|
||||||
|
|
||||||
interface ItemRowProps {
|
interface ItemRowProps {
|
||||||
item: EstimateItem;
|
item: EstimateItem;
|
||||||
@@ -18,23 +19,15 @@ interface ItemRowProps {
|
|||||||
onDuplicateItem: (id: string) => void;
|
onDuplicateItem: (id: string) => void;
|
||||||
hideColumns?: boolean;
|
hideColumns?: boolean;
|
||||||
activeSupplier?: string;
|
activeSupplier?: string;
|
||||||
|
subScopeModifiers?: Modifier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ItemRow: Component<ItemRowProps> = (props) => {
|
const ItemRow: Component<ItemRowProps> = (props) => {
|
||||||
let descriptionRef: HTMLTextAreaElement | undefined;
|
let descriptionRef: HTMLTextAreaElement | undefined;
|
||||||
|
|
||||||
const baseTotal = createMemo(() => props.item.quantity * props.item.unitPrice);
|
const pricing = createMemo(() => getEffectiveItemPricing(props.item, props.subScopeModifiers || []));
|
||||||
|
const subtotal = createMemo(() => pricing().effectiveSubtotal);
|
||||||
const total = createMemo(() => {
|
const total = createMemo(() => pricing().total);
|
||||||
const base = baseTotal();
|
|
||||||
const markup = props.item.markupType === 'percent'
|
|
||||||
? base * (props.item.markup / 100)
|
|
||||||
: props.item.markup;
|
|
||||||
const contingency = props.item.contingencyType === 'percent'
|
|
||||||
? base * (props.item.contingency / 100)
|
|
||||||
: props.item.contingency;
|
|
||||||
return base + markup + contingency;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isSearchingPrice, setIsSearchingPrice] = createSignal(false);
|
const [isSearchingPrice, setIsSearchingPrice] = createSignal(false);
|
||||||
const [searchResults, setSearchResults] = createSignal<StandardPrice[]>([]);
|
const [searchResults, setSearchResults] = createSignal<StandardPrice[]>([]);
|
||||||
@@ -272,7 +265,7 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
value={props.item.quantity}
|
value={props.item.quantity}
|
||||||
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
|
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
const val = props.item.quantity;
|
const val = pricing().effectiveQuantity;
|
||||||
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'qty', value: val, label: `${props.item.description || 'Unnamed Item'}: Qty` };
|
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'qty', value: val, label: `${props.item.description || 'Unnamed Item'}: Qty` };
|
||||||
e.dataTransfer!.setData('application/json', JSON.stringify(data));
|
e.dataTransfer!.setData('application/json', JSON.stringify(data));
|
||||||
e.dataTransfer!.setData('text/plain', String(val));
|
e.dataTransfer!.setData('text/plain', String(val));
|
||||||
@@ -284,35 +277,49 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
|
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
|
||||||
placeholder="Qty"
|
placeholder="Qty"
|
||||||
/>
|
/>
|
||||||
|
<Show when={pricing().hasDirectQuantityAdjustment}>
|
||||||
|
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||||
|
Adj {formatNumber(pricing().effectiveQuantity)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price — w-24 */}
|
{/* Price — w-24 */}
|
||||||
<div class="w-24 shrink-0 relative" ref={anchorRef}>
|
<div class="w-24 shrink-0 relative" ref={anchorRef}>
|
||||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px] pointer-events-none">$</span>
|
<div class="space-y-1">
|
||||||
<NumericInput
|
<div class="relative">
|
||||||
inputRef={(el: any) => desktopInputRef = el}
|
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px] pointer-events-none">$</span>
|
||||||
dataFieldName="price"
|
<NumericInput
|
||||||
step="0.1"
|
inputRef={(el: any) => desktopInputRef = el}
|
||||||
value={props.item.unitPrice}
|
dataFieldName="price"
|
||||||
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
|
step="0.1"
|
||||||
onDragStart={(e) => {
|
value={props.item.unitPrice}
|
||||||
const val = props.item.unitPrice;
|
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
|
||||||
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'price', value: val, label: `${props.item.description || 'Unnamed Item'}: Price` };
|
onDragStart={(e) => {
|
||||||
e.dataTransfer!.setData('application/json', JSON.stringify(data));
|
const val = pricing().effectiveUnitPrice;
|
||||||
e.dataTransfer!.setData('text/plain', String(val));
|
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'price', value: val, label: `${props.item.description || 'Unnamed Item'}: Price` };
|
||||||
e.dataTransfer!.effectAllowed = 'copy';
|
e.dataTransfer!.setData('application/json', JSON.stringify(data));
|
||||||
e.stopPropagation();
|
e.dataTransfer!.setData('text/plain', String(val));
|
||||||
}}
|
e.dataTransfer!.effectAllowed = 'copy';
|
||||||
draggable="true"
|
e.stopPropagation();
|
||||||
disabled={props.anySelected}
|
}}
|
||||||
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
|
draggable="true"
|
||||||
placeholder="Price"
|
disabled={props.anySelected}
|
||||||
allowTextSearch={true}
|
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
|
||||||
onTextSearch={handlePriceSearch}
|
placeholder="Price"
|
||||||
onKeyDown={handlePriceKeyDown}
|
allowTextSearch={true}
|
||||||
onTextSearchExit={() => setIsSearchingPrice(false)}
|
onTextSearch={handlePriceSearch}
|
||||||
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
|
onKeyDown={handlePriceKeyDown}
|
||||||
/>
|
onTextSearchExit={() => setIsSearchingPrice(false)}
|
||||||
|
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={pricing().hasDirectUnitPriceAdjustment}>
|
||||||
|
<div class="text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||||
|
Adj ${formatNumber(pricing().effectiveUnitPrice)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<PriceDropdown />
|
<PriceDropdown />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -320,7 +327,7 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
draggable="true"
|
draggable="true"
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
const val = baseTotal();
|
const val = subtotal();
|
||||||
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'subtotal', value: val, label: `${props.item.description || 'Unnamed Item'}: Subtotal` };
|
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'subtotal', value: val, label: `${props.item.description || 'Unnamed Item'}: Subtotal` };
|
||||||
e.dataTransfer!.setData('application/json', JSON.stringify(data));
|
e.dataTransfer!.setData('application/json', JSON.stringify(data));
|
||||||
e.dataTransfer!.setData('text/plain', String(val));
|
e.dataTransfer!.setData('text/plain', String(val));
|
||||||
@@ -329,7 +336,15 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
class="w-24 shrink-0 text-right text-muted-foreground/60 text-[11px] font-bold uppercase tracking-tighter cursor-grab hover:text-primary transition-colors"
|
class="w-24 shrink-0 text-right text-muted-foreground/60 text-[11px] font-bold uppercase tracking-tighter cursor-grab hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
${formatNumber(baseTotal())}
|
<div>${formatNumber(pricing().baseSubtotal)}</div>
|
||||||
|
<Show when={pricing().hasAdjustedSubtotal}>
|
||||||
|
<div class="mt-1 text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||||
|
Adjusted
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] font-black text-primary">
|
||||||
|
${formatNumber(subtotal())}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markup — w-32 (conditional) */}
|
{/* Markup — w-32 (conditional) */}
|
||||||
@@ -465,6 +480,11 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
<div class="w-20">
|
<div class="w-20">
|
||||||
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Qty</div>
|
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Qty</div>
|
||||||
<NumericInput value={props.item.quantity} onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Qty" />
|
<NumericInput value={props.item.quantity} onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Qty" />
|
||||||
|
<Show when={pricing().hasDirectQuantityAdjustment}>
|
||||||
|
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||||
|
Adj {formatNumber(pricing().effectiveQuantity)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-24">
|
||||||
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Price</div>
|
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Price</div>
|
||||||
@@ -473,6 +493,11 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
<NumericInput inputRef={(el: any) => mobileInputRef = el} step="0.1" value={props.item.unitPrice} onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Price" allowTextSearch={true} onTextSearch={handlePriceSearch} onKeyDown={handlePriceKeyDown} onTextSearchExit={() => setIsSearchingPrice(false)} onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)} />
|
<NumericInput inputRef={(el: any) => mobileInputRef = el} step="0.1" value={props.item.unitPrice} onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Price" allowTextSearch={true} onTextSearch={handlePriceSearch} onKeyDown={handlePriceKeyDown} onTextSearchExit={() => setIsSearchingPrice(false)} onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)} />
|
||||||
<PriceDropdown />
|
<PriceDropdown />
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={pricing().hasDirectUnitPriceAdjustment}>
|
||||||
|
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||||
|
Adj ${formatNumber(pricing().effectiveUnitPrice)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
@@ -495,6 +520,14 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
<div class={`font-black text-sm text-right ${props.isSelected ? 'text-primary' : 'text-foreground'}`}>${formatNumber(total())}</div>
|
<div class={`font-black text-sm text-right ${props.isSelected ? 'text-primary' : 'text-foreground'}`}>${formatNumber(total())}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={pricing().hasDirectSubtotalAdjustment || pricing().hasAdjustedSubtotal}>
|
||||||
|
<div class="pl-7 text-right text-[10px] font-black uppercase tracking-[0.15em] text-primary/70">
|
||||||
|
Adjusted
|
||||||
|
</div>
|
||||||
|
<div class="pl-7 text-right text-sm font-black text-primary">
|
||||||
|
${formatNumber(pricing().effectiveSubtotal)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { Show } from 'solid-js';
|
import { Show, createMemo } from 'solid-js';
|
||||||
import { Trash2, Calculator, EyeOff } from 'lucide-solid';
|
import { Trash2, Calculator, EyeOff } from 'lucide-solid';
|
||||||
import NumericInput from './ui/NumericInput';
|
import NumericInput from './ui/NumericInput';
|
||||||
import type { Modifier } from '../types';
|
import type { Modifier } from '../types';
|
||||||
import { ModifierRegistry } from '../utils/modifier-registry';
|
import { ModifierRegistry } from '../utils/modifier-registry';
|
||||||
|
import {
|
||||||
|
CUSTOM_CALC_DEFAULT_EXPRESSION,
|
||||||
|
getComputedModifierStatus,
|
||||||
|
isComputedFieldModifier,
|
||||||
|
isCustomCalculationModifier,
|
||||||
|
isPrevailingWageModifier,
|
||||||
|
TARGET_FIELD_LABELS
|
||||||
|
} from '../utils/pricing';
|
||||||
|
|
||||||
interface ModifierRowProps {
|
interface ModifierRowProps {
|
||||||
modifier: Modifier;
|
modifier: Modifier;
|
||||||
@@ -13,16 +21,233 @@ interface ModifierRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ModifierRow: Component<ModifierRowProps> = (props) => {
|
const ModifierRow: Component<ModifierRowProps> = (props) => {
|
||||||
const module = () => ModifierRegistry.getModule(props.modifier.moduleId);
|
const module = () => ModifierRegistry.getModule(props.modifier.moduleId, props.modifier);
|
||||||
|
const computedStatus = createMemo(() => getComputedModifierStatus(props.modifier));
|
||||||
|
const isPrevailing = () => isPrevailingWageModifier(props.modifier);
|
||||||
|
const isCustom = () => isCustomCalculationModifier(props.modifier);
|
||||||
|
const isComputed = () => isComputedFieldModifier(props.modifier);
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
const formatNumber = (num: number) => {
|
||||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isPrevailing()) {
|
||||||
|
return (
|
||||||
|
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1 space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
title={module().description}
|
||||||
|
value={props.modifier.name}
|
||||||
|
onInput={(e) => props.onUpdate(props.modifier.id, { name: e.currentTarget.value })}
|
||||||
|
class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48"
|
||||||
|
placeholder="Modifier Name"
|
||||||
|
/>
|
||||||
|
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em] text-primary">
|
||||||
|
Unit Price
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-[120px_120px_auto]">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">Base Rate</div>
|
||||||
|
<NumericInput
|
||||||
|
value={computedStatus().parameters.baseRate ?? 0}
|
||||||
|
onUpdate={(val) => props.onUpdate(props.modifier.id, {
|
||||||
|
targetField: 'unitPrice',
|
||||||
|
parameters: {
|
||||||
|
...computedStatus().parameters,
|
||||||
|
baseRate: val
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-right text-xs font-bold text-foreground outline-none transition-all focus:border-primary focus:bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">Prevailing</div>
|
||||||
|
<NumericInput
|
||||||
|
value={computedStatus().parameters.prevailingRate ?? 0}
|
||||||
|
onUpdate={(val) => props.onUpdate(props.modifier.id, {
|
||||||
|
targetField: 'unitPrice',
|
||||||
|
parameters: {
|
||||||
|
...computedStatus().parameters,
|
||||||
|
prevailingRate: val
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-right text-xs font-bold text-foreground outline-none transition-all focus:border-primary focus:bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<div class="rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-[10px] font-black uppercase tracking-[0.15em] text-muted-foreground">
|
||||||
|
<Show when={!computedStatus().error && computedStatus().multiplier !== undefined} fallback={'Formula: price * multiplier'}>
|
||||||
|
Multiplier {computedStatus().multiplier?.toFixed(4)}x
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={computedStatus().error}>
|
||||||
|
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest text-destructive">
|
||||||
|
{computedStatus().error}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
|
||||||
|
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
|
||||||
|
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
|
||||||
|
>
|
||||||
|
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
|
||||||
|
<Calculator class="w-3.5 h-3.5" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => props.onDelete(props.modifier.id)}
|
||||||
|
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCustom()) {
|
||||||
|
return (
|
||||||
|
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1 space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
title={module().description}
|
||||||
|
value={props.modifier.name}
|
||||||
|
onInput={(e) => props.onUpdate(props.modifier.id, { name: e.currentTarget.value })}
|
||||||
|
class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48"
|
||||||
|
placeholder="Modifier Name"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={computedStatus().targetField}
|
||||||
|
onChange={(e) => props.onUpdate(props.modifier.id, { targetField: e.currentTarget.value as Modifier['targetField'] })}
|
||||||
|
class="rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-black uppercase tracking-wider text-muted-foreground outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="unitPrice">{TARGET_FIELD_LABELS.unitPrice}</option>
|
||||||
|
<option value="quantity">{TARGET_FIELD_LABELS.quantity}</option>
|
||||||
|
<option value="subtotal">{TARGET_FIELD_LABELS.subtotal}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex items-center justify-between text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
<span>Math</span>
|
||||||
|
<span class="text-primary">Inputs: price, quantity, subtotal</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={computedStatus().expression}
|
||||||
|
onInput={(e) => props.onUpdate(props.modifier.id, { expression: e.currentTarget.value })}
|
||||||
|
class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-xs font-mono text-foreground outline-none transition-all focus:border-primary focus:bg-background"
|
||||||
|
placeholder={CUSTOM_CALC_DEFAULT_EXPRESSION}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
<Show
|
||||||
|
when={!computedStatus().error}
|
||||||
|
fallback={<span class="text-destructive">{computedStatus().error}</span>}
|
||||||
|
>
|
||||||
|
<span class="text-primary/70">
|
||||||
|
Output updates {TARGET_FIELD_LABELS[computedStatus().targetField].toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
Base inputs stay editable
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
|
||||||
|
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
|
||||||
|
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
|
||||||
|
>
|
||||||
|
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
|
||||||
|
<Calculator class="w-3.5 h-3.5" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => props.onDelete(props.modifier.id)}
|
||||||
|
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isComputed()) {
|
||||||
|
return (
|
||||||
|
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
title={module().description}
|
||||||
|
value={props.modifier.name}
|
||||||
|
onInput={(e) => props.onUpdate(props.modifier.id, { name: e.currentTarget.value })}
|
||||||
|
class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48"
|
||||||
|
placeholder="Modifier Name"
|
||||||
|
/>
|
||||||
|
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em] text-primary">
|
||||||
|
Legacy Modifier
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
<Show
|
||||||
|
when={!computedStatus().error}
|
||||||
|
fallback={<span class="text-destructive">{computedStatus().error}</span>}
|
||||||
|
>
|
||||||
|
<span class="text-primary/70">Existing computed modifier remains supported</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
|
||||||
|
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
|
||||||
|
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
|
||||||
|
>
|
||||||
|
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
|
||||||
|
<Calculator class="w-3.5 h-3.5" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => props.onDelete(props.modifier.id)}
|
||||||
|
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="group flex items-center justify-between py-2 px-1 hover:bg-primary/5 rounded-xl transition-all">
|
<div class="group flex items-center justify-between py-2 px-1 hover:bg-primary/5 rounded-xl transition-all">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{/* Name */}
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
title={module().description}
|
title={module().description}
|
||||||
@@ -32,7 +257,6 @@ const ModifierRow: Component<ModifierRowProps> = (props) => {
|
|||||||
placeholder="Modifier Name"
|
placeholder="Modifier Name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Type Toggles - Visible on hover */}
|
|
||||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={() => props.onUpdate(props.modifier.id, { valueType: 'percent' })}
|
onClick={() => props.onUpdate(props.modifier.id, { valueType: 'percent' })}
|
||||||
@@ -59,13 +283,12 @@ const ModifierRow: Component<ModifierRowProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
{/* Value Input */}
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NumericInput
|
<NumericInput
|
||||||
value={props.modifier.value}
|
value={props.modifier.value}
|
||||||
onUpdate={(val) => props.onUpdate(props.modifier.id, { value: val })}
|
onUpdate={(val) => props.onUpdate(props.modifier.id, { value: val })}
|
||||||
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent text-primary font-medium"
|
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent text-primary font-medium"
|
||||||
placeholder={props.modifier.moduleId === 'man-hours' ? "Factor" : "0.00"}
|
placeholder={props.modifier.moduleId === 'man-hours' ? 'Factor' : '0.00'}
|
||||||
/>
|
/>
|
||||||
<Show when={(props.modifier.valueType === 'unit' && props.modifier.unitLabel) || props.modifier.moduleId === 'man-hours'}>
|
<Show when={(props.modifier.valueType === 'unit' && props.modifier.unitLabel) || props.modifier.moduleId === 'man-hours'}>
|
||||||
<span class="text-[10px] font-bold text-primary/60">
|
<span class="text-[10px] font-bold text-primary/60">
|
||||||
@@ -74,14 +297,12 @@ const ModifierRow: Component<ModifierRowProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calculated Result */}
|
|
||||||
<div class="w-28 text-right flex items-center justify-end gap-1.5">
|
<div class="w-28 text-right flex items-center justify-end gap-1.5">
|
||||||
<span class={`text-sm font-medium transition-colors ${props.modifier.includeInTotal ? 'text-foreground' : 'text-muted-foreground/40'}`}>
|
<span class={`text-sm font-medium transition-colors ${props.modifier.includeInTotal ? 'text-foreground' : 'text-muted-foreground/40'}`}>
|
||||||
{props.modifier.moduleId === 'man-hours' ? '' : '$'}{formatNumber(props.calculatedValue)}
|
{props.modifier.moduleId === 'man-hours' ? '' : '$'}{formatNumber(props.calculatedValue)}
|
||||||
{props.modifier.moduleId === 'man-hours' ? ' hrs' : ''}
|
{props.modifier.moduleId === 'man-hours' ? ' hrs' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Active/Draft Toggle */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
|
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
|
||||||
class={`p-1 rounded-md transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
|
class={`p-1 rounded-md transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
|
||||||
@@ -93,7 +314,6 @@ const ModifierRow: Component<ModifierRowProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => props.onDelete(props.modifier.id)}
|
onClick={() => props.onDelete(props.modifier.id)}
|
||||||
class="p-1 opacity-0 group-hover:opacity-100 text-muted-foreground/30 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
|
class="p-1 opacity-0 group-hover:opacity-100 text-muted-foreground/30 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface NoteEditorProps {
|
|||||||
icon: any;
|
icon: any;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
highlight?: boolean;
|
highlight?: boolean;
|
||||||
|
headerActions?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoteEditor: Component<NoteEditorProps> = (props) => {
|
const NoteEditor: Component<NoteEditorProps> = (props) => {
|
||||||
@@ -56,13 +57,16 @@ const NoteEditor: Component<NoteEditorProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<span class={`text-[10px] font-black uppercase tracking-widest ${props.highlight ? 'text-destructive' : 'text-muted-foreground'}`}>{props.label}</span>
|
<span class={`text-[10px] font-black uppercase tracking-widest ${props.highlight ? 'text-destructive' : 'text-muted-foreground'}`}>{props.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
onClick={() => setIsEditing(!isEditing())}
|
{props.headerActions}
|
||||||
class={`flex items-center gap-1.5 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border ${isEditing() ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'text-muted-foreground hover:bg-muted border-border/40'}`}
|
<button
|
||||||
>
|
onClick={() => setIsEditing(!isEditing())}
|
||||||
{isEditing() ? <Eye class="w-3 h-3 mr-1.5" /> : <Edit3 class="w-3 h-3 mr-1.5" />}
|
class={`flex items-center gap-1.5 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border ${isEditing() ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'text-muted-foreground hover:bg-muted border-border/40'}`}
|
||||||
{isEditing() ? 'Preview' : 'Edit'}
|
>
|
||||||
</button>
|
{isEditing() ? <Eye class="w-3 h-3 mr-1.5" /> : <Edit3 class="w-3 h-3 mr-1.5" />}
|
||||||
|
{isEditing() ? 'Preview' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 min-h-[100px]">
|
<div class="p-4 min-h-[100px]">
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Scope, SubScope, EstimateItem, JobInfo } from '../types';
|
|||||||
import { NOTICE_TO_OWNERS } from '../notice-to-owners';
|
import { NOTICE_TO_OWNERS } from '../notice-to-owners';
|
||||||
import { COMPANY_NAME, COMPANY_ADDRESS, COMPANY_PHONE, COMPANY_EMAIL } from '../company-info';
|
import { COMPANY_NAME, COMPANY_ADDRESS, COMPANY_PHONE, COMPANY_EMAIL } from '../company-info';
|
||||||
import { ModifierRegistry } from '../utils/modifier-registry';
|
import { ModifierRegistry } from '../utils/modifier-registry';
|
||||||
|
import { getEffectiveItemPricing, getItemsPricingTotals } from '../utils/pricing';
|
||||||
|
|
||||||
interface PrintEstimateProps {
|
interface PrintEstimateProps {
|
||||||
scopes: Scope[];
|
scopes: Scope[];
|
||||||
@@ -28,9 +29,13 @@ interface PrintEstimateProps {
|
|||||||
generalEstimateNotes: string;
|
generalEstimateNotes: string;
|
||||||
formatNumber: (num: number) => string;
|
formatNumber: (num: number) => string;
|
||||||
hideColumns?: boolean;
|
hideColumns?: boolean;
|
||||||
|
variant?: 'finalized' | 'manager';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||||
|
const variant = () => props.variant ?? 'finalized';
|
||||||
|
const isManagerVariant = () => variant() === 'manager';
|
||||||
|
|
||||||
const getScopeTotal = (scopeId: string) => {
|
const getScopeTotal = (scopeId: string) => {
|
||||||
let scopeSubtotal = 0;
|
let scopeSubtotal = 0;
|
||||||
let scopeMarkup = 0;
|
let scopeMarkup = 0;
|
||||||
@@ -40,10 +45,13 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
if (!scope) return 0;
|
if (!scope) return 0;
|
||||||
|
|
||||||
props.items.filter((i: EstimateItem) => i.scopeId === scopeId).forEach((item: EstimateItem) => {
|
props.items.filter((i: EstimateItem) => i.scopeId === scopeId).forEach((item: EstimateItem) => {
|
||||||
const base = item.quantity * item.unitPrice;
|
const modifiers = item.subScopeId
|
||||||
scopeSubtotal += base;
|
? (props.subScopes.find(subScope => subScope.id === item.subScopeId)?.modifiers || [])
|
||||||
scopeMarkup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
: [];
|
||||||
scopeContingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
const pricing = getEffectiveItemPricing(item, modifiers);
|
||||||
|
scopeSubtotal += pricing.effectiveSubtotal;
|
||||||
|
scopeMarkup += pricing.markupAmount;
|
||||||
|
scopeContingency += pricing.contingencyAmount;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mgmtFee = scope.useManagementLogic === 'percent'
|
const mgmtFee = scope.useManagementLogic === 'percent'
|
||||||
@@ -59,12 +67,7 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
|
|
||||||
scopeSubScopes.forEach(ss => {
|
scopeSubScopes.forEach(ss => {
|
||||||
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
|
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
|
||||||
const ssTotalWithMarkup = ssItems.reduce((sum, item) => {
|
const ssTotalWithMarkup = getItemsPricingTotals(ssItems, ss.modifiers || []).total;
|
||||||
const base = item.quantity * item.unitPrice;
|
|
||||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
|
||||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
|
||||||
return sum + base + markup + contingency;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
(ss.modifiers || []).forEach(m => {
|
(ss.modifiers || []).forEach(m => {
|
||||||
if (!m.includeInTotal) return;
|
if (!m.includeInTotal) return;
|
||||||
@@ -75,6 +78,34 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
return scopeSubtotal + scopeMarkup + scopeContingency + mgmtFee + deliveryFee + modifiersTotal;
|
return scopeSubtotal + scopeMarkup + scopeContingency + mgmtFee + deliveryFee + modifiersTotal;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getScopeItems = (scopeId: string) => {
|
||||||
|
const scopeLevelItems = props.items.filter((item: EstimateItem) => item.scopeId === scopeId && !item.subScopeId);
|
||||||
|
const scopeSubScopes = props.subScopes
|
||||||
|
.filter((subScope) => subScope.scopeId === scopeId)
|
||||||
|
.map((subScope) => ({
|
||||||
|
subScope,
|
||||||
|
items: props.items.filter((item: EstimateItem) => item.subScopeId === subScope.id)
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.items.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scopeLevelItems,
|
||||||
|
scopeSubScopes
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemPricing = (item: EstimateItem) => {
|
||||||
|
const modifiers = item.subScopeId
|
||||||
|
? (props.subScopes.find(subScope => subScope.id === item.subScopeId)?.modifiers || [])
|
||||||
|
: [];
|
||||||
|
return getEffectiveItemPricing(item, modifiers);
|
||||||
|
};
|
||||||
|
const getItemSubtotal = (item: EstimateItem) => getItemPricing(item).effectiveSubtotal;
|
||||||
|
const getManagerScopeVisibleTotal = (scopeId: string) =>
|
||||||
|
props.items
|
||||||
|
.filter((item: EstimateItem) => item.scopeId === scopeId)
|
||||||
|
.reduce((sum, item) => sum + getItemSubtotal(item), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="print-only p-6 bg-white font-sans max-w-[8.5in] mx-auto text-sm leading-normal text-foreground">
|
<div class="print-only p-6 bg-white font-sans max-w-[8.5in] mx-auto text-sm leading-normal text-foreground">
|
||||||
{/* Company Header */}
|
{/* Company Header */}
|
||||||
@@ -88,7 +119,9 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<h2 class="text-2xl font-black text-foreground uppercase tracking-tighter leading-none">Estimate</h2>
|
<h2 class="text-2xl font-black text-foreground uppercase tracking-tighter leading-none">
|
||||||
|
{isManagerVariant() ? 'Manager Info' : 'Estimate'}
|
||||||
|
</h2>
|
||||||
<p class="text-[10px] text-muted-foreground italic uppercase tracking-widest font-bold mb-1">*** NOT A BILL ***</p>
|
<p class="text-[10px] text-muted-foreground italic uppercase tracking-widest font-bold mb-1">*** NOT A BILL ***</p>
|
||||||
<p class="text-foreground font-bold text-xs">Date: {new Date().toLocaleDateString()}</p>
|
<p class="text-foreground font-bold text-xs">Date: {new Date().toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,6 +143,10 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border pb-1 mb-2">Project Details</h3>
|
<h3 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border pb-1 mb-2">Project Details</h3>
|
||||||
<div class="pl-1 space-y-1">
|
<div class="pl-1 space-y-1">
|
||||||
|
<div class="flex gap-2 text-xs">
|
||||||
|
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Job No.</span>
|
||||||
|
<span class="font-bold text-foreground">{props.jobInfo.number || '---'}</span>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2 text-xs">
|
<div class="flex gap-2 text-xs">
|
||||||
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Project</span>
|
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Project</span>
|
||||||
<span class="font-bold text-foreground">{props.jobInfo.name || '---'}</span>
|
<span class="font-bold text-foreground">{props.jobInfo.name || '---'}</span>
|
||||||
@@ -129,7 +166,7 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* General Pre-Notes */}
|
{/* General Pre-Notes */}
|
||||||
<Show when={props.generalPreNotes}>
|
<Show when={props.generalPreNotes && !isManagerVariant()}>
|
||||||
<div class="mb-6 p-4 bg-muted/30 rounded-xl border border-border/40 break-inside-avoid shadow-sm outline outline-1 outline-border/20">
|
<div class="mb-6 p-4 bg-muted/30 rounded-xl border border-border/40 break-inside-avoid shadow-sm outline outline-1 outline-border/20">
|
||||||
<div class="text-[11px] text-foreground/80 leading-relaxed font-medium italic">
|
<div class="text-[11px] text-foreground/80 leading-relaxed font-medium italic">
|
||||||
{props.generalPreNotes}
|
{props.generalPreNotes}
|
||||||
@@ -138,47 +175,154 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Scope Details Table */}
|
{/* Scope Details Table */}
|
||||||
<div class="mb-8 border-t-2 border-foreground">
|
<Show
|
||||||
<div class="grid grid-cols-12 gap-2 py-2 bg-muted/40 px-3 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/60">
|
when={isManagerVariant()}
|
||||||
<div class="col-span-2">Scope</div>
|
fallback={
|
||||||
<div class="col-span-8 pl-2 border-l border-border/60">Description</div>
|
<div class="mb-8 border-t-2 border-foreground">
|
||||||
<div class="col-span-2 text-right">Subtotal</div>
|
<div class="grid grid-cols-12 gap-2 py-2 bg-muted/40 px-3 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/60">
|
||||||
</div>
|
<div class="col-span-2">Scope</div>
|
||||||
<div class="divide-y divide-border/20">
|
<div class="col-span-8 pl-2 border-l border-border/60">Description</div>
|
||||||
<For each={props.scopes}>
|
<div class="col-span-2 text-right">Subtotal</div>
|
||||||
{(scope) => (
|
</div>
|
||||||
<div class="grid grid-cols-12 gap-2 items-start py-4 px-3 hover:bg-muted/10 transition-colors break-inside-avoid">
|
<div class="divide-y divide-border/20">
|
||||||
<div class="col-span-2">
|
<For each={props.scopes}>
|
||||||
<h4 class="text-xs font-black text-foreground uppercase leading-snug">{scope.name}</h4>
|
{(scope) => (
|
||||||
</div>
|
<div class="grid grid-cols-12 gap-2 items-start py-4 px-3 hover:bg-muted/10 transition-colors break-inside-avoid">
|
||||||
<div class="col-span-8 pl-2 border-l border-dashed border-border/60 text-foreground/80 text-xs leading-relaxed">
|
<div class="col-span-2">
|
||||||
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70">
|
<h4 class="text-xs font-black text-foreground uppercase leading-snug">{scope.name}</h4>
|
||||||
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
|
</div>
|
||||||
{(line) => {
|
<div class="col-span-8 pl-2 border-l border-dashed border-border/60 text-foreground/80 text-xs leading-relaxed">
|
||||||
let processed = line;
|
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70">
|
||||||
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
|
||||||
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
{(line) => {
|
||||||
if (processed.trim().startsWith('- ')) {
|
let processed = line;
|
||||||
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
|
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
}
|
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
||||||
// Handle empty lines more gracefully in compact mode
|
if (processed.trim().startsWith('- ')) {
|
||||||
if (!processed.trim()) return <div class="h-1"></div>;
|
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
|
||||||
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
|
}
|
||||||
}}
|
if (!processed.trim()) return <div class="h-1"></div>;
|
||||||
</For>
|
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 text-right font-black text-foreground text-sm">
|
||||||
|
${props.formatNumber(getScopeTotal(scope.id))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div class="col-span-2 text-right font-black text-foreground text-sm">
|
</For>
|
||||||
${props.formatNumber(getScopeTotal(scope.id))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
)}
|
>
|
||||||
|
<div class="mb-8 space-y-6">
|
||||||
|
<For each={props.scopes}>
|
||||||
|
{(scope) => {
|
||||||
|
const scopeItems = () => getScopeItems(scope.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class={`border border-border/60 rounded-2xl overflow-hidden ${isManagerVariant() ? '' : 'break-inside-avoid'}`}>
|
||||||
|
<div class="px-4 py-3 bg-muted/30 border-b border-border/60 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-black text-foreground uppercase">{scope.name}</h4>
|
||||||
|
<p class="text-[10px] text-muted-foreground font-bold uppercase tracking-widest mt-1">Scope Total</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-lg font-black text-foreground">${props.formatNumber(getManagerScopeVisibleTotal(scope.id))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-border/50 p-3 bg-white">
|
||||||
|
<div class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">Scope Description</div>
|
||||||
|
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70 text-xs text-foreground/90">
|
||||||
|
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
|
||||||
|
{(line) => {
|
||||||
|
let processed = line;
|
||||||
|
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
||||||
|
if (processed.trim().startsWith('- ')) {
|
||||||
|
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
|
||||||
|
}
|
||||||
|
if (!processed.trim()) return <div class="h-1"></div>;
|
||||||
|
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/50 p-3 bg-white">
|
||||||
|
<div class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">Estimator Notes</div>
|
||||||
|
<div class="text-xs text-foreground/90 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{scope.estimatorNotes?.trim() || 'No estimator notes provided.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-border/50 rounded-xl overflow-hidden">
|
||||||
|
<div class="grid grid-cols-12 gap-3 px-3 py-2 bg-muted/20 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/50">
|
||||||
|
<div class="col-span-2">Section</div>
|
||||||
|
<div class="col-span-6">Line Item</div>
|
||||||
|
<div class="col-span-1 text-right">Qty</div>
|
||||||
|
<div class="col-span-1 text-right">Unit</div>
|
||||||
|
<div class="col-span-2 text-right">Subtotal</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={scopeItems().scopeLevelItems.length > 0}>
|
||||||
|
<div class="border-b border-border/40">
|
||||||
|
<div class="px-3 py-2 bg-background text-[10px] font-black uppercase tracking-widest text-muted-foreground">Scope Level</div>
|
||||||
|
<For each={scopeItems().scopeLevelItems}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="grid grid-cols-12 gap-3 px-3 py-2 text-xs border-t border-border/20 items-start">
|
||||||
|
<div class="col-span-2 font-bold text-foreground">Scope Level</div>
|
||||||
|
<div class="col-span-6 text-foreground/90">{item.description}</div>
|
||||||
|
<div class="col-span-1 text-right text-foreground">{getItemPricing(item).effectiveQuantity}</div>
|
||||||
|
<div class="col-span-1 text-right text-foreground">${props.formatNumber(getItemPricing(item).effectiveUnitPrice)}</div>
|
||||||
|
<div class="col-span-2 text-right font-bold text-foreground">${props.formatNumber(getItemSubtotal(item))}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={scopeItems().scopeSubScopes}>
|
||||||
|
{(entry) => (
|
||||||
|
<div class="border-b last:border-b-0 border-border/40">
|
||||||
|
<div class="px-3 py-2 bg-background text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||||
|
{entry.subScope.name}
|
||||||
|
</div>
|
||||||
|
<For each={entry.items}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="grid grid-cols-12 gap-3 px-3 py-2 text-xs border-t border-border/20 items-start">
|
||||||
|
<div class="col-span-2 font-bold text-foreground">{entry.subScope.name}</div>
|
||||||
|
<div class="col-span-6 text-foreground/90">{item.description}</div>
|
||||||
|
<div class="col-span-1 text-right text-foreground">{getItemPricing(item).effectiveQuantity}</div>
|
||||||
|
<div class="col-span-1 text-right text-foreground">${props.formatNumber(getItemPricing(item).effectiveUnitPrice)}</div>
|
||||||
|
<div class="col-span-2 text-right font-bold text-foreground">${props.formatNumber(getItemSubtotal(item))}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={scopeItems().scopeLevelItems.length === 0 && scopeItems().scopeSubScopes.length === 0}>
|
||||||
|
<div class="px-3 py-4 text-xs text-muted-foreground italic">No line items assigned to this scope.</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
|
|
||||||
{/* General Estimate Notes */}
|
{/* General Estimate Notes */}
|
||||||
<Show when={props.generalEstimateNotes}>
|
<Show when={props.generalEstimateNotes && !isManagerVariant()}>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1 border-b border-border/40 pb-1">General Estimate Notes</h5>
|
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1 border-b border-border/40 pb-1">General Estimate Notes</h5>
|
||||||
<div class="text-[11px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
|
<div class="text-[11px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
|
||||||
@@ -188,51 +332,53 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Final Section: Totals & Signature aligned */}
|
{/* Final Section: Totals & Signature aligned */}
|
||||||
<div class="grid grid-cols-12 gap-8 items-end mb-8 break-inside-avoid">
|
<Show when={!isManagerVariant()}>
|
||||||
<div class="col-span-7">
|
<div class="grid grid-cols-12 gap-8 items-end mb-8 break-inside-avoid">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="col-span-7">
|
||||||
<div class="w-full flex items-end gap-2 pt-2">
|
<div class="flex flex-col gap-2">
|
||||||
<span class="text-lg font-serif text-muted-foreground/40 translate-y-1">X</span>
|
<div class="w-full flex items-end gap-2 pt-2">
|
||||||
<div class="flex-1 border-b border-foreground h-6"></div>
|
<span class="text-lg font-serif text-muted-foreground/40 translate-y-1">X</span>
|
||||||
|
<div class="flex-1 border-b border-foreground h-6"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start px-0.5">
|
||||||
|
<p class="text-[9px] text-muted-foreground/60 italic max-w-[280px] leading-tight">
|
||||||
|
A Work Order is then established and Cardoza Construction, LLC will proceed with work.
|
||||||
|
Valid for 30 days from original date.
|
||||||
|
</p>
|
||||||
|
<span class="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">Authorized Signature</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-start px-0.5">
|
</div>
|
||||||
<p class="text-[9px] text-muted-foreground/60 italic max-w-[280px] leading-tight">
|
|
||||||
A Work Order is then established and Cardoza Construction, LLC will proceed with work.
|
<div class="col-span-5 text-right space-y-1">
|
||||||
Valid for 30 days from original date.
|
<div class="flex justify-end items-center gap-6 p-4 bg-white text-foreground rounded-2xl mt-3 border-2 border-foreground shadow-sm">
|
||||||
</p>
|
<div class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60">Grand Total</div>
|
||||||
<span class="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">Authorized Signature</span>
|
<div class="text-3xl font-black tracking-tighter text-foreground">${props.formatNumber(props.grandTotals().grandTotal)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-5 text-right space-y-1">
|
{/* Terms & Legal Notices */}
|
||||||
<div class="flex justify-end items-center gap-6 p-4 bg-white text-foreground rounded-2xl mt-3 border-2 border-foreground shadow-sm">
|
<div class="mt-8 pt-6 border-t-2 border-foreground space-y-4">
|
||||||
<div class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60">Grand Total</div>
|
<div class="break-inside-avoid">
|
||||||
<div class="text-3xl font-black tracking-tighter text-foreground">${props.formatNumber(props.grandTotals().grandTotal)}</div>
|
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Terms & Conditions</h5>
|
||||||
|
<p class="text-[10px] font-bold text-foreground/70 leading-relaxed">
|
||||||
|
Unless otherwise agreed, credit approval and 50% down is required prior to scheduling/starting work.
|
||||||
|
(Credit card authorization may be substituted for jobs under $2,000)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="break-inside-avoid">
|
||||||
|
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Notice to Owner</h5>
|
||||||
|
<div class="text-[10px] font-bold text-foreground/60 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{NOTICE_TO_OWNERS.replace('NOTICE TO OWNER', '').trim()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Terms & Legal Notices */}
|
{/* Print Page Counter */}
|
||||||
<div class="mt-8 pt-6 border-t-2 border-foreground space-y-4">
|
<div class="print-page-number"></div>
|
||||||
<div class="break-inside-avoid">
|
</Show>
|
||||||
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Terms & Conditions</h5>
|
|
||||||
<p class="text-[10px] font-bold text-foreground/70 leading-relaxed">
|
|
||||||
Unless otherwise agreed, credit approval and 50% down is required prior to scheduling/starting work.
|
|
||||||
(Credit card authorization may be substituted for jobs under $2,000)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="break-inside-avoid">
|
|
||||||
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Notice to Owner</h5>
|
|
||||||
<div class="text-[10px] font-bold text-foreground/60 leading-relaxed whitespace-pre-wrap">
|
|
||||||
{NOTICE_TO_OWNERS.replace('NOTICE TO OWNER', '').trim()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Print Page Counter */}
|
|
||||||
<div class="print-page-number"></div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
|
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
|
||||||
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
|
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
|
||||||
import NumericInput from './ui/NumericInput';
|
import NumericInput from './ui/NumericInput';
|
||||||
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
|
import type { EstimateItem, Scope, SubScope, PricingType, ScopeTextTemplateType } from '../types';
|
||||||
import ItemRow from './ItemRow';
|
import ItemRow from './ItemRow';
|
||||||
import SubScopeCard from './SubScopeCard';
|
import SubScopeCard from './SubScopeCard';
|
||||||
import NoteEditor from './NoteEditor';
|
import NoteEditor from './NoteEditor';
|
||||||
@@ -10,6 +10,8 @@ import ScopeScaleWrapper from './ui/ScopeScaleWrapper';
|
|||||||
import { ModifierRegistry } from '../utils/modifier-registry';
|
import { ModifierRegistry } from '../utils/modifier-registry';
|
||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
import { SupplierDropdown } from './ui/SupplierDropdown';
|
import { SupplierDropdown } from './ui/SupplierDropdown';
|
||||||
|
import ScopeTextTemplateModal from './ScopeTextTemplateModal';
|
||||||
|
import { getItemsPricingTotals } from '../utils/pricing';
|
||||||
|
|
||||||
interface ScopeCardProps {
|
interface ScopeCardProps {
|
||||||
scope: Scope;
|
scope: Scope;
|
||||||
@@ -44,24 +46,34 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
const [bulkMarkup, setBulkMarkup] = createSignal(0);
|
const [bulkMarkup, setBulkMarkup] = createSignal(0);
|
||||||
const [bulkContingency, setBulkContingency] = createSignal(0);
|
const [bulkContingency, setBulkContingency] = createSignal(0);
|
||||||
const [isOver, setIsOver] = createSignal(false);
|
const [isOver, setIsOver] = createSignal(false);
|
||||||
|
const [activeTemplateType, setActiveTemplateType] = createSignal<ScopeTextTemplateType>('estimatorNotes');
|
||||||
|
const [isTemplateModalOpen, setIsTemplateModalOpen] = createSignal(false);
|
||||||
|
|
||||||
const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId));
|
const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId));
|
||||||
|
|
||||||
const totals = createMemo(() => {
|
const totals = createMemo(() => {
|
||||||
let subtotal = 0;
|
const scopeLevelTotals = getItemsPricingTotals(scopeItems(), []);
|
||||||
let markup = 0;
|
let subtotal = scopeLevelTotals.subtotal;
|
||||||
let contingency = 0;
|
let markup = scopeLevelTotals.markup;
|
||||||
let totalQuantity = 0;
|
let contingency = scopeLevelTotals.contingency;
|
||||||
|
let totalQuantity = scopeLevelTotals.quantity;
|
||||||
|
|
||||||
props.items.forEach(item => {
|
let modifiersTotal = 0;
|
||||||
const base = item.quantity * item.unitPrice;
|
props.subScopes.forEach(ss => {
|
||||||
subtotal += base;
|
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
|
||||||
totalQuantity += item.quantity;
|
const ssTotals = getItemsPricingTotals(ssItems, ss.modifiers || []);
|
||||||
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
subtotal += ssTotals.subtotal;
|
||||||
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
markup += ssTotals.markup;
|
||||||
|
contingency += ssTotals.contingency;
|
||||||
|
totalQuantity += ssTotals.quantity;
|
||||||
|
|
||||||
|
(ss.modifiers || []).forEach(m => {
|
||||||
|
if (!m.includeInTotal) return;
|
||||||
|
modifiersTotal += ModifierRegistry.calculate(m, ssTotals.total, ssItems);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Management and Delivery
|
// Add Management and Delivery after all scope and sub-scope item totals are accounted for.
|
||||||
const mgmtTotal = props.scope.useManagementLogic === 'percent'
|
const mgmtTotal = props.scope.useManagementLogic === 'percent'
|
||||||
? subtotal * (props.scope.managementFee / 100)
|
? subtotal * (props.scope.managementFee / 100)
|
||||||
: props.scope.managementFee;
|
: props.scope.managementFee;
|
||||||
@@ -70,22 +82,6 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
? subtotal * (props.scope.deliveryFee / 100)
|
? subtotal * (props.scope.deliveryFee / 100)
|
||||||
: props.scope.deliveryFee;
|
: props.scope.deliveryFee;
|
||||||
|
|
||||||
let modifiersTotal = 0;
|
|
||||||
props.subScopes.forEach(ss => {
|
|
||||||
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
|
|
||||||
const ssTotalWithMarkup = ssItems.reduce((sum, item) => {
|
|
||||||
const base = item.quantity * item.unitPrice;
|
|
||||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
|
||||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
|
||||||
return sum + base + markup + contingency;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
(ss.modifiers || []).forEach(m => {
|
|
||||||
if (!m.includeInTotal) return;
|
|
||||||
modifiersTotal += ModifierRegistry.calculate(m, ssTotalWithMarkup, ssItems);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subtotal,
|
subtotal,
|
||||||
markup,
|
markup,
|
||||||
@@ -116,6 +112,26 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
setIsOver(false);
|
setIsOver(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openTemplateModal = (type: ScopeTextTemplateType) => {
|
||||||
|
setActiveTemplateType(type);
|
||||||
|
setIsTemplateModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateModalCurrentValue = createMemo(() =>
|
||||||
|
activeTemplateType() === 'estimatorNotes'
|
||||||
|
? props.scope.estimatorNotes || ''
|
||||||
|
: props.scope.bidDescription || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyTemplateValue = (value: string) => {
|
||||||
|
if (activeTemplateType() === 'estimatorNotes') {
|
||||||
|
props.onUpdateScope(props.scope.id, { estimatorNotes: value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onUpdateScope(props.scope.id, { bidDescription: value });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -460,6 +476,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
onUpdate={(val) => props.onUpdateScope(props.scope.id, { estimatorNotes: val })}
|
onUpdate={(val) => props.onUpdateScope(props.scope.id, { estimatorNotes: val })}
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
highlight={props.scope.estimatorNotes?.includes('#')}
|
highlight={props.scope.estimatorNotes?.includes('#')}
|
||||||
|
headerActions={
|
||||||
|
<button
|
||||||
|
onClick={() => openTemplateModal('estimatorNotes')}
|
||||||
|
class="px-3 py-1 rounded-lg text-[10px] font-bold transition-all border text-muted-foreground hover:bg-muted border-border/40"
|
||||||
|
>
|
||||||
|
Template
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
label="Scope Description (Appears on Finale Estimate)"
|
label="Scope Description (Appears on Finale Estimate)"
|
||||||
@@ -467,6 +491,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
onUpdate={(val) => props.onUpdateScope(props.scope.id, { bidDescription: val })}
|
onUpdate={(val) => props.onUpdateScope(props.scope.id, { bidDescription: val })}
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
highlight={props.scope.bidDescription?.includes('#')}
|
highlight={props.scope.bidDescription?.includes('#')}
|
||||||
|
headerActions={
|
||||||
|
<button
|
||||||
|
onClick={() => openTemplateModal('bidDescription')}
|
||||||
|
class="px-3 py-1 rounded-lg text-[10px] font-bold transition-all border text-muted-foreground hover:bg-muted border-border/40"
|
||||||
|
>
|
||||||
|
Template
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -504,6 +536,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<ScopeTextTemplateModal
|
||||||
|
show={isTemplateModalOpen()}
|
||||||
|
templateType={activeTemplateType()}
|
||||||
|
currentValue={templateModalCurrentValue()}
|
||||||
|
onApply={applyTemplateValue}
|
||||||
|
onClose={() => setIsTemplateModalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { createEffect, createMemo, createSignal, For, Show, type Component } from 'solid-js';
|
||||||
|
import { Portal } from 'solid-js/web';
|
||||||
|
import { CheckCircle2, CopyPlus, History, PencilLine, Search, Trash2, User, X } from 'lucide-solid';
|
||||||
|
|
||||||
|
import type { ScopeTextTemplate, ScopeTextTemplateHistoryEntry, ScopeTextTemplateType } from '../types';
|
||||||
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
|
import { createScopeTextDiff } from '../utils/scope-text-template-history';
|
||||||
|
|
||||||
|
interface ScopeTextTemplateModalProps {
|
||||||
|
show: boolean;
|
||||||
|
templateType: ScopeTextTemplateType;
|
||||||
|
currentValue: string;
|
||||||
|
onApply: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_LABELS: Record<ScopeTextTemplateType, string> = {
|
||||||
|
estimatorNotes: "Estimator's Notes",
|
||||||
|
bidDescription: 'Scope Description'
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentUserLabel = () =>
|
||||||
|
pb.authStore.model?.name || pb.authStore.model?.email || 'Unknown';
|
||||||
|
|
||||||
|
const ScopeTextTemplateModal: Component<ScopeTextTemplateModalProps> = (props) => {
|
||||||
|
const [templates, setTemplates] = createSignal<ScopeTextTemplate[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
|
const [isSaving, setIsSaving] = createSignal(false);
|
||||||
|
const [updatingTemplateId, setUpdatingTemplateId] = createSignal<string | null>(null);
|
||||||
|
const [deletingTemplateId, setDeletingTemplateId] = createSignal<string | null>(null);
|
||||||
|
const [expandedHistoryId, setExpandedHistoryId] = createSignal<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('');
|
||||||
|
const [newTemplateName, setNewTemplateName] = createSignal('');
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.show) {
|
||||||
|
void loadTemplates();
|
||||||
|
setSearchQuery('');
|
||||||
|
setNewTemplateName('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const records = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).getFullList<any>({
|
||||||
|
sort: '-updated'
|
||||||
|
});
|
||||||
|
setTemplates(records.map((record) => ({
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
type: record.type,
|
||||||
|
content: record.content,
|
||||||
|
lastUpdatedBy: record.lastUpdatedBy,
|
||||||
|
history: Array.isArray(record.history) ? record.history : [],
|
||||||
|
createdAt: record.created,
|
||||||
|
updatedAt: record.updated
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load scope text templates:', error);
|
||||||
|
alert('Failed to load scope text templates.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTemplates = createMemo(() => {
|
||||||
|
const query = searchQuery().trim().toLowerCase();
|
||||||
|
return templates().filter(template => {
|
||||||
|
if (template.type !== props.templateType) return false;
|
||||||
|
if (!query) return true;
|
||||||
|
return (
|
||||||
|
template.name.toLowerCase().includes(query) ||
|
||||||
|
template.content.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveCurrentAsTemplate = async () => {
|
||||||
|
const name = newTemplateName().trim();
|
||||||
|
const content = props.currentValue.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('Enter a template name first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
alert(`Add ${TEMPLATE_LABELS[props.templateType]} content before saving a template.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const userLabel = getCurrentUserLabel();
|
||||||
|
const historyEntry: ScopeTextTemplateHistoryEntry = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: now,
|
||||||
|
updatedBy: userLabel,
|
||||||
|
action: 'created',
|
||||||
|
previousContent: '',
|
||||||
|
nextContent: props.currentValue,
|
||||||
|
diff: createScopeTextDiff('', props.currentValue)
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).create<any>({
|
||||||
|
name,
|
||||||
|
type: props.templateType,
|
||||||
|
content: props.currentValue,
|
||||||
|
lastUpdatedBy: userLabel,
|
||||||
|
history: [historyEntry]
|
||||||
|
});
|
||||||
|
setTemplates(prev => [{
|
||||||
|
id: created.id,
|
||||||
|
name: created.name,
|
||||||
|
type: created.type,
|
||||||
|
content: created.content,
|
||||||
|
lastUpdatedBy: created.lastUpdatedBy,
|
||||||
|
history: Array.isArray(created.history) ? created.history : [historyEntry],
|
||||||
|
createdAt: created.created,
|
||||||
|
updatedAt: created.updated
|
||||||
|
}, ...prev]);
|
||||||
|
setNewTemplateName('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save scope text template:', error);
|
||||||
|
alert('Failed to save scope text template.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTemplateFromCurrent = async (template: ScopeTextTemplate) => {
|
||||||
|
if (template.content === props.currentValue) {
|
||||||
|
alert('Current text matches this template already.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLabel = getCurrentUserLabel();
|
||||||
|
const historyEntry: ScopeTextTemplateHistoryEntry = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
updatedBy: userLabel,
|
||||||
|
action: 'updated',
|
||||||
|
previousContent: template.content,
|
||||||
|
nextContent: props.currentValue,
|
||||||
|
diff: createScopeTextDiff(template.content, props.currentValue)
|
||||||
|
};
|
||||||
|
|
||||||
|
setUpdatingTemplateId(template.id);
|
||||||
|
try {
|
||||||
|
const updated = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).update<any>(template.id, {
|
||||||
|
content: props.currentValue,
|
||||||
|
lastUpdatedBy: userLabel,
|
||||||
|
history: [...(template.history || []), historyEntry]
|
||||||
|
});
|
||||||
|
|
||||||
|
setTemplates(prev => prev
|
||||||
|
.map((entry) => entry.id === template.id
|
||||||
|
? {
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
type: updated.type,
|
||||||
|
content: updated.content,
|
||||||
|
lastUpdatedBy: updated.lastUpdatedBy,
|
||||||
|
history: Array.isArray(updated.history) ? updated.history : [...(template.history || []), historyEntry],
|
||||||
|
createdAt: updated.created,
|
||||||
|
updatedAt: updated.updated
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update scope text template:', error);
|
||||||
|
alert('Failed to update scope text template.');
|
||||||
|
} finally {
|
||||||
|
setUpdatingTemplateId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
const target = templates().find(template => template.id === id);
|
||||||
|
if (!target) return;
|
||||||
|
if (!window.confirm(`Delete template "${target.name}"?`)) return;
|
||||||
|
setDeletingTemplateId(id);
|
||||||
|
try {
|
||||||
|
await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).delete(id);
|
||||||
|
setTemplates(prev => prev.filter(template => template.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete scope text template:', error);
|
||||||
|
alert('Failed to delete scope text template.');
|
||||||
|
} finally {
|
||||||
|
setDeletingTemplateId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.show}>
|
||||||
|
<Portal>
|
||||||
|
<div class="fixed inset-0 z-[110] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div class="bg-card w-full max-w-3xl max-h-[85vh] flex flex-col rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
|
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-black text-foreground">{TEMPLATE_LABELS[props.templateType]} Templates</h3>
|
||||||
|
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-1">Save reusable text and apply it to this scope</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={props.onClose} class="p-2 hover:bg-muted rounded-xl transition-colors">
|
||||||
|
<X class="w-5 h-5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-6 overflow-y-auto">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-3 items-end">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Save Current Text as Template</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTemplateName()}
|
||||||
|
onInput={(e) => setNewTemplateName(e.currentTarget.value)}
|
||||||
|
placeholder="Template name"
|
||||||
|
class="w-full bg-muted/20 border border-border/60 px-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={saveCurrentAsTemplate}
|
||||||
|
disabled={isSaving()}
|
||||||
|
class="flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-2xl text-sm font-bold hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<CopyPlus class="w-4 h-4" />
|
||||||
|
{isSaving() ? 'Saving...' : 'Save Template'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Saved Templates</label>
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
placeholder="Search templates"
|
||||||
|
class="w-full bg-muted/20 border border-border/60 pl-11 pr-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Show
|
||||||
|
when={!isLoading()}
|
||||||
|
fallback={
|
||||||
|
<div class="py-12 text-center text-muted-foreground text-sm font-medium">
|
||||||
|
Loading templates...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={filteredTemplates()}>
|
||||||
|
{(template) => (
|
||||||
|
<div class="rounded-2xl border border-border/60 bg-muted/10 p-4 space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-foreground">{template.name}</h4>
|
||||||
|
<p class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1">
|
||||||
|
Updated {new Date(template.updatedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<Show when={template.lastUpdatedBy}>
|
||||||
|
<div class="flex items-center gap-1.5 mt-1.5">
|
||||||
|
<User class="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span class="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||||
|
Last updated by {template.lastUpdatedBy}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
props.onApply(template.content);
|
||||||
|
props.onClose();
|
||||||
|
}}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-xl text-xs font-bold hover:bg-primary hover:text-primary-foreground transition-all"
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="w-4 h-4" />
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void updateTemplateFromCurrent(template)}
|
||||||
|
disabled={updatingTemplateId() === template.id}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-amber-50 text-amber-700 border border-amber-200 rounded-xl text-xs font-bold hover:bg-amber-100 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PencilLine class="w-4 h-4" />
|
||||||
|
{updatingTemplateId() === template.id ? 'Updating...' : 'Update'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedHistoryId(expandedHistoryId() === template.id ? null : template.id)}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-muted/40 text-muted-foreground border border-border rounded-xl text-xs font-bold hover:bg-muted transition-all"
|
||||||
|
>
|
||||||
|
<History class="w-4 h-4" />
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleDelete(template.id)}
|
||||||
|
disabled={deletingTemplateId() === template.id}
|
||||||
|
class="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-xl transition-colors disabled:opacity-50"
|
||||||
|
aria-label={`Delete ${template.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="whitespace-pre-wrap break-words text-sm text-foreground/85 bg-background/80 rounded-xl border border-border/40 p-3 font-sans">{template.content}</pre>
|
||||||
|
<Show when={expandedHistoryId() === template.id}>
|
||||||
|
<div class="space-y-3 border-t border-border/50 pt-4">
|
||||||
|
<div class="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||||
|
Edit History
|
||||||
|
</div>
|
||||||
|
<For each={[...(template.history || [])].slice().reverse()}>
|
||||||
|
{(entry) => (
|
||||||
|
<div class="rounded-xl border border-border/50 bg-background/70 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-border/40 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-bold text-foreground">
|
||||||
|
{entry.action === 'created' ? 'Created' : 'Updated'}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mt-1">
|
||||||
|
{new Date(entry.timestamp).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 text-[10px] uppercase tracking-tight font-bold text-muted-foreground">
|
||||||
|
<User class="w-3 h-3" />
|
||||||
|
{entry.updatedBy}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-1 bg-background/80">
|
||||||
|
<For each={entry.diff}>
|
||||||
|
{(line) => (
|
||||||
|
<Show when={line.op !== 'equal'}>
|
||||||
|
<div class={`font-mono text-xs whitespace-pre-wrap break-words px-3 py-1.5 rounded-lg ${
|
||||||
|
line.op === 'add'
|
||||||
|
? 'bg-green-50 text-green-700'
|
||||||
|
: 'bg-red-50 text-red-700'
|
||||||
|
}`}>
|
||||||
|
<span class="mr-2 font-bold">{line.op === 'add' ? '+' : '-'}</span>
|
||||||
|
{line.text || ' '}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={!entry.diff.some(line => line.op !== 'equal')}>
|
||||||
|
<div class="text-xs text-muted-foreground italic">
|
||||||
|
No line changes captured.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={(template.history || []).length === 0}>
|
||||||
|
<div class="text-xs text-muted-foreground italic">
|
||||||
|
No history recorded yet.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={filteredTemplates().length === 0}>
|
||||||
|
<div class="py-12 flex flex-col items-center justify-center text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-2xl bg-background/50">
|
||||||
|
<p class="text-sm font-medium">No {TEMPLATE_LABELS[props.templateType].toLowerCase()} templates found.</p>
|
||||||
|
<p class="text-xs mt-1">Save the current text to start reusing it across scopes.</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScopeTextTemplateModal;
|
||||||
@@ -6,6 +6,7 @@ import LazyItem from './LazyItem';
|
|||||||
import ModifierRow from './ModifierRow';
|
import ModifierRow from './ModifierRow';
|
||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
||||||
|
import { getItemsPricingTotals } from '../utils/pricing';
|
||||||
|
|
||||||
interface SubScopeCardProps {
|
interface SubScopeCardProps {
|
||||||
subScope: SubScope;
|
subScope: SubScope;
|
||||||
@@ -36,28 +37,20 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
|||||||
const subScopeItems = () => props.items.filter(item => item.subScopeId === props.subScope.id);
|
const subScopeItems = () => props.items.filter(item => item.subScopeId === props.subScope.id);
|
||||||
|
|
||||||
const totals = createMemo(() => {
|
const totals = createMemo(() => {
|
||||||
const itemTotals = props.items.reduce((acc, item) => {
|
|
||||||
const base = item.quantity * item.unitPrice;
|
|
||||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
|
||||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
|
||||||
return {
|
|
||||||
amount: acc.amount + base + markup + contingency,
|
|
||||||
quantity: acc.quantity + item.quantity
|
|
||||||
};
|
|
||||||
}, { amount: 0, quantity: 0 });
|
|
||||||
|
|
||||||
let modifiedAmount = itemTotals.amount;
|
|
||||||
const modifiers = props.subScope.modifiers || [];
|
const modifiers = props.subScope.modifiers || [];
|
||||||
|
const itemTotals = getItemsPricingTotals(props.items, modifiers);
|
||||||
|
|
||||||
|
let modifiedAmount = itemTotals.total;
|
||||||
|
|
||||||
modifiers.forEach(m => {
|
modifiers.forEach(m => {
|
||||||
if (!m.includeInTotal) return;
|
if (!m.includeInTotal) return;
|
||||||
modifiedAmount += ModifierRegistry.calculate(m, itemTotals.amount, subScopeItems());
|
modifiedAmount += ModifierRegistry.calculate(m, itemTotals.total, subScopeItems());
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
amount: modifiedAmount,
|
amount: modifiedAmount,
|
||||||
quantity: itemTotals.quantity,
|
quantity: itemTotals.quantity,
|
||||||
baseAmount: itemTotals.amount
|
baseAmount: itemTotals.total
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,6 +213,7 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
|||||||
onToggleSelection={props.onToggleSelection}
|
onToggleSelection={props.onToggleSelection}
|
||||||
hideColumns={props.hideColumns}
|
hideColumns={props.hideColumns}
|
||||||
activeSupplier={props.scopeSupplier}
|
activeSupplier={props.scopeSupplier}
|
||||||
|
subScopeModifiers={props.subScope.modifiers}
|
||||||
/>
|
/>
|
||||||
</LazyItem>
|
</LazyItem>
|
||||||
)}
|
)}
|
||||||
@@ -236,7 +230,7 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
|||||||
{(modifier) => (
|
{(modifier) => (
|
||||||
<ModifierRow
|
<ModifierRow
|
||||||
modifier={modifier}
|
modifier={modifier}
|
||||||
calculatedValue={ModifierRegistry.calculateDisplay(modifier, totals().baseAmount, subScopeItems())}
|
calculatedValue={ModifierRegistry.calculateDisplay(modifier, totals().baseAmount, subScopeItems(), props.subScope.modifiers || [])}
|
||||||
onUpdate={(id, updates) => appStore.updateModifier(props.subScope.id, id, updates)}
|
onUpdate={(id, updates) => appStore.updateModifier(props.subScope.id, id, updates)}
|
||||||
onDelete={(id) => appStore.removeModifier(props.subScope.id, id)}
|
onDelete={(id) => appStore.removeModifier(props.subScope.id, id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSignal, onCleanup, Show } from 'solid-js';
|
import { createSignal, onCleanup, Show } from 'solid-js';
|
||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal } from 'lucide-solid';
|
import { Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal, FileSpreadsheet } from 'lucide-solid';
|
||||||
import { appStore } from '../../store/appStore';
|
import { appStore } from '../../store/appStore';
|
||||||
|
|
||||||
interface BuilderHeaderProps {
|
interface BuilderHeaderProps {
|
||||||
@@ -8,6 +8,7 @@ interface BuilderHeaderProps {
|
|||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
onAddScope: () => void;
|
onAddScope: () => void;
|
||||||
onShowNewEstimateModal: () => void;
|
onShowNewEstimateModal: () => void;
|
||||||
|
onImportCsvQuantities: (e: Event) => void;
|
||||||
onImportEstimate: (e: Event) => void;
|
onImportEstimate: (e: Event) => void;
|
||||||
onShowExportTableModal: () => void;
|
onShowExportTableModal: () => void;
|
||||||
onExportEstimate: () => void;
|
onExportEstimate: () => void;
|
||||||
@@ -68,6 +69,10 @@ export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
|
|||||||
<div class="absolute right-0 mt-2 w-56 bg-card border border-border rounded-2xl shadow-premium z-[60] overflow-hidden animate-in fade-in zoom-in-95 duration-150 origin-top-right">
|
<div class="absolute right-0 mt-2 w-56 bg-card border border-border rounded-2xl shadow-premium z-[60] overflow-hidden animate-in fade-in zoom-in-95 duration-150 origin-top-right">
|
||||||
<div class="p-2 space-y-1">
|
<div class="p-2 space-y-1">
|
||||||
<p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 px-3 py-2">Data Exchange</p>
|
<p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 px-3 py-2">Data Exchange</p>
|
||||||
|
<label class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl cursor-pointer transition-colors w-full">
|
||||||
|
<FileSpreadsheet class="w-4 h-4 text-emerald-500" /> Import CSV Quantities
|
||||||
|
<input type="file" accept=".csv" class="hidden" onChange={(e) => { props.onImportCsvQuantities(e); setShowActions(false); }} />
|
||||||
|
</label>
|
||||||
<label class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl cursor-pointer transition-colors w-full">
|
<label class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl cursor-pointer transition-colors w-full">
|
||||||
<Upload class="w-4 h-4 text-blue-500" /> Import JSON
|
<Upload class="w-4 h-4 text-blue-500" /> Import JSON
|
||||||
<input type="file" accept=".json" class="hidden" onChange={(e) => { props.onImportEstimate(e); setShowActions(false); }} />
|
<input type="file" accept=".json" class="hidden" onChange={(e) => { props.onImportEstimate(e); setShowActions(false); }} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ExecutiveSummaryProps {
|
|||||||
grandTotal: number;
|
grandTotal: number;
|
||||||
};
|
};
|
||||||
onFinalizeBid: () => void;
|
onFinalizeBid: () => void;
|
||||||
|
onExportManagerInfo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
|
export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
|
||||||
@@ -68,12 +69,20 @@ export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
|
|||||||
{formatNumber(props.totals.grandTotal)}
|
{formatNumber(props.totals.grandTotal)}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<button
|
<div class="space-y-3">
|
||||||
onClick={props.onFinalizeBid}
|
<button
|
||||||
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-95 flex items-center justify-center gap-3"
|
onClick={props.onFinalizeBid}
|
||||||
>
|
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-95 flex items-center justify-center gap-3"
|
||||||
<Plus class="w-5 h-5" /> Finalize Bid
|
>
|
||||||
</button>
|
<Plus class="w-5 h-5" /> Finalize Bid
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={props.onExportManagerInfo}
|
||||||
|
class="w-full py-4 bg-muted hover:bg-muted/80 text-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all border border-border active:scale-95 flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<Info class="w-5 h-5" /> Manager Info
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createMemo } from 'solid-js';
|
|||||||
import type { Accessor } from 'solid-js';
|
import type { Accessor } from 'solid-js';
|
||||||
import { appStore } from '../../store/appStore';
|
import { appStore } from '../../store/appStore';
|
||||||
import { ModifierRegistry } from '../../utils/modifier-registry';
|
import { ModifierRegistry } from '../../utils/modifier-registry';
|
||||||
|
import { getEffectiveItemPricing, getItemsPricingTotals } from '../../utils/pricing';
|
||||||
|
|
||||||
export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
||||||
const items = appStore.estimateItems;
|
const items = appStore.estimateItems;
|
||||||
@@ -15,15 +16,17 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
|||||||
let contingency = 0;
|
let contingency = 0;
|
||||||
let fees = 0;
|
let fees = 0;
|
||||||
const scopeSubtotals = new Map<string, number>();
|
const scopeSubtotals = new Map<string, number>();
|
||||||
|
const subScopes = appStore.subScopes;
|
||||||
|
const subScopeById = new Map(subScopes.map(subScope => [subScope.id, subScope]));
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const base = item.quantity * item.unitPrice;
|
const pricing = getEffectiveItemPricing(item, item.subScopeId ? (subScopeById.get(item.subScopeId)?.modifiers || []) : []);
|
||||||
subtotal += base;
|
subtotal += pricing.effectiveSubtotal;
|
||||||
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
markup += pricing.markupAmount;
|
||||||
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
contingency += pricing.contingencyAmount;
|
||||||
|
|
||||||
if (item.scopeId) {
|
if (item.scopeId) {
|
||||||
scopeSubtotals.set(item.scopeId, (scopeSubtotals.get(item.scopeId) || 0) + base);
|
scopeSubtotals.set(item.scopeId, (scopeSubtotals.get(item.scopeId) || 0) + pricing.effectiveSubtotal);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,17 +43,10 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let modifiersTotal = 0;
|
let modifiersTotal = 0;
|
||||||
const subScopes = appStore.subScopes;
|
|
||||||
|
|
||||||
subScopes.forEach(subScope => {
|
subScopes.forEach(subScope => {
|
||||||
const scopeId = subScope.id;
|
const subScopeItems = items.filter(i => i.subScopeId === subScope.id);
|
||||||
const subScopeItems = items.filter(i => i.subScopeId === scopeId); // Wait, subScope.id is compared to item.subScopeId
|
const subScopeTotalWithMarkup = getItemsPricingTotals(subScopeItems, subScope.modifiers || []).total;
|
||||||
const subScopeTotalWithMarkup = subScopeItems.reduce((sum, item) => {
|
|
||||||
const base = item.quantity * item.unitPrice;
|
|
||||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
|
||||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
|
||||||
return sum + base + markup + contingency;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
(subScope.modifiers || []).forEach(m => {
|
(subScope.modifiers || []).forEach(m => {
|
||||||
if (!m.includeInTotal) return;
|
if (!m.includeInTotal) return;
|
||||||
@@ -86,19 +82,18 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
|||||||
|
|
||||||
const selectionTotals = createMemo(() => {
|
const selectionTotals = createMemo(() => {
|
||||||
const selected = items.filter(i => selectedIds().includes(i.id));
|
const selected = items.filter(i => selectedIds().includes(i.id));
|
||||||
|
const subScopeById = new Map(appStore.subScopes.map(subScope => [subScope.id, subScope]));
|
||||||
let qty = 0, price = 0, sub = 0, mark = 0, cont = 0, tot = 0;
|
let qty = 0, price = 0, sub = 0, mark = 0, cont = 0, tot = 0;
|
||||||
|
|
||||||
selected.forEach(item => {
|
selected.forEach(item => {
|
||||||
const base = item.quantity * item.unitPrice;
|
const pricing = getEffectiveItemPricing(item, item.subScopeId ? (subScopeById.get(item.subScopeId)?.modifiers || []) : []);
|
||||||
const markupAmt = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
|
||||||
const contingencyAmt = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
|
||||||
|
|
||||||
qty += item.quantity;
|
qty += pricing.effectiveQuantity;
|
||||||
price += item.unitPrice;
|
price += pricing.effectiveUnitPrice;
|
||||||
sub += base;
|
sub += pricing.effectiveSubtotal;
|
||||||
mark += markupAmt;
|
mark += pricing.markupAmount;
|
||||||
cont += contingencyAmt;
|
cont += pricing.contingencyAmount;
|
||||||
tot += base + markupAmt + contingencyAmt;
|
tot += pricing.total;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
/* Import Standard Shadcn Font (Inter) */
|
/* Import Standard Shadcn Font (Inter) */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Clean, single-font family for a modern SaaS look */
|
/* Clean, single-font family for a modern SaaS look */
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|||||||
+12
-4
@@ -84,8 +84,12 @@ export const appStore = {
|
|||||||
value: module.defaultValue,
|
value: module.defaultValue,
|
||||||
valueType: module.defaultValueType,
|
valueType: module.defaultValueType,
|
||||||
unitLabel: module.defaultUnitLabel,
|
unitLabel: module.defaultUnitLabel,
|
||||||
type: module.id as any, // Temporary cast
|
type: module.id as Modifier['type'],
|
||||||
includeInTotal: module.id !== 'man-hours'
|
includeInTotal: module.id !== 'man-hours',
|
||||||
|
presetId: module.defaultPresetId,
|
||||||
|
targetField: module.defaultTargetField,
|
||||||
|
expression: module.defaultExpression,
|
||||||
|
parameters: module.defaultParameters ? { ...module.defaultParameters } : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubScopes(s => s.id === subScopeId, produce((ss) => {
|
setSubScopes(s => s.id === subScopeId, produce((ss) => {
|
||||||
@@ -130,8 +134,12 @@ export const appStore = {
|
|||||||
value: module.defaultValue,
|
value: module.defaultValue,
|
||||||
valueType: module.defaultValueType,
|
valueType: module.defaultValueType,
|
||||||
unitLabel: module.defaultUnitLabel,
|
unitLabel: module.defaultUnitLabel,
|
||||||
type: module.id as any,
|
type: module.id as Modifier['type'],
|
||||||
includeInTotal: module.id !== 'man-hours'
|
includeInTotal: module.id !== 'man-hours',
|
||||||
|
presetId: module.defaultPresetId,
|
||||||
|
targetField: module.defaultTargetField,
|
||||||
|
expression: module.defaultExpression,
|
||||||
|
parameters: module.defaultParameters ? { ...module.defaultParameters } : undefined
|
||||||
};
|
};
|
||||||
setActiveTemplateModifiers(prev => [...prev, newModifier]);
|
setActiveTemplateModifiers(prev => [...prev, newModifier]);
|
||||||
},
|
},
|
||||||
|
|||||||
+37
-1
@@ -16,7 +16,9 @@ export interface Scope {
|
|||||||
supplier?: string;
|
supplier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModifierType = 'tax' | 'man-hours' | 'custom';
|
export type ModifierType = 'tax' | 'man-hours' | 'adjustment' | 'computed-field' | 'prevailing-wage' | 'custom-calculation' | 'custom';
|
||||||
|
export type ModifierPresetId = 'prevailing-wage' | 'custom';
|
||||||
|
export type ModifierTargetField = 'unitPrice' | 'quantity' | 'subtotal';
|
||||||
|
|
||||||
export interface Modifier {
|
export interface Modifier {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +29,10 @@ export interface Modifier {
|
|||||||
type: ModifierType;
|
type: ModifierType;
|
||||||
unitLabel?: string; // e.g. "hrs" for man-hours
|
unitLabel?: string; // e.g. "hrs" for man-hours
|
||||||
includeInTotal: boolean;
|
includeInTotal: boolean;
|
||||||
|
presetId?: ModifierPresetId;
|
||||||
|
targetField?: ModifierTargetField;
|
||||||
|
expression?: string;
|
||||||
|
parameters?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubScope {
|
export interface SubScope {
|
||||||
@@ -77,6 +83,36 @@ export interface Template {
|
|||||||
defaultSubScopeName?: string;
|
defaultSubScopeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScopeTextTemplateType = 'estimatorNotes' | 'bidDescription';
|
||||||
|
|
||||||
|
export type ScopeTextTemplateDiffOp = 'equal' | 'add' | 'remove';
|
||||||
|
|
||||||
|
export interface ScopeTextTemplateDiffLine {
|
||||||
|
op: ScopeTextTemplateDiffOp;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopeTextTemplateHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
updatedBy: string;
|
||||||
|
action: 'created' | 'updated';
|
||||||
|
previousContent: string;
|
||||||
|
nextContent: string;
|
||||||
|
diff: ScopeTextTemplateDiffLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopeTextTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ScopeTextTemplateType;
|
||||||
|
content: string;
|
||||||
|
lastUpdatedBy?: string;
|
||||||
|
history?: ScopeTextTemplateHistoryEntry[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StandardPrice {
|
export interface StandardPrice {
|
||||||
id: string;
|
id: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
|||||||
|
|
||||||
export const COLLECTIONS = {
|
export const COLLECTIONS = {
|
||||||
TEMPLATES: 'EstiMaker_Templates',
|
TEMPLATES: 'EstiMaker_Templates',
|
||||||
|
SCOPE_TEXT_TEMPLATES: 'EstiMaker_ScopeTextTemplates',
|
||||||
ESTIMATES: 'EstiMaker_Estimates',
|
ESTIMATES: 'EstiMaker_Estimates',
|
||||||
STANDARD_PRICES: 'EstiMaker_StandardPrices'
|
STANDARD_PRICES: 'EstiMaker_StandardPrices'
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { Modifier, EstimateItem } from '../types';
|
import type { Modifier, EstimateItem, ModifierTargetField } from '../types';
|
||||||
|
import {
|
||||||
|
CUSTOM_CALC_DEFAULT_EXPRESSION,
|
||||||
|
getComputedModifierStatus,
|
||||||
|
getEffectiveItemPricing,
|
||||||
|
PREVAILING_WAGE_DEFAULT_EXPRESSION,
|
||||||
|
PREVAILING_WAGE_DEFAULT_PARAMETERS,
|
||||||
|
resolveModifierModuleId
|
||||||
|
} from './pricing';
|
||||||
|
|
||||||
export interface ModifierModule {
|
export interface ModifierModule {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,12 +16,16 @@ export interface ModifierModule {
|
|||||||
defaultValue: number;
|
defaultValue: number;
|
||||||
defaultValueType: 'percent' | 'amount' | 'unit';
|
defaultValueType: 'percent' | 'amount' | 'unit';
|
||||||
defaultUnitLabel?: string;
|
defaultUnitLabel?: string;
|
||||||
|
defaultPresetId?: 'prevailing-wage' | 'custom';
|
||||||
|
defaultTargetField?: ModifierTargetField;
|
||||||
|
defaultExpression?: string;
|
||||||
|
defaultParameters?: Record<string, number>;
|
||||||
calculate: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[]) => number;
|
calculate: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[]) => number;
|
||||||
calculateDisplay?: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[]) => number;
|
calculateDisplay?: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[], subScopeModifiers?: Modifier[]) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPreMarkupSubtotal = (items: EstimateItem[]) => {
|
const getPreMarkupSubtotal = (items: EstimateItem[], modifiers: Modifier[] = []) => {
|
||||||
return items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
|
return items.reduce((sum, item) => sum + getEffectiveItemPricing(item, modifiers).effectiveSubtotal, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaxModule: ModifierModule = {
|
const TaxModule: ModifierModule = {
|
||||||
@@ -38,10 +50,10 @@ const ManHoursModule: ModifierModule = {
|
|||||||
defaultValueType: 'unit',
|
defaultValueType: 'unit',
|
||||||
defaultUnitLabel: 'factor',
|
defaultUnitLabel: 'factor',
|
||||||
calculate: () => 0,
|
calculate: () => 0,
|
||||||
calculateDisplay: (m, _, items) => {
|
calculateDisplay: (m, _, items, subScopeModifiers = []) => {
|
||||||
const factor = m.value || 1;
|
const factor = m.value || 1;
|
||||||
if (factor === 0) return 0;
|
if (factor === 0) return 0;
|
||||||
const subtotal = getPreMarkupSubtotal(items);
|
const subtotal = getPreMarkupSubtotal(items, subScopeModifiers);
|
||||||
return subtotal / factor;
|
return subtotal / factor;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -59,23 +71,62 @@ const SimpleAdjustmentModule: ModifierModule = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PrevailingWageModule: ModifierModule = {
|
||||||
|
id: 'prevailing-wage',
|
||||||
|
name: 'Prevailing Wage',
|
||||||
|
description: 'Scales each item price by the prevailing wage multiplier.',
|
||||||
|
defaultLabel: 'Prevailing Wage',
|
||||||
|
defaultValue: 0,
|
||||||
|
defaultValueType: 'unit',
|
||||||
|
defaultPresetId: 'prevailing-wage',
|
||||||
|
defaultTargetField: 'unitPrice',
|
||||||
|
defaultExpression: PREVAILING_WAGE_DEFAULT_EXPRESSION,
|
||||||
|
defaultParameters: { ...PREVAILING_WAGE_DEFAULT_PARAMETERS },
|
||||||
|
calculate: () => 0,
|
||||||
|
calculateDisplay: (modifier) => getComputedModifierStatus(modifier).multiplier || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomCalculationModule: ModifierModule = {
|
||||||
|
id: 'custom-calculation',
|
||||||
|
name: 'Custom Math',
|
||||||
|
description: 'Applies a custom expression using price, quantity, and subtotal variables.',
|
||||||
|
defaultLabel: 'Custom Math',
|
||||||
|
defaultValue: 0,
|
||||||
|
defaultValueType: 'unit',
|
||||||
|
defaultPresetId: 'custom',
|
||||||
|
defaultTargetField: 'unitPrice',
|
||||||
|
defaultExpression: CUSTOM_CALC_DEFAULT_EXPRESSION,
|
||||||
|
calculate: () => 0,
|
||||||
|
calculateDisplay: () => 0
|
||||||
|
};
|
||||||
|
|
||||||
export const MODIFIER_MODULES: ModifierModule[] = [
|
export const MODIFIER_MODULES: ModifierModule[] = [
|
||||||
TaxModule,
|
TaxModule,
|
||||||
ManHoursModule,
|
ManHoursModule,
|
||||||
SimpleAdjustmentModule
|
SimpleAdjustmentModule,
|
||||||
|
PrevailingWageModule,
|
||||||
|
CustomCalculationModule
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const getModuleByResolvedId = (id: string) => MODIFIER_MODULES.find(m => m.id === id) || SimpleAdjustmentModule;
|
||||||
|
|
||||||
export const ModifierRegistry = {
|
export const ModifierRegistry = {
|
||||||
getModule: (id: string) => MODIFIER_MODULES.find(m => m.id === id) || SimpleAdjustmentModule,
|
getModule: (id: string, modifier?: Modifier) => {
|
||||||
|
if (id === 'computed-field' && modifier) {
|
||||||
|
return getModuleByResolvedId(resolveModifierModuleId(modifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
return getModuleByResolvedId(id);
|
||||||
|
},
|
||||||
|
|
||||||
calculate: (modifier: Modifier, baseTotal: number, items: EstimateItem[]) => {
|
calculate: (modifier: Modifier, baseTotal: number, items: EstimateItem[]) => {
|
||||||
const module = ModifierRegistry.getModule(modifier.moduleId);
|
const module = ModifierRegistry.getModule(modifier.moduleId, modifier);
|
||||||
return module.calculate(modifier, baseTotal, items);
|
return module.calculate(modifier, baseTotal, items);
|
||||||
},
|
},
|
||||||
|
|
||||||
calculateDisplay: (modifier: Modifier, baseTotal: number, items: EstimateItem[]) => {
|
calculateDisplay: (modifier: Modifier, baseTotal: number, items: EstimateItem[], subScopeModifiers: Modifier[] = []) => {
|
||||||
const module = ModifierRegistry.getModule(modifier.moduleId);
|
const module = ModifierRegistry.getModule(modifier.moduleId, modifier);
|
||||||
if (module.calculateDisplay) return module.calculateDisplay(modifier, baseTotal, items);
|
if (module.calculateDisplay) return module.calculateDisplay(modifier, baseTotal, items, subScopeModifiers);
|
||||||
return module.calculate(modifier, baseTotal, items);
|
return module.calculate(modifier, baseTotal, items);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import type { EstimateItem, Modifier, ModifierTargetField } from '../types';
|
||||||
|
|
||||||
|
export const PREVAILING_WAGE_DEFAULT_PARAMETERS = {
|
||||||
|
baseRate: 30,
|
||||||
|
prevailingRate: 50
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PREVAILING_WAGE_DEFAULT_EXPRESSION = 'price * multiplier';
|
||||||
|
export const CUSTOM_CALC_DEFAULT_EXPRESSION = 'price';
|
||||||
|
|
||||||
|
export const TARGET_FIELD_LABELS: Record<ModifierTargetField, string> = {
|
||||||
|
unitPrice: 'Price',
|
||||||
|
quantity: 'Quantity',
|
||||||
|
subtotal: 'Subtotal'
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREVIEW_ITEM: EstimateItem = {
|
||||||
|
id: '__preview__',
|
||||||
|
description: 'Preview',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: 1,
|
||||||
|
markup: 0,
|
||||||
|
markupType: 'percent',
|
||||||
|
contingency: 0,
|
||||||
|
contingencyType: 'percent'
|
||||||
|
};
|
||||||
|
|
||||||
|
type Token =
|
||||||
|
| { type: 'number'; value: number }
|
||||||
|
| { type: 'identifier'; value: string }
|
||||||
|
| { type: 'operator'; value: '+' | '-' | '*' | '/' }
|
||||||
|
| { type: 'paren'; value: '(' | ')' };
|
||||||
|
|
||||||
|
interface ComputedItemState {
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputedModifierStatus {
|
||||||
|
parameters: Record<string, number>;
|
||||||
|
expression: string;
|
||||||
|
targetField: ModifierTargetField;
|
||||||
|
multiplier?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveItemPricing {
|
||||||
|
baseQuantity: number;
|
||||||
|
effectiveQuantity: number;
|
||||||
|
baseUnitPrice: number;
|
||||||
|
effectiveUnitPrice: number;
|
||||||
|
baseSubtotal: number;
|
||||||
|
effectiveSubtotal: number;
|
||||||
|
markupAmount: number;
|
||||||
|
contingencyAmount: number;
|
||||||
|
total: number;
|
||||||
|
hasAdjustedQuantity: boolean;
|
||||||
|
hasAdjustedUnitPrice: boolean;
|
||||||
|
hasAdjustedSubtotal: boolean;
|
||||||
|
hasDirectQuantityAdjustment: boolean;
|
||||||
|
hasDirectUnitPriceAdjustment: boolean;
|
||||||
|
hasDirectSubtotalAdjustment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemTotals {
|
||||||
|
subtotal: number;
|
||||||
|
markup: number;
|
||||||
|
contingency: number;
|
||||||
|
total: number;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveModifierModuleId = (modifier: Modifier) => {
|
||||||
|
if (modifier.moduleId === 'computed-field') {
|
||||||
|
return modifier.presetId === 'custom' ? 'custom-calculation' : 'prevailing-wage';
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifier.moduleId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPrevailingWageModifier = (modifier: Modifier) => resolveModifierModuleId(modifier) === 'prevailing-wage';
|
||||||
|
export const isCustomCalculationModifier = (modifier: Modifier) => resolveModifierModuleId(modifier) === 'custom-calculation';
|
||||||
|
export const isComputedFieldModifier = (modifier: Modifier) => isPrevailingWageModifier(modifier) || isCustomCalculationModifier(modifier);
|
||||||
|
|
||||||
|
export const getResolvedTargetField = (modifier: Modifier): ModifierTargetField => {
|
||||||
|
if (isPrevailingWageModifier(modifier)) return 'unitPrice';
|
||||||
|
return modifier.targetField || 'unitPrice';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getComputedModifierParameters = (modifier: Modifier) => {
|
||||||
|
const defaults: Record<string, number> = isPrevailingWageModifier(modifier)
|
||||||
|
? PREVAILING_WAGE_DEFAULT_PARAMETERS
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...(modifier.parameters || {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getModifierExpression = (modifier: Modifier) => {
|
||||||
|
if (isPrevailingWageModifier(modifier)) {
|
||||||
|
return PREVAILING_WAGE_DEFAULT_EXPRESSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifier.expression?.trim() || CUSTOM_CALC_DEFAULT_EXPRESSION;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenizeExpression = (expression: string): Token[] => {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (index < expression.length) {
|
||||||
|
const char = expression[index];
|
||||||
|
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[+\-*/]/.test(char)) {
|
||||||
|
tokens.push({ type: 'operator', value: char as '+' | '-' | '*' | '/' });
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '(' || char === ')') {
|
||||||
|
tokens.push({ type: 'paren', value: char });
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\d|\./.test(char)) {
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < expression.length && /[\d.]/.test(expression[end])) end += 1;
|
||||||
|
const raw = expression.slice(index, end);
|
||||||
|
const value = Number.parseFloat(raw);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error(`Invalid number "${raw}".`);
|
||||||
|
}
|
||||||
|
tokens.push({ type: 'number', value });
|
||||||
|
index = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[A-Za-z_]/.test(char)) {
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < expression.length && /[A-Za-z0-9_]/.test(expression[end])) end += 1;
|
||||||
|
tokens.push({ type: 'identifier', value: expression.slice(index, end) });
|
||||||
|
index = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported token "${char}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateArithmeticExpression = (expression: string, context: Record<string, number>) => {
|
||||||
|
const trimmedExpression = expression.trim();
|
||||||
|
if (!trimmedExpression) {
|
||||||
|
throw new Error('Expression is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = tokenizeExpression(trimmedExpression);
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const peek = () => tokens[index];
|
||||||
|
const consume = () => tokens[index++];
|
||||||
|
|
||||||
|
const parseExpression = (): number => {
|
||||||
|
let value = parseTerm();
|
||||||
|
while (peek()?.type === 'operator' && (peek()?.value === '+' || peek()?.value === '-')) {
|
||||||
|
const operator = consume().value;
|
||||||
|
const nextValue = parseTerm();
|
||||||
|
value = operator === '+' ? value + nextValue : value - nextValue;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTerm = (): number => {
|
||||||
|
let value = parseFactor();
|
||||||
|
while (peek()?.type === 'operator' && (peek()?.value === '*' || peek()?.value === '/')) {
|
||||||
|
const operator = consume().value;
|
||||||
|
const nextValue = parseFactor();
|
||||||
|
if (operator === '/') {
|
||||||
|
if (nextValue === 0) throw new Error('Division by zero is not allowed.');
|
||||||
|
value /= nextValue;
|
||||||
|
} else {
|
||||||
|
value *= nextValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseFactor = (): number => {
|
||||||
|
const token = peek();
|
||||||
|
if (!token) throw new Error('Unexpected end of expression.');
|
||||||
|
|
||||||
|
if (token.type === 'operator' && token.value === '-') {
|
||||||
|
consume();
|
||||||
|
return -parseFactor();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'number') {
|
||||||
|
consume();
|
||||||
|
return token.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'identifier') {
|
||||||
|
consume();
|
||||||
|
const value = context[token.value];
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error(`Unknown variable "${token.value}".`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'paren' && token.value === '(') {
|
||||||
|
consume();
|
||||||
|
const value = parseExpression();
|
||||||
|
const closing = consume();
|
||||||
|
if (!closing || closing.type !== 'paren' || closing.value !== ')') {
|
||||||
|
throw new Error('Missing closing parenthesis.');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unexpected token in expression.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = parseExpression();
|
||||||
|
if (index < tokens.length) {
|
||||||
|
throw new Error('Unexpected trailing token.');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error('Expression did not produce a finite number.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildModifierContext = (modifier: Modifier, item: EstimateItem, state: ComputedItemState) => {
|
||||||
|
const parameters = getComputedModifierParameters(modifier);
|
||||||
|
const baseRate = parameters.baseRate;
|
||||||
|
const prevailingRate = parameters.prevailingRate;
|
||||||
|
|
||||||
|
if (isPrevailingWageModifier(modifier)) {
|
||||||
|
if (!Number.isFinite(baseRate) || baseRate <= 0) {
|
||||||
|
throw new Error('Base rate must be greater than 0.');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(prevailingRate)) {
|
||||||
|
throw new Error('Prevailing wage rate must be a valid number.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetField = getResolvedTargetField(modifier);
|
||||||
|
const subtotal = state.quantity * state.unitPrice;
|
||||||
|
const context: Record<string, number> = {
|
||||||
|
value: targetField === 'unitPrice'
|
||||||
|
? state.unitPrice
|
||||||
|
: targetField === 'quantity'
|
||||||
|
? state.quantity
|
||||||
|
: subtotal,
|
||||||
|
price: state.unitPrice,
|
||||||
|
quantity: state.quantity,
|
||||||
|
subtotal,
|
||||||
|
basePrice: item.unitPrice,
|
||||||
|
baseQuantity: item.quantity,
|
||||||
|
baseSubtotal: item.quantity * item.unitPrice,
|
||||||
|
markup: item.markup,
|
||||||
|
contingency: item.contingency,
|
||||||
|
...parameters
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Number.isFinite(baseRate) && baseRate > 0 && Number.isFinite(prevailingRate)) {
|
||||||
|
context.multiplier = prevailingRate / baseRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parameters,
|
||||||
|
targetField,
|
||||||
|
context
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyComputedModifierToState = (modifier: Modifier, item: EstimateItem, state: ComputedItemState) => {
|
||||||
|
const expression = getModifierExpression(modifier);
|
||||||
|
const { targetField, context } = buildModifierContext(modifier, item, state);
|
||||||
|
const nextValue = evaluateArithmeticExpression(expression, context);
|
||||||
|
|
||||||
|
if (targetField === 'unitPrice') {
|
||||||
|
return {
|
||||||
|
quantity: state.quantity,
|
||||||
|
unitPrice: nextValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetField === 'quantity') {
|
||||||
|
return {
|
||||||
|
quantity: nextValue,
|
||||||
|
unitPrice: state.unitPrice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.quantity === 0) {
|
||||||
|
if (nextValue === 0) {
|
||||||
|
return {
|
||||||
|
quantity: state.quantity,
|
||||||
|
unitPrice: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Subtotal output requires a non-zero quantity.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
quantity: state.quantity,
|
||||||
|
unitPrice: nextValue / state.quantity
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getComputedModifierStatus = (
|
||||||
|
modifier: Modifier,
|
||||||
|
item: EstimateItem = PREVIEW_ITEM,
|
||||||
|
state: ComputedItemState = { quantity: item.quantity, unitPrice: item.unitPrice }
|
||||||
|
): ComputedModifierStatus => {
|
||||||
|
const expression = getModifierExpression(modifier);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { parameters, targetField, context } = buildModifierContext(modifier, item, state);
|
||||||
|
evaluateArithmeticExpression(expression, context);
|
||||||
|
return {
|
||||||
|
parameters,
|
||||||
|
expression,
|
||||||
|
targetField,
|
||||||
|
multiplier: context.multiplier
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
parameters: getComputedModifierParameters(modifier),
|
||||||
|
expression,
|
||||||
|
targetField: getResolvedTargetField(modifier),
|
||||||
|
error: error instanceof Error ? error.message : 'Invalid expression.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEffectiveItemPricing = (item: EstimateItem, modifiers: Modifier[] = []): EffectiveItemPricing => {
|
||||||
|
let state: ComputedItemState = {
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice
|
||||||
|
};
|
||||||
|
const adjustedTargets = new Set<ModifierTargetField>();
|
||||||
|
|
||||||
|
modifiers.forEach((modifier) => {
|
||||||
|
if (!modifier.includeInTotal || !isComputedFieldModifier(modifier)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state = applyComputedModifierToState(modifier, item, state);
|
||||||
|
adjustedTargets.add(getResolvedTargetField(modifier));
|
||||||
|
} catch {
|
||||||
|
// Invalid computed modifiers are treated as inactive for pricing.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveSubtotal = state.quantity * state.unitPrice;
|
||||||
|
const markupAmount = item.markupType === 'percent'
|
||||||
|
? effectiveSubtotal * (item.markup / 100)
|
||||||
|
: item.markup;
|
||||||
|
const contingencyAmount = item.contingencyType === 'percent'
|
||||||
|
? effectiveSubtotal * (item.contingency / 100)
|
||||||
|
: item.contingency;
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseQuantity: item.quantity,
|
||||||
|
effectiveQuantity: state.quantity,
|
||||||
|
baseUnitPrice: item.unitPrice,
|
||||||
|
effectiveUnitPrice: state.unitPrice,
|
||||||
|
baseSubtotal: item.quantity * item.unitPrice,
|
||||||
|
effectiveSubtotal,
|
||||||
|
markupAmount,
|
||||||
|
contingencyAmount,
|
||||||
|
total: effectiveSubtotal + markupAmount + contingencyAmount,
|
||||||
|
hasAdjustedQuantity: state.quantity !== item.quantity,
|
||||||
|
hasAdjustedUnitPrice: state.unitPrice !== item.unitPrice,
|
||||||
|
hasAdjustedSubtotal: effectiveSubtotal !== item.quantity * item.unitPrice,
|
||||||
|
hasDirectQuantityAdjustment: adjustedTargets.has('quantity'),
|
||||||
|
hasDirectUnitPriceAdjustment: adjustedTargets.has('unitPrice'),
|
||||||
|
hasDirectSubtotalAdjustment: adjustedTargets.has('subtotal')
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getItemsPricingTotals = (items: EstimateItem[], modifiers: Modifier[] = []): ItemTotals => {
|
||||||
|
return items.reduce<ItemTotals>((acc, item) => {
|
||||||
|
const pricing = getEffectiveItemPricing(item, modifiers);
|
||||||
|
return {
|
||||||
|
subtotal: acc.subtotal + pricing.effectiveSubtotal,
|
||||||
|
markup: acc.markup + pricing.markupAmount,
|
||||||
|
contingency: acc.contingency + pricing.contingencyAmount,
|
||||||
|
total: acc.total + pricing.total,
|
||||||
|
quantity: acc.quantity + pricing.effectiveQuantity
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
subtotal: 0,
|
||||||
|
markup: 0,
|
||||||
|
contingency: 0,
|
||||||
|
total: 0,
|
||||||
|
quantity: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ScopeTextTemplateDiffLine } from '../types';
|
||||||
|
|
||||||
|
const buildLcsTable = (a: string[], b: string[]) => {
|
||||||
|
const table = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
|
||||||
|
|
||||||
|
for (let i = a.length - 1; i >= 0; i--) {
|
||||||
|
for (let j = b.length - 1; j >= 0; j--) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
table[i][j] = table[i + 1][j + 1] + 1;
|
||||||
|
} else {
|
||||||
|
table[i][j] = Math.max(table[i + 1][j], table[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createScopeTextDiff = (previousContent: string, nextContent: string): ScopeTextTemplateDiffLine[] => {
|
||||||
|
const previousLines = previousContent.split('\n');
|
||||||
|
const nextLines = nextContent.split('\n');
|
||||||
|
const table = buildLcsTable(previousLines, nextLines);
|
||||||
|
const diff: ScopeTextTemplateDiffLine[] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
|
||||||
|
while (i < previousLines.length && j < nextLines.length) {
|
||||||
|
if (previousLines[i] === nextLines[j]) {
|
||||||
|
diff.push({ op: 'equal', text: previousLines[i] });
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table[i + 1][j] >= table[i][j + 1]) {
|
||||||
|
diff.push({ op: 'remove', text: previousLines[i] });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
diff.push({ op: 'add', text: nextLines[j] });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < previousLines.length) {
|
||||||
|
diff.push({ op: 'remove', text: previousLines[i] });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (j < nextLines.length) {
|
||||||
|
diff.push({ op: 'add', text: nextLines[j] });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff;
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { parseCSV } from './csv-parser';
|
||||||
|
|
||||||
|
export interface ParsedStandardPriceRow {
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
supplier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandardPriceImportResult {
|
||||||
|
rows: ParsedStandardPriceRow[];
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADER_ALIASES = {
|
||||||
|
category: new Set(['category', 'group', 'type', 'materialcategory']),
|
||||||
|
name: new Set(['name', 'item', 'itemname', 'material', 'materialname', 'description', 'product']),
|
||||||
|
price: new Set(['price', 'unitprice', 'cost', 'amount', 'materialprice']),
|
||||||
|
supplier: new Set(['supplier', 'vendor'])
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeHeader = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
|
||||||
|
const parsePrice = (value: string) => {
|
||||||
|
const cleaned = value.replace(/[$,\s]/g, '');
|
||||||
|
const parsed = Number.parseFloat(cleaned);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnIndex = (headers: string[], aliases: Set<string>) => {
|
||||||
|
return headers.findIndex(header => aliases.has(normalizeHeader(header)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseStandardPricesCSV(csvText: string, defaultSupplier?: string): StandardPriceImportResult {
|
||||||
|
const rows = parseCSV(csvText)
|
||||||
|
.map(row => row.map(cell => cell.trim()))
|
||||||
|
.filter(row => row.some(cell => cell !== ''));
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('The CSV file is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerRow = rows[0];
|
||||||
|
const dataRows = rows.slice(1);
|
||||||
|
|
||||||
|
const categoryIndex = getColumnIndex(headerRow, HEADER_ALIASES.category);
|
||||||
|
const nameIndex = getColumnIndex(headerRow, HEADER_ALIASES.name);
|
||||||
|
const priceIndex = getColumnIndex(headerRow, HEADER_ALIASES.price);
|
||||||
|
const supplierIndex = getColumnIndex(headerRow, HEADER_ALIASES.supplier);
|
||||||
|
|
||||||
|
if (nameIndex === -1 || priceIndex === -1) {
|
||||||
|
throw new Error('CSV must include item name and price columns.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedRows: ParsedStandardPriceRow[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const row of dataRows) {
|
||||||
|
const name = row[nameIndex]?.trim() || '';
|
||||||
|
const price = parsePrice(row[priceIndex] || '');
|
||||||
|
|
||||||
|
if (!name || price === null || price < 0) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = categoryIndex >= 0 ? row[categoryIndex]?.trim() || 'Uncategorized' : 'Uncategorized';
|
||||||
|
const supplierFromRow = supplierIndex >= 0 ? row[supplierIndex]?.trim() || '' : '';
|
||||||
|
const supplier = supplierFromRow || defaultSupplier?.trim() || '';
|
||||||
|
|
||||||
|
parsedRows.push({
|
||||||
|
category,
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
supplier: supplier || undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows: parsedRows, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStandardPriceKey(name: string, supplier?: string) {
|
||||||
|
return `${name.trim().toLowerCase()}::${(supplier || '').trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createSignal, onMount, For, createMemo, createResource } from 'solid-js';
|
import { createSignal, onMount, For, createMemo, createResource, Show } from 'solid-js';
|
||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { Trash2, Plus, RefreshCw, Edit2, Save, X, ChevronDown } from 'lucide-solid';
|
import { Trash2, Plus, RefreshCw, Edit2, Save, X, ChevronDown, Upload } from 'lucide-solid';
|
||||||
import { pb, COLLECTIONS } from '../utils/db';
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
import type { StandardPrice } from '../types';
|
import type { StandardPrice } from '../types';
|
||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
|
import { normalizeStandardPriceKey, parseStandardPricesCSV } from '../utils/standard-price-import';
|
||||||
|
|
||||||
const StandardPricing: Component = () => {
|
const StandardPricing: Component = () => {
|
||||||
const fetchPrices = async () => {
|
const fetchPrices = async () => {
|
||||||
@@ -20,6 +21,10 @@ const StandardPricing: Component = () => {
|
|||||||
const [newName, setNewName] = createSignal('');
|
const [newName, setNewName] = createSignal('');
|
||||||
const [newPrice, setNewPrice] = createSignal<number>(0);
|
const [newPrice, setNewPrice] = createSignal<number>(0);
|
||||||
const [newSupplier, setNewSupplier] = createSignal('');
|
const [newSupplier, setNewSupplier] = createSignal('');
|
||||||
|
const [importSupplier, setImportSupplier] = createSignal('');
|
||||||
|
const [isImporting, setIsImporting] = createSignal(false);
|
||||||
|
const [isImportOpen, setIsImportOpen] = createSignal(false);
|
||||||
|
const [importSummary, setImportSummary] = createSignal<string | null>(null);
|
||||||
|
|
||||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||||
const [editCategory, setEditCategory] = createSignal('');
|
const [editCategory, setEditCategory] = createSignal('');
|
||||||
@@ -64,6 +69,80 @@ const StandardPricing: Component = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readFileAsText = (file: File) => new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''));
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read the selected file.'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleImportFile = async (event: Event) => {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setImportSummary(null);
|
||||||
|
setIsImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csvText = await readFileAsText(file);
|
||||||
|
const { rows, skipped } = parseStandardPricesCSV(csvText, importSupplier());
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('No valid pricing rows were found in the CSV.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRecords = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
|
||||||
|
fields: 'id,name,supplier',
|
||||||
|
requestKey: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingByKey = new Map(
|
||||||
|
(existingRecords as unknown as Pick<StandardPrice, 'id' | 'name' | 'supplier'>[]).map(record => [
|
||||||
|
normalizeStandardPriceKey(record.name, record.supplier),
|
||||||
|
record.id
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const record = {
|
||||||
|
category: row.category,
|
||||||
|
name: row.name,
|
||||||
|
price: row.price,
|
||||||
|
supplier: row.supplier || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingId = existingByKey.get(normalizeStandardPriceKey(row.name, row.supplier));
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
await pb.collection(COLLECTIONS.STANDARD_PRICES).update(existingId, record);
|
||||||
|
updated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdRecord = await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record);
|
||||||
|
existingByKey.set(normalizeStandardPriceKey(row.name, row.supplier), createdRecord.id);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refetch();
|
||||||
|
await appStore.loadSuppliers();
|
||||||
|
setImportSummary(`Imported ${rows.length} rows: ${created} added, ${updated} replaced${skipped ? `, ${skipped} skipped` : ''}.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing standard prices:', error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to import standard prices.';
|
||||||
|
setImportSummary(message);
|
||||||
|
alert(message);
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEditStart = (price: StandardPrice) => {
|
const handleEditStart = (price: StandardPrice) => {
|
||||||
setEditingId(price.id);
|
setEditingId(price.id);
|
||||||
setEditCategory(price.category);
|
setEditCategory(price.category);
|
||||||
@@ -193,6 +272,68 @@ const StandardPricing: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 rounded-xl mb-8 border border-gray-200 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsImportOpen(!isImportOpen())}
|
||||||
|
class="w-full flex items-center justify-between gap-4 px-4 py-3 text-left hover:bg-gray-100/80 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wider">Import Supplier Pricing CSV</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Upload an Excel-style CSV and replace matching item names within the same supplier list.</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class={`w-5 h-5 text-gray-400 transition-transform ${isImportOpen() ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={isImportOpen()}>
|
||||||
|
<div class="border-t border-gray-200 bg-white p-4">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Required columns: <span class="font-semibold text-gray-800">Item Name</span>, <span class="font-semibold text-gray-800">Price</span>.
|
||||||
|
Optional columns: <span class="font-semibold text-gray-800">Category</span>, <span class="font-semibold text-gray-800">Supplier</span>.
|
||||||
|
Expected row order for your guide sheet: <span class="font-semibold text-gray-800">Category, Item Name, Price, Supplier</span>.
|
||||||
|
If the file has no supplier column, the supplier entered below will be applied to every imported row.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 lg:items-end">
|
||||||
|
<div class="min-w-[220px]">
|
||||||
|
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Supplier Override</label>
|
||||||
|
<div class="relative group/sel">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={importSupplier()}
|
||||||
|
onInput={(e) => setImportSupplier(e.currentTarget.value)}
|
||||||
|
placeholder="Used if CSV has no supplier"
|
||||||
|
list="supplier-suggestions"
|
||||||
|
class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none pr-10 transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300"
|
||||||
|
/>
|
||||||
|
<div class="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-300 group-hover/sel:text-blue-500 transition-colors">
|
||||||
|
<ChevronDown class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class={`flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm transition-all duration-200 h-[46px] ${isImporting() ? 'bg-gray-100 text-gray-500 border border-gray-200 cursor-wait' : 'bg-blue-600 text-white hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-500/25 active:scale-95 active:shadow-inner cursor-pointer shadow-lg shadow-blue-500/20'}`}>
|
||||||
|
<Upload class={`w-4 h-4 ${isImporting() ? 'animate-pulse' : ''}`} />
|
||||||
|
<span>{isImporting() ? 'Importing...' : 'Choose CSV'}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
onChange={handleImportFile}
|
||||||
|
disabled={isImporting()}
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={importSummary()}>
|
||||||
|
<div class="mt-4 text-sm font-medium text-gray-700 bg-gray-50 border border-gray-200 rounded-xl px-4 py-3">
|
||||||
|
{importSummary()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* List of Prices */}
|
{/* List of Prices */}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
|
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { For, createSignal, createMemo, type Component } from 'solid-js';
|
import { For, createSignal, createMemo, type Component } from 'solid-js';
|
||||||
import { FolderOpen, Save, Calculator, Plus } from 'lucide-solid';
|
import { FolderOpen, Save, Calculator, Plus } from 'lucide-solid';
|
||||||
import type { TemplateItem } from '../types';
|
import type { EstimateItem, TemplateItem } from '../types';
|
||||||
import TemplateItemList from '../components/template/TemplateItemList';
|
import TemplateItemList from '../components/template/TemplateItemList';
|
||||||
import ModifierRow from '../components/ModifierRow';
|
import ModifierRow from '../components/ModifierRow';
|
||||||
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
||||||
@@ -8,6 +8,7 @@ import TemplateLoadModal from '../components/template/TemplateLoadModal';
|
|||||||
import { pb, COLLECTIONS } from '../utils/db';
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
|
|
||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
|
import { getItemsPricingTotals } from '../utils/pricing';
|
||||||
|
|
||||||
const TemplateCreator: Component = () => {
|
const TemplateCreator: Component = () => {
|
||||||
const templateId = appStore.templateId;
|
const templateId = appStore.templateId;
|
||||||
@@ -21,9 +22,12 @@ const TemplateCreator: Component = () => {
|
|||||||
const defaultSubScopeName = appStore.templateSubScopeName;
|
const defaultSubScopeName = appStore.templateSubScopeName;
|
||||||
const setDefaultSubScopeName = appStore.setTemplateSubScopeName;
|
const setDefaultSubScopeName = appStore.setTemplateSubScopeName;
|
||||||
|
|
||||||
const templateSubtotal = createMemo(() => {
|
const templateEstimateItems = createMemo<EstimateItem[]>(() => items.map(item => ({
|
||||||
return items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
...item,
|
||||||
});
|
scopeId: undefined,
|
||||||
|
subScopeId: undefined
|
||||||
|
})));
|
||||||
|
const templateSubtotal = createMemo(() => getItemsPricingTotals(templateEstimateItems(), modifiers).total);
|
||||||
|
|
||||||
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
||||||
|
|
||||||
@@ -194,7 +198,7 @@ const TemplateCreator: Component = () => {
|
|||||||
{(modifier) => (
|
{(modifier) => (
|
||||||
<ModifierRow
|
<ModifierRow
|
||||||
modifier={modifier}
|
modifier={modifier}
|
||||||
calculatedValue={ModifierRegistry.calculateDisplay(modifier, templateSubtotal(), [])}
|
calculatedValue={ModifierRegistry.calculateDisplay(modifier, templateSubtotal(), templateEstimateItems(), modifiers)}
|
||||||
onUpdate={appStore.updateTemplateModifier}
|
onUpdate={appStore.updateTemplateModifier}
|
||||||
onDelete={appStore.removeTemplateModifier}
|
onDelete={appStore.removeTemplateModifier}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user