Files
foodlinkk-command-center/cockpit/templates/documents.html
T
2026-06-09 10:41:13 +00:00

468 lines
22 KiB
HTML

{% extends "base.html" %}
{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=9" />
<style>
.doc-neo-shell { --hm-cyan: #00e5ff; --hm-border: rgba(0, 229, 255, 0.12); --hm-panel: #0d1219; }
.doc-neo-shell .page-header h1 { font-size: 1.55rem; }
.doc-dash-filters { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:1rem; }
.doc-neo-kpi-row { display:grid; grid-template-columns: repeat(4, 1fr); gap:1rem; margin-bottom:1rem; }
@media (max-width: 900px) { .doc-neo-kpi-row { grid-template-columns: 1fr 1fr; } }
.doc-neo-kpi {
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
border: 1px solid var(--hm-border); border-radius: 14px; padding: 1rem 1.1rem 0.65rem;
}
.doc-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase; color: #94a3b8; }
.doc-neo-kpi-value { font-size: 1.65rem; font-weight: 700; color: #f1f5f9; margin: 0.25rem 0; }
.doc-chart-grid { display:grid; grid-template-columns: 1fr 1fr; gap:1rem; margin-top:1rem; }
@media (max-width: 800px) { .doc-chart-grid { grid-template-columns: 1fr; } }
.doc-chart-panel .hm-chart-wrap { height: 130px; }
.photo-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:1rem; }
.photo-card { border:1px solid rgba(0,229,255,0.12); border-radius:12px; overflow:hidden; background:rgba(13,18,25,0.95); }
.photo-card img { width:100%; height:140px; object-fit:cover; background:#0a0f1a; }
.photo-card-body { padding:0.6rem; font-size:0.78rem; }
.detection-wrap { position:relative; display:inline-block; max-width:100%; }
.detection-wrap canvas { position:absolute; left:0; top:0; pointer-events:none; }
</style>
{% endblock %}
{% block content %}
<div class="doc-neo-shell" x-data="documentsDash()" x-init="init()">
<header class="page-header herman-hero" style="margin-bottom:1rem">
<h1>Documents &amp; Foto's <span class="live-badge">Neo</span></h1>
<p class="hero-sub page-tip">NAS corpus · sentiment · woorden · foto OCR — interactief dashboard</p>
</header>
<div class="doc-neo-kpi-row">
<div class="doc-neo-kpi clickable-kpi" @click="filterSentiment=''">
<div class="doc-neo-kpi-label">Documenten</div>
<div class="doc-neo-kpi-value" x-text="filteredDocs.length"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Totaal woorden</div>
<div class="doc-neo-kpi-value">{{ summary.total_words }}</div>
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Foto's in DB</div>
<div class="doc-neo-kpi-value" x-text="photos.length"></div>
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="doc-neo-kpi clickable-kpi">
<div class="doc-neo-kpi-label">Gem. sentiment</div>
<div class="doc-neo-kpi-value">{{ summary.avg_sentiment }}</div>
<div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
</div>
<div class="doc-dash-filters panel">
<input type="search" class="form-input" style="min-width:180px" placeholder="Zoek bestand..." x-model="filterQuery" @input="applyFilters()" />
<select class="form-input" x-model="filterSentiment" @change="applyFilters()">
<option value="">Alle sentiment</option>
<option value="positive">Positief</option>
<option value="neutral">Neutraal</option>
<option value="negative">Negatief</option>
</select>
<select class="form-input" x-model="filterType" @change="applyFilters()">
<option value="">Alle types</option>
<option value="presentation">Presentatie (pptx)</option>
<template x-for="t in docTypes" :key="t"><option :value="t" x-text="t"></option></template>
</select>
<label style="font-size:0.8rem;color:var(--text-muted)"><input type="checkbox" x-model="liveRefresh" @change="toggleLive()" /> Live refresh (15s)</label>
<button class="btn btn-secondary btn-sm" @click="loadPhotos()">↻ Foto's</button>
<button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button>
</div>
<div class="doc-chart-grid">
<section class="hm-chart-panel doc-chart-panel">
<div class="hm-chart-head"><h4>Top woorden</h4><small>klik = filter</small></div>
<div class="hm-chart-wrap"><canvas id="wordsChart"></canvas></div>
<div id="viz-words-alt" class="viz-alt"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</section>
<section class="hm-chart-panel doc-chart-panel">
<div class="hm-chart-head"><h4>Sentiment</h4><small>klik = filter</small></div>
<div class="hm-chart-wrap"><canvas id="sentimentChart"></canvas></div>
<div id="viz-sentiment-alt" class="viz-alt"></div>
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</section>
</div>
<section class="panel" style="margin-top:1rem" x-data="wordSearch()" x-init="init()">
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap">
<h2 style="margin:0">Woord zoeken</h2>
<div style="display:flex;gap:.5rem">
<input type="search" x-model="query" @keydown.enter="search()" placeholder="Zoek lemma..." class="form-input" style="min-width:220px" />
<label style="display:flex;align-items:center;gap:.35rem;font-size:.85rem;color:var(--text-muted)">
<input type="checkbox" x-model="includeStopwords" @change="search()" /> stopwoorden
</label>
<button class="btn btn-primary" @click="search()">Zoek</button>
</div>
</div>
<table class="data-table" style="margin-top:1rem">
<thead><tr><th>Lemma</th><th>Token</th><th>Totaal</th><th>Documenten</th></tr></thead>
<tbody>
<template x-if="loading"><tr><td colspan="4">Laden...</td></tr></template>
<template x-for="row in results" :key="row.lemma">
<tr class="clickable-row" @click="$root.filterQuery=row.lemma; $root.applyFilters()">
<td x-text="row.lemma"></td>
<td x-text="row.token || row.sample_token || '—'"></td>
<td x-text="row.total_count"></td>
<td x-text="row.document_count || row.doc_count"></td>
</tr>
</template>
</tbody>
</table>
</section>
<section class="panel" style="margin-top:1rem">
<h2>Documenten op NAS <span class="muted" x-text="'(' + filteredDocs.length + ')'"></span></h2>
<table class="data-table">
<thead>
<tr><th>Bestand</th><th>Pad</th><th>Type</th><th>Woorden</th><th>Sentiment</th><th>Score</th><th>Geanalyseerd</th></tr>
</thead>
<tbody>
<template x-for="doc in filteredDocs" :key="doc.storage_path">
<tr>
<td x-text="doc.filename"></td>
<td class="muted" style="font-size:0.75rem" x-text="doc.storage_path"></td>
<td x-text="doc.doc_type"></td>
<td x-text="doc.word_count"></td>
<td><span class="badge" :class="'badge-' + (doc.sentiment_label || 'neutral')" x-text="doc.sentiment_label || '—'"></span></td>
<td x-text="doc.sentiment_compound != null ? Number(doc.sentiment_compound).toFixed(3) : '—'"></td>
<td x-text="(doc.analyzed_at || doc.modified_at || '').substring(0,16)"></td>
</tr>
</template>
<tr x-show="!filteredDocs.length"><td colspan="7" class="empty-state">Geen documenten voor dit filter.</td></tr>
</tbody>
</table>
</section>
<section class="panel" style="margin-top:1rem">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem">
<h2 style="margin:0">Foto's — OCR &amp; detectie</h2>
<div style="display:flex;gap:0.5rem">
<input type="file" accept="image/*" @change="uploadPhoto($event)" class="form-input" style="max-width:220px" />
<button class="btn btn-secondary btn-sm" @click="loadNasImages()">NAS share foto's</button>
</div>
</div>
<p class="page-tip">Telegram-foto's worden automatisch opgeslagen. Klik een foto voor detectie-boxes (gewicht/prijs extractie).</p>
<div class="photo-grid" style="margin-top:1rem">
<template x-for="p in photos" :key="p.id">
<div class="photo-card clickable-row" @click="openPhoto(p.id)">
<img :src="'/api/admin/photos/' + p.id + '/image.jpg'" :alt="p.filename || 'foto'" loading="lazy" />
<div class="photo-card-body">
<strong x-text="'#' + p.id + ' ' + (p.source || '')"></strong><br/>
<span x-text="(p.ocr_preview || '').substring(0,80)"></span><br/>
<small x-text="(p.box_count || 0) + ' boxes · ' + (p.item_count || 0) + ' items'"></small>
<button class="btn btn-sm btn-secondary" style="margin-top:0.35rem" @click.stop="exportPhoto(p.id)">📥 Export</button>
</div>
</div>
</template>
<p class="empty-state" x-show="!photos.length">Nog geen foto's — upload hier of stuur via Telegram.</p>
</div>
</section>
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem" x-show="activePhoto">
<select class="form-input" x-model="linkProjectId" style="max-width:200px">
<option value="">Koppel aan project…</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<button class="btn btn-sm btn-primary" @click="savePhotoToProject()">Opslaan in project</button>
<a href="/projects" class="btn btn-sm">Alle projecten →</a>
</div>
<div class="detection-wrap">
<img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" />
<canvas id="detectionCanvas"></canvas>
</div>
<pre class="browser-preview-text" style="margin-top:0.75rem;max-height:160px" x-text="activeOcr"></pre>
<table class="data-table" x-show="activeItems.length" style="margin-top:0.5rem;font-size:0.78rem">
<thead><tr><th>Geëxtraheerd</th><th>Gewicht</th><th>Prijs</th></tr></thead>
<tbody>
<template x-for="(it,i) in activeItems" :key="i">
<tr><td x-text="it.raw"></td><td x-text="it.weight||'—'"></td><td x-text="it.price||'—'"></td></tr>
</template>
</tbody>
</table>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
const topWords = {{ top_words | tojson }};
const summary = {{ summary | tojson }};
const allDocuments = {{ documents | tojson }};
let shareMerged = false;
let wordsChartInst = null;
let sentimentChartInst = null;
function documentsDash() {
return {
filterQuery: '',
filterSentiment: '',
filterType: '',
filteredDocs: allDocuments,
docTypes: [...new Set(allDocuments.map(d => d.doc_type).filter(Boolean))],
photos: [],
activePhoto: null,
activeOcr: '',
activeItems: [],
detections: [],
linkProjectId: '',
cockpitProjects: [],
liveRefresh: false,
liveTimer: null,
init() {
this.applyFilters();
this.renderCharts(window.__vizMode || 'neo-bars');
this.loadPhotos();
this.refreshShare(false);
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
document.addEventListener('foodlinkk:vizmode', (ev) => this.renderCharts(ev.detail.mode));
},
async savePhotoToProject() {
if (!this.activePhoto || !this.linkProjectId) return Cockpit.toast('Kies een project', 'error');
try {
await Cockpit.api('/api/projects/photos/' + this.activePhoto + '/link', {
method: 'POST',
body: JSON.stringify({ project_id: parseInt(this.linkProjectId, 10) }),
});
Cockpit.toast('Foto gekoppeld aan project', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
mergeShareItems(items) {
const byPath = new Map(allDocuments.map(d => [d.storage_path, d]));
(items || []).forEach(f => {
const path = f.path || f.storage_path;
if (!path) return;
if (byPath.has(path)) {
const ex = byPath.get(path);
if (!ex.analyzed_at && f.modified_at) ex.modified_at = f.modified_at;
return;
}
const row = {
filename: f.filename || path.split('/').pop(),
storage_path: path,
doc_type: f.doc_type || 'general',
word_count: f.word_count || 0,
sentiment_label: f.sentiment_label || 'neutral',
sentiment_compound: f.sentiment_compound || 0,
analyzed_at: f.analyzed_at || '',
modified_at: f.modified_at || '',
from_share: !f.analyzed_at,
};
allDocuments.push(row);
byPath.set(path, row);
});
this.docTypes = [...new Set(allDocuments.map(d => d.doc_type).filter(Boolean))];
this.applyFilters();
},
async refreshShare(showToast) {
try {
if (showToast !== false) Cockpit.toast('Share scannen…', 'info');
await fetch('/api/admin/documents/trigger-scan', { method: 'POST' });
const r = await fetch('/api/admin/documents/share-files?limit=300');
const data = await r.json();
if (data.ok) {
this.mergeShareItems(data.items || []);
if (showToast !== false) Cockpit.toast((data.total || 0) + ' bestanden op share', 'success');
}
} catch (e) {
if (showToast !== false) Cockpit.toast('Share scan mislukt', 'error');
}
},
applyFilters() {
const q = this.filterQuery.toLowerCase();
this.filteredDocs = allDocuments.filter(d => {
if (this.filterSentiment && d.sentiment_label !== this.filterSentiment) return false;
if (this.filterType && d.doc_type !== this.filterType) return false;
if (q && !(d.filename || '').toLowerCase().includes(q)) return false;
return true;
});
},
renderCharts(mode) {
mode = mode || window.__vizMode || 'neo-bars';
if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
const dash = this;
const wordLabels = topWords.slice(0, 10).map(w => w.lemma);
const wordValues = topWords.slice(0, 10).map(w => w.total_count);
const sentLabels = ['Positief', 'Neutraal', 'Negatief'];
const sentValues = [summary.positive, summary.neutral, summary.negative];
if (window.VizEngine && (mode === 'neo-equalizer' || mode === 'neo-cards' || mode === 'neo-table')) {
VizEngine.renderWidget(mode, {
canvas: document.getElementById('wordsChart'),
altContainer: document.getElementById('viz-words-alt'),
key: 'doc-words',
labels: wordLabels, values: wordValues,
rows: wordLabels.map((l, i) => ({ label: l, value: wordValues[i], raw: wordValues[i] })),
eqClass: 'hm-eq-cyan',
});
VizEngine.renderWidget(mode, {
canvas: document.getElementById('sentimentChart'),
altContainer: document.getElementById('viz-sentiment-alt'),
key: 'doc-sentiment',
labels: sentLabels, values: sentValues,
rows: sentLabels.map((l, i) => ({ label: l, value: sentValues[i], raw: sentValues[i] })),
eqClass: 'hm-eq-purple',
});
return;
}
document.getElementById('viz-words-alt').innerHTML = '';
document.getElementById('viz-sentiment-alt').innerHTML = '';
document.getElementById('wordsChart').style.display = '';
document.getElementById('sentimentChart').style.display = '';
wordsChartInst = new Chart(document.getElementById('wordsChart'), {
type: mode === 'neo-rings' ? 'polarArea' : 'bar',
data: {
labels: topWords.slice(0, 10).map(w => w.lemma),
datasets: [{ label: 'Aantal', data: topWords.slice(0, 10).map(w => w.total_count),
backgroundColor: 'rgba(0,229,255,0.75)', borderWidth: 0, borderRadius: 6 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
onClick: (_, els) => { if (els[0]) { dash.filterQuery = topWords[els[0].index].lemma; dash.applyFilters(); } },
scales: { y: { beginAtZero: true, ticks: { color: '#9aa4b2', font: { size: 9 } } },
x: { ticks: { color: '#9aa4b2', font: { size: 9 } } } }
}
});
sentimentChartInst = new Chart(document.getElementById('sentimentChart'), {
type: mode === 'neo-bars' ? 'doughnut' : 'doughnut',
data: {
labels: ['Positief', 'Neutraal', 'Negatief'],
datasets: [{ data: [summary.positive, summary.neutral, summary.negative],
backgroundColor: ['#4ade80', '#64748b', '#fb7185'], borderWidth: 0 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#cbd5e1', font: { size: 9 } } } },
onClick: (_, els) => {
if (!els[0]) return;
dash.filterSentiment = ['positive','neutral','negative'][els[0].index];
dash.applyFilters();
}
}
});
},
async loadPhotos() {
try {
const r = await Cockpit.api('/photos?limit=40');
this.photos = r.photos || [];
} catch (e) {}
},
toggleLive() {
if (this.liveTimer) clearInterval(this.liveTimer);
if (this.liveRefresh) {
this.liveTimer = setInterval(() => {
this.loadPhotos();
this.refreshShare(false);
}, 15000);
}
},
async openPhoto(id) {
this.activePhoto = id;
try {
const r = await Cockpit.api('/photos/' + id + '/detections');
this.detections = r.detections || [];
this.activeItems = r.extracted_items || [];
this.activeOcr = r.ocr_text || '';
setTimeout(() => this.drawDetections(), 200);
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
drawDetections() {
const img = document.getElementById('detectionImg');
const canvas = document.getElementById('detectionCanvas');
if (!img || !canvas || !this.detections.length) return;
canvas.width = img.clientWidth;
canvas.height = img.clientHeight;
const sx = img.clientWidth / (img.naturalWidth || 1);
const sy = img.clientHeight / (img.naturalHeight || 1);
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#e8a838';
ctx.lineWidth = 1;
ctx.font = '9px sans-serif';
this.detections.slice(0, 150).forEach(b => {
ctx.strokeRect(b.x * sx, b.y * sy, b.w * sx, b.h * sy);
if (b.text && b.text.length < 12) ctx.fillText(b.text, b.x * sx, b.y * sy - 2);
});
},
exportPhoto(id) {
const a = document.createElement('a');
a.href = '/api/admin/photos/' + id + '/image.jpg';
a.download = 'foodlinkk-photo-' + id + '.jpg';
a.click();
},
async uploadPhoto(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const b64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result.split(',')[1]);
r.onerror = rej;
r.readAsDataURL(file);
});
try {
await Cockpit.api('/photos/analyze', {
method: 'POST',
body: JSON.stringify({ image_b64: b64, source: 'upload', filename: file.name }),
});
Cockpit.toast('Foto geanalyseerd en opgeslagen', 'success');
await this.loadPhotos();
} catch (e) { Cockpit.toast(e.message, 'error'); }
ev.target.value = '';
},
async loadNasImages() {
try {
const r = await fetch('/api/admin/documents/nas-images?limit=10');
const data = await r.json();
if (!data.items || !data.items.length) return Cockpit.toast('Geen afbeeldingen in NAS share', 'info');
Cockpit.toast(data.items.length + ' NAS afbeeldingen gevonden — analyseer via upload of Telegram', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
};
}
function wordSearch() {
return {
query: '', includeStopwords: false, results: topWords.slice(0, 20), loading: false,
async search() {
this.loading = true;
try {
const params = new URLSearchParams({ limit: '40', stopwords: this.includeStopwords ? 'true' : 'false' });
if (this.query.trim()) params.set('q', this.query.trim());
const res = await fetch('/api/admin/documents/words?' + params);
this.results = (await res.json()).items || [];
} catch (e) { Cockpit.toast('Woord zoeken mislukt', 'error'); }
finally { this.loading = false; }
},
init() { this.search(); }
};
}
</script>
<style>
.badge-positive { background: rgba(62,207,142,.2); color: #3ecf8e; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
.badge-neutral { background: rgba(107,114,128,.25); color: #cbd5e1; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
.badge-negative { background: rgba(239,68,68,.2); color: #ef4444; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
</style>
{% endblock %}