feat: F016 future availability workspace

2-panel workspace at /supply/future-availability: filterable signal list with FutureSignalCard (type/sensitivity/review badges + mandatory disclaimer) and collapsible detail panel with full evidence, review workflow (IN_REVIEW→APPROVED/REJECTED), shortlist action, and review task creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-16 21:19:52 +02:00
parent 65130efca7
commit 2e68638246
13 changed files with 767 additions and 216 deletions
+121 -214
View File
@@ -1,234 +1,141 @@
import { useState } from 'react'
import { Box, Chip, Paper, Typography } from '@mui/material'
import { useFutureSignals } from '../../hooks/useFutureSignals'
import {
Box,
Button,
Card,
Chip,
Divider,
LinearProgress,
Typography,
} from '@mui/material'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
import { futureSignalService } from '../../services/futureSignalService'
import { SignalType } from '../../domain/enums'
FutureSignalCard,
FutureSignalFilterBar,
FutureSignalDetailPanel,
FutureSignalEmptyState,
DEFAULT_SIGNAL_FILTERS,
} from '../../components/future-signals'
import { AddToShortlistDialog } from '../../components/shortlist'
import type { SignalFilterState } from '../../components/future-signals'
import type { FutureSignal } from '../../domain/futureSignal'
function getSignalTypeLabel(type: SignalType): string {
switch (type) {
case SignalType.EXPANSION: return 'Expansion'
case SignalType.POSSIBLE_MOVE_OUT: return 'Möglicher Auszug'
case SignalType.CONSTRUCTION_PROJECT: return 'Bauprojekt'
case SignalType.RESTRUCTURING: return 'Restrukturierung'
case SignalType.PROJECT_DEVELOPMENT: return 'Projektentwicklung'
case SignalType.SPACE_CONSOLIDATION: return 'Flächenkonsolidierung'
}
}
function getSignalTypeColor(type: SignalType): string {
switch (type) {
case SignalType.EXPANSION: return '#1a7a4a'
case SignalType.POSSIBLE_MOVE_OUT: return '#d97706'
case SignalType.CONSTRUCTION_PROJECT: return '#1e3a5f'
case SignalType.RESTRUCTURING: return '#ea580c'
case SignalType.PROJECT_DEVELOPMENT: return '#7c3aed'
case SignalType.SPACE_CONSOLIDATION: return '#6b7280'
}
}
function getProbabilityColor(prob: number): 'success' | 'warning' | 'error' {
if (prob > 0.7) return 'success'
if (prob >= 0.5) return 'warning'
return 'error'
}
function formatDate(dateStr?: string): string {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function getSourceTypeLabel(type: string): string {
switch (type) {
case 'PRESS': return 'Pressebericht'
case 'CONSTRUCTION_PERMIT': return 'Baubewilligung'
case 'JOB_POSTING': return 'Stelleninserat'
case 'COMPANY_REPORT': return 'Geschäftsbericht'
case 'MARKET_DATA': return 'Marktdaten'
case 'MANUAL': return 'Manuell'
default: return type
}
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 queryClient = useQueryClient()
const { data: signals = [], isLoading } = useFutureSignals()
const [filters, setFilters] = useState<SignalFilterState>(DEFAULT_SIGNAL_FILTERS)
const [selectedSignal, setSelectedSignal] = useState<FutureSignal | null>(null)
const { data: resp, isLoading, error } = useQuery({
queryKey: ['futureSignals'],
queryFn: () => futureSignalService.getAll(),
})
const filtered = applyFilters(signals, filters)
const verifyMutation = useMutation({
mutationFn: (signalId: string) => futureSignalService.verify(signalId, 'admin@ideal-sharing.ch'),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['futureSignals'] }),
})
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
if (isLoading) return <LoadingPage />
if (error) return <ErrorState />
const signals = resp?.data ?? []
const totalCount = signals.length
const verifiedCount = signals.filter(s => s.isVerified).length
const highProbCount = signals.filter(s => s.probability > 0.7).length
const avgProbability = signals.length
? signals.reduce((sum, s) => sum + s.probability, 0) / signals.length
: 0
function handleSelect(signal: FutureSignal) {
setSelectedSignal(prev => prev?.id === signal.id ? null : signal)
}
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center gap-3">
<Box className="flex-1">
<Box className="flex items-center gap-2">
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Marktchancen</Typography>
<Chip
label="Shadow Intelligence Layer"
size="small"
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 600 }}
/>
<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>
<Typography variant="body2" color="text.secondary">KI-generierte Verfügbarkeitssignale</Typography>
<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>
{/* Content */}
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
{/* Filter bar */}
<FutureSignalFilterBar
filters={filters}
onChange={setFilters}
totalCount={signals.length}
filteredCount={filtered.length}
/>
{/* Stats Row */}
<Box className="grid grid-cols-4 gap-4">
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Signale gesamt</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>{totalCount}</Typography>
</Card>
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Verifiziert</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: '#1a7a4a' }}>{verifiedCount}</Typography>
</Card>
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Hohe Wahrscheinlichkeit</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: '#1e3a5f' }}>{highProbCount}</Typography>
</Card>
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Ø Wahrscheinlichkeit</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: getProbabilityColor(avgProbability) === 'success' ? '#1a7a4a' : getProbabilityColor(avgProbability) === 'warning' ? '#d97706' : '#c0392b' }}>
{Math.round(avgProbability * 100)}%
</Typography>
</Card>
{/* Body */}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Signal list */}
<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>
) : filtered.length === 0 ? (
<FutureSignalEmptyState context={signals.length === 0 ? 'no-signals' : 'filtered-empty'} />
) : (
<Box sx={{ bgcolor: 'white', borderRight: '1px solid #e2e8f0' }}>
{filtered.map(signal => (
<FutureSignalCard
key={signal.id}
signal={signal}
isSelected={selectedSignal?.id === signal.id}
onSelect={handleSelect}
/>
))}
</Box>
)}
</Box>
{/* Signal Cards Grid */}
{signals.length === 0 ? (
<EmptyState title="Keine Signale gefunden" description="Es sind noch keine Zukunftssignale vorhanden." />
) : (
<Box className="flex flex-wrap gap-4">
{signals.map(signal => (
<Card key={signal.id} sx={{ elevation: 1, p: 2, flex: '1 1 calc(50% - 16px)', minWidth: 320 }}>
{/* Card Header */}
<Box className="flex items-center justify-between mb-2">
<Chip
label={getSignalTypeLabel(signal.signalType)}
size="small"
sx={{ bgcolor: getSignalTypeColor(signal.signalType), color: 'white', fontWeight: 600 }}
/>
<Chip label={signal.sensitivityLevel === 'PUBLIC' ? 'Öffentlich' : signal.sensitivityLevel === 'CONFIDENTIAL' ? 'Vertraulich' : 'Intern'}
size="small"
variant="outlined"
color={signal.sensitivityLevel === 'CONFIDENTIAL' ? 'error' : 'default'}
/>
</Box>
{/* Location */}
<Box className="mb-2">
<Typography variant="body1" sx={{ fontWeight: 500 }}>{signal.locationHint}</Typography>
{signal.companyName && (
<Typography variant="body2" color="text.secondary">{signal.companyName}</Typography>
)}
</Box>
{/* Probability */}
<Box className="mb-2">
<Box className="flex items-center justify-between mb-1">
<Typography variant="caption" color="text.secondary">Wahrscheinlichkeit</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: signal.probability > 0.7 ? '#1a7a4a' : signal.probability >= 0.5 ? '#d97706' : '#c0392b' }}>
{Math.round(signal.probability * 100)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={signal.probability * 100}
color={getProbabilityColor(signal.probability)}
sx={{ height: 6, borderRadius: 3 }}
/>
</Box>
{/* Details */}
<Box className="flex flex-wrap gap-2 mb-2">
{signal.areaSqmEstimate && (
<Typography variant="caption" color="text.secondary">
Fläche: ca. {signal.areaSqmEstimate.toLocaleString('de-CH')} m²
</Typography>
)}
<Typography variant="caption" color="text.secondary">
Zeithorizont: {signal.timeHorizonMonths} Monate
</Typography>
{signal.expiresAt && (
<Typography variant="caption" color="text.secondary">
Verfügbar ab: {formatDate(signal.expiresAt)}
</Typography>
)}
</Box>
{/* Source */}
<Box className="mb-2">
<Typography variant="caption" color="text.secondary">
Quelle: {getSourceTypeLabel(signal.source.type)} Glaubwürdigkeit:{' '}
<span style={{ color: signal.source.credibility === 'HIGH' ? '#1a7a4a' : signal.source.credibility === 'MEDIUM' ? '#d97706' : '#c0392b', fontWeight: 600 }}>
{signal.source.credibility === 'HIGH' ? 'Hoch' : signal.source.credibility === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
</span>
</Typography>
</Box>
{/* Verification Status */}
<Box className="mb-2">
{signal.isVerified ? (
<Box className="flex items-center gap-2">
<Chip label="Verifiziert" color="success" size="small" />
<Typography variant="caption" color="text.secondary">{formatDate(signal.verifiedAt)}</Typography>
</Box>
) : (
<Chip label="Nicht verifiziert" color="warning" size="small" variant="outlined" />
)}
</Box>
<Divider sx={{ my: 1 }} />
{/* Footer buttons */}
<Box className="flex items-center gap-2">
{!signal.isVerified && (
<Button
variant="outlined"
size="small"
onClick={() => verifyMutation.mutate(signal.id)}
disabled={verifyMutation.isPending}
>
Verifizieren
</Button>
)}
<Button variant="outlined" size="small" disabled>
Zu Shortlist
</Button>
</Box>
</Card>
))}
</Box>
{/* Detail panel */}
{selectedSignal && (
<Paper elevation={0} sx={{
width: 360,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
borderRadius: 0,
borderLeft: '1px solid #e2e8f0',
overflow: 'hidden',
}}>
<FutureSignalDetailPanel
signal={selectedSignal}
onClose={() => setSelectedSignal(null)}
/>
</Paper>
)}
</Box>
</Box>