diff --git a/src/components/compare/AICompareSummary.tsx b/src/components/compare/AICompareSummary.tsx new file mode 100644 index 0000000..8ffcf8b --- /dev/null +++ b/src/components/compare/AICompareSummary.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material' +import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight } from 'lucide-react' +import type { ComparisonSummary } from '../../services/aiService' + +interface Props { + summary?: ComparisonSummary + isLoading: boolean +} + +export function AICompareSummary({ summary, isLoading }: Props) { + const [open, setOpen] = useState(true) + + return ( + + setOpen(v => !v)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + px: 2, + py: 1.25, + bgcolor: '#f8fafc', + cursor: 'pointer', + '&:hover': { bgcolor: '#f1f5f9' }, + }} + > + + AI Vergleichs-Zusammenfassung + + + {open ? : } + + + + + {isLoading && ( + + + Analyse wird erstellt… + + )} + + {!isLoading && summary && ( + + {/* Strongest option */} + + + + Stärkstes Match + {summary.strongestOption.label} + {summary.strongestOption.reason} + + + + {/* Best value */} + {summary.bestValue && ( + + + + Bestes Preis-Leistungs-Verhältnis + {summary.bestValue.label} + {summary.bestValue.reason} + + + )} + + {/* Highest confidence */} + + + + Höchste Datenkonfidenz + + {summary.highestConfidence.label} + + ({Math.round(summary.highestConfidence.confidenceLevel * 100)}%) + + + + + + {/* Tradeoffs */} + {summary.biggestTradeoffs.length > 0 && ( + + + + Wichtigste Abwägungen + {summary.biggestTradeoffs.map((t, i) => ( + · {t} + ))} + + + )} + + {/* Missing data */} + {summary.missingDataWarnings.length > 0 && ( + + + + Fehlende Informationen + {summary.missingDataWarnings.map((w, i) => ( + · {w} + ))} + + + )} + + {/* Next step */} + + + + Empfohlener nächster Schritt + {summary.recommendedNextStep} + + + + )} + + {!isLoading && !summary && ( + + Mindestens 2 Ergebnisse auswählen, um die Zusammenfassung zu generieren. + + )} + + + Diese Zusammenfassung basiert ausschliesslich auf den vorliegenden Daten und trifft keine endgültige Entscheidung. + + + + + ) +} diff --git a/src/components/compare/CompareCell.tsx b/src/components/compare/CompareCell.tsx new file mode 100644 index 0000000..6272b98 --- /dev/null +++ b/src/components/compare/CompareCell.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react' +import { Box, Tooltip, Typography } from '@mui/material' +import { Info } from 'lucide-react' + +export type CellHighlight = 'best' | 'worst' | 'critical' | 'future' | 'none' + +interface Props { + highlight?: CellHighlight + icon?: ReactNode + iconTooltip?: string + children: ReactNode +} + +const HIGHLIGHT_SX: Record = { + best: { bgcolor: '#f0fdf4', borderLeft: '3px solid #1a7a4a' }, + worst: { bgcolor: '#fef3c7', borderLeft: '3px solid #d97706' }, + critical: { bgcolor: '#fef2f2', borderLeft: '3px solid #c0392b' }, + future: { bgcolor: '#faf5ff', borderLeft: '3px solid #7c3aed' }, + none: {}, +} + +export function CompareCell({ highlight = 'none', icon, iconTooltip, children }: Props) { + return ( + + {icon && ( + iconTooltip + ? {icon} + : {icon} + )} + {children} + + ) +} + +export function MissingDataCell({ reason }: { reason?: string }) { + return ( + + + + + + + + Nicht verfügbar + + + ) +} diff --git a/src/components/compare/CompareColumnHeader.tsx b/src/components/compare/CompareColumnHeader.tsx new file mode 100644 index 0000000..92685f5 --- /dev/null +++ b/src/components/compare/CompareColumnHeader.tsx @@ -0,0 +1,85 @@ +import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material' +import { X, AlertTriangle } from 'lucide-react' +import type { UnifiedMatchResult } from '../../domain/unifiedResult' + +const TYPE_META: Record = { + VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, + FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, +} + +const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b' + +interface Props { + item: UnifiedMatchResult + onRemove: () => void +} + +export function CompareColumnHeader({ item, onRemove }: Props) { + const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' } + const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null + const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null + + const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? '–' + const subtitle = prop?.location?.city ?? sig?.locationHint ?? '–' + const availability = prop?.availabilityDate ?? (sig ? `~${sig.timeHorizonMonths} Monate` : null) + const confidence = Math.round(item.match.confidenceLevel * 100) + const source = prop?.sourceLabel ?? sig?.source?.type ?? '–' + + return ( + + + + + + + + + + {title} + + + {subtitle} + + + + + {item.matchScore} + + /100 + + + + + : undefined} + sx={{ + fontSize: 10, + bgcolor: confidence < 60 ? '#fef3c7' : '#f0fdf4', + color: confidence < 60 ? '#92400e' : '#166534', + }} + /> + + {source && source !== '–' && ( + + )} + {availability && ( + + )} + + + {item.resultType === 'FUTURE_AVAILABILITY' && sig && ( + + + Probabilistisches Signal + + + {Math.round(sig.probability * 100)}% Wahrscheinlichkeit + + + )} + + ) +} diff --git a/src/components/compare/CompareEmptyState.tsx b/src/components/compare/CompareEmptyState.tsx new file mode 100644 index 0000000..de4f42e --- /dev/null +++ b/src/components/compare/CompareEmptyState.tsx @@ -0,0 +1,24 @@ +import { Box, Button, Typography } from '@mui/material' +import { Columns2 } from 'lucide-react' +import { useNavigate } from 'react-router' + +export function CompareEmptyState() { + const navigate = useNavigate() + return ( + + + + + Keine Ergebnisse zum Vergleich + + + Fügen Sie 2–4 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu. + + + + + ) +} diff --git a/src/components/compare/index.ts b/src/components/compare/index.ts new file mode 100644 index 0000000..bf03c37 --- /dev/null +++ b/src/components/compare/index.ts @@ -0,0 +1,4 @@ +export { CompareEmptyState } from './CompareEmptyState' +export { CompareColumnHeader } from './CompareColumnHeader' +export { CompareCell, MissingDataCell } from './CompareCell' +export { AICompareSummary } from './AICompareSummary' diff --git a/src/components/layout/CompareTray.tsx b/src/components/layout/CompareTray.tsx index ebd899b..b9b890a 100644 --- a/src/components/layout/CompareTray.tsx +++ b/src/components/layout/CompareTray.tsx @@ -1,17 +1,31 @@ import { useEffect } from 'react' import { useNavigate } from 'react-router' -import { Box, Button, Chip, Typography } from '@mui/material' +import { Box, Button, IconButton, Typography } from '@mui/material' +import { X } from 'lucide-react' import { useCompareStore } from '../../stores/compareStore' import { useLayoutStore } from '../../stores/layoutStore' +const TYPE_DOT: Record = { + VERIFIED_PORTFOLIO: '#1e3a5f', + EXTERNAL_MARKET: '#d97706', + FUTURE_AVAILABILITY: '#7c3aed', +} + export function CompareTray() { - const { compareTray, removeFromCompare, clearCompare } = useCompareStore() + const { compareItems, removeFromCompare, clearCompare } = useCompareStore() const { setCompareTrayVisible } = useLayoutStore() const navigate = useNavigate() useEffect(() => { - setCompareTrayVisible(compareTray.length > 0) - }, [compareTray.length, setCompareTrayVisible]) + setCompareTrayVisible(compareItems.length > 0) + }, [compareItems.length, setCompareTrayVisible]) + + const getTitle = (item: (typeof compareItems)[number]) => { + if (item.resultType === 'FUTURE_AVAILABILITY') { + return (item as any).signal?.companyName ?? (item as any).signal?.locationHint ?? 'Signal' + } + return (item as any).property?.title ?? `Score ${item.matchScore}` + } return ( 0 ? 'translateY(0)' : 'translateY(100%)', + transform: compareItems.length > 0 ? 'translateY(0)' : 'translateY(100%)', transition: 'transform 0.25s ease', }} > - Vergleich ({compareTray.length}/3) + Vergleich ({compareItems.length}/4) - {compareTray.map((id, i) => ( - removeFromCompare(id)} + {compareItems.map((item) => ( + + > + + + {getTitle(item)} + + + {item.matchScore} + + removeFromCompare(item.matchId)} + sx={{ p: 0.25, color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#fff' } }} + > + + + ))} diff --git a/src/components/match-center/MatchBriefingPanel.tsx b/src/components/match-center/MatchBriefingPanel.tsx index f0cd39e..5e0af90 100644 --- a/src/components/match-center/MatchBriefingPanel.tsx +++ b/src/components/match-center/MatchBriefingPanel.tsx @@ -66,7 +66,7 @@ export function MatchBriefingPanel() { label: 'Vergleichen', actionType: 'ADD_COMPARE', variant: 'secondary', - onClick: () => { addToCompare(property.id); navigate('/demand/compare') }, + onClick: () => { addToCompare(result); navigate('/demand/compare') }, }, { id: 'details', diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx index 53e73a8..25e4c41 100644 --- a/src/components/results/UnifiedResultCard.tsx +++ b/src/components/results/UnifiedResultCard.tsx @@ -1,21 +1,18 @@ import { useNavigate } from 'react-router' import { MatchCardCompact } from '../match-card/MatchCardCompact' import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter' +import { useCompareStore } from '../../stores/compareStore' import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { MatchCardAction } from '../match-card/MatchCardViewModel' interface Props { result: UnifiedMatchResult - isInCompare: boolean - onCompare: (propertyId: string) => void } -export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) { +export function UnifiedResultCard({ result }: Props) { const navigate = useNavigate() - const isFuture = result.resultType === 'FUTURE_AVAILABILITY' - const compareId = !isFuture - ? (result as { property: { id: string } }).property.id - : '' + const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore() + const inCompare = isInCompare(result.matchId) const actions: MatchCardAction[] = [ { @@ -26,17 +23,17 @@ export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) { disabled: true, onClick: () => {}, }, - ...(!isFuture - ? [ - { - id: 'compare', - label: 'Vergleichen', - actionType: 'ADD_COMPARE' as const, - variant: (isInCompare ? 'primary' : 'secondary') as 'primary' | 'secondary', - onClick: () => onCompare(compareId), - }, - ] - : []), + { + id: 'compare', + label: inCompare ? 'Im Vergleich' : 'Vergleichen', + actionType: 'ADD_COMPARE', + variant: inCompare ? 'primary' : 'secondary', + disabled: !inCompare && isFull(), + onClick: () => { + if (inCompare) removeFromCompare(result.matchId) + else addToCompare(result) + }, + }, { id: 'details', label: 'Details', diff --git a/src/components/results/UnifiedResultFeed.tsx b/src/components/results/UnifiedResultFeed.tsx index ab8adbc..0aaad78 100644 --- a/src/components/results/UnifiedResultFeed.tsx +++ b/src/components/results/UnifiedResultFeed.tsx @@ -3,27 +3,14 @@ import { UnifiedResultCard } from './UnifiedResultCard' interface Props { results: UnifiedMatchResult[] - isInCompare: (id: string) => boolean - onCompare: (propertyId: string) => void } -export function UnifiedResultFeed({ results, isInCompare, onCompare }: Props) { +export function UnifiedResultFeed({ results }: Props) { return ( <> - {results.map(result => { - const compareId = - result.resultType !== 'FUTURE_AVAILABILITY' - ? (result as { property: { id: string } }).property.id - : '' - return ( - - ) - })} + {results.map(result => ( + + ))} ) } diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx index a0dfabd..724234a 100644 --- a/src/pages/demand/Compare.tsx +++ b/src/pages/demand/Compare.tsx @@ -1,310 +1,483 @@ import type { ReactNode } from 'react' import { + Alert, Box, Button, Card, Chip, - Typography, LinearProgress, + Stack, Table, TableBody, TableCell, TableHead, TableRow, - CircularProgress, - Stack, + Typography, } from '@mui/material' -import { X } from 'lucide-react' -import { useQuery } from '@tanstack/react-query' +import { Trophy, AlertTriangle, AlertOctagon, Zap, CheckCircle2, XCircle } from 'lucide-react' import { useNavigate } from 'react-router' -import { propertyService } from '../../services/propertyService' +import { useQuery } from '@tanstack/react-query' import { useCompareStore } from '../../stores/compareStore' -import { EmptyState } from '../../components/ui' -import { ResultType, RiskLevel } from '../../domain/enums' -import type { Property } from '../../domain/property' +import { aiService } from '../../services/aiService' +import { + CompareEmptyState, + CompareColumnHeader, + CompareCell, + MissingDataCell, + AICompareSummary, +} from '../../components/compare' +import type { UnifiedMatchResult } from '../../domain/unifiedResult' +import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult' -function getResultTypeLabel(type: ResultType): string { - switch (type) { - case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio' - case ResultType.EXTERNAL_MARKET: return 'Marktinserat' - case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal' - } +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) +const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b' + +function getProp(item: UnifiedMatchResult) { + return item.resultType !== 'FUTURE_AVAILABILITY' + ? (item as VerifiedPortfolioResult | ExternalMarketResult).property + : null } -function getResultTypeColor(type: ResultType): string { - switch (type) { - case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f' - case ResultType.EXTERNAL_MARKET: return '#d97706' - case ResultType.FUTURE_AVAILABILITY: return '#7c3aed' - } +function getSig(item: UnifiedMatchResult) { + return item.resultType === 'FUTURE_AVAILABILITY' + ? (item as FutureAvailabilityResult).signal + : null } -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' +const TYPE_META: Record = { + VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, + FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, } -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' - } +const RISK_LEVEL_ORDER: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 } + +// ── Row label cell ──────────────────────────────────────────────────────────── + +const LABEL_SX = { + position: 'sticky' as const, + left: 0, + bgcolor: 'white', + zIndex: 1, + width: 200, + minWidth: 200, + color: '#64748b', + fontSize: 13, + fontWeight: 600, + borderRight: '1px solid #e2e8f0', + verticalAlign: 'top', + py: 1.5, } -interface CompareRow { - label: string - getValue: (p: Property) => string | number | null - format?: (v: string | number | null, p: Property) => ReactNode - isHigherBetter?: boolean - isLowerBetter?: boolean +const DATA_SX = { + borderLeft: '1px solid #f1f5f9', + minWidth: 220, + verticalAlign: 'top', + py: 1.5, } -function NumericCell({ value, isBest }: { value: ReactNode; isBest: boolean }) { - return ( - - {value} - - ) -} +// ── Main component ──────────────────────────────────────────────────────────── export default function Compare() { const navigate = useNavigate() - const { compareTray, removeFromCompare, clearCompare } = useCompareStore() + const { compareItems, removeFromCompare, clearCompare } = useCompareStore() - const { data: propResp, isLoading } = useQuery({ - queryKey: ['properties'], - queryFn: () => propertyService.getAll(), + const { data: aiSummary, isLoading: aiLoading } = useQuery({ + queryKey: ['ai-compare', compareItems.map(i => i.matchId)], + queryFn: () => aiService.summarizeComparison(compareItems), + enabled: compareItems.length >= 2, + select: r => r.data, + staleTime: Infinity, }) - const properties = propResp?.data ?? [] - const compareProperties = properties.filter(p => compareTray.includes(p.id)) - // Keep ordering same as tray - const orderedProperties = compareTray - .map(id => compareProperties.find(p => p.id === id)) - .filter((p): p is Property => p !== undefined) - - if (isLoading) { - return ( - - - - ) - } - - if (compareTray.length === 0) { + if (compareItems.length === 0) { return ( Vergleich - - navigate('/demand/results') }} - /> - + ) } - const rows: CompareRow[] = [ - { - label: 'Fläche (m²)', - getValue: p => p.areaSqm, - isHigherBetter: false, - }, - { - label: 'Miete/m² (CHF)', - getValue: p => p.rentPricePerSqm, - isLowerBetter: true, - }, - { - label: 'Gesamtmiete/Monat (CHF)', - getValue: p => p.totalRentMonthly ?? null, - format: (v) => v != null ? `${Number(v).toLocaleString('de-CH')} CHF` : , - isLowerBetter: true, - }, - { - label: 'Verfügbarkeit', - getValue: p => p.availabilityDate, - format: (v) => v ?? , - }, - { - label: 'Standort', - getValue: p => `${p.location.city}${p.location.district ? ', ' + p.location.district : ''}`, - }, - { - label: 'Datenqualität', - getValue: p => p.dataQuality.score, - format: (_v, p) => ( - - - = 0.8 ? 'success' : p.dataQuality.score >= 0.6 ? 'warning' : 'error' }} - /> - - {Math.round(p.dataQuality.score * 100)}% + // ── Highlight indices ────────────────────────────────────────────────────── + + const bestScoreIdx = compareItems.reduce( + (best, item, i) => item.matchScore > compareItems[best].matchScore ? i : best, 0 + ) + const worstConfIdx = compareItems.reduce( + (worst, item, i) => item.match.confidenceLevel < compareItems[worst].match.confidenceLevel ? i : worst, 0 + ) + const dqScores = compareItems.map(item => getProp(item)?.dataQuality.score ?? 1) + const worstDQIdx = dqScores.indexOf(Math.min(...dqScores)) + + const missingCriticalCounts = compareItems.map( + item => item.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0 + ) + const maxMissingCritical = Math.max(...missingCriticalCounts) + + // ── Shared render helpers ───────────────────────────────────────────────── + + function row(label: string, cells: ReactNode[]) { + return ( + + {label} + {cells.map((cell, i) => ( + {cell} + ))} + + ) + } + + function scoreBar(value: number, label?: string) { + const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b' + return ( + + + - ), - isHigherBetter: true, - }, - { - label: 'Konfidenz', - getValue: p => p.confidenceScore, - format: (v) => v != null ? `${Math.round(Number(v) * 100)}%` : , - isHigherBetter: true, - }, - { - label: 'Risiko', - getValue: p => p.riskLevel ?? null, - format: (_v, p) => ( - - ), - }, - { - label: 'Prestige', - getValue: p => p.softFactors?.prestige ?? null, - format: (v) => v != null ? String(v) : , - isHigherBetter: true, - }, - { - label: 'Erreichbarkeit', - getValue: p => p.softFactors?.accessibility ?? null, - format: (v) => v != null ? String(v) : , - isHigherBetter: true, - }, - { - label: 'ÖV-Minuten', - getValue: p => p.softFactors?.publicTransportMinutes ?? null, - format: (v) => v != null ? `${v} Min.` : , - isLowerBetter: true, - }, - { - label: 'Fehlende Pflichtfelder', - getValue: p => p.dataQuality.missingCriticalFields.length, - format: (v) => v != null ? String(v) : , - isLowerBetter: true, - }, - ] + {label ?? `${Math.round(value * 100)}%`} + + ) + } return ( - {/* Page Header */} + {/* Page header */} Vergleich - + - + {/* Mobile notice */} + + + Die Vergleichsansicht ist für Desktop optimiert. Für beste Erfahrung auf einem grösseren Bildschirm öffnen. + + + + {/* Desktop table */} + + + {/* AI Summary */} + = 2} /> + - +
+ + {/* Column headers */} - + Kriterium - {orderedProperties.map(p => ( - - - - {p.title} - - - - + {compareItems.map(item => ( + + removeFromCompare(item.matchId)} /> ))} - {rows.map(row => { - const values = orderedProperties.map(p => row.getValue(p)) - const numericValues = values - .map((v, i) => ({ v, i })) - .filter(x => x.v != null && typeof x.v === 'number') as { v: number; i: number }[] - let bestIdx = -1 - if (numericValues.length > 1) { - if (row.isHigherBetter) { - bestIdx = numericValues.reduce((best, cur) => cur.v > best.v ? cur : best).i - } else if (row.isLowerBetter) { - bestIdx = numericValues.reduce((best, cur) => cur.v < best.v ? cur : best).i - } - } + {/* 1. Result Type */} + {row('1. Result-Typ', compareItems.map(item => { + const m = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' } + return + }))} + {/* 2. Source / Provenance */} + {row('2. Quelle / Provenienz', compareItems.map(item => { + const prop = getProp(item) + const sig = getSig(item) + const label = prop?.sourceLabel ?? sig?.source?.type ?? null + return label + ? {label} + : + }))} + + {/* 3. Match Score */} + {row('3. Match Score', compareItems.map((item, idx) => ( + : undefined} + iconTooltip="Höchster Match Score" + > + + + {item.matchScore} + + /100 + + + )))} + + {/* 4. Confidence Score */} + {row('4. Konfidenz', compareItems.map((item, idx) => ( + : undefined} + iconTooltip="Niedrigste Konfidenz" + > + {scoreBar(item.match.confidenceLevel)} + + )))} + + {/* 5. Data Quality Score */} + {row('5. Datenqualität', compareItems.map((item, idx) => { + const prop = getProp(item) + const dq = prop?.dataQuality.score ?? null + if (dq === null) return return ( - - - {row.label} - - {orderedProperties.map((p, idx) => { - const raw = row.getValue(p) - const displayValue = row.format - ? row.format(raw, p) - : raw != null - ? String(raw) - : - - const isBest = bestIdx === idx - return ( - - ) - })} - + : undefined} + iconTooltip="Niedrigste Datenqualität" + > + {scoreBar(dq)} + ) - })} + }))} + + {/* 6. Asset Type */} + {row('6. Nutzungstyp', compareItems.map(item => { + const prop = getProp(item) + const label = prop?.assetType ?? null + return label + ? + : + }))} + + {/* 7. Location */} + {row('7. Standort', compareItems.map(item => { + const prop = getProp(item) + const sig = getSig(item) + const city = prop?.location?.city ?? sig?.locationHint ?? null + const district = prop?.location?.district + return city + ? {city}{district ? `, ${district}` : ''} + : + }))} + + {/* 8. Area */} + {row('8. Fläche', compareItems.map(item => { + const prop = getProp(item) + const sig = getSig(item) + const area = prop?.areaSqm ?? sig?.areaSqmEstimate ?? null + return area !== null + ? {area.toLocaleString('de-CH')} m²{sig ? ' (Schätzung)' : ''} + : + }))} + + {/* 9. Rent / Budget Fit */} + {row('9. Miete / Budget', compareItems.map(item => { + const prop = getProp(item) + if (!prop) return + return ( + + + CHF {prop.rentPricePerSqm}/m² + + {prop.totalRentMonthly && ( + + {prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat + + )} + + ) + }))} + + {/* 10. Availability / Time Horizon */} + {row('10. Verfügbarkeit', compareItems.map(item => { + const prop = getProp(item) + const sig = getSig(item) + if (prop) return {prop.availabilityDate} + if (sig) return ( + } iconTooltip="Probabilistisches Signal — keine bestätigte Verfügbarkeit"> + ~{sig.timeHorizonMonths} Monate + + {Math.round(sig.probability * 100)}% Wahrscheinlichkeit + + + ) + return + }))} + + {/* 11. Hard Criteria Fit */} + {row('11. Hardkriterien', compareItems.map(item => { + const hardMatches = item.match.positiveFactors.filter(f => HARD_CRITERIA.has(f.criterion)) + const total = 4 + const count = hardMatches.length + const color = count >= 3 ? '#1a7a4a' : count >= 2 ? '#d97706' : '#c0392b' + return ( + + + {count}/{total} erfüllt + + + {hardMatches.map(f => ( + } + sx={{ fontSize: 10, bgcolor: '#f0fdf4', color: '#166534', '& .MuiChip-icon': { color: '#1a7a4a' } }} /> + ))} + + + ) + }))} + + {/* 12. Top Soft Factors */} + {row('12. Soft Factors', compareItems.map(item => { + const softFactors = item.match.positiveFactors + .filter(f => !HARD_CRITERIA.has(f.criterion)) + .slice(0, 3) + if (softFactors.length === 0) return + return ( + + {softFactors.map(f => ( + + ))} + + ) + }))} + + {/* 13. Main Strengths */} + {row('13. Stärken', compareItems.map(item => { + const top = item.match.positiveFactors.slice(0, 2) + if (top.length === 0) return + return ( + + {top.map((f, i) => ( + + + {f.explanation} + + ))} + + ) + }))} + + {/* 14. Main Tradeoffs */} + {row('14. Abwägungen', compareItems.map(item => { + const tradeoffs = item.match.tradeoffs?.slice(0, 2) ?? [] + if (tradeoffs.length === 0) return ( + Keine signifikanten Abwägungen + ) + return ( + + {tradeoffs.map((t, i) => ( + + + {t.criterion}: {t.concern} + + ))} + + ) + }))} + + {/* 15. Main Risks */} + {row('15. Risiken', compareItems.map(item => { + const risks = [...(item.match.risks ?? [])].sort( + (a, b) => (RISK_LEVEL_ORDER[a.level] ?? 4) - (RISK_LEVEL_ORDER[b.level] ?? 4) + ).slice(0, 2) + if (risks.length === 0) return ( + Keine identifizierten Risiken + ) + return ( + + {risks.map((r, i) => ( + + + {r.description} + + ))} + + ) + }))} + + {/* 16. Missing Data */} + {row('16. Fehlende Daten', compareItems.map((item, idx) => { + const total = item.match.missingData?.length ?? 0 + const critical = missingCriticalCounts[idx] + if (total === 0) return ( + + + Vollständig + + ) + return ( + 0 && critical === maxMissingCritical ? 'critical' : critical > 0 ? 'worst' : 'none'} + icon={critical > 0 ? : } + iconTooltip={critical > 0 ? 'Kritische Pflichtfelder fehlen' : 'Optionale Felder fehlen'} + > + {total} fehlend + {critical > 0 && ( + {critical} kritisch + )} + + ) + }))} + + {/* 17. Future Availability Context */} + {row('17. Zukunftskontext', compareItems.map(item => { + const sig = getSig(item) + if (!sig) return ( + Nicht anwendbar + ) + return ( + } iconTooltip="Probabilistisches Zukunftssignal"> + + + {Math.round(sig.probability * 100)}% Wahrscheinlichkeit + + + Sensitivität: {sig.sensitivityLevel} + + + {sig.disclaimer} + + + + ) + }))} + + {/* 18. Recommended Next Action */} + {row('18. Nächste Aktion', compareItems.map(item => { + const action = item.match.nextBestActions?.[0] + if (!action) return + return ( + + {action.label} + {action.description && ( + {action.description} + )} + + ) + }))} +
{/* Add more prompt */} - {orderedProperties.length < 3 && ( + {compareItems.length < 4 && ( - Weiteres Objekt hinzufügen + Weiteres Ergebnis hinzufügen - Bis zu {3 - orderedProperties.length} weitere{orderedProperties.length < 2 ? 's' : ''} Objekt{orderedProperties.length < 2 ? '' : 'e'} möglich + Bis zu {4 - compareItems.length} weitere{4 - compareItems.length === 1 ? 's' : ''} Ergebnis{4 - compareItems.length === 1 ? '' : 'se'} möglich - - -
- )}
) } diff --git a/src/services/aiService.ts b/src/services/aiService.ts index e253645..63f937d 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -3,6 +3,82 @@ import { ServiceErrorCode } from './types' import type { CreateNeedInput } from '../domain/need' import type { AssetType } from '../domain/enums' import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder' +import type { UnifiedMatchResult } from '../domain/unifiedResult' + +// ── Compare Summary ─────────────────────────────────────────────────────────── + +export interface ComparisonSummary { + strongestOption: { matchId: string; label: string; reason: string } + bestValue: { matchId: string; label: string; reason: string } | null + highestConfidence: { matchId: string; label: string; confidenceLevel: number } + biggestTradeoffs: string[] + missingDataWarnings: string[] + recommendedNextStep: string +} + +function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary { + if (items.length === 0) { + return { + strongestOption: { matchId: '', label: '–', reason: 'Keine Ergebnisse' }, + bestValue: null, + highestConfidence: { matchId: '', label: '–', confidenceLevel: 0 }, + biggestTradeoffs: [], + missingDataWarnings: [], + recommendedNextStep: 'Suchergebnisse überprüfen', + } + } + + const getTitle = (item: UnifiedMatchResult) => + item.resultType !== 'FUTURE_AVAILABILITY' + ? (item as any).property?.title ?? `Match ${item.matchScore}` + : (item as any).signal?.companyName ?? 'Zukunftssignal' + + const strongest = items.reduce((a, b) => a.matchScore > b.matchScore ? a : b) + + const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY') + const bestValue = propertyItems.length > 0 + ? propertyItems.reduce((a, b) => + ((a as any).property?.rentPricePerSqm ?? Infinity) <= ((b as any).property?.rentPricePerSqm ?? Infinity) ? a : b + ) + : null + + const highestConf = items.reduce((a, b) => + a.match.confidenceLevel >= b.match.confidenceLevel ? a : b + ) + + const tradeoffs = items + .flatMap(i => i.match.tradeoffs?.slice(0, 1).map(t => `${getTitle(i)}: ${t.concern}`) ?? []) + .slice(0, 3) + + const missingWarnings = items + .filter(i => (i.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0) > 0) + .map(i => `${getTitle(i)}: fehlende Pflichtfelder`) + + const topNextAction = strongest.match.nextBestActions?.[0]?.label ?? 'Objekt besichtigen oder Details prüfen' + + return { + strongestOption: { + matchId: strongest.matchId, + label: getTitle(strongest), + reason: `Höchster Match Score (${strongest.matchScore}/100)`, + }, + bestValue: bestValue + ? { + matchId: bestValue.matchId, + label: getTitle(bestValue), + reason: `Niedrigster Mietpreis (CHF ${(bestValue as any).property?.rentPricePerSqm ?? '–'}/m²)`, + } + : null, + highestConfidence: { + matchId: highestConf.matchId, + label: getTitle(highestConf), + confidenceLevel: highestConf.match.confidenceLevel, + }, + biggestTradeoffs: tradeoffs, + missingDataWarnings: missingWarnings, + recommendedNextStep: topNextAction, + } +} // ── Legacy types (kept for backward compatibility) ──────────────────────────── @@ -284,6 +360,12 @@ export const aiService = { return { data } }, + // F014: Compare summary + async summarizeComparison(items: UnifiedMatchResult[]): Promise> { + await new Promise(r => setTimeout(r, 600)) + return { data: buildComparisonSummary(items) } + }, + // F008 methods async parseNeed(input: string): Promise> { await new Promise(r => setTimeout(r, 1400)) diff --git a/src/stores/compareStore.ts b/src/stores/compareStore.ts index 8a5220c..bcb46bf 100644 --- a/src/stores/compareStore.ts +++ b/src/stores/compareStore.ts @@ -1,27 +1,28 @@ import { create } from 'zustand' +import type { UnifiedMatchResult } from '../domain/unifiedResult' -const MAX_COMPARE_ITEMS = 3 +const MAX_COMPARE_ITEMS = 4 interface CompareState { - compareTray: string[] - addToCompare: (propertyId: string) => void - removeFromCompare: (propertyId: string) => void + compareItems: UnifiedMatchResult[] + addToCompare: (result: UnifiedMatchResult) => void + removeFromCompare: (matchId: string) => void clearCompare: () => void - isInCompare: (propertyId: string) => boolean + isInCompare: (matchId: string) => boolean isFull: () => boolean } export const useCompareStore = create((set, get) => ({ - compareTray: [], - addToCompare: (propertyId) => + compareItems: [], + addToCompare: (result) => set((state) => { - if (state.compareTray.length >= MAX_COMPARE_ITEMS) return state - if (state.compareTray.includes(propertyId)) return state - return { compareTray: [...state.compareTray, propertyId] } + if (state.compareItems.length >= MAX_COMPARE_ITEMS) return state + if (state.compareItems.some(i => i.matchId === result.matchId)) return state + return { compareItems: [...state.compareItems, result] } }), - removeFromCompare: (propertyId) => - set((state) => ({ compareTray: state.compareTray.filter(id => id !== propertyId) })), - clearCompare: () => set({ compareTray: [] }), - isInCompare: (propertyId) => get().compareTray.includes(propertyId), - isFull: () => get().compareTray.length >= MAX_COMPARE_ITEMS, + removeFromCompare: (matchId) => + set((state) => ({ compareItems: state.compareItems.filter(i => i.matchId !== matchId) })), + clearCompare: () => set({ compareItems: [] }), + isInCompare: (matchId) => get().compareItems.some(i => i.matchId === matchId), + isFull: () => get().compareItems.length >= MAX_COMPARE_ITEMS, }))