Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc0a1ad15d | |||
| 72135a11d2 | |||
| c2f384a105 |
+4
-1
@@ -5,6 +5,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet" />
|
||||
<title>item-extractor-solid</title>
|
||||
</head>
|
||||
|
||||
@@ -13,4 +16,4 @@
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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';
|
||||
@@ -37,6 +38,8 @@ interface EstimateBuilderProps {
|
||||
}
|
||||
|
||||
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
type PrintMode = 'finalized' | 'manager';
|
||||
|
||||
// 1. Initialize State
|
||||
const { scopes, setScopes, subScopes, setSubScopes, estimateItems: items, setEstimateItems: setItems, clientInfo, jobInfo, generalEstimateNotes, setGeneralEstimateNotes, generalPreNotes, setGeneralPreNotes } = appStore;
|
||||
|
||||
@@ -50,7 +53,12 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
const [showHistoryModal, setShowHistoryModal] = createSignal(false);
|
||||
const [showNewEstimateModal, setShowNewEstimateModal] = createSignal(false);
|
||||
const [showExportTableModal, setShowExportTableModal] = createSignal(false);
|
||||
const [showCsvImportModal, setShowCsvImportModal] = createSignal(false);
|
||||
const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null);
|
||||
const [pendingCsvImportText, setPendingCsvImportText] = createSignal<string | null>(null);
|
||||
const [excludedScopeIds, setExcludedScopeIds] = createSignal<string[]>([]);
|
||||
const [excludedSubScopeIds, setExcludedSubScopeIds] = createSignal<string[]>([]);
|
||||
const [printMode, setPrintMode] = createSignal<PrintMode>('finalized');
|
||||
|
||||
// 2. Initialize custom hooks/primitives
|
||||
const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager();
|
||||
@@ -87,7 +95,19 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
const anyDescriptionIsLong = createMemo(() => items.some(item => (item.description || '').length > 120));
|
||||
|
||||
const finalizeBid = () => {
|
||||
window.print();
|
||||
runPrint('finalized');
|
||||
};
|
||||
|
||||
const exportManagerInfo = () => {
|
||||
runPrint('manager');
|
||||
};
|
||||
|
||||
const runPrint = (mode: PrintMode) => {
|
||||
setPrintMode(mode);
|
||||
window.requestAnimationFrame(() => {
|
||||
window.print();
|
||||
window.setTimeout(() => setPrintMode('finalized'), 0);
|
||||
});
|
||||
};
|
||||
|
||||
const addScope = () => {
|
||||
@@ -184,6 +204,151 @@ 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, options?: { excludedScopeIds?: string[]; excludedSubScopeIds?: 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 excludedScopeIdSet = new Set(options?.excludedScopeIds ?? []);
|
||||
const excludedSubScopeIdSet = new Set(options?.excludedSubScopeIds ?? []);
|
||||
|
||||
const nextItems = items.map(item => {
|
||||
const isExcludedByScope = item.scopeId ? excludedScopeIdSet.has(item.scopeId) : false;
|
||||
const isExcludedBySubScope = item.subScopeId ? excludedSubScopeIdSet.has(item.subScopeId) : false;
|
||||
if (isExcludedByScope || isExcludedBySubScope) return 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 excludedCount = excludedScopeIdSet.size + excludedSubScopeIdSet.size;
|
||||
const toastParts = [
|
||||
`Updated ${updatedExistingCount} item${updatedExistingCount === 1 ? '' : 's'}`,
|
||||
`added ${newUnassignedItems.length} unassigned`,
|
||||
excludedCount > 0 ? `excluded ${excludedCount} selection${excludedCount === 1 ? '' : 's'}` : null,
|
||||
ignoredCount > 0 ? `ignored ${ignoredCount} row${ignoredCount === 1 ? '' : 's'}` : null
|
||||
].filter(Boolean);
|
||||
setCopyToast(toastParts.join(' • '));
|
||||
window.setTimeout(() => setCopyToast(null), 3000);
|
||||
};
|
||||
|
||||
const toggleScopeExclusion = (scopeId: string) => {
|
||||
const isExcluded = excludedScopeIds().includes(scopeId);
|
||||
if (isExcluded) {
|
||||
setExcludedScopeIds(prev => prev.filter(id => id !== scopeId));
|
||||
return;
|
||||
}
|
||||
|
||||
setExcludedScopeIds(prev => [...prev, scopeId]);
|
||||
const subScopeIdsInScope = subScopes.filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
|
||||
if (subScopeIdsInScope.length > 0) {
|
||||
setExcludedSubScopeIds(prev => prev.filter(id => !subScopeIdsInScope.includes(id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSubScopeExclusion = (subScopeId: string) => {
|
||||
const isExcluded = excludedSubScopeIds().includes(subScopeId);
|
||||
if (isExcluded) {
|
||||
setExcludedSubScopeIds(prev => prev.filter(id => id !== subScopeId));
|
||||
return;
|
||||
}
|
||||
|
||||
setExcludedSubScopeIds(prev => [...prev, subScopeId]);
|
||||
};
|
||||
|
||||
const closeCsvImportModal = () => {
|
||||
setShowCsvImportModal(false);
|
||||
setPendingCsvImportText(null);
|
||||
setExcludedScopeIds([]);
|
||||
setExcludedSubScopeIds([]);
|
||||
};
|
||||
|
||||
const confirmCsvImport = () => {
|
||||
const csvText = pendingCsvImportText();
|
||||
if (!csvText) return;
|
||||
|
||||
mergeCsvQuantities(csvText, {
|
||||
excludedScopeIds: excludedScopeIds(),
|
||||
excludedSubScopeIds: excludedSubScopeIds()
|
||||
});
|
||||
closeCsvImportModal();
|
||||
};
|
||||
|
||||
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') {
|
||||
setPendingCsvImportText(text);
|
||||
setExcludedScopeIds([]);
|
||||
setExcludedSubScopeIds([]);
|
||||
setShowCsvImportModal(true);
|
||||
}
|
||||
};
|
||||
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
|
||||
@@ -197,6 +362,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
generalPreNotes={generalPreNotes()}
|
||||
formatNumber={formatNumber}
|
||||
hideColumns={anyDescriptionIsLong()}
|
||||
variant={printMode()}
|
||||
/>
|
||||
|
||||
<div class="no-print flex flex-col min-h-dvh">
|
||||
@@ -205,6 +371,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
|
||||
onAddScope={addScope}
|
||||
onShowNewEstimateModal={() => setShowNewEstimateModal(true)}
|
||||
onImportCsvQuantities={handleImportCsvQuantities}
|
||||
onImportEstimate={importEstimate}
|
||||
onShowExportTableModal={() => setShowExportTableModal(true)}
|
||||
onExportEstimate={exportEstimate}
|
||||
@@ -314,7 +481,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
|
||||
<ValidationStatusBar validationStats={validationStats()} />
|
||||
|
||||
<ExecutiveSummary totals={grandTotals()} onFinalizeBid={finalizeBid} />
|
||||
<ExecutiveSummary totals={grandTotals()} onFinalizeBid={finalizeBid} onExportManagerInfo={exportManagerInfo} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -376,6 +543,102 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
|
||||
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
|
||||
<HistoryModal show={showHistoryModal()} onClose={() => setShowHistoryModal(false)} onCheckout={checkoutVersion} />
|
||||
<Show when={showCsvImportModal()}>
|
||||
<Portal>
|
||||
<div class="fixed inset-0 z-[100] 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-2xl rounded-3xl shadow-premium border border-border animate-in zoom-in-95 duration-200 overflow-hidden">
|
||||
<div class="p-6 border-b border-border/50 flex items-center justify-between bg-muted/20">
|
||||
<div>
|
||||
<h3 class="text-xl font-black">Import CSV Quantities</h3>
|
||||
<p class="text-[10px] text-muted-foreground font-black uppercase tracking-widest mt-1">Choose scopes or sub-scopes to leave unchanged</p>
|
||||
</div>
|
||||
<button onClick={closeCsvImportModal} 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-5 max-h-[70vh] overflow-y-auto">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Matching items in excluded sections will keep their current quantities. Unassigned items are always eligible for updates.
|
||||
</p>
|
||||
|
||||
<Show
|
||||
when={scopes.length > 0}
|
||||
fallback={
|
||||
<div class="rounded-2xl border border-border bg-muted/20 px-4 py-5 text-sm text-muted-foreground">
|
||||
No scopes exist yet. Import will only update unassigned items and add unmatched rows to the unassigned list.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<For each={scopes}>
|
||||
{(scope) => {
|
||||
const scopeSubScopes = () => subScopes.filter(ss => ss.scopeId === scope.id);
|
||||
const isScopeExcluded = () => excludedScopeIds().includes(scope.id);
|
||||
|
||||
return (
|
||||
<div class="rounded-2xl border border-border bg-background overflow-hidden">
|
||||
<label class="flex items-start gap-3 px-4 py-4 border-b border-border/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isScopeExcluded()}
|
||||
onChange={() => toggleScopeExclusion(scope.id)}
|
||||
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-foreground">{scope.name}</div>
|
||||
<div class="text-xs text-muted-foreground">Exclude this entire scope from quantity replacement.</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Show when={scopeSubScopes().length > 0}>
|
||||
<div class={`px-4 py-3 space-y-2 ${isScopeExcluded() ? 'opacity-50' : ''}`}>
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Sub-scopes</div>
|
||||
<For each={scopeSubScopes()}>
|
||||
{(subScope) => (
|
||||
<label class="flex items-start gap-3 rounded-xl px-3 py-2 hover:bg-muted/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={excludedSubScopeIds().includes(subScope.id)}
|
||||
disabled={isScopeExcluded()}
|
||||
onChange={() => toggleSubScopeExclusion(subScope.id)}
|
||||
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-foreground">{subScope.name}</div>
|
||||
<div class="text-xs text-muted-foreground">Exclude only this sub-scope.</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-muted/20 border-t border-border/50 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={closeCsvImportModal}
|
||||
class="px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-background transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmCsvImport}
|
||||
class="px-5 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-premium hover:shadow-premium-hover transition-all"
|
||||
>
|
||||
Import Quantities
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
|
||||
<NewEstimateModal
|
||||
show={showNewEstimateModal()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,9 +28,13 @@ interface PrintEstimateProps {
|
||||
generalEstimateNotes: string;
|
||||
formatNumber: (num: number) => string;
|
||||
hideColumns?: boolean;
|
||||
variant?: 'finalized' | 'manager';
|
||||
}
|
||||
|
||||
const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
const variant = () => props.variant ?? 'finalized';
|
||||
const isManagerVariant = () => variant() === 'manager';
|
||||
|
||||
const getScopeTotal = (scopeId: string) => {
|
||||
let scopeSubtotal = 0;
|
||||
let scopeMarkup = 0;
|
||||
@@ -75,6 +79,28 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
return scopeSubtotal + scopeMarkup + scopeContingency + mgmtFee + deliveryFee + modifiersTotal;
|
||||
};
|
||||
|
||||
const getScopeItems = (scopeId: string) => {
|
||||
const scopeLevelItems = props.items.filter((item: EstimateItem) => item.scopeId === scopeId && !item.subScopeId);
|
||||
const scopeSubScopes = props.subScopes
|
||||
.filter((subScope) => subScope.scopeId === scopeId)
|
||||
.map((subScope) => ({
|
||||
subScope,
|
||||
items: props.items.filter((item: EstimateItem) => item.subScopeId === subScope.id)
|
||||
}))
|
||||
.filter((entry) => entry.items.length > 0);
|
||||
|
||||
return {
|
||||
scopeLevelItems,
|
||||
scopeSubScopes
|
||||
};
|
||||
};
|
||||
|
||||
const getItemSubtotal = (item: EstimateItem) => item.quantity * item.unitPrice;
|
||||
const getManagerScopeVisibleTotal = (scopeId: string) =>
|
||||
props.items
|
||||
.filter((item: EstimateItem) => item.scopeId === scopeId)
|
||||
.reduce((sum, item) => sum + getItemSubtotal(item), 0);
|
||||
|
||||
return (
|
||||
<div class="print-only p-6 bg-white font-sans max-w-[8.5in] mx-auto text-sm leading-normal text-foreground">
|
||||
{/* Company Header */}
|
||||
@@ -88,7 +114,9 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<h2 class="text-2xl font-black text-foreground uppercase tracking-tighter leading-none">Estimate</h2>
|
||||
<h2 class="text-2xl font-black text-foreground uppercase tracking-tighter leading-none">
|
||||
{isManagerVariant() ? 'Manager Info' : 'Estimate'}
|
||||
</h2>
|
||||
<p class="text-[10px] text-muted-foreground italic uppercase tracking-widest font-bold mb-1">*** NOT A BILL ***</p>
|
||||
<p class="text-foreground font-bold text-xs">Date: {new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
@@ -110,6 +138,10 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
<div>
|
||||
<h3 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border pb-1 mb-2">Project Details</h3>
|
||||
<div class="pl-1 space-y-1">
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Job No.</span>
|
||||
<span class="font-bold text-foreground">{props.jobInfo.number || '---'}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Project</span>
|
||||
<span class="font-bold text-foreground">{props.jobInfo.name || '---'}</span>
|
||||
@@ -129,7 +161,7 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
</div>
|
||||
|
||||
{/* General Pre-Notes */}
|
||||
<Show when={props.generalPreNotes}>
|
||||
<Show when={props.generalPreNotes && !isManagerVariant()}>
|
||||
<div class="mb-6 p-4 bg-muted/30 rounded-xl border border-border/40 break-inside-avoid shadow-sm outline outline-1 outline-border/20">
|
||||
<div class="text-[11px] text-foreground/80 leading-relaxed font-medium italic">
|
||||
{props.generalPreNotes}
|
||||
@@ -138,47 +170,154 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
{/* Scope Details Table */}
|
||||
<div class="mb-8 border-t-2 border-foreground">
|
||||
<div class="grid grid-cols-12 gap-2 py-2 bg-muted/40 px-3 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/60">
|
||||
<div class="col-span-2">Scope</div>
|
||||
<div class="col-span-8 pl-2 border-l border-border/60">Description</div>
|
||||
<div class="col-span-2 text-right">Subtotal</div>
|
||||
</div>
|
||||
<div class="divide-y divide-border/20">
|
||||
<For each={props.scopes}>
|
||||
{(scope) => (
|
||||
<div class="grid grid-cols-12 gap-2 items-start py-4 px-3 hover:bg-muted/10 transition-colors break-inside-avoid">
|
||||
<div class="col-span-2">
|
||||
<h4 class="text-xs font-black text-foreground uppercase leading-snug">{scope.name}</h4>
|
||||
</div>
|
||||
<div class="col-span-8 pl-2 border-l border-dashed border-border/60 text-foreground/80 text-xs leading-relaxed">
|
||||
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70">
|
||||
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
|
||||
{(line) => {
|
||||
let processed = line;
|
||||
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
||||
if (processed.trim().startsWith('- ')) {
|
||||
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
|
||||
}
|
||||
// Handle empty lines more gracefully in compact mode
|
||||
if (!processed.trim()) return <div class="h-1"></div>;
|
||||
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
|
||||
}}
|
||||
</For>
|
||||
<Show
|
||||
when={isManagerVariant()}
|
||||
fallback={
|
||||
<div class="mb-8 border-t-2 border-foreground">
|
||||
<div class="grid grid-cols-12 gap-2 py-2 bg-muted/40 px-3 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/60">
|
||||
<div class="col-span-2">Scope</div>
|
||||
<div class="col-span-8 pl-2 border-l border-border/60">Description</div>
|
||||
<div class="col-span-2 text-right">Subtotal</div>
|
||||
</div>
|
||||
<div class="divide-y divide-border/20">
|
||||
<For each={props.scopes}>
|
||||
{(scope) => (
|
||||
<div class="grid grid-cols-12 gap-2 items-start py-4 px-3 hover:bg-muted/10 transition-colors break-inside-avoid">
|
||||
<div class="col-span-2">
|
||||
<h4 class="text-xs font-black text-foreground uppercase leading-snug">{scope.name}</h4>
|
||||
</div>
|
||||
<div class="col-span-8 pl-2 border-l border-dashed border-border/60 text-foreground/80 text-xs leading-relaxed">
|
||||
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70">
|
||||
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
|
||||
{(line) => {
|
||||
let processed = line;
|
||||
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
||||
if (processed.trim().startsWith('- ')) {
|
||||
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
|
||||
}
|
||||
if (!processed.trim()) return <div class="h-1"></div>;
|
||||
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 text-right font-black text-foreground text-sm">
|
||||
${props.formatNumber(getScopeTotal(scope.id))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 text-right font-black text-foreground text-sm">
|
||||
${props.formatNumber(getScopeTotal(scope.id))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mb-8 space-y-6">
|
||||
<For each={props.scopes}>
|
||||
{(scope) => {
|
||||
const scopeItems = () => getScopeItems(scope.id);
|
||||
|
||||
return (
|
||||
<section class={`border border-border/60 rounded-2xl overflow-hidden ${isManagerVariant() ? '' : 'break-inside-avoid'}`}>
|
||||
<div class="px-4 py-3 bg-muted/30 border-b border-border/60 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-black text-foreground uppercase">{scope.name}</h4>
|
||||
<p class="text-[10px] text-muted-foreground font-bold uppercase tracking-widest mt-1">Scope Total</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-black text-foreground">${props.formatNumber(getManagerScopeVisibleTotal(scope.id))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-border/50 p-3 bg-white">
|
||||
<div class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">Scope Description</div>
|
||||
<div class="prose prose-sm max-w-none prose-strong:text-foreground prose-em:text-foreground/70 text-xs text-foreground/90">
|
||||
<For each={(scope.bidDescription || 'See detailed specifications.').split('\n')}>
|
||||
{(line) => {
|
||||
let processed = line;
|
||||
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
||||
if (processed.trim().startsWith('- ')) {
|
||||
return <li class="ml-3 list-disc pl-1 text-foreground/90" innerHTML={processed.substring(2)} />;
|
||||
}
|
||||
if (!processed.trim()) return <div class="h-1"></div>;
|
||||
return <p class="mb-0.5 last:mb-0 text-foreground/90" innerHTML={processed} />;
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/50 p-3 bg-white">
|
||||
<div class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">Estimator Notes</div>
|
||||
<div class="text-xs text-foreground/90 whitespace-pre-wrap leading-relaxed">
|
||||
{scope.estimatorNotes?.trim() || 'No estimator notes provided.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-border/50 rounded-xl overflow-hidden">
|
||||
<div class="grid grid-cols-12 gap-3 px-3 py-2 bg-muted/20 text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border/50">
|
||||
<div class="col-span-2">Section</div>
|
||||
<div class="col-span-6">Line Item</div>
|
||||
<div class="col-span-1 text-right">Qty</div>
|
||||
<div class="col-span-1 text-right">Unit</div>
|
||||
<div class="col-span-2 text-right">Subtotal</div>
|
||||
</div>
|
||||
|
||||
<Show when={scopeItems().scopeLevelItems.length > 0}>
|
||||
<div class="border-b border-border/40">
|
||||
<div class="px-3 py-2 bg-background text-[10px] font-black uppercase tracking-widest text-muted-foreground">Scope Level</div>
|
||||
<For each={scopeItems().scopeLevelItems}>
|
||||
{(item) => (
|
||||
<div class="grid grid-cols-12 gap-3 px-3 py-2 text-xs border-t border-border/20 items-start">
|
||||
<div class="col-span-2 font-bold text-foreground">Scope Level</div>
|
||||
<div class="col-span-6 text-foreground/90">{item.description}</div>
|
||||
<div class="col-span-1 text-right text-foreground">{item.quantity}</div>
|
||||
<div class="col-span-1 text-right text-foreground">${props.formatNumber(item.unitPrice)}</div>
|
||||
<div class="col-span-2 text-right font-bold text-foreground">${props.formatNumber(getItemSubtotal(item))}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={scopeItems().scopeSubScopes}>
|
||||
{(entry) => (
|
||||
<div class="border-b last:border-b-0 border-border/40">
|
||||
<div class="px-3 py-2 bg-background text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||
{entry.subScope.name}
|
||||
</div>
|
||||
<For each={entry.items}>
|
||||
{(item) => (
|
||||
<div class="grid grid-cols-12 gap-3 px-3 py-2 text-xs border-t border-border/20 items-start">
|
||||
<div class="col-span-2 font-bold text-foreground">{entry.subScope.name}</div>
|
||||
<div class="col-span-6 text-foreground/90">{item.description}</div>
|
||||
<div class="col-span-1 text-right text-foreground">{item.quantity}</div>
|
||||
<div class="col-span-1 text-right text-foreground">${props.formatNumber(item.unitPrice)}</div>
|
||||
<div class="col-span-2 text-right font-bold text-foreground">${props.formatNumber(getItemSubtotal(item))}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={scopeItems().scopeLevelItems.length === 0 && scopeItems().scopeSubScopes.length === 0}>
|
||||
<div class="px-3 py-4 text-xs text-muted-foreground italic">No line items assigned to this scope.</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* General Estimate Notes */}
|
||||
<Show when={props.generalEstimateNotes}>
|
||||
<Show when={props.generalEstimateNotes && !isManagerVariant()}>
|
||||
<div class="mb-8">
|
||||
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1 border-b border-border/40 pb-1">General Estimate Notes</h5>
|
||||
<div class="text-[11px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
|
||||
@@ -188,51 +327,53 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
{/* Final Section: Totals & Signature aligned */}
|
||||
<div class="grid grid-cols-12 gap-8 items-end mb-8 break-inside-avoid">
|
||||
<div class="col-span-7">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-full flex items-end gap-2 pt-2">
|
||||
<span class="text-lg font-serif text-muted-foreground/40 translate-y-1">X</span>
|
||||
<div class="flex-1 border-b border-foreground h-6"></div>
|
||||
<Show when={!isManagerVariant()}>
|
||||
<div class="grid grid-cols-12 gap-8 items-end mb-8 break-inside-avoid">
|
||||
<div class="col-span-7">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-full flex items-end gap-2 pt-2">
|
||||
<span class="text-lg font-serif text-muted-foreground/40 translate-y-1">X</span>
|
||||
<div class="flex-1 border-b border-foreground h-6"></div>
|
||||
</div>
|
||||
<div class="flex justify-between items-start px-0.5">
|
||||
<p class="text-[9px] text-muted-foreground/60 italic max-w-[280px] leading-tight">
|
||||
A Work Order is then established and Cardoza Construction, LLC will proceed with work.
|
||||
Valid for 30 days from original date.
|
||||
</p>
|
||||
<span class="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">Authorized Signature</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-start px-0.5">
|
||||
<p class="text-[9px] text-muted-foreground/60 italic max-w-[280px] leading-tight">
|
||||
A Work Order is then established and Cardoza Construction, LLC will proceed with work.
|
||||
Valid for 30 days from original date.
|
||||
</p>
|
||||
<span class="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">Authorized Signature</span>
|
||||
</div>
|
||||
|
||||
<div class="col-span-5 text-right space-y-1">
|
||||
<div class="flex justify-end items-center gap-6 p-4 bg-white text-foreground rounded-2xl mt-3 border-2 border-foreground shadow-sm">
|
||||
<div class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60">Grand Total</div>
|
||||
<div class="text-3xl font-black tracking-tighter text-foreground">${props.formatNumber(props.grandTotals().grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-5 text-right space-y-1">
|
||||
<div class="flex justify-end items-center gap-6 p-4 bg-white text-foreground rounded-2xl mt-3 border-2 border-foreground shadow-sm">
|
||||
<div class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60">Grand Total</div>
|
||||
<div class="text-3xl font-black tracking-tighter text-foreground">${props.formatNumber(props.grandTotals().grandTotal)}</div>
|
||||
{/* Terms & Legal Notices */}
|
||||
<div class="mt-8 pt-6 border-t-2 border-foreground space-y-4">
|
||||
<div class="break-inside-avoid">
|
||||
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Terms & Conditions</h5>
|
||||
<p class="text-[10px] font-bold text-foreground/70 leading-relaxed">
|
||||
Unless otherwise agreed, credit approval and 50% down is required prior to scheduling/starting work.
|
||||
(Credit card authorization may be substituted for jobs under $2,000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="break-inside-avoid">
|
||||
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Notice to Owner</h5>
|
||||
<div class="text-[10px] font-bold text-foreground/60 leading-relaxed whitespace-pre-wrap">
|
||||
{NOTICE_TO_OWNERS.replace('NOTICE TO OWNER', '').trim()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms & Legal Notices */}
|
||||
<div class="mt-8 pt-6 border-t-2 border-foreground space-y-4">
|
||||
<div class="break-inside-avoid">
|
||||
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Terms & Conditions</h5>
|
||||
<p class="text-[10px] font-bold text-foreground/70 leading-relaxed">
|
||||
Unless otherwise agreed, credit approval and 50% down is required prior to scheduling/starting work.
|
||||
(Credit card authorization may be substituted for jobs under $2,000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="break-inside-avoid">
|
||||
<h5 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1">Notice to Owner</h5>
|
||||
<div class="text-[10px] font-bold text-foreground/60 leading-relaxed whitespace-pre-wrap">
|
||||
{NOTICE_TO_OWNERS.replace('NOTICE TO OWNER', '').trim()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print Page Counter */}
|
||||
<div class="print-page-number"></div>
|
||||
{/* Print Page Counter */}
|
||||
<div class="print-page-number"></div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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); }} />
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ExecutiveSummaryProps {
|
||||
grandTotal: number;
|
||||
};
|
||||
onFinalizeBid: () => void;
|
||||
onExportManagerInfo: () => void;
|
||||
}
|
||||
|
||||
export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
|
||||
@@ -68,12 +69,20 @@ export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
|
||||
{formatNumber(props.totals.grandTotal)}
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
onClick={props.onFinalizeBid}
|
||||
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-95 flex items-center justify-center gap-3"
|
||||
>
|
||||
<Plus class="w-5 h-5" /> Finalize Bid
|
||||
</button>
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
onClick={props.onFinalizeBid}
|
||||
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-95 flex items-center justify-center gap-3"
|
||||
>
|
||||
<Plus class="w-5 h-5" /> Finalize Bid
|
||||
</button>
|
||||
<button
|
||||
onClick={props.onExportManagerInfo}
|
||||
class="w-full py-4 bg-muted hover:bg-muted/80 text-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all border border-border active:scale-95 flex items-center justify-center gap-3"
|
||||
>
|
||||
<Info class="w-5 h-5" /> Manager Info
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-3
@@ -2,8 +2,6 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* Import Standard Shadcn Font (Inter) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
||||
|
||||
@theme {
|
||||
/* Clean, single-font family for a modern SaaS look */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
@@ -130,4 +128,4 @@
|
||||
|
||||
.shadow-premium-hover:hover {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.07), 0 8px 10px -6px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user