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 }),
+}))