From c090c1873495d99ee7ee75f1f6c9b01d08f5146f Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Fri, 13 Mar 2026 14:12:13 -0500 Subject: [PATCH] template updating added and item text field fixes --- src/components/ItemRow.tsx | 33 +++--- src/components/ScopeCard.tsx | 23 ++-- src/components/template/TemplateItemRow.tsx | 21 ++-- src/components/template/TemplateLoadModal.tsx | 105 ++++++++++++++++++ src/components/ui/NumericInput.tsx | 83 ++++++++++++++ src/store/appStore.ts | 5 +- src/views/TemplateCreator.tsx | 85 ++++++++++---- 7 files changed, 289 insertions(+), 66 deletions(-) create mode 100644 src/components/template/TemplateLoadModal.tsx create mode 100644 src/components/ui/NumericInput.tsx diff --git a/src/components/ItemRow.tsx b/src/components/ItemRow.tsx index 6a9b7d3..d5beefd 100644 --- a/src/components/ItemRow.tsx +++ b/src/components/ItemRow.tsx @@ -1,6 +1,7 @@ import type { Component } from 'solid-js'; import { createMemo, createEffect, createSignal, Show } from 'solid-js'; import { GripVertical, Trash2, Copy } from 'lucide-solid'; +import NumericInput from './ui/NumericInput'; import type { EstimateItem } from '../types'; interface ItemRowProps { @@ -108,13 +109,12 @@ const ItemRow: Component = (props) => {
- props.onUpdate(props.item.id, { quantity: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })} onDragStart={(e) => { - const val = parseFloat(e.currentTarget.value) || 0; + const val = props.item.quantity; const data = { type: 'item-field', itemId: props.item.id, @@ -137,14 +137,13 @@ const ItemRow: Component = (props) => {
$ - props.onUpdate(props.item.id, { unitPrice: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} onDragStart={(e) => { - const val = parseFloat(e.currentTarget.value) || 0; + const val = props.item.unitPrice; const data = { type: 'item-field', itemId: props.item.id, @@ -188,11 +187,10 @@ const ItemRow: Component = (props) => {
- props.onUpdate(props.item.id, { markup: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })} disabled={props.anySelected} 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 disabled:opacity-50 font-bold text-primary transition-all" placeholder="Markup" @@ -210,11 +208,10 @@ const ItemRow: Component = (props) => {
- props.onUpdate(props.item.id, { contingency: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })} disabled={props.anySelected} 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 disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Contingency" diff --git a/src/components/ScopeCard.tsx b/src/components/ScopeCard.tsx index 4b888fa..368b43a 100644 --- a/src/components/ScopeCard.tsx +++ b/src/components/ScopeCard.tsx @@ -1,6 +1,7 @@ import type { Component } from 'solid-js'; import { For, createMemo, Show, createSignal } from 'solid-js'; import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid'; +import NumericInput from './ui/NumericInput'; import type { EstimateItem, Scope, SubScope, PricingType } from '../types'; import ItemRow from './ItemRow'; import SubScopeCard from './SubScopeCard'; @@ -162,10 +163,10 @@ const ScopeCard: Component = (props) => { Bulk Adjust Scope:
- setBulkMarkup(parseFloat(e.currentTarget.value) || 0)} + value={bulkMarkup()} + onUpdate={(val) => setBulkMarkup(val)} />
- setBulkContingency(parseFloat(e.currentTarget.value) || 0)} + value={bulkContingency()} + onUpdate={(val) => setBulkContingency(val)} />
- props.onUpdateScope(props.scope.id, { managementFee: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdateScope(props.scope.id, { managementFee: val })} class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent" /> ${formatNumber(totals().mgmtTotal)} @@ -329,10 +329,9 @@ const ScopeCard: Component = (props) => {
- props.onUpdateScope(props.scope.id, { deliveryFee: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdateScope(props.scope.id, { deliveryFee: val })} class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent" /> ${formatNumber(totals().deliveryTotal)} diff --git a/src/components/template/TemplateItemRow.tsx b/src/components/template/TemplateItemRow.tsx index 1178b44..9f155e7 100644 --- a/src/components/template/TemplateItemRow.tsx +++ b/src/components/template/TemplateItemRow.tsx @@ -1,6 +1,7 @@ import type { Component } from 'solid-js'; import { createSignal, createEffect } from 'solid-js'; import { Trash2, GripVertical } from 'lucide-solid'; +import NumericInput from '../ui/NumericInput'; import type { TemplateItem } from '../../types'; interface TemplateItemRowProps { @@ -56,10 +57,9 @@ const TemplateItemRow: Component = (props) => {
- props.onUpdate(props.item.id, { quantity: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })} 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" /> @@ -68,11 +68,10 @@ const TemplateItemRow: Component = (props) => {
$ - props.onUpdate(props.item.id, { unitPrice: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} 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" /> @@ -80,10 +79,9 @@ const TemplateItemRow: Component = (props) => {
- props.onUpdate(props.item.id, { markup: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })} 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" /> @@ -96,10 +94,9 @@ const TemplateItemRow: Component = (props) => {
- props.onUpdate(props.item.id, { contingency: parseFloat(e.currentTarget.value) || 0 })} + onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })} 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" /> diff --git a/src/components/template/TemplateLoadModal.tsx b/src/components/template/TemplateLoadModal.tsx new file mode 100644 index 0000000..a67960d --- /dev/null +++ b/src/components/template/TemplateLoadModal.tsx @@ -0,0 +1,105 @@ +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'; + +interface TemplateLoadModalProps { + show: boolean; + onClose: () => void; + onLoad: (template: any) => void; +} + +const TemplateLoadModal: Component = (props) => { + const [templates, setTemplates] = createSignal([]); + const [isLoading, setIsLoading] = createSignal(false); + + createEffect(() => { + if (props.show) { + loadTemplates(); + } + }); + + 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); + } + }; + + return ( + + +
+
+
+
+
+ +
+
+

Load Template

+

Select a template to edit

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

{record.name}

+

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

+
+ +
+ )} +
+ +
+ +

No saved templates found.

+
+
+ +
+
+
+
+
+ + + ); +}; + +export default TemplateLoadModal; diff --git a/src/components/ui/NumericInput.tsx b/src/components/ui/NumericInput.tsx new file mode 100644 index 0000000..7202566 --- /dev/null +++ b/src/components/ui/NumericInput.tsx @@ -0,0 +1,83 @@ +import type { Component, JSX } from 'solid-js'; +import { createSignal, createEffect } from 'solid-js'; + +interface NumericInputProps extends Omit, 'onInput' | 'value'> { + value: number; + onUpdate: (val: number) => void; + dataFieldName?: string; + class?: string; +} + +const NumericInput: Component = (props) => { + const [isFocused, setIsFocused] = createSignal(false); + const [localValue, setLocalValue] = createSignal(props.value.toString()); + + createEffect(() => { + if (!isFocused()) { + setLocalValue(props.value.toString()); + } + }); + + const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => { + let val = e.currentTarget.value; + + // Allow only numeric characters, one dot, and one leading minus sign + // This regex allows: "", "-", ".", "-.", and valid numbers + if (val !== '' && !/^-?(\d*\.?\d*)?$/.test(val)) { + // If invalid, revert to previous local value (simple way to block invalid keys) + e.currentTarget.value = localValue(); + return; + } + + setLocalValue(val); + + // Only update the store if it's a valid numeric value for calculations + if (val === '' || val === '-' || val === '.' || val === '-.') { + props.onUpdate(0); + } else { + const parsed = parseFloat(val); + if (!isNaN(parsed)) { + props.onUpdate(parsed); + } + } + }; + + const callHandler = (handler: any, event: any) => { + if (typeof handler === 'function') { + handler(event); + } else if (Array.isArray(handler)) { + handler[0](handler[1], event); + } + }; + + const handleFocus = (e: FocusEvent) => { + setIsFocused(true); + // Select all text on focus to make it easier to replace values + (e.currentTarget as HTMLInputElement).select(); + callHandler(props.onFocus, e); + }; + + const handleBlur = (e: FocusEvent) => { + setIsFocused(false); + // On blur, normalize the local value to the props value + // But if the user left it empty or as a partial, props.value will be 0 + setLocalValue(props.value.toString()); + callHandler(props.onBlur, e); + }; + + return ( + + ); +}; + +export default NumericInput; diff --git a/src/store/appStore.ts b/src/store/appStore.ts index 99f36f8..a0eaf7f 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -35,7 +35,8 @@ const [generalPreNotes, setGeneralPreNotes] = createSignal(''); // Template Creator State const [activeTemplateName, setActiveTemplateName] = createSignal(''); -const [activeTemplateItems, setActiveTemplateItems] = createSignal([]); +const [activeTemplateItems, setActiveTemplateItems] = createStore([]); +const [activeTemplateId, setActiveTemplateId] = createSignal(null); export const appStore = { // Raw CSV Extraction @@ -60,6 +61,8 @@ export const appStore = { generalPreNotes, setGeneralPreNotes, // Templates + templateId: activeTemplateId, + setTemplateId: setActiveTemplateId, templateName: activeTemplateName, setTemplateName: setActiveTemplateName, templateItems: activeTemplateItems, diff --git a/src/views/TemplateCreator.tsx b/src/views/TemplateCreator.tsx index 08beb95..9e42367 100644 --- a/src/views/TemplateCreator.tsx +++ b/src/views/TemplateCreator.tsx @@ -1,17 +1,23 @@ import type { Component } from 'solid-js'; -import { Save } from 'lucide-solid'; +import { FolderOpen, Save } from 'lucide-solid'; import type { TemplateItem } from '../types'; import TemplateItemList from '../components/template/TemplateItemList'; +import TemplateLoadModal from '../components/template/TemplateLoadModal'; import { pb, COLLECTIONS } from '../utils/db'; +import { createSignal } from 'solid-js'; import { appStore } from '../store/appStore'; const TemplateCreator: Component = () => { + const templateId = appStore.templateId; + const setTemplateId = appStore.setTemplateId; const templateName = appStore.templateName; const setTemplateName = appStore.setTemplateName; const items = appStore.templateItems; const setItems = appStore.setTemplateItems; + const [showLoadModal, setShowLoadModal] = createSignal(false); + const handleAddItem = () => { const newItem: TemplateItem = { id: crypto.randomUUID(), @@ -23,34 +29,46 @@ const TemplateCreator: Component = () => { contingency: 10, // default contingencyType: 'percent' }; - setItems([...items(), newItem]); + setItems([...items, newItem]); }; const handleUpdateItem = (id: string, updates: Partial) => { - setItems(items().map(item => item.id === id ? { ...item, ...updates } : item)); + setItems(item => item.id === id, updates); }; const handleDeleteItem = (id: string) => { - setItems(items().filter(item => item.id !== id)); + setItems(items.filter(item => item.id !== id)); + }; + + const handleLoadTemplate = (record: any) => { + setTemplateId(record.id); + setTemplateName(record.name); + setItems(JSON.parse(JSON.stringify(record.items || []))); + }; + + const handleNewTemplate = () => { + setTemplateId(null); + setTemplateName(''); + setItems([]); }; const handleSaveTemplate = async () => { - if (!templateName() || items().length === 0) return; + if (!templateName() || items.length === 0) return; try { const record = { name: templateName(), - items: items() // PocketBase will store this as JSON + items: JSON.parse(JSON.stringify(items)) // Ensure plain objects for PocketBase }; - await pb.collection(COLLECTIONS.TEMPLATES).create(record); - alert(`Template "${record.name}" saved successfully!`); - - // Reset form - setTemplateName(''); - setItems([]); - - // Refresh the list if we had one + if (templateId()) { + await pb.collection(COLLECTIONS.TEMPLATES).update(templateId()!, record); + alert(`Template "${record.name}" updated successfully!`); + } else { + const newRecord = await pb.collection(COLLECTIONS.TEMPLATES).create(record); + setTemplateId(newRecord.id); + alert(`Template "${record.name}" saved successfully!`); + } } catch (error) { console.error('Error saving template:', error); alert('Failed to save template. Make sure the collection and fields exist.'); @@ -66,14 +84,29 @@ const TemplateCreator: Component = () => {

Create templates for your estimate sub-scopes.

- +
+ + + +
@@ -90,12 +123,18 @@ const TemplateCreator: Component = () => {
+ + setShowLoadModal(false)} + onLoad={handleLoadTemplate} + />
); };