Added template creator (doesn't work without db)

This commit is contained in:
2026-03-10 19:15:03 -05:00
parent 5a8b20a827
commit 39c5021e2f
10 changed files with 322 additions and 8 deletions
+3
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "item-extractor-solid", "name": "item-extractor-solid",
"dependencies": { "dependencies": {
"@solidjs/router": "^0.15.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"lucide-solid": "^0.577.0", "lucide-solid": "^0.577.0",
"solid-element": "^1.9.1", "solid-element": "^1.9.1",
@@ -171,6 +172,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
+1 -1
View File
@@ -9,7 +9,7 @@
</head> </head>
<body> <body>
<item-extractor></item-extractor> <div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@solidjs/router": "^0.15.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"lucide-solid": "^0.577.0", "lucide-solid": "^0.577.0",
"solid-element": "^1.9.1", "solid-element": "^1.9.1",
+49
View File
@@ -0,0 +1,49 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
interface AppContainerProps {
children?: any;
}
const AppContainer: Component<AppContainerProps> = (props) => {
return (
<div class="min-h-screen bg-gray-50/50">
{/* Top Navigation Bar */}
<header class="bg-white border-b border-gray-200 sticky top-0 z-10 no-print">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center gap-8">
<div class="flex-shrink-0 flex items-center">
<span class="font-bold text-xl text-gray-900 tracking-tight">EstiMaker</span>
</div>
<nav class="flex space-x-4">
<A
href="/"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md transition-colors"
activeClass="text-blue-600 bg-blue-50 hover:bg-blue-50 hover:text-blue-700"
end={true}
>
Item Extractor
</A>
<A
href="/templates"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md transition-colors"
activeClass="text-blue-600 bg-blue-50 hover:bg-blue-50 hover:text-blue-700"
>
Template Creator
</A>
</nav>
</div>
</div>
</div>
</header>
{/* Main Content Area */}
<main class="max-w-6xl mx-auto p-4 sm:p-6 lg:p-8">
{props.children}
</main>
</div>
);
};
export default AppContainer;
+2 -5
View File
@@ -1,6 +1,6 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { createSignal, Show } from 'solid-js'; import { createSignal, Show } from 'solid-js';
import { customElement, noShadowDOM } from 'solid-element';
import FileUpload from './components/FileUpload'; import FileUpload from './components/FileUpload';
import EstimateBuilder from './components/EstimateBuilder'; import EstimateBuilder from './components/EstimateBuilder';
import IgnoredLinesList from './components/IgnoredLinesList'; import IgnoredLinesList from './components/IgnoredLinesList';
@@ -15,7 +15,7 @@ interface ItemExtractorProps {
} }
const ItemExtractor: Component<ItemExtractorProps> = () => { const ItemExtractor: Component<ItemExtractorProps> = () => {
noShadowDOM();
const [items, setItems] = createSignal<ExtractedItem[]>([]); const [items, setItems] = createSignal<ExtractedItem[]>([]);
const [ignoredLines, setIgnoredLines] = createSignal<string[][]>([]); const [ignoredLines, setIgnoredLines] = createSignal<string[][]>([]);
const [showToast] = createSignal(false); const [showToast] = createSignal(false);
@@ -77,7 +77,4 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
); );
}; };
// Register as Custom Element
customElement('item-extractor', { onItemsExtracted: undefined, onCopy: undefined }, ItemExtractor);
export default ItemExtractor; export default ItemExtractor;
@@ -0,0 +1,49 @@
import type { Component } from 'solid-js';
import { For } from 'solid-js';
import { Plus } from 'lucide-solid';
import type { TemplateItem } from '../../types';
import TemplateItemRow from './TemplateItemRow';
interface TemplateItemListProps {
items: TemplateItem[];
onUpdateItem: (id: string, updates: Partial<TemplateItem>) => void;
onDeleteItem: (id: string) => void;
onAddItem: () => void;
}
const TemplateItemList: Component<TemplateItemListProps> = (props) => {
return (
<div class="mt-8 space-y-4">
<div class="flex items-center justify-between pb-2 border-b border-border/60">
<h3 class="text-lg font-semibold text-foreground/90">Template Items</h3>
<button
onClick={props.onAddItem}
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
<Plus class="w-4 h-4" />
Add Item
</button>
</div>
<div class="space-y-2">
<For each={props.items}>
{(item) => (
<TemplateItemRow
item={item}
onUpdate={props.onUpdateItem}
onDelete={props.onDeleteItem}
/>
)}
</For>
{props.items.length === 0 && (
<div class="text-center py-12 text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-card">
<p>No items added yet. Click "Add Item" to start building this template.</p>
</div>
)}
</div>
</div>
);
};
export default TemplateItemList;
@@ -0,0 +1,97 @@
import type { Component } from 'solid-js';
import { createSignal, createEffect } from 'solid-js';
import { Trash2, GripVertical } from 'lucide-solid';
import type { TemplateItem } from '../../types';
interface TemplateItemRowProps {
item: TemplateItem;
onUpdate: (id: string, updates: Partial<TemplateItem>) => void;
onDelete: (id: string) => void;
}
const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
let descriptionRef: HTMLTextAreaElement | undefined;
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);
};
return (
<div class={`group flex items-start gap-4 p-3 border transition-all animate-in fade-in slide-in-from-left-2 rounded-2xl bg-card border-border hover:shadow-premium-hover hover:border-border/80 ${isEditing() ? 'ring-1 ring-primary/20 shadow-premium border-primary/30' : ''}`}>
<div class="cursor-grab transition-colors shrink-0 w-4 mt-2 p-1 -m-1 rounded hover:bg-muted text-muted-foreground/30 hover:text-muted-foreground/60" title="Drag to reorder (Coming soon)">
<GripVertical class="w-4 h-4" />
</div>
<div class="flex-1 relative">
<textarea
ref={descriptionRef}
value={localDescription()}
onFocus={() => setIsEditing(true)}
onBlur={handleBlur}
onInput={(e) => {
setLocalDescription(e.currentTarget.value);
if (!('fieldSizing' in document.documentElement.style)) {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}
}}
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"
style={{ "field-sizing": "content", "min-height": "1.5em" }}
placeholder="Item description..."
/>
</div>
<div class="w-24 shrink-0 mt-1">
<input
type="number"
value={props.item.quantity}
onInput={(e) => props.onUpdate(props.item.id, { quantity: parseFloat(e.currentTarget.value) || 0 })}
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 font-bold text-foreground/80 transition-all"
placeholder="Qty"
/>
</div>
<div class="w-32 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"
step="0.1"
value={props.item.unitPrice}
onInput={(e) => props.onUpdate(props.item.id, { unitPrice: parseFloat(e.currentTarget.value) || 0 })}
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 font-bold text-foreground/80 transition-all"
placeholder="Price"
/>
</div>
</div>
<div class="w-10 shrink-0 flex items-center justify-end gap-1.5 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => 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 TemplateItemRow;
+20 -2
View File
@@ -1,3 +1,21 @@
/* @refresh reload */ import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import './index.css'; import './index.css';
import './ItemExtractor'; import AppContainer from './AppContainer';
import ItemExtractor from './ItemExtractor';
import TemplateCreator from './views/TemplateCreator';
const root = document.getElementById('root');
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?',
);
}
render(() => (
<Router root={AppContainer}>
<Route path="/" component={ItemExtractor as any} />
<Route path="/templates" component={TemplateCreator} />
</Router>
), root!);
+12
View File
@@ -38,3 +38,15 @@ export interface EstimateItem {
scopeId?: string; scopeId?: string;
subScopeId?: string; subScopeId?: string;
} }
export interface TemplateItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
}
export interface Template {
id: string;
name: string;
items: TemplateItem[];
}
+88
View File
@@ -0,0 +1,88 @@
import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';
import { Save } from 'lucide-solid';
import type { TemplateItem, Template } from '../types';
import TemplateItemList from '../components/template/TemplateItemList';
const TemplateCreator: Component = () => {
const [templateName, setTemplateName] = createSignal('');
const [items, setItems] = createSignal<TemplateItem[]>([]);
const handleAddItem = () => {
const newItem: TemplateItem = {
id: crypto.randomUUID(),
description: '',
quantity: 1,
unitPrice: 0
};
setItems([...items(), newItem]);
};
const handleUpdateItem = (id: string, updates: Partial<TemplateItem>) => {
setItems(items().map(item => item.id === id ? { ...item, ...updates } : item));
};
const handleDeleteItem = (id: string) => {
setItems(items().filter(item => item.id !== id));
};
const handleSaveTemplate = () => {
// Here we would typically save to localStorage or backend
// For now, we'll just log it or show a toast
const template: Template = {
id: crypto.randomUUID(),
name: templateName(),
items: items()
};
console.log('Template saved:', template);
alert(`Template "${template.name}" saved successfully!`);
// Reset form
setTemplateName('');
setItems([]);
};
return (
<div class="flex flex-col items-center py-6 px-4 font-sans print:bg-transparent print:border-none print:p-0">
<div class="max-w-4xl w-full bg-white rounded-xl shadow-premium p-8 mb-8 border border-gray-100 no-print">
<div class="flex items-center justify-between mb-8 pb-4 border-b border-gray-100">
<div>
<h1 class="text-3xl font-bold text-gray-800 mb-2 tracking-tight">Template Creator</h1>
<p class="text-gray-500 font-medium">Create templates for your estimate sub-scopes.</p>
</div>
<button
onClick={handleSaveTemplate}
disabled={!templateName() || items().length === 0}
class="flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-all shadow-premium hover:shadow-premium-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save class="w-4 h-4" />
Save Template
</button>
</div>
<div class="mb-8">
<label class="block text-sm font-semibold text-gray-700 mb-2">
Sub-Scope Name
</label>
<input
type="text"
value={templateName()}
onInput={(e) => setTemplateName(e.currentTarget.value)}
placeholder="e.g. Master Bathroom Rough-in"
class="w-full bg-gray-50/50 border border-gray-200 rounded-xl px-4 py-3 text-gray-800 focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none font-medium transition-all shadow-sm"
/>
</div>
<TemplateItemList
items={items()}
onAddItem={handleAddItem}
onUpdateItem={handleUpdateItem}
onDeleteItem={handleDeleteItem}
/>
</div>
</div>
);
};
export default TemplateCreator;