From b515ab0c0a841c11239c746531b6432e41f3455e Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 8 Jun 2026 08:57:53 +0200 Subject: [PATCH] feat(generator): "Generate-All" bulk mode for recommended documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the workspace-cutover initiative: the Document Generator gets a Bulk-Generate mode that produces every recommended document in one click instead of forcing the user through 25+ per-template clicks. New: BulkGenerateModal.tsx (430 LOC) - On open: POSTs current CompanyProfile + ComplianceScope answers to /api/sdk/v1/compliance/recommend (Phase 1 endpoint) - Matches each recommendation's document_type against allTemplates - Shows tabular list: classification chip, title, document_type, source citation; checkboxes pre-selected for required+recommended (only where a template exists) - On submit: sequentially renders each selected template using the same pipeline as GeneratorSection (runRuleset → applyBlockRemoval → applyConditionalBlocks → placeholder replace), then POSTs documents + version v1.0 draft - Per-row progress: ⏳ generiere → ✓ erstellt / ✗ Fehler / — übersprungen; final summary counts page.tsx: - Imports BulkGenerateModal - Adds prominent "Empfohlene generieren →" CTA above the RecommendedDocuments block - Wires SDK state (companyProfile, complianceScope) into the modal Profile mapper: - CompanyProfile (camelCase): employeeCount, businessModel, isDataProcessor → org_employee_count, org_business_model, comp_has_processors - ComplianceScope answers (questionId/value): pass through 1:1 since the rule system uses the same field names as the wizard - compliance_depth_level pulled from decision.determinedLevel End-to-end flow: 1. User completes CompanyProfile + ComplianceScope 2. Clicks "Empfohlene generieren →" 3. Reviews 25-30 prefilled checkboxes 4. Clicks "Generieren" — modal iterates, all docs land as drafts in compliance_legal_documents + version v1.0 5. Phase 3 (next): document-library tab makes them findable 6. Phase 4 (next-next): workspace consumes these directly Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_components/BulkGenerateModal.tsx | 433 ++++++++++++++++++ .../app/sdk/document-generator/page.tsx | 31 ++ 2 files changed, 464 insertions(+) create mode 100644 admin-compliance/app/sdk/document-generator/_components/BulkGenerateModal.tsx diff --git a/admin-compliance/app/sdk/document-generator/_components/BulkGenerateModal.tsx b/admin-compliance/app/sdk/document-generator/_components/BulkGenerateModal.tsx new file mode 100644 index 00000000..92472291 --- /dev/null +++ b/admin-compliance/app/sdk/document-generator/_components/BulkGenerateModal.tsx @@ -0,0 +1,433 @@ +'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 +} diff --git a/admin-compliance/app/sdk/document-generator/page.tsx b/admin-compliance/app/sdk/document-generator/page.tsx index a8e850b7..9c0b01f5 100644 --- a/admin-compliance/app/sdk/document-generator/page.tsx +++ b/admin-compliance/app/sdk/document-generator/page.tsx @@ -16,6 +16,7 @@ import TemplateLibrary from './_components/TemplateLibrary' import GeneratorSection from './_components/GeneratorSection' import RecommendedDocuments from './_components/RecommendedDocuments' import { LifecycleFilter, filterTemplatesByStage } from './_components/LifecycleFilter' +import BulkGenerateModal from './_components/BulkGenerateModal' import type { LifecycleStage } from '@/lib/sdk/founding/template-categories' function DocumentGeneratorPageInner() { @@ -39,6 +40,7 @@ function DocumentGeneratorPageInner() { const generatorRef = useRef(null) const [totalCount, setTotalCount] = useState(0) + const [showBulkGenerate, setShowBulkGenerate] = useState(false) // Load all templates on mount useEffect(() => { @@ -332,6 +334,23 @@ function DocumentGeneratorPageInner() { countsByStage={countsByStage} /> + {/* Bulk-Generate-Knopf — alle empfohlenen Dokumente in einem Rutsch */} +
    +
    + Alle empfohlenen Dokumente in einem Rutsch generieren. +
    + Profil + Scope-Antworten werden gegen die Empfehlungs-Regeln ausgewertet — + markierte Templates werden als Drafts v1.0 in die Document-Library angelegt. +
    +
    + +
    + {/* Recommended documents based on scope profile */} )} + + {showBulkGenerate && ( + setShowBulkGenerate(false)} + /> + )} ) }