Update from CSV added

This commit is contained in:
2026-03-19 14:48:15 -05:00
parent f2dcff7b71
commit c2f384a105
8 changed files with 632 additions and 9 deletions
+94
View File
@@ -6,6 +6,7 @@ import { Portal } from 'solid-js/web';
import { appStore } from '../store/appStore';
import type { Scope, SubScope, EstimateItem } from '../types';
import type { ExtractedItem } from '../utils/csv-parser';
import { extractItemsFromCSV } from '../utils/csv-parser';
import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
import PrintEstimate from './PrintEstimate';
@@ -184,6 +185,98 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
const [copyToast, setCopyToast] = createSignal<string | null>(null);
const normalizeDescription = (value: string) => value.trim().toLowerCase().replace(/\s+/g, ' ');
const parseImportedQuantity = (value: string) => {
const parsed = Number.parseFloat(value.replace(/,/g, '').trim());
return Number.isFinite(parsed) ? parsed : 0;
};
const mergeCsvQuantities = (csvText: string) => {
const { items: extracted, ignored } = extractItemsFromCSV(csvText);
appStore.setIgnoredLines(ignored);
if (extracted.length === 0) {
setCopyToast('No CSV items were found to import.');
window.setTimeout(() => setCopyToast(null), 2500);
return;
}
const importedByDescription = new Map<string, { description: string; quantity: number }>();
for (const item of extracted) {
const key = normalizeDescription(item.description);
if (!key) continue;
const quantity = parseImportedQuantity(item.quantity);
const existing = importedByDescription.get(key);
if (existing) {
existing.quantity += quantity;
} else {
importedByDescription.set(key, {
description: item.description.trim(),
quantity
});
}
}
let updatedExistingCount = 0;
const matchedDescriptions = new Set<string>();
const nextItems = items.map(item => {
const key = normalizeDescription(item.description);
const imported = importedByDescription.get(key);
if (!imported) return item;
matchedDescriptions.add(key);
updatedExistingCount += 1;
return { ...item, quantity: imported.quantity };
});
const newUnassignedItems: EstimateItem[] = [];
for (const [key, imported] of importedByDescription.entries()) {
if (matchedDescriptions.has(key)) continue;
newUnassignedItems.push({
id: crypto.randomUUID(),
description: imported.description,
quantity: imported.quantity,
unitPrice: 0,
markup: 0,
markupType: 'percent',
contingency: 0,
contingencyType: 'percent'
});
}
setItems([...nextItems, ...newUnassignedItems]);
const ignoredCount = ignored.length;
const toastParts = [
`Updated ${updatedExistingCount} item${updatedExistingCount === 1 ? '' : 's'}`,
`added ${newUnassignedItems.length} unassigned`,
ignoredCount > 0 ? `ignored ${ignoredCount} row${ignoredCount === 1 ? '' : 's'}` : null
].filter(Boolean);
setCopyToast(toastParts.join(' • '));
window.setTimeout(() => setCopyToast(null), 3000);
};
const handleImportCsvQuantities = (e: Event) => {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result;
if (typeof text === 'string') {
mergeCsvQuantities(text);
}
};
reader.readAsText(file);
input.value = '';
};
return (
<div class="w-full flex flex-col min-h-screen bg-white shadow-2xl relative print:shadow-none print:border-none print:rounded-none">
<PrintEstimate
@@ -205,6 +298,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
onAddScope={addScope}
onShowNewEstimateModal={() => setShowNewEstimateModal(true)}
onImportCsvQuantities={handleImportCsvQuantities}
onImportEstimate={importEstimate}
onShowExportTableModal={() => setShowExportTableModal(true)}
onExportEstimate={exportEstimate}
+11 -7
View File
@@ -9,6 +9,7 @@ interface NoteEditorProps {
icon: any;
placeholder?: string;
highlight?: boolean;
headerActions?: any;
}
const NoteEditor: Component<NoteEditorProps> = (props) => {
@@ -56,13 +57,16 @@ const NoteEditor: Component<NoteEditorProps> = (props) => {
</div>
<span class={`text-[10px] font-black uppercase tracking-widest ${props.highlight ? 'text-destructive' : 'text-muted-foreground'}`}>{props.label}</span>
</div>
<button
onClick={() => setIsEditing(!isEditing())}
class={`flex items-center gap-1.5 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border ${isEditing() ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'text-muted-foreground hover:bg-muted border-border/40'}`}
>
{isEditing() ? <Eye class="w-3 h-3 mr-1.5" /> : <Edit3 class="w-3 h-3 mr-1.5" />}
{isEditing() ? 'Preview' : 'Edit'}
</button>
<div class="flex items-center gap-2">
{props.headerActions}
<button
onClick={() => setIsEditing(!isEditing())}
class={`flex items-center gap-1.5 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border ${isEditing() ? 'bg-primary text-primary-foreground border-primary shadow-sm' : 'text-muted-foreground hover:bg-muted border-border/40'}`}
>
{isEditing() ? <Eye class="w-3 h-3 mr-1.5" /> : <Edit3 class="w-3 h-3 mr-1.5" />}
{isEditing() ? 'Preview' : 'Edit'}
</button>
</div>
</div>
<div class="p-4 min-h-[100px]">
<Show
+48 -1
View File
@@ -1,7 +1,7 @@
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
import NumericInput from './ui/NumericInput';
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
import type { EstimateItem, Scope, SubScope, PricingType, ScopeTextTemplateType } from '../types';
import ItemRow from './ItemRow';
import SubScopeCard from './SubScopeCard';
import NoteEditor from './NoteEditor';
@@ -10,6 +10,7 @@ import ScopeScaleWrapper from './ui/ScopeScaleWrapper';
import { ModifierRegistry } from '../utils/modifier-registry';
import { appStore } from '../store/appStore';
import { SupplierDropdown } from './ui/SupplierDropdown';
import ScopeTextTemplateModal from './ScopeTextTemplateModal';
interface ScopeCardProps {
scope: Scope;
@@ -44,6 +45,8 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
const [bulkMarkup, setBulkMarkup] = createSignal(0);
const [bulkContingency, setBulkContingency] = createSignal(0);
const [isOver, setIsOver] = createSignal(false);
const [activeTemplateType, setActiveTemplateType] = createSignal<ScopeTextTemplateType>('estimatorNotes');
const [isTemplateModalOpen, setIsTemplateModalOpen] = createSignal(false);
const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId));
@@ -116,6 +119,26 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
setIsOver(false);
};
const openTemplateModal = (type: ScopeTextTemplateType) => {
setActiveTemplateType(type);
setIsTemplateModalOpen(true);
};
const templateModalCurrentValue = createMemo(() =>
activeTemplateType() === 'estimatorNotes'
? props.scope.estimatorNotes || ''
: props.scope.bidDescription || ''
);
const applyTemplateValue = (value: string) => {
if (activeTemplateType() === 'estimatorNotes') {
props.onUpdateScope(props.scope.id, { estimatorNotes: value });
return;
}
props.onUpdateScope(props.scope.id, { bidDescription: value });
};
return (
<div
onDragOver={handleDragOver}
@@ -460,6 +483,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
onUpdate={(val) => props.onUpdateScope(props.scope.id, { estimatorNotes: val })}
icon={ClipboardList}
highlight={props.scope.estimatorNotes?.includes('#')}
headerActions={
<button
onClick={() => openTemplateModal('estimatorNotes')}
class="px-3 py-1 rounded-lg text-[10px] font-bold transition-all border text-muted-foreground hover:bg-muted border-border/40"
>
Template
</button>
}
/>
<NoteEditor
label="Scope Description (Appears on Finale Estimate)"
@@ -467,6 +498,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
onUpdate={(val) => props.onUpdateScope(props.scope.id, { bidDescription: val })}
icon={FileText}
highlight={props.scope.bidDescription?.includes('#')}
headerActions={
<button
onClick={() => openTemplateModal('bidDescription')}
class="px-3 py-1 rounded-lg text-[10px] font-bold transition-all border text-muted-foreground hover:bg-muted border-border/40"
>
Template
</button>
}
/>
</div>
@@ -504,6 +543,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
</div>
</div>
</Show>
<ScopeTextTemplateModal
show={isTemplateModalOpen()}
templateType={activeTemplateType()}
currentValue={templateModalCurrentValue()}
onApply={applyTemplateValue}
onClose={() => setIsTemplateModalOpen(false)}
/>
</div>
);
};
+386
View File
@@ -0,0 +1,386 @@
import { createEffect, createMemo, createSignal, For, Show, type Component } from 'solid-js';
import { Portal } from 'solid-js/web';
import { CheckCircle2, CopyPlus, History, PencilLine, Search, Trash2, User, X } from 'lucide-solid';
import type { ScopeTextTemplate, ScopeTextTemplateHistoryEntry, ScopeTextTemplateType } from '../types';
import { pb, COLLECTIONS } from '../utils/db';
import { createScopeTextDiff } from '../utils/scope-text-template-history';
interface ScopeTextTemplateModalProps {
show: boolean;
templateType: ScopeTextTemplateType;
currentValue: string;
onApply: (value: string) => void;
onClose: () => void;
}
const TEMPLATE_LABELS: Record<ScopeTextTemplateType, string> = {
estimatorNotes: "Estimator's Notes",
bidDescription: 'Scope Description'
};
const getCurrentUserLabel = () =>
pb.authStore.model?.name || pb.authStore.model?.email || 'Unknown';
const ScopeTextTemplateModal: Component<ScopeTextTemplateModalProps> = (props) => {
const [templates, setTemplates] = createSignal<ScopeTextTemplate[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [isSaving, setIsSaving] = createSignal(false);
const [updatingTemplateId, setUpdatingTemplateId] = createSignal<string | null>(null);
const [deletingTemplateId, setDeletingTemplateId] = createSignal<string | null>(null);
const [expandedHistoryId, setExpandedHistoryId] = createSignal<string | null>(null);
const [searchQuery, setSearchQuery] = createSignal('');
const [newTemplateName, setNewTemplateName] = createSignal('');
createEffect(() => {
if (props.show) {
void loadTemplates();
setSearchQuery('');
setNewTemplateName('');
}
});
const loadTemplates = async () => {
setIsLoading(true);
try {
const records = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).getFullList<any>({
sort: '-updated'
});
setTemplates(records.map((record) => ({
id: record.id,
name: record.name,
type: record.type,
content: record.content,
lastUpdatedBy: record.lastUpdatedBy,
history: Array.isArray(record.history) ? record.history : [],
createdAt: record.created,
updatedAt: record.updated
})));
} catch (error) {
console.error('Failed to load scope text templates:', error);
alert('Failed to load scope text templates.');
} finally {
setIsLoading(false);
}
};
const filteredTemplates = createMemo(() => {
const query = searchQuery().trim().toLowerCase();
return templates().filter(template => {
if (template.type !== props.templateType) return false;
if (!query) return true;
return (
template.name.toLowerCase().includes(query) ||
template.content.toLowerCase().includes(query)
);
});
});
const saveCurrentAsTemplate = async () => {
const name = newTemplateName().trim();
const content = props.currentValue.trim();
if (!name) {
alert('Enter a template name first.');
return;
}
if (!content) {
alert(`Add ${TEMPLATE_LABELS[props.templateType]} content before saving a template.`);
return;
}
const now = new Date().toISOString();
const userLabel = getCurrentUserLabel();
const historyEntry: ScopeTextTemplateHistoryEntry = {
id: crypto.randomUUID(),
timestamp: now,
updatedBy: userLabel,
action: 'created',
previousContent: '',
nextContent: props.currentValue,
diff: createScopeTextDiff('', props.currentValue)
};
setIsSaving(true);
try {
const created = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).create<any>({
name,
type: props.templateType,
content: props.currentValue,
lastUpdatedBy: userLabel,
history: [historyEntry]
});
setTemplates(prev => [{
id: created.id,
name: created.name,
type: created.type,
content: created.content,
lastUpdatedBy: created.lastUpdatedBy,
history: Array.isArray(created.history) ? created.history : [historyEntry],
createdAt: created.created,
updatedAt: created.updated
}, ...prev]);
setNewTemplateName('');
} catch (error) {
console.error('Failed to save scope text template:', error);
alert('Failed to save scope text template.');
} finally {
setIsSaving(false);
}
};
const updateTemplateFromCurrent = async (template: ScopeTextTemplate) => {
if (template.content === props.currentValue) {
alert('Current text matches this template already.');
return;
}
const userLabel = getCurrentUserLabel();
const historyEntry: ScopeTextTemplateHistoryEntry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
updatedBy: userLabel,
action: 'updated',
previousContent: template.content,
nextContent: props.currentValue,
diff: createScopeTextDiff(template.content, props.currentValue)
};
setUpdatingTemplateId(template.id);
try {
const updated = await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).update<any>(template.id, {
content: props.currentValue,
lastUpdatedBy: userLabel,
history: [...(template.history || []), historyEntry]
});
setTemplates(prev => prev
.map((entry) => entry.id === template.id
? {
id: updated.id,
name: updated.name,
type: updated.type,
content: updated.content,
lastUpdatedBy: updated.lastUpdatedBy,
history: Array.isArray(updated.history) ? updated.history : [...(template.history || []), historyEntry],
createdAt: updated.created,
updatedAt: updated.updated
}
: entry
)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
);
} catch (error) {
console.error('Failed to update scope text template:', error);
alert('Failed to update scope text template.');
} finally {
setUpdatingTemplateId(null);
}
};
const handleDelete = async (id: string) => {
const target = templates().find(template => template.id === id);
if (!target) return;
if (!window.confirm(`Delete template "${target.name}"?`)) return;
setDeletingTemplateId(id);
try {
await pb.collection(COLLECTIONS.SCOPE_TEXT_TEMPLATES).delete(id);
setTemplates(prev => prev.filter(template => template.id !== id));
} catch (error) {
console.error('Failed to delete scope text template:', error);
alert('Failed to delete scope text template.');
} finally {
setDeletingTemplateId(null);
}
};
return (
<Show when={props.show}>
<Portal>
<div class="fixed inset-0 z-[110] 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-3xl 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>
<h3 class="text-xl font-black text-foreground">{TEMPLATE_LABELS[props.templateType]} Templates</h3>
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-1">Save reusable text and apply it to this scope</p>
</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="p-6 space-y-6 overflow-y-auto">
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-3 items-end">
<div class="space-y-2">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Save Current Text as Template</label>
<input
type="text"
value={newTemplateName()}
onInput={(e) => setNewTemplateName(e.currentTarget.value)}
placeholder="Template name"
class="w-full bg-muted/20 border border-border/60 px-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
/>
</div>
<button
onClick={saveCurrentAsTemplate}
disabled={isSaving()}
class="flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-2xl text-sm font-bold hover:bg-primary/90 transition-colors"
>
<CopyPlus class="w-4 h-4" />
{isSaving() ? 'Saving...' : 'Save Template'}
</button>
</div>
<div class="space-y-3">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Saved Templates</label>
<div class="relative">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
placeholder="Search templates"
class="w-full bg-muted/20 border border-border/60 pl-11 pr-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
/>
</div>
</div>
<div class="space-y-3">
<Show
when={!isLoading()}
fallback={
<div class="py-12 text-center text-muted-foreground text-sm font-medium">
Loading templates...
</div>
}
>
<For each={filteredTemplates()}>
{(template) => (
<div class="rounded-2xl border border-border/60 bg-muted/10 p-4 space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="font-bold text-foreground">{template.name}</h4>
<p class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1">
Updated {new Date(template.updatedAt).toLocaleDateString()}
</p>
<Show when={template.lastUpdatedBy}>
<div class="flex items-center gap-1.5 mt-1.5">
<User class="w-3 h-3 text-muted-foreground" />
<span class="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
Last updated by {template.lastUpdatedBy}
</span>
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => {
props.onApply(template.content);
props.onClose();
}}
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-xl text-xs font-bold hover:bg-primary hover:text-primary-foreground transition-all"
>
<CheckCircle2 class="w-4 h-4" />
Apply
</button>
<button
onClick={() => void updateTemplateFromCurrent(template)}
disabled={updatingTemplateId() === template.id}
class="flex items-center gap-2 px-4 py-2 bg-amber-50 text-amber-700 border border-amber-200 rounded-xl text-xs font-bold hover:bg-amber-100 transition-all disabled:opacity-50"
>
<PencilLine class="w-4 h-4" />
{updatingTemplateId() === template.id ? 'Updating...' : 'Update'}
</button>
<button
onClick={() => setExpandedHistoryId(expandedHistoryId() === template.id ? null : template.id)}
class="flex items-center gap-2 px-4 py-2 bg-muted/40 text-muted-foreground border border-border rounded-xl text-xs font-bold hover:bg-muted transition-all"
>
<History class="w-4 h-4" />
History
</button>
<button
onClick={() => void handleDelete(template.id)}
disabled={deletingTemplateId() === template.id}
class="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-xl transition-colors disabled:opacity-50"
aria-label={`Delete ${template.name}`}
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<pre class="whitespace-pre-wrap break-words text-sm text-foreground/85 bg-background/80 rounded-xl border border-border/40 p-3 font-sans">{template.content}</pre>
<Show when={expandedHistoryId() === template.id}>
<div class="space-y-3 border-t border-border/50 pt-4">
<div class="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
Edit History
</div>
<For each={[...(template.history || [])].slice().reverse()}>
{(entry) => (
<div class="rounded-xl border border-border/50 bg-background/70 overflow-hidden">
<div class="px-4 py-3 border-b border-border/40 flex items-center justify-between gap-4">
<div>
<div class="text-xs font-bold text-foreground">
{entry.action === 'created' ? 'Created' : 'Updated'}
</div>
<div class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mt-1">
{new Date(entry.timestamp).toLocaleString()}
</div>
</div>
<div class="flex items-center gap-1.5 text-[10px] uppercase tracking-tight font-bold text-muted-foreground">
<User class="w-3 h-3" />
{entry.updatedBy}
</div>
</div>
<div class="p-3 space-y-1 bg-background/80">
<For each={entry.diff}>
{(line) => (
<Show when={line.op !== 'equal'}>
<div class={`font-mono text-xs whitespace-pre-wrap break-words px-3 py-1.5 rounded-lg ${
line.op === 'add'
? 'bg-green-50 text-green-700'
: 'bg-red-50 text-red-700'
}`}>
<span class="mr-2 font-bold">{line.op === 'add' ? '+' : '-'}</span>
{line.text || ' '}
</div>
</Show>
)}
</For>
<Show when={!entry.diff.some(line => line.op !== 'equal')}>
<div class="text-xs text-muted-foreground italic">
No line changes captured.
</div>
</Show>
</div>
</div>
)}
</For>
<Show when={(template.history || []).length === 0}>
<div class="text-xs text-muted-foreground italic">
No history recorded yet.
</div>
</Show>
</div>
</Show>
</div>
)}
</For>
<Show when={filteredTemplates().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-2xl bg-background/50">
<p class="text-sm font-medium">No {TEMPLATE_LABELS[props.templateType].toLowerCase()} templates found.</p>
<p class="text-xs mt-1">Save the current text to start reusing it across scopes.</p>
</div>
</Show>
</Show>
</div>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default ScopeTextTemplateModal;
@@ -1,6 +1,6 @@
import { createSignal, onCleanup, Show } from 'solid-js';
import type { Component } from 'solid-js';
import { Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal } from 'lucide-solid';
import { Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal, FileSpreadsheet } from 'lucide-solid';
import { appStore } from '../../store/appStore';
interface BuilderHeaderProps {
@@ -8,6 +8,7 @@ interface BuilderHeaderProps {
onToggleSidebar: () => void;
onAddScope: () => void;
onShowNewEstimateModal: () => void;
onImportCsvQuantities: (e: Event) => void;
onImportEstimate: (e: Event) => void;
onShowExportTableModal: () => void;
onExportEstimate: () => void;
@@ -68,6 +69,10 @@ export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
<div class="absolute right-0 mt-2 w-56 bg-card border border-border rounded-2xl shadow-premium z-[60] overflow-hidden animate-in fade-in zoom-in-95 duration-150 origin-top-right">
<div class="p-2 space-y-1">
<p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 px-3 py-2">Data Exchange</p>
<label class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl cursor-pointer transition-colors w-full">
<FileSpreadsheet class="w-4 h-4 text-emerald-500" /> Import CSV Quantities
<input type="file" accept=".csv" class="hidden" onChange={(e) => { props.onImportCsvQuantities(e); setShowActions(false); }} />
</label>
<label class="flex items-center gap-3 px-3 py-2.5 text-xs font-bold text-foreground hover:bg-muted rounded-xl cursor-pointer transition-colors w-full">
<Upload class="w-4 h-4 text-blue-500" /> Import JSON
<input type="file" accept=".json" class="hidden" onChange={(e) => { props.onImportEstimate(e); setShowActions(false); }} />
+30
View File
@@ -77,6 +77,36 @@ export interface Template {
defaultSubScopeName?: string;
}
export type ScopeTextTemplateType = 'estimatorNotes' | 'bidDescription';
export type ScopeTextTemplateDiffOp = 'equal' | 'add' | 'remove';
export interface ScopeTextTemplateDiffLine {
op: ScopeTextTemplateDiffOp;
text: string;
}
export interface ScopeTextTemplateHistoryEntry {
id: string;
timestamp: string;
updatedBy: string;
action: 'created' | 'updated';
previousContent: string;
nextContent: string;
diff: ScopeTextTemplateDiffLine[];
}
export interface ScopeTextTemplate {
id: string;
name: string;
type: ScopeTextTemplateType;
content: string;
lastUpdatedBy?: string;
history?: ScopeTextTemplateHistoryEntry[];
createdAt: string;
updatedAt: string;
}
export interface StandardPrice {
id: string;
category: string;
+1
View File
@@ -5,6 +5,7 @@ export const pb = new PocketBase('https://pocketbase.ccllc.pro');
export const COLLECTIONS = {
TEMPLATES: 'EstiMaker_Templates',
SCOPE_TEXT_TEMPLATES: 'EstiMaker_ScopeTextTemplates',
ESTIMATES: 'EstiMaker_Estimates',
STANDARD_PRICES: 'EstiMaker_StandardPrices'
} as const;
+56
View File
@@ -0,0 +1,56 @@
import type { ScopeTextTemplateDiffLine } from '../types';
const buildLcsTable = (a: string[], b: string[]) => {
const table = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
if (a[i] === b[j]) {
table[i][j] = table[i + 1][j + 1] + 1;
} else {
table[i][j] = Math.max(table[i + 1][j], table[i][j + 1]);
}
}
}
return table;
};
export const createScopeTextDiff = (previousContent: string, nextContent: string): ScopeTextTemplateDiffLine[] => {
const previousLines = previousContent.split('\n');
const nextLines = nextContent.split('\n');
const table = buildLcsTable(previousLines, nextLines);
const diff: ScopeTextTemplateDiffLine[] = [];
let i = 0;
let j = 0;
while (i < previousLines.length && j < nextLines.length) {
if (previousLines[i] === nextLines[j]) {
diff.push({ op: 'equal', text: previousLines[i] });
i++;
j++;
continue;
}
if (table[i + 1][j] >= table[i][j + 1]) {
diff.push({ op: 'remove', text: previousLines[i] });
i++;
} else {
diff.push({ op: 'add', text: nextLines[j] });
j++;
}
}
while (i < previousLines.length) {
diff.push({ op: 'remove', text: previousLines[i] });
i++;
}
while (j < nextLines.length) {
diff.push({ op: 'add', text: nextLines[j] });
j++;
}
return diff;
};