Estimate History management added

This commit is contained in:
2026-03-13 14:41:46 -05:00
parent c090c18734
commit e637e0903d
6 changed files with 256 additions and 13 deletions
+3
View File
@@ -49,6 +49,9 @@ const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
appStore.setEstimateName(record.name); appStore.setEstimateName(record.name);
appStore.setEstimateId(record.id); appStore.setEstimateId(record.id);
appStore.setHistory(record.history || []);
appStore.setLatestSnapshot(record.items);
appStore.setIsLatestVersion(true);
// Also clear out the extracted CSV items so we don't accidentally switch back to the upload screen or cause conflicts // Also clear out the extracted CSV items so we don't accidentally switch back to the upload screen or cause conflicts
appStore.setItems([]); appStore.setItems([]);
+6 -1
View File
@@ -14,6 +14,7 @@ import CalculatorPopover from './CalculatorPopover';
import CloudLoadModal from './CloudLoadModal'; import CloudLoadModal from './CloudLoadModal';
import ExportTableModal from './ExportTableModal'; import ExportTableModal from './ExportTableModal';
import ApplyTemplateModal from './ApplyTemplateModal'; import ApplyTemplateModal from './ApplyTemplateModal';
import HistoryModal from './estimate-builder/HistoryModal';
// Import extracted components & hooks // Import extracted components & hooks
import { useEstimateCalculations } from './estimate-builder/useEstimateCalculations'; import { useEstimateCalculations } from './estimate-builder/useEstimateCalculations';
@@ -46,6 +47,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
// Cloud Modals // Cloud Modals
const [showSaveModal, setShowSaveModal] = createSignal(false); const [showSaveModal, setShowSaveModal] = createSignal(false);
const [showLoadModal, setShowLoadModal] = createSignal(false); const [showLoadModal, setShowLoadModal] = createSignal(false);
const [showHistoryModal, setShowHistoryModal] = createSignal(false);
const [showExportTableModal, setShowExportTableModal] = createSignal(false); const [showExportTableModal, setShowExportTableModal] = createSignal(false);
const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null); const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null);
@@ -58,7 +60,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
}; };
const { hoveredScopeId, setHoveredScopeId, handleDragStart, handleDrop } = useDragAndDrop(selectedIds, setSelectedIds, updateItem); const { hoveredScopeId, setHoveredScopeId, handleDragStart, handleDrop } = useDragAndDrop(selectedIds, setSelectedIds, updateItem);
const { exportEstimate, importEstimate, saveToCloud, isCloudLoading } = useEstimateSync(setShowSaveModal); const { exportEstimate, importEstimate, saveToCloud, saveChangesToCloud, saveAsNewestToCloud, checkoutVersion, isCloudLoading } = useEstimateSync(setShowSaveModal);
onMount(() => { onMount(() => {
if (!generalEstimateNotes()) setGeneralEstimateNotes(GENERAL_NOTES_DEFAULT); if (!generalEstimateNotes()) setGeneralEstimateNotes(GENERAL_NOTES_DEFAULT);
@@ -199,6 +201,8 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
onExportEstimate={exportEstimate} onExportEstimate={exportEstimate}
onShowLoadModal={() => setShowLoadModal(true)} onShowLoadModal={() => setShowLoadModal(true)}
onShowSaveModal={() => setShowSaveModal(true)} onShowSaveModal={() => setShowSaveModal(true)}
onShowHistoryModal={() => setShowHistoryModal(true)}
onSaveChanges={saveChangesToCloud}
/> />
<div class="flex flex-1 relative items-start"> <div class="flex flex-1 relative items-start">
@@ -353,6 +357,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
<CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} /> <CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} />
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} /> <ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} /> <ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
<HistoryModal show={showHistoryModal()} onClose={() => setShowHistoryModal(false)} onCheckout={checkoutVersion} />
</div> </div>
); );
}; };
@@ -1,5 +1,6 @@
import { Show } from 'solid-js';
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { PanelLeftOpen, PanelLeftClose, Calculator, Plus, FileUp, Upload, Table, Download } from 'lucide-solid'; import { PanelLeftOpen, PanelLeftClose, Calculator, Plus, FileUp, Upload, Table, Download, History, Save } from 'lucide-solid';
import { appStore } from '../../store/appStore'; import { appStore } from '../../store/appStore';
interface BuilderHeaderProps { interface BuilderHeaderProps {
@@ -12,6 +13,8 @@ interface BuilderHeaderProps {
onExportEstimate: () => void; onExportEstimate: () => void;
onShowLoadModal: () => void; onShowLoadModal: () => void;
onShowSaveModal: () => void; onShowSaveModal: () => void;
onShowHistoryModal: () => void;
onSaveChanges: () => void;
} }
export const BuilderHeader: Component<BuilderHeaderProps> = (props) => { export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
@@ -78,11 +81,29 @@ export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
> >
<Download class="w-4 h-4" /> Load <Download class="w-4 h-4" /> Load
</button> </button>
<Show when={appStore.estimateId()}>
<button
onClick={props.onShowHistoryModal}
class="flex items-center gap-2 px-3 py-2 bg-purple-50 text-purple-600 rounded-xl text-xs font-bold hover:bg-purple-100 transition-all border border-purple-200"
>
<History class="w-4 h-4" /> History
</button>
<button
onClick={props.onSaveChanges}
class="flex items-center gap-2 px-3 py-2 bg-green-50 text-green-600 rounded-xl text-xs font-bold hover:bg-green-100 transition-all border border-green-200"
>
<Save class="w-4 h-4" />
{appStore.isLatestVersion() ? 'Save Changes' : 'Save as Newest'}
</button>
</Show>
<button <button
onClick={props.onShowSaveModal} onClick={props.onShowSaveModal}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-xl text-sm font-bold hover:bg-primary/90 transition-all shadow-lg shadow-primary/10" class="flex items-center gap-4 py-2 px-4 bg-primary text-primary-foreground rounded-xl text-sm font-bold hover:bg-primary/90 transition-all shadow-lg shadow-primary/10"
> >
<Upload class="w-4 h-4" /> Save <Upload class="w-4 h-4" /> Save New
</button> </button>
</div> </div>
</div> </div>
@@ -0,0 +1,124 @@
import { Show, For } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, History, CheckCircle2, Clock } from 'lucide-solid';
import { appStore } from '../../store/appStore';
interface HistoryModalProps {
show: boolean;
onClose: () => void;
onCheckout: (record: any) => void;
}
const HistoryModal = (props: HistoryModalProps) => {
// History is an array of snapshots from the 'history' field of the estimate record
const historyEntries = () => [...appStore.history()].reverse();
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-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-purple-100 text-purple-600 rounded-xl">
<History class="w-5 h-5" />
</div>
<div>
<h3 class="text-xl font-black text-foreground">Estimate History</h3>
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-0.5">Version Control & Timeline</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-4 overflow-hidden">
<div class="space-y-3 overflow-y-auto pr-2">
{/* Current Live Version */}
<div
class={`group flex items-center justify-between p-4 rounded-2xl border transition-all ${
appStore.isLatestVersion()
? 'border-primary bg-primary/5 shadow-premium'
: 'border-border/60 bg-muted/10 hover:border-primary/30'
}`}
>
<div class="flex items-center gap-4">
<div class={`p-2 rounded-lg ${appStore.isLatestVersion() ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
<CheckCircle2 class="w-4 h-4" />
</div>
<div>
<div class="flex items-center gap-2">
<h4 class="font-bold text-foreground">Latest Version</h4>
<span class="px-1.5 py-0.5 bg-green-100 text-green-700 text-[8px] font-black uppercase rounded tracking-widest">Live</span>
<Show when={appStore.isLatestVersion()}>
<span class="px-1.5 py-0.5 bg-primary/20 text-primary text-[8px] font-black uppercase rounded tracking-widest border border-primary/20">Active</span>
</Show>
</div>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
The current tip of the project
</p>
</div>
</div>
<Show when={!appStore.isLatestVersion()}>
<button
onClick={() => {
props.onCheckout(appStore.latestSnapshot());
props.onClose();
}}
class="flex items-center gap-2 px-4 py-2 bg-background border border-border rounded-xl text-xs font-bold shadow-sm hover:text-primary hover:border-primary/50 transition-all opacity-0 group-hover:opacity-100"
>
Return to Latest
</button>
</Show>
</div>
{/* Historical Snapshots */}
<For each={historyEntries()}>
{(snapshot) => (
<div class="group flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-muted/5 hover:border-primary/30 transition-all">
<div class="flex items-center gap-4">
<div class="p-2 bg-muted text-muted-foreground rounded-lg">
<Clock class="w-4 h-4" />
</div>
<div>
<div class="flex items-center gap-2">
<h4 class="font-bold text-foreground">
{snapshot.jobInfo?.name || snapshot.name || 'Version'}
</h4>
</div>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
{new Date(snapshot.timestamp || Date.now()).toLocaleString()}
</p>
</div>
</div>
<button
onClick={() => {
props.onCheckout(snapshot);
props.onClose();
}}
class="flex items-center gap-2 px-4 py-2 bg-background border border-border rounded-xl text-xs font-bold shadow-sm hover:text-primary hover:border-primary/50 transition-all opacity-0 group-hover:opacity-100"
>
Checkout
</button>
</div>
)}
</For>
<Show when={historyEntries().length === 0}>
<div class="py-12 text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-muted/5 font-medium text-sm">
No previous versions found.
</div>
</Show>
</div>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default HistoryModal;
@@ -7,7 +7,19 @@ export function useEstimateSync(
) { ) {
const [isCloudLoading, setIsCloudLoading] = createSignal(false); const [isCloudLoading, setIsCloudLoading] = createSignal(false);
const { scopes, setScopes, subScopes, setSubScopes, estimateItems: items, setEstimateItems: setItems, clientInfo, setClientInfo, jobInfo, setJobInfo, generalEstimateNotes, setGeneralEstimateNotes, generalPreNotes, setGeneralPreNotes } = appStore; const {
scopes, setScopes,
subScopes, setSubScopes,
estimateItems: items, setEstimateItems: setItems,
clientInfo, setClientInfo,
jobInfo, setJobInfo,
generalEstimateNotes, setGeneralEstimateNotes,
generalPreNotes, setGeneralPreNotes,
setHistory,
setIsLatestVersion,
setLatestSnapshot,
setEstimateId, setEstimateName
} = appStore;
const exportEstimate = () => { const exportEstimate = () => {
const data = { const data = {
@@ -18,7 +30,7 @@ export function useEstimateSync(
jobInfo: { ...jobInfo }, jobInfo: { ...jobInfo },
generalEstimateNotes: generalEstimateNotes(), generalEstimateNotes: generalEstimateNotes(),
generalPreNotes: generalPreNotes(), generalPreNotes: generalPreNotes(),
version: '1.0' version: '1.2'
}; };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -60,7 +72,8 @@ export function useEstimateSync(
clientInfo: { ...clientInfo }, clientInfo: { ...clientInfo },
jobInfo: { ...jobInfo }, jobInfo: { ...jobInfo },
generalEstimateNotes: generalEstimateNotes(), generalEstimateNotes: generalEstimateNotes(),
generalPreNotes: generalPreNotes() generalPreNotes: generalPreNotes(),
timestamp: new Date().toISOString()
}; };
}; };
@@ -68,20 +81,88 @@ export function useEstimateSync(
setIsCloudLoading(true); setIsCloudLoading(true);
try { try {
const data = getFullEstimateData(); const data = getFullEstimateData();
await pb.collection(COLLECTIONS.ESTIMATES).create({ const record = await pb.collection(COLLECTIONS.ESTIMATES).create({
name, name,
items: data items: data,
history: [] // New estimate start with empty history
}); });
setShowSaveModal(false); setShowSaveModal(false);
appStore.setEstimateName(name); setEstimateName(name);
alert('Estimate saved to PocketBase successfully!'); setEstimateId(record.id);
setHistory([]);
setLatestSnapshot(data);
setIsLatestVersion(true);
alert('New estimate saved successfully!');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Failed to save. Ensure EstiMaker_Estimates collection permissions are public.'); alert('Failed to save. Ensure collection permissions are public.');
} finally { } finally {
setIsCloudLoading(false); setIsCloudLoading(false);
} }
}; };
return { exportEstimate, importEstimate, saveToCloud, isCloudLoading }; const saveChangesToCloud = async () => {
const id = appStore.estimateId();
if (!id) return;
setIsCloudLoading(true);
try {
const currentData = getFullEstimateData();
// 1. Fetch current record to get latest history (to be safe against concurrency)
const record = await pb.collection(COLLECTIONS.ESTIMATES).getOne(id);
const updatedHistory = [...(record.history || [])];
// 2. Push current state (before this save) to history if it's new
// Actually, the user wants to save "as diffs" or "history".
// In a single-record approach, 'items' is the LATEST state.
// When we "Save Changes", we move the PREVIOUS 'items' to history.
updatedHistory.push(record.items);
// 3. Update record with new current items and expanded history
await pb.collection(COLLECTIONS.ESTIMATES).update(id, {
items: currentData,
history: updatedHistory
});
setHistory(updatedHistory);
setLatestSnapshot(currentData);
setIsLatestVersion(true);
alert('Changes saved successfully!');
} catch (err) {
console.error(err);
alert('Failed to save changes.');
} finally {
setIsCloudLoading(false);
}
};
const saveAsNewestToCloud = async () => {
// "Save as Newest" is exactly same as saveChanges in this model
// It pushes the current editor state as the new 'items' and moves whatever was in 'items' to history.
await saveChangesToCloud();
};
const checkoutVersion = async (snapshot: any) => {
// snapshot is an entry from the history array
if (!snapshot) return;
if (snapshot.scopes) setScopes(snapshot.scopes);
if (snapshot.subScopes) setSubScopes(snapshot.subScopes);
if (snapshot.items) setItems(snapshot.items);
if (snapshot.clientInfo) setClientInfo(snapshot.clientInfo);
if (snapshot.jobInfo) setJobInfo(snapshot.jobInfo);
if (snapshot.generalEstimateNotes !== undefined) setGeneralEstimateNotes(snapshot.generalEstimateNotes);
if (snapshot.generalPreNotes !== undefined) setGeneralPreNotes(snapshot.generalPreNotes);
// This checkout handles both historical items and the 'latestSnapshot'
// If we are checking out the latest snapshot, then isLatestVersion is true
const isTip = JSON.stringify(snapshot) === JSON.stringify(appStore.latestSnapshot());
setIsLatestVersion(isTip);
// Clear CSV items to avoid confusion
appStore.setItems([]);
};
return { exportEstimate, importEstimate, saveToCloud, saveChangesToCloud, saveAsNewestToCloud, checkoutVersion, isCloudLoading };
} }
+9
View File
@@ -10,6 +10,9 @@ const [activeIgnoredLines, setActiveIgnoredLines] = createSignal<string[][]>([])
// Estimate Metadata // Estimate Metadata
const [activeEstimateId, setActiveEstimateId] = createSignal<string | null>(null); const [activeEstimateId, setActiveEstimateId] = createSignal<string | null>(null);
const [activeEstimateName, setActiveEstimateName] = createSignal(''); const [activeEstimateName, setActiveEstimateName] = createSignal('');
const [activeHistory, setActiveHistory] = createSignal<any[]>([]);
const [activeLatestSnapshot, setActiveLatestSnapshot] = createSignal<any | null>(null);
const [isLatestVersion, setIsLatestVersion] = createSignal(true);
// Estimate Builder State // Estimate Builder State
const [scopes, setScopes] = createStore<Scope[]>([]); const [scopes, setScopes] = createStore<Scope[]>([]);
@@ -50,6 +53,12 @@ export const appStore = {
setEstimateId: setActiveEstimateId, setEstimateId: setActiveEstimateId,
estimateName: activeEstimateName, estimateName: activeEstimateName,
setEstimateName: setActiveEstimateName, setEstimateName: setActiveEstimateName,
history: activeHistory,
setHistory: setActiveHistory,
latestSnapshot: activeLatestSnapshot,
setLatestSnapshot: setActiveLatestSnapshot,
isLatestVersion: isLatestVersion,
setIsLatestVersion: setIsLatestVersion,
// Estimate Builder // Estimate Builder
scopes, setScopes, scopes, setScopes,