feat: F018 human review queue — 2-panel governance workspace

Replaces the old ad-hoc ReviewQueue page with a full governance-compliant
workflow: filterable task list, detail panel with confidence/risk context,
role-aware approve/reject/escalate/more-data actions, and persistent notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-17 13:03:34 +02:00
parent 1d0cb8b154
commit 5d3323617c
18 changed files with 1231 additions and 432 deletions
@@ -0,0 +1,97 @@
import { Box, Button, CircularProgress } from '@mui/material'
import { CheckCircle, XCircle, AlertTriangle, ArrowUpCircle } from 'lucide-react'
import type { ReviewTask, ReviewTaskStatus } from '../../domain/review'
import type { UserRole } from '../../domain/enums'
interface ReviewActionToolbarProps {
task: ReviewTask
userRole: UserRole
onAction: (status: ReviewTaskStatus, note?: string) => void
isSubmitting?: boolean
}
export function ReviewActionToolbar({ task, userRole, onAction, isSubmitting }: ReviewActionToolbarProps) {
const isActive = task.status === 'PENDING' || task.status === 'IN_REVIEW' || task.status === 'ESCALATED'
const canApproveReject = isActive && (userRole === 'REVIEWER' || userRole === 'ORGANIZATION_ADMIN' || userRole === 'SUPER_ADMIN')
const canEscalate = task.status !== 'ESCALATED' && task.status !== 'APPROVED' && task.status !== 'REJECTED'
&& (userRole === 'ORGANIZATION_ADMIN' || userRole === 'SUPER_ADMIN')
const canRequestMore = task.status !== 'NEEDS_MORE_DATA' && task.status !== 'APPROVED' && task.status !== 'REJECTED'
if (!canApproveReject && !canEscalate && !canRequestMore) return null
return (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{canApproveReject && (
<>
<Button
size="small"
variant="contained"
disabled={isSubmitting}
startIcon={isSubmitting ? <CircularProgress size={12} /> : <CheckCircle size={14} />}
onClick={() => onAction('APPROVED')}
sx={{
bgcolor: '#1a7a4a',
'&:hover': { bgcolor: '#155f3a' },
textTransform: 'none',
fontSize: '0.75rem',
}}
>
Genehmigen
</Button>
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
startIcon={<XCircle size={14} />}
onClick={() => onAction('REJECTED')}
sx={{
color: '#c0392b',
borderColor: '#c0392b',
'&:hover': { borderColor: '#a93226', bgcolor: '#fef2f2' },
textTransform: 'none',
fontSize: '0.75rem',
}}
>
Ablehnen
</Button>
</>
)}
{canRequestMore && (
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
startIcon={<AlertTriangle size={14} />}
onClick={() => onAction('NEEDS_MORE_DATA')}
sx={{
color: '#d97706',
borderColor: '#d97706',
'&:hover': { borderColor: '#b45309', bgcolor: '#fffbeb' },
textTransform: 'none',
fontSize: '0.75rem',
}}
>
Mehr Daten
</Button>
)}
{canEscalate && (
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
startIcon={<ArrowUpCircle size={14} />}
onClick={() => onAction('ESCALATED')}
sx={{
color: '#ea580c',
borderColor: '#ea580c',
'&:hover': { borderColor: '#c2410c', bgcolor: '#fff7ed' },
textTransform: 'none',
fontSize: '0.75rem',
}}
>
Eskalieren
</Button>
)}
</Box>
)
}
+176
View File
@@ -0,0 +1,176 @@
import { Box, Divider, IconButton, LinearProgress, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { ReviewEntityTypeBadge } from './ReviewEntityTypeBadge'
import { ReviewPriorityBadge } from './ReviewPriorityBadge'
import { ReviewStatusBadge } from './ReviewStatusBadge'
import { ReviewNotesPanel } from './ReviewNotesPanel'
import { ReviewActionToolbar } from './ReviewActionToolbar'
import type { ReviewTask, ReviewTaskStatus } from '../../domain/review'
import type { UserRole } from '../../domain/enums'
interface ReviewDetailPanelProps {
task: ReviewTask
userRole: UserRole
onClose: () => void
onAction: (status: ReviewTaskStatus) => void
onAddNote: (content: string) => void
isSubmitting?: boolean
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
FUTURE_SIGNAL: 'Zukunftssignal',
MATCH_EXPLANATION: 'Match-Begründung',
LOW_CONFIDENCE_MATCH: 'Niedr. Konfidenz-Match',
CONTACT_RELEASE: 'Kontaktfreigabe',
AI_OUTPUT: 'AI-Output',
PROPERTY_DATA_ISSUE: 'Datenfehler',
}
const RISK_LABELS: Record<string, string> = {
LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', CRITICAL: 'Kritisch',
}
const RISK_COLORS: Record<string, string> = {
LOW: '#1a7a4a', MEDIUM: '#d97706', HIGH: '#ea580c', CRITICAL: '#c0392b',
}
function MetaRow({ label, value }: { label: string; value: string | undefined }) {
if (!value) return null
return (
<Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 110, flexShrink: 0 }}>
{label}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 500, color: '#334155' }}>
{value}
</Typography>
</Box>
)
}
export function ReviewDetailPanel({
task,
userRole,
onClose,
onAction,
onAddNote,
isSubmitting,
}: ReviewDetailPanelProps) {
const isActive = task.status === 'PENDING' || task.status === 'IN_REVIEW' || task.status === 'ESCALATED'
const canAddNote = isActive || task.status === 'NEEDS_MORE_DATA'
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 0.5 }}>
<ReviewEntityTypeBadge entityType={task.entityType} />
<ReviewPriorityBadge priority={task.priority} />
<ReviewStatusBadge status={task.status} />
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3 }}>
{task.title}
</Typography>
</Box>
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, mt: -0.25 }}>
<X size={16} />
</IconButton>
</Box>
</Box>
{/* Scrollable body */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1.5 }}>
{/* Description */}
{task.description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5, fontSize: '0.8125rem', lineHeight: 1.5 }}>
{task.description}
</Typography>
)}
{/* Metadata */}
<Box sx={{ mb: 1.5 }}>
<MetaRow label="Typ" value={ENTITY_TYPE_LABELS[task.entityType] ?? task.entityType} />
<MetaRow label="Erstellt von" value={task.createdBy} />
<MetaRow label="Zugewiesen" value={task.assignedTo} />
<MetaRow label="Fällig" value={task.dueDate ? new Date(task.dueDate).toLocaleDateString('de-CH') : undefined} />
{task.relatedOrganizationId && (
<MetaRow label="Organisation" value={task.relatedOrganizationId} />
)}
{task.promptVersion && (
<MetaRow label="Prompt-Version" value={task.promptVersion} />
)}
</Box>
{/* Confidence / Risk context */}
{(task.confidenceScore !== undefined || task.riskLevel) && (
<>
<Divider sx={{ mb: 1.5 }} />
{task.confidenceScore !== undefined && (
<Box sx={{ mb: 1.25 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
<Typography variant="caption" color="text.secondary">Konfidenz</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{Math.round(task.confidenceScore * 100)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={task.confidenceScore * 100}
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
bgcolor: task.confidenceScore >= 0.7 ? '#1a7a4a' : task.confidenceScore >= 0.5 ? '#d97706' : '#c0392b',
},
}}
/>
</Box>
)}
{task.riskLevel && (
<Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 110 }}>Risikostufe</Typography>
<Typography
variant="caption"
sx={{ fontWeight: 700, color: RISK_COLORS[task.riskLevel] ?? '#64748b' }}
>
{RISK_LABELS[task.riskLevel] ?? task.riskLevel}
</Typography>
</Box>
)}
{task.matchScore !== undefined && (
<Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 110 }}>Match-Score</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>{task.matchScore}%</Typography>
</Box>
)}
</>
)}
<Divider sx={{ my: 1.5 }} />
{/* Actions */}
<Box sx={{ mb: 1.5 }}>
<ReviewActionToolbar
task={task}
userRole={userRole}
onAction={onAction}
isSubmitting={isSubmitting}
/>
</Box>
<Divider sx={{ mb: 1.5 }} />
{/* Notes */}
<ReviewNotesPanel
notes={task.reviewNotes}
canAddNote={canAddNote}
onAddNote={onAddNote}
isSubmitting={isSubmitting}
/>
</Box>
</Box>
)
}
@@ -0,0 +1,38 @@
import { Box, Typography } from '@mui/material'
import { CheckCircle, MousePointer, ShieldOff } from 'lucide-react'
interface ReviewEmptyStateProps {
variant: 'empty-queue' | 'no-selection' | 'no-permission'
}
const CONFIG = {
'empty-queue': {
icon: CheckCircle,
color: '#1a7a4a',
title: 'Keine ausstehenden Reviews',
desc: 'Alle Aufgaben wurden bearbeitet. Gute Arbeit.',
},
'no-selection': {
icon: MousePointer,
color: '#94a3b8',
title: 'Aufgabe wählen',
desc: 'Klicken Sie auf eine Aufgabe in der Liste, um Details und Aktionen anzuzeigen.',
},
'no-permission': {
icon: ShieldOff,
color: '#c0392b',
title: 'Kein Zugriff',
desc: 'Sie haben keine Berechtigung, Review-Aufgaben zu bearbeiten.',
},
}
export function ReviewEmptyState({ variant }: ReviewEmptyStateProps) {
const { icon: Icon, color, title, desc } = CONFIG[variant]
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 200, p: 4, textAlign: 'center' }}>
<Icon size={40} color={color} style={{ marginBottom: 12, opacity: 0.7 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: '#1e293b' }}>{title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 280 }}>{desc}</Typography>
</Box>
)
}
@@ -0,0 +1,32 @@
import { Chip } from '@mui/material'
import type { ReviewEntityType } from '../../domain/review'
interface ReviewEntityTypeBadgeProps {
entityType: ReviewEntityType
size?: 'small' | 'medium'
}
const CONFIG: Record<ReviewEntityType, { label: string; color: string }> = {
FUTURE_SIGNAL: { label: 'Zukunftssignal', color: '#7c3aed' },
MATCH_EXPLANATION: { label: 'Match-Begründung', color: '#1e3a5f' },
LOW_CONFIDENCE_MATCH: { label: 'Niedr. Konfidenz', color: '#ea580c' },
CONTACT_RELEASE: { label: 'Kontaktfreigabe', color: '#0891b2' },
AI_OUTPUT: { label: 'AI-Output', color: '#4f46e5' },
PROPERTY_DATA_ISSUE: { label: 'Datenfehler', color: '#c0392b' },
}
export function ReviewEntityTypeBadge({ entityType, size = 'small' }: ReviewEntityTypeBadgeProps) {
const { label, color } = CONFIG[entityType] ?? { label: entityType, color: '#64748b' }
return (
<Chip
size={size}
label={label}
sx={{
bgcolor: `${color}18`,
color,
fontWeight: 600,
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
}}
/>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { Box, MenuItem, Select, Typography } from '@mui/material'
import { ReviewEntityType, ReviewPriority, ReviewTaskStatus } from '../../domain/review'
import type { ReviewFilters } from '../../provider/IReviewProvider'
interface ReviewFilterBarProps {
filters: ReviewFilters
onChange: (f: ReviewFilters) => void
totalCount: number
filteredCount: number
}
export function ReviewFilterBar({ filters, onChange, totalCount, filteredCount }: ReviewFilterBarProps) {
const activeCount = Object.values(filters).filter(Boolean).length
return (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: '#fafafa', flexShrink: 0, flexWrap: 'wrap' }}>
<Select
size="small"
value={filters.status ?? ''}
onChange={e => onChange({ ...filters, status: (e.target.value || undefined) as ReviewFilters['status'] })}
displayEmpty
sx={{ fontSize: '0.75rem', minWidth: 140 }}
>
<MenuItem value="">Alle Status</MenuItem>
{Object.values(ReviewTaskStatus).map(s => (
<MenuItem key={s} value={s} sx={{ fontSize: '0.75rem' }}>
{{ PENDING: 'Ausstehend', IN_REVIEW: 'In Prüfung', APPROVED: 'Genehmigt', REJECTED: 'Abgelehnt', NEEDS_MORE_DATA: 'Mehr Daten', ESCALATED: 'Eskaliert' }[s] ?? s}
</MenuItem>
))}
</Select>
<Select
size="small"
value={filters.priority ?? ''}
onChange={e => onChange({ ...filters, priority: (e.target.value || undefined) as ReviewFilters['priority'] })}
displayEmpty
sx={{ fontSize: '0.75rem', minWidth: 120 }}
>
<MenuItem value="">Alle Prioritäten</MenuItem>
{Object.values(ReviewPriority).map(p => (
<MenuItem key={p} value={p} sx={{ fontSize: '0.75rem' }}>
{{ LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', CRITICAL: 'Kritisch' }[p] ?? p}
</MenuItem>
))}
</Select>
<Select
size="small"
value={filters.entityType ?? ''}
onChange={e => onChange({ ...filters, entityType: (e.target.value || undefined) as ReviewFilters['entityType'] })}
displayEmpty
sx={{ fontSize: '0.75rem', minWidth: 150 }}
>
<MenuItem value="">Alle Typen</MenuItem>
{Object.values(ReviewEntityType).map(t => (
<MenuItem key={t} value={t} sx={{ fontSize: '0.75rem' }}>
{{ FUTURE_SIGNAL: 'Zukunftssignal', MATCH_EXPLANATION: 'Match-Begründung', LOW_CONFIDENCE_MATCH: 'Niedr. Konfidenz', CONTACT_RELEASE: 'Kontaktfreigabe', AI_OUTPUT: 'AI-Output', PROPERTY_DATA_ISSUE: 'Datenfehler' }[t] ?? t}
</MenuItem>
))}
</Select>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto', whiteSpace: 'nowrap' }}>
{activeCount > 0 ? `${filteredCount} / ${totalCount}` : `${totalCount} Aufgaben`}
</Typography>
</Box>
)
}
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Box, Button, TextField, Typography } from '@mui/material'
import { MessageSquare } from 'lucide-react'
import type { ReviewNote } from '../../domain/review'
interface ReviewNotesPanelProps {
notes: ReviewNote[]
canAddNote: boolean
onAddNote: (content: string) => void
isSubmitting?: boolean
}
export function ReviewNotesPanel({ notes, canAddNote, onAddNote, isSubmitting }: ReviewNotesPanelProps) {
const [noteText, setNoteText] = useState('')
const handleSubmit = () => {
if (!noteText.trim()) return
onAddNote(noteText.trim())
setNoteText('')
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
<MessageSquare size={14} color="#64748b" />
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#64748b' }}>
Notizen ({notes.length})
</Typography>
</Box>
{/* Existing notes */}
{notes.length > 0 ? (
<Box sx={{ mb: 1.5 }}>
{[...notes].reverse().map(note => (
<Box
key={note.id}
sx={{
px: 1.5,
py: 1,
mb: 0.75,
bgcolor: '#f8fafc',
borderRadius: 1,
borderLeft: '3px solid #e2e8f0',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', fontSize: '0.7rem' }}>
{note.createdBy}
</Typography>
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.65rem' }}>
{new Date(note.createdAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: '#334155', lineHeight: 1.5 }}>
{note.content}
</Typography>
</Box>
))}
</Box>
) : (
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mb: 1.5 }}>
Noch keine Notizen.
</Typography>
)}
{/* Add note */}
{canAddNote && (
<Box>
<TextField
multiline
rows={2}
fullWidth
size="small"
placeholder="Notiz hinzufügen..."
value={noteText}
onChange={e => setNoteText(e.target.value)}
sx={{ mb: 0.75, fontSize: '0.8125rem' }}
/>
<Button
size="small"
variant="outlined"
disabled={!noteText.trim() || isSubmitting}
onClick={handleSubmit}
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
>
Notiz speichern
</Button>
</Box>
)}
</Box>
)
}
@@ -0,0 +1,31 @@
import { Chip } from '@mui/material'
import type { ReviewPriority } from '../../domain/review'
interface ReviewPriorityBadgeProps {
priority: ReviewPriority
size?: 'small' | 'medium'
}
const CONFIG: Record<ReviewPriority, { label: string; color: string }> = {
LOW: { label: 'Niedrig', color: '#64748b' },
MEDIUM: { label: 'Mittel', color: '#d97706' },
HIGH: { label: 'Hoch', color: '#ea580c' },
CRITICAL: { label: 'Kritisch', color: '#c0392b' },
}
export function ReviewPriorityBadge({ priority, size = 'small' }: ReviewPriorityBadgeProps) {
const { label, color } = CONFIG[priority] ?? CONFIG.MEDIUM
return (
<Chip
size={size}
label={label}
sx={{
bgcolor: `${color}18`,
color,
fontWeight: 700,
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
border: `1px solid ${color}40`,
}}
/>
)
}
@@ -0,0 +1,33 @@
import { Chip } from '@mui/material'
import type { ReviewTaskStatus } from '../../domain/review'
interface ReviewStatusBadgeProps {
status: ReviewTaskStatus
size?: 'small' | 'medium'
}
const CONFIG: Record<ReviewTaskStatus, { label: string; color: string }> = {
PENDING: { label: 'Ausstehend', color: '#d97706' },
IN_REVIEW: { label: 'In Prüfung', color: '#2563eb' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
NEEDS_MORE_DATA: { label: 'Mehr Daten nötig', color: '#7c3aed' },
ESCALATED: { label: 'Eskaliert', color: '#ea580c' },
}
export function ReviewStatusBadge({ status, size = 'small' }: ReviewStatusBadgeProps) {
const { label, color } = CONFIG[status] ?? CONFIG.PENDING
return (
<Chip
size={size}
label={label}
sx={{
bgcolor: `${color}18`,
color,
fontWeight: 600,
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
border: `1px solid ${color}40`,
}}
/>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { Box, LinearProgress, Typography } from '@mui/material'
import { Calendar, User } from 'lucide-react'
import { ReviewStatusBadge } from './ReviewStatusBadge'
import { ReviewPriorityBadge } from './ReviewPriorityBadge'
import { ReviewEntityTypeBadge } from './ReviewEntityTypeBadge'
import type { ReviewTask } from '../../domain/review'
interface ReviewTaskCardProps {
task: ReviewTask
isSelected: boolean
onSelect: (task: ReviewTask) => void
}
function isOverdue(dueDate?: string): boolean {
if (!dueDate) return false
return new Date(dueDate) < new Date()
}
export function ReviewTaskCard({ task, isSelected, onSelect }: ReviewTaskCardProps) {
const overdue = isOverdue(task.dueDate)
const isActive = task.status === 'PENDING' || task.status === 'IN_REVIEW' || task.status === 'ESCALATED'
return (
<Box
onClick={() => onSelect(task)}
sx={{
px: 1.5,
py: 1.25,
cursor: 'pointer',
borderBottom: '1px solid #f1f5f9',
borderLeft: isSelected
? '3px solid #1e3a5f'
: overdue && isActive
? '3px solid #c0392b'
: task.priority === 'CRITICAL' && isActive
? '3px solid #ea580c'
: '3px solid transparent',
bgcolor: isSelected ? '#eff6ff' : 'white',
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
transition: 'background-color 0.1s ease',
}}
>
{/* Badges row */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 0.5 }}>
<ReviewEntityTypeBadge entityType={task.entityType} />
<ReviewPriorityBadge priority={task.priority} />
<ReviewStatusBadge status={task.status} />
</Box>
{/* Title */}
<Typography
variant="body2"
sx={{ fontWeight: 600, fontSize: '0.8125rem', lineHeight: 1.3, mb: 0.25 }}
noWrap
>
{task.title}
</Typography>
{/* Description */}
{task.description && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mb: 0.5, overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}
>
{task.description}
</Typography>
)}
{/* Confidence bar */}
{task.confidenceScore !== undefined && (
<Box sx={{ mb: 0.75 }}>
<LinearProgress
variant="determinate"
value={task.confidenceScore * 100}
sx={{
height: 3,
borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
bgcolor: task.confidenceScore >= 0.7 ? '#1a7a4a' : task.confidenceScore >= 0.5 ? '#d97706' : '#c0392b',
},
}}
/>
</Box>
)}
{/* Footer: meta */}
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
{task.assignedTo && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<User size={10} color="#94a3b8" />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
{task.assignedTo}
</Typography>
</Box>
)}
{task.dueDate && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Calendar size={10} color={overdue ? '#c0392b' : '#94a3b8'} />
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: overdue ? '#c0392b' : 'text.secondary', fontWeight: overdue ? 600 : 400 }}>
{new Date(task.dueDate).toLocaleDateString('de-CH')}
</Typography>
</Box>
)}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', ml: 'auto' }}>
{new Date(task.createdAt).toLocaleDateString('de-CH')}
</Typography>
</Box>
</Box>
)
}
+9
View File
@@ -0,0 +1,9 @@
export { ReviewStatusBadge } from './ReviewStatusBadge'
export { ReviewPriorityBadge } from './ReviewPriorityBadge'
export { ReviewEntityTypeBadge } from './ReviewEntityTypeBadge'
export { ReviewEmptyState } from './ReviewEmptyState'
export { ReviewFilterBar } from './ReviewFilterBar'
export { ReviewTaskCard } from './ReviewTaskCard'
export { ReviewNotesPanel } from './ReviewNotesPanel'
export { ReviewActionToolbar } from './ReviewActionToolbar'
export { ReviewDetailPanel } from './ReviewDetailPanel'