made scope exclusions for csv update
This commit is contained in:
@@ -51,7 +51,11 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (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<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
|
||||
const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager();
|
||||
@@ -191,7 +195,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (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<EstimateBuilderProps> = (props) => {
|
||||
|
||||
let updatedExistingCount = 0;
|
||||
const matchedDescriptions = new Set<string>();
|
||||
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<EstimateBuilderProps> = (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<EstimateBuilderProps> = (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<EstimateBuilderProps> = (props) => {
|
||||
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
|
||||
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
|
||||
<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
|
||||
show={showNewEstimateModal()}
|
||||
|
||||
Reference in New Issue
Block a user