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