diff --git a/src/components/EstimateBuilder.tsx b/src/components/EstimateBuilder.tsx index 9fbc50e..d80ffe2 100644 --- a/src/components/EstimateBuilder.tsx +++ b/src/components/EstimateBuilder.tsx @@ -51,7 +51,11 @@ const EstimateBuilder: Component = (props) => { const [showHistoryModal, setShowHistoryModal] = createSignal(false); const [showNewEstimateModal, setShowNewEstimateModal] = createSignal(false); const [showExportTableModal, setShowExportTableModal] = createSignal(false); + const [showCsvImportModal, setShowCsvImportModal] = createSignal(false); const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal(null); + const [pendingCsvImportText, setPendingCsvImportText] = createSignal(null); + const [excludedScopeIds, setExcludedScopeIds] = createSignal([]); + const [excludedSubScopeIds, setExcludedSubScopeIds] = createSignal([]); // 2. Initialize custom hooks/primitives const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager(); @@ -191,7 +195,7 @@ const EstimateBuilder: Component = (props) => { return Number.isFinite(parsed) ? parsed : 0; }; - const mergeCsvQuantities = (csvText: string) => { + const mergeCsvQuantities = (csvText: string, options?: { excludedScopeIds?: string[]; excludedSubScopeIds?: string[] }) => { const { items: extracted, ignored } = extractItemsFromCSV(csvText); appStore.setIgnoredLines(ignored); @@ -221,8 +225,14 @@ const EstimateBuilder: Component = (props) => { let updatedExistingCount = 0; const matchedDescriptions = new Set(); + const excludedScopeIdSet = new Set(options?.excludedScopeIds ?? []); + const excludedSubScopeIdSet = new Set(options?.excludedSubScopeIds ?? []); const nextItems = items.map(item => { + const isExcludedByScope = item.scopeId ? excludedScopeIdSet.has(item.scopeId) : false; + const isExcludedBySubScope = item.subScopeId ? excludedSubScopeIdSet.has(item.subScopeId) : false; + if (isExcludedByScope || isExcludedBySubScope) return item; + const key = normalizeDescription(item.description); const imported = importedByDescription.get(key); @@ -252,15 +262,59 @@ const EstimateBuilder: Component = (props) => { setItems([...nextItems, ...newUnassignedItems]); const ignoredCount = ignored.length; + const excludedCount = excludedScopeIdSet.size + excludedSubScopeIdSet.size; const toastParts = [ `Updated ${updatedExistingCount} item${updatedExistingCount === 1 ? '' : 's'}`, `added ${newUnassignedItems.length} unassigned`, + excludedCount > 0 ? `excluded ${excludedCount} selection${excludedCount === 1 ? '' : 's'}` : null, ignoredCount > 0 ? `ignored ${ignoredCount} row${ignoredCount === 1 ? '' : 's'}` : null ].filter(Boolean); setCopyToast(toastParts.join(' • ')); window.setTimeout(() => setCopyToast(null), 3000); }; + const toggleScopeExclusion = (scopeId: string) => { + const isExcluded = excludedScopeIds().includes(scopeId); + if (isExcluded) { + setExcludedScopeIds(prev => prev.filter(id => id !== scopeId)); + return; + } + + setExcludedScopeIds(prev => [...prev, scopeId]); + const subScopeIdsInScope = subScopes.filter(ss => ss.scopeId === scopeId).map(ss => ss.id); + if (subScopeIdsInScope.length > 0) { + setExcludedSubScopeIds(prev => prev.filter(id => !subScopeIdsInScope.includes(id))); + } + }; + + const toggleSubScopeExclusion = (subScopeId: string) => { + const isExcluded = excludedSubScopeIds().includes(subScopeId); + if (isExcluded) { + setExcludedSubScopeIds(prev => prev.filter(id => id !== subScopeId)); + return; + } + + setExcludedSubScopeIds(prev => [...prev, subScopeId]); + }; + + const closeCsvImportModal = () => { + setShowCsvImportModal(false); + setPendingCsvImportText(null); + setExcludedScopeIds([]); + setExcludedSubScopeIds([]); + }; + + const confirmCsvImport = () => { + const csvText = pendingCsvImportText(); + if (!csvText) return; + + mergeCsvQuantities(csvText, { + excludedScopeIds: excludedScopeIds(), + excludedSubScopeIds: excludedSubScopeIds() + }); + closeCsvImportModal(); + }; + const handleImportCsvQuantities = (e: Event) => { const input = e.currentTarget as HTMLInputElement; const file = input.files?.[0]; @@ -270,7 +324,10 @@ const EstimateBuilder: Component = (props) => { reader.onload = (event) => { const text = event.target?.result; if (typeof text === 'string') { - mergeCsvQuantities(text); + setPendingCsvImportText(text); + setExcludedScopeIds([]); + setExcludedSubScopeIds([]); + setShowCsvImportModal(true); } }; reader.readAsText(file); @@ -470,6 +527,102 @@ const EstimateBuilder: Component = (props) => { setShowExportTableModal(false)} /> setApplyTemplateScopeId(null)} /> setShowHistoryModal(false)} onCheckout={checkoutVersion} /> + + +
+
+
+
+

Import CSV Quantities

+

Choose scopes or sub-scopes to leave unchanged

+
+ +
+ +
+

+ Matching items in excluded sections will keep their current quantities. Unassigned items are always eligible for updates. +

+ + 0} + fallback={ +
+ No scopes exist yet. Import will only update unassigned items and add unmatched rows to the unassigned list. +
+ } + > +
+ + {(scope) => { + const scopeSubScopes = () => subScopes.filter(ss => ss.scopeId === scope.id); + const isScopeExcluded = () => excludedScopeIds().includes(scope.id); + + return ( +
+ + + 0}> +
+
Sub-scopes
+ + {(subScope) => ( + + )} + +
+
+
+ ); + }} +
+
+
+
+ +
+ + +
+
+
+
+