Templates and export tables
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user