273 lines
15 KiB
TypeScript
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;
|