Initial commit
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export default function FutureAvailability() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: resp, isLoading, error } = useQuery({
|
||||
queryKey: ['futureSignals'],
|
||||
queryFn: () => futureSignalService.getAll(),
|
||||
})
|
||||
|
||||
const verifyMutation = useMutation({
|
||||
mutationFn: (signalId: string) => futureSignalService.verify(signalId, 'admin@ideal-sharing.ch'),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['futureSignals'] }),
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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" fontWeight={700} color="text.primary">Marktchancen</Typography>
|
||||
<Chip
|
||||
label="Shadow Intelligence Layer"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">KI-generierte Verfügbarkeitssignale</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
||||
|
||||
{/* Stats Row */}
|
||||
<Box className="grid grid-cols-4 gap-4">
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" display="block">Signale gesamt</Typography>
|
||||
<Typography variant="h4" fontWeight={700}>{totalCount}</Typography>
|
||||
</Card>
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" display="block">Verifiziert</Typography>
|
||||
<Typography variant="h4" fontWeight={700} sx={{ color: '#1a7a4a' }}>{verifiedCount}</Typography>
|
||||
</Card>
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" display="block">Hohe Wahrscheinlichkeit</Typography>
|
||||
<Typography variant="h4" fontWeight={700} sx={{ color: '#1e3a5f' }}>{highProbCount}</Typography>
|
||||
</Card>
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" display="block">Ø Wahrscheinlichkeit</Typography>
|
||||
<Typography variant="h4" fontWeight={700} sx={{ color: getProbabilityColor(avgProbability) === 'success' ? '#1a7a4a' : getProbabilityColor(avgProbability) === 'warning' ? '#d97706' : '#c0392b' }}>
|
||||
{Math.round(avgProbability * 100)}%
|
||||
</Typography>
|
||||
</Card>
|
||||
</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" 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" fontWeight={700} sx={{ 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>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user