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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-03 13:09:32 +02:00
parent 9b4be663f7
commit 4c92b17617
9 changed files with 782 additions and 0 deletions
@@ -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({
</div>
)}
{/* Review Assignment */}
<ReviewAssignmentPanel
documentType={template.templateType || ''}
documentTitle={template.documentTitle || 'Dokument'}
documentContent={renderedContent}
/>
{/* Attribution */}
{template.attributionRequired && template.attributionText && (
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
@@ -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<string, string> = {
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<string, string> = {
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<ReviewerInfo[]>([])
const [existingReviews, setExistingReviews] = useState<ReviewRecord[]>([])
const [sending, setSending] = useState(false)
const [result, setResult] = useState<string | null>(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 (
<div className="border border-purple-200 rounded-lg p-4 bg-purple-50/50 space-y-3">
<h4 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Pruefung & Freigabe
</h4>
{/* Assigned reviewers */}
{reviewers.length > 0 && (
<div className="space-y-1">
{reviewers.map(r => (
<div key={r.role_key} className="flex items-center gap-2 text-xs">
<span className="font-medium text-gray-700">{r.role_label || r.role_key}:</span>
{r.person_name ? (
<span className="text-gray-600">{r.person_name} ({r.person_email || 'keine E-Mail'})</span>
) : (
<span className="text-gray-400 italic">Nicht zugewiesen</span>
)}
</div>
))}
</div>
)}
{/* Existing reviews */}
{existingReviews.length > 0 && (
<div className="space-y-1">
{existingReviews.map(r => (
<div key={r.id} className="flex items-center gap-2">
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${STATUS_COLORS[r.status] || ''}`}>
{STATUS_LABELS[r.status] || r.status}
</span>
<span className="text-xs text-gray-600">{r.reviewer_name || r.reviewer_role_key}</span>
{r.email_sent && <span className="text-[10px] text-green-600">E-Mail gesendet</span>}
</div>
))}
</div>
)}
{/* Send for review */}
<button onClick={handleSendForReview} disabled={sending || reviewers.length === 0}
className="w-full px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors">
{sending ? 'Sende...' : 'Zur Pruefung senden'}
</button>
{result && (
<p className={`text-xs ${result.includes('Fehler') ? 'text-red-600' : 'text-green-600'}`}>{result}</p>
)}
</div>
)
}
@@ -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<void>
onReject: (id: string, comment: string) => Promise<void>
onSendNotification: (id: string) => Promise<void>
}
export function ReviewList({
reviews, stats, loading, statusFilter, onFilterChange,
onApprove, onReject, onSendNotification,
}: ReviewListProps) {
const [rejectId, setRejectId] = useState<string | null>(null)
const [rejectComment, setRejectComment] = useState('')
const [processing, setProcessing] = useState<string | null>(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 (
<div className="space-y-4">
{/* Stats */}
<div className="flex gap-3 flex-wrap">
<button onClick={() => onFilterChange(null)}
className={`px-3 py-1 text-xs rounded-full border ${!statusFilter ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}`}>
Alle ({total})
</button>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
<button key={key} onClick={() => onFilterChange(key === statusFilter ? null : key)}
className={`px-3 py-1 text-xs rounded-full border ${statusFilter === key ? cfg.color + ' border-current' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}`}>
{cfg.label} ({stats[key as keyof ReviewStats] || 0})
</button>
))}
</div>
{/* List */}
{loading ? (
<div className="text-center py-8 text-gray-400">Lade Reviews...</div>
) : reviews.length === 0 ? (
<div className="text-center py-8 text-gray-400">Keine Reviews vorhanden</div>
) : (
<div className="space-y-2">
{reviews.map(review => {
const cfg = STATUS_CONFIG[review.status] || STATUS_CONFIG.pending
return (
<div key={review.id} className="bg-white border border-gray-200 rounded-lg p-4 flex items-center gap-4">
<span className="text-xl">{ROLE_ICONS[review.reviewer_role_key] || '\u{1F464}'}</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 truncate">{review.document_title}</div>
<div className="text-xs text-gray-500">
{review.reviewer_name || review.reviewer_role_key}
{review.submitted_at && `${new Date(review.submitted_at).toLocaleDateString('de-DE')}`}
</div>
</div>
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${cfg.color}`}>{cfg.label}</span>
{review.status === 'pending' && (
<button onClick={() => onSendNotification(review.id)}
className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded hover:bg-blue-100">
E-Mail
</button>
)}
{(review.status === 'pending' || review.status === 'in_review') && (
<div className="flex gap-1">
<button onClick={() => handleApprove(review.id)} disabled={processing === review.id}
className="px-2 py-1 text-xs bg-green-50 text-green-600 rounded hover:bg-green-100 disabled:opacity-50">
Freigeben
</button>
<button onClick={() => setRejectId(review.id)}
className="px-2 py-1 text-xs bg-red-50 text-red-600 rounded hover:bg-red-100">
Ablehnen
</button>
</div>
)}
{review.status === 'rejected' && review.review_comment && (
<span className="text-xs text-red-500 max-w-[200px] truncate" title={review.review_comment}>
{review.review_comment}
</span>
)}
</div>
)
})}
</div>
)}
{/* Reject Dialog */}
{rejectId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-2xl">
<h3 className="text-lg font-semibold mb-3">Dokument ablehnen</h3>
<textarea value={rejectComment} onChange={e => setRejectComment(e.target.value)}
placeholder="Grund fuer die Ablehnung..." rows={3}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-red-500" />
<div className="flex justify-end gap-2 mt-4">
<button onClick={() => { setRejectId(null); setRejectComment('') }}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={handleReject} disabled={!rejectComment.trim() || processing === rejectId}
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
Ablehnen
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,106 @@
'use client'
import { useState } from 'react'
import type { OrgRole, DefaultRole } from '../_types'
import { ROLE_ICONS } from '../_types'
interface RoleCardProps {
role: OrgRole | DefaultRole
onSave: (roleId: string, data: Partial<OrgRole>) => Promise<void>
onSendTest: (roleId: string) => Promise<{ sent: boolean; email: string }>
}
export function RoleCard({ role, onSave, onSendTest }: RoleCardProps) {
const isAssigned = 'id' in role
const [editing, setEditing] = useState(false)
const [name, setName] = useState((role as OrgRole).person_name || '')
const [email, setEmail] = useState((role as OrgRole).person_email || '')
const [dept, setDept] = useState((role as OrgRole).department || '')
const [sending, setSending] = useState(false)
const [testResult, setTestResult] = useState<string | null>(null)
const handleSave = async () => {
if (!isAssigned) return
await onSave((role as OrgRole).id, { person_name: name, person_email: email, department: dept })
setEditing(false)
}
const handleTest = async () => {
if (!isAssigned) return
setSending(true)
setTestResult(null)
try {
const result = await onSendTest((role as OrgRole).id)
setTestResult(result.sent ? `Gesendet an ${result.email}` : 'Fehler')
} catch {
setTestResult('Fehler beim Senden')
} finally {
setSending(false)
}
}
const icon = ROLE_ICONS[role.role_key] || '\u{1F464}'
return (
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-3">
<div className="flex items-center gap-3">
<span className="text-2xl">{icon}</span>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 text-sm">{role.role_label}</h3>
{isAssigned && (role as OrgRole).person_name && !editing && (
<p className="text-xs text-gray-500">{(role as OrgRole).person_name}</p>
)}
</div>
{isAssigned && !editing && (
<button onClick={() => setEditing(true)} className="text-xs text-purple-600 hover:underline">
Bearbeiten
</button>
)}
</div>
{editing ? (
<div className="space-y-2">
<input value={name} onChange={e => setName(e.target.value)} placeholder="Name"
className="w-full px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 focus:border-purple-500" />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="E-Mail" type="email"
className="w-full px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 focus:border-purple-500" />
<input value={dept} onChange={e => setDept(e.target.value)} placeholder="Abteilung"
className="w-full px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 focus:border-purple-500" />
<div className="flex gap-2">
<button onClick={handleSave} className="px-3 py-1 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
Speichern
</button>
<button onClick={() => setEditing(false)} className="px-3 py-1 text-xs text-gray-500 hover:text-gray-700">
Abbrechen
</button>
</div>
</div>
) : (
<>
{isAssigned && (role as OrgRole).person_email && (
<div className="text-xs text-gray-500 space-y-0.5">
<div>{(role as OrgRole).person_email}</div>
{(role as OrgRole).department && <div>{(role as OrgRole).department}</div>}
</div>
)}
{!isAssigned && (
<p className="text-xs text-gray-400 italic">Noch nicht zugewiesen</p>
)}
</>
)}
{isAssigned && (role as OrgRole).person_email && !editing && (
<button onClick={handleTest} disabled={sending}
className="w-full px-3 py-1.5 text-xs bg-blue-50 text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-100 disabled:opacity-50">
{sending ? 'Sende...' : 'Test-E-Mail senden'}
</button>
)}
{testResult && (
<p className={`text-xs ${testResult.startsWith('Gesendet') ? 'text-green-600' : 'text-red-600'}`}>
{testResult}
</p>
)}
</div>
)
}
@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
import type { DocumentReview, ReviewStats } from '../_types'
const API_BASE = '/api/sdk/v1/compliance/document-reviews'
async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json()
}
export function useDocumentReviews() {
const { projectId } = useSDK()
const [reviews, setReviews] = useState<DocumentReview[]>([])
const [stats, setStats] = useState<ReviewStats>({})
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState<string | null>(null)
const loadReviews = useCallback(async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (projectId) params.set('project_id', projectId)
if (statusFilter) params.set('status', statusFilter)
const qs = params.toString() ? `?${params}` : ''
const [reviewsData, statsData] = await Promise.all([
apiFetch<DocumentReview[]>(`${API_BASE}${qs}`),
apiFetch<ReviewStats>(`${API_BASE}/stats${projectId ? `?project_id=${projectId}` : ''}`),
])
setReviews(reviewsData)
setStats(statsData)
} catch {
// Silent — component shows empty state
} finally {
setLoading(false)
}
}, [projectId, statusFilter])
useEffect(() => { loadReviews() }, [loadReviews])
const approveReview = useCallback(async (reviewId: string) => {
const updated = await apiFetch<DocumentReview>(`${API_BASE}/${reviewId}/approve`, { method: 'POST' })
setReviews(prev => prev.map(r => r.id === reviewId ? updated : r))
return updated
}, [])
const rejectReview = useCallback(async (reviewId: string, comment: string) => {
const updated = await apiFetch<DocumentReview>(`${API_BASE}/${reviewId}/reject`, {
method: 'POST',
body: JSON.stringify({ comment }),
})
setReviews(prev => prev.map(r => r.id === reviewId ? updated : r))
return updated
}, [])
const sendNotification = useCallback(async (reviewId: string) => {
return apiFetch<{ sent: boolean }>(`${API_BASE}/${reviewId}/send`, { method: 'POST' })
}, [])
return {
reviews, stats, loading, statusFilter, setStatusFilter,
loadReviews, approveReview, rejectReview, sendNotification,
}
}
@@ -0,0 +1,84 @@
import { useState, useEffect, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
import type { OrgRole, DefaultRole, RoleMapping } from '../_types'
const API_BASE = '/api/sdk/v1/compliance/org-roles'
async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json()
}
export function useOrgRoles() {
const { projectId } = useSDK()
const [roles, setRoles] = useState<OrgRole[]>([])
const [defaults, setDefaults] = useState<DefaultRole[]>([])
const [mapping, setMapping] = useState<RoleMapping[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadRoles = useCallback(async () => {
try {
setLoading(true)
const qs = projectId ? `?project_id=${projectId}` : ''
const [rolesData, defaultsData, mappingData] = await Promise.all([
apiFetch<OrgRole[]>(`${API_BASE}${qs}`),
apiFetch<DefaultRole[]>(`${API_BASE}/defaults`),
apiFetch<RoleMapping[]>(`${API_BASE}/mapping`),
])
setRoles(rolesData)
setDefaults(defaultsData)
setMapping(mappingData)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { loadRoles() }, [loadRoles])
const seedRoles = useCallback(async () => {
const qs = projectId ? `?project_id=${projectId}` : ''
await apiFetch(`${API_BASE}/seed${qs}`, { method: 'POST' })
await loadRoles()
}, [projectId, loadRoles])
const updateRole = useCallback(async (roleId: string, data: Partial<OrgRole>) => {
const updated = await apiFetch<OrgRole>(`${API_BASE}/${roleId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
setRoles(prev => prev.map(r => r.id === roleId ? updated : r))
return updated
}, [])
const sendTestEmail = useCallback(async (roleId: string) => {
return apiFetch<{ sent: boolean; email: string }>(`${API_BASE}/${roleId}/send-test`, {
method: 'POST',
})
}, [])
const updateMapping = useCallback(async (entries: { document_type: string; role_key: string; is_primary: boolean }[]) => {
await apiFetch(`${API_BASE}/mapping`, {
method: 'PUT',
body: JSON.stringify({ entries }),
})
await loadRoles()
}, [loadRoles])
// Get role by key (merge defaults with actual role data)
const getRoleByKey = useCallback((key: string): OrgRole | DefaultRole | undefined => {
return roles.find(r => r.role_key === key) || defaults.find(d => d.role_key === key)
}, [roles, defaults])
return {
roles, defaults, mapping, loading, error,
loadRoles, seedRoles, updateRole, sendTestEmail, updateMapping, getRoleByKey,
}
}
@@ -0,0 +1,75 @@
export interface OrgRole {
id: string
tenant_id: string
project_id: string | null
role_key: string
role_label: string
person_name: string | null
person_email: string | null
department: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface DefaultRole {
role_key: string
role_label: string
}
export interface DocumentReview {
id: string
tenant_id: string
project_id: string | null
document_type: string
document_title: string
document_content_hash: string | null
reviewer_role_key: string
reviewer_name: string | null
reviewer_email: string | null
status: 'pending' | 'in_review' | 'approved' | 'rejected'
submitted_at: string | null
submitted_by: string | null
reviewed_at: string | null
review_comment: string | null
review_link: string | null
email_sent: boolean
email_sent_at: string | null
created_at: string
updated_at: string
}
export interface RoleMapping {
id: string
tenant_id: string
document_type: string
role_key: string
is_primary: boolean
created_at: string
}
export interface ReviewStats {
pending?: number
in_review?: number
approved?: number
rejected?: number
}
export type RollenkonzeptTab = 'rollen' | 'zuordnung' | 'reviews'
export const ROLE_ICONS: Record<string, string> = {
dsb: '\u{1F6E1}\uFE0F',
gf: '\u{1F4BC}',
it_leiter: '\u{1F4BB}',
hr_leitung: '\u{1F465}',
marketing_leitung: '\u{1F4E3}',
compliance_beauftragter: '\u2696\uFE0F',
einkauf: '\u{1F6D2}',
}
export const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
in_review: { label: 'In Pruefung', color: 'bg-blue-100 text-blue-700' },
approved: { label: 'Freigegeben', color: 'bg-green-100 text-green-700' },
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
}
@@ -0,0 +1,140 @@
'use client'
import { useState } from 'react'
import { useOrgRoles } from './_hooks/useOrgRoles'
import { useDocumentReviews } from './_hooks/useDocumentReviews'
import { RoleCard } from './_components/RoleCard'
import { ReviewList } from './_components/ReviewList'
import type { RollenkonzeptTab } from './_types'
const TABS: { id: RollenkonzeptTab; label: string }[] = [
{ id: 'rollen', label: 'Rollen' },
{ id: 'zuordnung', label: 'Zuordnung' },
{ id: 'reviews', label: 'Reviews' },
]
export default function RollenkonzeptPage() {
const [tab, setTab] = useState<RollenkonzeptTab>('rollen')
const { roles, defaults, mapping, loading, seedRoles, updateRole, sendTestEmail } = useOrgRoles()
const reviewHook = useDocumentReviews()
// Merge defaults with actual roles
const mergedRoles = defaults.map(d => {
const actual = roles.find(r => r.role_key === d.role_key)
return actual || d
})
// Group mapping by role
const mappingByRole: Record<string, string[]> = {}
for (const m of mapping) {
if (!mappingByRole[m.role_key]) mappingByRole[m.role_key] = []
mappingByRole[m.role_key].push(m.document_type)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Rollenkonzept</h1>
<p className="mt-1 text-gray-500">
Weisen Sie Compliance-Rollen zu und verwalten Sie den Dokumenten-Pruefprozess.
</p>
</div>
{roles.length === 0 && !loading && (
<button onClick={seedRoles}
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700">
Standard-Rollen anlegen
</button>
)}
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === t.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
}`}>
{t.label}
{t.id === 'reviews' && (reviewHook.stats.pending || 0) > 0 && (
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-orange-100 text-orange-600 rounded-full">
{reviewHook.stats.pending}
</span>
)}
</button>
))}
</div>
{/* Tab: Rollen */}
{tab === 'rollen' && (
<div>
{loading ? (
<div className="text-center py-12 text-gray-400">Lade Rollen...</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{mergedRoles.map(role => (
<RoleCard key={role.role_key} role={role} onSave={updateRole} onSendTest={sendTestEmail} />
))}
</div>
)}
</div>
)}
{/* Tab: Zuordnung */}
{tab === 'zuordnung' && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="font-semibold text-gray-900">Dokument Rollen-Zuordnung</h2>
<p className="text-xs text-gray-500 mt-1">
Zeigt welche Rolle welche Dokumente zur Pruefung erhaelt. Anpassbar pro Tenant.
</p>
</div>
{Object.keys(mappingByRole).length === 0 ? (
<div className="text-center py-8 text-gray-400">
Keine Zuordnung vorhanden. Bitte erst Standard-Rollen anlegen.
</div>
) : (
<div className="divide-y divide-gray-100">
{defaults.map(d => {
const docs = mappingByRole[d.role_key] || []
return (
<div key={d.role_key} className="px-6 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm text-gray-900">{d.role_label}</span>
<span className="text-xs text-gray-400">({docs.length} Dokumente)</span>
</div>
<div className="flex flex-wrap gap-1">
{docs.map(dt => (
<span key={dt} className="px-2 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded-full">
{dt.replace(/_/g, ' ')}
</span>
))}
{docs.length === 0 && (
<span className="text-xs text-gray-400 italic">Keine Dokumente zugeordnet</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* Tab: Reviews */}
{tab === 'reviews' && (
<ReviewList
reviews={reviewHook.reviews}
stats={reviewHook.stats}
loading={reviewHook.loading}
statusFilter={reviewHook.statusFilter}
onFilterChange={reviewHook.setStatusFilter}
onApprove={reviewHook.approveReview}
onReject={reviewHook.rejectReview}
onSendNotification={reviewHook.sendNotification}
/>
)}
</div>
)
}
@@ -92,6 +92,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
Zusatzmodule
</div>
)}
<AdditionalModuleItem href="/sdk/rollenkonzept" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>} label="Rollenkonzept" isActive={pathname?.startsWith('/sdk/rollenkonzept') ?? false} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/training" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>} label="Schulung (Admin)" isActive={pathname === '/sdk/training'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/training/learner" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>} label="Schulung (Learner)" isActive={pathname === '/sdk/training/learner'} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/rag" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>} label="Legal RAG" isActive={pathname === '/sdk/rag'} collapsed={collapsed} projectId={projectId} />