diff --git a/src/components/review/ReviewActionToolbar.tsx b/src/components/review/ReviewActionToolbar.tsx
new file mode 100644
index 0000000..9b4dd92
--- /dev/null
+++ b/src/components/review/ReviewActionToolbar.tsx
@@ -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 (
+
+ {canApproveReject && (
+ <>
+ : }
+ onClick={() => onAction('APPROVED')}
+ sx={{
+ bgcolor: '#1a7a4a',
+ '&:hover': { bgcolor: '#155f3a' },
+ textTransform: 'none',
+ fontSize: '0.75rem',
+ }}
+ >
+ Genehmigen
+
+ }
+ onClick={() => onAction('REJECTED')}
+ sx={{
+ color: '#c0392b',
+ borderColor: '#c0392b',
+ '&:hover': { borderColor: '#a93226', bgcolor: '#fef2f2' },
+ textTransform: 'none',
+ fontSize: '0.75rem',
+ }}
+ >
+ Ablehnen
+
+ >
+ )}
+ {canRequestMore && (
+ }
+ onClick={() => onAction('NEEDS_MORE_DATA')}
+ sx={{
+ color: '#d97706',
+ borderColor: '#d97706',
+ '&:hover': { borderColor: '#b45309', bgcolor: '#fffbeb' },
+ textTransform: 'none',
+ fontSize: '0.75rem',
+ }}
+ >
+ Mehr Daten
+
+ )}
+ {canEscalate && (
+ }
+ onClick={() => onAction('ESCALATED')}
+ sx={{
+ color: '#ea580c',
+ borderColor: '#ea580c',
+ '&:hover': { borderColor: '#c2410c', bgcolor: '#fff7ed' },
+ textTransform: 'none',
+ fontSize: '0.75rem',
+ }}
+ >
+ Eskalieren
+
+ )}
+
+ )
+}
diff --git a/src/components/review/ReviewDetailPanel.tsx b/src/components/review/ReviewDetailPanel.tsx
new file mode 100644
index 0000000..40a15e4
--- /dev/null
+++ b/src/components/review/ReviewDetailPanel.tsx
@@ -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 = {
+ 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 = {
+ LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', CRITICAL: 'Kritisch',
+}
+
+const RISK_COLORS: Record = {
+ LOW: '#1a7a4a', MEDIUM: '#d97706', HIGH: '#ea580c', CRITICAL: '#c0392b',
+}
+
+function MetaRow({ label, value }: { label: string; value: string | undefined }) {
+ if (!value) return null
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+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 (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ {task.title}
+
+
+
+
+
+
+
+
+ {/* Scrollable body */}
+
+ {/* Description */}
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+ {/* Metadata */}
+
+
+
+
+
+ {task.relatedOrganizationId && (
+
+ )}
+ {task.promptVersion && (
+
+ )}
+
+
+ {/* Confidence / Risk context */}
+ {(task.confidenceScore !== undefined || task.riskLevel) && (
+ <>
+
+ {task.confidenceScore !== undefined && (
+
+
+ Konfidenz
+
+ {Math.round(task.confidenceScore * 100)}%
+
+
+ = 0.7 ? '#1a7a4a' : task.confidenceScore >= 0.5 ? '#d97706' : '#c0392b',
+ },
+ }}
+ />
+
+ )}
+ {task.riskLevel && (
+
+ Risikostufe
+
+ {RISK_LABELS[task.riskLevel] ?? task.riskLevel}
+
+
+ )}
+ {task.matchScore !== undefined && (
+
+ Match-Score
+ {task.matchScore}%
+
+ )}
+ >
+ )}
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ {/* Notes */}
+
+
+
+ )
+}
diff --git a/src/components/review/ReviewEmptyState.tsx b/src/components/review/ReviewEmptyState.tsx
new file mode 100644
index 0000000..ff49e06
--- /dev/null
+++ b/src/components/review/ReviewEmptyState.tsx
@@ -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 (
+
+
+ {title}
+ {desc}
+
+ )
+}
diff --git a/src/components/review/ReviewEntityTypeBadge.tsx b/src/components/review/ReviewEntityTypeBadge.tsx
new file mode 100644
index 0000000..8b320d5
--- /dev/null
+++ b/src/components/review/ReviewEntityTypeBadge.tsx
@@ -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 = {
+ 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 (
+
+ )
+}
diff --git a/src/components/review/ReviewFilterBar.tsx b/src/components/review/ReviewFilterBar.tsx
new file mode 100644
index 0000000..80a430e
--- /dev/null
+++ b/src/components/review/ReviewFilterBar.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {activeCount > 0 ? `${filteredCount} / ${totalCount}` : `${totalCount} Aufgaben`}
+
+
+ )
+}
diff --git a/src/components/review/ReviewNotesPanel.tsx b/src/components/review/ReviewNotesPanel.tsx
new file mode 100644
index 0000000..d72a036
--- /dev/null
+++ b/src/components/review/ReviewNotesPanel.tsx
@@ -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 (
+
+
+
+
+ Notizen ({notes.length})
+
+
+
+ {/* Existing notes */}
+ {notes.length > 0 ? (
+
+ {[...notes].reverse().map(note => (
+
+
+
+ {note.createdBy}
+
+
+ {new Date(note.createdAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}
+
+
+
+ {note.content}
+
+
+ ))}
+
+ ) : (
+
+ Noch keine Notizen.
+
+ )}
+
+ {/* Add note */}
+ {canAddNote && (
+
+ setNoteText(e.target.value)}
+ sx={{ mb: 0.75, fontSize: '0.8125rem' }}
+ />
+
+
+ )}
+
+ )
+}
diff --git a/src/components/review/ReviewPriorityBadge.tsx b/src/components/review/ReviewPriorityBadge.tsx
new file mode 100644
index 0000000..6732c33
--- /dev/null
+++ b/src/components/review/ReviewPriorityBadge.tsx
@@ -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 = {
+ 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 (
+
+ )
+}
diff --git a/src/components/review/ReviewStatusBadge.tsx b/src/components/review/ReviewStatusBadge.tsx
new file mode 100644
index 0000000..3f35414
--- /dev/null
+++ b/src/components/review/ReviewStatusBadge.tsx
@@ -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 = {
+ 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 (
+
+ )
+}
diff --git a/src/components/review/ReviewTaskCard.tsx b/src/components/review/ReviewTaskCard.tsx
new file mode 100644
index 0000000..8e5d39b
--- /dev/null
+++ b/src/components/review/ReviewTaskCard.tsx
@@ -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 (
+ 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 */}
+
+
+
+
+
+
+ {/* Title */}
+
+ {task.title}
+
+
+ {/* Description */}
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+ {/* Confidence bar */}
+ {task.confidenceScore !== undefined && (
+
+ = 0.7 ? '#1a7a4a' : task.confidenceScore >= 0.5 ? '#d97706' : '#c0392b',
+ },
+ }}
+ />
+
+ )}
+
+ {/* Footer: meta */}
+
+ {task.assignedTo && (
+
+
+
+ {task.assignedTo}
+
+
+ )}
+ {task.dueDate && (
+
+
+
+ {new Date(task.dueDate).toLocaleDateString('de-CH')}
+
+
+ )}
+
+ {new Date(task.createdAt).toLocaleDateString('de-CH')}
+
+
+
+ )
+}
diff --git a/src/components/review/index.ts b/src/components/review/index.ts
new file mode 100644
index 0000000..2cee22d
--- /dev/null
+++ b/src/components/review/index.ts
@@ -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'
diff --git a/src/domain/review.ts b/src/domain/review.ts
index 8865747..5ade373 100644
--- a/src/domain/review.ts
+++ b/src/domain/review.ts
@@ -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
diff --git a/src/hooks/useReviewQueue.ts b/src/hooks/useReviewQueue.ts
index 825f5ab..282563b 100644
--- a/src/hooks/useReviewQueue.ts
+++ b/src/hooks/useReviewQueue.ts
@@ -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] }),
})
}
diff --git a/src/mock-data/reviewQueue.ts b/src/mock-data/reviewQueue.ts
index 1a6b401..e9f51f2 100644
--- a/src/mock-data/reviewQueue.ts
+++ b/src/mock-data/reviewQueue.ts
@@ -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,
},
]
diff --git a/src/pages/ops/ReviewQueue.tsx b/src/pages/ops/ReviewQueue.tsx
index fe49282..47f4d86 100644
--- a/src/pages/ops/ReviewQueue.tsx
+++ b/src/pages/ops/ReviewQueue.tsx
@@ -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(null)
- const [reviewNotes, setReviewNotes] = useState('')
- const [approvedIds, setApprovedIds] = useState>(new Set())
- const [rejectedIds, setRejectedIds] = useState>(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({})
+ const [selectedTask, setSelectedTask] = useState(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 (
-
-
+
+
)
}
- const totalPending = matchItems.length + signalItems.length
-
return (
-
- {/* Page Header */}
-
-
-
-
- Review Queue
-
- {totalPending > 0 && (
+
+ {/* Header */}
+
+
+
+
+ Review Queue
+
+
+ {pendingCount > 0 && (
+ )}
+ {inReviewCount > 0 && (
+
+ )}
+ {escalatedCount > 0 && (
+
+ )}
+ {criticalCount > 0 && (
+
)}
-
- Human-in-the-loop Prüfung
-
+
+ Human-in-the-loop Governance für KI-Outputs, Matches und Datenfehler
+
-
- {/* Stats row */}
-
-
-
- Offene Reviews
-
- 0 ? 'warning.main' : 'text.primary' }}>
- {totalPending}
-
-
- Ausstehende Prüfungen
-
-
-
-
- Signale zur Prüfung
-
- 0 ? 'warning.main' : 'text.primary' }}>
- {signalItems.length}
-
-
- Unverifizierte Signale
-
-
+ {/* Filter bar */}
+
+
+ {/* Body */}
+
+ {/* Left: task list */}
+
+ {isLoading ? (
+
+ Laden…
+
+ ) : filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map(task => (
+
+ ))}
+
+ )}
- {/* Two-column layout */}
-
- {/* Left: Item List */}
-
-
-
- Ausstehende Elemente
-
-
-
- {allItems.length === 0 ? (
-
- ) : (
-
- {allItems.map(item => (
- {
- 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,
- }}
- >
-
- {item.type === 'MATCH'
- ?
- :
- }
-
-
-
- {item.title}
-
-
-
-
-
-
-
- ))}
-
- )}
-
-
- {/* Right: Review Panel */}
-
- {!selectedItem ? (
-
- ) : (
-
-
-
-
-
- {selectedItem.title}
-
-
-
-
-
- {/* Key facts */}
-
-
-
- Konfidenz
-
- {Math.round(selectedItem.confidence * 100)}%
-
-
- {selectedItem.probability != null && (
-
- Wahrscheinlichkeit
-
- {Math.round(selectedItem.probability * 100)}%
-
-
- )}
-
- Risiko
-
-
-
-
-
- {selectedItem.summary && (
-
- {selectedItem.summary}
-
- )}
-
-
-
- {/* Notes */}
-
- Notizen
-
- setReviewNotes(e.target.value)}
- size="small"
- sx={{ mb: 2 }}
- />
-
- {/* Decision buttons */}
-
-
-
-
-
-
-
- )}
-
+ {/* Right: detail panel */}
+
+ {selectedTask ? (
+ setSelectedTask(null)}
+ onAction={handleAction}
+ onAddNote={handleAddNote}
+ isSubmitting={isSubmitting}
+ />
+ ) : (
+
+ )}
diff --git a/src/provider/IReviewProvider.ts b/src/provider/IReviewProvider.ts
index 944b479..84c44f2 100644
--- a/src/provider/IReviewProvider.ts
+++ b/src/provider/IReviewProvider.ts
@@ -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
- getById(id: string): Promise
- approve(id: string, reviewedBy: string, notes?: string): Promise
- reject(id: string, reviewedBy: string, notes?: string): Promise
- assign(id: string, assignTo: string): Promise
+ getQueue(filters?: ReviewFilters): Promise
+ getById(id: string): Promise
+ updateStatus(id: string, status: ReviewTaskStatus, userId: string, note?: string): Promise
+ addNote(id: string, note: Omit): Promise
+ assign(id: string, assignTo: string): Promise
+ // Legacy actions (delegate to updateStatus internally)
+ approve(id: string, reviewedBy: string, notes?: string): Promise
+ reject(id: string, reviewedBy: string, notes?: string): Promise
}
diff --git a/src/provider/MockupDashboardProvider.ts b/src/provider/MockupDashboardProvider.ts
index 85b4ba4..2941522 100644
--- a/src/provider/MockupDashboardProvider.ts
+++ b/src/provider/MockupDashboardProvider.ts
@@ -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 =
diff --git a/src/provider/MockupReviewProvider.ts b/src/provider/MockupReviewProvider.ts
index 6decb42..ba4a897 100644
--- a/src/provider/MockupReviewProvider.ts
+++ b/src/provider/MockupReviewProvider.ts
@@ -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 = { HIGH: 0, MEDIUM: 1, LOW: 2 }
+const priorityOrder: Record = { 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)
+ },
}
diff --git a/src/services/reviewService.ts b/src/services/reviewService.ts
index 819bedd..6ead966 100644
--- a/src/services/reviewService.ts
+++ b/src/services/reviewService.ts
@@ -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> {
+ async getQueue(filters?: ReviewFilters): Promise> {
const data = await provider.getQueue(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
- async getById(id: string): Promise> {
+
+ async getTasks(filters?: ReviewFilters): Promise> {
+ return this.getQueue(filters)
+ },
+
+ async getById(id: string): Promise> {
const data = await provider.getById(id)
return { data }
},
- async approve(id: string, reviewedBy: string, notes?: string): Promise> {
+
+ async getTask(id: string): Promise> {
+ return this.getById(id)
+ },
+
+ async updateStatus(id: string, status: ReviewTaskStatus, userId: string, note?: string): Promise> {
+ const data = await provider.updateStatus(id, status, userId, note)
+ return { data }
+ },
+
+ async addNote(id: string, note: Omit): Promise> {
+ const data = await provider.addNote(id, note)
+ return { data }
+ },
+
+ async approve(id: string, reviewedBy: string, notes?: string): Promise> {
const data = await provider.approve(id, reviewedBy, notes)
return { data }
},
- async reject(id: string, reviewedBy: string, notes?: string): Promise> {
+
+ async reject(id: string, reviewedBy: string, notes?: string): Promise> {
const data = await provider.reject(id, reviewedBy, notes)
return { data }
},
- async assign(id: string, assignTo: string): Promise> {
+
+ async assign(id: string, assignTo: string): Promise> {
const data = await provider.assign(id, assignTo)
return { data }
},
@@ -35,12 +57,15 @@ export const reviewService = {
async getDashboardTasks(): Promise {
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,
+ }))
},
}