242 lines
9.5 KiB
TypeScript
242 lines
9.5 KiB
TypeScript
import { Box, Button, Chip, CircularProgress, Divider, Tooltip, Typography } from '@mui/material'
|
|
import {
|
|
Building2, FileText, Newspaper, TrendingUp, FileCheck2,
|
|
HardHat, Database, CalendarClock, Search, PenLine, CheckCircle2,
|
|
XCircle, Send, Link2,
|
|
} from 'lucide-react'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
import type { MarketSignal } from '../../domain/marketSignal'
|
|
import {
|
|
MARKET_SIGNAL_SOURCE_LABELS,
|
|
SIGNAL_PROCESSING_STATUS_LABELS,
|
|
SIGNAL_PROCESSING_STATUS_COLORS,
|
|
SignalProcessingStatus,
|
|
ExtractedEntityType,
|
|
} from '../../domain/marketSignal'
|
|
import { SensitivityLevel } from '../../domain/enums'
|
|
import { FreshnessBadge } from '../badges/FreshnessBadge'
|
|
import { SourceReliabilityBadge } from './SourceReliabilityBadge'
|
|
import { SignalConfidenceBadge } from './SignalConfidenceBadge'
|
|
import { SensitivityWarningPanel } from './SensitivityWarningPanel'
|
|
import { SignalEvidenceList } from './SignalEvidenceList'
|
|
import { SignalConversionPanel } from './SignalConversionPanel'
|
|
import { MarketSignalEmptyState } from './MarketSignalEmptyState'
|
|
import { FeedEligibilityBadge } from './FeedEligibilityBadge'
|
|
import { useUpdateSignalStatus, useCreateReviewTask } from '../../hooks/useMarketSignals'
|
|
|
|
const SOURCE_ICONS: Record<string, LucideIcon> = {
|
|
PUBLIC_LISTING_PLATFORM: Building2,
|
|
BUILDING_PERMIT_REGISTER: FileText,
|
|
COMPANY_NEWS: Newspaper,
|
|
JOB_GROWTH_SIGNAL: TrendingUp,
|
|
COMMERCIAL_REGISTER: FileCheck2,
|
|
INFRASTRUCTURE_PROJECT: HardHat,
|
|
PORTFOLIO_IMPORT: Database,
|
|
LEASE_EXPIRY_DATA: CalendarClock,
|
|
USER_DEMAND_SIGNAL: Search,
|
|
MANUAL_ANALYST_SIGNAL: PenLine,
|
|
}
|
|
|
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
|
[ExtractedEntityType.COMPANY]: 'Unternehmen',
|
|
[ExtractedEntityType.PERSON]: 'Person',
|
|
[ExtractedEntityType.LOCATION]: 'Ort',
|
|
[ExtractedEntityType.ASSET]: 'Objekt',
|
|
[ExtractedEntityType.DATE]: 'Datum',
|
|
}
|
|
|
|
const SENSITIVITY_LABELS: Record<string, string> = {
|
|
[SensitivityLevel.PUBLIC]: 'Öffentlich',
|
|
[SensitivityLevel.INTERNAL]: 'Intern',
|
|
[SensitivityLevel.CONFIDENTIAL]: 'Vertraulich',
|
|
[SensitivityLevel.RESTRICTED]: 'Eingeschränkt',
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleString('de-CH', {
|
|
day: '2-digit', month: '2-digit', year: '2-digit',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
interface MarketSignalDetailPanelProps {
|
|
signal: MarketSignal | null
|
|
}
|
|
|
|
export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps) {
|
|
const { mutate: updateStatus, isPending: isUpdating } = useUpdateSignalStatus()
|
|
const { mutate: sendToReview, isPending: isSending } = useCreateReviewTask()
|
|
|
|
if (!signal) return <MarketSignalEmptyState variant="not-found" />
|
|
|
|
const SourceIcon = SOURCE_ICONS[signal.sourceCategory] ?? Building2
|
|
const { bg, fg } = SIGNAL_PROCESSING_STATUS_COLORS[signal.processingStatus]
|
|
const NON_ACTIONABLE_STATUSES: SignalProcessingStatus[] = [
|
|
SignalProcessingStatus.REJECTED,
|
|
SignalProcessingStatus.ARCHIVED,
|
|
SignalProcessingStatus.CONVERTED_TO_FUTURE_AVAILABILITY,
|
|
]
|
|
const isActionable = !NON_ACTIONABLE_STATUSES.includes(signal.processingStatus)
|
|
|
|
return (
|
|
<Box sx={{ height: '100%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
|
{/* Header */}
|
|
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, pr: 2 }}>
|
|
{signal.title}
|
|
</Typography>
|
|
<Chip
|
|
size="small"
|
|
label={SIGNAL_PROCESSING_STATUS_LABELS[signal.processingStatus]}
|
|
sx={{ bgcolor: bg, color: fg, border: 'none', fontWeight: 600, flexShrink: 0 }}
|
|
/>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
|
<SourceIcon size={13} color="#64748b" />
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
{MARKET_SIGNAL_SOURCE_LABELS[signal.sourceCategory]} · {signal.location} · {formatDate(signal.detectedAt)}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
|
<SourceReliabilityBadge score={signal.sourceReliabilityScore} />
|
|
<SignalConfidenceBadge score={signal.confidenceScore} />
|
|
<FreshnessBadge status={signal.freshnessStatus} />
|
|
<Chip
|
|
size="small"
|
|
label={SENSITIVITY_LABELS[signal.sensitivityLevel]}
|
|
sx={{
|
|
bgcolor: 'transparent', border: '1px solid',
|
|
borderColor: signal.sensitivityLevel === SensitivityLevel.CONFIDENTIAL ? '#ef4444'
|
|
: signal.sensitivityLevel === SensitivityLevel.RESTRICTED ? '#7c3aed'
|
|
: 'divider',
|
|
color: signal.sensitivityLevel === SensitivityLevel.CONFIDENTIAL ? '#dc2626'
|
|
: signal.sensitivityLevel === SensitivityLevel.RESTRICTED ? '#7c3aed'
|
|
: 'text.secondary',
|
|
fontSize: '0.7rem',
|
|
}}
|
|
/>
|
|
<FeedEligibilityBadge signal={signal} />
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Body */}
|
|
<Box sx={{ p: 3, flex: 1 }}>
|
|
<SensitivityWarningPanel sensitivityLevel={signal.sensitivityLevel} />
|
|
|
|
{/* Summary */}
|
|
<Typography variant="body2" sx={{ color: '#1e293b', lineHeight: 1.6, mb: 2.5 }}>
|
|
{signal.summary}
|
|
</Typography>
|
|
|
|
<Divider sx={{ mb: 2 }} />
|
|
|
|
{/* Evidence */}
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
|
Quellenangaben & Evidenz
|
|
</Typography>
|
|
<SignalEvidenceList evidence={signal.evidence} />
|
|
|
|
{signal.extractedEntities.length > 0 && (
|
|
<>
|
|
<Divider sx={{ my: 2 }} />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
|
Extrahierte Entitäten
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
|
{signal.extractedEntities.map((entity, i) => (
|
|
<Tooltip
|
|
key={i}
|
|
title={`${ENTITY_TYPE_LABELS[entity.type]} · Konfidenz ${Math.round(entity.confidence * 100)}%`}
|
|
>
|
|
<Chip
|
|
size="small"
|
|
label={entity.value}
|
|
sx={{
|
|
bgcolor: 'rgba(30,58,95,0.08)',
|
|
color: '#1e3a5f',
|
|
border: 'none',
|
|
fontSize: '0.75rem',
|
|
cursor: 'default',
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
))}
|
|
</Box>
|
|
</>
|
|
)}
|
|
|
|
{signal.analystNotes && (
|
|
<>
|
|
<Divider sx={{ my: 2 }} />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.75 }}>
|
|
Analyst-Notizen
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic', lineHeight: 1.5 }}>
|
|
{signal.analystNotes}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
|
|
{/* Conversion panel */}
|
|
<Divider sx={{ my: 2 }} />
|
|
<SignalConversionPanel signal={signal} />
|
|
|
|
{/* Actions */}
|
|
{isActionable && (
|
|
<>
|
|
<Divider sx={{ my: 2 }} />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
|
Aktionen
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={isUpdating ? <CircularProgress size={12} /> : <CheckCircle2 size={14} />}
|
|
disabled={isUpdating || signal.processingStatus === SignalProcessingStatus.APPROVED_AS_SIGNAL}
|
|
onClick={() => updateStatus({ id: signal.id, status: SignalProcessingStatus.APPROVED_AS_SIGNAL })}
|
|
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#16a34a', color: '#16a34a', '&:hover': { borderColor: '#15803d', bgcolor: 'rgba(22,163,74,0.04)' } }}
|
|
>
|
|
Als relevant markieren
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<Send size={14} />}
|
|
disabled={isSending}
|
|
onClick={() => sendToReview(signal.id)}
|
|
sx={{ textTransform: 'none', fontSize: '0.8rem' }}
|
|
>
|
|
Zur Prüfung senden
|
|
</Button>
|
|
<Tooltip title="Objekt verknüpfen (Demo)">
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<Link2 size={14} />}
|
|
sx={{ textTransform: 'none', fontSize: '0.8rem', color: 'text.secondary', borderColor: 'divider' }}
|
|
>
|
|
Verknüpfen
|
|
</Button>
|
|
</Tooltip>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<XCircle size={14} />}
|
|
disabled={isUpdating}
|
|
onClick={() => updateStatus({ id: signal.id, status: SignalProcessingStatus.REJECTED })}
|
|
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#ef4444', color: '#dc2626', '&:hover': { borderColor: '#dc2626', bgcolor: 'rgba(239,68,68,0.04)' } }}
|
|
>
|
|
Ablehnen
|
|
</Button>
|
|
</Box>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|