From 4c92b17617b151e8dacdf650477a905fd75a5b9d Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 3 May 2026 13:09:32 +0200 Subject: [PATCH] feat: Rollenkonzept module + Document Generator review integration (Phase 4-5) - New /sdk/rollenkonzept/ module with 3 tabs (Rollen, Zuordnung, Reviews) - 7 standard compliance roles (DSB, GF, IT-Leiter, HR, Marketing, Compliance, Einkauf) - Inline role editing with test email via Mailpit - Document-to-role mapping table (editable per tenant) - Review list with status filters and approve/reject workflow - ReviewAssignmentPanel in Document Generator preview tab - "Zur Pruefung senden" button creates reviews + sends notification emails - Approval notification sent to all affected roles after document sign-off - Sidebar navigation link added Co-Authored-By: Claude Opus 4.6 (1M context) --- .../_components/GeneratorPreviewTab.tsx | 8 + .../_components/ReviewAssignmentPanel.tsx | 170 ++++++++++++++++++ .../rollenkonzept/_components/ReviewList.tsx | 129 +++++++++++++ .../rollenkonzept/_components/RoleCard.tsx | 106 +++++++++++ .../_hooks/useDocumentReviews.ts | 69 +++++++ .../sdk/rollenkonzept/_hooks/useOrgRoles.ts | 84 +++++++++ .../app/sdk/rollenkonzept/_types.ts | 75 ++++++++ .../app/sdk/rollenkonzept/page.tsx | 140 +++++++++++++++ .../sdk/Sidebar/SidebarModuleList.tsx | 1 + 9 files changed, 782 insertions(+) create mode 100644 admin-compliance/app/sdk/document-generator/_components/ReviewAssignmentPanel.tsx create mode 100644 admin-compliance/app/sdk/rollenkonzept/_components/ReviewList.tsx create mode 100644 admin-compliance/app/sdk/rollenkonzept/_components/RoleCard.tsx create mode 100644 admin-compliance/app/sdk/rollenkonzept/_hooks/useDocumentReviews.ts create mode 100644 admin-compliance/app/sdk/rollenkonzept/_hooks/useOrgRoles.ts create mode 100644 admin-compliance/app/sdk/rollenkonzept/_types.ts create mode 100644 admin-compliance/app/sdk/rollenkonzept/page.tsx diff --git a/admin-compliance/app/sdk/document-generator/_components/GeneratorPreviewTab.tsx b/admin-compliance/app/sdk/document-generator/_components/GeneratorPreviewTab.tsx index cee658c..0478845 100644 --- a/admin-compliance/app/sdk/document-generator/_components/GeneratorPreviewTab.tsx +++ b/admin-compliance/app/sdk/document-generator/_components/GeneratorPreviewTab.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { LegalTemplateResult } from '@/lib/sdk/types' import { RuleEngineResult } from '../ruleEngine' +import ReviewAssignmentPanel from './ReviewAssignmentPanel' interface GeneratorPreviewTabProps { template: LegalTemplateResult @@ -243,6 +244,13 @@ export default function GeneratorPreviewTab({ )} + {/* Review Assignment */} + + {/* Attribution */} {template.attributionRequired && template.attributionText && (
diff --git a/admin-compliance/app/sdk/document-generator/_components/ReviewAssignmentPanel.tsx b/admin-compliance/app/sdk/document-generator/_components/ReviewAssignmentPanel.tsx new file mode 100644 index 0000000..4aa2a94 --- /dev/null +++ b/admin-compliance/app/sdk/document-generator/_components/ReviewAssignmentPanel.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useSDK } from '@/lib/sdk' + +interface ReviewerInfo { + role_key: string + role_label?: string + person_name?: string | null + person_email?: string | null + is_primary?: boolean +} + +interface ReviewRecord { + id: string + status: string + reviewer_role_key: string + reviewer_name: string | null + email_sent: boolean +} + +const STATUS_COLORS: Record = { + pending: 'bg-gray-100 text-gray-700', + in_review: 'bg-blue-100 text-blue-700', + approved: 'bg-green-100 text-green-700', + rejected: 'bg-red-100 text-red-700', +} + +const STATUS_LABELS: Record = { + pending: 'Ausstehend', + in_review: 'In Pruefung', + approved: 'Freigegeben', + rejected: 'Abgelehnt', +} + +export default function ReviewAssignmentPanel({ + documentType, + documentTitle, + documentContent, +}: { + documentType: string + documentTitle: string + documentContent: string +}) { + const { projectId } = useSDK() + const [reviewers, setReviewers] = useState([]) + const [existingReviews, setExistingReviews] = useState([]) + const [sending, setSending] = useState(false) + const [result, setResult] = useState(null) + + // Load reviewers for this document type + useEffect(() => { + if (!documentType) return + const qs = new URLSearchParams() + if (projectId) qs.set('project_id', projectId) + qs.set('document_type', documentType) + + // Load mapping + existing reviews + Promise.all([ + fetch(`/api/sdk/v1/compliance/org-roles/mapping`).then(r => r.ok ? r.json() : []), + fetch(`/api/sdk/v1/compliance/org-roles${projectId ? `?project_id=${projectId}` : ''}`).then(r => r.ok ? r.json() : []), + fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.ok ? r.json() : []), + ]).then(([mappings, roles, reviews]) => { + // Filter mappings for this document type + const relevant = (mappings as Array<{ document_type: string; role_key: string; is_primary: boolean }>) + .filter(m => m.document_type === documentType) + // Enrich with role info + const enriched: ReviewerInfo[] = relevant.map(m => { + const role = (roles as Array<{ role_key: string; role_label: string; person_name: string | null; person_email: string | null }>) + .find(r => r.role_key === m.role_key) + return { ...m, role_label: role?.role_label, person_name: role?.person_name, person_email: role?.person_email } + }) + setReviewers(enriched) + setExistingReviews(reviews) + }).catch(() => {}) + }, [documentType, projectId]) + + const handleSendForReview = async () => { + setSending(true) + setResult(null) + try { + const res = await fetch('/api/sdk/v1/compliance/document-reviews', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + document_type: documentType, + document_title: documentTitle, + document_content: documentContent, + project_id: projectId, + review_link: window.location.href, + }), + }) + if (!res.ok) throw new Error('Fehler beim Erstellen') + const reviews = await res.json() + + // Send email for each review + let sentCount = 0 + for (const review of reviews) { + if (review.reviewer_email) { + const sendRes = await fetch(`/api/sdk/v1/compliance/document-reviews/${review.id}/send`, { method: 'POST' }) + if (sendRes.ok) sentCount++ + } + } + setResult(`${reviews.length} Review(s) erstellt, ${sentCount} E-Mail(s) gesendet`) + // Refresh + const qs = new URLSearchParams({ document_type: documentType }) + if (projectId) qs.set('project_id', projectId) + const updated = await fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.json()) + setExistingReviews(updated) + } catch (e) { + setResult(e instanceof Error ? e.message : 'Fehler') + } finally { + setSending(false) + } + } + + if (reviewers.length === 0 && existingReviews.length === 0) return null + + return ( +
+

+ + + + Pruefung & Freigabe +

+ + {/* Assigned reviewers */} + {reviewers.length > 0 && ( +
+ {reviewers.map(r => ( +
+ {r.role_label || r.role_key}: + {r.person_name ? ( + {r.person_name} ({r.person_email || 'keine E-Mail'}) + ) : ( + Nicht zugewiesen + )} +
+ ))} +
+ )} + + {/* Existing reviews */} + {existingReviews.length > 0 && ( +
+ {existingReviews.map(r => ( +
+ + {STATUS_LABELS[r.status] || r.status} + + {r.reviewer_name || r.reviewer_role_key} + {r.email_sent && E-Mail gesendet} +
+ ))} +
+ )} + + {/* Send for review */} + + + {result && ( +

{result}

+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/rollenkonzept/_components/ReviewList.tsx b/admin-compliance/app/sdk/rollenkonzept/_components/ReviewList.tsx new file mode 100644 index 0000000..e01effc --- /dev/null +++ b/admin-compliance/app/sdk/rollenkonzept/_components/ReviewList.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useState } from 'react' +import type { DocumentReview, ReviewStats } from '../_types' +import { STATUS_CONFIG, ROLE_ICONS } from '../_types' + +interface ReviewListProps { + reviews: DocumentReview[] + stats: ReviewStats + loading: boolean + statusFilter: string | null + onFilterChange: (status: string | null) => void + onApprove: (id: string) => Promise + onReject: (id: string, comment: string) => Promise + onSendNotification: (id: string) => Promise +} + +export function ReviewList({ + reviews, stats, loading, statusFilter, onFilterChange, + onApprove, onReject, onSendNotification, +}: ReviewListProps) { + const [rejectId, setRejectId] = useState(null) + const [rejectComment, setRejectComment] = useState('') + const [processing, setProcessing] = useState(null) + + const total = Object.values(stats).reduce((s, n) => s + (n || 0), 0) + + const handleApprove = async (id: string) => { + setProcessing(id) + try { await onApprove(id) } finally { setProcessing(null) } + } + + const handleReject = async () => { + if (!rejectId || !rejectComment.trim()) return + setProcessing(rejectId) + try { + await onReject(rejectId, rejectComment) + setRejectId(null) + setRejectComment('') + } finally { setProcessing(null) } + } + + return ( +
+ {/* Stats */} +
+ + {Object.entries(STATUS_CONFIG).map(([key, cfg]) => ( + + ))} +
+ + {/* List */} + {loading ? ( +
Lade Reviews...
+ ) : reviews.length === 0 ? ( +
Keine Reviews vorhanden
+ ) : ( +
+ {reviews.map(review => { + const cfg = STATUS_CONFIG[review.status] || STATUS_CONFIG.pending + return ( +
+ {ROLE_ICONS[review.reviewer_role_key] || '\u{1F464}'} +
+
{review.document_title}
+
+ {review.reviewer_name || review.reviewer_role_key} + {review.submitted_at && ` — ${new Date(review.submitted_at).toLocaleDateString('de-DE')}`} +
+
+ {cfg.label} + {review.status === 'pending' && ( + + )} + {(review.status === 'pending' || review.status === 'in_review') && ( +
+ + +
+ )} + {review.status === 'rejected' && review.review_comment && ( + + {review.review_comment} + + )} +
+ ) + })} +
+ )} + + {/* Reject Dialog */} + {rejectId && ( +
+
+

Dokument ablehnen

+