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;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user