diff --git a/src/components/EstimateBuilder.tsx b/src/components/EstimateBuilder.tsx index b450d89..9fbc50e 100644 --- a/src/components/EstimateBuilder.tsx +++ b/src/components/EstimateBuilder.tsx @@ -6,6 +6,7 @@ import { Portal } from 'solid-js/web'; import { appStore } from '../store/appStore'; import type { Scope, SubScope, EstimateItem } from '../types'; import type { ExtractedItem } from '../utils/csv-parser'; +import { extractItemsFromCSV } from '../utils/csv-parser'; import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info'; import PrintEstimate from './PrintEstimate'; @@ -184,6 +185,98 @@ const EstimateBuilder: Component = (props) => { const [copyToast, setCopyToast] = createSignal(null); + const normalizeDescription = (value: string) => value.trim().toLowerCase().replace(/\s+/g, ' '); + const parseImportedQuantity = (value: string) => { + const parsed = Number.parseFloat(value.replace(/,/g, '').trim()); + return Number.isFinite(parsed) ? parsed : 0; + }; + + const mergeCsvQuantities = (csvText: string) => { + const { items: extracted, ignored } = extractItemsFromCSV(csvText); + appStore.setIgnoredLines(ignored); + + if (extracted.length === 0) { + setCopyToast('No CSV items were found to import.'); + window.setTimeout(() => setCopyToast(null), 2500); + return; + } + + const importedByDescription = new Map(); + for (const item of extracted) { + const key = normalizeDescription(item.description); + if (!key) continue; + + const quantity = parseImportedQuantity(item.quantity); + const existing = importedByDescription.get(key); + + if (existing) { + existing.quantity += quantity; + } else { + importedByDescription.set(key, { + description: item.description.trim(), + quantity + }); + } + } + + let updatedExistingCount = 0; + const matchedDescriptions = new Set(); + + const nextItems = items.map(item => { + const key = normalizeDescription(item.description); + const imported = importedByDescription.get(key); + + if (!imported) return item; + + matchedDescriptions.add(key); + updatedExistingCount += 1; + return { ...item, quantity: imported.quantity }; + }); + + const newUnassignedItems: EstimateItem[] = []; + for (const [key, imported] of importedByDescription.entries()) { + if (matchedDescriptions.has(key)) continue; + + newUnassignedItems.push({ + id: crypto.randomUUID(), + description: imported.description, + quantity: imported.quantity, + unitPrice: 0, + markup: 0, + markupType: 'percent', + contingency: 0, + contingencyType: 'percent' + }); + } + + setItems([...nextItems, ...newUnassignedItems]); + + const ignoredCount = ignored.length; + const toastParts = [ + `Updated ${updatedExistingCount} item${updatedExistingCount === 1 ? '' : 's'}`, + `added ${newUnassignedItems.length} unassigned`, + ignoredCount > 0 ? `ignored ${ignoredCount} row${ignoredCount === 1 ? '' : 's'}` : null + ].filter(Boolean); + setCopyToast(toastParts.join(' • ')); + window.setTimeout(() => setCopyToast(null), 3000); + }; + + const handleImportCsvQuantities = (e: Event) => { + const input = e.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target?.result; + if (typeof text === 'string') { + mergeCsvQuantities(text); + } + }; + reader.readAsText(file); + input.value = ''; + }; + return (
= (props) => { onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())} onAddScope={addScope} onShowNewEstimateModal={() => setShowNewEstimateModal(true)} + onImportCsvQuantities={handleImportCsvQuantities} onImportEstimate={importEstimate} onShowExportTableModal={() => setShowExportTableModal(true)} onExportEstimate={exportEstimate} diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index b6c567b..6ec90a1 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -9,6 +9,7 @@ interface NoteEditorProps { icon: any; placeholder?: string; highlight?: boolean; + headerActions?: any; } const NoteEditor: Component = (props) => { @@ -56,13 +57,16 @@ const NoteEditor: Component = (props) => {
{props.label} - +
+ {props.headerActions} + +
= (props) => { const [bulkMarkup, setBulkMarkup] = createSignal(0); const [bulkContingency, setBulkContingency] = createSignal(0); const [isOver, setIsOver] = createSignal(false); + const [activeTemplateType, setActiveTemplateType] = createSignal('estimatorNotes'); + const [isTemplateModalOpen, setIsTemplateModalOpen] = createSignal(false); const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId)); @@ -116,6 +119,26 @@ const ScopeCard: Component = (props) => { setIsOver(false); }; + const openTemplateModal = (type: ScopeTextTemplateType) => { + setActiveTemplateType(type); + setIsTemplateModalOpen(true); + }; + + const templateModalCurrentValue = createMemo(() => + activeTemplateType() === 'estimatorNotes' + ? props.scope.estimatorNotes || '' + : props.scope.bidDescription || '' + ); + + const applyTemplateValue = (value: string) => { + if (activeTemplateType() === 'estimatorNotes') { + props.onUpdateScope(props.scope.id, { estimatorNotes: value }); + return; + } + + props.onUpdateScope(props.scope.id, { bidDescription: value }); + }; + return (
= (props) => { onUpdate={(val) => props.onUpdateScope(props.scope.id, { estimatorNotes: val })} icon={ClipboardList} highlight={props.scope.estimatorNotes?.includes('#')} + headerActions={ + + } /> = (props) => { onUpdate={(val) => props.onUpdateScope(props.scope.id, { bidDescription: val })} icon={FileText} highlight={props.scope.bidDescription?.includes('#')} + headerActions={ + + } />
@@ -504,6 +543,14 @@ const ScopeCard: Component = (props) => {
+ + setIsTemplateModalOpen(false)} + /> ); }; diff --git a/src/components/ScopeTextTemplateModal.tsx b/src/components/ScopeTextTemplateModal.tsx new file mode 100644 index 0000000..827d853 --- /dev/null +++ b/src/components/ScopeTextTemplateModal.tsx @@ -0,0 +1,386 @@ +import { createEffect, createMemo, createSignal, For, Show, type Component } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { CheckCircle2, CopyPlus, History, PencilLine, Search, Trash2, User, X } from 'lucide-solid'; + +import type { ScopeTextTemplate, ScopeTextTemplateHistoryEntry, ScopeTextTemplateType } from '../types'; +import { pb, COLLECTIONS } from '../utils/db'; +import { createScopeTextDiff } from '../utils/scope-text-template-history'; + +interface ScopeTextTemplateModalProps { + show: boolean; + templateType: ScopeTextTemplateType; + currentValue: string; + onApply: (value: string) => void; + onClose: () => void; +} + +const TEMPLATE_LABELS: Record = { + estimatorNotes: "Estimator's Notes", + bidDescription: 'Scope Description' +}; + +const getCurrentUserLabel = () => + pb.authStore.model?.name || pb.authStore.model?.email || 'Unknown'; + +const ScopeTextTemplateModal: Component = (props) => { + const [templates, setTemplates] = createSignal([]); + const [isLoading, setIsLoading] = createSignal(false); + const [isSaving, setIsSaving] = createSignal(false); + const [updatingTemplateId, setUpdatingTemplateId] = createSignal(null); + const [deletingTemplateId, setDeletingTemplateId] = createSignal(null); + const [expandedHistoryId, setExpandedHistoryId] = createSignal(null); + const [searchQuery, setSearchQuery] = createSignal(''); + const [newTemplateName, setNewTemplateName] = createSignal(''); + + createEffect(() => { + if (props.show) { + void loadTemplates(); + setSearchQuery(''); + setNewTemplateName(''); + } + }); + + const loadTemplates = async () => { + setIsLoading(true); + try { + const records = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).getFullList({ + sort: '-updated' + }); + setTemplates(records.map((record) => ({ + id: record.id, + name: record.name, + type: record.type, + content: record.content, + lastUpdatedBy: record.lastUpdatedBy, + history: Array.isArray(record.history) ? record.history : [], + createdAt: record.created, + updatedAt: record.updated + }))); + } catch (error) { + console.error('Failed to load scope text templates:', error); + alert('Failed to load scope text templates.'); + } finally { + setIsLoading(false); + } + }; + + const filteredTemplates = createMemo(() => { + const query = searchQuery().trim().toLowerCase(); + return templates().filter(template => { + if (template.type !== props.templateType) return false; + if (!query) return true; + return ( + template.name.toLowerCase().includes(query) || + template.content.toLowerCase().includes(query) + ); + }); + }); + + const saveCurrentAsTemplate = async () => { + const name = newTemplateName().trim(); + const content = props.currentValue.trim(); + + if (!name) { + alert('Enter a template name first.'); + return; + } + + if (!content) { + alert(`Add ${TEMPLATE_LABELS[props.templateType]} content before saving a template.`); + return; + } + + const now = new Date().toISOString(); + const userLabel = getCurrentUserLabel(); + const historyEntry: ScopeTextTemplateHistoryEntry = { + id: crypto.randomUUID(), + timestamp: now, + updatedBy: userLabel, + action: 'created', + previousContent: '', + nextContent: props.currentValue, + diff: createScopeTextDiff('', props.currentValue) + }; + + setIsSaving(true); + try { + const created = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).create({ + name, + type: props.templateType, + content: props.currentValue, + lastUpdatedBy: userLabel, + history: [historyEntry] + }); + setTemplates(prev => [{ + id: created.id, + name: created.name, + type: created.type, + content: created.content, + lastUpdatedBy: created.lastUpdatedBy, + history: Array.isArray(created.history) ? created.history : [historyEntry], + createdAt: created.created, + updatedAt: created.updated + }, ...prev]); + setNewTemplateName(''); + } catch (error) { + console.error('Failed to save scope text template:', error); + alert('Failed to save scope text template.'); + } finally { + setIsSaving(false); + } + }; + + const updateTemplateFromCurrent = async (template: ScopeTextTemplate) => { + if (template.content === props.currentValue) { + alert('Current text matches this template already.'); + return; + } + + const userLabel = getCurrentUserLabel(); + const historyEntry: ScopeTextTemplateHistoryEntry = { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + updatedBy: userLabel, + action: 'updated', + previousContent: template.content, + nextContent: props.currentValue, + diff: createScopeTextDiff(template.content, props.currentValue) + }; + + setUpdatingTemplateId(template.id); + try { + const updated = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).update(template.id, { + content: props.currentValue, + lastUpdatedBy: userLabel, + history: [...(template.history || []), historyEntry] + }); + + setTemplates(prev => prev + .map((entry) => entry.id === template.id + ? { + id: updated.id, + name: updated.name, + type: updated.type, + content: updated.content, + lastUpdatedBy: updated.lastUpdatedBy, + history: Array.isArray(updated.history) ? updated.history : [...(template.history || []), historyEntry], + createdAt: updated.created, + updatedAt: updated.updated + } + : entry + ) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + ); + } catch (error) { + console.error('Failed to update scope text template:', error); + alert('Failed to update scope text template.'); + } finally { + setUpdatingTemplateId(null); + } + }; + + const handleDelete = async (id: string) => { + const target = templates().find(template => template.id === id); + if (!target) return; + if (!window.confirm(`Delete template "${target.name}"?`)) return; + setDeletingTemplateId(id); + try { + await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).delete(id); + setTemplates(prev => prev.filter(template => template.id !== id)); + } catch (error) { + console.error('Failed to delete scope text template:', error); + alert('Failed to delete scope text template.'); + } finally { + setDeletingTemplateId(null); + } + }; + + return ( + + +
+
+
+
+

{TEMPLATE_LABELS[props.templateType]} Templates

+

Save reusable text and apply it to this scope

+
+ +
+ +
+
+
+ + setNewTemplateName(e.currentTarget.value)} + placeholder="Template name" + class="w-full bg-muted/20 border border-border/60 px-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" + /> +
+ +
+ +
+ +
+ + setSearchQuery(e.currentTarget.value)} + placeholder="Search templates" + 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" + /> +
+
+ +
+ + Loading templates... +
+ } + > + + {(template) => ( +
+
+
+

{template.name}

+

+ Updated {new Date(template.updatedAt).toLocaleDateString()} +

+ +
+ + + Last updated by {template.lastUpdatedBy} + +
+
+
+
+ + + + +
+
+
{template.content}
+ +
+
+ Edit History +
+ + {(entry) => ( +
+
+
+
+ {entry.action === 'created' ? 'Created' : 'Updated'} +
+
+ {new Date(entry.timestamp).toLocaleString()} +
+
+
+ + {entry.updatedBy} +
+
+
+ + {(line) => ( + +
+ {line.op === 'add' ? '+' : '-'} + {line.text || ' '} +
+
+ )} +
+ line.op !== 'equal')}> +
+ No line changes captured. +
+
+
+
+ )} +
+ +
+ No history recorded yet. +
+
+
+
+
+ )} +
+ + +
+

No {TEMPLATE_LABELS[props.templateType].toLowerCase()} templates found.

+

Save the current text to start reusing it across scopes.

+
+
+ +
+
+
+ +
+
+ ); +}; + +export default ScopeTextTemplateModal; diff --git a/src/components/estimate-builder/BuilderHeader.tsx b/src/components/estimate-builder/BuilderHeader.tsx index 300b342..9bc6568 100644 --- a/src/components/estimate-builder/BuilderHeader.tsx +++ b/src/components/estimate-builder/BuilderHeader.tsx @@ -1,6 +1,6 @@ import { createSignal, onCleanup, Show } from 'solid-js'; import type { Component } from 'solid-js'; -import { Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal } from 'lucide-solid'; +import { Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal, FileSpreadsheet } from 'lucide-solid'; import { appStore } from '../../store/appStore'; interface BuilderHeaderProps { @@ -8,6 +8,7 @@ interface BuilderHeaderProps { onToggleSidebar: () => void; onAddScope: () => void; onShowNewEstimateModal: () => void; + onImportCsvQuantities: (e: Event) => void; onImportEstimate: (e: Event) => void; onShowExportTableModal: () => void; onExportEstimate: () => void; @@ -68,6 +69,10 @@ export const BuilderHeader: Component = (props) => {

Data Exchange

+