6515acb7f0
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>
141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
import { useState } from 'react'
|
|
import { Box, Chip, Drawer, Typography } from '@mui/material'
|
|
import { useFutureSignals } from '../../hooks/useFutureSignals'
|
|
import {
|
|
FutureSignalCard,
|
|
FutureSignalFilterBar,
|
|
FutureSignalDetailPanel,
|
|
FutureSignalEmptyState,
|
|
DEFAULT_SIGNAL_FILTERS,
|
|
} from '../../components/future-signals'
|
|
import { AddToShortlistDialog } from '../../components/shortlist'
|
|
import { ErrorState } from '../../components/ui'
|
|
import type { SignalFilterState } from '../../components/future-signals'
|
|
import type { FutureSignal } from '../../domain/futureSignal'
|
|
|
|
function applyFilters(signals: FutureSignal[], f: SignalFilterState): FutureSignal[] {
|
|
return signals.filter(s => {
|
|
if (f.signalType && s.signalType !== f.signalType) return false
|
|
if (f.minConfidence > 0 && s.confidenceScore < f.minConfidence) return false
|
|
if (f.sensitivityLevel && s.sensitivityLevel !== f.sensitivityLevel) return false
|
|
if (f.reviewStatus) {
|
|
const rs = s.reviewStatus ?? 'UNREVIEWED'
|
|
if (rs !== f.reviewStatus) return false
|
|
}
|
|
if (f.timeHorizon === 'short' && s.timeHorizonMonths > 6) return false
|
|
if (f.timeHorizon === 'medium' && (s.timeHorizonMonths <= 6 || s.timeHorizonMonths > 12)) return false
|
|
if (f.timeHorizon === 'long' && s.timeHorizonMonths <= 12) return false
|
|
return true
|
|
})
|
|
}
|
|
|
|
export default function FutureAvailability() {
|
|
const { data: signals = [], isLoading, isError, refetch } = useFutureSignals()
|
|
const [filters, setFilters] = useState<SignalFilterState>(DEFAULT_SIGNAL_FILTERS)
|
|
const [selectedSignal, setSelectedSignal] = useState<FutureSignal | null>(null)
|
|
|
|
const filtered = applyFilters(signals, filters)
|
|
|
|
const needsReviewCount = signals.filter(s => !s.reviewStatus || s.reviewStatus === 'UNREVIEWED').length
|
|
const highConfCount = signals.filter(s => s.confidenceScore >= 0.75).length
|
|
const confidentialCount = signals.filter(s => s.sensitivityLevel === 'CONFIDENTIAL').length
|
|
|
|
function handleSelect(signal: FutureSignal) {
|
|
setSelectedSignal(prev => prev?.id === signal.id ? null : signal)
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
|
<AddToShortlistDialog />
|
|
|
|
{/* Header */}
|
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>Zukunftssignale</Typography>
|
|
<Typography variant="body2" color="text.secondary">Probabilistische Markt- und Verfügbarkeitssignale</Typography>
|
|
</Box>
|
|
<Chip
|
|
label="Shadow Intelligence Layer"
|
|
size="small"
|
|
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 600 }}
|
|
/>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
<Chip
|
|
label={`${signals.length} Signale`}
|
|
size="small"
|
|
sx={{ bgcolor: '#f1f5f9', color: '#475569' }}
|
|
/>
|
|
<Chip
|
|
label={`${needsReviewCount} Prüfung erforderlich`}
|
|
size="small"
|
|
sx={{ bgcolor: needsReviewCount > 0 ? '#fef3c7' : '#f1f5f9', color: needsReviewCount > 0 ? '#d97706' : '#475569', fontWeight: needsReviewCount > 0 ? 600 : 400 }}
|
|
/>
|
|
<Chip
|
|
label={`${highConfCount} hohe Konfidenz`}
|
|
size="small"
|
|
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a' }}
|
|
/>
|
|
{confidentialCount > 0 && (
|
|
<Chip
|
|
label={`${confidentialCount} vertraulich`}
|
|
size="small"
|
|
sx={{ bgcolor: '#fff1f2', color: '#c0392b', fontWeight: 600 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Filter bar */}
|
|
<FutureSignalFilterBar
|
|
filters={filters}
|
|
onChange={setFilters}
|
|
totalCount={signals.length}
|
|
filteredCount={filtered.length}
|
|
/>
|
|
|
|
{/* Signal list — always full width */}
|
|
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: '#f8fafc' }}>
|
|
{isLoading ? (
|
|
<Box sx={{ p: 3 }}>
|
|
{[0, 1, 2, 3].map(i => (
|
|
<Box key={i} sx={{ p: 2, mb: 1, bgcolor: 'white', borderRadius: 1, height: 120 }} />
|
|
))}
|
|
</Box>
|
|
) : isError ? (
|
|
<ErrorState onRetry={refetch} />
|
|
) : filtered.length === 0 ? (
|
|
<FutureSignalEmptyState context={signals.length === 0 ? 'no-signals' : 'filtered-empty'} />
|
|
) : (
|
|
<Box sx={{ bgcolor: 'white' }}>
|
|
{filtered.map(signal => (
|
|
<FutureSignalCard
|
|
key={signal.id}
|
|
signal={signal}
|
|
isSelected={selectedSignal?.id === signal.id}
|
|
onSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Detail drawer */}
|
|
<Drawer
|
|
anchor="right"
|
|
open={!!selectedSignal}
|
|
onClose={() => setSelectedSignal(null)}
|
|
slotProps={{ paper: { sx: { width: 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
|
|
>
|
|
{selectedSignal && (
|
|
<FutureSignalDetailPanel
|
|
signal={selectedSignal}
|
|
onClose={() => setSelectedSignal(null)}
|
|
/>
|
|
)}
|
|
</Drawer>
|
|
</Box>
|
|
)
|
|
}
|