Templates and export tables

This commit is contained in:
2026-03-11 14:34:10 -05:00
parent 6906cfe5da
commit cd6265e2f2
4 changed files with 457 additions and 2 deletions
+157
View File
@@ -0,0 +1,157 @@
import type { Component } from 'solid-js';
import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, FileBox, CheckCircle2 } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import { appStore } from '../store/appStore';
import type { EstimateItem, TemplateItem } from '../types';
interface ApplyTemplateModalProps {
show: boolean;
onClose: () => void;
scopeId: string | null;
}
const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
const subScopes = () => appStore.subScopes.filter(ss => ss.scopeId === props.scopeId);
const [templates, setTemplates] = createSignal<any[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [targetSubScopeId, setTargetSubScopeId] = createSignal<string>('scope'); // 'scope' means no sub-scope selected
const [isApplying, setIsApplying] = createSignal(false);
createEffect(() => {
if (props.show) {
loadTemplates();
setTargetSubScopeId('scope'); // reset selection
}
});
const loadTemplates = async () => {
setIsLoading(true);
try {
const records = await pb.collection(COLLECTIONS.TEMPLATES).getFullList({
sort: '-created'
});
setTemplates(records);
} catch (err) {
console.error(err);
alert('Failed to load templates.');
} finally {
setIsLoading(false);
}
};
const applyTemplate = (record: any) => {
const templateItems: TemplateItem[] = record.items || [];
if (templateItems.length === 0) {
alert('This template has no items.');
return;
}
setIsApplying(true);
const newItems: EstimateItem[] = templateItems.map(item => ({
id: crypto.randomUUID(),
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
markup: item.markup,
markupType: item.markupType,
contingency: item.contingency,
contingencyType: item.contingencyType,
scopeId: props.scopeId || undefined,
subScopeId: targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined
}));
// Append to existing items
appStore.setEstimateItems(prev => [...prev, ...newItems]);
setIsApplying(false);
props.onClose();
};
return (
<Show when={props.show && props.scopeId}>
<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-2xl 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">
<FileBox class="w-5 h-5" />
</div>
<div>
<h3 class="text-xl font-black">Apply Template</h3>
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-0.5">Append items to scope</p>
</div>
</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 flex flex-col p-6 gap-6 overflow-hidden">
{/* Target Selection */}
<div class="space-y-3 shrink-0">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Target Destination</label>
<select
value={targetSubScopeId()}
onChange={(e) => setTargetSubScopeId(e.currentTarget.value)}
class="w-full bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium appearance-none"
>
<option value="scope">Scope Level (Default)</option>
<For each={subScopes()}>
{(ss) => (
<option value={ss.id}>Sub-scope: {ss.name}</option>
)}
</For>
</select>
</div>
{/* Templates List */}
<div class="flex-1 flex flex-col min-h-0 space-y-3">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Select Template</label>
<div class="flex-1 overflow-y-auto space-y-3 bg-muted/10 border border-border/50 rounded-2xl p-4">
<Show
when={!isLoading()}
fallback={<div class="py-12 text-center text-muted-foreground text-sm font-medium">Loading templates...</div>}
>
<For each={templates()}>
{(record) => (
<div class="group flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card hover:border-primary/50 hover:shadow-premium transition-all">
<div>
<h4 class="font-bold text-foreground">{record.name}</h4>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
{record.items?.length || 0} Items {new Date(record.created).toLocaleDateString()}
</p>
</div>
<button
disabled={isApplying()}
onClick={() => applyTemplate(record)}
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
>
<CheckCircle2 class="w-4 h-4" /> Apply
</button>
</div>
)}
</For>
<Show when={templates().length === 0}>
<div class="py-12 flex flex-col items-center justify-center text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-background/50 h-full">
<FileBox class="w-8 h-8 mb-3 opacity-50" />
<p class="text-sm font-medium">No saved templates found.</p>
<p class="text-xs mt-1">Create one in the Template Creator first.</p>
</div>
</Show>
</Show>
</div>
</div>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default ApplyTemplateModal;
+18 -1
View File
@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import { createSignal, For, createMemo, onMount, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import { Plus, ChevronRight, ListTree, Calculator, FolderKanban, Info, PanelLeftOpen, PanelLeftClose, FileUp, Download, Upload, FileText, ChevronDown, AlertTriangle, CheckCircle2, X } from 'lucide-solid';
import { Plus, ChevronRight, ListTree, Calculator, FolderKanban, Info, PanelLeftOpen, PanelLeftClose, FileUp, Download, Upload, FileText, ChevronDown, AlertTriangle, CheckCircle2, X, Table } from 'lucide-solid';
import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
import type { Scope, SubScope, EstimateItem } from '../types';
import type { ExtractedItem } from '../utils/csv-parser';
@@ -14,6 +14,8 @@ import CalculatorPopover from './CalculatorPopover';
import { appStore } from '../store/appStore';
import { pb, COLLECTIONS } from '../utils/db';
import CloudLoadModal from './CloudLoadModal';
import ExportTableModal from './ExportTableModal';
import ApplyTemplateModal from './ApplyTemplateModal';
interface EstimateBuilderProps {
initialItems: ExtractedItem[];
@@ -48,6 +50,8 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
const [showSaveModal, setShowSaveModal] = createSignal(false);
const [showLoadModal, setShowLoadModal] = createSignal(false);
const [isCloudLoading, setIsCloudLoading] = createSignal(false);
const [showExportTableModal, setShowExportTableModal] = createSignal(false);
const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null);
onMount(() => {
// Initialize general notes if not already set by an imported estimate
@@ -500,6 +504,12 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
<Upload class="w-4 h-4" /> Import JSON
<input type="file" accept=".json" class="hidden" onChange={importEstimate} />
</label>
<button
onClick={() => setShowExportTableModal(true)}
class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border mr-2"
>
<Table class="w-4 h-4" /> Export Table
</button>
<button
onClick={exportEstimate}
class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border mr-2"
@@ -818,6 +828,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
allScopes={scopes}
allSubScopes={subScopes}
onCopySubScopeItems={copySubScopeItems}
onOpenApplyTemplate={setApplyTemplateScopeId}
isSidebarCollapsed={isSidebarCollapsed}
selectedIds={selectedIds()}
onToggleSelection={toggleSelection}
@@ -1064,6 +1075,12 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
{/* Load Modal */}
<CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} />
{/* Export Table Modal */}
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
{/* Apply Template Modal */}
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
</div>
);
};
+272
View File
@@ -0,0 +1,272 @@
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;
+10 -1
View File
@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import { For, createMemo, Show, createSignal } from 'solid-js';
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList } from 'lucide-solid';
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
import ItemRow from './ItemRow';
import SubScopeCard from './SubScopeCard';
@@ -25,6 +25,7 @@ interface ScopeCardProps {
allScopes: Scope[];
allSubScopes: SubScope[];
onCopySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
onOpenApplyTemplate: (scopeId: string) => void;
isSidebarCollapsed: () => boolean;
selectedIds: string[];
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
@@ -187,6 +188,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
Apply
</button>
</div>
<div class="flex items-center ml-auto">
<button
onClick={() => props.onOpenApplyTemplate(props.scope.id)}
class="flex items-center gap-2 px-3 py-1.5 bg-background text-primary border border-primary/30 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
>
<FileBox class="w-3.5 h-3.5" /> Apply Template
</button>
</div>
</div>
</Show>