Files
EstiMaker/src/components/ExportTableModal.tsx
T
2026-03-11 14:34:10 -05:00

273 lines
15 KiB
TypeScript

import type { Component } from 'solid-js';
import { createSignal, Show, For, onMount } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, Table, Copy, Download, CheckCircle2 } from 'lucide-solid';
import { appStore } from '../store/appStore';
interface ExportTableModalProps {
show: boolean;
onClose: () => void;
}
const AVAILABLE_COLUMNS = [
{ id: 'scope', label: 'Scope' },
{ id: 'subscope', label: 'Subscope' },
{ id: 'description', label: 'Description' },
{ id: 'quantity', label: 'Quantity' },
{ id: 'unitPrice', label: 'Unit Price' },
{ id: 'subtotal', label: 'Subtotal' },
{ id: 'markup', label: 'Markup' },
{ id: 'contingency', label: 'Contingency' },
{ id: 'total', label: 'Total' }
];
const ExportTableModal: Component<ExportTableModalProps> = (props) => {
const scopes = () => appStore.scopes;
const subScopes = () => appStore.subScopes;
const items = () => appStore.estimateItems;
const [selectedScopeIds, setSelectedScopeIds] = createSignal<string[]>([]);
const [selectedSubScopeIds, setSelectedSubScopeIds] = createSignal<string[]>([]);
const [selectedColumns, setSelectedColumns] = createSignal<string[]>(AVAILABLE_COLUMNS.map(c => c.id));
const [copyToast, setCopyToast] = createSignal(false);
// Initialize with all scopes and subscopes selected
onMount(() => {
if (props.show) {
setSelectedScopeIds(scopes().map(s => s.id));
setSelectedSubScopeIds(subScopes().map(s => s.id));
}
});
const toggleScope = (scopeId: string) => {
const isSelected = selectedScopeIds().includes(scopeId);
if (isSelected) {
setSelectedScopeIds(prev => prev.filter(id => id !== scopeId));
// Deselect all its subscopes
const subscopeIds = subScopes().filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
setSelectedSubScopeIds(prev => prev.filter(id => !subscopeIds.includes(id)));
} else {
setSelectedScopeIds(prev => [...prev, scopeId]);
// Select all its subscopes
const subscopeIds = subScopes().filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
setSelectedSubScopeIds(prev => [...new Set([...prev, ...subscopeIds])]);
}
};
const toggleSubScope = (subScopeId: string, scopeId: string) => {
const isSelected = selectedSubScopeIds().includes(subScopeId);
if (isSelected) {
setSelectedSubScopeIds(prev => prev.filter(id => id !== subScopeId));
// If we deselected a subscope, also make sure the parent scope stays selected if we want, or deselect it if 0
// Actually, keep it simple. Just toggle the subscope.
} else {
setSelectedSubScopeIds(prev => [...prev, subScopeId]);
// If the parent scope isn't selected, select it
if (!selectedScopeIds().includes(scopeId)) {
setSelectedScopeIds(prev => [...prev, scopeId]);
}
}
};
const toggleColumn = (columnId: string) => {
setSelectedColumns(prev =>
prev.includes(columnId) ? prev.filter(id => id !== columnId) : [...prev, columnId]
);
};
const generateTableData = () => {
const dataRows: Record<string, any>[] = [];
// Filter items that belong to the selected scopes and subscopes (or unassigned if we handled it, but let's stick to assigned)
// Also handle scope-level items (no subscope) if the scope is selected
const filteredItems = items().filter(item => {
if (item.subScopeId) {
return selectedSubScopeIds().includes(item.subScopeId);
} else if (item.scopeId) {
return selectedScopeIds().includes(item.scopeId);
}
return false; // ignore unassigned for this specific scope/subscope export
});
filteredItems.forEach(item => {
const scope = scopes().find(s => s.id === item.scopeId);
const subScope = subScopes().find(ss => ss.id === item.subScopeId);
const base = item.quantity * item.unitPrice;
const markupAmt = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
const contingencyAmt = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
const total = base + markupAmt + contingencyAmt;
dataRows.push({
scope: scope?.name || 'Unassigned',
subscope: subScope?.name || 'Scope Level',
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
subtotal: base,
markup: markupAmt,
contingency: contingencyAmt,
total: total
});
});
return dataRows;
};
const copyToClipboard = async () => {
const data = generateTableData();
const cols = AVAILABLE_COLUMNS.filter(c => selectedColumns().includes(c.id));
let tsv = cols.map(c => c.label).join('\t') + '\n';
data.forEach(row => {
tsv += cols.map(c => {
const val = row[c.id];
if (typeof val === 'number') {
// For quantities don't add $ sign typically, but we will rely on excel to format it or just give raw numbers
return ['unitPrice', 'subtotal', 'markup', 'contingency', 'total'].includes(c.id)
? `$${val.toFixed(2)}`
: val.toString();
}
return String(val || '').replace(/\t/g, ' ');
}).join('\t') + '\n';
});
try {
await navigator.clipboard.writeText(tsv);
setCopyToast(true);
setTimeout(() => setCopyToast(false), 2000);
} catch (err) {
console.error('Failed to copy', err);
}
};
const downloadCSV = () => {
const data = generateTableData();
const cols = AVAILABLE_COLUMNS.filter(c => selectedColumns().includes(c.id));
let csv = cols.map(c => c.label).join(',') + '\n';
data.forEach(row => {
csv += cols.map(c => {
const val = row[c.id];
const strVal = typeof val === 'number' && ['unitPrice', 'subtotal', 'markup', 'contingency', 'total'].includes(c.id)
? `$${val.toFixed(2)}`
: String(val || '');
return `"${strVal.replace(/"/g, '""')}"`;
}).join(',') + '\n';
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `exported-table.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<Show when={props.show}>
<Portal>
<div class="fixed inset-0 z-[100] flex flex-col 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-4xl max-h-[85vh] flex flex-col rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
<div class="flex items-center justify-between p-6 border-b border-border">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 text-primary rounded-xl">
<Table class="w-5 h-5" />
</div>
<h3 class="text-xl font-black">Export Table Output</h3>
</div>
<button onClick={props.onClose} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-6 flex flex-col md:flex-row gap-8">
{/* Scopes & Subscopes Column */}
<div class="flex-1 space-y-4">
<h4 class="text-xs font-black text-muted-foreground uppercase tracking-widest">Include Scopes & Subscopes</h4>
<div class="space-y-3 bg-muted/10 p-4 rounded-2xl border border-border/50 max-h-[50vh] overflow-y-auto">
<For each={scopes()}>
{(scope) => (
<div class="space-y-2">
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleScope(scope.id); }}>
<div class={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${selectedScopeIds().includes(scope.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
<Show when={selectedScopeIds().includes(scope.id)}>
<CheckCircle2 class="w-3.5 h-3.5" />
</Show>
</div>
<span class="font-bold">{scope.name}</span>
</label>
<div class="pl-8 space-y-2 border-l border-border/50 ml-2.5">
<For each={subScopes().filter(ss => ss.scopeId === scope.id)}>
{(subScope) => (
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleSubScope(subScope.id, scope.id); }}>
<div class={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${selectedSubScopeIds().includes(subScope.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
<Show when={selectedSubScopeIds().includes(subScope.id)}>
<CheckCircle2 class="w-3 h-3" />
</Show>
</div>
<span class="text-sm text-foreground/80">{subScope.name}</span>
</label>
)}
</For>
</div>
</div>
)}
</For>
<Show when={scopes().length === 0}>
<div class="text-sm text-muted-foreground italic">No scopes available.</div>
</Show>
</div>
</div>
{/* Columns Column */}
<div class="flex-1 space-y-4">
<h4 class="text-xs font-black text-muted-foreground uppercase tracking-widest">Include Columns</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 bg-muted/10 p-4 rounded-2xl border border-border/50">
<For each={AVAILABLE_COLUMNS}>
{(col) => (
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleColumn(col.id); }}>
<div class={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${selectedColumns().includes(col.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
<Show when={selectedColumns().includes(col.id)}>
<CheckCircle2 class="w-3.5 h-3.5" />
</Show>
</div>
<span class="font-bold text-sm">{col.label}</span>
</label>
)}
</For>
</div>
</div>
</div>
<div class="p-6 border-t border-border flex justify-end gap-3 bg-muted/5">
<button
onClick={copyToClipboard}
class="flex items-center gap-2 px-6 py-2.5 bg-background text-foreground border border-border rounded-xl text-sm font-bold shadow-sm hover:border-primary/50 hover:text-primary transition-all relative"
>
<Copy class="w-4 h-4" /> Copy to Clipboard
<Show when={copyToast()}>
<div class="absolute -top-10 left-1/2 -translate-x-1/2 bg-foreground text-background text-xs px-3 py-1 rounded-lg font-bold shadow-lg animate-in slide-in-from-bottom-2">
Copied!
</div>
</Show>
</button>
<button
onClick={downloadCSV}
class="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-xl text-sm font-bold shadow-lg shadow-primary/20 hover:bg-primary/90 transition-all"
>
<Download class="w-4 h-4" /> Download CSV
</button>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default ExportTableModal;