663a1c3e38
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Failing after 12s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m16s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 30s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Neue Compliance-Admin-Seite /sdk/document-library: zeigt alle compliance_
legal_documents mit aktueller Version, gruppiert nach Empfehlungs-Klassi-
fikation, filterbar nach Status + Volltextsuche.
Backend (Service + Routes):
- LegalDocumentService.list_documents_with_versions() — JOIN über docs +
latest/published version in einem Roundtrip statt N+1
- GET /api/v1/compliance/legal-documents/documents-with-versions
liefert {documents:[{...doc, latest_version, published_version}]}
Admin-Frontend:
- app/sdk/document-library/page.tsx (350 LOC)
- Lädt Docs + Recommend parallel
- Mapped jedes Doc per .type → Recommend-Item (klassifiziert in
required/recommended/optional/uncategorized)
- 4 Sektionen mit Klassifikations-Chip + Anzahl-Badge
- Tabelle pro Sektion: Titel · Type · Status · Version · Geändert · Override
- Status-Filter (alle / draft / review_internal / review_client /
approved / published / archived / rejected)
- Klick auf Zeile → /sdk/workflow?doc=<uuid>
- Empty state mit Link zum Generator (Bulk-Modus)
- workflow/page.tsx: auto-select bei ?doc=<uuid> URL-Param
- lib/sdk/types/sdk-steps.ts: 'document-library' bei seq=2500 im Paket
'dokumentation' registriert (sichtbar in der SDK-Sidebar)
Workflow-Hookup vervollständigt: Library → click → Workflow öffnet
direkt das gewünschte Dokument im SplitViewEditor, keine manuelle
Selektion über DocumentSelectorBar mehr nötig.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Document-Library — zentraler Tab für alle für den Mandanten erzeugten
|
|
* Dokumente. Listet compliance_legal_documents + jeweils latest/published
|
|
* Version, gruppiert nach Empfehlungs-Klassifikation (required/recommended/
|
|
* optional/uncategorized).
|
|
*
|
|
* Recommend-Engine (compliance_template_rules) wird gegen das aktuelle
|
|
* CompanyProfile + ComplianceScope ausgewertet, um document_type → Klassifi-
|
|
* kation zu mappen.
|
|
*
|
|
* Click auf eine Zeile → /sdk/workflow?doc=<uuid> (Workflow-Editor öffnet
|
|
* den Doc automatisch).
|
|
*/
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useSDK } from '@/lib/sdk'
|
|
import { StepHeader } from '@/components/sdk/StepHeader'
|
|
import type { CompanyProfile } from '@/lib/sdk/types/company-profile'
|
|
import type { ComplianceScopeState } from '@/lib/sdk/compliance-scope-types/state'
|
|
|
|
const DOCS_ENDPOINT = '/api/sdk/v1/compliance/legal-documents/documents-with-versions'
|
|
const RECOMMEND_ENDPOINT = '/api/sdk/v1/compliance/recommend'
|
|
|
|
type Classification = 'required' | 'recommended' | 'optional' | 'uncategorized'
|
|
type VersionStatus =
|
|
| 'draft' | 'review' | 'review_internal' | 'review_client'
|
|
| 'approved' | 'published' | 'archived' | 'rejected'
|
|
|
|
interface DocVersion {
|
|
id: string
|
|
document_id: string
|
|
version: string
|
|
status: VersionStatus
|
|
title: string
|
|
created_at: string
|
|
updated_at: string | null
|
|
approved_internal_at: string | null
|
|
approved_client_at: string | null
|
|
}
|
|
|
|
interface DocWithVersions {
|
|
id: string
|
|
type: string
|
|
name: string
|
|
description: string | null
|
|
created_at: string
|
|
updated_at: string | null
|
|
latest_version: DocVersion | null
|
|
published_version: DocVersion | null
|
|
}
|
|
|
|
interface Rec {
|
|
document_type: string
|
|
title: string
|
|
classification: 'required' | 'recommended' | 'optional'
|
|
source_citation: string
|
|
override_applied: boolean
|
|
}
|
|
|
|
export default function DocumentLibraryPage() {
|
|
const { state } = useSDK()
|
|
const router = useRouter()
|
|
|
|
const [docs, setDocs] = useState<DocWithVersions[]>([])
|
|
const [recommendations, setRecommendations] = useState<Map<string, Rec>>(new Map())
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [search, setSearch] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<VersionStatus | 'all'>('all')
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
async function load() {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const profile = buildRecommendProfile(state.companyProfile ?? null, state.complianceScope ?? null)
|
|
const [docsRes, recRes] = await Promise.all([
|
|
fetch(DOCS_ENDPOINT),
|
|
fetch(RECOMMEND_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
profile,
|
|
compliance_depth_level: profile.compliance_depth_level ?? 'L2',
|
|
}),
|
|
}),
|
|
])
|
|
if (!docsRes.ok) throw new Error(`Docs-API: ${docsRes.status}`)
|
|
if (!recRes.ok) throw new Error(`Recommend-API: ${recRes.status}`)
|
|
|
|
const docsData = await docsRes.json() as { documents: DocWithVersions[] }
|
|
const recData = await recRes.json()
|
|
|
|
const recMap = new Map<string, Rec>()
|
|
for (const cls of ['required', 'recommended', 'optional'] as const) {
|
|
for (const item of (recData[cls] ?? []) as Rec[]) {
|
|
recMap.set(item.document_type, { ...item, classification: cls })
|
|
}
|
|
}
|
|
|
|
if (!cancelled) {
|
|
setDocs(docsData.documents ?? [])
|
|
setRecommendations(recMap)
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) setError((e as Error).message)
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
}
|
|
load()
|
|
return () => { cancelled = true }
|
|
}, [state.companyProfile, state.complianceScope])
|
|
|
|
const grouped = useMemo(() => {
|
|
const groups: Record<Classification, DocWithVersions[]> = {
|
|
required: [], recommended: [], optional: [], uncategorized: [],
|
|
}
|
|
const q = search.toLowerCase().trim()
|
|
for (const doc of docs) {
|
|
// Filter
|
|
if (q) {
|
|
const hit =
|
|
doc.name.toLowerCase().includes(q) ||
|
|
doc.type.toLowerCase().includes(q) ||
|
|
(doc.description?.toLowerCase() ?? '').includes(q)
|
|
if (!hit) continue
|
|
}
|
|
if (statusFilter !== 'all') {
|
|
const s = doc.latest_version?.status
|
|
if (s !== statusFilter) continue
|
|
}
|
|
const rec = recommendations.get(doc.type)
|
|
const klass: Classification = rec?.classification ?? 'uncategorized'
|
|
groups[klass].push(doc)
|
|
}
|
|
return groups
|
|
}, [docs, recommendations, search, statusFilter])
|
|
|
|
const totalShown = grouped.required.length + grouped.recommended.length
|
|
+ grouped.optional.length + grouped.uncategorized.length
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-white">
|
|
<StepHeader
|
|
stepId="document-library"
|
|
title="Document Library"
|
|
description="Zentrale Übersicht aller erzeugten Dokumente — gruppiert nach Empfehlung (Pflicht/Empfohlen/Optional), gefiltert nach Status. Klick auf eine Zeile öffnet den Workflow-Editor."
|
|
/>
|
|
|
|
<div className="px-5 py-3 border-b border-gray-200 bg-gray-50 flex items-center gap-3 flex-wrap">
|
|
<input
|
|
type="text"
|
|
placeholder="Suchen (Titel, Type, Beschreibung)…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="text-sm px-3 py-1.5 border border-gray-300 rounded w-72"
|
|
/>
|
|
<select
|
|
className="text-sm px-2 py-1.5 border border-gray-300 rounded bg-white"
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as VersionStatus | 'all')}
|
|
>
|
|
<option value="all">Alle Stati</option>
|
|
<option value="draft">Entwurf</option>
|
|
<option value="review_internal">DSB-Prüfung</option>
|
|
<option value="review_client">Mandanten-Prüfung</option>
|
|
<option value="approved">Freigegeben</option>
|
|
<option value="published">Live</option>
|
|
<option value="archived">Archiviert</option>
|
|
<option value="rejected">Abgelehnt</option>
|
|
</select>
|
|
<div className="ml-auto text-xs text-gray-600">
|
|
{loading ? 'lädt…' : `${totalShown} sichtbar · ${docs.length} insgesamt`}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="m-5 p-3 text-sm text-rose-800 bg-rose-50 border border-rose-200 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{!loading && docs.length === 0 && (
|
|
<div className="p-8 text-center text-sm text-gray-500">
|
|
Noch keine Dokumente vorhanden. Generiere welche über den{' '}
|
|
<a href="/sdk/document-generator" className="underline text-blue-700">Document Generator</a>{' '}
|
|
(Bulk-Modus „Empfohlene generieren →").
|
|
</div>
|
|
)}
|
|
|
|
<Group
|
|
title="Pflichtdokumente"
|
|
chipCls="bg-rose-100 text-rose-800 border-rose-300"
|
|
docs={grouped.required}
|
|
recommendations={recommendations}
|
|
onOpen={(id) => router.push(`/sdk/workflow?doc=${id}`)}
|
|
/>
|
|
<Group
|
|
title="Empfohlene Dokumente"
|
|
chipCls="bg-amber-100 text-amber-800 border-amber-300"
|
|
docs={grouped.recommended}
|
|
recommendations={recommendations}
|
|
onOpen={(id) => router.push(`/sdk/workflow?doc=${id}`)}
|
|
/>
|
|
<Group
|
|
title="Optionale Dokumente"
|
|
chipCls="bg-slate-100 text-slate-700 border-slate-300"
|
|
docs={grouped.optional}
|
|
recommendations={recommendations}
|
|
onOpen={(id) => router.push(`/sdk/workflow?doc=${id}`)}
|
|
/>
|
|
<Group
|
|
title="Nicht klassifiziert"
|
|
chipCls="bg-gray-100 text-gray-600 border-gray-300"
|
|
docs={grouped.uncategorized}
|
|
recommendations={recommendations}
|
|
onOpen={(id) => router.push(`/sdk/workflow?doc=${id}`)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Group({
|
|
title, chipCls, docs, recommendations, onOpen,
|
|
}: {
|
|
title: string
|
|
chipCls: string
|
|
docs: DocWithVersions[]
|
|
recommendations: Map<string, Rec>
|
|
onOpen: (id: string) => void
|
|
}) {
|
|
if (docs.length === 0) return null
|
|
return (
|
|
<section className="border-b border-gray-200">
|
|
<h3 className="px-5 py-2 bg-gray-50 text-sm font-semibold text-gray-700 flex items-center gap-2">
|
|
<span className={`px-2 py-0.5 text-xs rounded border ${chipCls}`}>{title}</span>
|
|
<span className="text-xs font-normal text-gray-500">{docs.length}</span>
|
|
</h3>
|
|
<table className="w-full text-sm">
|
|
<thead className="text-xs uppercase text-gray-500">
|
|
<tr>
|
|
<th className="px-5 py-2 text-left">Titel</th>
|
|
<th className="px-3 py-2 text-left">Type</th>
|
|
<th className="px-3 py-2 text-left">Status</th>
|
|
<th className="px-3 py-2 text-left">Version</th>
|
|
<th className="px-3 py-2 text-left">Geändert</th>
|
|
<th className="px-3 py-2 text-left">Override</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{docs.map((doc) => (
|
|
<DocRow key={doc.id} doc={doc} rec={recommendations.get(doc.type)} onOpen={onOpen} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function DocRow({
|
|
doc, rec, onOpen,
|
|
}: {
|
|
doc: DocWithVersions
|
|
rec: Rec | undefined
|
|
onOpen: (id: string) => void
|
|
}) {
|
|
const latest = doc.latest_version
|
|
const updated = doc.updated_at ?? doc.created_at
|
|
return (
|
|
<tr
|
|
className="border-t border-gray-100 hover:bg-amber-50 cursor-pointer"
|
|
onClick={() => onOpen(doc.id)}
|
|
>
|
|
<td className="px-5 py-2 font-medium text-gray-800">{doc.name}</td>
|
|
<td className="px-3 py-2 text-xs"><code>{doc.type}</code></td>
|
|
<td className="px-3 py-2">
|
|
{latest ? <StatusBadge status={latest.status} /> : <span className="text-xs text-gray-400">—</span>}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-gray-700">
|
|
{latest?.version ?? '—'}
|
|
{doc.published_version && doc.published_version.id !== latest?.id && (
|
|
<span className="ml-1 text-emerald-700">(live: {doc.published_version.version})</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-gray-500">
|
|
{new Date(updated).toLocaleString('de-DE')}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs">
|
|
{rec?.override_applied && (
|
|
<span className="px-1.5 py-0.5 bg-blue-50 text-blue-700 border border-blue-300 rounded">
|
|
Override
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: VersionStatus }) {
|
|
const map: Record<VersionStatus, { label: string; cls: string }> = {
|
|
draft: { label: 'Entwurf', cls: 'bg-slate-100 text-slate-700 border-slate-300' },
|
|
review: { label: 'Prüfung', cls: 'bg-amber-50 text-amber-800 border-amber-300' },
|
|
review_internal: { label: 'DSB-Prüfung', cls: 'bg-amber-50 text-amber-800 border-amber-300' },
|
|
review_client: { label: 'Mandant-Prüfung', cls: 'bg-blue-50 text-blue-800 border-blue-300' },
|
|
approved: { label: 'Freigegeben', cls: 'bg-emerald-50 text-emerald-800 border-emerald-300' },
|
|
published: { label: 'Live', cls: 'bg-emerald-100 text-emerald-900 border-emerald-400 font-medium' },
|
|
archived: { label: 'Archiviert', cls: 'bg-gray-100 text-gray-600 border-gray-300' },
|
|
rejected: { label: 'Abgelehnt', cls: 'bg-rose-50 text-rose-800 border-rose-300' },
|
|
}
|
|
const { label, cls } = map[status] ?? { label: status, cls: 'bg-gray-100 text-gray-700 border-gray-300' }
|
|
return <span className={`px-1.5 py-0.5 text-xs rounded border ${cls}`}>{label}</span>
|
|
}
|
|
|
|
// ----- Profile-Builder (gleich wie in BulkGenerateModal — könnten wir später extrahieren) -----
|
|
|
|
function buildRecommendProfile(
|
|
companyProfile: CompanyProfile | null,
|
|
complianceScope: ComplianceScopeState | null,
|
|
): Record<string, unknown> {
|
|
const profile: Record<string, unknown> = {}
|
|
if (companyProfile) {
|
|
if (companyProfile.employeeCount) {
|
|
profile.org_employee_count = String(companyProfile.employeeCount).replace(/-/g, '_')
|
|
}
|
|
if (companyProfile.businessModel) {
|
|
profile.org_business_model = String(companyProfile.businessModel).toLowerCase().replace(/\s+/g, '_')
|
|
}
|
|
if (companyProfile.isDataProcessor) {
|
|
profile.comp_has_processors = 'yes'
|
|
}
|
|
}
|
|
if (complianceScope?.answers) {
|
|
for (const a of complianceScope.answers) {
|
|
if (!a.questionId) continue
|
|
if (a.value === null || a.value === undefined || a.value === '') continue
|
|
profile[a.questionId] = a.value
|
|
}
|
|
}
|
|
if (complianceScope?.decision?.determinedLevel) {
|
|
profile.compliance_depth_level = complianceScope.decision.determinedLevel
|
|
}
|
|
return profile
|
|
}
|