history working

This commit is contained in:
2026-03-13 15:04:37 -05:00
parent e637e0903d
commit 4b2585a59f
2 changed files with 196 additions and 65 deletions
+119 -47
View File
@@ -37,72 +37,144 @@ const HistoryModal = (props: HistoryModalProps) => {
<div class="space-y-3 overflow-y-auto pr-2"> <div class="space-y-3 overflow-y-auto pr-2">
{/* Current Live Version */} {/* Current Live Version */}
<div <div
class={`group flex items-center justify-between p-4 rounded-2xl border transition-all ${ class={`group flex flex-col p-4 rounded-2xl border transition-all ${
appStore.isLatestVersion() appStore.isLatestVersion()
? 'border-primary bg-primary/5 shadow-premium' ? 'border-primary bg-primary/5 shadow-premium'
: 'border-border/60 bg-muted/10 hover:border-primary/30' : 'border-border/60 bg-muted/10 hover:border-primary/30'
}`} }`}
> >
<div class="flex items-center gap-4"> <div class="flex items-center justify-between">
<div class={`p-2 rounded-lg ${appStore.isLatestVersion() ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}> <div class="flex items-center gap-4">
<CheckCircle2 class="w-4 h-4" /> <div class={`p-2 rounded-lg ${appStore.isLatestVersion() ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
</div> <CheckCircle2 class="w-4 h-4" />
<div> </div>
<div class="flex items-center gap-2"> <div>
<h4 class="font-bold text-foreground">Latest Version</h4> <div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-green-100 text-green-700 text-[8px] font-black uppercase rounded tracking-widest">Live</span> <h4 class="font-bold text-foreground">Latest Version</h4>
<Show when={appStore.isLatestVersion()}> <span class="px-1.5 py-0.5 bg-green-100 text-green-700 text-[8px] font-black uppercase rounded tracking-widest">Live</span>
<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 when={appStore.isLatestVersion()}>
</Show> <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>
<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> </div>
<Show when={!appStore.isLatestVersion()}> {/* Change Summary Breakdown for Latest */}
<button <Show when={appStore.latestSnapshot()?.changeSummary && Object.keys(appStore.latestSnapshot().changeSummary).length > 0}>
onClick={() => { <div class="mt-4 pt-4 border-t border-border/40">
props.onCheckout(appStore.latestSnapshot()); <p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 mb-2">Changes in this Version:</p>
props.onClose(); <div class="space-y-3">
}} <For each={Object.values(appStore.latestSnapshot().changeSummary)}>
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" {(sum: any) => (
> <div class="flex flex-col gap-1">
Return to Latest <div class="flex items-center justify-between text-[11px]">
</button> <span class="font-bold text-foreground/80">{sum.scopeName}</span>
<div class="flex gap-2 font-medium">
<Show when={sum.itemsAdded > 0}>
<span class="text-green-600">+{sum.itemsAdded} Add</span>
</Show>
<Show when={sum.itemsRemoved > 0}>
<span class="text-red-600">-{sum.itemsRemoved} Rem</span>
</Show>
<Show when={sum.itemsUpdated > 0}>
<span class="text-blue-600">{sum.itemsUpdated} Upd</span>
</Show>
</div>
</div>
<Show when={sum.fieldChanges > 0}>
<p class="text-[9px] text-muted-foreground ml-1">
{sum.fieldChanges} field adjustments
</p>
</Show>
</div>
)}
</For>
</div>
</div>
</Show> </Show>
</div> </div>
{/* Historical Snapshots */} {/* Historical Snapshots */}
<For each={historyEntries()}> <For each={historyEntries()}>
{(snapshot) => ( {(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="group flex flex-col 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="flex items-center justify-between">
<div class="p-2 bg-muted text-muted-foreground rounded-lg"> <div class="flex items-center gap-4">
<Clock class="w-4 h-4" /> <div class="p-2 bg-muted text-muted-foreground rounded-lg">
</div> <Clock class="w-4 h-4" />
<div> </div>
<div class="flex items-center gap-2"> <div>
<h4 class="font-bold text-foreground"> <div class="flex items-center gap-2">
{snapshot.jobInfo?.name || snapshot.name || 'Version'} <h4 class="font-bold text-foreground">
</h4> {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>
<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> </div>
<button {/* Change Summary Breakdown */}
onClick={() => { <Show when={snapshot.changeSummary && Object.keys(snapshot.changeSummary).length > 0}>
props.onCheckout(snapshot); <div class="mt-4 pt-4 border-t border-border/40">
props.onClose(); <p class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 mb-2">Scope Changes in this Version:</p>
}} <div class="space-y-3">
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" <For each={Object.values(snapshot.changeSummary)}>
> {(sum: any) => (
Checkout <div class="flex flex-col gap-1">
</button> <div class="flex items-center justify-between text-[11px]">
<span class="font-bold text-foreground/80">{sum.scopeName}</span>
<div class="flex gap-2 font-medium">
<Show when={sum.itemsAdded > 0}>
<span class="text-green-600">+{sum.itemsAdded} Add</span>
</Show>
<Show when={sum.itemsRemoved > 0}>
<span class="text-red-600">-{sum.itemsRemoved} Rem</span>
</Show>
<Show when={sum.itemsUpdated > 0}>
<span class="text-blue-600">{sum.itemsUpdated} Upd</span>
</Show>
</div>
</div>
<Show when={sum.fieldChanges > 0}>
<p class="text-[9px] text-muted-foreground ml-1">
{sum.fieldChanges} field adjustments
</p>
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
</div> </div>
)} )}
</For> </For>
@@ -77,21 +77,83 @@ export function useEstimateSync(
}; };
}; };
const calculateChangeSummary = (prevData: any, newData: any) => {
const summary: Record<string, { scopeName: string, itemsAdded: number, itemsRemoved: number, itemsUpdated: number, fieldChanges: number }> = {};
const prevItems = prevData.items || [];
const newItems = newData.items || [];
const allScopeIds = new Set([
...prevItems.map((i: any) => i.scopeId),
...newItems.map((i: any) => i.scopeId)
]);
const scopesMap = new Map((newData.scopes || []).map((s: any) => [s.id, s.name]));
allScopeIds.forEach(rawId => {
const scopeId = String(rawId);
if (!scopeId || scopeId === 'undefined') return;
const scopeName = scopesMap.get(scopeId) || "Unknown Scope";
const scopePrevItems = prevItems.filter((i: any) => i.scopeId === scopeId);
const scopeNewItems = newItems.filter((i: any) => i.scopeId === scopeId);
let added = 0;
let removed = 0;
let updated = 0;
let fields = 0;
const scopePrevMap = new Map(scopePrevItems.map((i: any) => [i.id, i]));
const scopeNewMap = new Map(scopeNewItems.map((i: any) => [i.id, i]));
scopeNewItems.forEach((item: any) => {
const prev = scopePrevMap.get(item.id);
if (!prev) {
added++;
} else {
let changed = false;
['description', 'quantity', 'unit', 'price', 'type'].forEach(k => {
const v1 = (item as any)[k];
const v2 = (prev as any)[k];
if (String(v1) !== String(v2)) {
fields++;
changed = true;
}
});
if (changed) updated++;
}
});
scopePrevItems.forEach((item: any) => {
if (!scopeNewMap.has(item.id)) {
removed++;
}
});
if (added > 0 || removed > 0 || updated > 0 || fields > 0) {
(summary as any)[scopeId] = { scopeName, itemsAdded: added, itemsRemoved: removed, itemsUpdated: updated, fieldChanges: fields };
}
});
return summary;
};
const saveToCloud = async (name: string) => { const saveToCloud = async (name: string) => {
setIsCloudLoading(true); setIsCloudLoading(true);
try { try {
const data = getFullEstimateData(); const data = getFullEstimateData();
const record = await pb.collection(COLLECTIONS.ESTIMATES).create({ const record = await pb.collection(COLLECTIONS.ESTIMATES).create({
name, name,
items: data, items: { ...data, changeSummary: null },
history: [] // New estimate start with empty history history: []
}); });
setShowSaveModal(false); setShowSaveModal(false);
setEstimateName(name); setEstimateName(name);
setEstimateId(record.id); setEstimateId(record.id);
setHistory([]); setHistory([]);
setLatestSnapshot(data); setLatestSnapshot({ ...data, changeSummary: null });
setIsLatestVersion(true); setIsLatestVersion(true);
alert('New estimate saved successfully!'); alert('New estimate saved successfully!');
} catch (err) { } catch (err) {
@@ -109,24 +171,23 @@ export function useEstimateSync(
try { try {
const currentData = getFullEstimateData(); 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 record = await pb.collection(COLLECTIONS.ESTIMATES).getOne(id);
const updatedHistory = [...(record.history || [])]; const updatedHistory = [...(record.history || [])];
// 2. Push current state (before this save) to history if it's new const changeSummary = calculateChangeSummary(record.items, currentData);
// Actually, the user wants to save "as diffs" or "history".
// In a single-record approach, 'items' is the LATEST state. const newTip = {
// When we "Save Changes", we move the PREVIOUS 'items' to history. ...currentData,
updatedHistory.push(record.items); changeSummary
};
// 3. Update record with new current items and expanded history
await pb.collection(COLLECTIONS.ESTIMATES).update(id, { await pb.collection(COLLECTIONS.ESTIMATES).update(id, {
items: currentData, items: newTip,
history: updatedHistory history: [...updatedHistory, record.items]
}); });
setHistory(updatedHistory); setHistory([...updatedHistory, record.items]);
setLatestSnapshot(currentData); setLatestSnapshot(newTip);
setIsLatestVersion(true); setIsLatestVersion(true);
alert('Changes saved successfully!'); alert('Changes saved successfully!');
} catch (err) { } catch (err) {
@@ -138,8 +199,6 @@ export function useEstimateSync(
}; };
const saveAsNewestToCloud = async () => { 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(); await saveChangesToCloud();
}; };