Add change history modal with undo and note editing

- GET /api/versions/:id/log — log entries with row counts via JOIN
- DELETE /api/log/:logid — undo in a transaction (delete fc rows + log entry)
- PATCH /api/log/:logid — update note text
- History button opens a modal: op badge, slice, editable note, row count, Undo per entry
- Undo triggers full Perspective table reload via initViewer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-25 20:36:18 -04:00
parent 3bdd7d0028
commit 6449fff573
2 changed files with 226 additions and 0 deletions

View File

@ -282,5 +282,82 @@ module.exports = function(pool) {
} }
}); });
// list log entries for a version, newest first, with row counts
router.get('/versions/:id/log', async (req, res) => {
const versionId = parseInt(req.params.id);
try {
const verResult = await pool.query(
`SELECT v.*, s.tname FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
[versionId]
);
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
const table = fcTable(verResult.rows[0].tname, versionId);
const result = await pool.query(`
SELECT l.*, count(f.pf_id)::int AS row_count
FROM pf.log l
LEFT JOIN ${table} f ON f.pf_logid = l.id
WHERE l.version_id = $1
GROUP BY l.id
ORDER BY l.id DESC
`, [versionId]);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// undo a log entry — delete all fc rows with this logid, then delete the log entry
router.delete('/log/:logid', async (req, res) => {
const logId = parseInt(req.params.logid);
try {
const logResult = await pool.query(`
SELECT l.*, v.status, s.tname, v.id AS version_id
FROM pf.log l
JOIN pf.version v ON v.id = l.version_id
JOIN pf.source s ON s.id = v.source_id
WHERE l.id = $1
`, [logId]);
if (!logResult.rows.length) return res.status(404).json({ error: 'Log entry not found' });
const log = logResult.rows[0];
if (log.status === 'closed') return res.status(403).json({ error: 'Version is closed' });
const table = fcTable(log.tname, log.version_id);
const client = await pool.connect();
try {
await client.query('BEGIN');
const deleted = await client.query(
`DELETE FROM ${table} WHERE pf_logid = $1 RETURNING pf_id`, [logId]
);
await client.query('DELETE FROM pf.log WHERE id = $1', [logId]);
await client.query('COMMIT');
res.json({ rows_deleted: deleted.rowCount });
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
// update the note on a log entry
router.patch('/log/:logid', async (req, res) => {
const logId = parseInt(req.params.logid);
const { note } = req.body;
try {
const result = await pool.query(
`UPDATE pf.log SET note = $1 WHERE id = $2 RETURNING *`, [note, logId]
);
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
}
});
return router; return router;
}; };

View File

@ -58,6 +58,13 @@ export default function Forecast() {
const [panelWidth, setPanelWidth] = useState(224) const [panelWidth, setPanelWidth] = useState(224)
// history modal
const [showLog, setShowLog] = useState(false)
const [logEntries, setLogEntries] = useState([])
const [logLoading, setLogLoading] = useState(false)
const [editingNote, setEditingNote] = useState(null) // { id, text }
const [undoingId, setUndoingId] = useState(null)
const viewerRef = useRef(null) const viewerRef = useRef(null)
const workerRef = useRef(null) const workerRef = useRef(null)
const tableRef = useRef(null) const tableRef = useRef(null)
@ -323,6 +330,49 @@ export default function Forecast() {
setTimeout(() => setMsg(null), 3000) setTimeout(() => setMsg(null), 3000)
} }
async function openLog() {
setShowLog(true)
setLogLoading(true)
try {
const data = await fetch(`/api/versions/${versionId}/log`).then(r => r.json())
setLogEntries(data)
} catch (err) {
flash(err.message, 'error')
} finally {
setLogLoading(false)
}
}
async function undoEntry(logId) {
setUndoingId(logId)
try {
const res = await fetch(`/api/log/${logId}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
setLogEntries(prev => prev.filter(e => e.id !== logId))
flash(`Undone — ${data.rows_deleted} rows removed`)
initViewer(versionId, sourceId)
} catch (err) {
flash(err.message, 'error')
} finally {
setUndoingId(null)
}
}
async function saveNote(logId, text) {
try {
const res = await fetch(`/api/log/${logId}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: text })
})
if (!res.ok) { flash('Failed to save note', 'error'); return }
setLogEntries(prev => prev.map(e => e.id === logId ? { ...e, note: text } : e))
setEditingNote(null)
} catch (err) {
flash(err.message, 'error')
}
}
const selectedVersion = versions.find(v => String(v.id) === versionId) const selectedVersion = versions.find(v => String(v.id) === versionId)
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension') const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
const hasSlice = Object.keys(slice).length > 0 const hasSlice = Object.keys(slice).length > 0
@ -393,6 +443,11 @@ export default function Forecast() {
<button onClick={resetLayout} className="text-xs text-gray-300 hover:text-gray-500">reset</button> <button onClick={resetLayout} className="text-xs text-gray-300 hover:text-gray-500">reset</button>
)} )}
<button onClick={openLog} disabled={!versionId}
className="text-xs border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
History
</button>
{/* Depth controls */} {/* Depth controls */}
<div className="ml-auto flex items-center gap-1.5"> <div className="ml-auto flex items-center gap-1.5">
<span className="text-xs text-gray-400">depth</span> <span className="text-xs text-gray-400">depth</span>
@ -410,6 +465,82 @@ export default function Forecast() {
</div> </div>
</div> </div>
{/* History modal */}
{showLog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 flex flex-col max-h-[80vh]" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
<span className="font-medium text-gray-700 text-sm">Change History</span>
<button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="overflow-y-auto flex-1">
{logLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : logEntries.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No log entries yet.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
<tr>
<th className="text-left px-4 py-2 font-medium w-32">Time</th>
<th className="text-left px-4 py-2 font-medium w-24">Op</th>
<th className="text-left px-4 py-2 font-medium">Slice</th>
<th className="text-left px-4 py-2 font-medium">Note</th>
<th className="text-right px-4 py-2 font-medium w-16">Rows</th>
<th className="px-4 py-2 w-16"></th>
</tr>
</thead>
<tbody>
{logEntries.map(entry => (
<tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50">
<td className="px-4 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
<td className="px-4 py-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
{entry.operation}
</span>
</td>
<td className="px-4 py-2 text-gray-600 font-mono">{fmtSlice(entry.slice)}</td>
<td className="px-4 py-2 text-gray-600 max-w-xs">
{editingNote?.id === entry.id ? (
<div className="flex items-center gap-1">
<input autoFocus value={editingNote.text}
onChange={e => setEditingNote(n => ({ ...n, text: e.target.value }))}
onKeyDown={e => {
if (e.key === 'Enter') saveNote(entry.id, editingNote.text)
if (e.key === 'Escape') setEditingNote(null)
}}
className="border border-blue-300 rounded px-1.5 py-0.5 text-xs flex-1 focus:outline-none" />
<button onClick={() => saveNote(entry.id, editingNote.text)} className="text-blue-600 hover:text-blue-800"></button>
<button onClick={() => setEditingNote(null)} className="text-gray-400 hover:text-gray-600"></button>
</div>
) : (
<span onClick={() => setEditingNote({ id: entry.id, text: entry.note || '' })}
className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
title={entry.note || 'Click to add note'}>
{entry.note || <span className="text-gray-300 italic">add note</span>}
</span>
)}
</td>
<td className="px-4 py-2 text-right text-gray-500 tabular-nums">{entry.row_count ?? '—'}</td>
<td className="px-4 py-2">
<button
onClick={() => undoEntry(entry.id)}
disabled={undoingId === entry.id}
className="text-xs border border-red-200 text-red-400 hover:text-red-600 hover:border-red-400 rounded px-2 py-0.5 disabled:opacity-40 whitespace-nowrap">
{undoingId === entry.id ? '…' : 'Undo'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
{/* Main area */} {/* Main area */}
<div className="flex-1 flex min-h-0"> <div className="flex-1 flex min-h-0">
{/* Perspective viewer */} {/* Perspective viewer */}
@ -539,6 +670,24 @@ export default function Forecast() {
const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0' const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'
function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function fmtSlice(slice) {
if (!slice || !Object.keys(slice).length) return '—'
return Object.entries(slice).map(([k, v]) => `${k} = ${v}`).join(', ')
}
const OP_BADGE = {
baseline: 'bg-gray-100 text-gray-600',
reference: 'bg-blue-50 text-blue-600',
scale: 'bg-green-50 text-green-700',
recode: 'bg-amber-50 text-amber-700',
clone: 'bg-purple-50 text-purple-700',
}
function opBadge(op) { return OP_BADGE[op] || 'bg-gray-100 text-gray-500' }
function Row({ label, children }) { function Row({ label, children }) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">