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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user