From cd6265e2f21ed41fc986d7619295529a32e630c1 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Wed, 11 Mar 2026 14:34:10 -0500 Subject: [PATCH] Templates and export tables --- src/components/ApplyTemplateModal.tsx | 157 +++++++++++++++ src/components/EstimateBuilder.tsx | 19 +- src/components/ExportTableModal.tsx | 272 ++++++++++++++++++++++++++ src/components/ScopeCard.tsx | 11 +- 4 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 src/components/ApplyTemplateModal.tsx create mode 100644 src/components/ExportTableModal.tsx diff --git a/src/components/ApplyTemplateModal.tsx b/src/components/ApplyTemplateModal.tsx new file mode 100644 index 0000000..706302d --- /dev/null +++ b/src/components/ApplyTemplateModal.tsx @@ -0,0 +1,157 @@ +import type { Component } from 'solid-js'; +import { createSignal, Show, For, createEffect } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { X, FileBox, CheckCircle2 } from 'lucide-solid'; +import { pb, COLLECTIONS } from '../utils/db'; +import { appStore } from '../store/appStore'; +import type { EstimateItem, TemplateItem } from '../types'; + +interface ApplyTemplateModalProps { + show: boolean; + onClose: () => void; + scopeId: string | null; +} + +const ApplyTemplateModal: Component = (props) => { + const subScopes = () => appStore.subScopes.filter(ss => ss.scopeId === props.scopeId); + + const [templates, setTemplates] = createSignal([]); + const [isLoading, setIsLoading] = createSignal(false); + const [targetSubScopeId, setTargetSubScopeId] = createSignal('scope'); // 'scope' means no sub-scope selected + const [isApplying, setIsApplying] = createSignal(false); + + createEffect(() => { + if (props.show) { + loadTemplates(); + setTargetSubScopeId('scope'); // reset selection + } + }); + + const loadTemplates = async () => { + setIsLoading(true); + try { + const records = await pb.collection(COLLECTIONS.TEMPLATES).getFullList({ + sort: '-created' + }); + setTemplates(records); + } catch (err) { + console.error(err); + alert('Failed to load templates.'); + } finally { + setIsLoading(false); + } + }; + + const applyTemplate = (record: any) => { + const templateItems: TemplateItem[] = record.items || []; + if (templateItems.length === 0) { + alert('This template has no items.'); + return; + } + + setIsApplying(true); + + const newItems: EstimateItem[] = templateItems.map(item => ({ + id: crypto.randomUUID(), + description: item.description, + quantity: item.quantity, + unitPrice: item.unitPrice, + markup: item.markup, + markupType: item.markupType, + contingency: item.contingency, + contingencyType: item.contingencyType, + scopeId: props.scopeId || undefined, + subScopeId: targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined + })); + + // Append to existing items + appStore.setEstimateItems(prev => [...prev, ...newItems]); + + setIsApplying(false); + props.onClose(); + }; + + return ( + + +
+
+
+
+
+ +
+
+

Apply Template

+

Append items to scope

+
+
+ +
+ +
+ {/* Target Selection */} +
+ + +
+ + {/* Templates List */} +
+ +
+ Loading templates...
} + > + + {(record) => ( +
+
+

{record.name}

+

+ {record.items?.length || 0} Items • {new Date(record.created).toLocaleDateString()} +

+
+ +
+ )} +
+ +
+ +

No saved templates found.

+

Create one in the Template Creator first.

+
+
+ +
+
+
+
+ +
+
+ ); +}; + +export default ApplyTemplateModal; diff --git a/src/components/EstimateBuilder.tsx b/src/components/EstimateBuilder.tsx index fa48672..2602614 100644 --- a/src/components/EstimateBuilder.tsx +++ b/src/components/EstimateBuilder.tsx @@ -1,7 +1,7 @@ import type { Component } from 'solid-js'; import { createSignal, For, createMemo, onMount, Show } from 'solid-js'; import { Portal } from 'solid-js/web'; -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, Table } from 'lucide-solid'; import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info'; import type { Scope, SubScope, EstimateItem } from '../types'; import type { ExtractedItem } from '../utils/csv-parser'; @@ -14,6 +14,8 @@ import CalculatorPopover from './CalculatorPopover'; import { appStore } from '../store/appStore'; import { pb, COLLECTIONS } from '../utils/db'; import CloudLoadModal from './CloudLoadModal'; +import ExportTableModal from './ExportTableModal'; +import ApplyTemplateModal from './ApplyTemplateModal'; interface EstimateBuilderProps { initialItems: ExtractedItem[]; @@ -48,6 +50,8 @@ const EstimateBuilder: Component = (props) => { const [showSaveModal, setShowSaveModal] = createSignal(false); const [showLoadModal, setShowLoadModal] = createSignal(false); const [isCloudLoading, setIsCloudLoading] = createSignal(false); + const [showExportTableModal, setShowExportTableModal] = createSignal(false); + const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal(null); onMount(() => { // Initialize general notes if not already set by an imported estimate @@ -500,6 +504,12 @@ const EstimateBuilder: Component = (props) => { Import JSON +