feat(tools): owner feedback review surface + tool_feedback migration

Adds the missing migration for the tool_feedback table (dobetternorge_maindb)
that the in-result feedback widget writes to, repoints api/feedback.php to
dbnmDb() for consistency with the engine-config table, and adds an owner-only
dashboard (page + read API + nav) summarising ratings and notes by tool/engine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 15:19:45 +02:00
parent 198f0526cf
commit 519bdbb6e5
6 changed files with 294 additions and 1 deletions
+88
View File
@@ -0,0 +1,88 @@
(function () {
'use strict';
const d = window.DBN_DASHBOARD || {};
const api = (d.apiBase || '/api/dashboard') + '/feedback.php';
const $totals = document.getElementById('fbTotals');
const $byTool = document.getElementById('fbByTool');
const $byEngine = document.getElementById('fbByEngine');
const $recent = document.getElementById('fbRecent');
const $refresh = document.getElementById('fbRefresh');
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
function pct(pos, total) {
return total > 0 ? Math.round((pos / total) * 100) : 0;
}
function totalsHtml(t) {
return '' +
'<div class="fb-stat"><div class="fb-stat__num">' + t.total + '</div><div class="fb-stat__lbl">Total</div></div>' +
'<div class="fb-stat fb-stat--pos"><div class="fb-stat__num">' + t.positive + '</div><div class="fb-stat__lbl">Positive</div></div>' +
'<div class="fb-stat fb-stat--neg"><div class="fb-stat__num">' + t.negative + '</div><div class="fb-stat__lbl">Negative</div></div>' +
'<div class="fb-stat"><div class="fb-stat__num">' + pct(t.positive, t.total) + '%</div><div class="fb-stat__lbl">Approval</div></div>';
}
function rowsHtml(rows, nameKey, withMeta) {
const head = '<div class="fb-head"><div>' + (nameKey === 'tool' ? 'Tool' : 'Engine') +
'</div><div>Total</div><div>Up</div><div>Down</div><div>' + (withMeta ? 'Last' : 'Approval') + '</div></div>';
if (!rows.length) return head + '<div class="fb-empty">No feedback yet.</div>';
const body = rows.map(r => {
const p = pct(r.positive, r.total);
const right = withMeta
? '<div class="fb-meta">' + safe(r.last_at || '—') + '</div>'
: '<div><div class="fb-bar"><div class="fb-bar__fill" style="width:' + p + '%"></div></div>' +
'<div class="fb-meta">' + p + '%</div></div>';
return '<div class="fb-row">' +
'<div class="fb-row__name">' + safe(r[nameKey]) + '</div>' +
'<div>' + r.total + '</div>' +
'<div class="fb-pos">' + r.positive + '</div>' +
'<div class="fb-neg">' + r.negative + '</div>' +
right +
'</div>';
}).join('');
return head + body;
}
function recentHtml(rows) {
if (!rows.length) return '<div class="fb-empty">No notes yet.</div>';
return rows.map(n => {
const isPos = n.rating === 'positive';
return '<div class="fb-note">' +
'<div class="fb-note__head">' +
'<span class="fb-note__tool">' + safe(n.tool) + '</span>' +
'<span class="fb-note__rating ' + (isPos ? 'is-pos' : 'is-neg') + '">' + (isPos ? '\u{1F44D}' : '\u{1F44E}') + '</span>' +
(n.engine ? '<span class="fb-meta">' + safe(n.engine) + '</span>' : '') +
'<span class="fb-meta">' + safe(n.created_at || '') + '</span>' +
'</div>' +
'<div class="fb-note__text">' + safe(n.missed_or_wrong) + '</div>' +
'</div>';
}).join('');
}
function render(data) {
$totals.innerHTML = totalsHtml(data.totals || { total: 0, positive: 0, negative: 0 });
$byTool.innerHTML = rowsHtml(data.by_tool || [], 'tool', true);
$byEngine.innerHTML = rowsHtml(data.by_engine || [], 'engine', false);
$recent.innerHTML = recentHtml(data.recent || []);
}
async function load() {
$totals.innerHTML = '<div class="dms-loading"></div>';
try {
const r = await fetch(api, { credentials: 'same-origin' });
const data = await r.json();
if (!data.ok) throw new Error(data.message || 'Could not load feedback');
render(data);
} catch (e) {
$totals.innerHTML = '<div class="fb-empty">Could not load: ' + safe(e.message) + '</div>';
$byTool.innerHTML = '';
$byEngine.innerHTML = '';
$recent.innerHTML = '';
}
}
if ($refresh) $refresh.addEventListener('click', load);
load();
})();