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
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>
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
'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
|
||
}
|