True clean start with src folder
This commit is contained in:
-1
Submodule src deleted from f12bf53389
+27
@@ -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;
|
||||||
|
}
|
||||||
+35
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<a href="https://vite.dev" target="_blank">
|
||||||
|
<img src={viteLogo} class="logo" alt="Vite logo" />
|
||||||
|
</a>
|
||||||
|
<a href="https://solidjs.com" target="_blank">
|
||||||
|
<img src={solidLogo} class="logo solid" alt="Solid logo" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h1>Vite + Solid</h1>
|
||||||
|
<div class="card">
|
||||||
|
<button onClick={() => setCount((count) => count + 1)}>
|
||||||
|
count is {count()}
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
Edit <code>src/App.tsx</code> and save to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="read-the-docs">
|
||||||
|
Click on the Vite and Solid logos to learn more
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -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<ItemExtractorProps> = () => {
|
||||||
|
noShadowDOM();
|
||||||
|
const [items, setItems] = createSignal<ExtractedItem[]>([]);
|
||||||
|
const [ignoredLines, setIgnoredLines] = createSignal<string[][]>([]);
|
||||||
|
const [showToast] = createSignal(false);
|
||||||
|
const [toastMessage] = createSignal('');
|
||||||
|
|
||||||
|
const handleFileSelect = (csvText: string) => {
|
||||||
|
const { items: extracted, ignored } = extractItemsFromCSV(csvText);
|
||||||
|
setItems(extracted);
|
||||||
|
setIgnoredLines(ignored);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center py-10 px-4 font-sans border border-gray-200 rounded-xl bg-gray-50/50 backdrop-blur-sm print:bg-transparent print:border-none print:p-0">
|
||||||
|
{/* Header - Hidden after upload */}
|
||||||
|
<Show when={items().length === 0}>
|
||||||
|
<div class="max-w-4xl w-full bg-white rounded-xl shadow-sm p-8 mb-8 border border-gray-100 no-print">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-2">Item Parser & Extractor</h1>
|
||||||
|
<p class="text-gray-600">Upload your CSV to extract "Item" fields and split quantities.</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-blue-600 text-4xl">
|
||||||
|
<FileSpreadsheet class="w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileUpload onFileSelect={handleFileSelect} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<Show
|
||||||
|
when={items().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="text-gray-400 text-center mt-12 opacity-50">
|
||||||
|
<p>Results will appear here</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="w-full">
|
||||||
|
<EstimateBuilder
|
||||||
|
initialItems={items()}
|
||||||
|
onReset={() => {
|
||||||
|
setItems([]);
|
||||||
|
setIgnoredLines([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={ignoredLines().length > 0}>
|
||||||
|
<div class="mt-8 max-w-4xl w-full no-print">
|
||||||
|
<IgnoredLinesList lines={ignoredLines()} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Toast show={showToast()} message={toastMessage()} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register as Custom Element
|
||||||
|
customElement('item-extractor', { onItemsExtracted: undefined, onCopy: undefined }, ItemExtractor);
|
||||||
|
|
||||||
|
export default ItemExtractor;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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.`;
|
||||||
@@ -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;
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
/* Import Standard Shadcn Font (Inter) */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Clean, single-font family for a modern SaaS look */
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* Classic Shadcn Zinc Palette */
|
||||||
|
--color-background: hsl(0 0% 100%);
|
||||||
|
--color-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
|
--color-card: hsl(0 0% 100%);
|
||||||
|
--color-card-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
|
--color-popover: hsl(0 0% 100%);
|
||||||
|
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
|
--color-primary: hsl(240 5.9% 10%);
|
||||||
|
--color-primary-foreground: hsl(0 0% 98%);
|
||||||
|
|
||||||
|
--color-secondary: hsl(240 4.8% 95.9%);
|
||||||
|
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||||
|
|
||||||
|
--color-muted: hsl(240 4.8% 95.9%);
|
||||||
|
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||||
|
|
||||||
|
--color-accent: hsl(240 4.8% 95.9%);
|
||||||
|
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
|
||||||
|
--color-destructive: hsl(0 84.2% 60.2%);
|
||||||
|
--color-destructive-foreground: hsl(0 0% 98%);
|
||||||
|
|
||||||
|
/* Softened borders to reduce the "inky" grid feel */
|
||||||
|
--color-border: hsl(240 5.9% 90%);
|
||||||
|
--color-input: hsl(240 5.9% 90%);
|
||||||
|
--shadow-premium: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
--color-ring: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
|
/* Tighter shadcn radii for a sharper, professional edge */
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
|
/* Preserved Layout Tokens */
|
||||||
|
--spacing-header: 4rem;
|
||||||
|
--spacing-sidebar: 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Standard shadcn base radius */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground antialiased selection:bg-primary/10 selection:text-primary;
|
||||||
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preserved Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/40 transition-colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preserved Print Optimization */
|
||||||
|
@layer utilities {
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
@apply !hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-only {
|
||||||
|
@apply !block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply !bg-white !text-black !p-0 !m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-page-number {
|
||||||
|
@apply fixed bottom-4 right-4 text-[10px] font-bold text-slate-400;
|
||||||
|
counter-increment: page-counter;
|
||||||
|
content: "Page " counter(page-counter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-only {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/* @refresh reload */
|
||||||
|
import './index.css';
|
||||||
|
import './ItemExtractor';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const NOTICE_TO_OWNERS = `
|
||||||
|
NOTICE TO OWNER
|
||||||
|
Failure of this contractor to pay those persons supplying material or services to complete this contract can result in the filing of a mechanic's lien on the property which is the subject of this contract pursuant to Chapter 429 RSMo. To avoid this result you may ask this contractor for "lien waivers" from all persons supplying material or services for the work described in this contract. Failure to secure lien waivers may result in your paying for labor and material twice. Payment due upon receipt. Any payment not received within 30 days will be assessed a 1 1/2% per month finance charge. As a part of the consideration hereof, the customer agrees to pay all costs of collection, including but not limited to all court costs and reasonable attorney's fees, if this account is placed for collection.
|
||||||
|
`;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface Scope {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
managementFee: number;
|
||||||
|
managementFeeType: 'percent' | 'amount';
|
||||||
|
deliveryFee: number;
|
||||||
|
deliveryFeeType: 'percent' | 'amount';
|
||||||
|
useManagementLogic: 'percent' | 'simple';
|
||||||
|
useDeliveryLogic: 'percent' | 'simple';
|
||||||
|
estimatorNotes?: string;
|
||||||
|
bidDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubScope {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobInfo {
|
||||||
|
number: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
workOrder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PricingType = 'percent' | 'amount';
|
||||||
|
|
||||||
|
export interface EstimateItem {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
markup: number;
|
||||||
|
markupType: PricingType;
|
||||||
|
contingency: number;
|
||||||
|
contingencyType: PricingType;
|
||||||
|
scopeId?: string;
|
||||||
|
subScopeId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
export interface ExtractedItem {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCSV(text: string): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let currentRow: string[] = [];
|
||||||
|
let currentCell = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
const nextChar = text[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && nextChar === '"') {
|
||||||
|
currentCell += '"';
|
||||||
|
i++;
|
||||||
|
} else if (!inQuotes) {
|
||||||
|
inQuotes = true;
|
||||||
|
} else {
|
||||||
|
const isClosing = nextChar === ',' || nextChar === '\r' || nextChar === '\n' || nextChar === undefined;
|
||||||
|
if (isClosing) {
|
||||||
|
inQuotes = false;
|
||||||
|
} else {
|
||||||
|
currentCell += '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
currentRow.push(currentCell);
|
||||||
|
currentCell = '';
|
||||||
|
} else if ((char === '\r' || char === '\n') && !inQuotes) {
|
||||||
|
if (currentCell || currentRow.length > 0) currentRow.push(currentCell);
|
||||||
|
if (currentRow.length > 0) rows.push(currentRow);
|
||||||
|
currentRow = [];
|
||||||
|
currentCell = '';
|
||||||
|
if (char === '\r' && nextChar === '\n') i++;
|
||||||
|
} else {
|
||||||
|
currentCell += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCell || currentRow.length > 0) {
|
||||||
|
currentRow.push(currentCell);
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractionResult {
|
||||||
|
items: ExtractedItem[];
|
||||||
|
ignored: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractItemsFromCSV(csvText: string): ExtractionResult {
|
||||||
|
const rows = parseCSV(csvText);
|
||||||
|
const extractedItems: ExtractedItem[] = [];
|
||||||
|
const ignored: string[][] = [];
|
||||||
|
|
||||||
|
const regexInline = /^Item\s*:\s*([\s\S]*?)\s*\(\s*([\d,.]+)\s*\).*$/i;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
let matched = false;
|
||||||
|
row.forEach((cell, index) => {
|
||||||
|
if (cell && typeof cell === 'string' && cell.trim().toLowerCase().startsWith('item')) {
|
||||||
|
const cellContent = cell.trim();
|
||||||
|
const match = cellContent.match(regexInline);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
let desc = match[1].trim().replace(/,\s*$/, '').trim();
|
||||||
|
|
||||||
|
extractedItems.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
description: desc,
|
||||||
|
quantity: match[2].trim()
|
||||||
|
});
|
||||||
|
matched = true;
|
||||||
|
} else {
|
||||||
|
let description = cellContent.replace(/^Item\s*:\s*/i, '').trim().replace(/,\s*$/, '').trim();
|
||||||
|
|
||||||
|
let quantity = '';
|
||||||
|
if (index + 1 < row.length) {
|
||||||
|
const nextCell = row[index + 1];
|
||||||
|
if (nextCell) {
|
||||||
|
const nextCellStr = nextCell.toString().trim();
|
||||||
|
const qtyMatch = nextCellStr.match(/[\d,.]+/);
|
||||||
|
if (qtyMatch) {
|
||||||
|
quantity = qtyMatch[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedItems.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
description,
|
||||||
|
quantity: quantity || ""
|
||||||
|
});
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
// Check if row is not just empty strings
|
||||||
|
if (row.some(cell => cell.trim() !== '')) {
|
||||||
|
ignored.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items: extractedItems, ignored };
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
|||||||
|
export interface ExtractedItem {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractionResult {
|
||||||
|
items: ExtractedItem[];
|
||||||
|
ignored: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses raw XML text into a 2D array of strings based on <LINE> and <CELL> tags.
|
||||||
|
*/
|
||||||
|
export function parseXML(text: string): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
const lines = text.split('</LINE>');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.includes('<LINE>')) continue;
|
||||||
|
|
||||||
|
// Extract content inside <LINE> ... </LINE>
|
||||||
|
const lineContent = line.substring(line.indexOf('<LINE>') + 6);
|
||||||
|
const cells = lineContent.split('</CELL>');
|
||||||
|
const currentRow: string[] = [];
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (!cell.includes('<CELL')) continue;
|
||||||
|
|
||||||
|
// Extract content inside <CELL ...> ... </CELL>
|
||||||
|
const cellContent = cell.substring(cell.indexOf('>') + 1);
|
||||||
|
currentRow.push(cellContent.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRow.length > 0) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts items from the parsed XML grid, matching the CSV parser's output structure.
|
||||||
|
*/
|
||||||
|
export function extractItemsFromXML(xmlText: string): ExtractionResult {
|
||||||
|
const rows = parseXML(xmlText);
|
||||||
|
const extractedItems: ExtractedItem[] = [];
|
||||||
|
const ignored: string[][] = [];
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
let matched = false;
|
||||||
|
|
||||||
|
// Valid data lines in the provided XML have at least 8 columns.
|
||||||
|
// Index 6 maps to "Item", Index 7 maps to "Order" (Quantity)
|
||||||
|
if (row.length >= 8) {
|
||||||
|
const itemDesc = row[6];
|
||||||
|
const quantityStr = row[7];
|
||||||
|
|
||||||
|
// Filter out the header row ('Item') and empty/layout spacer rows ('.')
|
||||||
|
if (itemDesc && quantityStr && itemDesc !== 'Item' && itemDesc !== '.') {
|
||||||
|
const qtyMatch = quantityStr.match(/[\d,.]+/);
|
||||||
|
|
||||||
|
if (qtyMatch) {
|
||||||
|
// Remove trailing commas, identically to the CSV parser logic
|
||||||
|
const desc = itemDesc.replace(/,\s*$/, '').trim();
|
||||||
|
|
||||||
|
extractedItems.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
description: desc,
|
||||||
|
quantity: qtyMatch[0]
|
||||||
|
});
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
// Check if row is not just empty strings, mimicking original logic
|
||||||
|
if (row.some(cell => cell !== '')) {
|
||||||
|
ignored.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items: extractedItems, ignored };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user