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
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
$dashboardPage = 'documents';
$dashboardTitle = 'Dokumenter';
$dashboardLead = 'Alle dokumenter i din private korpus. Klikk for å åpne, eller velg flere for bulk-handlinger.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dash-card">
<div class="dash-filters">
<input type="search" id="docFilterQ" placeholder="Søk i titler eller tagger…" autocomplete="off">
<select id="docFilterStatus">
<option value="">Alle statuser</option>
<option value="ready">Klar</option>
<option value="pending">Venter</option>
<option value="processing">Behandler</option>
<option value="error">Feil</option>
</select>
<button id="docBulkDelete" class="dash-btn dash-btn--danger" disabled>Slett valgte</button>
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary" style="margin-left: auto;">+ Last opp</a>
</div>
<div id="docListWrap"><p class="dash-loading">Laster dokumenter…</p></div>
<div class="dash-pager" id="docPager" hidden>
<span id="docPagerLabel"></span>
<div class="dash-pager__actions">
<button class="dash-btn" id="docPagerPrev">← Forrige</button>
<button class="dash-btn" id="docPagerNext">Neste →</button>
</div>
</div>
</section>
<script>
(function () {
'use strict';
const api = window.DBN_DASHBOARD.apiBase;
const PAGE = 25;
const state = { offset: 0, total: 0, selected: new Set(), q: '', status: '' };
const $wrap = document.getElementById('docListWrap');
const $pager = document.getElementById('docPager');
const $pl = document.getElementById('docPagerLabel');
const $prev = document.getElementById('docPagerPrev');
const $next = document.getElementById('docPagerNext');
const $bulk = document.getElementById('docBulkDelete');
const $fq = document.getElementById('docFilterQ');
const $fs = document.getElementById('docFilterStatus');
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
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 lbl = { ready:'Klar', pending:'Venter', processing:'Behandler', error:'Feil' }[status] || status;
return '<span class="dash-status ' + cls + '">' + lbl + '</span>';
}
function load() {
const qs = new URLSearchParams({ action:'list', offset:String(state.offset), limit:String(PAGE) });
if (state.q) qs.set('q', state.q);
if (state.status) qs.set('status', state.status);
$wrap.innerHTML = '<p class="dash-loading">Laster dokumenter…</p>';
fetch(api + '/documents.php?' + qs, { credentials:'same-origin' })
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Failed');
state.total = data.total;
render(data.documents || []);
})
.catch(err => {
$wrap.innerHTML = '<div class="dash-error">' + safe(err.message) + '</div>';
});
}
function render(docs) {
if (!docs.length) {
$wrap.innerHTML = '<div class="dash-empty"><span class="dash-empty__icon">📭</span>'
+ (state.q || state.status ? 'Ingen treff for valgt filter.'
: 'Ingen dokumenter ennå. <a href="/dashboard/upload.php">Last opp ditt første</a>.')
+ '</div>';
$pager.hidden = true;
return;
}
const table = document.createElement('table');
table.className = 'dash-doctable';
table.innerHTML =
'<thead><tr>'
+ '<th style="width:36px;"><input type="checkbox" id="docSelectAll"></th>'
+ '<th>Tittel</th><th>Kategori</th><th>Status</th>'
+ '<th>Passasjer</th><th>Lagt til</th>'
+ '</tr></thead>';
const tbody = document.createElement('tbody');
docs.forEach(doc => {
const tr = document.createElement('tr');
tr.dataset.id = String(doc.id);
tr.innerHTML =
'<td><input type="checkbox" class="doc-check" value="' + doc.id + '"' + (state.selected.has(doc.id) ? ' checked' : '') + '></td>'
+ '<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>'
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + (doc.tags ? ' · ' + safe(doc.tags) : '') + '</div>' : (doc.tags ? '<div class="dash-doctable__meta">' + safe(doc.tags) + '</div>' : ''))
+ '</td>'
+ '<td>' + safe(doc.category || '—') + '</td>'
+ '<td>' + statusPill(doc.status) + '</td>'
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
+ '<td>' + fmtDate(doc.created_at) + '</td>';
tr.addEventListener('click', (e) => {
if (e.target.matches('input[type="checkbox"]')) return;
location.href = '/dashboard/document.php?id=' + doc.id;
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
$wrap.innerHTML = '';
$wrap.appendChild(table);
// Wire selection
const all = document.getElementById('docSelectAll');
all.addEventListener('change', () => {
tbody.querySelectorAll('.doc-check').forEach(c => {
c.checked = all.checked;
const id = parseInt(c.value, 10);
if (all.checked) state.selected.add(id); else state.selected.delete(id);
});
updateBulk();
});
tbody.querySelectorAll('.doc-check').forEach(c => {
c.addEventListener('change', (e) => {
const id = parseInt(e.target.value, 10);
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
updateBulk();
});
});
const from = state.offset + 1;
const to = Math.min(state.offset + docs.length, state.total);
$pl.textContent = 'Viser ' + from + '' + to + ' av ' + state.total;
$prev.disabled = state.offset === 0;
$next.disabled = state.offset + PAGE >= state.total;
$pager.hidden = false;
}
function updateBulk() {
$bulk.disabled = state.selected.size === 0;
$bulk.textContent = state.selected.size > 0 ? 'Slett valgte (' + state.selected.size + ')' : 'Slett valgte';
}
$prev.addEventListener('click', () => { state.offset = Math.max(0, state.offset - PAGE); load(); });
$next.addEventListener('click', () => { state.offset += PAGE; load(); });
let filterTimer = null;
$fq.addEventListener('input', () => {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => { state.q = $fq.value.trim(); state.offset = 0; load(); }, 250);
});
$fs.addEventListener('change', () => { state.status = $fs.value; state.offset = 0; load(); });
$bulk.addEventListener('click', () => {
if (!state.selected.size) return;
if (!confirm('Slette ' + state.selected.size + ' dokumenter? Dette kan ikke angres.')) return;
const ids = Array.from(state.selected);
$bulk.disabled = true;
fetch(api + '/documents.php?action=delete', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Slett feilet');
state.selected.clear();
updateBulk();
load();
})
.catch(err => alert('Feil: ' + err.message));
});
load();
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>