diff --git a/src b/src deleted file mode 160000 index f12bf53..0000000 --- a/src +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f12bf533892f611065f31cd6b5c50ab1ef9b30f2 diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..613607d --- /dev/null +++ b/src/App.css @@ -0,0 +1,27 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.solid:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..bacc16a --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,35 @@ +import { createSignal } from 'solid-js' +import solidLogo from './assets/solid.svg' +import viteLogo from '/vite.svg' +import './App.css' + +function App() { + const [count, setCount] = createSignal(0) + + return ( + <> +
+ + + + + + +
+

Vite + Solid

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and Solid logos to learn more +

+ + ) +} + +export default App diff --git a/src/ItemExtractor.tsx b/src/ItemExtractor.tsx new file mode 100644 index 0000000..cdc64c7 --- /dev/null +++ b/src/ItemExtractor.tsx @@ -0,0 +1,83 @@ +import type { Component } from 'solid-js'; +import { createSignal, Show } from 'solid-js'; +import { customElement, noShadowDOM } from 'solid-element'; +import FileUpload from './components/FileUpload'; +import EstimateBuilder from './components/EstimateBuilder'; +import IgnoredLinesList from './components/IgnoredLinesList'; +import Toast from './components/Toast'; +import { extractItemsFromCSV } from './utils/csv-parser'; +import type { ExtractedItem } from './utils/csv-parser'; +import { FileSpreadsheet } from 'lucide-solid'; + +interface ItemExtractorProps { + onItemsExtracted?: (items: ExtractedItem[]) => void; + onCopy?: (text: string) => void; +} + +const ItemExtractor: Component = () => { + noShadowDOM(); + const [items, setItems] = createSignal([]); + const [ignoredLines, setIgnoredLines] = createSignal([]); + const [showToast] = createSignal(false); + const [toastMessage] = createSignal(''); + + const handleFileSelect = (csvText: string) => { + const { items: extracted, ignored } = extractItemsFromCSV(csvText); + setItems(extracted); + setIgnoredLines(ignored); + }; + + return ( +
+ {/* Header - Hidden after upload */} + +
+
+
+

Item Parser & Extractor

+

Upload your CSV to extract "Item" fields and split quantities.

+
+
+ +
+
+ + +
+
+ + {/* Results */} + 0} + fallback={ +
+

Results will appear here

+
+ } + > +
+ { + setItems([]); + setIgnoredLines([]); + }} + /> +
+
+ + 0}> +
+ +
+
+ + +
+ ); +}; + +// Register as Custom Element +customElement('item-extractor', { onItemsExtracted: undefined, onCopy: undefined }, ItemExtractor); + +export default ItemExtractor; diff --git a/src/assets/solid.svg b/src/assets/solid.svg new file mode 100644 index 0000000..025aa30 --- /dev/null +++ b/src/assets/solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/company-info.ts b/src/company-info.ts new file mode 100644 index 0000000..abfc37e --- /dev/null +++ b/src/company-info.ts @@ -0,0 +1,20 @@ +export const COMPANY_NAME = "Cardoza Construction, LLC"; +export const COMPANY_ADDRESS = "1836 N Deffer Dr. Nixa, MO 65714"; +export const COMPANY_PHONE = "417-725-6717"; +export const COMPANY_EMAIL = "Sales@cardoza.construction"; + +export const GENERAL_NOTES_DEFAULT = `- Client will need to have area professionally cleaned after our work is complete. Our work order includes basic cleanup, unless otherwise specified, but some dust, mud, and residue may remain. +- Price is for entire portion of each scope of work to be done in one mobilization (Unless otherwise noted in writing). If multiple mobilizations will be required, please notify our office so we can adjust our schedule and price. +- Estimate is based on standard scheduling and working hours. Please allow reasonable time without +- Job site is to be clean and free of debris or other trades material. If situation occurs to where it is not clear, we will notify builder/owner first, and then if still not cleaned up we will clean and reasonably charge for time. +- Concrete slab, sub-floor, and stairs must be installed prior to work. Additional charges may apply for gravel or other uneven surfaces. +- Water, electricity and dumpster are necessities for drywall. Please advise before if any of these will not be available. Builder to supply dumpster, chute or fork lift if needed. +- The owner or general contractor is responsible for approval of drywall upon completion, and prior to painting. Cardoza Construction LLC is not responsible, or liable, for any repainting charges. +- Heat is required certain months of the year. Our estimates do NOT include heat on jobs, unless specifically noted. If heat is not furnished by contractor or owner, we will furnish heaters and fuel, and these charges will be added to invoice. Lack of heat can significantly affect production and quality of job. +- Temperature of work area is to be in accordance with ASTM prior to finishing drywall, installing FRP, or any other material that is reliant on heat or consistent temperatures, and kept at a steady temperature there after, or warranty may be void. +- Price does not include any caulking/sealant (fire, acoustical, etc.). +- Providing a professional and positive work environment for our team and our customers is core to who we are. + +Foul language, harassment, or yelling does not provide a positive atmosphere for our team. Cardoza Construction reserves the right to withdraw our team from a hostile environment until the situation is corrected which may cause delays to the job or termination of our contract. Client shall terminate under the AIA 14.4 Termination by Owner for Convenience clause.#`; + +export const GENERAL_PRE_NOTES_DEFAULT = `Material and labor pricing is good through #. Materials must be ordered prior to this date to avoid price increase. A material draw is due upon delivery (COD) and labor net 15 days.`; diff --git a/src/components/CalculatorPopover.tsx b/src/components/CalculatorPopover.tsx new file mode 100644 index 0000000..73d5fca --- /dev/null +++ b/src/components/CalculatorPopover.tsx @@ -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([]); + 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 ( + +
+ +
{ e.preventDefault(); setIsHovered(true); }} + onDragLeave={() => setIsHovered(false)} + onDrop={handleDrop} + > + {/* Header */} +
+
+
+ +
+ Item Calculator +
+
+ + +
+
+ + {/* Display */} +
+
Calculation History
+ +
+ + {(entry) => ( + + + {entry.label} + +
+ {entry.type === 'result' && =} + {formatNumber(entry.value || 0)} +
+
+ }> +
+
+ + {entry.label} + +
+
+ + )} + + +
{operator()}
+
+
+ +
+ 0 && entries()[entries().length - 1].type === 'result'}> + Total + + $ + {currentInput() || formatNumber(runningTotal())} +
+
+ + {/* Controls */} +
+ + + + + + + + + +
+ + {/* Drop Zone Info */} +
+ {isHovered() ? 'Release to add field' : 'Drag item fields here to calculate'} +
+
+ + + + +
+ + ); +}; + +export default CalculatorPopover; diff --git a/src/components/EstimateBuilder.tsx b/src/components/EstimateBuilder.tsx new file mode 100644 index 0000000..ed26f11 --- /dev/null +++ b/src/components/EstimateBuilder.tsx @@ -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 = (props) => { + const [scopes, setScopes] = createStore([]); + const [subScopes, setSubScopes] = createStore([]); + const [items, setItems] = createStore([]); + const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false); + const [isNotesExpanded, setIsNotesExpanded] = createSignal(true); + const [isPreNotesExpanded, setIsPreNotesExpanded] = createSignal(true); + const [hoveredScopeId, setHoveredScopeId] = createSignal(null); + const [selectedIds, setSelectedIds] = createSignal([]); + const [lastSelectedId, setLastSelectedId] = createSignal(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({ + 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(); + + 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) => { + 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) => { + setScopes(s => s.id === id, updates); + }; + + const updateSubScope = (id: string, updates: Partial) => { + 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(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) => { + const val = selectionTotals()[column]; + navigator.clipboard.writeText(String(val)).then(() => { + const labels: Record = { + quantity: 'Qty', unitPrice: 'Price', subtotal: 'Subtotal', + markup: 'Markup', contingency: 'Contingency', total: 'Total' + }; + setCopyToast(`Copied ${labels[column]}: ${val}`); + setTimeout(() => setCopyToast(null), 2500); + }); + }; + + return ( +
+ {/* Print Summary View */} + + + {/* Main Application UI */} +
+ {/* Header Area - Sticky & Slim */} +
+
+ +
+
+ +
+
+

Estimate Builder

+
+ {scopes.length} Scopes + + {items.length} Items +
+
+
+
+
+ + +
+ + +
+
+ +
+ {/* Collapsible Sidebar */} + + + {/* Main Content Area */} +
+ {/* Client & Project Details Form (No Print) */} +
+
+
+ +
+

Client & Project Details

+
+ +
+
+

Client Information

+
+ 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" /> + 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" /> +
+ 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" /> + 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" /> +
+
+
+ +
+

Project Information

+
+
+ 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" /> + 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" /> +
+ 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" /> + 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" /> +
+
+
+
+ + {/* General Pre-Notes Section */} +
+
+
+
+ +
+
+

General Pre-Notes

+

Project-wide preamble & terms

+
+
+ +
+ + +
+ +
+
+
+ + {/* Unassigned Items Section */} + 0}> +
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" + > +
+
+
+ +
+
+

Unassigned Takeoff Items

+

Drag these items into scopes to begin your estimate.

+
+
+ + {unassignedItems().length} Items Pending + +
+ {/* Sticky Column Headers for Unassigned */} + + +
+ + {(item) => ( + + 0} + onToggleSelection={(id, isMulti, isShift) => toggleSelection(id, isMulti, isShift)} + hideColumns={anyDescriptionIsLong()} + /> + + )} + +
+
+
+ + {/* Scopes Content */} +
+ + {(scope) => ( +
+ 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()} + /> +
+ )} +
+ + 0}> +
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+

No Scopes Defined

+

Click "New Scope" in the header to start building your estimate structure.

+
+ +
+
+
+ + {/* Note Validation Status Bar */} +
+
+
+ {validationStats().hasIncomplete ? : } +
+
+

+ {validationStats().hasIncomplete + ? `Attention Required: ${validationStats().incompleteCount} fields contain unfinalized information (#)` + : "All notes have been updated."} +

+

+ {validationStats().hasIncomplete + ? "Please replace all '#' markers with final project data before finalizing." + : "Everything looks good to go!"} +

+
+
+ +
+ Ready to Print +
+
+
+ + {/* Light-Themed Bottom Executive Summary */} +
+
+
+
+
+ Executive Summary +
+
+
+
+
+ Project Subtotal +
${formatNumber(grandTotals().subtotal)}
+
+
+ Total Markup +
${formatNumber(grandTotals().markup)}
+
+
+ Contingency +
${formatNumber(grandTotals().contingency)}
+
+
+ Fees & Logistics +
${formatNumber(grandTotals().fees)}
+
+
+
+ +

+ Comprehensive project total including all active scopes, sub-scopes, and unassigned takeoff items. + Pricing is calculated dynamically based on current material/labor allocations. +

+
+
+ +
+
Total Estimate Value
+
+ $ + {formatNumber(grandTotals().grandTotal)} +
+
+ +
+
+
+
+
+
+ {/* Floating Calculator */} + +
+ + {/* Floating Selection Toolbar - Portal used to ensure it anchors to viewport */} + = 2}> + +
+
+ + {selectedIds().length} + + Selected +
+
+ + {(item) => ( + + )} + +
+
+ +
+ + + + {/* Copy Toast */} + + +
+ ✓ {copyToast()} +
+
+
+
+ ); +}; + +export default EstimateBuilder; diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx new file mode 100644 index 0000000..a13a294 --- /dev/null +++ b/src/components/FileUpload.tsx @@ -0,0 +1,49 @@ +import type { Component } from 'solid-js'; +import { UploadCloud } from 'lucide-solid'; + +interface FileUploadProps { + onFileSelect: (text: string) => void; +} + +const FileUpload: Component = (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 ( +
+ +
+ ); +}; + +export default FileUpload; diff --git a/src/components/IgnoredLinesList.tsx b/src/components/IgnoredLinesList.tsx new file mode 100644 index 0000000..8be941f --- /dev/null +++ b/src/components/IgnoredLinesList.tsx @@ -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 = (props) => { + const [isOpen, setIsOpen] = createSignal(false); + + return ( +
+ + + +
+ + + + + + + + + + {(row, index) => ( + + + + + )} + + +
LineRaw Data
{index() + 1} + {row.join(' | ')} +
+
+ +

+ These lines were skipped because they don't follow the "Item : Description (Qty)" or "Item : Description" pattern. +

+
+
+
+
+ ); +}; + +export default IgnoredLinesList; diff --git a/src/components/ItemRow.tsx b/src/components/ItemRow.tsx new file mode 100644 index 0000000..3345038 --- /dev/null +++ b/src/components/ItemRow.tsx @@ -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) => 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 = (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 ( +
+
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" + > + +
+ +
+ +