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 [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()}
|
||||||
|
|||||||
Reference in New Issue
Block a user