Files
breakpilot-compliance/admin-compliance/app/sdk/document-generator/_components/BulkGenerateModal.tsx
T
Benjamin Admin b515ab0c0a
CI / detect-changes (push) Successful in 7s
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 13s
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 2m19s
CI / test-go (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
feat(generator): "Generate-All" bulk mode for recommended documents
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) <noreply@anthropic.com>
2026-06-08 08:57:53 +02:00

434 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string, string>
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<string | null>(null)
const [rows, setRows] = useState<Row[]>([])
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<string, LegalTemplateResult>()
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 (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-white rounded-lg shadow-2xl w-[820px] max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<header className="px-5 py-3 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-800">Alle empfohlenen Dokumente generieren</h3>
<p className="text-xs text-gray-500 mt-0.5">
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.
</p>
</div>
<button className="text-gray-400 hover:text-gray-700 text-2xl" onClick={onClose}>×</button>
</header>
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="p-8 text-center text-sm text-gray-500">Lade Empfehlungen</div>
)}
{loadError && (
<div className="m-5 p-3 text-sm text-rose-800 bg-rose-50 border border-rose-200 rounded">
{loadError}
</div>
)}
{!loading && !loadError && rows.length === 0 && (
<div className="p-8 text-center text-sm text-gray-500">
Keine Empfehlungen für dieses Profil.
Stell sicher dass CompanyProfile + ComplianceScope ausgefüllt sind.
</div>
)}
{!loading && rows.length > 0 && (
<>
<div className="px-5 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs">
<div className="text-gray-600">
<b>{selectedCount}</b> von {rows.length} ausgewählt
{unmatchedCount > 0 && (
<span className="ml-2 text-amber-700">
({unmatchedCount} ohne Template kann nicht generiert werden)
</span>
)}
</div>
<div className="flex gap-1">
<button
className="px-2 py-1 border border-gray-300 rounded hover:bg-white"
onClick={() => setAll(true)}
disabled={running}
>
Alle wählen
</button>
<button
className="px-2 py-1 border border-gray-300 rounded hover:bg-white"
onClick={() => setAll(false)}
disabled={running}
>
Keine wählen
</button>
</div>
</div>
<ul className="divide-y divide-gray-100">
{rows.map((row, i) => (
<BulkRow key={row.item.rule_id} row={row} onToggle={() => toggle(i)} running={running} />
))}
</ul>
</>
)}
</div>
<footer className="px-5 py-3 border-t border-gray-200 bg-gray-50 flex items-center gap-3">
{summary ? (
<div className="text-sm text-gray-700">
<b className="text-emerald-700">{summary.done} erstellt</b>
{summary.skipped > 0 && <span className="ml-2 text-amber-700">· {summary.skipped} übersprungen</span>}
{summary.failed > 0 && <span className="ml-2 text-rose-700">· {summary.failed} fehlgeschlagen</span>}
</div>
) : (
<div className="text-xs text-gray-500">
Erzeugt {selectedCount} neue Drafts in der Document-Library.
</div>
)}
<button className="ml-auto px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800" onClick={onClose}>
Schließen
</button>
{!summary && (
<button
className="px-4 py-1.5 text-sm bg-emerald-600 text-white rounded hover:bg-emerald-700 disabled:opacity-50"
disabled={running || loading || selectedCount === 0}
onClick={runBulk}
>
{running ? 'Generiere…' : `${selectedCount} Dokumente generieren`}
</button>
)}
</footer>
</div>
</div>
)
}
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 <span className="text-amber-700"> generiere</span>
case 'done': return <span className="text-emerald-700"> erstellt</span>
case 'error': return <span className="text-rose-700" title={row.errorMessage}> Fehler</span>
case 'skipped': return <span className="text-gray-500"> übersprungen</span>
default: return null
}
})()
return (
<li className="px-5 py-2 flex items-start gap-3 hover:bg-gray-50">
<input
type="checkbox"
className="mt-1"
checked={row.selected}
onChange={onToggle}
disabled={!hasTemplate || running}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<ClassChip classification={cls} />
<span className="text-sm font-medium text-gray-800">{row.item.title}</span>
{!hasTemplate && (
<span className="px-1.5 py-0.5 text-xs rounded border bg-amber-50 text-amber-800 border-amber-300">
kein Template
</span>
)}
<span className="ml-auto text-xs">{stateBadge}</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
<code>{row.item.document_type}</code>
{row.item.source_citation && <> · {row.item.source_citation}</>}
</div>
{row.errorMessage && (
<div className="text-xs text-rose-700 mt-0.5">{row.errorMessage}</div>
)}
</div>
</li>
)
}
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 (
<span className={`px-1.5 py-0.5 text-xs rounded border ${map.cls}`}>{map.label}</span>
)
}
// ----- Render-Pipeline (Kopie aus GeneratorSection mit gleicher Logik) -----
function renderTemplate(
template: LegalTemplateResult,
context: TemplateContext,
extraPlaceholders: Record<string, string>,
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<void> {
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<string, unknown> {
const profile: Record<string, unknown> = {}
// 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
}