diff --git a/src/components/ApplyTemplateModal.tsx b/src/components/ApplyTemplateModal.tsx index 706302d..d67c731 100644 --- a/src/components/ApplyTemplateModal.tsx +++ b/src/components/ApplyTemplateModal.tsx @@ -1,10 +1,9 @@ -import type { Component } from 'solid-js'; -import { createSignal, Show, For, createEffect } from 'solid-js'; +import { createSignal, Show, For, createEffect, createMemo, type Component } from 'solid-js'; import { Portal } from 'solid-js/web'; -import { X, FileBox, CheckCircle2 } from 'lucide-solid'; +import { X, FileBox, CheckCircle2, Search } from 'lucide-solid'; import { pb, COLLECTIONS } from '../utils/db'; import { appStore } from '../store/appStore'; -import type { EstimateItem, TemplateItem } from '../types'; +import type { EstimateItem, TemplateItem, Modifier } from '../types'; interface ApplyTemplateModalProps { show: boolean; @@ -17,9 +16,36 @@ const ApplyTemplateModal: Component = (props) => { const [templates, setTemplates] = createSignal([]); const [isLoading, setIsLoading] = createSignal(false); + const [searchQuery, setSearchQuery] = createSignal(''); const [targetSubScopeId, setTargetSubScopeId] = createSignal('scope'); // 'scope' means no sub-scope selected const [isApplying, setIsApplying] = createSignal(false); + const filteredAndGroupedTemplates = createMemo(() => { + const query = searchQuery().toLowerCase(); + let filtered = templates().filter(t => + t.name.toLowerCase().includes(query) || + (t.defaultSubScopeName || '').toLowerCase().includes(query) + ); + + // Sort alphabetically by name + filtered.sort((a, b) => a.name.localeCompare(b.name)); + + // Group by defaultSubScopeName + const groups: Record = {}; + filtered.forEach(t => { + const groupName = t.defaultSubScopeName || 'Uncategorized'; + if (!groups[groupName]) groups[groupName] = []; + groups[groupName].push(t); + }); + + // Convert to array of groups for sorting groups + return Object.entries(groups).sort(([a], [b]) => { + if (a === 'Uncategorized') return 1; + if (b === 'Uncategorized') return -1; + return a.localeCompare(b); + }); + }); + createEffect(() => { if (props.show) { loadTemplates(); @@ -44,13 +70,21 @@ const ApplyTemplateModal: Component = (props) => { const applyTemplate = (record: any) => { const templateItems: TemplateItem[] = record.items || []; - if (templateItems.length === 0) { - alert('This template has no items.'); + if (templateItems.length === 0 && (record.modifiers || []).length === 0) { + alert('This template has no items or modifiers.'); return; } setIsApplying(true); + // Find default sub-scope if target is 'scope' and template has a defaultSubScopeName + let finalSubScopeId = targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined; + + if (!finalSubScopeId && record.defaultSubScopeName) { + const match = subScopes().find(ss => ss.name.toLowerCase() === record.defaultSubScopeName.toLowerCase()); + if (match) finalSubScopeId = match.id; + } + const newItems: EstimateItem[] = templateItems.map(item => ({ id: crypto.randomUUID(), description: item.description, @@ -61,12 +95,27 @@ const ApplyTemplateModal: Component = (props) => { contingency: item.contingency, contingencyType: item.contingencyType, scopeId: props.scopeId || undefined, - subScopeId: targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined + subScopeId: finalSubScopeId })); // Append to existing items appStore.setEstimateItems(prev => [...prev, ...newItems]); + // Copy modifiers if target is (or resolved to) a sub-scope + if (finalSubScopeId) { + const templateModifiers: Modifier[] = record.modifiers || []; + if (templateModifiers.length > 0) { + appStore.setSubScopes( + ss => ss.id === finalSubScopeId, + 'modifiers', + (existing = []) => [ + ...existing, + ...templateModifiers.map(m => ({ ...m, id: crypto.randomUUID() })) + ] + ); + } + } + setIsApplying(false); props.onClose(); }; @@ -92,50 +141,78 @@ const ApplyTemplateModal: Component = (props) => {
- {/* Target Selection */} -
- - + {/* Target Selection & Search */} +
+
+ + +
+
+ +
+ + setSearchQuery(e.currentTarget.value)} + class="w-full bg-muted/20 border border-border/60 pl-11 pr-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" + /> +
+
{/* Templates List */}
-
+
Loading templates...
} > - - {(record) => ( -
-
-

{record.name}

-

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

+ + {([groupName, groupTemplates]) => ( +
+
+ {groupName} +
+
+
+ + {(record) => ( +
+
+

{record.name}

+

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

+
+ +
+ )} +
-
)}
+
@@ -143,6 +220,12 @@ const ApplyTemplateModal: Component = (props) => {

Create one in the Template Creator first.

+ + 0 && filteredAndGroupedTemplates().length === 0}> +
+ No templates match your search. +
+
diff --git a/src/components/ScopeCard.tsx b/src/components/ScopeCard.tsx index d449b0f..49663c5 100644 --- a/src/components/ScopeCard.tsx +++ b/src/components/ScopeCard.tsx @@ -153,12 +153,20 @@ const ScopeCard: Component = (props) => {
- +
+ + +
-
- -
diff --git a/src/store/appStore.ts b/src/store/appStore.ts index 7cf60c5..a30e87a 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -40,6 +40,8 @@ const [generalPreNotes, setGeneralPreNotes] = createSignal(''); // Template Creator State const [activeTemplateName, setActiveTemplateName] = createSignal(''); const [activeTemplateItems, setActiveTemplateItems] = createStore([]); +const [activeTemplateModifiers, setActiveTemplateModifiers] = createStore([]); +const [activeTemplateSubScopeName, setActiveTemplateSubScopeName] = createSignal(undefined); const [activeTemplateId, setActiveTemplateId] = createSignal(null); export const appStore = { @@ -111,6 +113,34 @@ export const appStore = { setTemplateName: setActiveTemplateName, templateItems: activeTemplateItems, setTemplateItems: setActiveTemplateItems, + templateModifiers: activeTemplateModifiers, + setTemplateModifiers: setActiveTemplateModifiers, + templateSubScopeName: activeTemplateSubScopeName, + setTemplateSubScopeName: setActiveTemplateSubScopeName, + + addTemplateModifier(moduleId: string) { + const module = ModifierRegistry.getModule(moduleId); + const newModifier: Modifier = { + id: Math.random().toString(36).substr(2, 9), + moduleId: module.id, + name: module.defaultLabel, + value: module.defaultValue, + valueType: module.defaultValueType, + unitLabel: module.defaultUnitLabel, + type: module.id as any, + includeInTotal: module.id !== 'man-hours' + }; + setActiveTemplateModifiers(prev => [...prev, newModifier]); + }, + updateTemplateModifier: (modifierId: string, updates: Partial) => { + setActiveTemplateModifiers( + (m: Modifier) => m.id === modifierId, + updates + ); + }, + removeTemplateModifier: (modifierId: string) => { + setActiveTemplateModifiers(prev => prev.filter(mod => mod.id !== modifierId)); + }, resetEstimate: () => { setActiveItems([]); @@ -127,5 +157,10 @@ export const appStore = { setJobInfo({ number: '', name: '', address: '', workOrder: '' }); setGeneralEstimateNotes(''); setGeneralPreNotes(''); + setActiveTemplateName(''); + setActiveTemplateItems([]); + setActiveTemplateModifiers([]); + setActiveTemplateSubScopeName(undefined); + setActiveTemplateId(null); } }; diff --git a/src/types.ts b/src/types.ts index d0424d7..e3f6e5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,4 +68,6 @@ export interface Template { id: string; name: string; items: TemplateItem[]; + modifiers?: Modifier[]; + defaultSubScopeName?: string; } diff --git a/src/views/TemplateCreator.tsx b/src/views/TemplateCreator.tsx index 9e42367..da7744b 100644 --- a/src/views/TemplateCreator.tsx +++ b/src/views/TemplateCreator.tsx @@ -1,10 +1,11 @@ -import type { Component } from 'solid-js'; -import { FolderOpen, Save } from 'lucide-solid'; +import { For, createSignal, createMemo, type Component } from 'solid-js'; +import { FolderOpen, Save, Calculator, Plus } from 'lucide-solid'; import type { TemplateItem } from '../types'; import TemplateItemList from '../components/template/TemplateItemList'; +import ModifierRow from '../components/ModifierRow'; +import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry'; import TemplateLoadModal from '../components/template/TemplateLoadModal'; import { pb, COLLECTIONS } from '../utils/db'; -import { createSignal } from 'solid-js'; import { appStore } from '../store/appStore'; @@ -15,6 +16,14 @@ const TemplateCreator: Component = () => { const setTemplateName = appStore.setTemplateName; const items = appStore.templateItems; const setItems = appStore.setTemplateItems; + const modifiers = appStore.templateModifiers; + const setModifiers = appStore.setTemplateModifiers; + const defaultSubScopeName = appStore.templateSubScopeName; + const setDefaultSubScopeName = appStore.setTemplateSubScopeName; + + const templateSubtotal = createMemo(() => { + return items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0); + }); const [showLoadModal, setShowLoadModal] = createSignal(false); @@ -44,21 +53,27 @@ const TemplateCreator: Component = () => { setTemplateId(record.id); setTemplateName(record.name); setItems(JSON.parse(JSON.stringify(record.items || []))); + setModifiers(JSON.parse(JSON.stringify(record.modifiers || []))); + setDefaultSubScopeName(record.defaultSubScopeName); }; const handleNewTemplate = () => { setTemplateId(null); setTemplateName(''); setItems([]); + setModifiers([]); + setDefaultSubScopeName(undefined); }; const handleSaveTemplate = async () => { - if (!templateName() || items.length === 0) return; + if (!templateName() || (items.length === 0 && modifiers.length === 0)) return; try { const record = { name: templateName(), - items: JSON.parse(JSON.stringify(items)) // Ensure plain objects for PocketBase + items: JSON.parse(JSON.stringify(items)), // Ensure plain objects for PocketBase + modifiers: JSON.parse(JSON.stringify(modifiers)), + defaultSubScopeName: defaultSubScopeName() }; if (templateId()) { @@ -100,7 +115,7 @@ const TemplateCreator: Component = () => {
-
- - 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" - /> +
+
+ + 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" + /> +
+ +
+ + +
{ onUpdateItem={handleUpdateItem} onDeleteItem={handleDeleteItem} /> + + {/* Modifiers Section */} +
+
+
+
+ +
+
+

Template Modifiers

+

Standard fees or adjustments for this sub-scope

+
+
+ +
+ + {(module) => ( + + )} + +
+
+ +
+ + {(modifier) => ( + + )} + + + {modifiers.length === 0 && ( +
+ No modifiers added to this template. +
+ )} +
+