Full Pocketbase Integration
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
"@solidjs/router": "^0.15.4",
|
"@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",
|
||||||
|
"pocketbase": "^0.26.8",
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
"solid-js": "^1.9.11",
|
"solid-js": "^1.9.11",
|
||||||
},
|
},
|
||||||
@@ -318,6 +319,8 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"pocketbase": ["pocketbase@0.26.8", "", {}, "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||||
|
|
||||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@solidjs/router": "^0.15.4",
|
"@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",
|
||||||
|
"pocketbase": "^0.26.8",
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
"solid-js": "^1.9.11"
|
"solid-js": "^1.9.11"
|
||||||
},
|
},
|
||||||
|
|||||||
+31
-6
@@ -7,7 +7,9 @@ import IgnoredLinesList from './components/IgnoredLinesList';
|
|||||||
import Toast from './components/Toast';
|
import Toast from './components/Toast';
|
||||||
import { extractItemsFromCSV } from './utils/csv-parser';
|
import { extractItemsFromCSV } from './utils/csv-parser';
|
||||||
import type { ExtractedItem } from './utils/csv-parser';
|
import type { ExtractedItem } from './utils/csv-parser';
|
||||||
import { FileSpreadsheet } from 'lucide-solid';
|
import { FileSpreadsheet, Download } from 'lucide-solid';
|
||||||
|
import { appStore } from './store/appStore';
|
||||||
|
import CloudLoadModal from './components/CloudLoadModal';
|
||||||
|
|
||||||
interface ItemExtractorProps {
|
interface ItemExtractorProps {
|
||||||
onItemsExtracted?: (items: ExtractedItem[]) => void;
|
onItemsExtracted?: (items: ExtractedItem[]) => void;
|
||||||
@@ -16,10 +18,16 @@ interface ItemExtractorProps {
|
|||||||
|
|
||||||
const ItemExtractor: Component<ItemExtractorProps> = () => {
|
const ItemExtractor: Component<ItemExtractorProps> = () => {
|
||||||
|
|
||||||
const [items, setItems] = createSignal<ExtractedItem[]>([]);
|
const items = appStore.items;
|
||||||
const [ignoredLines, setIgnoredLines] = createSignal<string[][]>([]);
|
const setItems = appStore.setItems;
|
||||||
|
const ignoredLines = appStore.ignoredLines;
|
||||||
|
const setIgnoredLines = appStore.setIgnoredLines;
|
||||||
|
const estimateItems = appStore.estimateItems;
|
||||||
|
const setEstimateName = appStore.setEstimateName;
|
||||||
|
|
||||||
const [showToast] = createSignal(false);
|
const [showToast] = createSignal(false);
|
||||||
const [toastMessage] = createSignal('');
|
const [toastMessage] = createSignal('');
|
||||||
|
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
||||||
|
|
||||||
const handleFileSelect = (csvText: string) => {
|
const handleFileSelect = (csvText: string) => {
|
||||||
const { items: extracted, ignored } = extractItemsFromCSV(csvText);
|
const { items: extracted, ignored } = extractItemsFromCSV(csvText);
|
||||||
@@ -29,8 +37,8 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
|
|||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* Header - Hidden after upload or load */}
|
||||||
<Show when={items().length === 0}>
|
<Show when={items().length === 0 && estimateItems.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="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 class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -43,12 +51,26 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileUpload onFileSelect={handleFileSelect} />
|
<FileUpload onFileSelect={handleFileSelect} />
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-col items-center justify-center">
|
||||||
|
<div class="flex items-center w-full max-w-sm mb-6">
|
||||||
|
<div class="flex-1 h-px bg-gray-200"></div>
|
||||||
|
<span class="px-4 text-[10px] font-black text-gray-400 uppercase tracking-[0.2em]">OR</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLoadModal(true)}
|
||||||
|
class="flex items-center justify-center gap-3 w-full max-w-sm px-6 py-4 bg-white text-gray-700 rounded-2xl font-bold shadow-sm border border-gray-200 hover:bg-gray-50 hover:border-gray-300 hover:shadow transition-all"
|
||||||
|
>
|
||||||
|
<Download class="w-5 h-5 text-blue-600" /> Load Saved Estimate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<Show
|
<Show
|
||||||
when={items().length > 0}
|
when={items().length > 0 || estimateItems.length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="text-gray-400 text-center mt-12 opacity-50">
|
<div class="text-gray-400 text-center mt-12 opacity-50">
|
||||||
<p>Results will appear here</p>
|
<p>Results will appear here</p>
|
||||||
@@ -61,6 +83,7 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
|
|||||||
onReset={() => {
|
onReset={() => {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setIgnoredLines([]);
|
setIgnoredLines([]);
|
||||||
|
setEstimateName('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +96,8 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Toast show={showToast()} message={toastMessage()} />
|
<Toast show={showToast()} message={toastMessage()} />
|
||||||
|
|
||||||
|
<CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { createSignal, Show, For, createEffect } from 'solid-js';
|
||||||
|
import { Portal } from 'solid-js/web';
|
||||||
|
import { X } from 'lucide-solid';
|
||||||
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
|
import { appStore } from '../store/appStore';
|
||||||
|
|
||||||
|
interface CloudLoadModalProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
|
||||||
|
const [cloudEstimates, setCloudEstimates] = createSignal<any[]>([]);
|
||||||
|
const [isCloudLoading, setIsCloudLoading] = createSignal(false);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.show) {
|
||||||
|
loadCloudEstimatesList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadCloudEstimatesList = async () => {
|
||||||
|
setIsCloudLoading(true);
|
||||||
|
try {
|
||||||
|
const records = await pb.collection(COLLECTIONS.ESTIMATES).getFullList({
|
||||||
|
sort: '-created'
|
||||||
|
});
|
||||||
|
setCloudEstimates(records);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to list estimates.');
|
||||||
|
} finally {
|
||||||
|
setIsCloudLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFromCloud = (record: any) => {
|
||||||
|
const data = record.items; // items holds the full JSON blob
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
if (data.scopes) appStore.setScopes(data.scopes);
|
||||||
|
if (data.subScopes) appStore.setSubScopes(data.subScopes);
|
||||||
|
if (data.items) appStore.setEstimateItems(data.items);
|
||||||
|
if (data.clientInfo) appStore.setClientInfo(data.clientInfo);
|
||||||
|
if (data.jobInfo) appStore.setJobInfo(data.jobInfo);
|
||||||
|
if (data.generalEstimateNotes !== undefined) appStore.setGeneralEstimateNotes(data.generalEstimateNotes);
|
||||||
|
if (data.generalPreNotes !== undefined) appStore.setGeneralPreNotes(data.generalPreNotes);
|
||||||
|
|
||||||
|
appStore.setEstimateName(record.name);
|
||||||
|
appStore.setEstimateId(record.id);
|
||||||
|
|
||||||
|
// Also clear out the extracted CSV items so we don't accidentally switch back to the upload screen or cause conflicts
|
||||||
|
appStore.setItems([]);
|
||||||
|
|
||||||
|
props.onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.show}>
|
||||||
|
<Portal>
|
||||||
|
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div class="bg-card w-full max-w-2xl max-h-[85vh] flex flex-col rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
|
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<h3 class="text-xl font-black">Load from Cloud</h3>
|
||||||
|
<button onClick={props.onClose} class="p-2 hover:bg-muted rounded-xl transition-colors">
|
||||||
|
<X class="w-5 h-5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 flex flex-col">
|
||||||
|
<Show
|
||||||
|
when={!isCloudLoading()}
|
||||||
|
fallback={<div class="py-12 text-center text-muted-foreground">Loading estimates...</div>}
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<For each={cloudEstimates()}>
|
||||||
|
{(record) => (
|
||||||
|
<div class="group flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-muted/10 hover:bg-primary/5 hover:border-primary/30 transition-all">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-foreground mb-1">{record.name}</h4>
|
||||||
|
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||||
|
{new Date(record.created).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => loadFromCloud(record)}
|
||||||
|
class="px-4 py-2 bg-background border border-border rounded-xl text-xs font-bold shadow-sm hover:text-primary hover:border-primary/50 transition-all opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={cloudEstimates().length === 0}>
|
||||||
|
<div class="py-12 text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-muted/5">
|
||||||
|
No saved estimates found.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CloudLoadModal;
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { createSignal, For, createMemo, onMount, Show } from 'solid-js';
|
import { createSignal, For, createMemo, onMount, Show } from 'solid-js';
|
||||||
import { Portal } from 'solid-js/web';
|
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 { 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 { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
|
||||||
import type { Scope, SubScope, EstimateItem, JobInfo } from '../types';
|
import type { Scope, SubScope, EstimateItem } from '../types';
|
||||||
import type { ExtractedItem } from '../utils/csv-parser';
|
import type { ExtractedItem } from '../utils/csv-parser';
|
||||||
import ScopeCard from './ScopeCard';
|
import ScopeCard from './ScopeCard';
|
||||||
import ItemRow from './ItemRow';
|
import ItemRow from './ItemRow';
|
||||||
@@ -12,6 +11,9 @@ import PrintEstimate from './PrintEstimate';
|
|||||||
import NoteEditor from './NoteEditor';
|
import NoteEditor from './NoteEditor';
|
||||||
import LazyItem from './LazyItem';
|
import LazyItem from './LazyItem';
|
||||||
import CalculatorPopover from './CalculatorPopover';
|
import CalculatorPopover from './CalculatorPopover';
|
||||||
|
import { appStore } from '../store/appStore';
|
||||||
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
|
import CloudLoadModal from './CloudLoadModal';
|
||||||
|
|
||||||
interface EstimateBuilderProps {
|
interface EstimateBuilderProps {
|
||||||
initialItems: ExtractedItem[];
|
initialItems: ExtractedItem[];
|
||||||
@@ -19,34 +21,42 @@ interface EstimateBuilderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||||
const [scopes, setScopes] = createStore<Scope[]>([]);
|
// Use Global Store State
|
||||||
const [subScopes, setSubScopes] = createStore<SubScope[]>([]);
|
const scopes = appStore.scopes;
|
||||||
const [items, setItems] = createStore<EstimateItem[]>([]);
|
const setScopes = appStore.setScopes;
|
||||||
|
const subScopes = appStore.subScopes;
|
||||||
|
const setSubScopes = appStore.setSubScopes;
|
||||||
|
const items = appStore.estimateItems;
|
||||||
|
const setItems = appStore.setEstimateItems;
|
||||||
|
const clientInfo = appStore.clientInfo;
|
||||||
|
const setClientInfo = appStore.setClientInfo;
|
||||||
|
const jobInfo = appStore.jobInfo;
|
||||||
|
const setJobInfo = appStore.setJobInfo;
|
||||||
|
const generalEstimateNotes = appStore.generalEstimateNotes;
|
||||||
|
const setGeneralEstimateNotes = appStore.setGeneralEstimateNotes;
|
||||||
|
const generalPreNotes = appStore.generalPreNotes;
|
||||||
|
const setGeneralPreNotes = appStore.setGeneralPreNotes;
|
||||||
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
|
||||||
const [isNotesExpanded, setIsNotesExpanded] = createSignal(true);
|
const [isNotesExpanded, setIsNotesExpanded] = createSignal(true);
|
||||||
const [isPreNotesExpanded, setIsPreNotesExpanded] = createSignal(true);
|
const [isPreNotesExpanded, setIsPreNotesExpanded] = createSignal(true);
|
||||||
const [hoveredScopeId, setHoveredScopeId] = createSignal<string | null>(null);
|
const [hoveredScopeId, setHoveredScopeId] = createSignal<string | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = createSignal<string[]>([]);
|
const [selectedIds, setSelectedIds] = createSignal<string[]>([]);
|
||||||
const [lastSelectedId, setLastSelectedId] = createSignal<string | null>(null);
|
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
|
// Cloud Modals
|
||||||
const [clientInfo, setClientInfo] = createStore({
|
const [showSaveModal, setShowSaveModal] = createSignal(false);
|
||||||
name: '',
|
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
||||||
address: '',
|
const [isCloudLoading, setIsCloudLoading] = createSignal(false);
|
||||||
phone: '',
|
|
||||||
fax: ''
|
|
||||||
});
|
|
||||||
const [jobInfo, setJobInfo] = createStore<JobInfo>({
|
|
||||||
number: '',
|
|
||||||
name: '',
|
|
||||||
address: '',
|
|
||||||
workOrder: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Convert initial items to Estimate items
|
// Initialize general notes if not already set by an imported estimate
|
||||||
|
if (!generalEstimateNotes()) setGeneralEstimateNotes(GENERAL_NOTES_DEFAULT);
|
||||||
|
if (!generalPreNotes()) setGeneralPreNotes(GENERAL_PRE_NOTES_DEFAULT);
|
||||||
|
|
||||||
|
// Convert initial items to Estimate items only if we are creating a new estimate
|
||||||
|
// (i.e. we don't already have estimateItems loaded)
|
||||||
|
if (items.length === 0 && props.initialItems.length > 0) {
|
||||||
const transformed: EstimateItem[] = props.initialItems.map(item => ({
|
const transformed: EstimateItem[] = props.initialItems.map(item => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
@@ -58,6 +68,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
contingencyType: 'percent'
|
contingencyType: 'percent'
|
||||||
}));
|
}));
|
||||||
setItems(transformed);
|
setItems(transformed);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const unassignedItems = createMemo(() => items.filter(i => !i.scopeId));
|
const unassignedItems = createMemo(() => items.filter(i => !i.scopeId));
|
||||||
@@ -169,6 +180,37 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFullEstimateData = () => {
|
||||||
|
return {
|
||||||
|
scopes: [...scopes],
|
||||||
|
subScopes: [...subScopes],
|
||||||
|
items: [...items],
|
||||||
|
clientInfo: { ...clientInfo },
|
||||||
|
jobInfo: { ...jobInfo },
|
||||||
|
generalEstimateNotes: generalEstimateNotes(),
|
||||||
|
generalPreNotes: generalPreNotes()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveToCloud = async (name: string) => {
|
||||||
|
setIsCloudLoading(true);
|
||||||
|
try {
|
||||||
|
const data = getFullEstimateData();
|
||||||
|
await pb.collection(COLLECTIONS.ESTIMATES).create({
|
||||||
|
name,
|
||||||
|
items: data
|
||||||
|
});
|
||||||
|
setShowSaveModal(false);
|
||||||
|
appStore.setEstimateName(name); // update header
|
||||||
|
alert('Estimate saved to PocketBase successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to save. Ensure EstiMaker_Estimates collection permissions are public.');
|
||||||
|
} finally {
|
||||||
|
setIsCloudLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addScope = () => {
|
const addScope = () => {
|
||||||
const scopeId = crypto.randomUUID();
|
const scopeId = crypto.randomUUID();
|
||||||
const newScope: Scope = {
|
const newScope: Scope = {
|
||||||
@@ -455,14 +497,27 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
<div class="h-6 w-px bg-border mx-1"></div>
|
<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">
|
<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
|
<Upload class="w-4 h-4" /> Import JSON
|
||||||
<input type="file" accept=".json" class="hidden" onChange={importEstimate} />
|
<input type="file" accept=".json" class="hidden" onChange={importEstimate} />
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={exportEstimate}
|
onClick={exportEstimate}
|
||||||
|
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 mr-2"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" /> Export JSON
|
||||||
|
</button>
|
||||||
|
<div class="h-6 w-px bg-border mx-1"></div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLoadModal(true)}
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-200"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" /> Load
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSaveModal(true)}
|
||||||
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"
|
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
|
<Upload class="w-4 h-4" /> Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -899,7 +954,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={finalizeBid}
|
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"
|
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-95 flex items-center justify-center gap-3"
|
||||||
>
|
>
|
||||||
<Plus class="w-5 h-5" /> Finalize Bid
|
<Plus class="w-5 h-5" /> Finalize Bid
|
||||||
</button>
|
</button>
|
||||||
@@ -961,14 +1016,54 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
|||||||
</Portal>
|
</Portal>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Copy Toast */}
|
{/* Toast */}
|
||||||
<Show when={copyToast() !== null}>
|
<Show when={copyToast()}>
|
||||||
|
<div class="fixed bottom-6 right-6 bg-gray-900 border border-gray-800 text-white px-4 py-3 rounded-xl shadow-2xl flex items-center gap-3 animate-in slide-in-from-bottom-5 z-50 pointer-events-none fade-out-90 duration-300">
|
||||||
|
<CheckCircle2 class="w-5 h-5 text-green-400" />
|
||||||
|
<span class="font-medium text-sm drop-shadow-sm">{copyToast()}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Save Modal */}
|
||||||
|
<Show when={showSaveModal()}>
|
||||||
<Portal>
|
<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">
|
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
✓ {copyToast()}
|
<div class="bg-card w-full max-w-md rounded-3xl shadow-premium border border-border p-6 animate-in zoom-in-95 duration-200">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-xl font-black">Save to Cloud</h3>
|
||||||
|
<button onClick={() => setShowSaveModal(false)} class="p-2 hover:bg-muted rounded-xl transition-colors">
|
||||||
|
<X class="w-5 h-5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="cloud-save-name"
|
||||||
|
placeholder="Estimate Name (e.g. Smith Master Bath)"
|
||||||
|
class="w-full bg-muted/30 border border-border/60 rounded-xl px-4 py-3 text-sm focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium"
|
||||||
|
value={appStore.estimateName()}
|
||||||
|
/>
|
||||||
|
<div class="flex gap-3 justify-end mt-6">
|
||||||
|
<button onClick={() => setShowSaveModal(false)} class="px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-muted transition-colors">Cancel</button>
|
||||||
|
<button
|
||||||
|
disabled={isCloudLoading()}
|
||||||
|
onClick={() => {
|
||||||
|
const name = (document.getElementById('cloud-save-name') as HTMLInputElement).value;
|
||||||
|
if (name) saveToCloud(name);
|
||||||
|
}}
|
||||||
|
class="px-5 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-premium hover:shadow-premium-hover transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isCloudLoading() ? 'Saving...' : 'Save Estimate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Load Modal */}
|
||||||
|
<CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { For } from 'solid-js';
|
import { For, Show } from 'solid-js';
|
||||||
import { Plus } from 'lucide-solid';
|
import { Plus } from 'lucide-solid';
|
||||||
import type { TemplateItem } from '../../types';
|
import type { TemplateItem } from '../../types';
|
||||||
import TemplateItemRow from './TemplateItemRow';
|
import TemplateItemRow from './TemplateItemRow';
|
||||||
@@ -26,6 +26,19 @@ const TemplateItemList: Component<TemplateItemListProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
{/* Table Header */}
|
||||||
|
<Show when={props.items.length > 0}>
|
||||||
|
<div class="flex items-center gap-4 px-3 py-1 text-[10px] font-bold text-muted-foreground/50 uppercase tracking-wider">
|
||||||
|
<div class="w-4 shrink-0"></div> {/* Grip Spacer */}
|
||||||
|
<div class="flex-1">Description</div>
|
||||||
|
<div class="w-24 shrink-0 text-right">Qty</div>
|
||||||
|
<div class="w-32 shrink-0 text-right pr-6">Price</div>
|
||||||
|
<div class="w-28 shrink-0 text-center">Markup</div>
|
||||||
|
<div class="w-28 shrink-0 text-center">Cont</div>
|
||||||
|
<div class="w-10 shrink-0"></div> {/* Actions Spacer */}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<For each={props.items}>
|
<For each={props.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TemplateItemRow
|
<TemplateItemRow
|
||||||
|
|||||||
@@ -79,6 +79,38 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={props.item.markup}
|
||||||
|
onInput={(e) => props.onUpdate(props.item.id, { markup: 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-primary transition-all"
|
||||||
|
placeholder="Markup"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => props.onUpdate(props.item.id, { markupType: props.item.markupType === 'percent' ? 'amount' : 'percent' })}
|
||||||
|
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] transition-colors border border-border/40"
|
||||||
|
>
|
||||||
|
{props.item.markupType === 'percent' ? '%' : '$'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={props.item.contingency}
|
||||||
|
onInput={(e) => props.onUpdate(props.item.id, { contingency: 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="Cont"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => props.onUpdate(props.item.id, { contingencyType: props.item.contingencyType === 'percent' ? 'amount' : 'percent' })}
|
||||||
|
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] transition-colors border border-border/40"
|
||||||
|
>
|
||||||
|
{props.item.contingencyType === 'percent' ? '%' : '$'}
|
||||||
|
</button>
|
||||||
|
</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">
|
<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
|
<button
|
||||||
onClick={() => props.onDelete(props.item.id)}
|
onClick={() => props.onDelete(props.item.id)}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createSignal } from 'solid-js';
|
||||||
|
import { createStore } from 'solid-js/store';
|
||||||
|
import type { ExtractedItem } from '../utils/csv-parser';
|
||||||
|
import type { TemplateItem, Scope, SubScope, EstimateItem, JobInfo } from '../types';
|
||||||
|
|
||||||
|
// Raw CSV Data
|
||||||
|
const [activeItems, setActiveItems] = createSignal<ExtractedItem[]>([]);
|
||||||
|
const [activeIgnoredLines, setActiveIgnoredLines] = createSignal<string[][]>([]);
|
||||||
|
|
||||||
|
// Estimate Metadata
|
||||||
|
const [activeEstimateId, setActiveEstimateId] = createSignal<string | null>(null);
|
||||||
|
const [activeEstimateName, setActiveEstimateName] = createSignal('');
|
||||||
|
|
||||||
|
// Estimate Builder State
|
||||||
|
const [scopes, setScopes] = createStore<Scope[]>([]);
|
||||||
|
const [subScopes, setSubScopes] = createStore<SubScope[]>([]);
|
||||||
|
const [estimateItems, setEstimateItems] = createStore<EstimateItem[]>([]);
|
||||||
|
|
||||||
|
const [clientInfo, setClientInfo] = createStore({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
phone: '',
|
||||||
|
fax: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [jobInfo, setJobInfo] = createStore<JobInfo>({
|
||||||
|
number: '',
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
workOrder: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [generalEstimateNotes, setGeneralEstimateNotes] = createSignal('');
|
||||||
|
const [generalPreNotes, setGeneralPreNotes] = createSignal('');
|
||||||
|
|
||||||
|
// Template Creator State
|
||||||
|
const [activeTemplateName, setActiveTemplateName] = createSignal('');
|
||||||
|
const [activeTemplateItems, setActiveTemplateItems] = createSignal<TemplateItem[]>([]);
|
||||||
|
|
||||||
|
export const appStore = {
|
||||||
|
// Raw CSV Extraction
|
||||||
|
items: activeItems,
|
||||||
|
setItems: setActiveItems,
|
||||||
|
ignoredLines: activeIgnoredLines,
|
||||||
|
setIgnoredLines: setActiveIgnoredLines,
|
||||||
|
|
||||||
|
// Estimate Meta
|
||||||
|
estimateId: activeEstimateId,
|
||||||
|
setEstimateId: setActiveEstimateId,
|
||||||
|
estimateName: activeEstimateName,
|
||||||
|
setEstimateName: setActiveEstimateName,
|
||||||
|
|
||||||
|
// Estimate Builder
|
||||||
|
scopes, setScopes,
|
||||||
|
subScopes, setSubScopes,
|
||||||
|
estimateItems, setEstimateItems,
|
||||||
|
clientInfo, setClientInfo,
|
||||||
|
jobInfo, setJobInfo,
|
||||||
|
generalEstimateNotes, setGeneralEstimateNotes,
|
||||||
|
generalPreNotes, setGeneralPreNotes,
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
templateName: activeTemplateName,
|
||||||
|
setTemplateName: setActiveTemplateName,
|
||||||
|
templateItems: activeTemplateItems,
|
||||||
|
setTemplateItems: setActiveTemplateItems
|
||||||
|
};
|
||||||
@@ -38,11 +38,16 @@ export interface EstimateItem {
|
|||||||
scopeId?: string;
|
scopeId?: string;
|
||||||
subScopeId?: string;
|
subScopeId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateItem {
|
export interface TemplateItem {
|
||||||
id: string;
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
markup: number;
|
||||||
|
markupType: PricingType;
|
||||||
|
contingency: number;
|
||||||
|
contingencyType: PricingType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
// Initialize the PocketBase client configured for the ccllc.pro backend
|
||||||
|
export const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||||
|
|
||||||
|
export const COLLECTIONS = {
|
||||||
|
TEMPLATES: 'EstiMaker_Templates',
|
||||||
|
ESTIMATES: 'EstiMaker_Estimates'
|
||||||
|
} as const;
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { createSignal } from 'solid-js';
|
|
||||||
import { Save } from 'lucide-solid';
|
import { Save } from 'lucide-solid';
|
||||||
import type { TemplateItem, Template } from '../types';
|
import type { TemplateItem } from '../types';
|
||||||
import TemplateItemList from '../components/template/TemplateItemList';
|
import TemplateItemList from '../components/template/TemplateItemList';
|
||||||
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
|
|
||||||
|
import { appStore } from '../store/appStore';
|
||||||
|
|
||||||
const TemplateCreator: Component = () => {
|
const TemplateCreator: Component = () => {
|
||||||
const [templateName, setTemplateName] = createSignal('');
|
const templateName = appStore.templateName;
|
||||||
const [items, setItems] = createSignal<TemplateItem[]>([]);
|
const setTemplateName = appStore.setTemplateName;
|
||||||
|
const items = appStore.templateItems;
|
||||||
|
const setItems = appStore.setTemplateItems;
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const newItem: TemplateItem = {
|
const newItem: TemplateItem = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
description: '',
|
description: '',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitPrice: 0
|
unitPrice: 0,
|
||||||
|
markup: 20, // default
|
||||||
|
markupType: 'percent',
|
||||||
|
contingency: 10, // default
|
||||||
|
contingencyType: 'percent'
|
||||||
};
|
};
|
||||||
setItems([...items(), newItem]);
|
setItems([...items(), newItem]);
|
||||||
};
|
};
|
||||||
@@ -26,20 +34,27 @@ const TemplateCreator: Component = () => {
|
|||||||
setItems(items().filter(item => item.id !== id));
|
setItems(items().filter(item => item.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveTemplate = () => {
|
const handleSaveTemplate = async () => {
|
||||||
// Here we would typically save to localStorage or backend
|
if (!templateName() || items().length === 0) return;
|
||||||
// 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
|
try {
|
||||||
setTemplateName('');
|
const record = {
|
||||||
setItems([]);
|
name: templateName(),
|
||||||
|
items: items() // PocketBase will store this as JSON
|
||||||
|
};
|
||||||
|
|
||||||
|
await pb.collection(COLLECTIONS.TEMPLATES).create(record);
|
||||||
|
alert(`Template "${record.name}" saved successfully!`);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setTemplateName('');
|
||||||
|
setItems([]);
|
||||||
|
|
||||||
|
// Refresh the list if we had one
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving template:', error);
|
||||||
|
alert('Failed to save template. Make sure the collection and fields exist.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user