'use client' /** * Bulk-Generate-Modal: ruft den Compliance-Recommend-Endpoint mit dem aktuellen * Profil/Scope-Stand, matched die empfohlenen Dokumenttypen gegen die geladenen * Templates, und rendert + speichert alle markierten Dokumente in einem Rutsch * (als compliance_legal_documents + version v1.0 draft). * * Verwendet die existierende Render-Pipeline aus GeneratorSection.tsx: * runRuleset -> applyBlockRemoval -> applyConditionalBlocks -> placeholder-Replace */ import { useEffect, useMemo, useState } from 'react' import { applyBlockRemoval, applyConditionalBlocks, buildBoolContext, getDocType, runRuleset, } from '../ruleEngine' import { contextToPlaceholders, type TemplateContext } from '../contextBridge' import type { LegalTemplateResult } from '@/lib/sdk/types' import type { CompanyProfile } from '@/lib/sdk/types/company-profile' import type { ComplianceScopeState } from '@/lib/sdk/compliance-scope-types/state' const RECOMMEND_ENDPOINT = '/api/sdk/v1/compliance/recommend' const DOC_ENDPOINT = '/api/sdk/v1/compliance/legal-documents/documents' const VERSION_ENDPOINT = '/api/sdk/v1/compliance/legal-documents/versions' interface RecommendedItem { document_type: string title: string rule_id: string rule_key: string classification: 'required' | 'recommended' | 'optional' base_classification: 'required' | 'recommended' | 'optional' source_citation: string reason: string override_applied: boolean } interface RecommendationResult { required: RecommendedItem[] recommended: RecommendedItem[] optional: RecommendedItem[] } interface Row { item: RecommendedItem template: LegalTemplateResult | undefined selected: boolean state: 'idle' | 'generating' | 'done' | 'skipped' | 'error' errorMessage?: string } interface Props { allTemplates: LegalTemplateResult[] context: TemplateContext extraPlaceholders: Record enabledModules: string[] companyProfile: CompanyProfile | null complianceScope: ComplianceScopeState | null onClose: () => void } export default function BulkGenerateModal({ allTemplates, context, extraPlaceholders, enabledModules, companyProfile, complianceScope, onClose, }: Props) { const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [rows, setRows] = useState([]) const [running, setRunning] = useState(false) const [summary, setSummary] = useState<{ done: number; skipped: number; failed: number } | null>(null) const recommendProfile = useMemo( () => buildRecommendProfile(companyProfile, complianceScope), [companyProfile, complianceScope], ) // Templates nach document_type indizieren — ein Document_type hat oft nur EIN Template const templatesByType = useMemo(() => { const map = new Map() for (const t of allTemplates) { if (t.templateType && !map.has(t.templateType)) { map.set(t.templateType, t) } } return map }, [allTemplates]) // Recommend abrufen sobald das Modal geöffnet ist useEffect(() => { let cancelled = false async function load() { setLoading(true) setLoadError(null) try { const res = await fetch(RECOMMEND_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile: recommendProfile, compliance_depth_level: recommendProfile.compliance_depth_level ?? 'L2', }), }) if (!res.ok) throw new Error(`Recommend-API: ${res.status}`) const data = (await res.json()) as RecommendationResult if (cancelled) return const all: RecommendedItem[] = [...data.required, ...data.recommended, ...data.optional] const newRows: Row[] = all.map((item) => ({ item, template: templatesByType.get(item.document_type), // Default: required + recommended sind aktiv, optional inaktiv, // und ohne Template generell deaktiviert selected: item.classification !== 'optional' && templatesByType.has(item.document_type), state: 'idle', })) setRows(newRows) } catch (e) { if (!cancelled) setLoadError((e as Error).message) } finally { if (!cancelled) setLoading(false) } } load() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const selectedCount = rows.filter((r) => r.selected && r.template).length const unmatchedCount = rows.filter((r) => !r.template).length function toggle(i: number) { setRows((rs) => rs.map((r, idx) => idx === i ? { ...r, selected: !r.selected } : r)) } function setAll(selected: boolean) { setRows((rs) => rs.map((r) => r.template ? { ...r, selected } : r)) } async function runBulk() { setRunning(true) setSummary(null) let done = 0 let failed = 0 let skipped = 0 for (let i = 0; i < rows.length; i++) { const row = rows[i] if (!row.selected) continue if (!row.template) { skipped++; continue } setRows((rs) => rs.map((r, idx) => idx === i ? { ...r, state: 'generating' } : r)) try { const rendered = renderTemplate(row.template, context, extraPlaceholders, enabledModules) await saveDocAndVersion(row.template, rendered) setRows((rs) => rs.map((r, idx) => idx === i ? { ...r, state: 'done' } : r)) done++ } catch (e) { setRows((rs) => rs.map((r, idx) => idx === i ? { ...r, state: 'error', errorMessage: (e as Error).message } : r, )) failed++ } } setSummary({ done, skipped, failed }) setRunning(false) } return (
e.stopPropagation()} >

Alle empfohlenen Dokumente generieren

Compliance-Recommend wertet das aktuelle CompanyProfile + ComplianceScope aus und schlägt Vorlagen vor. Markierte werden client-seitig gerendert und als Drafts v1.0 in der Document-Library angelegt.

{loading && (
Lade Empfehlungen…
)} {loadError && (
{loadError}
)} {!loading && !loadError && rows.length === 0 && (
Keine Empfehlungen für dieses Profil. Stell sicher dass CompanyProfile + ComplianceScope ausgefüllt sind.
)} {!loading && rows.length > 0 && ( <>
{selectedCount} von {rows.length} ausgewählt {unmatchedCount > 0 && ( ({unmatchedCount} ohne Template — kann nicht generiert werden) )}
    {rows.map((row, i) => ( toggle(i)} running={running} /> ))}
)}
{summary ? (
{summary.done} erstellt {summary.skipped > 0 && · {summary.skipped} übersprungen} {summary.failed > 0 && · {summary.failed} fehlgeschlagen}
) : (
Erzeugt {selectedCount} neue Drafts in der Document-Library.
)} {!summary && ( )}
) } function BulkRow({ row, onToggle, running }: { row: Row; onToggle: () => void; running: boolean }) { const hasTemplate = !!row.template const cls = row.item.classification const stateBadge = (() => { switch (row.state) { case 'generating': return ⏳ generiere… case 'done': return ✓ erstellt case 'error': return ✗ Fehler case 'skipped': return — übersprungen default: return null } })() return (
  • {row.item.title} {!hasTemplate && ( kein Template )} {stateBadge}
    {row.item.document_type} {row.item.source_citation && <> · {row.item.source_citation}}
    {row.errorMessage && (
    {row.errorMessage}
    )}
  • ) } function ClassChip({ classification }: { classification: 'required' | 'recommended' | 'optional' }) { const map = { required: { label: 'Pflicht', cls: 'bg-rose-100 text-rose-800 border-rose-300' }, recommended: { label: 'Empfohlen', cls: 'bg-amber-100 text-amber-800 border-amber-300' }, optional: { label: 'Optional', cls: 'bg-slate-100 text-slate-700 border-slate-300' }, }[classification] return ( {map.label} ) } // ----- Render-Pipeline (Kopie aus GeneratorSection mit gleicher Logik) ----- function renderTemplate( template: LegalTemplateResult, context: TemplateContext, extraPlaceholders: Record, enabledModules: string[], ): string { const ruleResult = runRuleset({ doc_type: getDocType(template.templateType ?? '', template.language ?? 'de'), render: { lang: template.language ?? 'de', variant: 'standard' }, context, modules: { enabled: enabledModules }, }) const allValues = { ...contextToPlaceholders(ruleResult?.contextAfterDefaults ?? context), ...extraPlaceholders, } const boolCtx = ruleResult ? buildBoolContext(ruleResult.contextAfterDefaults, ruleResult.computedFlags) : {} let content = applyBlockRemoval(template.text, ruleResult?.removedBlocks ?? []) content = applyConditionalBlocks(content, boolCtx) for (const [key, value] of Object.entries(allValues)) { if (value) { content = content.replace( new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value, ) } } return content } async function saveDocAndVersion( template: LegalTemplateResult, renderedContent: string, ): Promise { const docRes = await fetch(DOC_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: template.templateType || 'custom', name: template.documentTitle || 'Dokument', description: `Bulk-generiert aus Template ${template.templateType}`, }), }) if (!docRes.ok) { throw new Error(`Document anlegen fehlgeschlagen: ${docRes.status} ${await docRes.text().catch(() => '')}`) } const doc = await docRes.json() const verRes = await fetch(VERSION_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_id: doc.id, title: template.documentTitle || 'Dokument', content: renderedContent, language: template.language || 'de', version: '1.0', }), }) if (!verRes.ok) { throw new Error(`Version anlegen fehlgeschlagen: ${verRes.status} ${await verRes.text().catch(() => '')}`) } } // ----- Profile-Builder: SDK-State → /recommend Body ----- function buildRecommendProfile( companyProfile: CompanyProfile | null, complianceScope: ComplianceScopeState | null, ): Record { const profile: Record = {} // Aus CompanyProfile (camelCase TS-Modell) 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' } } // ComplianceScope-Antworten: questionId entspricht direkt unserer Profil- // Feld-Konvention (proc_ai_usage, tech_third_country, prod_webshop, etc.) 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 }