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:
@@ -0,0 +1,101 @@
|
||||
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import { SignalTypeBadge } from './SignalTypeBadge'
|
||||
import { SensitivityBadge } from './SensitivityBadge'
|
||||
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
||||
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
PRESS: 'Presse',
|
||||
CONSTRUCTION_PERMIT: 'Baubewilligung',
|
||||
JOB_POSTING: 'Stelleninserat',
|
||||
COMPANY_REPORT: 'Geschäftsbericht',
|
||||
MARKET_DATA: 'Marktdaten',
|
||||
MANUAL: 'Manuell',
|
||||
}
|
||||
|
||||
function probColor(p: number): string {
|
||||
return p >= 0.7 ? '#1a7a4a' : p >= 0.5 ? '#d97706' : '#c0392b'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
signal: FutureSignal
|
||||
isSelected: boolean
|
||||
onSelect: (signal: FutureSignal) => void
|
||||
}
|
||||
|
||||
export function FutureSignalCard({ signal, isSelected, onSelect }: Props) {
|
||||
const isConfidential = signal.sensitivityLevel === 'CONFIDENTIAL'
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onSelect(signal)}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
borderLeft: isSelected
|
||||
? '3px solid #1e3a5f'
|
||||
: isConfidential
|
||||
? '3px solid #d97706'
|
||||
: '3px solid transparent',
|
||||
bgcolor: isSelected ? '#eff6ff' : isConfidential ? '#fffbeb' : 'transparent',
|
||||
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
|
||||
}}
|
||||
>
|
||||
{/* Row 1: type + sensitivity + review status */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
|
||||
<SignalTypeBadge type={signal.signalType} />
|
||||
<SensitivityBadge level={signal.sensitivityLevel} />
|
||||
<SignalReviewStatusBadge status={signal.reviewStatus} />
|
||||
</Box>
|
||||
|
||||
{/* Row 2: title / company / location */}
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.25 }}>
|
||||
{signal.title ?? signal.companyName ?? signal.locationHint}
|
||||
</Typography>
|
||||
{(signal.title || signal.companyName) && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75 }}>
|
||||
{signal.locationHint}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Row 3: probability */}
|
||||
<Box sx={{ mb: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">Wahrscheinlichkeit</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: probColor(signal.probability) }}>
|
||||
{Math.round(signal.probability * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={signal.probability * 100}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
bgcolor: '#f1f5f9',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: probColor(signal.probability) },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Row 4: meta chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
|
||||
<Chip label={`${signal.timeHorizonMonths}M`} size="small" sx={{ fontSize: 10, height: 18 }} />
|
||||
{signal.areaSqmEstimate && (
|
||||
<Chip label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} size="small" sx={{ fontSize: 10, height: 18 }} />
|
||||
)}
|
||||
<Chip
|
||||
label={SOURCE_LABELS[signal.source.type] ?? signal.source.type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: 10, height: 18 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Footer: mini disclaimer */}
|
||||
<FutureSignalDisclaimer mini />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Box, Button, Chip, CircularProgress, Divider, IconButton, LinearProgress, Paper, Snackbar, Typography } from '@mui/material'
|
||||
import { X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { SignalTypeBadge } from './SignalTypeBadge'
|
||||
import { SensitivityBadge } from './SensitivityBadge'
|
||||
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
||||
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
||||
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
|
||||
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||
import { reviewService } from '../../services/reviewService'
|
||||
import { ReviewStatus } from '../../domain/enums'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
PRESS: 'Pressebericht',
|
||||
CONSTRUCTION_PERMIT: 'Baubewilligung',
|
||||
JOB_POSTING: 'Stelleninserat',
|
||||
COMPANY_REPORT: 'Geschäftsbericht',
|
||||
MARKET_DATA: 'Marktdaten',
|
||||
MANUAL: 'Manuell erfasst',
|
||||
}
|
||||
|
||||
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
||||
HIGH: { label: 'Hoch', color: '#1a7a4a' },
|
||||
MEDIUM: { label: 'Mittel', color: '#d97706' },
|
||||
LOW: { label: 'Niedrig', color: '#c0392b' },
|
||||
}
|
||||
|
||||
function BarRow({ label, value }: { label: string; value: number }) {
|
||||
const color = value >= 0.75 ? '#1a7a4a' : value >= 0.55 ? '#d97706' : '#c0392b'
|
||||
return (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color }}>{Math.round(value * 100)}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={value * 100}
|
||||
sx={{ height: 5, borderRadius: 2, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
signal: FutureSignal
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
||||
const updateStatus = useUpdateSignalReviewStatus()
|
||||
const { openAddDialog } = useShortlistStore()
|
||||
const [snackbar, setSnackbar] = useState<string | null>(null)
|
||||
const [reviewTaskSent, setReviewTaskSent] = useState(false)
|
||||
|
||||
const reviewStatus = signal.reviewStatus ?? ReviewStatus.UNREVIEWED
|
||||
const isRejected = reviewStatus === ReviewStatus.REJECTED
|
||||
const isApproved = reviewStatus === ReviewStatus.APPROVED
|
||||
|
||||
async function handleStatus(status: typeof ReviewStatus[keyof typeof ReviewStatus]) {
|
||||
await updateStatus.mutateAsync({ id: signal.id, status })
|
||||
setSnackbar(`Status aktualisiert: ${status}`)
|
||||
}
|
||||
|
||||
async function handleSendReview() {
|
||||
await reviewService.createReviewTask(signal.id)
|
||||
setReviewTaskSent(true)
|
||||
setSnackbar('Prüfungsaufgabe erstellt')
|
||||
}
|
||||
|
||||
function handleShortlist() {
|
||||
openAddDialog({
|
||||
resultId: signal.id,
|
||||
resultType: 'FUTURE_AVAILABILITY',
|
||||
title: signal.title ?? signal.companyName ?? signal.locationHint,
|
||||
matchScore: Math.round(signal.confidenceScore * 100),
|
||||
confidenceScore: signal.confidenceScore,
|
||||
sourceLabel: SOURCE_LABELS[signal.source.type] ?? signal.source.type,
|
||||
addedBy: 'admin@ideal-sharing.ch',
|
||||
})
|
||||
}
|
||||
|
||||
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? { label: signal.source.credibility, color: '#64748b' }
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ flex: 1, mr: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
|
||||
{signal.title ?? signal.companyName ?? signal.locationHint}
|
||||
</Typography>
|
||||
{(signal.title || signal.companyName) && (
|
||||
<Typography variant="caption" color="text.secondary">{signal.locationHint}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onClose} sx={{ color: '#94a3b8', mt: -0.5 }}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<SignalTypeBadge type={signal.signalType} />
|
||||
<SensitivityBadge level={signal.sensitivityLevel} />
|
||||
<SignalReviewStatusBadge status={signal.reviewStatus} />
|
||||
{signal.isVerified && (
|
||||
<Chip label="Verifiziert" size="small" sx={{ bgcolor: '#1a7a4a', color: 'white', fontSize: 10 }} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Body */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
||||
<FutureSignalDisclaimer />
|
||||
|
||||
{/* Probability + confidence */}
|
||||
<BarRow label="Wahrscheinlichkeit" value={signal.probability} />
|
||||
<BarRow label="Konfidenz" value={signal.confidenceScore} />
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
{/* Meta */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Zeithorizont</Typography>
|
||||
<Typography variant="caption">~{signal.timeHorizonMonths} Monate</Typography>
|
||||
</Box>
|
||||
{signal.areaSqmEstimate && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Geschätzte Fläche</Typography>
|
||||
<Typography variant="caption">~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{signal.companyName && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Unternehmen</Typography>
|
||||
<Typography variant="caption">{signal.companyName}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{signal.riskLevel && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Risikoniveau</Typography>
|
||||
<Typography variant="caption">{signal.riskLevel}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
{/* Source */}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Quelle</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Typ</Typography>
|
||||
<Typography variant="caption">{SOURCE_LABELS[signal.source.type] ?? signal.source.type}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Glaubwürdigkeit</Typography>
|
||||
<Chip label={credMeta.label} size="small" sx={{ bgcolor: credMeta.color, color: 'white', fontSize: 10, height: 18 }} />
|
||||
</Box>
|
||||
{signal.source.publishedAt && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Veröffentlicht</Typography>
|
||||
<Typography variant="caption">{signal.source.publishedAt}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Evidence */}
|
||||
{signal.evidence?.summary && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Evidenz</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: '#f8fafc' }}>
|
||||
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Market indicator */}
|
||||
{signal.marketIndicator && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>Marktindikator</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{signal.marketIndicator}</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
{/* Actions */}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }}>Aktionen</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{reviewStatus === ReviewStatus.UNREVIEWED && (
|
||||
<Button
|
||||
fullWidth size="small" variant="outlined"
|
||||
disabled={updateStatus.isPending}
|
||||
onClick={() => handleStatus(ReviewStatus.IN_REVIEW)}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
Verfolgen (In Prüfung setzen)
|
||||
</Button>
|
||||
)}
|
||||
{!isRejected && !reviewTaskSent && (
|
||||
<Button
|
||||
fullWidth size="small" variant="outlined"
|
||||
onClick={handleSendReview}
|
||||
sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}
|
||||
>
|
||||
Zur Prüfung senden
|
||||
</Button>
|
||||
)}
|
||||
{(reviewStatus === ReviewStatus.IN_REVIEW || reviewStatus === ReviewStatus.FLAGGED) && !isApproved && (
|
||||
<Button
|
||||
fullWidth size="small" variant="contained"
|
||||
disabled={updateStatus.isPending}
|
||||
onClick={() => handleStatus(ReviewStatus.APPROVED)}
|
||||
sx={{ bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' }, justifyContent: 'flex-start' }}
|
||||
endIcon={updateStatus.isPending ? <CircularProgress size={14} color="inherit" /> : undefined}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
)}
|
||||
{!isRejected && (
|
||||
<Button
|
||||
fullWidth size="small" variant="outlined"
|
||||
disabled={updateStatus.isPending}
|
||||
onClick={() => handleStatus(ReviewStatus.REJECTED)}
|
||||
sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
fullWidth size="small" variant="outlined"
|
||||
onClick={handleShortlist}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
Zu Shortlist hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Snackbar
|
||||
open={!!snackbar}
|
||||
autoHideDuration={2500}
|
||||
onClose={() => setSnackbar(null)}
|
||||
message={snackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Alert, Typography } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
mini?: boolean
|
||||
}
|
||||
|
||||
export function FutureSignalDisclaimer({ mini = false }: Props) {
|
||||
if (mini) {
|
||||
return (
|
||||
<Alert severity="warning" sx={{ py: 0.25, px: 1, '& .MuiAlert-message': { py: 0.25 } }}>
|
||||
<Typography variant="caption">Probabilistisches Signal – keine bestätigte Fläche</Typography>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Hinweis: Probabilistisches Zukunftssignal
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Dieses Signal basiert auf AI-Analyse öffentlicher Daten. Es handelt sich um keine bestätigte verfügbare Fläche.
|
||||
Bitte ausschliesslich für strategische Beobachtung und interne Prüfung verwenden — keine verbindlichen Aussagen gegenüber Dritten.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Filter, Radio } from 'lucide-react'
|
||||
|
||||
type EmptyContext = 'no-signals' | 'filtered-empty'
|
||||
|
||||
const META: Record<EmptyContext, { icon: React.ReactNode; title: string; desc: string }> = {
|
||||
'no-signals': {
|
||||
icon: <Radio size={32} color="#94a3b8" />,
|
||||
title: 'Keine Signale vorhanden',
|
||||
desc: 'Es wurden noch keine Zukunftssignale erfasst.',
|
||||
},
|
||||
'filtered-empty': {
|
||||
icon: <Filter size={32} color="#94a3b8" />,
|
||||
title: 'Keine Signale für diese Filter',
|
||||
desc: 'Passen Sie die Filtereinstellungen an, um Signale anzuzeigen.',
|
||||
},
|
||||
}
|
||||
|
||||
export function FutureSignalEmptyState({ context }: { context: EmptyContext }) {
|
||||
const { icon, title, desc } = META[context]
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 8, px: 3 }}>
|
||||
{icon}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} color="text.secondary">{title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', maxWidth: 280 }}>{desc}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Box, Chip, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material'
|
||||
import { SignalType } from '../../domain/enums'
|
||||
import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
|
||||
|
||||
export interface SignalFilterState {
|
||||
signalType: string
|
||||
minConfidence: number
|
||||
sensitivityLevel: string
|
||||
reviewStatus: string
|
||||
timeHorizon: string
|
||||
}
|
||||
|
||||
export const DEFAULT_SIGNAL_FILTERS: SignalFilterState = {
|
||||
signalType: '',
|
||||
minConfidence: 0,
|
||||
sensitivityLevel: '',
|
||||
reviewStatus: '',
|
||||
timeHorizon: '',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filters: SignalFilterState
|
||||
onChange: (f: SignalFilterState) => void
|
||||
totalCount: number
|
||||
filteredCount: number
|
||||
}
|
||||
|
||||
const SIGNAL_TYPES = Object.values(SignalType)
|
||||
|
||||
export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredCount }: Props) {
|
||||
const set = (partial: Partial<SignalFilterState>) => onChange({ ...filters, ...partial })
|
||||
|
||||
const activeCount = [
|
||||
filters.signalType !== '',
|
||||
filters.minConfidence > 0,
|
||||
filters.sensitivityLevel !== '',
|
||||
filters.reviewStatus !== '',
|
||||
filters.timeHorizon !== '',
|
||||
].filter(Boolean).length
|
||||
|
||||
return (
|
||||
<Box sx={{ px: 3, py: 1.5, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||
{/* Signal type chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label="Alle"
|
||||
size="small"
|
||||
onClick={() => set({ signalType: '' })}
|
||||
color={filters.signalType === '' ? 'primary' : 'default'}
|
||||
sx={{ fontWeight: filters.signalType === '' ? 700 : 400 }}
|
||||
/>
|
||||
{SIGNAL_TYPES.map(t => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={SIGNAL_TYPE_LABELS[t] ?? t}
|
||||
size="small"
|
||||
onClick={() => set({ signalType: filters.signalType === t ? '' : t })}
|
||||
sx={{
|
||||
fontWeight: filters.signalType === t ? 700 : 400,
|
||||
bgcolor: filters.signalType === t ? '#1e3a5f' : undefined,
|
||||
color: filters.signalType === t ? 'white' : undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Selects */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Konfidenz</InputLabel>
|
||||
<Select
|
||||
label="Konfidenz"
|
||||
value={filters.minConfidence}
|
||||
onChange={e => set({ minConfidence: Number(e.target.value) })}
|
||||
>
|
||||
<MenuItem value={0}>Alle</MenuItem>
|
||||
<MenuItem value={0.75}>Hoch ≥75%</MenuItem>
|
||||
<MenuItem value={0.55}>Mittel ≥55%</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Prüfstatus</InputLabel>
|
||||
<Select
|
||||
label="Prüfstatus"
|
||||
value={filters.reviewStatus}
|
||||
onChange={e => set({ reviewStatus: e.target.value })}
|
||||
>
|
||||
<MenuItem value="">Alle</MenuItem>
|
||||
<MenuItem value="UNREVIEWED">Ungeprüft</MenuItem>
|
||||
<MenuItem value="IN_REVIEW">In Prüfung</MenuItem>
|
||||
<MenuItem value="APPROVED">Genehmigt</MenuItem>
|
||||
<MenuItem value="REJECTED">Abgelehnt</MenuItem>
|
||||
<MenuItem value="FLAGGED">Markiert</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Zeithorizont</InputLabel>
|
||||
<Select
|
||||
label="Zeithorizont"
|
||||
value={filters.timeHorizon}
|
||||
onChange={e => set({ timeHorizon: e.target.value })}
|
||||
>
|
||||
<MenuItem value="">Alle</MenuItem>
|
||||
<MenuItem value="short">Kurz ≤6M</MenuItem>
|
||||
<MenuItem value="medium">Mittel 7–12M</MenuItem>
|
||||
<MenuItem value="long">Lang >12M</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Vertraulichkeit</InputLabel>
|
||||
<Select
|
||||
label="Vertraulichkeit"
|
||||
value={filters.sensitivityLevel}
|
||||
onChange={e => set({ sensitivityLevel: e.target.value })}
|
||||
>
|
||||
<MenuItem value="">Alle</MenuItem>
|
||||
<MenuItem value="PUBLIC">Öffentlich</MenuItem>
|
||||
<MenuItem value="INTERNAL">Intern</MenuItem>
|
||||
<MenuItem value="CONFIDENTIAL">Vertraulich</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Result count */}
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{activeCount > 0 && (
|
||||
<Chip
|
||||
label={`${activeCount} Filter aktiv`}
|
||||
size="small"
|
||||
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
|
||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f' }}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{filteredCount} / {totalCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Chip } from '@mui/material'
|
||||
|
||||
const SENSITIVITY_META: Record<string, { label: string; color: string }> = {
|
||||
PUBLIC: { label: 'Öffentlich', color: '#64748b' },
|
||||
INTERNAL: { label: 'Intern', color: '#d97706' },
|
||||
CONFIDENTIAL: { label: 'Vertraulich', color: '#c0392b' },
|
||||
RESTRICTED: { label: 'Eingeschränkt', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
export function SensitivityBadge({ level }: { level: string }) {
|
||||
const meta = SENSITIVITY_META[level] ?? { label: level, color: '#64748b' }
|
||||
return (
|
||||
<Chip
|
||||
label={meta.label}
|
||||
size="small"
|
||||
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Chip } from '@mui/material'
|
||||
import type { ReviewStatus } from '../../domain/enums'
|
||||
|
||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
|
||||
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
|
||||
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
|
||||
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
|
||||
FLAGGED: { label: 'Markiert', color: '#ea580c' },
|
||||
}
|
||||
|
||||
export function SignalReviewStatusBadge({ status }: { status?: ReviewStatus | null }) {
|
||||
const key = status ?? 'UNREVIEWED'
|
||||
const meta = STATUS_META[key] ?? { label: key, color: '#94a3b8' }
|
||||
return (
|
||||
<Chip
|
||||
label={meta.label}
|
||||
size="small"
|
||||
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1 +1,10 @@
|
||||
export { SignalTypeBadge } from './SignalTypeBadge'
|
||||
export { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
||||
export { SensitivityBadge } from './SensitivityBadge'
|
||||
export { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
||||
export { FutureSignalCard } from './FutureSignalCard'
|
||||
export { FutureSignalFilterBar } from './FutureSignalFilterBar'
|
||||
export { FutureSignalDetailPanel } from './FutureSignalDetailPanel'
|
||||
export { FutureSignalEmptyState } from './FutureSignalEmptyState'
|
||||
export type { SignalFilterState } from './FutureSignalFilterBar'
|
||||
export { DEFAULT_SIGNAL_FILTERS } from './FutureSignalFilterBar'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
import type { ReviewStatus } from '../domain/enums'
|
||||
import { STALE_SIGNALS } from '../lib/constants'
|
||||
|
||||
export function useFutureSignals() {
|
||||
@@ -11,6 +12,16 @@ export function useFutureSignals() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useFutureSignal(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['futureSignal', id],
|
||||
queryFn: () => futureSignalService.getById(id),
|
||||
staleTime: STALE_SIGNALS,
|
||||
enabled: !!id,
|
||||
select: (res) => res.data ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFutureSignalsByProperty(propertyId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['futureSignals', 'property', propertyId],
|
||||
@@ -24,9 +35,21 @@ export function useFutureSignalsByProperty(propertyId: string) {
|
||||
export function useVerifySignal() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (signalId: string) => futureSignalService.verify(signalId, 'current-user'),
|
||||
mutationFn: (signalId: string) => futureSignalService.verify(signalId, 'admin@ideal-sharing.ch'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSignalReviewStatus() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
futureSignalService.updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['futureSignal'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import type { SignalType } from '../domain/enums'
|
||||
import type { SignalType, ReviewStatus } from '../domain/enums'
|
||||
|
||||
export interface FutureSignalFilters {
|
||||
signalType?: SignalType
|
||||
minProbability?: number
|
||||
organizationId?: string
|
||||
isVerified?: boolean
|
||||
sensitivityLevel?: string
|
||||
reviewStatus?: ReviewStatus
|
||||
maxTimeHorizonMonths?: number
|
||||
}
|
||||
|
||||
export interface IFutureSignalProvider {
|
||||
@@ -13,4 +16,5 @@ export interface IFutureSignalProvider {
|
||||
getById(id: string): Promise<FutureSignal | null>
|
||||
getByProperty(propertyId: string): Promise<FutureSignal[]>
|
||||
verify(id: string, verifiedBy: string): Promise<FutureSignal>
|
||||
updateReviewStatus(id: string, status: ReviewStatus): Promise<FutureSignal>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IFutureSignalProvider, FutureSignalFilters } from './IFutureSignalProvider'
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import type { ReviewStatus } from '../domain/enums'
|
||||
import { mockFutureSignals } from '../mock-data/futureSignals'
|
||||
|
||||
const store: FutureSignal[] = [...mockFutureSignals]
|
||||
@@ -11,6 +12,9 @@ export const MockupFutureSignalProvider: IFutureSignalProvider = {
|
||||
if (filters?.minProbability !== undefined) results = results.filter(s => s.probability >= filters.minProbability!)
|
||||
if (filters?.organizationId) results = results.filter(s => s.organizationId === filters.organizationId)
|
||||
if (filters?.isVerified !== undefined) results = results.filter(s => s.isVerified === filters.isVerified)
|
||||
if (filters?.sensitivityLevel) results = results.filter(s => s.sensitivityLevel === filters.sensitivityLevel)
|
||||
if (filters?.reviewStatus) results = results.filter(s => (s.reviewStatus ?? 'UNREVIEWED') === filters.reviewStatus)
|
||||
if (filters?.maxTimeHorizonMonths !== undefined) results = results.filter(s => s.timeHorizonMonths <= filters.maxTimeHorizonMonths!)
|
||||
return results.sort((a, b) => b.probability - a.probability)
|
||||
},
|
||||
async getById(id) {
|
||||
@@ -24,4 +28,9 @@ export const MockupFutureSignalProvider: IFutureSignalProvider = {
|
||||
store[idx] = { ...store[idx], isVerified: true, verifiedBy, verifiedAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||
return store[idx]
|
||||
},
|
||||
async updateReviewStatus(id, status: ReviewStatus) {
|
||||
const idx = store.findIndex(s => s.id === id)
|
||||
store[idx] = { ...store[idx], reviewStatus: status, updatedAt: new Date().toISOString() }
|
||||
return store[idx]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider'
|
||||
import type { FutureSignalFilters } from '../provider/IFutureSignalProvider'
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import type { ReviewStatus } from '../domain/enums'
|
||||
import type { FutureSignalSummary } from '../domain/dashboard'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
|
||||
@@ -23,6 +24,10 @@ export const futureSignalService = {
|
||||
const data = await provider.verify(id, verifiedBy)
|
||||
return { data }
|
||||
},
|
||||
async updateReviewStatus(id: string, status: ReviewStatus): Promise<ItemResponse<FutureSignal>> {
|
||||
const data = await provider.updateReviewStatus(id, status)
|
||||
return { data }
|
||||
},
|
||||
|
||||
async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
||||
const data = await provider.getByProperty(propertyId)
|
||||
|
||||
Reference in New Issue
Block a user