feat(dashboard): add corpus dashboard at /dashboard/

Full private corpus dashboard for tools.dobetternorge.no users — each SSO
account gets an auto-provisioned CaveauAI tenant (clients row, corpus) on
first visit. Includes upload (file/paste/URL), RAG chat with SSE streaming
and citation chips, document CRUD, FalkorDB graph relations tab, and
improved save-from-tool flow with tag/preview support.

- dashboard/{index,documents,document,upload,chat,settings}.php
- api/dashboard/{corpus-init,documents,upload,ingest-status,chat-stream,
  save-from-tool,graph}.php
- includes/{CorpusProvision,layout_dashboard,layout_dashboard_footer}.php
- assets/css/dashboard.css  assets/js/corpus-save.js (routing upgrade)
- includes/{bootstrap,layout}.php extended for dashboard provisioning

Migration 141 (clients.dbn_sso_uid + import_method enum) applied on chloe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 17:15:40 +02:00
parent 83fc71414f
commit 06d01a3bce
20 changed files with 2632 additions and 28 deletions
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
$dashboardPage = 'index';
$dashboardTitle = 'Min korpus';
$dashboardLead = 'Privat juridisk kunnskapsbase. Last opp, organiser, spør — alt holdes til din konto.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dash-kpis" id="dashKpis" aria-label="Corpus statistics">
<div class="dash-kpi">
<p class="dash-kpi__label">Dokumenter</p>
<p class="dash-kpi__value" data-kpi="documents">—</p>
<p class="dash-kpi__hint">av kvote</p>
</div>
<div class="dash-kpi">
<p class="dash-kpi__label">Passasjer indeksert</p>
<p class="dash-kpi__value" data-kpi="chunks">—</p>
<p class="dash-kpi__hint">søkbare biter</p>
</div>
<div class="dash-kpi">
<p class="dash-kpi__label">Klare</p>
<p class="dash-kpi__value" data-kpi="ready">—</p>
<p class="dash-kpi__hint">av totalt</p>
</div>
<div class="dash-kpi">
<p class="dash-kpi__label">Siste opplasting</p>
<p class="dash-kpi__value" data-kpi="last_upload" style="font-size: 1.05rem;">—</p>
<p class="dash-kpi__hint">dato</p>
</div>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Kom i gang</h2>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem;">
<a class="dash-btn dash-btn--primary" href="/dashboard/upload.php">📥 Last opp dokumenter</a>
<a class="dash-btn" href="/dashboard/chat.php">💬 Still et juridisk spørsmål</a>
<a class="dash-btn" href="/dashboard/documents.php">📚 Bla gjennom korpus</a>
</div>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Nylig aktivitet</h2>
<div class="dash-card__actions">
<a href="/dashboard/documents.php" class="dash-btn">Se alle →</a>
</div>
</div>
<div id="dashRecent" class="dash-loading">Laster…</div>
</section>
<script>
(function () {
'use strict';
const api = window.DBN_DASHBOARD.apiBase;
function fmtDate(s) {
if (!s) return '—';
try {
return new Date(s.replace(' ', 'T') + 'Z').toLocaleDateString('nb-NO',
{ day: 'numeric', month: 'short', year: 'numeric' });
} catch (_) { return s; }
}
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString('nb-NO'); }
function statusPill(status) {
const cls = {
ready: 'dash-status--ready',
pending: 'dash-status--pending',
processing: 'dash-status--processing',
error: 'dash-status--error',
}[status] || 'dash-status--pending';
const label = { ready: 'Klar', pending: 'Venter', processing: 'Behandler', error: 'Feil' }[status] || status;
return '<span class="dash-status ' + cls + '">' + label + '</span>';
}
fetch(api + '/documents.php?action=list&limit=100', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Failed');
const docs = data.documents || [];
const total = data.total;
const chunks = docs.reduce((s, d) => s + (d.chunk_count || 0), 0);
const ready = docs.filter(d => d.status === 'ready').length;
const last = docs[0] ? docs[0].created_at : null;
document.querySelector('[data-kpi="documents"]').textContent = fmtNum(total);
document.querySelector('[data-kpi="chunks"]').textContent = fmtNum(chunks);
document.querySelector('[data-kpi="ready"]').textContent = ready + ' / ' + docs.length;
document.querySelector('[data-kpi="last_upload"]').textContent = fmtDate(last);
const recent = document.getElementById('dashRecent');
if (!docs.length) {
recent.className = 'dash-empty';
recent.innerHTML = '<span class="dash-empty__icon">📭</span>Ingen dokumenter ennå. '
+ '<a href="/dashboard/upload.php">Last opp ditt første</a>.';
return;
}
recent.className = '';
recent.innerHTML = '';
const table = document.createElement('table');
table.className = 'dash-doctable';
table.innerHTML = '<thead><tr><th>Tittel</th><th>Status</th><th>Passasjer</th><th>Lagt til</th></tr></thead>';
const tbody = document.createElement('tbody');
docs.slice(0, 8).forEach(doc => {
const tr = document.createElement('tr');
tr.addEventListener('click', () => location.href = '/dashboard/document.php?id=' + doc.id);
const safe = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
tr.innerHTML =
'<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>'
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + '</div>' : '') + '</td>'
+ '<td>' + statusPill(doc.status) + '</td>'
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
+ '<td>' + fmtDate(doc.created_at) + '</td>';
tbody.appendChild(tr);
});
table.appendChild(tbody);
recent.appendChild(table);
})
.catch(err => {
const recent = document.getElementById('dashRecent');
recent.className = 'dash-error';
recent.textContent = 'Kunne ikke laste: ' + err.message;
});
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>