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 && ( + <> + + + + )} + {canRequestMore && ( + + )} + {canEscalate && ( + + )} + + ) +} 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, + })) }, }