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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user