feat(C3): Document Inbox live — shows observations from Paperless-ngx webhook, setup instructions
This commit is contained in:
parent
daf5f43f01
commit
67133efb93
1 changed files with 135 additions and 62 deletions
|
|
@ -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=<secret></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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue