feat(C3): Document Inbox live — shows observations from Paperless-ngx webhook, setup instructions

This commit is contained in:
admin-valentin 2026-08-26 15:41:11 +00:00
parent daf5f43f01
commit 67133efb93

View file

@ -1,75 +1,148 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
import Link from 'next/link';
const SETUP_STEPS = [
{ n: 1, label: 'Paperless-ngx server activ', status: 'done', desc: 'Paperless-ngx rulează pe serverul Hetzner (port 8010).' },
{ n: 2, label: 'Webhook POST configurat', status: 'todo', desc: 'În Paperless-ngx → Settings → Workflows → adaugă webhook POST la ceo-api /v1/data-sources/paperless/webhook.' },
{ n: 3, label: 'Adapter ceo-api', status: 'todo', desc: 'Endpoint /v1/documents/inbox care consumă webhook-ul și creează documente în CEO OS.' },
{ n: 4, label: 'UI Document Inbox', status: 'planned', desc: 'Lista documentelor primite cu preview, tag automat, și flux de aprobare.' },
];
interface Observation {
id: string; metric: string; value: string; unit: string | null;
subjectType: string; source: string | null; confidence: number | null;
observedAt: string | null; createdAt: string;
}
const DOC_TYPES: Record<string, { icon: string; label: string }> = {
invoice: { icon: '🧾', label: 'Factură' },
contract: { icon: '📝', label: 'Contract' },
report: { icon: '📊', label: 'Raport' },
letter: { icon: '✉️', label: 'Scrisoare' },
document: { icon: '📄', label: 'Document' },
receipt: { icon: '🗝️', label: 'Chitanță' },
};
export default function DocumentInboxPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [typeFilter, setTypeFilter] = useState('all');
const { data: observations = [], isLoading } = useQuery({
queryKey: ['doc-inbox', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000,
refetchInterval: 60_000, // poll every 60s for new Paperless docs
});
const documents = useMemo(() =>
observations
.filter((o) => o.subjectType === 'document' || o.metric === 'document-received')
.sort((a, b) => (b.observedAt ?? b.createdAt).localeCompare(a.observedAt ?? a.createdAt)),
[observations]);
const typeCountMap = useMemo(() => {
const map: Record<string, number> = {};
for (const d of documents) {
const t = d.unit ?? 'document';
map[t] = (map[t] ?? 0) + 1;
}
return map;
}, [documents]);
const filtered = typeFilter === 'all'
? documents
: documents.filter((d) => d.unit === typeFilter);
function docTypeInfo(d: Observation) {
const key = (d.unit ?? 'document').toLowerCase();
return DOC_TYPES[key] ?? { icon: '📄', label: d.unit ?? 'Document' };
}
return (
<div className="max-w-3xl space-y-6 p-6">
<div>
<nav className="text-xs text-ink-faint mb-1">
<Link href="/dashboard/documents" className="hover:underline">Documente</Link> / Inbox
</nav>
<h1 className="font-display text-2xl font-semibold text-ink">Document Inbox</h1>
<p className="text-sm text-ink-faint mt-1">Documente scanate și procesate automat din Paperless-ngx.</p>
</div>
<div className="card p-4 bg-warn/5 border-warn/30 space-y-1">
<p className="text-xs font-semibold text-warn">Paperless-ngx neconectat</p>
<p className="text-xs text-ink-faint">
Paperless-ngx rulează pe server (port 8010) dar nu este conectat la ceo-api.
Configurează webhook-ul pentru a activa inbox-ul automat.
</p>
</div>
{/* Setup steps */}
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Pași de configurare</p>
{SETUP_STEPS.map((step) => (
<div key={step.n} className="card p-4 flex items-start gap-4">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 ${
step.status === 'done' ? 'bg-signal-ok/20 text-signal-ok' :
step.status === 'todo' ? 'bg-warn/20 text-warn' :
'bg-muted text-ink-faint'}`}>
{step.status === 'done' ? '✓' : step.n}
</div>
<div>
<p className="text-sm font-medium text-ink">{step.label}</p>
<p className="text-[10px] text-ink-faint mt-0.5">{step.desc}</p>
</div>
</div>
))}
</div>
{/* Architecture */}
<div className="card p-4 space-y-3">
<p className="text-xs font-semibold text-ink">Flux de date planificat</p>
<div className="space-y-1 text-[10px] text-ink-faint font-mono">
{[
'Scanner → Paperless-ngx (OCR + tagging)',
'Paperless-ngx → webhook POST → ceo-api /v1/documents/inbox',
'ceo-api → creare document în CEO OS (cu metadate Paperless)',
'CEO OS UI → Document Inbox (previzualizare, aprobare, taguri)',
].map((line, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-primary">{i < 3 ? '→' : '✓'}</span>
<span>{line}</span>
</div>
))}
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Document Inbox</h1>
<p className="text-sm text-ink-faint mt-1">
{documents.length} documente primite via Paperless-ngx
</p>
</div>
<Link href="/dashboard/intelligence" className="rounded-lg border px-3 py-1.5 text-sm text-ink hover:bg-muted/50">
🔌 Intelligence Hub
</Link>
</div>
<div className="flex gap-3 flex-wrap text-xs">
<Link href="/dashboard/documents" className="text-primary hover:underline"> Toate documentele</Link>
<Link href="/dashboard/integrations/documents" className="text-primary hover:underline">Integrare Paperless-ngx </Link>
<Link href="/dashboard/data/ingestion" className="text-primary hover:underline">Ingestie date </Link>
</div>
{/* Webhook setup info */}
{documents.length === 0 && !isLoading && (
<div className="card p-5 border-primary/20 bg-primary/5 space-y-3">
<p className="text-sm font-semibold text-ink">Configurare Paperless-ngx CEO OS</p>
<ol className="space-y-2 text-xs text-ink-faint list-decimal list-inside">
<li>
În Paperless-ngx Admin Settings Workflows Add Workflow trigger: Document Added
</li>
<li>
Action type: Webhook · URL: <code className="font-mono bg-muted px-1 rounded">https://boardmind.dev/api/webhooks/paperless</code>
</li>
<li>
Header opțional: <code className="font-mono bg-muted px-1 rounded">X-Paperless-Tenant: {tenantId}</code>
</li>
<li>
În Coolify ceo-api env: <code className="font-mono bg-muted px-1 rounded">PAPERLESS_WEBHOOK_SECRET=&lt;secret&gt;</code> (opțional pentru HMAC)
</li>
</ol>
<p className="text-[10px] text-ink-faint">
Test webhook: <code>curl -X POST https://boardmind.dev/api/webhooks/ping</code>
</p>
</div>
)}
{/* Type filters */}
{documents.length > 0 && (
<div className="flex flex-wrap gap-2">
<button onClick={() => setTypeFilter('all')}
className={`rounded-full px-3 py-1 text-xs border ${typeFilter === 'all' ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
Toate ({documents.length})
</button>
{Object.entries(typeCountMap).map(([t, c]) => {
const info = DOC_TYPES[t.toLowerCase()] ?? { icon: '📄', label: t };
return (
<button key={t} onClick={() => setTypeFilter(t)}
className={`rounded-full px-3 py-1 text-xs border ${typeFilter === t ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{info.icon} {info.label} ({c})
</button>
);
})}
</div>
)}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : filtered.length > 0 ? (
<div className="card divide-y divide-border/50">
{filtered.map((d) => {
const info = docTypeInfo(d);
const date = d.observedAt ?? d.createdAt;
return (
<div key={d.id} className="flex items-start gap-3 p-4">
<span className="text-2xl shrink-0">{info.icon}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink truncate">{d.value}</p>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
{d.source && <p className="text-xs text-ink-faint">{d.source}</p>}
<p className="text-xs text-ink-faint">
{new Date(date).toLocaleDateString('ro-RO', { day: 'numeric', month: 'short', year: 'numeric' })}
</p>
<span className="text-xs text-ink-faint">{info.label}</span>
</div>
</div>
</div>
);
})}
</div>
) : documents.length > 0 ? (
<div className="card p-8 text-center">
<p className="text-sm text-ink-faint">Niciun document de tipul selectat.</p>
</div>
) : null}
</div>
);
}