diff --git a/src/components/match-card/MatchCardViewModel.ts b/src/components/match-card/MatchCardViewModel.ts index 20d8e5b..9a79e15 100644 --- a/src/components/match-card/MatchCardViewModel.ts +++ b/src/components/match-card/MatchCardViewModel.ts @@ -8,6 +8,7 @@ export type MatchCardActionType = | 'ADD_COMPARE' | 'SAVE_SHORTLIST' | 'SEND_REVIEW' + | 'APPROVE' | 'REJECT' | 'REQUEST_DATA' diff --git a/src/components/match-center/MatchBriefingPanel.tsx b/src/components/match-center/MatchBriefingPanel.tsx new file mode 100644 index 0000000..f0cd39e --- /dev/null +++ b/src/components/match-center/MatchBriefingPanel.tsx @@ -0,0 +1,91 @@ +import { Box, CircularProgress, Typography } from '@mui/material' +import { useNavigate } from 'react-router' +import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches' +import { usePropertyById } from '../../hooks/useProperties' +import { useMatchCenterStore } from '../../stores/matchCenterStore' +import { useCompareStore } from '../../stores/compareStore' +import { MatchCardExpanded } from '../match-card/MatchCardExpanded' +import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter' +import { reviewService } from '../../services/reviewService' +import { MatchCenterEmptyState } from './MatchCenterEmptyState' +import { MatchStatusBadge } from './MatchStatusBadge' +import type { MatchCardAction } from '../match-card/MatchCardViewModel' +import type { VerifiedPortfolioResult } from '../../domain/unifiedResult' + +export function MatchBriefingPanel() { + const navigate = useNavigate() + const { selectedPropertyId, selectedNeedId } = useMatchCenterStore() + const { addToCompare } = useCompareStore() + const approveMatch = useApproveMatch() + + const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '') + const { data: property = null, isLoading: propLoading } = usePropertyById(selectedPropertyId) + + if (!selectedPropertyId && !selectedNeedId) return + if (selectedPropertyId && !selectedNeedId) return + if (!selectedPropertyId && selectedNeedId) return + + if (matchesLoading || propLoading) { + return ( + + + + ) + } + + const match = matches.find(m => m.needId === selectedNeedId) ?? null + + if (!match || !property) return + + const result: VerifiedPortfolioResult = { + resultType: 'VERIFIED_PORTFOLIO', + matchId: match.id, + needId: match.needId, + matchScore: match.matchScore, + match, + property, + } + + const actions: MatchCardAction[] = [ + { + id: 'approve', + label: 'Genehmigen', + actionType: 'APPROVE', + variant: 'primary', + onClick: () => approveMatch.mutate(match.id), + }, + { + id: 'review', + label: 'Zur Prüfung', + actionType: 'SEND_REVIEW', + variant: 'secondary', + onClick: () => { reviewService.createReviewTask(match.id) }, + }, + { + id: 'compare', + label: 'Vergleichen', + actionType: 'ADD_COMPARE', + variant: 'secondary', + onClick: () => { addToCompare(property.id); navigate('/demand/compare') }, + }, + { + id: 'details', + label: 'Details', + actionType: 'OPEN_DETAIL', + variant: 'secondary', + onClick: () => navigate(`/demand/results/${match.id}`), + }, + ] + + const vm = buildMatchCardViewModel(result, actions) + + return ( + + + Match-Briefing + + + + + ) +} diff --git a/src/components/match-center/MatchCenterEmptyState.tsx b/src/components/match-center/MatchCenterEmptyState.tsx new file mode 100644 index 0000000..ec28c9f --- /dev/null +++ b/src/components/match-center/MatchCenterEmptyState.tsx @@ -0,0 +1,38 @@ +import { Box, Typography } from '@mui/material' +import { Building2, Users, ArrowLeftRight } from 'lucide-react' + +type EmptyContext = 'select-both' | 'no-need' | 'no-property' | 'no-match' + +const META = { + 'select-both': { + icon: , + title: 'Objekt und Bedarf wählen', + desc: 'Wählen Sie ein Objekt links und einen Bedarf rechts, um das Match-Briefing zu sehen.', + }, + 'no-need': { + icon: , + title: 'Keinen Bedarf ausgewählt', + desc: 'Wählen Sie rechts einen Bedarf aus.', + }, + 'no-property': { + icon: , + title: 'Kein Objekt ausgewählt', + desc: 'Wählen Sie links ein Objekt aus.', + }, + 'no-match': { + icon: , + title: 'Kein Match gefunden', + desc: 'Zwischen diesem Objekt und Bedarf existiert kein berechnetes Match.', + }, +} + +export function MatchCenterEmptyState({ context }: { context: EmptyContext }) { + const { icon, title, desc } = META[context] + return ( + + {icon} + {title} + {desc} + + ) +} diff --git a/src/components/match-center/MatchCenterSkeleton.tsx b/src/components/match-center/MatchCenterSkeleton.tsx new file mode 100644 index 0000000..43947b6 --- /dev/null +++ b/src/components/match-center/MatchCenterSkeleton.tsx @@ -0,0 +1,15 @@ +import { Box, Skeleton } from '@mui/material' + +export function MatchCenterSkeleton({ count = 4 }: { count?: number }) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( + + + + + + ))} + + ) +} diff --git a/src/components/match-center/MatchStatusBadge.tsx b/src/components/match-center/MatchStatusBadge.tsx new file mode 100644 index 0000000..2e5a2c2 --- /dev/null +++ b/src/components/match-center/MatchStatusBadge.tsx @@ -0,0 +1,18 @@ +import { Chip } from '@mui/material' +import type { MatchStatus } from '../../domain/enums' + +const STATUS_META: Record = { + PENDING_REVIEW: { label: 'Ausstehend', color: '#d97706' }, + APPROVED: { label: 'Genehmigt', color: '#1a7a4a' }, + REJECTED: { label: 'Abgelehnt', color: '#c0392b' }, + SHORTLISTED: { label: 'Shortlist', color: '#1e3a5f' }, +} + +export function MatchStatusBadge({ status }: { status?: MatchStatus }) { + if (!status) return null + const meta = STATUS_META[status] ?? { label: status, color: '#64748b' } + return ( + + ) +} diff --git a/src/components/match-center/NeedSelectionPanel.tsx b/src/components/match-center/NeedSelectionPanel.tsx new file mode 100644 index 0000000..c51ac1e --- /dev/null +++ b/src/components/match-center/NeedSelectionPanel.tsx @@ -0,0 +1,52 @@ +import { Box, Chip, Typography } from '@mui/material' +import { useNeeds } from '../../hooks/useNeeds' +import { useMatchCenterStore } from '../../stores/matchCenterStore' +import { MatchCenterSkeleton } from './MatchCenterSkeleton' +import type { Match } from '../../domain/match' +import type { Need } from '../../domain/need' + +interface Props { matches: Match[] } + +export function NeedSelectionPanel({ matches }: Props) { + const { data: needs = [], isLoading } = useNeeds() + const { selectedNeedId, setSelectedNeed } = useMatchCenterStore() + + if (isLoading) return + + return ( + + {needs.map((need: Need) => { + const pendingCount = matches.filter(m => m.needId === need.id && m.status === 'PENDING_REVIEW').length + const isSelected = selectedNeedId === need.id + return ( + setSelectedNeed(isSelected ? null : need.id)} + sx={{ + p: 1.5, + cursor: 'pointer', + borderBottom: '1px solid #f1f5f9', + borderRight: isSelected ? '3px solid #d97706' : '3px solid transparent', + bgcolor: isSelected ? '#fef3c7' : 'transparent', + '&:hover': { bgcolor: isSelected ? '#fef3c7' : '#f8fafc' }, + }} + > + + + {need.companyName} + + {pendingCount > 0 && ( + + )} + + + {need.requiredArea.min}–{need.requiredArea.max} m² · {need.preferredLocations.slice(0, 2).join(', ')} + + + + ) + })} + + ) +} diff --git a/src/components/match-center/PropertySelectionPanel.tsx b/src/components/match-center/PropertySelectionPanel.tsx new file mode 100644 index 0000000..3bb13e7 --- /dev/null +++ b/src/components/match-center/PropertySelectionPanel.tsx @@ -0,0 +1,51 @@ +import { Box, Chip, Typography } from '@mui/material' +import { useProperties } from '../../hooks/useProperties' +import { useMatchCenterStore } from '../../stores/matchCenterStore' +import { MatchCenterSkeleton } from './MatchCenterSkeleton' +import type { Match } from '../../domain/match' + +interface Props { matches: Match[] } + +export function PropertySelectionPanel({ matches }: Props) { + const { data: properties = [], isLoading } = useProperties() + const { selectedPropertyId, setSelectedProperty } = useMatchCenterStore() + + if (isLoading) return + + return ( + + {properties.map(prop => { + const pendingCount = matches.filter(m => m.propertyId === prop.id && m.status === 'PENDING_REVIEW').length + const isSelected = selectedPropertyId === prop.id + return ( + setSelectedProperty(isSelected ? null : prop.id)} + sx={{ + p: 1.5, + cursor: 'pointer', + borderBottom: '1px solid #f1f5f9', + borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent', + bgcolor: isSelected ? '#eff6ff' : 'transparent', + '&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' }, + }} + > + + + {prop.title} + + {pendingCount > 0 && ( + + )} + + + {prop.location.city} · {prop.areaSqm} m² + + + + ) + })} + + ) +} diff --git a/src/components/match-center/index.ts b/src/components/match-center/index.ts new file mode 100644 index 0000000..3557f59 --- /dev/null +++ b/src/components/match-center/index.ts @@ -0,0 +1,6 @@ +export { MatchStatusBadge } from './MatchStatusBadge' +export { MatchCenterEmptyState } from './MatchCenterEmptyState' +export { MatchCenterSkeleton } from './MatchCenterSkeleton' +export { PropertySelectionPanel } from './PropertySelectionPanel' +export { NeedSelectionPanel } from './NeedSelectionPanel' +export { MatchBriefingPanel } from './MatchBriefingPanel' diff --git a/src/pages/supply/MatchCenter.tsx b/src/pages/supply/MatchCenter.tsx index 69d9b67..3f5793f 100644 --- a/src/pages/supply/MatchCenter.tsx +++ b/src/pages/supply/MatchCenter.tsx @@ -1,283 +1,73 @@ +import { Box, Paper, Typography } from '@mui/material' +import { useMatches } from '../../hooks/useMatches' import { - Box, - Button, - Card, - Chip, - Divider, - LinearProgress, - Typography, -} from '@mui/material' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Building2 } from 'lucide-react' -import { useState } from 'react' -import { EmptyState, ErrorState, LoadingPage } from '../../components/ui' -import { matchService } from '../../services/matchService' -import { propertyService } from '../../services/propertyService' -import { needService } from '../../services/needService' -import { MatchStrength, RiskLevel } from '../../domain/enums' + PropertySelectionPanel, + NeedSelectionPanel, + MatchBriefingPanel, +} from '../../components/match-center' -function getMatchStrengthLabel(strength: MatchStrength): string { - switch (strength) { - case MatchStrength.STRONG: return 'Stark' - case MatchStrength.MODERATE: return 'Mittel' - case MatchStrength.WEAK: return 'Schwach' - } -} - -function getMatchStrengthColor(strength: MatchStrength): 'success' | 'warning' | 'error' { - switch (strength) { - case MatchStrength.STRONG: return 'success' - case MatchStrength.MODERATE: return 'warning' - case MatchStrength.WEAK: return 'error' - } -} - -function getScoreColor(strength: MatchStrength): string { - switch (strength) { - case MatchStrength.STRONG: return '#1a7a4a' - case MatchStrength.MODERATE: return '#d97706' - case MatchStrength.WEAK: return '#c0392b' - } -} - -function getConfidenceColor(score: number): 'success' | 'primary' | 'warning' { - if (score >= 0.85) return 'success' - if (score >= 0.65) return 'primary' - return 'warning' -} - -function getConfidenceLabel(score: number): string { - if (score >= 0.85) return 'Hoch' - if (score >= 0.65) return 'Mittel' - return 'Niedrig' -} - -function getRiskLabel(level: RiskLevel): string { - switch (level) { - case RiskLevel.LOW: return 'Niedriges Risiko' - case RiskLevel.MEDIUM: return 'Mittleres Risiko' - case RiskLevel.HIGH: return 'Hohes Risiko' - case RiskLevel.CRITICAL: return 'Kritisches Risiko' - } -} - -function getRiskColor(level: RiskLevel): 'success' | 'warning' | 'error' { - switch (level) { - case RiskLevel.LOW: return 'success' - case RiskLevel.MEDIUM: return 'warning' - case RiskLevel.HIGH: return 'error' - case RiskLevel.CRITICAL: return 'error' - } +const PANEL_HEADER_SX = { + px: 2, + py: 1.5, + borderBottom: '1px solid #e2e8f0', + bgcolor: 'white', + position: 'sticky' as const, + top: 0, + zIndex: 1, + flexShrink: 0, } export default function MatchCenter() { - const [filterStrength, setFilterStrength] = useState('ALL') - - const queryClient = useQueryClient() - - const { data: matchResp, isLoading: matchLoading, error: matchError } = useQuery({ - queryKey: ['matches'], - queryFn: () => matchService.getAll(), - }) - const { data: propResp, isLoading: propLoading, error: propError } = useQuery({ - queryKey: ['properties'], - queryFn: () => propertyService.getAll(), - }) - const { data: needResp, isLoading: needLoading, error: needError } = useQuery({ - queryKey: ['needs'], - queryFn: () => needService.getAll(), - }) - - const approveMutation = useMutation({ - mutationFn: (matchId: string) => matchService.approve(matchId, 'admin@ideal-sharing.ch'), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['matches'] }), - }) - - if (matchLoading || propLoading || needLoading) return - if (matchError || propError || needError) return - - const matches = matchResp?.data ?? [] - const properties = propResp?.data ?? [] - const needs = needResp?.data ?? [] - - const filtered = filterStrength === 'ALL' - ? matches - : matches.filter(m => m.matchStrength === filterStrength) - - const sortedFiltered = [...filtered].sort((a, b) => b.matchScore - a.matchScore) - - const strengthFilters: { value: MatchStrength | 'ALL'; label: string }[] = [ - { value: 'ALL', label: 'Alle' }, - { value: MatchStrength.STRONG, label: 'Stark' }, - { value: MatchStrength.MODERATE, label: 'Mittel' }, - { value: MatchStrength.WEAK, label: 'Schwach' }, - ] + const { data: matches = [] } = useMatches() return ( - - {/* Page Header */} - - - - Match Center - - - KI-gestützte Objekt-Bedarfs-Analyse + + {/* Left: Properties */} + + + Objekte + {matches.length} Matches gesamt + + + + {/* Center: Match Briefing */} + + + Match-Briefing + + - {/* Content */} - - - {/* Filter Chips */} - - {strengthFilters.map(f => ( - setFilterStrength(f.value)} - color={ - f.value === MatchStrength.STRONG ? 'success' - : f.value === MatchStrength.MODERATE ? 'warning' - : f.value === MatchStrength.WEAK ? 'error' - : 'default' - } - sx={{ cursor: 'pointer', fontWeight: filterStrength === f.value ? 700 : 400 }} - /> - ))} + {/* Right: Needs */} + + + Bedarfe - - {/* Match Cards */} - {sortedFiltered.length === 0 ? ( - - ) : ( - - {sortedFiltered.map(match => { - const property = properties.find(p => p.id === match.propertyId) - const need = needs.find(n => n.id === match.needId) - - return ( - - - {/* Left column: score */} - - - {match.matchScore} - - / 100 - - - - {/* Center column: details */} - - {/* Property info */} - - - - {property?.title ?? match.propertyId} - - - {need && ( - - {need.companyName} — {need.assetType} - - )} - - - - {/* Positive factors */} - - {match.positiveFactors.slice(0, 3).map((f, i) => ( - - - ✓ {f.criterion}: {Math.round(f.score)}% - - - - - - ))} - - - {/* Negative factors */} - {match.negativeFactors.slice(0, 2).map((f, i) => ( - - ✗ {f.criterion} - - ))} - - {/* Tradeoffs */} - {match.tradeoffs.length > 0 && ( - - {match.tradeoffs.slice(0, 2).map((t, i) => ( - - ⚠ {t.concern} - - ))} - - )} - - - {/* Right column: actions */} - - - - - {match.isApproved ? ( - - ) : ( - - )} - - - - ) - })} - - )} - + + ) } diff --git a/src/stores/matchCenterStore.ts b/src/stores/matchCenterStore.ts new file mode 100644 index 0000000..a9797f9 --- /dev/null +++ b/src/stores/matchCenterStore.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand' + +interface MatchCenterState { + selectedPropertyId: string | null + selectedNeedId: string | null + setSelectedProperty: (id: string | null) => void + setSelectedNeed: (id: string | null) => void + clearSelection: () => void +} + +export const useMatchCenterStore = create((set) => ({ + selectedPropertyId: null, + selectedNeedId: null, + setSelectedProperty: (id) => set({ selectedPropertyId: id }), + setSelectedNeed: (id) => set({ selectedNeedId: id }), + clearSelection: () => set({ selectedPropertyId: null, selectedNeedId: null }), +}))