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:
parent
3bdd7d0028
commit
6449fff573
@ -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;
|
||||
};
|
||||
|
||||
@ -58,6 +58,13 @@ export default function Forecast() {
|
||||
|
||||
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 workerRef = useRef(null)
|
||||
const tableRef = useRef(null)
|
||||
@ -323,6 +330,49 @@ export default function Forecast() {
|
||||
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 dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
|
||||
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={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 */}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-400">depth</span>
|
||||
@ -410,6 +465,82 @@ export default function Forecast() {
|
||||
</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 */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* 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'
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user