made scope exclusions for csv update

This commit is contained in:
2026-03-19 14:56:43 -05:00
parent c2f384a105
commit 72135a11d2
+155 -2
View File
@@ -51,7 +51,11 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
const [showHistoryModal, setShowHistoryModal] = createSignal(false); const [showHistoryModal, setShowHistoryModal] = createSignal(false);
const [showNewEstimateModal, setShowNewEstimateModal] = createSignal(false); const [showNewEstimateModal, setShowNewEstimateModal] = createSignal(false);
const [showExportTableModal, setShowExportTableModal] = createSignal(false); const [showExportTableModal, setShowExportTableModal] = createSignal(false);
const [showCsvImportModal, setShowCsvImportModal] = createSignal(false);
const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null); const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null);
const [pendingCsvImportText, setPendingCsvImportText] = createSignal<string | null>(null);
const [excludedScopeIds, setExcludedScopeIds] = createSignal<string[]>([]);
const [excludedSubScopeIds, setExcludedSubScopeIds] = createSignal<string[]>([]);
// 2. Initialize custom hooks/primitives // 2. Initialize custom hooks/primitives
const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager(); const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager();
@@ -191,7 +195,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
return Number.isFinite(parsed) ? parsed : 0; 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); const { items: extracted, ignored } = extractItemsFromCSV(csvText);
appStore.setIgnoredLines(ignored); appStore.setIgnoredLines(ignored);
@@ -221,8 +225,14 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
let updatedExistingCount = 0; let updatedExistingCount = 0;
const matchedDescriptions = new Set<string>(); const matchedDescriptions = new Set<string>();
const excludedScopeIdSet = new Set(options?.excludedScopeIds ?? []);
const excludedSubScopeIdSet = new Set(options?.excludedSubScopeIds ?? []);
const nextItems = items.map(item => { 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 key = normalizeDescription(item.description);
const imported = importedByDescription.get(key); const imported = importedByDescription.get(key);
@@ -252,15 +262,59 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
setItems([...nextItems, ...newUnassignedItems]); setItems([...nextItems, ...newUnassignedItems]);
const ignoredCount = ignored.length; const ignoredCount = ignored.length;
const excludedCount = excludedScopeIdSet.size + excludedSubScopeIdSet.size;
const toastParts = [ const toastParts = [
`Updated ${updatedExistingCount} item${updatedExistingCount === 1 ? '' : 's'}`, `Updated ${updatedExistingCount} item${updatedExistingCount === 1 ? '' : 's'}`,
`added ${newUnassignedItems.length} unassigned`, `added ${newUnassignedItems.length} unassigned`,
excludedCount > 0 ? `excluded ${excludedCount} selection${excludedCount === 1 ? '' : 's'}` : null,
ignoredCount > 0 ? `ignored ${ignoredCount} row${ignoredCount === 1 ? '' : 's'}` : null ignoredCount > 0 ? `ignored ${ignoredCount} row${ignoredCount === 1 ? '' : 's'}` : null
].filter(Boolean); ].filter(Boolean);
setCopyToast(toastParts.join(' • ')); setCopyToast(toastParts.join(' • '));
window.setTimeout(() => setCopyToast(null), 3000); 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 handleImportCsvQuantities = (e: Event) => {
const input = e.currentTarget as HTMLInputElement; const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
@@ -270,7 +324,10 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
reader.onload = (event) => { reader.onload = (event) => {
const text = event.target?.result; const text = event.target?.result;
if (typeof text === 'string') { if (typeof text === 'string') {
mergeCsvQuantities(text); setPendingCsvImportText(text);
setExcludedScopeIds([]);
setExcludedSubScopeIds([]);
setShowCsvImportModal(true);
} }
}; };
reader.readAsText(file); reader.readAsText(file);
@@ -470,6 +527,102 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} /> <ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} /> <ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
<HistoryModal show={showHistoryModal()} onClose={() => setShowHistoryModal(false)} onCheckout={checkoutVersion} /> <HistoryModal show={showHistoryModal()} onClose={() => setShowHistoryModal(false)} onCheckout={checkoutVersion} />
<Show when={showCsvImportModal()}>
<Portal>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div class="bg-card w-full max-w-2xl rounded-3xl shadow-premium border border-border animate-in zoom-in-95 duration-200 overflow-hidden">
<div class="p-6 border-b border-border/50 flex items-center justify-between bg-muted/20">
<div>
<h3 class="text-xl font-black">Import CSV Quantities</h3>
<p class="text-[10px] text-muted-foreground font-black uppercase tracking-widest mt-1">Choose scopes or sub-scopes to leave unchanged</p>
</div>
<button onClick={closeCsvImportModal} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
<p class="text-sm text-muted-foreground">
Matching items in excluded sections will keep their current quantities. Unassigned items are always eligible for updates.
</p>
<Show
when={scopes.length > 0}
fallback={
<div class="rounded-2xl border border-border bg-muted/20 px-4 py-5 text-sm text-muted-foreground">
No scopes exist yet. Import will only update unassigned items and add unmatched rows to the unassigned list.
</div>
}
>
<div class="space-y-4">
<For each={scopes}>
{(scope) => {
const scopeSubScopes = () => subScopes.filter(ss => ss.scopeId === scope.id);
const isScopeExcluded = () => excludedScopeIds().includes(scope.id);
return (
<div class="rounded-2xl border border-border bg-background overflow-hidden">
<label class="flex items-start gap-3 px-4 py-4 border-b border-border/50">
<input
type="checkbox"
checked={isScopeExcluded()}
onChange={() => toggleScopeExclusion(scope.id)}
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
<div class="min-w-0">
<div class="font-bold text-foreground">{scope.name}</div>
<div class="text-xs text-muted-foreground">Exclude this entire scope from quantity replacement.</div>
</div>
</label>
<Show when={scopeSubScopes().length > 0}>
<div class={`px-4 py-3 space-y-2 ${isScopeExcluded() ? 'opacity-50' : ''}`}>
<div class="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Sub-scopes</div>
<For each={scopeSubScopes()}>
{(subScope) => (
<label class="flex items-start gap-3 rounded-xl px-3 py-2 hover:bg-muted/30 transition-colors">
<input
type="checkbox"
checked={excludedSubScopeIds().includes(subScope.id)}
disabled={isScopeExcluded()}
onChange={() => toggleSubScopeExclusion(subScope.id)}
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary disabled:cursor-not-allowed"
/>
<div class="min-w-0">
<div class="font-medium text-foreground">{subScope.name}</div>
<div class="text-xs text-muted-foreground">Exclude only this sub-scope.</div>
</div>
</label>
)}
</For>
</div>
</Show>
</div>
);
}}
</For>
</div>
</Show>
</div>
<div class="p-6 bg-muted/20 border-t border-border/50 flex justify-end gap-3">
<button
onClick={closeCsvImportModal}
class="px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-background transition-colors"
>
Cancel
</button>
<button
onClick={confirmCsvImport}
class="px-5 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-premium hover:shadow-premium-hover transition-all"
>
Import Quantities
</button>
</div>
</div>
</div>
</Portal>
</Show>
<NewEstimateModal <NewEstimateModal
show={showNewEstimateModal()} show={showNewEstimateModal()}