+ );
+};
+
+// Register as Custom Element
+customElement('item-extractor', { onItemsExtracted: undefined, onCopy: undefined }, ItemExtractor);
+
+export default ItemExtractor;
diff --git a/src/assets/solid.svg b/src/assets/solid.svg
new file mode 100644
index 0000000..025aa30
--- /dev/null
+++ b/src/assets/solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/company-info.ts b/src/company-info.ts
new file mode 100644
index 0000000..abfc37e
--- /dev/null
+++ b/src/company-info.ts
@@ -0,0 +1,20 @@
+export const COMPANY_NAME = "Cardoza Construction, LLC";
+export const COMPANY_ADDRESS = "1836 N Deffer Dr. Nixa, MO 65714";
+export const COMPANY_PHONE = "417-725-6717";
+export const COMPANY_EMAIL = "Sales@cardoza.construction";
+
+export const GENERAL_NOTES_DEFAULT = `- Client will need to have area professionally cleaned after our work is complete. Our work order includes basic cleanup, unless otherwise specified, but some dust, mud, and residue may remain.
+- Price is for entire portion of each scope of work to be done in one mobilization (Unless otherwise noted in writing). If multiple mobilizations will be required, please notify our office so we can adjust our schedule and price.
+- Estimate is based on standard scheduling and working hours. Please allow reasonable time without
+- Job site is to be clean and free of debris or other trades material. If situation occurs to where it is not clear, we will notify builder/owner first, and then if still not cleaned up we will clean and reasonably charge for time.
+- Concrete slab, sub-floor, and stairs must be installed prior to work. Additional charges may apply for gravel or other uneven surfaces.
+- Water, electricity and dumpster are necessities for drywall. Please advise before if any of these will not be available. Builder to supply dumpster, chute or fork lift if needed.
+- The owner or general contractor is responsible for approval of drywall upon completion, and prior to painting. Cardoza Construction LLC is not responsible, or liable, for any repainting charges.
+- Heat is required certain months of the year. Our estimates do NOT include heat on jobs, unless specifically noted. If heat is not furnished by contractor or owner, we will furnish heaters and fuel, and these charges will be added to invoice. Lack of heat can significantly affect production and quality of job.
+- Temperature of work area is to be in accordance with ASTM prior to finishing drywall, installing FRP, or any other material that is reliant on heat or consistent temperatures, and kept at a steady temperature there after, or warranty may be void.
+- Price does not include any caulking/sealant (fire, acoustical, etc.).
+- Providing a professional and positive work environment for our team and our customers is core to who we are.
+
+Foul language, harassment, or yelling does not provide a positive atmosphere for our team. Cardoza Construction reserves the right to withdraw our team from a hostile environment until the situation is corrected which may cause delays to the job or termination of our contract. Client shall terminate under the AIA 14.4 Termination by Owner for Convenience clause.#`;
+
+export const GENERAL_PRE_NOTES_DEFAULT = `Material and labor pricing is good through #. Materials must be ordered prior to this date to avoid price increase. A material draw is due upon delivery (COD) and labor net 15 days.`;
diff --git a/src/components/CalculatorPopover.tsx b/src/components/CalculatorPopover.tsx
new file mode 100644
index 0000000..73d5fca
--- /dev/null
+++ b/src/components/CalculatorPopover.tsx
@@ -0,0 +1,271 @@
+import type { Component } from 'solid-js';
+import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
+import { Calculator, X, Trash2, Plus, Minus, Asterisk, Divide, Equal, Flag } from 'lucide-solid';
+
+interface Entry {
+ id: string;
+ type: 'number' | 'field' | 'marker' | 'result';
+ value?: number;
+ label?: string;
+}
+
+import { Portal } from 'solid-js/web';
+
+const CalculatorPopover: Component = () => {
+ const [isOpen, setIsOpen] = createSignal(false);
+ const [entries, setEntries] = createSignal([]);
+ const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
+ const [currentInput, setCurrentInput] = createSignal('');
+ const [isHovered, setIsHovered] = createSignal(false);
+
+ // For simplicity in this version, we'll just track a running total
+ const [runningTotal, setRunningTotal] = createSignal(0);
+
+ const handleDrop = (e: DragEvent) => {
+ e.preventDefault();
+ setIsHovered(false);
+ const dataStr = e.dataTransfer?.getData('application/json');
+ if (dataStr) {
+ try {
+ const data = JSON.parse(dataStr);
+ if (data.type === 'item-field') {
+ addValue(data.value, data.label);
+ }
+ } catch (err) {
+ console.error('Failed to parse dropped data', err);
+ }
+ } else {
+ const text = e.dataTransfer?.getData('text/plain');
+ if (text && !isNaN(parseFloat(text))) {
+ addValue(parseFloat(text), 'Dropped Value');
+ }
+ }
+ };
+
+ const addValue = (val: number, label?: string) => {
+ const op = operator();
+ if (!op && entries().length > 0 && entries()[entries().length - 1].type !== 'result' && entries()[entries().length - 1].type !== 'marker') {
+ // If no operator but we have a previous value that isn't a result/marker, default to addition
+ let nextTotal = runningTotal() + val;
+ setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
+ setRunningTotal(nextTotal);
+ } else if (!op) {
+ setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
+ setRunningTotal(val);
+ } else {
+ let nextTotal = runningTotal();
+ if (op === '+') nextTotal += val;
+ if (op === '-') nextTotal -= val;
+ if (op === '*') nextTotal *= val;
+ if (op === '/') nextTotal = val !== 0 ? nextTotal / val : 0;
+
+ setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
+ setRunningTotal(nextTotal);
+ setOperator(null);
+ }
+ };
+
+ const handleNumberClick = (num: string) => {
+ setCurrentInput(prev => prev + num);
+ };
+
+ const handleOperatorClick = (op: '+' | '-' | '*' | '/') => {
+ if (currentInput()) {
+ addValue(parseFloat(currentInput()));
+ setCurrentInput('');
+ }
+ setOperator(op);
+ };
+
+ const handleEquals = () => {
+ if (currentInput()) {
+ addValue(parseFloat(currentInput()));
+ setCurrentInput('');
+ }
+
+ // Add result entry to history
+ const result = runningTotal();
+ setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'result', value: result }]);
+ setOperator(null);
+ };
+
+ const createMarker = () => {
+ setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', label: `Marker ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` }]);
+ };
+
+ const clearInput = () => {
+ setRunningTotal(0);
+ setOperator(null);
+ setCurrentInput('');
+ };
+
+ const resetHistory = () => {
+ if (confirm('Clear entire history?')) {
+ setEntries([]);
+ clearInput();
+ }
+ };
+
+ const formatNumber = (num: number) => {
+ return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
+ };
+
+ // Keyboard support
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (!isOpen()) return;
+
+ if (e.key >= '0' && e.key <= '9') {
+ handleNumberClick(e.key);
+ } else if (e.key === '.') {
+ if (!currentInput().includes('.')) handleNumberClick('.');
+ } else if (e.key === '+') {
+ handleOperatorClick('+');
+ } else if (e.key === '-') {
+ handleOperatorClick('-');
+ } else if (e.key === '*' || e.key === 'x') {
+ handleOperatorClick('*');
+ } else if (e.key === '/') {
+ handleOperatorClick('/');
+ } else if (e.key === 'Enter' || e.key === '=') {
+ handleEquals();
+ } else if (e.key === 'Escape') {
+ setIsOpen(false);
+ } else if (e.key === 'Backspace' && currentInput()) {
+ setCurrentInput(prev => prev.slice(0, -1));
+ } else if (e.key === 'Delete' || (e.key === 'Backspace' && !currentInput())) {
+ clearInput();
+ }
+ };
+
+ onMount(() => {
+ window.addEventListener('keydown', handleKeyDown);
+ });
+
+ onCleanup(() => {
+ window.removeEventListener('keydown', handleKeyDown);
+ });
+
+ return (
+
+
Click "New Scope" in the header to start building your estimate structure.
+
+
+
+
+
+
+ {/* Note Validation Status Bar */}
+
+
+
+ {validationStats().hasIncomplete ? : }
+
+
+
+ {validationStats().hasIncomplete
+ ? `Attention Required: ${validationStats().incompleteCount} fields contain unfinalized information (#)`
+ : "All notes have been updated."}
+
+
+ {validationStats().hasIncomplete
+ ? "Please replace all '#' markers with final project data before finalizing."
+ : "Everything looks good to go!"}
+
+ Comprehensive project total including all active scopes, sub-scopes, and unassigned takeoff items.
+ Pricing is calculated dynamically based on current material/labor allocations.
+
+
+
+
+
+
Total Estimate Value
+
+ $
+ {formatNumber(grandTotals().grandTotal)}
+
+
+
+
+
+
+
+
+
+ {/* Floating Calculator */}
+
+
+
+ {/* Floating Selection Toolbar - Portal used to ensure it anchors to viewport */}
+ = 2}>
+
+
+ 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)
+
+
+
+
+
Notice to Owner
+
+ {NOTICE_TO_OWNERS.replace('NOTICE TO OWNER', '').trim()}
+
+
+
+
+ {/* Print Page Counter */}
+
+
+ );
+};
+
+export default PrintEstimate;
diff --git a/src/components/ResultsTable.tsx b/src/components/ResultsTable.tsx
new file mode 100644
index 0000000..db819cc
--- /dev/null
+++ b/src/components/ResultsTable.tsx
@@ -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;
+ copiedParts: Record;
+ onCopy: (id: string, text: string, type: 'desc' | 'qty') => void;
+ onToggleCheck: (id: string) => void;
+}
+
+const ResultsTable: Component = (props) => {
+ return (
+
+`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`${n}>
+`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${R(e,!0)}`}br(e){return" "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='"+s+"",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=`",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:R(e.text)}};var _=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||w,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new _}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=`
+Please report this to https://github.com/markedjs/marked.`,e){let s="
An error occurred:
"+R(n.message+"",!0)+"
";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex;
+
+if(__exports != exports)module.exports = exports;return module.exports}));
diff --git a/src/utils/xml-parser.ts b/src/utils/xml-parser.ts
new file mode 100644
index 0000000..1185e05
--- /dev/null
+++ b/src/utils/xml-parser.ts
@@ -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 and tags.
+ */
+export function parseXML(text: string): string[][] {
+ const rows: string[][] = [];
+ const lines = text.split('');
+
+ for (const line of lines) {
+ if (!line.includes('')) continue;
+
+ // Extract content inside ...
+ const lineContent = line.substring(line.indexOf('') + 6);
+ const cells = lineContent.split('');
+ const currentRow: string[] = [];
+
+ for (const cell of cells) {
+ if (!cell.includes(' ...
+ 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 };
+}
\ No newline at end of file