diff --git a/routes/operations.js b/routes/operations.js index be397fe..38c1426 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -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; }; diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 30acef1..1ed44fb 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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() { )} + + {/* Depth controls */}
depth @@ -410,6 +465,82 @@ export default function Forecast() {
+ {/* History modal */} + {showLog && ( +
setShowLog(false)}> +
e.stopPropagation()}> +
+ Change History + +
+ +
+ {logLoading ? ( +
Loading…
+ ) : logEntries.length === 0 ? ( +
No log entries yet.
+ ) : ( + + + + + + + + + + + + + {logEntries.map(entry => ( + + + + + + + + + ))} + +
TimeOpSliceNoteRows
{fmtStamp(entry.stamp)} + + {entry.operation} + + {fmtSlice(entry.slice)} + {editingNote?.id === entry.id ? ( +
+ 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" /> + + +
+ ) : ( + 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 || add note} + + )} +
{entry.row_count ?? '—'} + +
+ )} +
+
+
+ )} + {/* Main area */}
{/* 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 (