True clean start with src folder
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
|
||||
import { Calculator, X, Trash2, Plus, Minus, Asterisk, Divide, Equal, Flag } from 'lucide-solid';
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
type: 'number' | 'field' | 'marker' | 'result';
|
||||
value?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
import { Portal } from 'solid-js/web';
|
||||
|
||||
const CalculatorPopover: Component = () => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [entries, setEntries] = createSignal<Entry[]>([]);
|
||||
const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
|
||||
const [currentInput, setCurrentInput] = createSignal('');
|
||||
const [isHovered, setIsHovered] = createSignal(false);
|
||||
|
||||
// For simplicity in this version, we'll just track a running total
|
||||
const [runningTotal, setRunningTotal] = createSignal(0);
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsHovered(false);
|
||||
const dataStr = e.dataTransfer?.getData('application/json');
|
||||
if (dataStr) {
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
if (data.type === 'item-field') {
|
||||
addValue(data.value, data.label);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse dropped data', err);
|
||||
}
|
||||
} else {
|
||||
const text = e.dataTransfer?.getData('text/plain');
|
||||
if (text && !isNaN(parseFloat(text))) {
|
||||
addValue(parseFloat(text), 'Dropped Value');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addValue = (val: number, label?: string) => {
|
||||
const op = operator();
|
||||
if (!op && entries().length > 0 && entries()[entries().length - 1].type !== 'result' && entries()[entries().length - 1].type !== 'marker') {
|
||||
// If no operator but we have a previous value that isn't a result/marker, default to addition
|
||||
let nextTotal = runningTotal() + val;
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
|
||||
setRunningTotal(nextTotal);
|
||||
} else if (!op) {
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
|
||||
setRunningTotal(val);
|
||||
} else {
|
||||
let nextTotal = runningTotal();
|
||||
if (op === '+') nextTotal += val;
|
||||
if (op === '-') nextTotal -= val;
|
||||
if (op === '*') nextTotal *= val;
|
||||
if (op === '/') nextTotal = val !== 0 ? nextTotal / val : 0;
|
||||
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
|
||||
setRunningTotal(nextTotal);
|
||||
setOperator(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNumberClick = (num: string) => {
|
||||
setCurrentInput(prev => prev + num);
|
||||
};
|
||||
|
||||
const handleOperatorClick = (op: '+' | '-' | '*' | '/') => {
|
||||
if (currentInput()) {
|
||||
addValue(parseFloat(currentInput()));
|
||||
setCurrentInput('');
|
||||
}
|
||||
setOperator(op);
|
||||
};
|
||||
|
||||
const handleEquals = () => {
|
||||
if (currentInput()) {
|
||||
addValue(parseFloat(currentInput()));
|
||||
setCurrentInput('');
|
||||
}
|
||||
|
||||
// Add result entry to history
|
||||
const result = runningTotal();
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'result', value: result }]);
|
||||
setOperator(null);
|
||||
};
|
||||
|
||||
const createMarker = () => {
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', label: `Marker ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` }]);
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
setRunningTotal(0);
|
||||
setOperator(null);
|
||||
setCurrentInput('');
|
||||
};
|
||||
|
||||
const resetHistory = () => {
|
||||
if (confirm('Clear entire history?')) {
|
||||
setEntries([]);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||
};
|
||||
|
||||
// Keyboard support
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen()) return;
|
||||
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
handleNumberClick(e.key);
|
||||
} else if (e.key === '.') {
|
||||
if (!currentInput().includes('.')) handleNumberClick('.');
|
||||
} else if (e.key === '+') {
|
||||
handleOperatorClick('+');
|
||||
} else if (e.key === '-') {
|
||||
handleOperatorClick('-');
|
||||
} else if (e.key === '*' || e.key === 'x') {
|
||||
handleOperatorClick('*');
|
||||
} else if (e.key === '/') {
|
||||
handleOperatorClick('/');
|
||||
} else if (e.key === 'Enter' || e.key === '=') {
|
||||
handleEquals();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === 'Backspace' && currentInput()) {
|
||||
setCurrentInput(prev => prev.slice(0, -1));
|
||||
} else if (e.key === 'Delete' || (e.key === 'Backspace' && !currentInput())) {
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div class="fixed bottom-6 right-6 z-[100] no-print">
|
||||
<Show when={isOpen()}>
|
||||
<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' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsHovered(true); }}
|
||||
onDragLeave={() => setIsHovered(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="p-6 bg-muted/30 border-b border-border flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-foreground font-black text-sm uppercase tracking-widest">
|
||||
<div class="p-2 bg-primary rounded-xl text-primary-foreground shadow-lg shadow-primary/20">
|
||||
<Calculator class="w-4 h-4" />
|
||||
</div>
|
||||
<span>Item Calculator</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onClick={resetHistory} title="Reset History" class="p-2.5 hover:bg-destructive/10 hover:text-destructive rounded-xl text-muted-foreground/60 transition-all active:scale-90">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setIsOpen(false)} class="p-2.5 hover:bg-muted rounded-xl text-muted-foreground transition-all active:scale-90">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display */}
|
||||
<div class="p-8 bg-foreground text-background min-h-[220px] flex flex-col justify-end items-end relative overflow-hidden group/display">
|
||||
<div class="absolute top-6 left-8 text-[10px] font-black text-background/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">
|
||||
<For each={entries()}>
|
||||
{(entry) => (
|
||||
<Show when={entry.type === 'marker'} fallback={
|
||||
<div class={`flex items-center gap-3 group/entry transition-all ${entry.type === 'result' ? 'mt-3 pt-3 border-t border-background/10 w-full justify-end' : ''}`}>
|
||||
<Show when={entry.label}>
|
||||
<span class="text-[9px] font-black text-background/40 uppercase tracking-wider truncate max-w-[200px] group-hover/entry:text-background/60 transition-colors">{entry.label}</span>
|
||||
</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' :
|
||||
entry.type === 'result' ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-background/10 text-background/80'
|
||||
}`}>
|
||||
{entry.type === 'result' && <span class="mr-2 opacity-60">=</span>}
|
||||
{formatNumber(entry.value || 0)}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<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-background/20"></div>
|
||||
<span class="text-[9px] font-black text-background/60 uppercase tracking-[0.2em] whitespace-nowrap bg-background/5 px-3 py-1 rounded-full border border-background/10 italic">
|
||||
{entry.label}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-background/20"></div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={operator()}>
|
||||
<div class="px-3 py-1 rounded-xl text-[10px] font-black bg-primary text-primary-foreground shadow-lg shadow-primary/20 self-end mt-2 animate-bounce">{operator()}</div>
|
||||
</Show>
|
||||
</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">
|
||||
<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>
|
||||
</Show>
|
||||
<span class="text-primary text-2xl font-medium">$</span>
|
||||
{currentInput() || formatNumber(runningTotal())}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div class="p-6 grid grid-cols-4 gap-3 bg-card">
|
||||
<button onClick={clearInput} class="col-span-2 p-4 bg-destructive/5 text-destructive rounded-2xl font-black text-[10px] uppercase tracking-widest hover:bg-destructive/10 transition-all flex items-center justify-center gap-3 active:scale-95 shadow-sm">
|
||||
<Trash2 class="w-4 h-4" /> Clear Input
|
||||
</button>
|
||||
<button onClick={() => handleOperatorClick('/')} class={`p-4 rounded-2xl font-black transition-all active:scale-95 border-2 ${operator() === '/' ? 'bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/20' : 'bg-card border-border text-muted-foreground hover:bg-muted'}`}>
|
||||
<Divide class="w-5 h-5 mx-auto" />
|
||||
</button>
|
||||
<button onClick={() => handleOperatorClick('*')} class={`p-4 rounded-2xl font-black transition-all active:scale-95 border-2 ${operator() === '*' ? 'bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/20' : 'bg-card border-border text-muted-foreground hover:bg-muted'}`}>
|
||||
<Asterisk class="w-5 h-5 mx-auto" />
|
||||
</button>
|
||||
|
||||
<button onClick={createMarker} class="col-span-2 p-4 bg-muted/40 border border-border/60 rounded-2xl hover:bg-muted transition-all flex items-center justify-center gap-3 text-[10px] font-black uppercase tracking-widest text-muted-foreground active:scale-95 shadow-sm">
|
||||
<Flag class="w-4 h-4 text-primary" /> Create Marker
|
||||
</button>
|
||||
<button onClick={() => handleOperatorClick('-')} class={`p-4 rounded-2xl font-black transition-all active:scale-95 border-2 ${operator() === '-' ? 'bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/20' : 'bg-card border-border text-muted-foreground hover:bg-muted'}`}>
|
||||
<Minus class="w-5 h-5 mx-auto" />
|
||||
</button>
|
||||
<button onClick={() => handleOperatorClick('+')} class={`p-4 rounded-2xl font-black transition-all active:scale-95 border-2 ${operator() === '+' ? 'bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/20' : 'bg-card border-border text-muted-foreground hover:bg-muted'}`}>
|
||||
<Plus class="w-5 h-5 mx-auto" />
|
||||
</button>
|
||||
|
||||
<button onClick={handleEquals} class="col-span-4 p-5 bg-primary text-primary-foreground rounded-[2rem] font-black hover:bg-primary/90 transition-all flex items-center justify-center shadow-xl shadow-primary/20 active:scale-[0.98] border-2 border-primary/20 group">
|
||||
<Equal class="w-8 h-8 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone Info */}
|
||||
<div class="px-8 py-4 bg-primary/5 text-[9px] font-black text-primary uppercase tracking-[0.3em] text-center border-t border-primary/10">
|
||||
{isHovered() ? 'Release to add field' : 'Drag item fields here to calculate'}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class={`p-5 rounded-full shadow-elevated transition-all duration-500 group relative border-2 ${isOpen() ? 'bg-foreground text-background border-foreground rotate-90 scale-110' : 'bg-primary text-primary-foreground border-primary shadow-primary/20 hover:scale-110 hover:shadow-2xl'}`}
|
||||
>
|
||||
{isOpen() ? <X class="w-6 h-6" /> : <Calculator class="w-6 h-6" />}
|
||||
<Show when={!isOpen()}>
|
||||
<span class="absolute right-full mr-6 px-4 py-2 bg-foreground text-background text-[10px] font-black uppercase tracking-widest rounded-xl opacity-0 group-hover:opacity-100 transition-all translate-x-4 group-hover:translate-x-0 pointer-events-none shadow-2xl flex items-center gap-2">
|
||||
<Calculator class="w-3 h-3 text-primary" /> Calculator
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default CalculatorPopover;
|
||||
@@ -0,0 +1,976 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, For, createMemo, onMount, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import { Plus, ChevronRight, ListTree, Calculator, FolderKanban, Info, PanelLeftOpen, PanelLeftClose, FileUp, Download, Upload, FileText, ChevronDown, AlertTriangle, CheckCircle2, X } from 'lucide-solid';
|
||||
import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
|
||||
import type { Scope, SubScope, EstimateItem, JobInfo } from '../types';
|
||||
import type { ExtractedItem } from '../utils/csv-parser';
|
||||
import ScopeCard from './ScopeCard';
|
||||
import ItemRow from './ItemRow';
|
||||
import PrintEstimate from './PrintEstimate';
|
||||
import NoteEditor from './NoteEditor';
|
||||
import LazyItem from './LazyItem';
|
||||
import CalculatorPopover from './CalculatorPopover';
|
||||
|
||||
interface EstimateBuilderProps {
|
||||
initialItems: ExtractedItem[];
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
const [scopes, setScopes] = createStore<Scope[]>([]);
|
||||
const [subScopes, setSubScopes] = createStore<SubScope[]>([]);
|
||||
const [items, setItems] = createStore<EstimateItem[]>([]);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
|
||||
const [isNotesExpanded, setIsNotesExpanded] = createSignal(true);
|
||||
const [isPreNotesExpanded, setIsPreNotesExpanded] = createSignal(true);
|
||||
const [hoveredScopeId, setHoveredScopeId] = createSignal<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = createSignal<string[]>([]);
|
||||
const [lastSelectedId, setLastSelectedId] = createSignal<string | null>(null);
|
||||
const [generalEstimateNotes, setGeneralEstimateNotes] = createSignal(GENERAL_NOTES_DEFAULT);
|
||||
const [generalPreNotes, setGeneralPreNotes] = createSignal(GENERAL_PRE_NOTES_DEFAULT);
|
||||
|
||||
// Client & Project signals
|
||||
const [clientInfo, setClientInfo] = createStore({
|
||||
name: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
fax: ''
|
||||
});
|
||||
const [jobInfo, setJobInfo] = createStore<JobInfo>({
|
||||
number: '',
|
||||
name: '',
|
||||
address: '',
|
||||
workOrder: ''
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Convert initial items to Estimate items
|
||||
const transformed: EstimateItem[] = props.initialItems.map(item => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
quantity: parseFloat(item.quantity.replace(/,/g, '')) || 0,
|
||||
unitPrice: 0,
|
||||
markup: 0,
|
||||
markupType: 'percent',
|
||||
contingency: 0,
|
||||
contingencyType: 'percent'
|
||||
}));
|
||||
setItems(transformed);
|
||||
});
|
||||
|
||||
const unassignedItems = createMemo(() => items.filter(i => !i.scopeId));
|
||||
|
||||
const grandTotals = createMemo(() => {
|
||||
let subtotal = 0;
|
||||
let markup = 0;
|
||||
let contingency = 0;
|
||||
let fees = 0;
|
||||
const scopeSubtotals = new Map<string, number>();
|
||||
|
||||
items.forEach(item => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
subtotal += base;
|
||||
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
|
||||
if (item.scopeId) {
|
||||
scopeSubtotals.set(item.scopeId, (scopeSubtotals.get(item.scopeId) || 0) + base);
|
||||
}
|
||||
});
|
||||
|
||||
scopes.forEach(scope => {
|
||||
const scopeSubtotal = scopeSubtotals.get(scope.id) || 0;
|
||||
|
||||
fees += scope.useManagementLogic === 'percent'
|
||||
? scopeSubtotal * (scope.managementFee / 100)
|
||||
: scope.managementFee;
|
||||
|
||||
fees += scope.useDeliveryLogic === 'percent'
|
||||
? scopeSubtotal * (scope.deliveryFee / 100)
|
||||
: scope.deliveryFee;
|
||||
});
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
markup,
|
||||
contingency,
|
||||
fees,
|
||||
grandTotal: subtotal + markup + contingency + fees
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const validationStats = createMemo(() => {
|
||||
let count = 0;
|
||||
if (generalPreNotes().includes('#')) count++;
|
||||
if (generalEstimateNotes().includes('#')) count++;
|
||||
|
||||
scopes.forEach(s => {
|
||||
if (s.bidDescription?.includes('#')) count++;
|
||||
if (s.estimatorNotes?.includes('#')) count++;
|
||||
});
|
||||
|
||||
return {
|
||||
hasIncomplete: count > 0,
|
||||
incompleteCount: count
|
||||
};
|
||||
});
|
||||
|
||||
const anyDescriptionIsLong = createMemo(() => {
|
||||
return items.some(item => (item.description || '').length > 120);
|
||||
});
|
||||
|
||||
const finalizeBid = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const exportEstimate = () => {
|
||||
const data = {
|
||||
scopes: [...scopes],
|
||||
subScopes: [...subScopes],
|
||||
items: [...items],
|
||||
clientInfo: { ...clientInfo },
|
||||
jobInfo: { ...jobInfo },
|
||||
generalEstimateNotes: generalEstimateNotes(),
|
||||
generalPreNotes: generalPreNotes(),
|
||||
version: '1.0'
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${jobInfo.number || '0000'}-${jobInfo.name || 'unnamed'}-${clientInfo.name || 'client'}-estimate.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importEstimate = (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string);
|
||||
if (data.scopes) setScopes(data.scopes);
|
||||
if (data.subScopes) setSubScopes(data.subScopes);
|
||||
if (data.items) setItems(data.items);
|
||||
if (data.clientInfo) setClientInfo(data.clientInfo);
|
||||
if (data.jobInfo) setJobInfo(data.jobInfo);
|
||||
if (data.generalEstimateNotes !== undefined) setGeneralEstimateNotes(data.generalEstimateNotes);
|
||||
if (data.generalNotes !== undefined) setGeneralEstimateNotes(data.generalNotes); // Support old files
|
||||
if (data.generalPreNotes !== undefined) setGeneralPreNotes(data.generalPreNotes);
|
||||
} catch (err) {
|
||||
alert('Failed to parse estimate file.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const addScope = () => {
|
||||
const scopeId = crypto.randomUUID();
|
||||
const newScope: Scope = {
|
||||
id: scopeId,
|
||||
name: 'New Scope',
|
||||
managementFee: 0,
|
||||
managementFeeType: 'percent',
|
||||
deliveryFee: 0,
|
||||
deliveryFeeType: 'percent',
|
||||
useManagementLogic: 'percent',
|
||||
useDeliveryLogic: 'percent',
|
||||
estimatorNotes: '',
|
||||
bidDescription: '**Material and labor to install #**\n**Includes:**\n- #\n**Does Not Include:**\n- #'
|
||||
};
|
||||
setScopes(prev => [...prev, newScope]);
|
||||
|
||||
// Add default sub-scopes
|
||||
addSubScopeWithName(scopeId, 'Materials');
|
||||
addSubScopeWithName(scopeId, 'Labor');
|
||||
};
|
||||
|
||||
const addSubScopeWithName = (scopeId: string, name: string) => {
|
||||
const newSubScope: SubScope = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
scopeId
|
||||
};
|
||||
setSubScopes(prev => [...prev, newSubScope]);
|
||||
};
|
||||
|
||||
const addSubScope = (scopeId: string) => {
|
||||
addSubScopeWithName(scopeId, 'New Sub-scope');
|
||||
};
|
||||
|
||||
const updateItem = (id: string, updates: Partial<EstimateItem>) => {
|
||||
setItems(item => item.id === id, updates);
|
||||
};
|
||||
|
||||
const deleteItem = (id: string) => {
|
||||
setItems(items.filter(i => i.id !== id));
|
||||
setSelectedIds(prev => prev.filter(selId => selId !== id));
|
||||
if (lastSelectedId() === id) setLastSelectedId(null);
|
||||
};
|
||||
|
||||
const duplicateItem = (id: string) => {
|
||||
const itemIndex = items.findIndex(i => i.id === id);
|
||||
if (itemIndex !== -1) {
|
||||
const itemToClone = items[itemIndex];
|
||||
const newItem: EstimateItem = {
|
||||
...itemToClone,
|
||||
id: crypto.randomUUID()
|
||||
};
|
||||
// Insert immediately after the original item
|
||||
const newItems = [...items];
|
||||
newItems.splice(itemIndex + 1, 0, newItem);
|
||||
setItems(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const copySubScopeItems = (sourceSubScopeId: string, targetSubScopeId: string) => {
|
||||
const targetSubScope = subScopes.find(ss => ss.id === targetSubScopeId);
|
||||
if (!targetSubScope) return;
|
||||
|
||||
const itemsToClone = items.filter(i => i.subScopeId === sourceSubScopeId);
|
||||
const newClonedItems = itemsToClone.map(item => ({
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
subScopeId: targetSubScopeId,
|
||||
scopeId: targetSubScope.scopeId
|
||||
}));
|
||||
|
||||
setItems(prev => [...prev, ...newClonedItems]);
|
||||
};
|
||||
|
||||
const updateScope = (id: string, updates: Partial<Scope>) => {
|
||||
setScopes(s => s.id === id, updates);
|
||||
};
|
||||
|
||||
const updateSubScope = (id: string, updates: Partial<SubScope>) => {
|
||||
setSubScopes(ss => ss.id === id, updates);
|
||||
};
|
||||
|
||||
const deleteScope = (id: string) => {
|
||||
setScopes(scopes.filter(s => s.id !== id));
|
||||
// Move items back to unassigned
|
||||
setItems(i => i.scopeId === id, { scopeId: undefined, subScopeId: undefined });
|
||||
// Delete related sub-scopes
|
||||
setSubScopes(subScopes.filter(ss => ss.scopeId !== id));
|
||||
};
|
||||
|
||||
const deleteSubScope = (id: string) => {
|
||||
setSubScopes(subScopes.filter(ss => ss.id !== id));
|
||||
// Move items back to parent scope
|
||||
setItems(i => i.subScopeId === id, { subScopeId: undefined });
|
||||
};
|
||||
|
||||
const getVisualOrderedItems = () => {
|
||||
const ordered: EstimateItem[] = [];
|
||||
|
||||
// 1. Unassigned
|
||||
ordered.push(...items.filter(i => !i.scopeId));
|
||||
|
||||
// 2. Scopes
|
||||
scopes.forEach(scope => {
|
||||
// Scope level items
|
||||
ordered.push(...items.filter(i => i.scopeId === scope.id && !i.subScopeId));
|
||||
|
||||
// Sub-scope items
|
||||
subScopes.filter(ss => ss.scopeId === scope.id).forEach(ss => {
|
||||
ordered.push(...items.filter(i => i.subScopeId === ss.id));
|
||||
});
|
||||
});
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
const toggleSelection = (id: string, isMulti: boolean, isShift: boolean = false) => {
|
||||
if (isShift && lastSelectedId()) {
|
||||
const orderedItems = getVisualOrderedItems();
|
||||
const lastIdx = orderedItems.findIndex(i => i.id === lastSelectedId());
|
||||
const currentIdx = orderedItems.findIndex(i => i.id === id);
|
||||
|
||||
if (lastIdx !== -1 && currentIdx !== -1) {
|
||||
const start = Math.min(lastIdx, currentIdx);
|
||||
const end = Math.max(lastIdx, currentIdx);
|
||||
const rangeIds = orderedItems.slice(start, end + 1).map(i => i.id);
|
||||
|
||||
setSelectedIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
rangeIds.forEach(rid => newSet.add(rid));
|
||||
return Array.from(newSet);
|
||||
});
|
||||
}
|
||||
} else if (isMulti) {
|
||||
setSelectedIds(prev => prev.includes(id)
|
||||
? prev.filter(i => i !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
} else {
|
||||
if (selectedIds().includes(id) && selectedIds().length === 1) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds([id]);
|
||||
}
|
||||
}
|
||||
setLastSelectedId(id);
|
||||
};
|
||||
|
||||
const clearSelection = (e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setSelectedIds([]);
|
||||
setLastSelectedId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent, id: string) => {
|
||||
// If the dragged item isn't selected, make it the only selection
|
||||
if (!selectedIds().includes(id)) {
|
||||
setSelectedIds([id]);
|
||||
}
|
||||
e.dataTransfer!.setData('text/plain', id);
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, scopeId?: string, subScopeId?: string) => {
|
||||
e.preventDefault();
|
||||
setHoveredScopeId(null);
|
||||
|
||||
const idsToMove = selectedIds();
|
||||
if (idsToMove.length > 0) {
|
||||
idsToMove.forEach(id => {
|
||||
updateItem(id, { scopeId, subScopeId });
|
||||
});
|
||||
// Clear selection after move to avoid confusion
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkUpdate = (scopeId: string, subScopeId: string | undefined, updates: any) => {
|
||||
setItems(
|
||||
item => item.scopeId === scopeId && (subScopeId === undefined || item.subScopeId === subScopeId),
|
||||
updates
|
||||
);
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const [copyToast, setCopyToast] = createSignal<string | null>(null);
|
||||
|
||||
const selectionTotals = createMemo(() => {
|
||||
const selected = items.filter(i => selectedIds().includes(i.id));
|
||||
let qty = 0, price = 0, sub = 0, mark = 0, cont = 0, tot = 0;
|
||||
|
||||
selected.forEach(item => {
|
||||
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;
|
||||
|
||||
qty += item.quantity;
|
||||
price += item.unitPrice;
|
||||
sub += base;
|
||||
mark += markupAmt;
|
||||
cont += contingencyAmt;
|
||||
tot += base + markupAmt + contingencyAmt;
|
||||
});
|
||||
|
||||
return {
|
||||
quantity: Math.round(qty * 100) / 100,
|
||||
unitPrice: Math.round(price * 100) / 100,
|
||||
subtotal: Math.round(sub * 100) / 100,
|
||||
markup: Math.round(mark * 100) / 100,
|
||||
contingency: Math.round(cont * 100) / 100,
|
||||
total: Math.round(tot * 100) / 100
|
||||
};
|
||||
});
|
||||
|
||||
const copyColumnSum = (column: keyof ReturnType<typeof selectionTotals>) => {
|
||||
const val = selectionTotals()[column];
|
||||
navigator.clipboard.writeText(String(val)).then(() => {
|
||||
const labels: Record<string, string> = {
|
||||
quantity: 'Qty', unitPrice: 'Price', subtotal: 'Subtotal',
|
||||
markup: 'Markup', contingency: 'Contingency', total: 'Total'
|
||||
};
|
||||
setCopyToast(`Copied ${labels[column]}: ${val}`);
|
||||
setTimeout(() => setCopyToast(null), 2500);
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
{/* Print Summary View */}
|
||||
<PrintEstimate
|
||||
scopes={scopes}
|
||||
items={items}
|
||||
grandTotals={grandTotals}
|
||||
clientInfo={clientInfo}
|
||||
jobInfo={jobInfo}
|
||||
generalEstimateNotes={generalEstimateNotes()}
|
||||
generalPreNotes={generalPreNotes()}
|
||||
formatNumber={formatNumber}
|
||||
hideColumns={anyDescriptionIsLong()}
|
||||
/>
|
||||
|
||||
{/* Main Application UI */}
|
||||
<div class="no-print flex flex-col min-h-dvh">
|
||||
{/* Header Area - Sticky & Slim */}
|
||||
<div class="h-16 px-6 border-b border-border bg-background/50 flex items-center justify-between sticky top-0 z-50 backdrop-blur-md">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
|
||||
class="p-2 hover:bg-accent rounded-xl transition-colors text-muted-foreground shrink-0"
|
||||
title={isSidebarCollapsed() ? "Expand Sidebar" : "Collapse Sidebar"}
|
||||
>
|
||||
{isSidebarCollapsed() ? <PanelLeftOpen class="w-5 h-5" /> : <PanelLeftClose class="w-5 h-5" />}
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary rounded-xl text-primary-foreground shadow-md shadow-primary/20 shrink-0">
|
||||
<Calculator class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-black text-foreground tracking-tight leading-none">Estimate <span class="text-primary">Builder</span></h2>
|
||||
<div class="flex items-center gap-2 text-muted-foreground text-[10px] font-bold uppercase tracking-wider mt-0.5">
|
||||
<span>{scopes.length} Scopes</span>
|
||||
<span class="text-border">•</span>
|
||||
<span>{items.length} Items</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={addScope}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-foreground text-background rounded-xl text-sm font-bold hover:bg-foreground/90 transition-all shadow-lg shadow-foreground/10 mr-2"
|
||||
>
|
||||
<Plus class="w-4 h-4" /> New Scope
|
||||
</button>
|
||||
<button
|
||||
onClick={props.onReset}
|
||||
class="flex items-center gap-2 px-3 py-2 bg-muted/50 text-primary rounded-xl text-xs font-black hover:bg-primary/10 transition-all border border-border"
|
||||
>
|
||||
<FileUp class="w-4 h-4" /> New CSV
|
||||
</button>
|
||||
<div class="h-6 w-px bg-border mx-1"></div>
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border cursor-pointer">
|
||||
<Upload class="w-4 h-4" /> Load Estimate
|
||||
<input type="file" accept=".json" class="hidden" onChange={importEstimate} />
|
||||
</label>
|
||||
<button
|
||||
onClick={exportEstimate}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-xl text-sm font-bold hover:bg-primary/90 transition-all shadow-lg shadow-primary/10"
|
||||
>
|
||||
<Download class="w-4 h-4" /> Save Estimate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 relative items-start">
|
||||
{/* Collapsible Sidebar */}
|
||||
<aside
|
||||
class={`h-[calc(100vh-var(--spacing-header))] sticky top-header bg-background border-r border-border flex flex-col transition-all duration-500 ease-in-out relative group/sidebar
|
||||
${isSidebarCollapsed() ? 'w-0 opacity-0 -translate-x-full overflow-hidden' : 'w-72 opacity-100'}`}
|
||||
> <div class="flex-1 overflow-y-auto px-4 py-8">
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-4 px-2">
|
||||
<span class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Estimate Navigator</span>
|
||||
<button
|
||||
onClick={addScope}
|
||||
class="p-1.5 bg-muted hover:bg-primary/10 text-muted-foreground hover:text-primary rounded-lg transition-all"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="space-y-1">
|
||||
{/* Unassigned Sidebar Link */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setHoveredScopeId('unassigned');
|
||||
}}
|
||||
onDragLeave={() => setHoveredScopeId(null)}
|
||||
onDrop={(e) => handleDrop(e, undefined, undefined)}
|
||||
onClick={() => {
|
||||
const el = document.getElementById('unassigned-section');
|
||||
if (el) {
|
||||
const headerOffset = 180;
|
||||
const elementPosition = el.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}}
|
||||
class={`group flex items-center justify-between px-3 py-3 rounded-xl text-xs font-black uppercase tracking-wider transition-all cursor-pointer border-2 ${hoveredScopeId() === 'unassigned' ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium-lg shadow-primary/10 text-primary' : (unassignedItems().length > 0 ? 'bg-muted/30 text-foreground border-border/50 hover:bg-muted/50' : 'text-muted-foreground border-transparent hover:bg-accent')}`}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<ListTree class="w-4 h-4" /> Unassigned
|
||||
</span>
|
||||
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${hoveredScopeId() === 'unassigned' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-primary border-border'}`}>
|
||||
{unassignedItems().length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-4"></div>
|
||||
|
||||
{/* Scopes Sidebar Links */}
|
||||
<For each={scopes}>
|
||||
{(scope) => (
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setHoveredScopeId(scope.id);
|
||||
}}
|
||||
onDragLeave={() => setHoveredScopeId(null)}
|
||||
onDrop={(e) => handleDrop(e, scope.id)}
|
||||
class={`group flex items-center justify-between px-4 py-3 rounded-xl text-sm font-bold transition-all cursor-pointer border-2 ${hoveredScopeId() === scope.id ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium-lg shadow-primary/10 text-primary-foreground' : 'text-muted-foreground border-transparent hover:bg-card hover:shadow-premium hover:border-border'}`}
|
||||
onClick={() => {
|
||||
const el = document.getElementById(`scope-${scope.id}`);
|
||||
if (el) {
|
||||
const headerOffset = 180;
|
||||
const elementPosition = el.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="truncate pr-2">{scope.name}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${hoveredScopeId() === scope.id ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted text-muted-foreground border-border'}`}>
|
||||
{items.filter(i => i.scopeId === scope.id).length}
|
||||
</span>
|
||||
<ChevronRight class={`w-4 h-4 text-muted-foreground/40 group-hover:text-primary transition-colors ${hoveredScopeId() === scope.id ? 'text-primary' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nested Sub-scopes in Sidebar */}
|
||||
<div class="pl-4 space-y-0.5 border-l-2 border-gray-50 ml-6">
|
||||
<For each={subScopes.filter(ss => ss.scopeId === scope.id)}>
|
||||
{(subScope) => (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHoveredScopeId(subScope.id);
|
||||
}}
|
||||
onDragLeave={() => setHoveredScopeId(null)}
|
||||
onDrop={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDrop(e, scope.id, subScope.id);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const el = document.getElementById(`subscope-${subScope.id}`);
|
||||
if (el) {
|
||||
const headerOffset = 180;
|
||||
const elementPosition = el.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}}
|
||||
class={`flex items-center justify-between px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wider transition-all cursor-pointer border ${hoveredScopeId() === subScope.id ? 'bg-primary/5 border-primary/30 text-primary scale-[1.02]' : 'text-muted-foreground/60 border-transparent hover:bg-muted hover:text-foreground'}`}
|
||||
>
|
||||
<span class="truncate">{subScope.name}</span>
|
||||
<span class="text-[10px] opacity-60 font-medium">
|
||||
{items.filter(i => i.subScopeId === subScope.id).length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div class="p-4 border-t border-border bg-muted/30">
|
||||
<div class="bg-card rounded-2xl p-4 border border-border shadow-sm">
|
||||
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Current Total</div>
|
||||
<div class="text-xl font-black text-foreground">${formatNumber(grandTotals().grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main
|
||||
onClick={clearSelection}
|
||||
class="flex-1 p-8 bg-zinc-50/50 space-y-8 min-h-screen"
|
||||
>
|
||||
{/* Client & Project Details Form (No Print) */}
|
||||
<div class="bg-card border border-border rounded-[2.5rem] p-8 shadow-premium transition-all">
|
||||
<div class="flex items-center gap-3 text-foreground mb-8">
|
||||
<div class="p-2.5 bg-primary/10 text-primary rounded-2xl shadow-sm">
|
||||
<FolderKanban class="w-5 h-5" />
|
||||
</div>
|
||||
<h3 class="font-black text-xl tracking-tight">Client & Project Details</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] px-1 blur-[0.2px]">Client Information</h4>
|
||||
<div class="grid gap-3">
|
||||
<input type="text" value={clientInfo.name} onInput={(e) => setClientInfo('name', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Client Name" />
|
||||
<input type="text" value={clientInfo.address} onInput={(e) => setClientInfo('address', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Client Address" />
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={clientInfo.phone} onInput={(e) => setClientInfo('phone', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Phone" />
|
||||
<input type="text" value={clientInfo.fax} onInput={(e) => setClientInfo('fax', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Fax" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] px-1 blur-[0.2px]">Project Information</h4>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={jobInfo.number} onInput={(e) => setJobInfo('number', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Job Number" />
|
||||
<input type="text" value={jobInfo.name} onInput={(e) => setJobInfo('name', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Job Name" />
|
||||
</div>
|
||||
<input type="text" value={jobInfo.workOrder} onInput={(e) => setJobInfo('workOrder', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Work Order Number" />
|
||||
<input type="text" value={jobInfo.address} onInput={(e) => setJobInfo('address', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Job Site Address" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Pre-Notes Section */}
|
||||
<div class="bg-muted/30 border border-border rounded-3xl p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3 text-foreground">
|
||||
<div class="p-2 bg-background rounded-xl shadow-sm">
|
||||
<FileText class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-black text-lg">General Pre-Notes</h3>
|
||||
<p class="text-muted-foreground text-[10px] font-bold uppercase tracking-wider">Project-wide preamble & terms</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsPreNotesExpanded(!isPreNotesExpanded())}
|
||||
class="p-2 hover:bg-background rounded-xl transition-all text-muted-foreground"
|
||||
>
|
||||
<ChevronDown class={`w-5 h-5 transition-transform ${isPreNotesExpanded() ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={isPreNotesExpanded()}>
|
||||
<div class="p-6 pt-0">
|
||||
<NoteEditor
|
||||
label="Pre-Scope Terms"
|
||||
value={generalPreNotes()}
|
||||
onUpdate={setGeneralPreNotes}
|
||||
icon={FileText}
|
||||
highlight={generalPreNotes().includes('#')}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Unassigned Items Section */}
|
||||
<Show when={unassignedItems().length > 0}>
|
||||
<div
|
||||
id="unassigned-section"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDrop(e, undefined, undefined)}
|
||||
onClick={clearSelection}
|
||||
class="bg-muted/10 border-2 border-dashed border-border rounded-3xl p-6 transition-all hover:bg-muted/20"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3 text-foreground">
|
||||
<div class="p-2 bg-primary/10 rounded-xl text-primary shadow-sm">
|
||||
<ListTree class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-black text-lg tracking-tight">Unassigned Takeoff Items</h3>
|
||||
<p class="text-muted-foreground text-sm">Drag these items into scopes to begin your estimate.</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-4 py-1.5 bg-card border border-border text-primary rounded-full text-[10px] font-black uppercase tracking-widest shadow-sm">
|
||||
{unassignedItems().length} Items Pending
|
||||
</span>
|
||||
</div>
|
||||
{/* Sticky Column Headers for Unassigned */}
|
||||
<div class="sticky top-header z-10 bg-background/95 backdrop-blur-sm border-b border-border py-3 mb-4 hidden md:flex items-center gap-4 text-[10px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] px-3">
|
||||
<div class="w-4"></div>
|
||||
<div class="flex-1">Description</div>
|
||||
<div class="w-20 text-right">Qty</div>
|
||||
<div class="w-24 text-right">Unit Price</div>
|
||||
<div class="w-24 text-right">Subtotal</div>
|
||||
<Show when={isSidebarCollapsed() || !anyDescriptionIsLong()}>
|
||||
<div class="w-32 text-center">Markup</div>
|
||||
<div class="w-32 text-center">Contingency</div>
|
||||
</Show>
|
||||
<div class="w-28 text-right px-2">Total</div>
|
||||
<div class="w-16"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<For each={unassignedItems()}>
|
||||
{(item) => (
|
||||
<LazyItem estimatedHeight={60}>
|
||||
<ItemRow
|
||||
item={item}
|
||||
onUpdate={updateItem}
|
||||
onDelete={deleteItem}
|
||||
onDragStart={handleDragStart}
|
||||
onDuplicateItem={duplicateItem}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
isSelected={selectedIds().includes(item.id)}
|
||||
anySelected={selectedIds().length > 0}
|
||||
onToggleSelection={(id, isMulti, isShift) => toggleSelection(id, isMulti, isShift)}
|
||||
hideColumns={anyDescriptionIsLong()}
|
||||
/>
|
||||
</LazyItem>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Scopes Content */}
|
||||
<div class="space-y-12">
|
||||
<For each={scopes}>
|
||||
{(scope) => (
|
||||
<div id={`scope-${scope.id}`}>
|
||||
<ScopeCard
|
||||
scope={scope}
|
||||
subScopes={subScopes.filter(ss => ss.scopeId === scope.id)}
|
||||
items={items.filter(i => i.scopeId === scope.id)}
|
||||
onUpdateScope={updateScope}
|
||||
onDeleteScope={deleteScope}
|
||||
onUpdateItem={updateItem}
|
||||
onDeleteItem={deleteItem}
|
||||
onAddSubScope={addSubScope}
|
||||
onUpdateSubScope={updateSubScope}
|
||||
onDeleteSubScope={deleteSubScope}
|
||||
onDragStart={handleDragStart}
|
||||
onDrop={handleDrop}
|
||||
onBulkUpdate={handleBulkUpdate}
|
||||
onDuplicateItem={duplicateItem}
|
||||
allScopes={scopes}
|
||||
allSubScopes={subScopes}
|
||||
onCopySubScopeItems={copySubScopeItems}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
selectedIds={selectedIds()}
|
||||
onToggleSelection={toggleSelection}
|
||||
onClearSelection={() => setSelectedIds([])}
|
||||
hideColumns={anyDescriptionIsLong()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={scopes.length > 0}>
|
||||
<div class="mt-8 bg-card border border-border rounded-3xl overflow-hidden shadow-premium transition-all duration-300">
|
||||
<button
|
||||
onClick={() => setIsNotesExpanded(!isNotesExpanded())}
|
||||
class="w-full flex items-center justify-between p-6 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-foreground">
|
||||
<div class="p-2 bg-muted rounded-xl shadow-sm">
|
||||
<FileText class="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="font-black text-lg">General Estimate Notes</h3>
|
||||
<p class="text-muted-foreground text-xs font-bold uppercase tracking-wider">Configure standard project terms and exclusions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`transition-transform duration-300 ${isNotesExpanded() ? 'rotate-180' : ''}`}>
|
||||
<ChevronDown class="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class={`transition-all duration-300 ease-in-out ${isNotesExpanded() ? 'max-h-[2000px] border-t border-gray-50' : 'max-h-0'}`}>
|
||||
<div class="p-6 pt-2">
|
||||
<NoteEditor
|
||||
label="Inclusions, Exclusions & Terms"
|
||||
value={generalEstimateNotes()}
|
||||
onUpdate={setGeneralEstimateNotes}
|
||||
icon={FileText}
|
||||
placeholder="Add project-wide notes here..."
|
||||
highlight={generalEstimateNotes().includes('#')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={scopes.length === 0}>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center space-y-6">
|
||||
<div class="p-8 bg-muted rounded-full text-muted-foreground/30 shadow-inner">
|
||||
<FolderKanban class="w-16 h-16" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-2xl font-black text-foreground tracking-tight">No Scopes Defined</h3>
|
||||
<p class="text-muted-foreground max-w-sm mx-auto font-medium">Click "New Scope" in the header to start building your estimate structure.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={addScope}
|
||||
class="flex items-center gap-2 px-8 py-4 bg-primary text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-primary/90 transition-all shadow-xl shadow-primary/20 active:scale-95"
|
||||
>
|
||||
<Plus class="w-5 h-5" /> Start First Scope
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Note Validation Status Bar */}
|
||||
<div class={`mt-12 p-5 rounded-3xl border shadow-premium transition-all duration-500 flex items-center justify-between ${validationStats().hasIncomplete ? 'bg-destructive/5 border-destructive/20 text-destructive' : 'bg-primary/5 border-primary/20 text-primary'}`}>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class={`p-3 rounded-2xl shadow-sm ${validationStats().hasIncomplete ? 'bg-destructive/20' : 'bg-primary/20'}`}>
|
||||
{validationStats().hasIncomplete ? <AlertTriangle class="w-5 h-5" /> : <CheckCircle2 class="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-black text-sm uppercase tracking-tight">
|
||||
{validationStats().hasIncomplete
|
||||
? `Attention Required: ${validationStats().incompleteCount} fields contain unfinalized information (#)`
|
||||
: "All notes have been updated."}
|
||||
</p>
|
||||
<p class={`text-[10px] font-bold uppercase tracking-widest opacity-80 mt-0.5`}>
|
||||
{validationStats().hasIncomplete
|
||||
? "Please replace all '#' markers with final project data before finalizing."
|
||||
: "Everything looks good to go!"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!validationStats().hasIncomplete}>
|
||||
<div class="px-4 py-2 bg-primary text-primary-foreground rounded-xl text-[10px] font-black uppercase tracking-wider shadow-lg shadow-primary/20 transition-all">
|
||||
Ready to Print
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Light-Themed Bottom Executive Summary */}
|
||||
<div class="mt-20 p-10 bg-muted/30 rounded-[3rem] border border-border shadow-inner">
|
||||
<div class="max-w-7xl mx-auto flex flex-col lg:flex-row gap-10 items-center lg:items-end justify-between">
|
||||
<div class="flex-1 w-full space-y-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="px-4 py-1.5 bg-primary/10 text-primary rounded-full text-[10px] font-black uppercase tracking-[0.2em] border border-primary/20 shadow-sm">
|
||||
Executive Summary
|
||||
</div>
|
||||
<div class="flex-1 h-px bg-border/60"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="bg-card p-6 rounded-3xl border border-border shadow-premium space-y-2 flex flex-col justify-center min-w-0 transition-transform hover:-translate-y-1">
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Project Subtotal</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(grandTotals().subtotal)}</div>
|
||||
</div>
|
||||
<div class="bg-card p-6 rounded-3xl border border-border shadow-premium space-y-2 flex flex-col justify-center min-w-0 transition-transform hover:-translate-y-1">
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Total Markup</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-primary tracking-tight truncate">${formatNumber(grandTotals().markup)}</div>
|
||||
</div>
|
||||
<div class="bg-card p-6 rounded-3xl border border-border shadow-premium space-y-2 flex flex-col justify-center min-w-0 transition-transform hover:-translate-y-1">
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Contingency</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(grandTotals().contingency)}</div>
|
||||
</div>
|
||||
<div class="bg-card p-6 rounded-3xl border border-border shadow-premium space-y-2 flex flex-col justify-center min-w-0 transition-transform hover:-translate-y-1">
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Fees & Logistics</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(grandTotals().fees)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4 px-6 py-4 bg-primary/5 rounded-2xl border border-primary/10 shadow-sm">
|
||||
<Info class="w-5 h-5 text-primary/60 shrink-0 mt-0.5" />
|
||||
<p class="text-xs font-medium text-primary/70 leading-relaxed">
|
||||
Comprehensive project total including all active scopes, sub-scopes, and unassigned takeoff items.
|
||||
Pricing is calculated dynamically based on current material/labor allocations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-center lg:text-right group bg-card p-10 rounded-[3rem] border border-primary/10 shadow-elevated min-w-[340px] lg:min-w-[380px] max-w-full transition-all hover:shadow-2xl">
|
||||
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.4em] mb-3 group-hover:text-primary transition-colors">Total Estimate Value</div>
|
||||
<div class="text-5xl sm:text-6xl xl:text-7xl font-black text-foreground tracking-tighter transition-all group-hover:scale-[1.02] duration-500 break-words drop-shadow-sm">
|
||||
<span class="text-2xl sm:text-3xl text-primary font-medium align-top mr-1">$</span>
|
||||
{formatNumber(grandTotals().grandTotal)}
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
onClick={finalizeBid}
|
||||
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-[0.98] flex items-center justify-center gap-3"
|
||||
>
|
||||
<Plus class="w-5 h-5" /> Finalize Bid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/* Floating Calculator */}
|
||||
<CalculatorPopover />
|
||||
</div>
|
||||
|
||||
{/* Floating Selection Toolbar - Portal used to ensure it anchors to viewport */}
|
||||
<Show when={selectedIds().length >= 2}>
|
||||
<Portal>
|
||||
<div class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-3 bg-card/95 backdrop-blur-md text-foreground px-5 py-4 rounded-[2rem] shadow-elevated border border-border animate-in fade-in slide-in-from-bottom-6 duration-500">
|
||||
<div class="flex flex-col items-center justify-center pr-2 border-r border-border">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-primary mb-0.5">
|
||||
{selectedIds().length}
|
||||
</span>
|
||||
<span class="text-[8px] font-black uppercase tracking-tighter text-muted-foreground">Selected</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<For each={[
|
||||
{ key: 'quantity', label: 'Qty' },
|
||||
{ key: 'unitPrice', label: 'Price' },
|
||||
{ key: 'subtotal', label: 'Sub' },
|
||||
{ key: 'markup', label: 'Mark' },
|
||||
{ key: 'contingency', label: 'Cont' },
|
||||
{ key: 'total', label: 'Total' },
|
||||
] as const}>
|
||||
{(item) => (
|
||||
<button
|
||||
onClick={() => copyColumnSum(item.key)}
|
||||
class="flex flex-col items-center gap-0 px-4 py-2 rounded-2xl bg-muted/40 hover:bg-primary hover:text-primary-foreground text-foreground transition-all border border-border/40 hover:border-primary hover:-translate-y-1 group relative active:scale-95"
|
||||
>
|
||||
<span class="text-[8px] font-black uppercase tracking-tighter text-muted-foreground group-hover:text-primary-foreground/60 transition-colors">{item.label}</span>
|
||||
<span class="text-xs font-black mt-[-1px] tracking-tight">
|
||||
{item.key === 'quantity' ? selectionTotals()[item.key] : `$${formatNumber(selectionTotals()[item.key])}`}
|
||||
</span>
|
||||
{/* Simple hover tooltip hint */}
|
||||
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-foreground text-background text-[8px] font-black px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none uppercase tracking-widest">
|
||||
Copy
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-border mx-1" />
|
||||
<button
|
||||
onClick={() => { setSelectedIds([]); setLastSelectedId(null); }}
|
||||
class="p-3 rounded-2xl hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all active:scale-90"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
|
||||
{/* Copy Toast */}
|
||||
<Show when={copyToast() !== null}>
|
||||
<Portal>
|
||||
<div class="fixed bottom-24 left-1/2 -translate-x-1/2 z-[101] bg-gray-900/90 backdrop-blur-md text-white text-[11px] font-black uppercase tracking-widest px-6 py-3 rounded-2xl shadow-2xl animate-in fade-in slide-in-from-bottom-2 border border-gray-700">
|
||||
✓ {copyToast()}
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EstimateBuilder;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { UploadCloud } from 'lucide-solid';
|
||||
|
||||
interface FileUploadProps {
|
||||
onFileSelect: (text: string) => void;
|
||||
}
|
||||
|
||||
const FileUpload: Component<FileUploadProps> = (props) => {
|
||||
const handleFileChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
props.onFileSelect(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-center w-full">
|
||||
<label
|
||||
for="dropzone-file"
|
||||
class="flex flex-col items-center justify-center w-full h-48 border-2 border-primary/20 border-dashed rounded-[2rem] cursor-pointer bg-muted/30 hover:bg-primary/5 transition-all group shadow-inner"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<div class="p-4 bg-card rounded-2xl shadow-premium mb-4 group-hover:scale-110 transition-transform duration-500">
|
||||
<UploadCloud class="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<p class="mb-2 text-sm text-foreground font-black tracking-tight">
|
||||
<span class="text-primary underline underline-offset-4 decoration-2">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-[0.2em]">CSV files only</p>
|
||||
</div>
|
||||
<input
|
||||
id="dropzone-file"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-solid';
|
||||
|
||||
interface IgnoredLinesListProps {
|
||||
lines: string[][];
|
||||
}
|
||||
|
||||
const IgnoredLinesList: Component<IgnoredLinesListProps> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
return (
|
||||
<div class="bg-card rounded-3xl shadow-premium border border-border overflow-hidden transition-all hover:shadow-elevated">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class="w-full flex items-center justify-between p-6 hover:bg-muted/30 transition-colors text-left"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-muted rounded-2xl shadow-sm">
|
||||
<AlertCircle class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-black text-lg text-foreground tracking-tight">Ignored CSV Rows ({props.lines.length})</h3>
|
||||
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mt-0.5">Lines that didn't match the extraction rules</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted-foreground/40">
|
||||
<Show when={isOpen()} fallback={<ChevronRight class="w-5 h-5" />}>
|
||||
<ChevronDown class="w-5 h-5 transition-transform duration-300 rotate-180" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div class="border-t border-border bg-muted/20 p-6 max-h-[500px] overflow-y-auto custom-scrollbar">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground/60 border-b border-border/60">
|
||||
<th class="pb-3 px-3">Line</th>
|
||||
<th class="pb-3 px-3">Raw Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-xs text-foreground font-medium">
|
||||
<For each={props.lines}>
|
||||
{(row, index) => (
|
||||
<tr class="border-b border-border/40 last:border-0 hover:bg-card/50 transition-colors group">
|
||||
<td class="py-3 px-3 font-mono text-muted-foreground/40 w-16 text-[10px]">{index() + 1}</td>
|
||||
<td class="py-3 px-3 font-mono break-all leading-relaxed text-muted-foreground group-hover:text-foreground">
|
||||
{row.join(' | ')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-6 flex items-center gap-3 justify-center px-4 py-3 bg-card/50 rounded-2xl border border-border/40 shadow-inner">
|
||||
<AlertCircle class="w-4 h-4 text-muted-foreground/40" />
|
||||
<p class="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-wider italic">
|
||||
These lines were skipped because they don't follow the "Item : Description (Qty)" or "Item : Description" pattern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IgnoredLinesList;
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createMemo, createEffect, createSignal, Show } from 'solid-js';
|
||||
import { GripVertical, Trash2, Copy } from 'lucide-solid';
|
||||
import type { EstimateItem } from '../types';
|
||||
|
||||
interface ItemRowProps {
|
||||
item: EstimateItem;
|
||||
onUpdate: (id: string, updates: Partial<EstimateItem>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onDragStart: (e: DragEvent, id: string) => void;
|
||||
isSidebarCollapsed: () => boolean;
|
||||
isSelected: boolean;
|
||||
anySelected: boolean;
|
||||
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
|
||||
onDuplicateItem: (id: string) => void;
|
||||
hideColumns?: boolean;
|
||||
}
|
||||
|
||||
const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
let descriptionRef: HTMLTextAreaElement | undefined;
|
||||
|
||||
const baseTotal = createMemo(() => props.item.quantity * props.item.unitPrice);
|
||||
|
||||
const total = createMemo(() => {
|
||||
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 [isEditing, setIsEditing] = createSignal(false);
|
||||
const [localDescription, setLocalDescription] = createSignal(props.item.description);
|
||||
|
||||
createEffect(() => {
|
||||
if (!isEditing()) {
|
||||
setLocalDescription(props.item.description);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBlur = () => {
|
||||
const currentDesc = localDescription();
|
||||
if (currentDesc !== props.item.description) {
|
||||
props.onUpdate(props.item.id, { description: currentDesc });
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`group flex items-start gap-4 p-3 border transition-all animate-in fade-in slide-in-from-left-2
|
||||
${props.isSelected
|
||||
? 'bg-primary/5 border-primary shadow-premium z-10'
|
||||
: 'bg-card border-border hover:shadow-premium-hover hover:border-border/80'
|
||||
}
|
||||
${isEditing() ? 'ring-1 ring-primary/20 shadow-premium border-primary/30' : 'rounded-2xl'}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
draggable={!isEditing() || props.anySelected}
|
||||
onDragStart={(e) => props.onDragStart(e, props.item.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onToggleSelection(props.item.id, e.metaKey || e.ctrlKey, e.shiftKey);
|
||||
}}
|
||||
class={`cursor-grab transition-colors shrink-0 w-4 mt-2 p-1 -m-1 rounded hover:bg-muted ${props.isSelected ? 'text-primary' : 'text-muted-foreground/30 hover:text-muted-foreground/60'}`}
|
||||
title="Drag to move or click to select"
|
||||
>
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 relative">
|
||||
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
value={localDescription()}
|
||||
onFocus={() => {
|
||||
if (!props.anySelected) setIsEditing(true);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
onInput={(e) => {
|
||||
setLocalDescription(e.currentTarget.value);
|
||||
|
||||
// Fallback auto-resize for browsers without field-sizing support
|
||||
if (!('fieldSizing' in document.documentElement.style)) {
|
||||
e.currentTarget.style.height = 'auto';
|
||||
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
||||
}
|
||||
}}
|
||||
disabled={props.anySelected}
|
||||
rows="1"
|
||||
class={`w-full bg-transparent border-b border-transparent hover:border-border/40 focus:border-primary outline-none py-1 transition-all resize-none block overflow-hidden align-top leading-normal text-base font-medium text-foreground/90
|
||||
${props.anySelected ? 'cursor-default pointer-events-none' : ''}
|
||||
`}
|
||||
style={{
|
||||
"field-sizing": "content",
|
||||
"min-height": "1.5em"
|
||||
}}
|
||||
placeholder="Item description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-20 shrink-0 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
data-field-name="qty"
|
||||
value={props.item.quantity}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { quantity: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onDragStart={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value) || 0;
|
||||
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('text/plain', String(val));
|
||||
e.dataTransfer!.effectAllowed = 'copy';
|
||||
e.stopPropagation();
|
||||
}}
|
||||
draggable="true"
|
||||
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 cursor-text font-bold text-foreground/80 transition-all"
|
||||
placeholder="Qty"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-24 shrink-0 mt-1">
|
||||
<div class="relative">
|
||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
|
||||
<input
|
||||
type="number"
|
||||
data-field-name="price"
|
||||
step="0.1"
|
||||
value={props.item.unitPrice}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { unitPrice: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onDragStart={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value) || 0;
|
||||
const data = {
|
||||
type: 'item-field',
|
||||
itemId: props.item.id,
|
||||
fieldName: 'price',
|
||||
value: val,
|
||||
label: `${props.item.description || 'Unnamed Item'}: Price`
|
||||
};
|
||||
e.dataTransfer!.setData('application/json', JSON.stringify(data));
|
||||
e.dataTransfer!.setData('text/plain', String(val));
|
||||
e.dataTransfer!.effectAllowed = 'copy';
|
||||
e.stopPropagation();
|
||||
}}
|
||||
draggable="true"
|
||||
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"
|
||||
placeholder="Price"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
draggable="true"
|
||||
onDragStart={(e) => {
|
||||
const val = baseTotal();
|
||||
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('text/plain', String(val));
|
||||
e.dataTransfer!.effectAllowed = 'copy';
|
||||
e.stopPropagation();
|
||||
}}
|
||||
class={`w-24 shrink-0 text-right text-muted-foreground/60 text-[11px] mt-2 font-bold uppercase tracking-tighter cursor-grab hover:text-primary transition-colors`}
|
||||
>
|
||||
${formatNumber(baseTotal())}
|
||||
</div>
|
||||
|
||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||
<div class="w-32 shrink-0 flex items-center gap-1.5 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
data-field-name="markup"
|
||||
value={props.item.markup}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { markup: parseFloat(e.currentTarget.value) || 0 })}
|
||||
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-primary transition-all"
|
||||
placeholder="Markup"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onUpdate(props.item.id, { markupType: props.item.markupType === 'percent' ? 'amount' : 'percent' });
|
||||
}}
|
||||
disabled={props.anySelected}
|
||||
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] disabled:opacity-50 transition-colors border border-border/40"
|
||||
>
|
||||
{props.item.markupType === 'percent' ? '%' : '$'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-32 shrink-0 flex items-center gap-1.5 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
data-field-name="contingency"
|
||||
value={props.item.contingency}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { contingency: parseFloat(e.currentTarget.value) || 0 })}
|
||||
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="Contingency"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onUpdate(props.item.id, { contingencyType: props.item.contingencyType === 'percent' ? 'amount' : 'percent' });
|
||||
}}
|
||||
disabled={props.anySelected}
|
||||
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] disabled:opacity-50 transition-colors border border-border/40"
|
||||
>
|
||||
{props.item.contingencyType === 'percent' ? '%' : '$'}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
draggable="true"
|
||||
onDragStart={(e) => {
|
||||
const val = total();
|
||||
const data = {
|
||||
type: 'item-field',
|
||||
itemId: props.item.id,
|
||||
fieldName: 'total',
|
||||
value: val,
|
||||
label: `${props.item.description || 'Unnamed Item'}: Total`
|
||||
};
|
||||
e.dataTransfer!.setData('application/json', JSON.stringify(data));
|
||||
e.dataTransfer!.setData('text/plain', String(val));
|
||||
e.dataTransfer!.effectAllowed = 'copy';
|
||||
e.stopPropagation();
|
||||
}}
|
||||
class={`w-28 shrink-0 text-right font-black text-sm mt-2 cursor-grab hover:text-primary transition-colors ${props.isSelected ? 'text-primary' : 'text-foreground'}`}
|
||||
>
|
||||
${formatNumber(total())}
|
||||
</div>
|
||||
|
||||
<div class="w-16 shrink-0 flex items-center justify-end gap-1.5 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onDuplicateItem(props.item.id);
|
||||
}}
|
||||
class="text-muted-foreground/40 hover:text-primary transition-colors"
|
||||
title="Duplicate Item"
|
||||
>
|
||||
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-primary/10">
|
||||
<Copy class="w-3 h-3" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onDelete(props.item.id);
|
||||
}}
|
||||
class="text-muted-foreground/40 hover:text-destructive transition-colors"
|
||||
title="Delete Item"
|
||||
>
|
||||
<div class="p-1 px-1.5 bg-muted rounded-lg border border-border/40 hover:bg-destructive/10">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ItemRow;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import { createSignal, onMount, onCleanup, Show } from 'solid-js';
|
||||
|
||||
interface LazyItemProps {
|
||||
children: JSX.Element;
|
||||
estimatedHeight?: number;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that only renders its children when it's within (or near) the viewport.
|
||||
* This is a lightweight alternative to full list virtualization.
|
||||
*/
|
||||
const LazyItem: Component<LazyItemProps> = (props) => {
|
||||
const [isVisible, setIsVisible] = createSignal(false);
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
setIsVisible(entry.isIntersecting);
|
||||
}, {
|
||||
rootMargin: props.rootMargin || '400px 0px', // Pre-render 400px before appearing
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
if (containerRef) {
|
||||
observer.observe(containerRef);
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="lazy-item-container"
|
||||
style={{
|
||||
"min-height": isVisible() ? "auto" : `${props.estimatedHeight || 60}px`,
|
||||
"contain-intrinsic-size": `auto ${props.estimatedHeight || 60}px`,
|
||||
"content-visibility": "auto" // CSS-native optimization hint
|
||||
}}
|
||||
>
|
||||
<Show when={isVisible()}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyItem;
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, Show, createSignal } from 'solid-js';
|
||||
import { Eye, Edit3 } from 'lucide-solid';
|
||||
|
||||
interface NoteEditorProps {
|
||||
value: string;
|
||||
onUpdate: (value: string) => void;
|
||||
label: string;
|
||||
icon: any;
|
||||
placeholder?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const NoteEditor: Component<NoteEditorProps> = (props) => {
|
||||
const [isEditing, setIsEditing] = createSignal(false);
|
||||
|
||||
const renderMarkdown = (text: string) => {
|
||||
if (!text) return <span class="text-muted-foreground italic font-medium opacity-50">No notes yet...</span>;
|
||||
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
<div class="space-y-1">
|
||||
<For each={lines}>
|
||||
{(line) => {
|
||||
let processed = line;
|
||||
// Basic Bold
|
||||
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
// Basic Italics
|
||||
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
||||
|
||||
// Basic Bullet Points
|
||||
if (processed.trim().startsWith('- ')) {
|
||||
return <li class="ml-4 list-disc text-foreground/80" innerHTML={processed.substring(2)} />;
|
||||
}
|
||||
|
||||
return <p class="min-h-[1.2em] text-foreground/90" innerHTML={processed || ' '} />;
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleInput = (e: any) => {
|
||||
props.onUpdate(e.currentTarget.value);
|
||||
// Auto-resize
|
||||
e.currentTarget.style.height = 'auto';
|
||||
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`rounded-2xl border transition-all duration-300 overflow-hidden flex flex-col ${props.highlight ? 'border-destructive/30 bg-destructive/5 shadow-premium ring-1 ring-destructive/10' : 'bg-card border-border shadow-sm'}`}>
|
||||
<div class={`px-4 py-2 border-b flex items-center justify-between ${props.highlight ? 'bg-destructive/10 border-destructive/20' : 'bg-muted/30 border-border/40'}`}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class={props.highlight ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
<props.icon class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span class={`text-[10px] font-black uppercase tracking-widest ${props.highlight ? 'text-destructive' : 'text-muted-foreground'}`}>{props.label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing())}
|
||||
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() ? <Eye class="w-3 h-3 mr-1.5" /> : <Edit3 class="w-3 h-3 mr-1.5" />}
|
||||
{isEditing() ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 min-h-[100px]">
|
||||
<Show
|
||||
when={isEditing()}
|
||||
fallback={
|
||||
<div class="text-sm text-foreground/90 prose prose-sm max-w-none prose-headings:text-foreground prose-strong:text-foreground">
|
||||
{renderMarkdown(props.value)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
value={props.value}
|
||||
onInput={handleInput}
|
||||
class="w-full min-h-[100px] bg-transparent border-none outline-none text-sm text-foreground/90 resize-none font-sans"
|
||||
style={{ "field-sizing": "content" }}
|
||||
placeholder={props.placeholder || "Type notes here... (- for bullets, **bold**, _italic_)"}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteEditor;
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { Component, Accessor } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import type { Scope, EstimateItem, JobInfo } from '../types';
|
||||
import { NOTICE_TO_OWNERS } from '../notice-to-owners';
|
||||
import { COMPANY_NAME, COMPANY_ADDRESS, COMPANY_PHONE, COMPANY_EMAIL } from '../company-info';
|
||||
|
||||
interface PrintEstimateProps {
|
||||
scopes: Scope[];
|
||||
items: EstimateItem[];
|
||||
grandTotals: Accessor<{
|
||||
subtotal: number;
|
||||
markup: number;
|
||||
contingency: number;
|
||||
fees: number;
|
||||
grandTotal: number;
|
||||
}>;
|
||||
clientInfo: {
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
fax: string;
|
||||
};
|
||||
jobInfo: JobInfo;
|
||||
generalPreNotes: string;
|
||||
generalEstimateNotes: string;
|
||||
formatNumber: (num: number) => string;
|
||||
hideColumns?: boolean;
|
||||
}
|
||||
|
||||
const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
const getScopeTotal = (scopeId: string) => {
|
||||
let scopeSubtotal = 0;
|
||||
let scopeMarkup = 0;
|
||||
let scopeContingency = 0;
|
||||
|
||||
const scope = props.scopes.find((s: Scope) => s.id === scopeId);
|
||||
if (!scope) return 0;
|
||||
|
||||
props.items.filter((i: EstimateItem) => i.scopeId === scopeId).forEach((item: EstimateItem) => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
scopeSubtotal += base;
|
||||
scopeMarkup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
scopeContingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
});
|
||||
|
||||
const mgmtFee = scope.useManagementLogic === 'percent'
|
||||
? scopeSubtotal * (scope.managementFee / 100)
|
||||
: scope.managementFee;
|
||||
|
||||
const deliveryFee = scope.useDeliveryLogic === 'percent'
|
||||
? scopeSubtotal * (scope.deliveryFee / 100)
|
||||
: scope.deliveryFee;
|
||||
|
||||
return scopeSubtotal + scopeMarkup + scopeContingency + mgmtFee + deliveryFee;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="print-only p-6 bg-white font-sans max-w-[8.5in] mx-auto text-sm leading-normal text-foreground">
|
||||
{/* Company Header */}
|
||||
<div class="flex justify-between items-start mb-6 pb-4 border-b-2 border-foreground">
|
||||
<div class="space-y-0.5">
|
||||
<h1 class="text-xl font-black text-foreground uppercase tracking-tight">{COMPANY_NAME}</h1>
|
||||
<div class="text-muted-foreground font-medium text-xs flex flex-col">
|
||||
<span>{COMPANY_ADDRESS}</span>
|
||||
<span>{COMPANY_PHONE}</span>
|
||||
<Show when={COMPANY_EMAIL}><span>{COMPANY_EMAIL}</span></Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<h2 class="text-2xl font-black text-foreground uppercase tracking-tighter leading-none">Estimate</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client & Project Information Grid */}
|
||||
<div class="grid grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<h3 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border pb-1 mb-2">Client Information</h3>
|
||||
<div class="pl-1">
|
||||
<p class="font-bold text-foreground text-sm">{props.clientInfo.name || '---'}</p>
|
||||
<p class="text-muted-foreground text-xs">{props.clientInfo.address || '---'}</p>
|
||||
<div class="flex gap-3 text-[11px] text-muted-foreground/80 mt-0.5">
|
||||
<Show when={props.clientInfo.phone}><span>P: {props.clientInfo.phone}</span></Show>
|
||||
<Show when={props.clientInfo.fax}><span>F: {props.clientInfo.fax}</span></Show>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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">Project</span>
|
||||
<span class="font-bold text-foreground">{props.jobInfo.name || '---'}</span>
|
||||
</div>
|
||||
<Show when={props.jobInfo.workOrder}>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">WO No.</span>
|
||||
<span class="font-bold text-foreground">{props.jobInfo.workOrder}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Location</span>
|
||||
<span class="font-medium text-foreground">{props.jobInfo.address || '---'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Pre-Notes */}
|
||||
<Show when={props.generalPreNotes}>
|
||||
<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">
|
||||
{props.generalPreNotes}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Scope Details Table */}
|
||||
<div class="mb-8 border-t-2 border-foreground">
|
||||
<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 class="col-span-2">Scope</div>
|
||||
<div class="col-span-8 pl-2 border-l border-border/60">Description</div>
|
||||
<div class="col-span-2 text-right">Subtotal</div>
|
||||
</div>
|
||||
<div class="divide-y divide-border/20">
|
||||
<For each={props.scopes}>
|
||||
{(scope) => (
|
||||
<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-2">
|
||||
<h4 class="text-xs font-black text-foreground uppercase leading-snug">{scope.name}</h4>
|
||||
</div>
|
||||
<div class="col-span-8 pl-2 border-l border-dashed border-border/60 text-foreground/80 text-xs leading-relaxed">
|
||||
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70">
|
||||
<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)} />;
|
||||
}
|
||||
// Handle empty lines more gracefully in compact mode
|
||||
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="col-span-2 text-right font-black text-foreground text-sm">
|
||||
${props.formatNumber(getScopeTotal(scope.id))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Estimate Notes */}
|
||||
<Show when={props.generalEstimateNotes}>
|
||||
<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>
|
||||
<div class="text-[11px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
|
||||
{props.generalEstimateNotes}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Final Section: Totals & Signature aligned */}
|
||||
<div class="grid grid-cols-12 gap-8 items-end mb-8 break-inside-avoid">
|
||||
<div class="col-span-7">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-full flex items-end gap-2 pt-2">
|
||||
<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="col-span-5 text-right space-y-1">
|
||||
<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="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60">Grand Total</div>
|
||||
<div class="text-3xl font-black tracking-tighter text-foreground">${props.formatNumber(props.grandTotals().grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms & Legal Notices */}
|
||||
<div class="mt-8 pt-6 border-t-2 border-foreground space-y-4">
|
||||
<div class="break-inside-avoid">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrintEstimate;
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { Copy, Check, CheckCircle2 } from 'lucide-solid';
|
||||
import type { ExtractedItem } from '../utils/csv-parser';
|
||||
|
||||
interface ResultsTableProps {
|
||||
items: ExtractedItem[];
|
||||
checkedIds: Set<string>;
|
||||
copiedParts: Record<string, { desc: boolean, qty: boolean }>;
|
||||
onCopy: (id: string, text: string, type: 'desc' | 'qty') => void;
|
||||
onToggleCheck: (id: string) => void;
|
||||
}
|
||||
|
||||
const ResultsTable: Component<ResultsTableProps> = (props) => {
|
||||
return (
|
||||
<div class="max-w-4xl w-full">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden border border-gray-100">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-800 text-white">
|
||||
<th class="p-4 w-12 text-center"></th>
|
||||
<th class="p-4 w-2/3 font-semibold text-sm">
|
||||
Description <span class="text-[10px] font-normal text-gray-400 ml-2">(Click to copy)</span>
|
||||
</th>
|
||||
<th class="p-4 w-1/4 font-semibold text-sm text-center">
|
||||
Quantity <span class="text-[10px] font-normal text-gray-400 ml-2">(Click to copy)</span>
|
||||
</th>
|
||||
<th class="p-4 w-12 text-center text-[10px] font-normal text-gray-400">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
<For each={props.items}>
|
||||
{(item, index) => {
|
||||
const isChecked = () => props.checkedIds.has(item.id);
|
||||
const isDescCopied = () => props.copiedParts[item.id]?.desc;
|
||||
const isQtyCopied = () => props.copiedParts[item.id]?.qty;
|
||||
const isFullyCopied = () => isDescCopied() && isQtyCopied();
|
||||
|
||||
return (
|
||||
<tr
|
||||
class={`transition-all duration-200 ${index() % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||
} ${isChecked() ? 'opacity-40 grayscale-[0.5]' : 'opacity-100'} border-b border-gray-100 hover:bg-blue-50/30`}
|
||||
>
|
||||
<td class="p-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isChecked()}
|
||||
onChange={() => props.onToggleCheck(item.id)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="p-4 border-r border-gray-100 cursor-pointer relative group"
|
||||
onClick={() => props.onCopy(item.id, item.description, 'desc')}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class={`transition-colors text-sm ${isDescCopied() ? 'text-gray-400' : 'text-gray-800'}`}>
|
||||
{item.description}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isDescCopied()}>
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
</Show>
|
||||
<Copy class="w-4 h-4 text-gray-300 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="p-4 cursor-pointer group text-center"
|
||||
onClick={() => props.onCopy(item.id, item.quantity, 'qty')}
|
||||
>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<span class={`font-mono text-sm font-medium px-2 py-1 rounded transition-colors ${isQtyCopied()
|
||||
? 'bg-gray-100 text-gray-400 font-normal'
|
||||
: 'bg-blue-50 text-blue-600'
|
||||
}`}>
|
||||
{item.quantity}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={isQtyCopied()}>
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
</Show>
|
||||
<Copy class="w-4 h-4 text-gray-300 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4 text-center">
|
||||
<Show when={isFullyCopied()}>
|
||||
<CheckCircle2 class="w-5 h-5 text-green-600 inline-block" />
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultsTable;
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, createMemo, Show, createSignal } from 'solid-js';
|
||||
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList } from 'lucide-solid';
|
||||
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
|
||||
import ItemRow from './ItemRow';
|
||||
import SubScopeCard from './SubScopeCard';
|
||||
import NoteEditor from './NoteEditor';
|
||||
import LazyItem from './LazyItem';
|
||||
|
||||
interface ScopeCardProps {
|
||||
scope: Scope;
|
||||
subScopes: SubScope[];
|
||||
items: EstimateItem[];
|
||||
onUpdateScope: (id: string, updates: Partial<Scope>) => void;
|
||||
onDeleteScope: (id: string) => void;
|
||||
onUpdateItem: (id: string, updates: Partial<EstimateItem>) => void;
|
||||
onDeleteItem: (id: string) => void;
|
||||
onAddSubScope: (scopeId: string) => void;
|
||||
onUpdateSubScope: (id: string, updates: Partial<SubScope>) => void;
|
||||
onDeleteSubScope: (id: string) => void;
|
||||
onDragStart: (e: DragEvent, id: string) => void;
|
||||
onDrop: (e: DragEvent, scopeId: string, subScopeId?: string) => void;
|
||||
onBulkUpdate: (scopeId: string, subScopeId: string | undefined, updates: { markup?: number, markupType?: PricingType, contingency?: number, contingencyType?: PricingType }) => void;
|
||||
onDuplicateItem: (id: string) => void;
|
||||
allScopes: Scope[];
|
||||
allSubScopes: SubScope[];
|
||||
onCopySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
|
||||
isSidebarCollapsed: () => boolean;
|
||||
selectedIds: string[];
|
||||
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
|
||||
onClearSelection: () => void;
|
||||
hideColumns?: boolean;
|
||||
}
|
||||
|
||||
|
||||
const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
const [showBulkActions, setShowBulkActions] = createSignal(false);
|
||||
const [isCollapsed, setIsCollapsed] = createSignal(false);
|
||||
const [bulkMarkup, setBulkMarkup] = createSignal(0);
|
||||
const [bulkContingency, setBulkContingency] = createSignal(0);
|
||||
const [isOver, setIsOver] = createSignal(false);
|
||||
|
||||
const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId));
|
||||
|
||||
const totals = createMemo(() => {
|
||||
let subtotal = 0;
|
||||
let markup = 0;
|
||||
let contingency = 0;
|
||||
let totalQuantity = 0;
|
||||
|
||||
props.items.forEach(item => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
subtotal += base;
|
||||
totalQuantity += item.quantity;
|
||||
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
});
|
||||
|
||||
// Add Management and Delivery
|
||||
const mgmtTotal = props.scope.useManagementLogic === 'percent'
|
||||
? subtotal * (props.scope.managementFee / 100)
|
||||
: props.scope.managementFee;
|
||||
|
||||
const deliveryTotal = props.scope.useDeliveryLogic === 'percent'
|
||||
? subtotal * (props.scope.deliveryFee / 100)
|
||||
: props.scope.deliveryFee;
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
markup,
|
||||
contingency,
|
||||
mgmtTotal,
|
||||
deliveryTotal,
|
||||
totalQuantity,
|
||||
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal
|
||||
};
|
||||
});
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const formatQuantity = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
setIsOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsOver(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => {
|
||||
setIsOver(false);
|
||||
props.onDrop(e, props.scope.id);
|
||||
}}
|
||||
onDragLeave={handleDragLeave}
|
||||
class={`bg-muted/30 border rounded-3xl overflow-visible transition-all ring-1 ring-border/50 shadow-premium
|
||||
${isOver() ? 'bg-primary/5 ring-4 ring-primary/10 border-primary' : 'border-border'}
|
||||
`}
|
||||
>
|
||||
{/* Scope Header - Rounded Top */}
|
||||
<div class="p-6 bg-card border-b border-border flex items-center justify-between rounded-t-3xl">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed())}
|
||||
class="p-2 hover:bg-muted rounded-lg transition-colors text-muted-foreground group"
|
||||
>
|
||||
<ChevronDown class={`w-4 h-4 transition-transform duration-200 ${isCollapsed() ? '-rotate-90' : ''}`} />
|
||||
</button>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
value={props.scope.name}
|
||||
onInput={(e) => props.onUpdateScope(props.scope.id, { name: e.currentTarget.value })}
|
||||
class="text-xl font-black text-foreground bg-transparent border-none outline-none focus:ring-0 w-64"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground font-bold uppercase tracking-wider">
|
||||
<span>{props.items.length} Items</span>
|
||||
<span>•</span>
|
||||
<span>{formatQuantity(totals().totalQuantity)} Qty</span>
|
||||
<span>•</span>
|
||||
<span class="text-primary">${formatNumber(totals().total)} Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => props.onAddSubScope(props.scope.id)}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary rounded-xl text-sm font-bold hover:bg-primary/20 transition-colors border border-primary/20"
|
||||
>
|
||||
<Plus class="mr-2 h-3.5 w-3.5" /> Sub-scope
|
||||
</button>
|
||||
<div class="h-8 w-px bg-border mx-2"></div>
|
||||
<button
|
||||
onClick={() => setShowBulkActions(!showBulkActions())}
|
||||
class="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-all"
|
||||
>
|
||||
<Settings2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onDeleteScope(props.scope.id)}
|
||||
class="p-2 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!isCollapsed()}>
|
||||
<Show when={showBulkActions()}>
|
||||
<div class="bg-primary/5 border-b border-primary/10 p-4 flex flex-wrap gap-4 items-center animate-in slide-in-from-top duration-300">
|
||||
<span class="text-sm font-semibold text-primary">Bulk Adjust Scope:</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-primary/70">Markup %:</label>
|
||||
<input
|
||||
type="number"
|
||||
class="w-16 px-2 py-1 border border-primary/20 bg-background rounded text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||
onInput={(e) => setBulkMarkup(parseFloat(e.currentTarget.value) || 0)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { markup: bulkMarkup(), markupType: 'percent' })}
|
||||
class="px-2 py-1 bg-primary text-primary-foreground text-xs rounded-lg font-bold hover:bg-primary/90"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 border-l border-primary/20 pl-4">
|
||||
<label class="text-xs text-primary/70">Contingency %:</label>
|
||||
<input
|
||||
type="number"
|
||||
class="w-16 px-2 py-1 border border-primary/20 bg-background rounded text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||
onInput={(e) => setBulkContingency(parseFloat(e.currentTarget.value) || 0)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { contingency: bulkContingency(), contingencyType: 'percent' })}
|
||||
class="px-2 py-1 bg-primary text-primary-foreground text-xs rounded-lg font-bold hover:bg-primary/90"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
|
||||
class="p-6 space-y-4 relative"
|
||||
>
|
||||
{/* Column Headers - Sticky, Square Top */}
|
||||
<div class="sticky top-header z-20 bg-background/95 backdrop-blur-sm border-b border-border py-2 mb-2 hidden md:flex items-center gap-4 text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-3 rounded-none">
|
||||
<div class="w-4"></div> {/* Grip space */}
|
||||
<div class="flex-1">Description</div>
|
||||
<div class="w-20 text-right">Qty</div>
|
||||
<div class="w-24 text-right">Price</div>
|
||||
<div class="w-24 text-right">Subtotal</div>
|
||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||
<div class="w-32 text-center">Markup</div>
|
||||
<div class="w-32 text-center">Contingency</div>
|
||||
</Show>
|
||||
<div class="w-28 text-right px-2">Total</div>
|
||||
<div class="w-16"></div> {/* Action space */}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
{/* Scope Level Items */}
|
||||
<For each={props.items.filter(i => !i.subScopeId)}>
|
||||
{(item) => (
|
||||
<LazyItem estimatedHeight={60}>
|
||||
<ItemRow
|
||||
item={item}
|
||||
onUpdate={props.onUpdateItem}
|
||||
onDelete={props.onDeleteItem}
|
||||
onDragStart={props.onDragStart}
|
||||
onDuplicateItem={props.onDuplicateItem}
|
||||
isSidebarCollapsed={props.isSidebarCollapsed}
|
||||
isSelected={props.selectedIds.includes(item.id)}
|
||||
anySelected={props.selectedIds.length > 0}
|
||||
onToggleSelection={props.onToggleSelection}
|
||||
hideColumns={props.hideColumns}
|
||||
/>
|
||||
</LazyItem>
|
||||
)}
|
||||
</For>
|
||||
<Show when={scopeItems().length === 0 && props.subScopes.length === 0}>
|
||||
<div class="text-center py-8 text-muted-foreground/50 italic text-sm">
|
||||
Empty scope. Drag items here or add sub-scopes.
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Sub-scopes */}
|
||||
<div class="space-y-6 pt-4">
|
||||
<For each={props.subScopes}>
|
||||
{(subScope) => (
|
||||
<SubScopeCard
|
||||
subScope={subScope}
|
||||
items={props.items.filter(i => i.subScopeId === subScope.id)}
|
||||
onUpdateSubScope={props.onUpdateSubScope}
|
||||
onUpdateItem={props.onUpdateItem}
|
||||
onDeleteItem={props.onDeleteItem}
|
||||
onDeleteSubScope={props.onDeleteSubScope}
|
||||
onDragStart={props.onDragStart}
|
||||
onDrop={(e, sid, ssid) => props.onDrop(e, sid, ssid)}
|
||||
onDuplicateItem={props.onDuplicateItem}
|
||||
allScopes={props.allScopes}
|
||||
allSubScopes={props.allSubScopes}
|
||||
onCopySubScopeItems={props.onCopySubScopeItems}
|
||||
isSidebarCollapsed={props.isSidebarCollapsed}
|
||||
selectedIds={props.selectedIds}
|
||||
onToggleSelection={props.onToggleSelection}
|
||||
onClearSelection={props.onClearSelection}
|
||||
hideColumns={props.hideColumns}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Scope Extras & Totals */}
|
||||
<div class="mt-8 pt-6 border-t border-border space-y-3">
|
||||
{/* Management Line */}
|
||||
<div class="flex items-center justify-between group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-muted-foreground">Management Fee</span>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => props.onUpdateScope(props.scope.id, { useManagementLogic: 'percent' })}
|
||||
class={`p-1 rounded-md transition-colors ${props.scope.useManagementLogic === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<Percent class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onUpdateScope(props.scope.id, { useManagementLogic: 'simple' })}
|
||||
class={`p-1 rounded-md transition-colors ${props.scope.useManagementLogic === 'simple' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<DollarSign class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
type="number"
|
||||
value={props.scope.managementFee}
|
||||
onInput={(e) => props.onUpdateScope(props.scope.id, { managementFee: parseFloat(e.currentTarget.value) || 0 })}
|
||||
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
|
||||
/>
|
||||
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().mgmtTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Fee */}
|
||||
<div class="flex items-center justify-between group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-muted-foreground">Delivery Fee</span>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => props.onUpdateScope(props.scope.id, { useDeliveryLogic: 'percent' })}
|
||||
class={`p-1 rounded-md transition-colors ${props.scope.useDeliveryLogic === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<Percent class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onUpdateScope(props.scope.id, { useDeliveryLogic: 'simple' })}
|
||||
class={`p-1 rounded-md transition-colors ${props.scope.useDeliveryLogic === 'simple' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<DollarSign class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
type="number"
|
||||
value={props.scope.deliveryFee}
|
||||
onInput={(e) => props.onUpdateScope(props.scope.id, { deliveryFee: parseFloat(e.currentTarget.value) || 0 })}
|
||||
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
|
||||
/>
|
||||
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().deliveryTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope Notes Section */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-8 border-t border-border">
|
||||
<NoteEditor
|
||||
label="Estimator's Notes"
|
||||
value={props.scope.estimatorNotes || ''}
|
||||
onUpdate={(val) => props.onUpdateScope(props.scope.id, { estimatorNotes: val })}
|
||||
icon={ClipboardList}
|
||||
highlight={props.scope.estimatorNotes?.includes('#')}
|
||||
/>
|
||||
<NoteEditor
|
||||
label="Scope Description (Appears on Finale Estimate)"
|
||||
value={props.scope.bidDescription || ''}
|
||||
onUpdate={(val) => props.onUpdateScope(props.scope.id, { bidDescription: val })}
|
||||
icon={FileText}
|
||||
highlight={props.scope.bidDescription?.includes('#')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope Totals Breakdown */}
|
||||
<div class="space-y-1 text-sm pt-4">
|
||||
<div class="flex justify-end gap-8 text-muted-foreground italic">
|
||||
<span>Scope Subtotal:</span>
|
||||
<span class="w-28 text-right">${formatNumber(totals().subtotal)}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-8 text-muted-foreground italic">
|
||||
<span>Scope Total Quantity:</span>
|
||||
<span class="w-28 text-right">{formatQuantity(totals().totalQuantity)}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-8 text-primary">
|
||||
<span>Scope Markup:</span>
|
||||
<span class="w-28 text-right">${formatNumber(totals().markup)}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-8 text-orange-600">
|
||||
<span>Scope Contingency:</span>
|
||||
<span class="w-28 text-right">${formatNumber(totals().contingency)}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-8 font-black text-foreground text-xl mt-4">
|
||||
<span>Scope Grand Total:</span>
|
||||
<span class="w-32 text-right border-t-2 border-foreground pt-1">
|
||||
${formatNumber(totals().total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScopeCard;
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, createMemo, Show, createSignal } from 'solid-js';
|
||||
import { Trash2, ChevronDown, Copy, ListTree } from 'lucide-solid';
|
||||
import type { EstimateItem, SubScope, Scope } from '../types';
|
||||
import ItemRow from './ItemRow';
|
||||
import LazyItem from './LazyItem';
|
||||
|
||||
interface SubScopeCardProps {
|
||||
subScope: SubScope;
|
||||
items: EstimateItem[];
|
||||
onUpdateSubScope: (id: string, updates: Partial<SubScope>) => void;
|
||||
onUpdateItem: (id: string, updates: Partial<EstimateItem>) => void;
|
||||
onDeleteItem: (id: string) => void;
|
||||
onDeleteSubScope: (id: string) => void;
|
||||
onDragStart: (e: DragEvent, id: string) => void;
|
||||
onDrop: (e: DragEvent, scopeId: string, subScopeId: string) => void;
|
||||
onDuplicateItem: (id: string) => void;
|
||||
allScopes: Scope[];
|
||||
allSubScopes: SubScope[];
|
||||
onCopySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
|
||||
isSidebarCollapsed: () => boolean;
|
||||
selectedIds: string[];
|
||||
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
|
||||
onClearSelection: () => void;
|
||||
hideColumns?: boolean;
|
||||
}
|
||||
|
||||
const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
const [isCollapsed, setIsCollapsed] = createSignal(false);
|
||||
const [isOver, setIsOver] = createSignal(false);
|
||||
const [showCopyMenu, setShowCopyMenu] = createSignal(false);
|
||||
|
||||
const totals = createMemo(() => {
|
||||
return 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 });
|
||||
});
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const formatQuantity = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(false);
|
||||
props.onDrop(e, props.subScope.scopeId, props.subScope.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`subscope-${props.subScope.id}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
class={`border rounded-xl transition-all duration-200 ml-8
|
||||
${isOver()
|
||||
? 'bg-primary/5 border-primary ring-4 ring-primary/10 z-10'
|
||||
: 'bg-card border-border hover:border-border/50 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div class="px-4 py-3 flex items-center justify-between border-b border-border/40">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed())}
|
||||
class="p-1 hover:bg-muted rounded transition-colors text-muted-foreground"
|
||||
>
|
||||
<ChevronDown class={`w-4 h-4 transition-transform duration-200 ${isCollapsed() ? '-rotate-90' : ''}`} />
|
||||
</button>
|
||||
<div class="p-1.5 bg-muted rounded-lg text-muted-foreground">
|
||||
<ListTree class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={props.subScope.name}
|
||||
onInput={(e) => props.onUpdateSubScope(props.subScope.id, { name: e.currentTarget.value })}
|
||||
class="bg-transparent border-none outline-none font-black text-foreground text-sm focus:ring-0 w-48"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
<span>{props.items.length} Items</span>
|
||||
<span>•</span>
|
||||
<span class="text-muted-foreground/60">{formatQuantity(totals().quantity)} Qty</span>
|
||||
<span>•</span>
|
||||
<span class="text-primary font-black">${formatNumber(totals().amount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setShowCopyMenu(!showCopyMenu())}
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded-lg text-[10px] font-black uppercase tracking-wider text-muted-foreground hover:text-primary hover:border-primary/30 transition-all"
|
||||
title="Copy items to another sub-scope"
|
||||
>
|
||||
<span class="p-0.5 bg-muted rounded">
|
||||
<Copy class="w-2.5 h-2.5" />
|
||||
</span>
|
||||
Copy To
|
||||
</button>
|
||||
|
||||
<Show when={showCopyMenu()}>
|
||||
<div
|
||||
class="absolute right-0 mt-1 w-64 bg-popover border border-border rounded-xl shadow-elevated z-[100] py-2 animate-in fade-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div class="px-4 py-2 border-b border-border/40 mb-1">
|
||||
<span class="text-[9px] font-black text-muted-foreground uppercase tracking-[0.2em]">Select Target Sub-scope</span>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<For each={props.allScopes}>
|
||||
{(scope) => (
|
||||
<div class="px-2">
|
||||
<div class="px-2 py-1 text-[9px] font-black text-primary/50 uppercase tracking-widest">{scope.name}</div>
|
||||
<For each={props.allSubScopes.filter(ss => ss.scopeId === scope.id && ss.id !== props.subScope.id)}>
|
||||
{(targetSS) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
props.onCopySubScopeItems(props.subScope.id, targetSS.id);
|
||||
setShowCopyMenu(false);
|
||||
}}
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-xs font-bold text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between group"
|
||||
>
|
||||
<span>{targetSS.name}</span>
|
||||
<ChevronDown class="w-3 h-3 opacity-0 group-hover:opacity-40 -rotate-90" />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => props.onDeleteSubScope(props.subScope.id)}
|
||||
class="p-1.5 text-muted-foreground/30 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!isCollapsed()}>
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
|
||||
class="p-2 space-y-1.5"
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<LazyItem estimatedHeight={60}>
|
||||
<ItemRow
|
||||
item={item}
|
||||
onUpdate={props.onUpdateItem}
|
||||
onDelete={props.onDeleteItem}
|
||||
onDragStart={props.onDragStart}
|
||||
onDuplicateItem={props.onDuplicateItem}
|
||||
isSidebarCollapsed={props.isSidebarCollapsed}
|
||||
isSelected={props.selectedIds.includes(item.id)}
|
||||
anySelected={props.selectedIds.length > 0}
|
||||
onToggleSelection={props.onToggleSelection}
|
||||
hideColumns={props.hideColumns}
|
||||
/>
|
||||
</LazyItem>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.items.length === 0}>
|
||||
<div class="text-center py-4 text-[10px] font-bold text-muted-foreground/40 uppercase tracking-widest italic">
|
||||
Empty Sub-scope
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubScopeCard;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Show } from 'solid-js';
|
||||
|
||||
interface ToastProps {
|
||||
show: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const Toast: Component<ToastProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.show}>
|
||||
<div
|
||||
class="fixed bottom-12 left-1/2 -translate-x-1/2 min-w-[250px] bg-gray-800 text-white text-center rounded-lg p-4 shadow-lg transition-all transform animate-bounce-in z-50"
|
||||
>
|
||||
{props.message}
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
Reference in New Issue
Block a user