Files
property-match/src/pages/demand/Results.tsx
T
Benjamin Sutter 6515acb7f0 feat: unified error handling + AI service modularisation
Error handling (Prompt 2):
- src/services/errors.ts: AppError class, normalizeError(), throwServiceError() helper
- 6 services wrapped with try/catch (property, match, need, shortlist, futureSignal, inquiry)
- inquiryService aligned from custom ServiceResult<T> to standard ServiceResponse types
- Results, MatchCenter, FutureAvailability pages show <ErrorState onRetry> on query failure

AI modularisation (Prompt 3):
- src/services/aiService.ts reduced from 755 → 19 lines (barrel re-export)
- src/services/ai/IAIService.ts: typed interface + all response types
- src/services/ai/mock/: needParser, compareBuilder, decisionBrief, listingParser, MockAIService
- src/services/ai/openrouter/OpenRouterAIService.ts: model-agnostic skeleton
- src/services/ai/prompts/: 4 prompt template files (needParsing, matchExplanation, compareSummary, decisionBrief)
- src/services/ai/index.ts: factory selects Mock or OpenRouter via VITE_USE_REAL_AI flag
- All existing import paths unchanged — zero call-site modifications

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:32:01 +02:00

200 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { Box, Button, Card, Typography } from '@mui/material'
import { useNavigate, useLocation } from 'react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
import { needService } from '../../services/needService'
import { DecisionContextPanel } from '../../components/ui'
import {
FeedEmptyState,
FeedSkeleton,
ResultFeedHeader,
ResultFilterBar,
UnifiedResultFeed,
} from '../../components/results'
import { AddToPipelineDialog } from '../../components/shortlist'
import { ErrorState } from '../../components/ui'
import { useSessionStore } from '../../stores/sessionStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
type FilterSource = 'ALL' | 'PLATFORM' | 'MAISON_WORK'
type SortBy = 'score' | 'rent' | 'area'
function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] {
return [...results].sort((a, b) => {
if (sortBy === 'score') return b.matchScore - a.matchScore
const propA = a.resultType !== 'FUTURE_AVAILABILITY' ? (a as { property: { rentPricePerSqm: number; areaSqm: number } }).property : null
const propB = b.resultType !== 'FUTURE_AVAILABILITY' ? (b as { property: { rentPricePerSqm: number; areaSqm: number } }).property : null
if (sortBy === 'rent') return (propA?.rentPricePerSqm ?? 0) - (propB?.rentPricePerSqm ?? 0)
if (sortBy === 'area') return (propB?.areaSqm ?? 0) - (propA?.areaSqm ?? 0)
return 0
})
}
export default function Results() {
const navigate = useNavigate()
const location = useLocation()
const queryClient = useQueryClient()
const { currentUser } = useSessionStore()
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
const [sortBy, setSortBy] = useState<SortBy>('score')
const [showFutureAvailability, setShowFutureAvailability] = useState(true)
const [showOwnProperties, setShowOwnProperties] = useState(true)
const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
)
// When coming from NeedBuilder, invalidate so the freshly created need is included
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
const { data: needResp } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
refetchOnMount: activeNeedIdFromNav ? 'always' : true,
gcTime: 0,
})
const allNeeds = needResp?.data ?? []
// Nav ID (from NeedBuilder) takes priority; otherwise first named need
const effectiveNeedId = activeNeedIdFromNav ?? allNeeds.find(n => n.companyName !== 'Neue Suche')?.id
const activeNeed = allNeeds.find(n => n.id === effectiveNeedId) ?? allNeeds[0]
// Ensure query cache is invalidated when a new need was just created
if (activeNeedIdFromNav) {
queryClient.invalidateQueries({ queryKey: ['needs'] })
}
const { data: results = [], isLoading, error } = useUnifiedResults(effectiveNeedId)
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const filtered = results.filter(r => {
if (r.resultType === 'FUTURE_AVAILABILITY') return showFutureAvailability
if (r.resultType === 'VERIFIED_PORTFOLIO') {
if (!showOwnProperties) return false
return filterSource === 'ALL' || filterSource === 'PLATFORM'
}
if (filterSource === 'ALL') return true
if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
return r.resultType === filterSource // MAISON_WORK
})
const sorted = sortResults(filtered, sortBy)
const platformCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET').length
const maisonWorkCount = results.filter(r => r.resultType === 'MAISON_WORK').length
const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length
const strongCount = filtered.filter(r => r.matchScore >= 80).length
const missingDataCount = results.filter(r =>
'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0
).length
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<AddToPipelineDialog />
<ResultFeedHeader
total={sorted.length}
platformCount={platformCount}
maisonWorkCount={maisonWorkCount}
futureCount={futureCount}
view={view}
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
/>
{!isLoading && results.length > 0 && (
<DecisionContextPanel
decision="Welche Treffer lohnen sich für die Shortlist — und welche tragen Risiken, die zuerst geprüft werden müssen?"
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
metrics={[
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []),
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
]}
risks={[
...(futureCount > 0 ? [`${futureCount} Zukunftssignal${futureCount > 1 ? 'e' : ''} sind probabilistisch — keine bestätigte Verfügbarkeit`] : []),
...(missingDataCount > 0 ? [`${missingDataCount} Treffer mit fehlenden Daten — Einschätzung eingeschränkt`] : []),
]}
actions={[
{ label: 'Vergleich öffnen', onClick: () => navigate('/demand/compare') },
{ label: 'Suche anpassen', onClick: () => navigate('/demand/ai-search') },
]}
/>
)}
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
{activeNeed && (
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#1e3a5f">
Aktive Suche: {activeNeed.companyName}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
<Typography variant="caption" color="text.secondary">
<strong>Typ:</strong> {activeNeed.assetType}
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Fläche:</strong> {activeNeed.requiredArea.min}{activeNeed.requiredArea.max} m²
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Standort:</strong> {activeNeed.preferredLocations.join(', ')}
</Typography>
{activeNeed.budgetRange && activeNeed.budgetRange.maxPerSqm > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Budget:</strong> max. CHF {activeNeed.budgetRange.maxPerSqm}/m²
</Typography>
)}
{activeNeed.timing?.earliestMoveIn && !isNaN(new Date(activeNeed.timing.earliestMoveIn).getTime()) && (
<Typography variant="caption" color="text.secondary">
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
</Typography>
)}
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
</Typography>
)}
</Box>
</Box>
<Button
size="small"
variant="text"
onClick={() => navigate('/demand/ai-search')}
sx={{ color: '#1e3a5f', flexShrink: 0 }}
>
Suche ändern
</Button>
</Box>
</Card>
)}
<ResultFilterBar
filterSource={filterSource}
onFilterChange={setFilterSource}
sortBy={sortBy}
onSortChange={setSortBy}
showFutureAvailability={showFutureAvailability}
onShowFutureAvailabilityChange={setShowFutureAvailability}
showOwnProperties={showOwnProperties}
onShowOwnPropertiesChange={setShowOwnProperties}
isPropertyManager={isStaff}
/>
{isLoading ? (
<FeedSkeleton />
) : error ? (
<ErrorState message={(error as Error).message} />
) : sorted.length === 0 ? (
<FeedEmptyState filtered={filterSource !== 'ALL' || !showFutureAvailability || !showOwnProperties} />
) : (
<UnifiedResultFeed results={sorted} view={view} />
)}
</Box>
</Box>
)
}