diff --git a/bun.lock b/bun.lock index d3f2578..281470f 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@solidjs/router": "^0.15.4", "@tailwindcss/typography": "^0.5.19", "lucide-solid": "^0.577.0", + "pocketbase": "^0.26.8", "solid-element": "^1.9.1", "solid-js": "^1.9.11", }, @@ -318,6 +319,8 @@ "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-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], diff --git a/package.json b/package.json index c818cb4..f2a5dec 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@solidjs/router": "^0.15.4", "@tailwindcss/typography": "^0.5.19", "lucide-solid": "^0.577.0", + "pocketbase": "^0.26.8", "solid-element": "^1.9.1", "solid-js": "^1.9.11" }, diff --git a/src/ItemExtractor.tsx b/src/ItemExtractor.tsx index 8162e8a..72734f8 100644 --- a/src/ItemExtractor.tsx +++ b/src/ItemExtractor.tsx @@ -7,7 +7,9 @@ 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'; +import { FileSpreadsheet, Download } from 'lucide-solid'; +import { appStore } from './store/appStore'; +import CloudLoadModal from './components/CloudLoadModal'; interface ItemExtractorProps { onItemsExtracted?: (items: ExtractedItem[]) => void; @@ -16,10 +18,16 @@ interface ItemExtractorProps { const ItemExtractor: Component = () => { - const [items, setItems] = createSignal([]); - const [ignoredLines, setIgnoredLines] = createSignal([]); + const items = appStore.items; + 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 [toastMessage] = createSignal(''); + const [showLoadModal, setShowLoadModal] = createSignal(false); const handleFileSelect = (csvText: string) => { const { items: extracted, ignored } = extractItemsFromCSV(csvText); @@ -29,8 +37,8 @@ const ItemExtractor: Component = () => { return (
- {/* Header - Hidden after upload */} - + {/* Header - Hidden after upload or load */} +
@@ -43,12 +51,26 @@ const ItemExtractor: Component = () => {
+ +
+
+
+ OR +
+
+ +
{/* Results */} 0} + when={items().length > 0 || estimateItems.length > 0} fallback={

Results will appear here

@@ -61,6 +83,7 @@ const ItemExtractor: Component = () => { onReset={() => { setItems([]); setIgnoredLines([]); + setEstimateName(''); }} />
@@ -73,6 +96,8 @@ const ItemExtractor: Component = () => {
+ + setShowLoadModal(false)} />
); }; diff --git a/src/components/CloudLoadModal.tsx b/src/components/CloudLoadModal.tsx new file mode 100644 index 0000000..9e81318 --- /dev/null +++ b/src/components/CloudLoadModal.tsx @@ -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 = (props) => { + const [cloudEstimates, setCloudEstimates] = createSignal([]); + 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 ( + + +
+
+
+

Load from Cloud

+ +
+
+ Loading estimates...
} + > +
+ + {(record) => ( +
+
+

{record.name}

+

+ {new Date(record.created).toLocaleDateString()} +

+
+ +
+ )} +
+ +
+ No saved estimates found. +
+
+
+ +
+
+
+ + + ); +}; + +export default CloudLoadModal; diff --git a/src/components/EstimateBuilder.tsx b/src/components/EstimateBuilder.tsx index ed26f11..fa48672 100644 --- a/src/components/EstimateBuilder.tsx +++ b/src/components/EstimateBuilder.tsx @@ -1,10 +1,9 @@ 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 { Scope, SubScope, EstimateItem } from '../types'; import type { ExtractedItem } from '../utils/csv-parser'; import ScopeCard from './ScopeCard'; import ItemRow from './ItemRow'; @@ -12,6 +11,9 @@ import PrintEstimate from './PrintEstimate'; import NoteEditor from './NoteEditor'; import LazyItem from './LazyItem'; import CalculatorPopover from './CalculatorPopover'; +import { appStore } from '../store/appStore'; +import { pb, COLLECTIONS } from '../utils/db'; +import CloudLoadModal from './CloudLoadModal'; interface EstimateBuilderProps { initialItems: ExtractedItem[]; @@ -19,34 +21,42 @@ interface EstimateBuilderProps { } const EstimateBuilder: Component = (props) => { - const [scopes, setScopes] = createStore([]); - const [subScopes, setSubScopes] = createStore([]); - const [items, setItems] = createStore([]); + // Use Global Store State + const scopes = appStore.scopes; + 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 [isNotesExpanded, setIsNotesExpanded] = createSignal(true); const [isPreNotesExpanded, setIsPreNotesExpanded] = createSignal(true); const [hoveredScopeId, setHoveredScopeId] = createSignal(null); const [selectedIds, setSelectedIds] = createSignal([]); const [lastSelectedId, setLastSelectedId] = createSignal(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({ - number: '', - name: '', - address: '', - workOrder: '' - }); + // Cloud Modals + const [showSaveModal, setShowSaveModal] = createSignal(false); + const [showLoadModal, setShowLoadModal] = createSignal(false); + const [isCloudLoading, setIsCloudLoading] = createSignal(false); 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 => ({ id: item.id, description: item.description, @@ -58,6 +68,7 @@ const EstimateBuilder: Component = (props) => { contingencyType: 'percent' })); setItems(transformed); + } }); const unassignedItems = createMemo(() => items.filter(i => !i.scopeId)); @@ -169,6 +180,37 @@ const EstimateBuilder: Component = (props) => { 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 scopeId = crypto.randomUUID(); const newScope: Scope = { @@ -455,14 +497,27 @@ const EstimateBuilder: Component = (props) => {
+
+ + @@ -899,7 +954,7 @@ const EstimateBuilder: Component = (props) => {
@@ -961,14 +1016,54 @@ const EstimateBuilder: Component = (props) => { - {/* Copy Toast */} - + {/* Toast */} + +
+ + {copyToast()} +
+
+ + {/* Save Modal */} + -
- ✓ {copyToast()} +
+
+
+

Save to Cloud

+ +
+
+ +
+ + +
+
+
+ + {/* Load Modal */} + setShowLoadModal(false)} />
); }; diff --git a/src/components/template/TemplateItemList.tsx b/src/components/template/TemplateItemList.tsx index f6ed677..223f2c3 100644 --- a/src/components/template/TemplateItemList.tsx +++ b/src/components/template/TemplateItemList.tsx @@ -1,5 +1,5 @@ import type { Component } from 'solid-js'; -import { For } from 'solid-js'; +import { For, Show } from 'solid-js'; import { Plus } from 'lucide-solid'; import type { TemplateItem } from '../../types'; import TemplateItemRow from './TemplateItemRow'; @@ -26,6 +26,19 @@ const TemplateItemList: Component = (props) => {
+ {/* Table Header */} + 0}> +
+
{/* Grip Spacer */} +
Description
+
Qty
+
Price
+
Markup
+
Cont
+
{/* Actions Spacer */} +
+
+ {(item) => ( = (props) => {
+
+ 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" + /> + +
+ +
+ 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" + /> + +
+