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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
+68
-18
@@ -1,29 +1,79 @@
|
||||
// ── Entity Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ReviewEntityType = {
|
||||
FUTURE_SIGNAL: 'FUTURE_SIGNAL',
|
||||
MATCH_EXPLANATION: 'MATCH_EXPLANATION',
|
||||
LOW_CONFIDENCE_MATCH:'LOW_CONFIDENCE_MATCH',
|
||||
CONTACT_RELEASE: 'CONTACT_RELEASE',
|
||||
AI_OUTPUT: 'AI_OUTPUT',
|
||||
PROPERTY_DATA_ISSUE: 'PROPERTY_DATA_ISSUE',
|
||||
} as const
|
||||
export type ReviewEntityType = typeof ReviewEntityType[keyof typeof ReviewEntityType]
|
||||
|
||||
// ── Task Status ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const ReviewTaskStatus = {
|
||||
PENDING: 'PENDING',
|
||||
IN_REVIEW: 'IN_REVIEW',
|
||||
APPROVED: 'APPROVED',
|
||||
REJECTED: 'REJECTED',
|
||||
NEEDS_MORE_DATA: 'NEEDS_MORE_DATA',
|
||||
ESCALATED: 'ESCALATED',
|
||||
} as const
|
||||
export type ReviewTaskStatus = typeof ReviewTaskStatus[keyof typeof ReviewTaskStatus]
|
||||
|
||||
// ── Priority ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ReviewPriority = {
|
||||
HIGH: 'HIGH',
|
||||
MEDIUM: 'MEDIUM',
|
||||
LOW: 'LOW',
|
||||
LOW: 'LOW',
|
||||
MEDIUM: 'MEDIUM',
|
||||
HIGH: 'HIGH',
|
||||
CRITICAL: 'CRITICAL',
|
||||
} as const
|
||||
export type ReviewPriority = typeof ReviewPriority[keyof typeof ReviewPriority]
|
||||
|
||||
export const ReviewQueueStatus = {
|
||||
PENDING: 'PENDING',
|
||||
IN_REVIEW: 'IN_REVIEW',
|
||||
COMPLETED: 'COMPLETED',
|
||||
} as const
|
||||
export type ReviewQueueStatus = typeof ReviewQueueStatus[keyof typeof ReviewQueueStatus]
|
||||
// ── Note ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReviewQueueItem {
|
||||
export interface ReviewNote {
|
||||
id: string
|
||||
matchId: string
|
||||
needId: string
|
||||
propertyId: string
|
||||
matchScore: number
|
||||
content: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ── Task ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReviewTask {
|
||||
id: string
|
||||
entityType: ReviewEntityType
|
||||
entityId: string
|
||||
title: string
|
||||
description?: string
|
||||
priority: ReviewPriority
|
||||
status: ReviewQueueStatus
|
||||
status: ReviewTaskStatus
|
||||
assignedTo?: string
|
||||
notes?: string
|
||||
dueAt?: string
|
||||
organizationId?: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
dueDate?: string
|
||||
reviewNotes: ReviewNote[]
|
||||
relatedOrganizationId?: string
|
||||
// Risk / confidence context
|
||||
confidenceScore?: number
|
||||
riskLevel?: string
|
||||
// AI-output context
|
||||
promptVersion?: string
|
||||
// Legacy compat (match-based items)
|
||||
matchId?: string
|
||||
needId?: string
|
||||
propertyId?: string
|
||||
matchScore?: number
|
||||
}
|
||||
|
||||
// ── Backward-compat aliases ───────────────────────────────────────────────────
|
||||
|
||||
export type ReviewQueueItem = ReviewTask
|
||||
export type ReviewQueueStatus = ReviewTaskStatus
|
||||
|
||||
/** @deprecated use ReviewTaskStatus */
|
||||
export const ReviewQueueStatus = ReviewTaskStatus
|
||||
|
||||
@@ -1,26 +1,77 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { reviewService } from '../services/reviewService'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import type { ReviewFilters } from '../provider/IReviewProvider'
|
||||
import type { ReviewTaskStatus } from '../domain/review'
|
||||
|
||||
const STALE_REVIEW = 30_000
|
||||
const QK = 'reviewQueue'
|
||||
|
||||
export function useReviewQueue(filters?: ReviewFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['reviewQueue', filters ?? {}],
|
||||
queryFn: () => reviewService.getQueue(filters),
|
||||
queryKey: [QK, filters ?? {}],
|
||||
queryFn: () => reviewService.getTasks(filters),
|
||||
staleTime: STALE_REVIEW,
|
||||
select: (res) => res.data ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
export function useReviewTask(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [QK, 'task', id],
|
||||
queryFn: () => reviewService.getTask(id!),
|
||||
staleTime: STALE_REVIEW,
|
||||
enabled: !!id,
|
||||
select: (res) => res.data ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateReviewStatus() {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentUser } = useSessionStore.getState()
|
||||
const userId = currentUser?.email ?? 'unknown'
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status, note }: { id: string; status: ReviewTaskStatus; note?: string }) =>
|
||||
reviewService.updateStatus(id, status, userId, note),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QK] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAssignReviewTask() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, assignTo }: { id: string; assignTo: string }) =>
|
||||
reviewService.assign(id, assignTo),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QK] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddReviewNote() {
|
||||
const queryClient = useQueryClient()
|
||||
const { currentUser } = useSessionStore.getState()
|
||||
const userId = currentUser?.email ?? 'unknown'
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) =>
|
||||
reviewService.addNote(id, { content, createdBy: userId, createdAt: new Date().toISOString() }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QK] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Legacy exports
|
||||
export function useApproveReviewItem() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||
reviewService.approve(id, 'current-user', notes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['reviewQueue'] })
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,8 +80,6 @@ export function useRejectReviewItem() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||
reviewService.reject(id, 'current-user', notes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['reviewQueue'] })
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }),
|
||||
})
|
||||
}
|
||||
|
||||
+174
-30
@@ -1,43 +1,187 @@
|
||||
import type { ReviewQueueItem } from '../domain/review'
|
||||
import type { ReviewTask } from '../domain/review'
|
||||
|
||||
export const mockReviewQueue: ReviewQueueItem[] = [
|
||||
export const mockReviewQueue: ReviewTask[] = [
|
||||
{
|
||||
id: 'review-001',
|
||||
matchId: 'match-001',
|
||||
needId: 'need-001',
|
||||
propertyId: 'prop-001',
|
||||
matchScore: 88,
|
||||
id: 'rev-001',
|
||||
entityType: 'FUTURE_SIGNAL',
|
||||
entityId: 'sig-001',
|
||||
title: 'Vertrauliches Signal: Auszug Zürich-Nord Seebach',
|
||||
description: 'Signal mit Sensitivity RESTRICTED — muss vor Anzeige im Demand Feed manuell geprüft werden. KI-Konfidenz 82%. Quelle: Stellenausschreibungen.',
|
||||
priority: 'CRITICAL',
|
||||
status: 'PENDING',
|
||||
createdBy: 'system',
|
||||
createdAt: '2026-05-15T08:00:00Z',
|
||||
updatedAt: '2026-05-15T08:00:00Z',
|
||||
dueDate: '2026-05-19T17:00:00Z',
|
||||
reviewNotes: [],
|
||||
relatedOrganizationId: 'org-wincasa',
|
||||
confidenceScore: 0.82,
|
||||
riskLevel: 'HIGH',
|
||||
},
|
||||
{
|
||||
id: 'rev-002',
|
||||
entityType: 'LOW_CONFIDENCE_MATCH',
|
||||
entityId: 'match-021',
|
||||
title: 'Niedriger Konfidenz-Match: Bern Wabern Office',
|
||||
description: 'Match-Score 47 mit Konfidenz 0.38. Fehlende Daten: Mietpreis, Ausbaustandard. AI-Begründung unsicher.',
|
||||
priority: 'HIGH',
|
||||
status: 'PENDING',
|
||||
assignedTo: 'user-001',
|
||||
dueAt: '2025-06-15T17:00:00Z',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-05-10T08:00:00Z',
|
||||
updatedAt: '2025-05-10T08:00:00Z',
|
||||
createdBy: 'system',
|
||||
createdAt: '2026-05-15T09:30:00Z',
|
||||
updatedAt: '2026-05-15T09:30:00Z',
|
||||
dueDate: '2026-05-22T17:00:00Z',
|
||||
reviewNotes: [],
|
||||
relatedOrganizationId: 'org-wincasa',
|
||||
confidenceScore: 0.38,
|
||||
riskLevel: 'MEDIUM',
|
||||
matchId: 'match-021',
|
||||
matchScore: 47,
|
||||
},
|
||||
{
|
||||
id: 'review-002',
|
||||
matchId: 'match-002',
|
||||
needId: 'need-001',
|
||||
propertyId: 'prop-004',
|
||||
matchScore: 64,
|
||||
id: 'rev-003',
|
||||
entityType: 'CONTACT_RELEASE',
|
||||
entityId: 'need-005',
|
||||
title: 'Kontaktfreigabe: Mobimo Management AG → Zollstrasse 12',
|
||||
description: 'Nachfrager Mobimo AG bittet um Direktkontakt mit Eigentümer Wincasa. Anfrage durch property-manager prüfen.',
|
||||
priority: 'HIGH',
|
||||
status: 'PENDING',
|
||||
createdBy: 'user-dem',
|
||||
createdAt: '2026-05-15T11:00:00Z',
|
||||
updatedAt: '2026-05-15T11:00:00Z',
|
||||
dueDate: '2026-05-20T17:00:00Z',
|
||||
reviewNotes: [
|
||||
{
|
||||
id: 'note-001',
|
||||
content: 'Nachfrager hat Bonität A+ bei CRIF. Anfrage scheint seriös.',
|
||||
createdBy: 'admin@ideal-sharing.ch',
|
||||
createdAt: '2026-05-15T12:00:00Z',
|
||||
},
|
||||
],
|
||||
relatedOrganizationId: 'org-wincasa',
|
||||
confidenceScore: 0.91,
|
||||
riskLevel: 'LOW',
|
||||
},
|
||||
{
|
||||
id: 'rev-004',
|
||||
entityType: 'MATCH_EXPLANATION',
|
||||
entityId: 'match-008',
|
||||
title: 'Match-Begründung prüfen: Basel Logistik',
|
||||
description: 'AI-Erklärung für Match enthält widersprüchliche Faktoren. Konfidenz niedrig bei Soft Factors.',
|
||||
priority: 'HIGH',
|
||||
status: 'IN_REVIEW',
|
||||
assignedTo: 'user-rev',
|
||||
createdBy: 'system',
|
||||
createdAt: '2026-05-14T14:00:00Z',
|
||||
updatedAt: '2026-05-15T08:30:00Z',
|
||||
dueDate: '2026-05-18T17:00:00Z',
|
||||
reviewNotes: [
|
||||
{
|
||||
id: 'note-002',
|
||||
content: 'Soft Factor "Passantenfrequenz" ist für Logistik nicht relevant. Score neu berechnen.',
|
||||
createdBy: 'reviewer@ideal-sharing.ch',
|
||||
createdAt: '2026-05-15T08:30:00Z',
|
||||
},
|
||||
],
|
||||
relatedOrganizationId: 'org-mobimo',
|
||||
confidenceScore: 0.55,
|
||||
riskLevel: 'MEDIUM',
|
||||
matchId: 'match-008',
|
||||
matchScore: 63,
|
||||
},
|
||||
{
|
||||
id: 'rev-005',
|
||||
entityType: 'AI_OUTPUT',
|
||||
entityId: 'ai-summary-zug-001',
|
||||
title: 'AI Portfolio-Zusammenfassung: Zug Kantonsstrasse',
|
||||
description: 'Automatisch generierte Marktzusammenfassung für Zug-Portfolio. Vor Weitergabe an Eigentümer prüfen.',
|
||||
priority: 'MEDIUM',
|
||||
status: 'PENDING',
|
||||
dueAt: '2025-06-20T17:00:00Z',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-05-10T08:15:00Z',
|
||||
updatedAt: '2025-05-10T08:15:00Z',
|
||||
createdBy: 'system',
|
||||
createdAt: '2026-05-14T10:00:00Z',
|
||||
updatedAt: '2026-05-14T10:00:00Z',
|
||||
reviewNotes: [],
|
||||
relatedOrganizationId: 'org-ubs',
|
||||
confidenceScore: 0.72,
|
||||
riskLevel: 'LOW',
|
||||
promptVersion: 'claude-3-5-sonnet-v1.2',
|
||||
},
|
||||
{
|
||||
id: 'review-003',
|
||||
matchId: 'match-004',
|
||||
needId: 'need-002',
|
||||
propertyId: 'prop-006',
|
||||
matchScore: 47,
|
||||
id: 'rev-006',
|
||||
entityType: 'PROPERTY_DATA_ISSUE',
|
||||
entityId: 'prop-031',
|
||||
title: 'Datenfehler: Mietpreis fehlt — Bürofläche Winterthur',
|
||||
description: 'Pflichtfeld Mietpreis/m² fehlt seit Import. Match-Scoring wird blockiert. Quelle: HomegateImport 2026-05-10.',
|
||||
priority: 'MEDIUM',
|
||||
status: 'NEEDS_MORE_DATA',
|
||||
createdBy: 'system',
|
||||
createdAt: '2026-05-13T07:00:00Z',
|
||||
updatedAt: '2026-05-14T16:00:00Z',
|
||||
reviewNotes: [
|
||||
{
|
||||
id: 'note-003',
|
||||
content: 'PM wurde benachrichtigt. Wartet auf Rückmeldung vom Eigentümer.',
|
||||
createdBy: 'admin@ideal-sharing.ch',
|
||||
createdAt: '2026-05-14T16:00:00Z',
|
||||
},
|
||||
],
|
||||
relatedOrganizationId: 'org-wincasa',
|
||||
riskLevel: 'MEDIUM',
|
||||
propertyId: 'prop-031',
|
||||
},
|
||||
{
|
||||
id: 'rev-007',
|
||||
entityType: 'FUTURE_SIGNAL',
|
||||
entityId: 'sig-004',
|
||||
title: 'Signal genehmigt: Baugesuch Basel-Gündeldingen',
|
||||
description: 'Öffentliches Baugesuch für Büroumbau. Quelle: Kantonales Amtsblatt.',
|
||||
priority: 'LOW',
|
||||
status: 'PENDING',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-05-11T09:00:00Z',
|
||||
updatedAt: '2025-05-11T09:00:00Z',
|
||||
status: 'APPROVED',
|
||||
assignedTo: 'user-rev',
|
||||
createdBy: 'system',
|
||||
createdAt: '2026-05-10T09:00:00Z',
|
||||
updatedAt: '2026-05-12T14:30:00Z',
|
||||
reviewNotes: [
|
||||
{
|
||||
id: 'note-004',
|
||||
content: 'Quelle verifiziert. Baugesuch öffentlich zugänglich. Freigabe für Demand Feed.',
|
||||
createdBy: 'reviewer@ideal-sharing.ch',
|
||||
createdAt: '2026-05-12T14:30:00Z',
|
||||
},
|
||||
],
|
||||
relatedOrganizationId: 'org-wincasa',
|
||||
confidenceScore: 0.78,
|
||||
riskLevel: 'LOW',
|
||||
},
|
||||
{
|
||||
id: 'rev-008',
|
||||
entityType: 'LOW_CONFIDENCE_MATCH',
|
||||
entityId: 'match-015',
|
||||
title: 'Eskaliert: Match Zürich Retail — widersprüchliche Daten',
|
||||
description: 'Match wurde eskaliert weil Fläche lt. Inserat (320m²) und Katasterdaten (290m²) abweichen.',
|
||||
priority: 'CRITICAL',
|
||||
status: 'ESCALATED',
|
||||
assignedTo: 'user-001',
|
||||
createdBy: 'user-rev',
|
||||
createdAt: '2026-05-11T15:00:00Z',
|
||||
updatedAt: '2026-05-13T09:00:00Z',
|
||||
reviewNotes: [
|
||||
{
|
||||
id: 'note-005',
|
||||
content: 'Abweichung 30m² zwischen Inserat und Kataster. Klärung mit Eigentümer notwendig.',
|
||||
createdBy: 'reviewer@ideal-sharing.ch',
|
||||
createdAt: '2026-05-12T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'note-006',
|
||||
content: 'An Org-Admin eskaliert für finale Entscheidung.',
|
||||
createdBy: 'reviewer@ideal-sharing.ch',
|
||||
createdAt: '2026-05-13T09:00:00Z',
|
||||
},
|
||||
],
|
||||
relatedOrganizationId: 'org-wincasa',
|
||||
confidenceScore: 0.44,
|
||||
riskLevel: 'HIGH',
|
||||
matchId: 'match-015',
|
||||
matchScore: 61,
|
||||
},
|
||||
]
|
||||
|
||||
+149
-343
@@ -1,377 +1,183 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Typography,
|
||||
TextField,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Alert,
|
||||
} from '@mui/material'
|
||||
import { Target, TrendingUp } from 'lucide-react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { matchService } from '../../services/matchService'
|
||||
import { futureSignalService } from '../../services/futureSignalService'
|
||||
import { RiskLevel } from '../../domain/enums'
|
||||
import { EmptyState } from '../../components/ui'
|
||||
ReviewFilterBar,
|
||||
ReviewTaskCard,
|
||||
ReviewDetailPanel,
|
||||
ReviewEmptyState,
|
||||
} from '../../components/review'
|
||||
import { useReviewQueue, useUpdateReviewStatus, useAddReviewNote } from '../../hooks/useReviewQueue'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import type { ReviewTask, ReviewTaskStatus } from '../../domain/review'
|
||||
import type { ReviewFilters } from '../../provider/IReviewProvider'
|
||||
|
||||
type ReviewItemType = 'MATCH' | 'SIGNAL'
|
||||
|
||||
interface ReviewItem {
|
||||
id: string
|
||||
type: ReviewItemType
|
||||
title: string
|
||||
confidence: number
|
||||
risk?: RiskLevel
|
||||
summary?: string
|
||||
probability?: number
|
||||
}
|
||||
|
||||
function getPriorityLabel(confidence: number): string {
|
||||
return confidence > 0.8 ? 'Kritisch' : 'Normal'
|
||||
}
|
||||
|
||||
function getPriorityColor(confidence: number): 'error' | 'primary' {
|
||||
return confidence > 0.8 ? 'error' : 'primary'
|
||||
}
|
||||
|
||||
function getRiskLabel(risk?: RiskLevel): string {
|
||||
if (!risk) return '–'
|
||||
switch (risk) {
|
||||
case RiskLevel.LOW: return 'Niedrig'
|
||||
case RiskLevel.MEDIUM: return 'Mittel'
|
||||
case RiskLevel.HIGH: return 'Hoch'
|
||||
case RiskLevel.CRITICAL: return 'Kritisch'
|
||||
}
|
||||
}
|
||||
|
||||
function getRiskColor(risk?: RiskLevel): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (!risk) return 'default'
|
||||
if (risk === RiskLevel.LOW) return 'success'
|
||||
if (risk === RiskLevel.MEDIUM) return 'warning'
|
||||
return 'error'
|
||||
function filterTasks(tasks: ReviewTask[], filters: ReviewFilters): ReviewTask[] {
|
||||
return tasks.filter(t => {
|
||||
if (filters.status && t.status !== filters.status) return false
|
||||
if (filters.priority && t.priority !== filters.priority) return false
|
||||
if (filters.entityType && t.entityType !== filters.entityType) return false
|
||||
if (filters.assignedTo && t.assignedTo !== filters.assignedTo) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export default function ReviewQueue() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeItem, setActiveItem] = useState<string | null>(null)
|
||||
const [reviewNotes, setReviewNotes] = useState('')
|
||||
const [approvedIds, setApprovedIds] = useState<Set<string>>(new Set())
|
||||
const [rejectedIds, setRejectedIds] = useState<Set<string>>(new Set())
|
||||
const { currentUser } = useSessionStore()
|
||||
const userRole = currentUser?.role ?? 'REVIEWER'
|
||||
|
||||
const { data: matchResp, isLoading: matchLoading } = useQuery({
|
||||
queryKey: ['matches'],
|
||||
queryFn: () => matchService.getAll(),
|
||||
})
|
||||
const { data: signalResp, isLoading: signalLoading } = useQuery({
|
||||
queryKey: ['futureSignals'],
|
||||
queryFn: () => futureSignalService.getAll(),
|
||||
})
|
||||
const [filters, setFilters] = useState<ReviewFilters>({})
|
||||
const [selectedTask, setSelectedTask] = useState<ReviewTask | null>(null)
|
||||
|
||||
const matches = matchResp?.data ?? []
|
||||
const signals = signalResp?.data ?? []
|
||||
const { data: allTasks = [], isLoading } = useReviewQueue()
|
||||
const updateStatus = useUpdateReviewStatus()
|
||||
const addNote = useAddReviewNote()
|
||||
|
||||
// Review items = matches NOT approved + future signals NOT verified
|
||||
const matchItems: ReviewItem[] = matches
|
||||
.filter(m => !m.isApproved && !approvedIds.has(m.id) && !rejectedIds.has(m.id))
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
type: 'MATCH' as ReviewItemType,
|
||||
title: `Match: ${m.propertyId} / ${m.needId}`,
|
||||
confidence: m.confidenceLevel,
|
||||
risk: m.riskLevel,
|
||||
summary: m.explainabilitySummary,
|
||||
}))
|
||||
const filtered = filterTasks(allTasks, filters)
|
||||
|
||||
const signalItems: ReviewItem[] = signals
|
||||
.filter(s => !s.isVerified && !approvedIds.has(s.id) && !rejectedIds.has(s.id))
|
||||
.map(s => ({
|
||||
id: s.id,
|
||||
type: 'SIGNAL' as ReviewItemType,
|
||||
title: `${s.signalType}: ${s.locationHint}`,
|
||||
confidence: s.confidenceScore,
|
||||
risk: s.riskLevel,
|
||||
probability: s.probability,
|
||||
summary: s.disclaimer,
|
||||
}))
|
||||
const pendingCount = allTasks.filter(t => t.status === 'PENDING').length
|
||||
const inReviewCount = allTasks.filter(t => t.status === 'IN_REVIEW').length
|
||||
const escalatedCount = allTasks.filter(t => t.status === 'ESCALATED').length
|
||||
const criticalCount = allTasks.filter(t => t.priority === 'CRITICAL' && (t.status === 'PENDING' || t.status === 'IN_REVIEW')).length
|
||||
|
||||
const allItems = [...matchItems, ...signalItems]
|
||||
const selectedItem = allItems.find(i => i.id === activeItem)
|
||||
|
||||
const isLoading = matchLoading || signalLoading
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!activeItem) return
|
||||
const item = allItems.find(i => i.id === activeItem)
|
||||
if (item?.type === 'MATCH') {
|
||||
await matchService.approve(activeItem, 'admin@ideal-sharing.ch')
|
||||
await queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||
} else if (item?.type === 'SIGNAL') {
|
||||
await futureSignalService.verify(activeItem, 'admin@ideal-sharing.ch')
|
||||
await queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||
}
|
||||
setApprovedIds(prev => new Set([...prev, activeItem]))
|
||||
setActiveItem(null)
|
||||
setReviewNotes('')
|
||||
const handleAction = (status: ReviewTaskStatus) => {
|
||||
if (!selectedTask) return
|
||||
updateStatus.mutate(
|
||||
{ id: selectedTask.id, status },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setSelectedTask(res.data)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
if (!activeItem) return
|
||||
setRejectedIds(prev => new Set([...prev, activeItem]))
|
||||
setActiveItem(null)
|
||||
setReviewNotes('')
|
||||
const handleAddNote = (content: string) => {
|
||||
if (!selectedTask) return
|
||||
addNote.mutate(
|
||||
{ id: selectedTask.id, content },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setSelectedTask(res.data)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
const handleSelect = (task: ReviewTask) => {
|
||||
setSelectedTask(task)
|
||||
}
|
||||
|
||||
const isSubmitting = updateStatus.isPending || addNote.isPending
|
||||
|
||||
if (!currentUser || !currentUser.allowedWorkspaces.includes('OPERATIONS')) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
|
||||
<CircularProgress />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 56px)', overflow: 'hidden' }}>
|
||||
<ReviewEmptyState variant="no-permission" />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const totalPending = matchItems.length + signalItems.length
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Page Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">
|
||||
Review Queue
|
||||
</Typography>
|
||||
{totalPending > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 56px)', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 2.5, py: 1.5, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
|
||||
<ShieldCheck size={18} color="#1e3a5f" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1rem', color: '#1e293b' }}>
|
||||
Review Queue
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
{pendingCount > 0 && (
|
||||
<Chip
|
||||
label={totalPending}
|
||||
label={`${pendingCount} Ausstehend`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#d97706', color: 'white', fontWeight: 700 }}
|
||||
sx={{ bgcolor: '#fef3c7', color: '#92400e', fontWeight: 600, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{inReviewCount > 0 && (
|
||||
<Chip
|
||||
label={`${inReviewCount} In Prüfung`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#e0f2fe', color: '#075985', fontWeight: 600, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{escalatedCount > 0 && (
|
||||
<Chip
|
||||
label={`${escalatedCount} Eskaliert`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#fff7ed', color: '#c2410c', fontWeight: 600, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{criticalCount > 0 && (
|
||||
<Chip
|
||||
label={`${criticalCount} Kritisch`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#fee2e2', color: '#991b1b', fontWeight: 600, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Human-in-the-loop Prüfung
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Human-in-the-loop Governance für KI-Outputs, Matches und Datenfehler
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, py: 3 }}>
|
||||
{/* Stats row */}
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 3 }}>
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 1.4, display: 'block' }}>
|
||||
Offene Reviews
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color: totalPending > 0 ? 'warning.main' : 'text.primary' }}>
|
||||
{totalPending}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Ausstehende Prüfungen
|
||||
</Typography>
|
||||
</Card>
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 1.4, display: 'block' }}>
|
||||
Signale zur Prüfung
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color: signalItems.length > 0 ? 'warning.main' : 'text.primary' }}>
|
||||
{signalItems.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Unverifizierte Signale
|
||||
</Typography>
|
||||
</Card>
|
||||
{/* Filter bar */}
|
||||
<ReviewFilterBar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
totalCount={allTasks.length}
|
||||
filteredCount={filtered.length}
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* Left: task list */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 360,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">Laden…</Typography>
|
||||
</Box>
|
||||
) : filtered.length === 0 ? (
|
||||
<ReviewEmptyState variant={allTasks.length === 0 ? 'empty-queue' : 'empty-queue'} />
|
||||
) : (
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filtered.map(task => (
|
||||
<ReviewTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTask?.id === task.id}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
|
||||
{/* Left: Item List */}
|
||||
<Card sx={{ p: 0, overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Ausstehende Elemente
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{allItems.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Keine ausstehenden Reviews"
|
||||
description="Alle Elemente wurden geprüft."
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
{allItems.map(item => (
|
||||
<Box
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveItem(item.id)
|
||||
setReviewNotes('')
|
||||
}}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #f8fafc',
|
||||
bgcolor: activeItem === item.id ? '#eff6ff' : 'white',
|
||||
'&:hover': { bgcolor: activeItem === item.id ? '#eff6ff' : '#fafafa' },
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
bgcolor: item.type === 'MATCH' ? '#eff6ff' : '#faf5ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
{item.type === 'MATCH'
|
||||
? <Target size={16} color="#1e3a5f" />
|
||||
: <TrendingUp size={16} color="#7c3aed" />
|
||||
}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
|
||||
{item.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={getPriorityLabel(item.confidence)}
|
||||
size="small"
|
||||
color={getPriorityColor(item.confidence)}
|
||||
variant="outlined"
|
||||
sx={{ fontSize: 10 }}
|
||||
/>
|
||||
<Chip
|
||||
label="Ausstehend"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#fef3c7', color: '#92400e', fontSize: 10 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Right: Review Panel */}
|
||||
<Card sx={{ p: 0, overflow: 'hidden' }}>
|
||||
{!selectedItem ? (
|
||||
<EmptyState
|
||||
title="Wählen Sie ein Element zur Prüfung"
|
||||
description="Klicken Sie auf ein Element in der Liste, um es zu prüfen."
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Chip
|
||||
label={selectedItem.type === 'MATCH' ? 'Match' : 'Signal'}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: selectedItem.type === 'MATCH' ? '#eff6ff' : '#faf5ff',
|
||||
color: selectedItem.type === 'MATCH' ? '#1e3a5f' : '#7c3aed',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{selectedItem.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2.5, py: 2 }}>
|
||||
{/* Key facts */}
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Konfidenz</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{Math.round(selectedItem.confidence * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
{selectedItem.probability != null && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Wahrscheinlichkeit</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{Math.round(selectedItem.probability * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Risiko</Typography>
|
||||
<Chip label={getRiskLabel(selectedItem.risk)}
|
||||
size="small"
|
||||
color={getRiskColor(selectedItem.risk)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{selectedItem.summary && (
|
||||
<Alert severity="info" sx={{ mb: 2, '& .MuiAlert-message': { fontSize: 13 } }}>
|
||||
{selectedItem.summary}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* Notes */}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
|
||||
Notizen
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
placeholder="Optionale Anmerkungen zur Entscheidung..."
|
||||
value={reviewNotes}
|
||||
onChange={e => setReviewNotes(e.target.value)}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* Decision buttons */}
|
||||
<Stack spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleApprove}
|
||||
sx={{ bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#155f3a' } }}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
color="error"
|
||||
onClick={handleReject}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
sx={{ color: '#64748b', borderColor: '#e2e8f0' }}
|
||||
>
|
||||
Weiterleiten
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
{/* Right: detail panel */}
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'white' }}>
|
||||
{selectedTask ? (
|
||||
<ReviewDetailPanel
|
||||
task={selectedTask}
|
||||
userRole={userRole}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
onAction={handleAction}
|
||||
onAddNote={handleAddNote}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
) : (
|
||||
<ReviewEmptyState variant="no-selection" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { ReviewQueueItem, ReviewPriority, ReviewQueueStatus } from '../domain/review'
|
||||
import type { ReviewTask, ReviewPriority, ReviewTaskStatus, ReviewEntityType, ReviewNote } from '../domain/review'
|
||||
|
||||
export interface ReviewFilters {
|
||||
priority?: ReviewPriority
|
||||
status?: ReviewQueueStatus
|
||||
status?: ReviewTaskStatus
|
||||
entityType?: ReviewEntityType
|
||||
assignedTo?: string
|
||||
organizationId?: string
|
||||
}
|
||||
|
||||
export interface IReviewProvider {
|
||||
getQueue(filters?: ReviewFilters): Promise<ReviewQueueItem[]>
|
||||
getById(id: string): Promise<ReviewQueueItem | null>
|
||||
approve(id: string, reviewedBy: string, notes?: string): Promise<ReviewQueueItem>
|
||||
reject(id: string, reviewedBy: string, notes?: string): Promise<ReviewQueueItem>
|
||||
assign(id: string, assignTo: string): Promise<ReviewQueueItem>
|
||||
getQueue(filters?: ReviewFilters): Promise<ReviewTask[]>
|
||||
getById(id: string): Promise<ReviewTask | null>
|
||||
updateStatus(id: string, status: ReviewTaskStatus, userId: string, note?: string): Promise<ReviewTask>
|
||||
addNote(id: string, note: Omit<ReviewNote, 'id'>): Promise<ReviewTask>
|
||||
assign(id: string, assignTo: string): Promise<ReviewTask>
|
||||
// Legacy actions (delegate to updateStatus internally)
|
||||
approve(id: string, reviewedBy: string, notes?: string): Promise<ReviewTask>
|
||||
reject(id: string, reviewedBy: string, notes?: string): Promise<ReviewTask>
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const MockupDashboardProvider: IDashboardProvider = {
|
||||
matches = matches.filter(m => m.organizationId === organizationId)
|
||||
needs = needs.filter(n => n.organizationId === organizationId)
|
||||
signals = signals.filter(s => s.organizationId === organizationId)
|
||||
queue = queue.filter(r => r.organizationId === organizationId)
|
||||
queue = queue.filter(r => r.relatedOrganizationId === organizationId)
|
||||
}
|
||||
|
||||
const avgScore =
|
||||
|
||||
@@ -1,42 +1,76 @@
|
||||
import type { IReviewProvider, ReviewFilters } from './IReviewProvider'
|
||||
import type { ReviewQueueItem } from '../domain/review'
|
||||
import type { ReviewTask, ReviewNote } from '../domain/review'
|
||||
import { mockReviewQueue } from '../mock-data/reviewQueue'
|
||||
import { mockDelay } from '../lib/mockUtils'
|
||||
|
||||
const store: ReviewQueueItem[] = [...mockReviewQueue]
|
||||
const store: ReviewTask[] = [...mockReviewQueue]
|
||||
|
||||
const priorityOrder: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||
const priorityOrder: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||
|
||||
function makeNote(content: string, createdBy: string): ReviewNote {
|
||||
return {
|
||||
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
content,
|
||||
createdBy,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export const MockupReviewProvider: IReviewProvider = {
|
||||
async getQueue(filters?: ReviewFilters) {
|
||||
await mockDelay()
|
||||
let results = [...store]
|
||||
if (filters?.priority) results = results.filter(r => r.priority === filters.priority)
|
||||
if (filters?.status) results = results.filter(r => r.status === filters.status)
|
||||
if (filters?.assignedTo) results = results.filter(r => r.assignedTo === filters.assignedTo)
|
||||
if (filters?.organizationId) results = results.filter(r => r.organizationId === filters.organizationId)
|
||||
if (filters?.priority) results = results.filter(r => r.priority === filters.priority)
|
||||
if (filters?.status) results = results.filter(r => r.status === filters.status)
|
||||
if (filters?.entityType) results = results.filter(r => r.entityType === filters.entityType)
|
||||
if (filters?.assignedTo) results = results.filter(r => r.assignedTo === filters.assignedTo)
|
||||
if (filters?.organizationId) results = results.filter(r => r.relatedOrganizationId === filters.organizationId)
|
||||
return results.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9))
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
await mockDelay()
|
||||
return store.find(r => r.id === id) ?? null
|
||||
},
|
||||
async approve(id, _reviewedBy, notes?) {
|
||||
|
||||
async updateStatus(id, status, userId, note?) {
|
||||
await mockDelay()
|
||||
const idx = store.findIndex(r => r.id === id)
|
||||
store[idx] = { ...store[idx], status: 'COMPLETED', notes, updatedAt: new Date().toISOString() }
|
||||
const notes = [...store[idx].reviewNotes]
|
||||
if (note) notes.push(makeNote(note, userId))
|
||||
store[idx] = { ...store[idx], status, reviewNotes: notes, updatedAt: new Date().toISOString() }
|
||||
return store[idx]
|
||||
},
|
||||
async reject(id, _reviewedBy, notes?) {
|
||||
|
||||
async addNote(id, note) {
|
||||
await mockDelay()
|
||||
const idx = store.findIndex(r => r.id === id)
|
||||
store[idx] = { ...store[idx], status: 'COMPLETED', notes, updatedAt: new Date().toISOString() }
|
||||
const newNote: ReviewNote = { id: `note-${Date.now()}`, ...note }
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
reviewNotes: [...store[idx].reviewNotes, newNote],
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
return store[idx]
|
||||
},
|
||||
|
||||
async assign(id, assignTo) {
|
||||
await mockDelay()
|
||||
const idx = store.findIndex(r => r.id === id)
|
||||
store[idx] = { ...store[idx], assignedTo: assignTo, status: 'IN_REVIEW', updatedAt: new Date().toISOString() }
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
assignedTo: assignTo,
|
||||
status: store[idx].status === 'PENDING' ? 'IN_REVIEW' : store[idx].status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
return store[idx]
|
||||
},
|
||||
|
||||
async approve(id, reviewedBy, notes?) {
|
||||
return this.updateStatus(id, 'APPROVED', reviewedBy, notes)
|
||||
},
|
||||
|
||||
async reject(id, reviewedBy, notes?) {
|
||||
return this.updateStatus(id, 'REJECTED', reviewedBy, notes)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
import { MockupReviewProvider } from '../provider/MockupReviewProvider'
|
||||
import type { ReviewFilters } from '../provider/IReviewProvider'
|
||||
import type { ReviewQueueItem } from '../domain/review'
|
||||
import type { ReviewTask, ReviewTaskStatus, ReviewNote } from '../domain/review'
|
||||
import type { DashboardReviewTask } from '../domain/dashboard'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
|
||||
const provider = MockupReviewProvider
|
||||
|
||||
export const reviewService = {
|
||||
async getQueue(filters?: ReviewFilters): Promise<ListResponse<ReviewQueueItem>> {
|
||||
async getQueue(filters?: ReviewFilters): Promise<ListResponse<ReviewTask>> {
|
||||
const data = await provider.getQueue(filters)
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
},
|
||||
async getById(id: string): Promise<ItemResponse<ReviewQueueItem | null>> {
|
||||
|
||||
async getTasks(filters?: ReviewFilters): Promise<ListResponse<ReviewTask>> {
|
||||
return this.getQueue(filters)
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<ItemResponse<ReviewTask | null>> {
|
||||
const data = await provider.getById(id)
|
||||
return { data }
|
||||
},
|
||||
async approve(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewQueueItem>> {
|
||||
|
||||
async getTask(id: string): Promise<ItemResponse<ReviewTask | null>> {
|
||||
return this.getById(id)
|
||||
},
|
||||
|
||||
async updateStatus(id: string, status: ReviewTaskStatus, userId: string, note?: string): Promise<ItemResponse<ReviewTask>> {
|
||||
const data = await provider.updateStatus(id, status, userId, note)
|
||||
return { data }
|
||||
},
|
||||
|
||||
async addNote(id: string, note: Omit<ReviewNote, 'id'>): Promise<ItemResponse<ReviewTask>> {
|
||||
const data = await provider.addNote(id, note)
|
||||
return { data }
|
||||
},
|
||||
|
||||
async approve(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewTask>> {
|
||||
const data = await provider.approve(id, reviewedBy, notes)
|
||||
return { data }
|
||||
},
|
||||
async reject(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewQueueItem>> {
|
||||
|
||||
async reject(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewTask>> {
|
||||
const data = await provider.reject(id, reviewedBy, notes)
|
||||
return { data }
|
||||
},
|
||||
async assign(id: string, assignTo: string): Promise<ItemResponse<ReviewQueueItem>> {
|
||||
|
||||
async assign(id: string, assignTo: string): Promise<ItemResponse<ReviewTask>> {
|
||||
const data = await provider.assign(id, assignTo)
|
||||
return { data }
|
||||
},
|
||||
@@ -35,12 +57,15 @@ export const reviewService = {
|
||||
|
||||
async getDashboardTasks(): Promise<DashboardReviewTask[]> {
|
||||
const items = await provider.getQueue()
|
||||
return items.slice(0, 8).map(r => ({
|
||||
id: r.id,
|
||||
title: `Match ${r.matchId} · Score ${r.matchScore}`,
|
||||
priority: r.priority,
|
||||
status: r.status,
|
||||
type: 'REVIEW',
|
||||
}))
|
||||
return items
|
||||
.filter(r => r.status === 'PENDING' || r.status === 'IN_REVIEW' || r.status === 'ESCALATED')
|
||||
.slice(0, 8)
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
priority: r.priority as 'HIGH' | 'MEDIUM' | 'LOW',
|
||||
status: r.status,
|
||||
type: r.entityType,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user