Added template creator (doesn't work without db)
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "item-extractor-solid",
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"lucide-solid": "^0.577.0",
|
||||
"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=="],
|
||||
|
||||
"@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/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
@@ -9,7 +9,7 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<item-extractor></item-extractor>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"lucide-solid": "^0.577.0",
|
||||
"solid-element": "^1.9.1",
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
@@ -15,7 +15,7 @@ interface ItemExtractorProps {
|
||||
}
|
||||
|
||||
const ItemExtractor: Component<ItemExtractorProps> = () => {
|
||||
noShadowDOM();
|
||||
|
||||
const [items, setItems] = createSignal<ExtractedItem[]>([]);
|
||||
const [ignoredLines, setIgnoredLines] = createSignal<string[][]>([]);
|
||||
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;
|
||||
|
||||
@@ -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
@@ -1,3 +1,21 @@
|
||||
/* @refresh reload */
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
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!);
|
||||
|
||||
@@ -38,3 +38,15 @@ export interface EstimateItem {
|
||||
scopeId?: string;
|
||||
subScopeId?: string;
|
||||
}
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
items: TemplateItem[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user