feat: F021 market intelligence & signal discovery
Signal domain types, mock data (8 signals), provider interface + MockupMarketIntelligenceProvider, marketIntelligenceService, TanStack Query hooks, 10 ops components (SignalInbox, MarketSignalCard, MarketSignalFilterBar, MarketSignalSkeleton, MarketSignalEmptyState, SignalConfidenceBadge, SourceReliabilityBadge, SensitivityWarningPanel, SignalConversionPanel, SignalEvidenceList), MarketIntelligence page wired into AppShell nav and App router. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
|
import {
|
||||||
|
Building2, FileText, Newspaper, TrendingUp, FileCheck2,
|
||||||
|
HardHat, Database, CalendarClock, Search, PenLine,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
MARKET_SIGNAL_SOURCE_LABELS,
|
||||||
|
SIGNAL_PROCESSING_STATUS_LABELS,
|
||||||
|
SIGNAL_PROCESSING_STATUS_COLORS,
|
||||||
|
MarketSignalSourceCategory,
|
||||||
|
} from '../../domain/marketSignal'
|
||||||
|
import type { MarketSignal } from '../../domain/marketSignal'
|
||||||
|
import { SensitivityLevel } from '../../domain/enums'
|
||||||
|
import { SignalConfidenceBadge } from './SignalConfidenceBadge'
|
||||||
|
|
||||||
|
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 SENSITIVITY_COLORS: Record<string, string> = {
|
||||||
|
[SensitivityLevel.INTERNAL]: '#f59e0b',
|
||||||
|
[SensitivityLevel.CONFIDENTIAL]: '#ef4444',
|
||||||
|
[SensitivityLevel.RESTRICTED]: '#7c3aed',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketSignalCardProps {
|
||||||
|
signal: MarketSignal
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketSignalCard({ signal, selected, onClick }: MarketSignalCardProps) {
|
||||||
|
const Icon = SOURCE_ICONS[signal.sourceCategory] ?? Building2
|
||||||
|
const { bg, fg } = SIGNAL_PROCESSING_STATUS_COLORS[signal.processingStatus]
|
||||||
|
const sensitivityColor = SENSITIVITY_COLORS[signal.sensitivityLevel]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
borderLeft: selected ? '3px solid #1e3a5f' : '3px solid transparent',
|
||||||
|
bgcolor: selected ? 'rgba(30,58,95,0.04)' : 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.12s ease',
|
||||||
|
'&:hover': { bgcolor: selected ? 'rgba(30,58,95,0.06)' : 'rgba(0,0,0,0.02)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Row 1: source + sensitivity */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Icon size={12} color="#64748b" />
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: '#64748b' }}>
|
||||||
|
{MARKET_SIGNAL_SOURCE_LABELS[signal.sourceCategory as MarketSignalSourceCategory]}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{sensitivityColor && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: sensitivityColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Row 2: title */}
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#1e293b',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{signal.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Row 3: location + date */}
|
||||||
|
<Typography sx={{ fontSize: '0.7rem', color: '#94a3b8', mb: 0.5 }}>
|
||||||
|
{signal.location} · {formatDate(signal.detectedAt)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Row 4: status + confidence */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={SIGNAL_PROCESSING_STATUS_LABELS[signal.processingStatus]}
|
||||||
|
sx={{
|
||||||
|
bgcolor: bg, color: fg, border: 'none', fontWeight: 600,
|
||||||
|
fontSize: '0.65rem', height: 18,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SignalConfidenceBadge score={signal.confidenceScore} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { Inbox, MousePointerClick, SearchX, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
type EmptyVariant = 'no-selection' | 'empty-inbox' | 'no-results' | 'not-found'
|
||||||
|
|
||||||
|
const VARIANTS: Record<EmptyVariant, { icon: typeof Inbox; title: string; subtitle: string }> = {
|
||||||
|
'no-selection': {
|
||||||
|
icon: MousePointerClick,
|
||||||
|
title: 'Signal auswählen',
|
||||||
|
subtitle: 'Wählen Sie ein Signal aus der Liste, um die Details anzuzeigen.',
|
||||||
|
},
|
||||||
|
'empty-inbox': {
|
||||||
|
icon: Inbox,
|
||||||
|
title: 'Keine Signale',
|
||||||
|
subtitle: 'Es wurden noch keine Marktzeichen erkannt. Starten Sie eine Intelligence-Analyse.',
|
||||||
|
},
|
||||||
|
'no-results': {
|
||||||
|
icon: SearchX,
|
||||||
|
title: 'Keine Ergebnisse',
|
||||||
|
subtitle: 'Keine Signale entsprechen den aktiven Filtern. Filter anpassen oder zurücksetzen.',
|
||||||
|
},
|
||||||
|
'not-found': {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
title: 'Signal nicht gefunden',
|
||||||
|
subtitle: 'Das ausgewählte Signal konnte nicht geladen werden.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketSignalEmptyState({ variant }: { variant: EmptyVariant }) {
|
||||||
|
const { icon: Icon, title, subtitle } = VARIANTS[variant]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 300,
|
||||||
|
gap: 1.5,
|
||||||
|
p: 4,
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'rgba(148,163,184,0.12)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={24} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ textAlign: 'center', maxWidth: 320 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', mb: 0.5 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
MarketSignalSourceCategory,
|
||||||
|
MARKET_SIGNAL_SOURCE_LABELS,
|
||||||
|
SignalProcessingStatus,
|
||||||
|
SIGNAL_PROCESSING_STATUS_LABELS,
|
||||||
|
SIGNAL_PROCESSING_STATUS_COLORS,
|
||||||
|
} from '../../domain/marketSignal'
|
||||||
|
import type { MarketSignalFilters } from '../../domain/marketSignal'
|
||||||
|
|
||||||
|
interface MarketSignalFilterBarProps {
|
||||||
|
filters: MarketSignalFilters
|
||||||
|
onChange: (f: MarketSignalFilters) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_OPTIONS = Object.values(MarketSignalSourceCategory)
|
||||||
|
const STATUS_OPTIONS: SignalProcessingStatus[] = [
|
||||||
|
SignalProcessingStatus.NEEDS_REVIEW,
|
||||||
|
SignalProcessingStatus.ENRICHED,
|
||||||
|
SignalProcessingStatus.APPROVED_AS_SIGNAL,
|
||||||
|
SignalProcessingStatus.DETECTED,
|
||||||
|
SignalProcessingStatus.CONVERTED_TO_FUTURE_AVAILABILITY,
|
||||||
|
SignalProcessingStatus.REJECTED,
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MarketSignalFilterBar({ filters, onChange }: MarketSignalFilterBarProps) {
|
||||||
|
const hasFilters = !!(filters.sourceCategory || filters.processingStatus)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
{/* Source category */}
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5, fontWeight: 600 }}>
|
||||||
|
Quelle
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
|
||||||
|
{SOURCE_OPTIONS.map((cat) => {
|
||||||
|
const active = filters.sourceCategory === cat
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={cat}
|
||||||
|
label={MARKET_SIGNAL_SOURCE_LABELS[cat]}
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ ...filters, sourceCategory: active ? undefined : cat })
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: 20,
|
||||||
|
bgcolor: active ? '#1e3a5f' : 'transparent',
|
||||||
|
color: active ? '#fff' : 'text.secondary',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: active ? '#1e3a5f' : 'divider',
|
||||||
|
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Processing status */}
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5, fontWeight: 600 }}>
|
||||||
|
Status
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: hasFilters ? 1 : 0 }}>
|
||||||
|
{STATUS_OPTIONS.map((status) => {
|
||||||
|
const active = filters.processingStatus === status
|
||||||
|
const { bg, fg } = SIGNAL_PROCESSING_STATUS_COLORS[status]
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={status}
|
||||||
|
label={SIGNAL_PROCESSING_STATUS_LABELS[status]}
|
||||||
|
size="small"
|
||||||
|
clickable
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ ...filters, processingStatus: active ? undefined : status })
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: 20,
|
||||||
|
bgcolor: active ? bg : 'transparent',
|
||||||
|
color: active ? fg : 'text.secondary',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: active ? fg : 'divider',
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
'&:hover': { bgcolor: bg },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<X size={12} />}
|
||||||
|
onClick={() => onChange({})}
|
||||||
|
sx={{ color: 'text.secondary', textTransform: 'none', fontSize: '0.75rem', p: 0 }}
|
||||||
|
>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Box, Skeleton } from '@mui/material'
|
||||||
|
|
||||||
|
export function MarketSignalSkeleton() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
<Skeleton variant="rounded" width={80} height={20} />
|
||||||
|
<Skeleton variant="rounded" width={60} height={20} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="text" sx={{ fontSize: '0.875rem' }} width="90%" />
|
||||||
|
<Skeleton variant="text" sx={{ fontSize: '0.75rem' }} width="60%" />
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Skeleton variant="rounded" width={90} height={20} />
|
||||||
|
<Skeleton variant="rounded" width={70} height={20} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Alert, AlertTitle } from '@mui/material'
|
||||||
|
import { Lock } from 'lucide-react'
|
||||||
|
import { SensitivityLevel } from '../../domain/enums'
|
||||||
|
|
||||||
|
interface SensitivityWarningPanelProps {
|
||||||
|
sensitivityLevel: SensitivityLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SensitivityWarningPanel({ sensitivityLevel }: SensitivityWarningPanelProps) {
|
||||||
|
if (
|
||||||
|
sensitivityLevel === SensitivityLevel.PUBLIC ||
|
||||||
|
sensitivityLevel === SensitivityLevel.INTERNAL
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfidential = sensitivityLevel === SensitivityLevel.CONFIDENTIAL
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
icon={<Lock size={18} />}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<AlertTitle sx={{ fontWeight: 600 }}>
|
||||||
|
{isConfidential ? 'Vertrauliche Daten' : 'Zugriff eingeschränkt'}
|
||||||
|
</AlertTitle>
|
||||||
|
{isConfidential
|
||||||
|
? 'Dieser Hinweis enthält vertrauliche Informationen aus internen oder Partnerdaten. Keine Weitergabe an Demand User. Nur für berechtigte Personen sichtbar.'
|
||||||
|
: 'Der Zugriff auf diesen Hinweis ist eingeschränkt. Verbreitung und Veröffentlichung ohne Freigabe nicht zulässig.'}
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import { Target } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SignalConfidenceBadgeProps {
|
||||||
|
score: number
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(score: number): { bg: string; fg: string } {
|
||||||
|
if (score >= 0.8) return { bg: 'rgba(34,197,94,0.12)', fg: '#16a34a' }
|
||||||
|
if (score >= 0.6) return { bg: 'rgba(99,102,241,0.12)', fg: '#4f46e5' }
|
||||||
|
if (score >= 0.4) return { bg: 'rgba(245,158,11,0.12)', fg: '#d97706' }
|
||||||
|
return { bg: 'rgba(239,68,68,0.12)', fg: '#dc2626' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalConfidenceBadge({ score, size = 'small' }: SignalConfidenceBadgeProps) {
|
||||||
|
const { bg, fg } = getColor(score)
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size={size}
|
||||||
|
icon={<Target size={11} color={fg} />}
|
||||||
|
label={`Konfidenz ${Math.round(score * 100)}%`}
|
||||||
|
sx={{
|
||||||
|
bgcolor: bg,
|
||||||
|
color: fg,
|
||||||
|
border: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: size === 'small' ? '0.7rem' : '0.75rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
|
||||||
|
import { ArrowRight, Zap } from 'lucide-react'
|
||||||
|
import type { MarketSignal } from '../../domain/marketSignal'
|
||||||
|
import { SignalProcessingStatus } from '../../domain/marketSignal'
|
||||||
|
import { useConvertToFutureSignal } from '../../hooks/useMarketSignals'
|
||||||
|
|
||||||
|
const ELIGIBLE_STATUSES: SignalProcessingStatus[] = [
|
||||||
|
SignalProcessingStatus.ENRICHED,
|
||||||
|
SignalProcessingStatus.APPROVED_AS_SIGNAL,
|
||||||
|
]
|
||||||
|
|
||||||
|
interface SignalConversionPanelProps {
|
||||||
|
signal: MarketSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalConversionPanel({ signal }: SignalConversionPanelProps) {
|
||||||
|
const { mutate: convert, isPending, isSuccess } = useConvertToFutureSignal()
|
||||||
|
|
||||||
|
if (!ELIGIBLE_STATUSES.includes(signal.processingStatus)) return null
|
||||||
|
|
||||||
|
if (signal.possibleFutureSignalId || isSuccess) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'rgba(139,92,246,0.08)',
|
||||||
|
border: '1px solid rgba(139,92,246,0.2)',
|
||||||
|
borderRadius: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Zap size={16} color="#7c3aed" />
|
||||||
|
<Typography variant="body2" sx={{ color: '#7c3aed', fontWeight: 500 }}>
|
||||||
|
Bereits in Future Availability überführt
|
||||||
|
{signal.possibleFutureSignalId && ` (${signal.possibleFutureSignalId})`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<ArrowRight size={16} color="#1e3a5f" />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
In Future Availability überführen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 0.75 }}>
|
||||||
|
<strong>Vorgeschlagener Titel:</strong> {signal.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||||
|
<strong>Begründung:</strong> Signal hat ausreichend Evidenz und Source Reliability, um als
|
||||||
|
Future Availability Kandidat geführt zu werden. Manueller Review empfohlen.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => convert(signal.id)}
|
||||||
|
startIcon={isPending ? <CircularProgress size={14} sx={{ color: '#fff' }} /> : <Zap size={14} />}
|
||||||
|
sx={{ bgcolor: '#7c3aed', textTransform: 'none', '&:hover': { bgcolor: '#6d28d9' } }}
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird überführt...' : 'Jetzt konvertieren'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Box, Chip, Link, Typography } from '@mui/material'
|
||||||
|
import { FileText, Globe, PenLine, FileArchive } from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { SignalEvidence } from '../../domain/marketSignal'
|
||||||
|
import { EvidenceType } from '../../domain/marketSignal'
|
||||||
|
|
||||||
|
const EVIDENCE_ICONS: Record<string, LucideIcon> = {
|
||||||
|
[EvidenceType.TEXT_EXCERPT]: FileText,
|
||||||
|
[EvidenceType.URL_REFERENCE]: Globe,
|
||||||
|
[EvidenceType.ANALYST_NOTE]: PenLine,
|
||||||
|
[EvidenceType.DOCUMENT]: FileArchive,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVIDENCE_LABELS: Record<string, string> = {
|
||||||
|
[EvidenceType.TEXT_EXCERPT]: 'Textauszug',
|
||||||
|
[EvidenceType.URL_REFERENCE]: 'URL-Referenz',
|
||||||
|
[EvidenceType.ANALYST_NOTE]: 'Analyst-Notiz',
|
||||||
|
[EvidenceType.DOCUMENT]: 'Dokument',
|
||||||
|
}
|
||||||
|
|
||||||
|
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 SignalEvidenceListProps {
|
||||||
|
evidence: SignalEvidence[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalEvidenceList({ evidence }: SignalEvidenceListProps) {
|
||||||
|
if (evidence.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||||
|
Keine Evidenz vorhanden.
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{evidence.map((ev) => {
|
||||||
|
const Icon = EVIDENCE_ICONS[ev.evidenceType] ?? FileText
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={ev.id}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||||
|
<Icon size={14} color="#64748b" />
|
||||||
|
<Typography sx={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 600 }}>
|
||||||
|
{EVIDENCE_LABELS[ev.evidenceType]}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`${Math.round(ev.confidence * 100)}%`}
|
||||||
|
sx={{ bgcolor: 'rgba(99,102,241,0.1)', color: '#4f46e5', border: 'none', fontSize: '0.65rem', height: 18, ml: 'auto' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: '#1e293b', lineHeight: 1.5, mb: ev.sourceUrl ? 0.75 : 0 }}>
|
||||||
|
{ev.content}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{ev.sourceUrl && (
|
||||||
|
<Link
|
||||||
|
href={ev.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ fontSize: '0.7rem', display: 'block' }}
|
||||||
|
>
|
||||||
|
{ev.sourceUrl}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: '#94a3b8', mt: 0.5 }}>
|
||||||
|
Abgerufen: {formatDate(ev.retrievedAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Box, Chip, TextField, Typography } from '@mui/material'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import type { MarketSignal, MarketSignalFilters } from '../../domain/marketSignal'
|
||||||
|
import { MarketSignalCard } from './MarketSignalCard'
|
||||||
|
import { MarketSignalFilterBar } from './MarketSignalFilterBar'
|
||||||
|
import { MarketSignalSkeleton } from './MarketSignalSkeleton'
|
||||||
|
import { MarketSignalEmptyState } from './MarketSignalEmptyState'
|
||||||
|
|
||||||
|
interface SignalInboxProps {
|
||||||
|
signals: MarketSignal[]
|
||||||
|
isLoading: boolean
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
filters: MarketSignalFilters
|
||||||
|
onFiltersChange: (f: MarketSignalFilters) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalInbox({
|
||||||
|
signals,
|
||||||
|
isLoading,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
}: SignalInboxProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Inbox header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||||
|
Signal-Inbox
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={isLoading ? '…' : signals.length}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #f1f5f9', flexShrink: 0 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Suchen..."
|
||||||
|
fullWidth
|
||||||
|
value={filters.search ?? ''}
|
||||||
|
onChange={(e) => onFiltersChange({ ...filters, search: e.target.value || undefined })}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: <Search size={14} color="#94a3b8" style={{ marginRight: 6 }} />,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
|
<MarketSignalFilterBar filters={filters} onChange={onFiltersChange} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Signal list */}
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => <MarketSignalSkeleton key={i} />)
|
||||||
|
) : signals.length === 0 ? (
|
||||||
|
<MarketSignalEmptyState
|
||||||
|
variant={
|
||||||
|
filters.sourceCategory || filters.processingStatus || filters.search
|
||||||
|
? 'no-results'
|
||||||
|
: 'empty-inbox'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
signals.map((signal) => (
|
||||||
|
<MarketSignalCard
|
||||||
|
key={signal.id}
|
||||||
|
signal={signal}
|
||||||
|
selected={signal.id === selectedId}
|
||||||
|
onClick={() => onSelect(signal.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import { ShieldCheck } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SourceReliabilityBadgeProps {
|
||||||
|
score: number
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(score: number): { bg: string; fg: string } {
|
||||||
|
if (score >= 0.8) return { bg: 'rgba(34,197,94,0.12)', fg: '#16a34a' }
|
||||||
|
if (score >= 0.6) return { bg: 'rgba(245,158,11,0.12)', fg: '#d97706' }
|
||||||
|
return { bg: 'rgba(239,68,68,0.12)', fg: '#dc2626' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceReliabilityBadge({ score, size = 'small' }: SourceReliabilityBadgeProps) {
|
||||||
|
const { bg, fg } = getColor(score)
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size={size}
|
||||||
|
icon={<ShieldCheck size={11} color={fg} />}
|
||||||
|
label={`Quelle ${Math.round(score * 100)}%`}
|
||||||
|
sx={{
|
||||||
|
bgcolor: bg,
|
||||||
|
color: fg,
|
||||||
|
border: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: size === 'small' ? '0.7rem' : '0.75rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { AssetType, SignalType, SensitivityLevel, FreshnessStatus } from './enums'
|
||||||
|
|
||||||
|
// ── Source Categories ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const MarketSignalSourceCategory = {
|
||||||
|
PUBLIC_LISTING_PLATFORM: 'PUBLIC_LISTING_PLATFORM',
|
||||||
|
BUILDING_PERMIT_REGISTER: 'BUILDING_PERMIT_REGISTER',
|
||||||
|
COMPANY_NEWS: 'COMPANY_NEWS',
|
||||||
|
JOB_GROWTH_SIGNAL: 'JOB_GROWTH_SIGNAL',
|
||||||
|
COMMERCIAL_REGISTER: 'COMMERCIAL_REGISTER',
|
||||||
|
INFRASTRUCTURE_PROJECT: 'INFRASTRUCTURE_PROJECT',
|
||||||
|
PORTFOLIO_IMPORT: 'PORTFOLIO_IMPORT',
|
||||||
|
LEASE_EXPIRY_DATA: 'LEASE_EXPIRY_DATA',
|
||||||
|
USER_DEMAND_SIGNAL: 'USER_DEMAND_SIGNAL',
|
||||||
|
MANUAL_ANALYST_SIGNAL: 'MANUAL_ANALYST_SIGNAL',
|
||||||
|
} as const
|
||||||
|
export type MarketSignalSourceCategory = typeof MarketSignalSourceCategory[keyof typeof MarketSignalSourceCategory]
|
||||||
|
|
||||||
|
export const MARKET_SIGNAL_SOURCE_LABELS: Record<MarketSignalSourceCategory, string> = {
|
||||||
|
PUBLIC_LISTING_PLATFORM: 'Listing-Plattform',
|
||||||
|
BUILDING_PERMIT_REGISTER: 'Baugesuch-Register',
|
||||||
|
COMPANY_NEWS: 'Unternehmensnachrichten',
|
||||||
|
JOB_GROWTH_SIGNAL: 'Stellenwachstum',
|
||||||
|
COMMERCIAL_REGISTER: 'Handelsregister',
|
||||||
|
INFRASTRUCTURE_PROJECT: 'Infrastrukturprojekt',
|
||||||
|
PORTFOLIO_IMPORT: 'Portfolio-Import',
|
||||||
|
LEASE_EXPIRY_DATA: 'Vertragslaufdaten',
|
||||||
|
USER_DEMAND_SIGNAL: 'Nutzernachfrage',
|
||||||
|
MANUAL_ANALYST_SIGNAL: 'Analyst-Signal',
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEASE_EXPIRY_DATA and PORTFOLIO_IMPORT are sensitive — never expose raw to Demand Users
|
||||||
|
export const SENSITIVE_SOURCE_CATEGORIES: MarketSignalSourceCategory[] = [
|
||||||
|
MarketSignalSourceCategory.LEASE_EXPIRY_DATA,
|
||||||
|
MarketSignalSourceCategory.PORTFOLIO_IMPORT,
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Processing Status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const SignalProcessingStatus = {
|
||||||
|
DETECTED: 'DETECTED',
|
||||||
|
NORMALIZED: 'NORMALIZED',
|
||||||
|
ENRICHED: 'ENRICHED',
|
||||||
|
NEEDS_REVIEW: 'NEEDS_REVIEW',
|
||||||
|
APPROVED_AS_SIGNAL: 'APPROVED_AS_SIGNAL',
|
||||||
|
REJECTED: 'REJECTED',
|
||||||
|
CONVERTED_TO_FUTURE_AVAILABILITY: 'CONVERTED_TO_FUTURE_AVAILABILITY',
|
||||||
|
ARCHIVED: 'ARCHIVED',
|
||||||
|
} as const
|
||||||
|
export type SignalProcessingStatus = typeof SignalProcessingStatus[keyof typeof SignalProcessingStatus]
|
||||||
|
|
||||||
|
export const SIGNAL_PROCESSING_STATUS_LABELS: Record<SignalProcessingStatus, string> = {
|
||||||
|
DETECTED: 'Erkannt',
|
||||||
|
NORMALIZED: 'Normalisiert',
|
||||||
|
ENRICHED: 'Angereichert',
|
||||||
|
NEEDS_REVIEW: 'Prüfung erforderlich',
|
||||||
|
APPROVED_AS_SIGNAL: 'Genehmigt',
|
||||||
|
REJECTED: 'Abgelehnt',
|
||||||
|
CONVERTED_TO_FUTURE_AVAILABILITY: 'Konvertiert',
|
||||||
|
ARCHIVED: 'Archiviert',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIGNAL_PROCESSING_STATUS_COLORS: Record<SignalProcessingStatus, { bg: string; fg: string }> = {
|
||||||
|
DETECTED: { bg: 'rgba(148,163,184,0.15)', fg: '#64748b' },
|
||||||
|
NORMALIZED: { bg: 'rgba(59,130,246,0.12)', fg: '#2563eb' },
|
||||||
|
ENRICHED: { bg: 'rgba(99,102,241,0.12)', fg: '#4f46e5' },
|
||||||
|
NEEDS_REVIEW: { bg: 'rgba(245,158,11,0.12)', fg: '#d97706' },
|
||||||
|
APPROVED_AS_SIGNAL: { bg: 'rgba(34,197,94,0.12)', fg: '#16a34a' },
|
||||||
|
REJECTED: { bg: 'rgba(239,68,68,0.12)', fg: '#dc2626' },
|
||||||
|
CONVERTED_TO_FUTURE_AVAILABILITY: { bg: 'rgba(139,92,246,0.12)', fg: '#7c3aed' },
|
||||||
|
ARCHIVED: { bg: 'rgba(148,163,184,0.10)', fg: '#94a3b8' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Evidence & Entities ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ExtractedEntityType = {
|
||||||
|
COMPANY: 'COMPANY',
|
||||||
|
PERSON: 'PERSON',
|
||||||
|
LOCATION: 'LOCATION',
|
||||||
|
ASSET: 'ASSET',
|
||||||
|
DATE: 'DATE',
|
||||||
|
} as const
|
||||||
|
export type ExtractedEntityType = typeof ExtractedEntityType[keyof typeof ExtractedEntityType]
|
||||||
|
|
||||||
|
export interface ExtractedEntity {
|
||||||
|
type: ExtractedEntityType
|
||||||
|
value: string
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EvidenceType = {
|
||||||
|
TEXT_EXCERPT: 'TEXT_EXCERPT',
|
||||||
|
URL_REFERENCE: 'URL_REFERENCE',
|
||||||
|
ANALYST_NOTE: 'ANALYST_NOTE',
|
||||||
|
DOCUMENT: 'DOCUMENT',
|
||||||
|
} as const
|
||||||
|
export type EvidenceType = typeof EvidenceType[keyof typeof EvidenceType]
|
||||||
|
|
||||||
|
export interface SignalEvidence {
|
||||||
|
id: string
|
||||||
|
signalId: string
|
||||||
|
evidenceType: EvidenceType
|
||||||
|
content: string
|
||||||
|
sourceUrl?: string
|
||||||
|
retrievedAt: string
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core Signal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MarketSignal {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
sourceCategory: MarketSignalSourceCategory
|
||||||
|
sourceLabel: string
|
||||||
|
sourceUrl?: string
|
||||||
|
detectedAt: string
|
||||||
|
location: string
|
||||||
|
affectedAssetTypes: AssetType[]
|
||||||
|
signalType: SignalType
|
||||||
|
rawEvidenceSummary: string
|
||||||
|
extractedEntities: ExtractedEntity[]
|
||||||
|
evidence: SignalEvidence[]
|
||||||
|
sourceReliabilityScore: number // 0–1
|
||||||
|
confidenceScore: number // 0–1
|
||||||
|
sensitivityLevel: SensitivityLevel
|
||||||
|
freshnessStatus: FreshnessStatus
|
||||||
|
processingStatus: SignalProcessingStatus
|
||||||
|
linkedPropertyId?: string
|
||||||
|
linkedNeedId?: string
|
||||||
|
possibleFutureSignalId?: string
|
||||||
|
analystNotes: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intelligence Aggregates ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MarketInsight {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
signalIds: string[]
|
||||||
|
location: string
|
||||||
|
affectedAssetTypes: AssetType[]
|
||||||
|
confidenceScore: number
|
||||||
|
createdAt: string
|
||||||
|
analystId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntelligenceRun {
|
||||||
|
id: string
|
||||||
|
triggeredAt: string
|
||||||
|
completedAt?: string
|
||||||
|
signalsDetected: number
|
||||||
|
signalsProcessed: number
|
||||||
|
status: 'RUNNING' | 'COMPLETED' | 'FAILED'
|
||||||
|
sourceCategories: MarketSignalSourceCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalConversionCandidate {
|
||||||
|
signalId: string
|
||||||
|
proposedTitle: string
|
||||||
|
proposedSummary: string
|
||||||
|
estimatedAvailabilityDate?: string
|
||||||
|
proposedConfidence: number
|
||||||
|
conversionRationale: string
|
||||||
|
requiresReview: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filters ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MarketSignalFilters {
|
||||||
|
sourceCategory?: MarketSignalSourceCategory
|
||||||
|
processingStatus?: SignalProcessingStatus
|
||||||
|
sensitivityLevel?: SensitivityLevel
|
||||||
|
signalType?: SignalType
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { marketIntelligenceService } from '../services/marketIntelligenceService'
|
||||||
|
import { reviewService } from '../services/reviewService'
|
||||||
|
import type { MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
|
||||||
|
|
||||||
|
const STALE_SIGNALS = 30_000
|
||||||
|
|
||||||
|
export function useMarketSignals(filters?: MarketSignalFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['market-signals', filters ?? {}],
|
||||||
|
queryFn: () => marketIntelligenceService.getSignals(filters),
|
||||||
|
staleTime: STALE_SIGNALS,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarketSignalDetail(id: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['market-signal', id],
|
||||||
|
queryFn: () => marketIntelligenceService.getSignalDetail(id!),
|
||||||
|
enabled: id !== null,
|
||||||
|
staleTime: STALE_SIGNALS,
|
||||||
|
select: (res) => res.data ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSignalStatus() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: SignalProcessingStatus }) =>
|
||||||
|
marketIntelligenceService.updateSignalStatus(id, status),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['market-signals'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['market-signal'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConvertToFutureSignal() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => marketIntelligenceService.convertToFutureSignal(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['market-signals'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['market-signal'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLinkSignalToEntity() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
entityType: 'property' | 'need'
|
||||||
|
entityId: string
|
||||||
|
}) => marketIntelligenceService.linkSignalToEntity(id, entityType, entityId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['market-signals'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['market-signal'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateReviewTask() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (signalId: string) => reviewService.createReviewTask(signalId),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import {
|
||||||
|
MarketSignalSourceCategory,
|
||||||
|
SignalProcessingStatus,
|
||||||
|
EvidenceType,
|
||||||
|
ExtractedEntityType,
|
||||||
|
} from '../domain/marketSignal'
|
||||||
|
import type { MarketSignal } from '../domain/marketSignal'
|
||||||
|
import { AssetType, SignalType, SensitivityLevel, FreshnessStatus } from '../domain/enums'
|
||||||
|
|
||||||
|
export const MOCK_MARKET_SIGNALS: MarketSignal[] = [
|
||||||
|
{
|
||||||
|
id: 'sig-001',
|
||||||
|
title: 'UBS plant 200 neue Stellen – Büroflächenbedarf Zürich Innenstadt',
|
||||||
|
summary: 'UBS AG plant laut Medienbericht die Einstellung von 200 Spezialisten im Bereich Digital Banking bis Ende 2025. Standort ist primär Zürich. Der abgeleitete Flächenbedarf beträgt ca. 2\'500 m² zusätzlicher Bürofläche.',
|
||||||
|
sourceCategory: MarketSignalSourceCategory.COMPANY_NEWS,
|
||||||
|
sourceLabel: 'Neue Zürcher Zeitung',
|
||||||
|
sourceUrl: 'https://www.nzz.ch/',
|
||||||
|
detectedAt: '2025-05-10T09:23:00Z',
|
||||||
|
location: 'Zürich, ZH',
|
||||||
|
affectedAssetTypes: [AssetType.OFFICE],
|
||||||
|
signalType: SignalType.EXPANSION,
|
||||||
|
rawEvidenceSummary: '"UBS plant signifikante Aufstockung der digitalen Teams" – NZZ 10.05.2025',
|
||||||
|
extractedEntities: [
|
||||||
|
{ type: ExtractedEntityType.COMPANY, value: 'UBS AG', confidence: 0.98 },
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Zürich', confidence: 0.95 },
|
||||||
|
{ type: ExtractedEntityType.DATE, value: 'Ende 2025', confidence: 0.87 },
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'ev-001-1', signalId: 'sig-001',
|
||||||
|
evidenceType: EvidenceType.TEXT_EXCERPT,
|
||||||
|
content: '"UBS plant signifikante Aufstockung der digitalen Teams in Zürich" – NZZ, 10.05.2025',
|
||||||
|
sourceUrl: 'https://www.nzz.ch/',
|
||||||
|
retrievedAt: '2025-05-10T09:23:00Z', confidence: 0.92,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReliabilityScore: 0.85,
|
||||||
|
confidenceScore: 0.72,
|
||||||
|
sensitivityLevel: SensitivityLevel.INTERNAL,
|
||||||
|
freshnessStatus: FreshnessStatus.FRESH,
|
||||||
|
processingStatus: SignalProcessingStatus.NEEDS_REVIEW,
|
||||||
|
analystNotes: 'Flächenbedarf abgeleitet, nicht direkt kommuniziert. Bestätigung durch Quellenanfrage empfohlen.',
|
||||||
|
createdAt: '2025-05-10T10:00:00Z',
|
||||||
|
updatedAt: '2025-05-11T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-002',
|
||||||
|
title: 'Baugesuch für neues Gewerbepark-Areal in Basel-Nord',
|
||||||
|
summary: 'Im Handelsregister Basel-Stadt wurde ein Baugesuch für ein gemischt genutztes Gewerbeareal mit ca. 8\'000 m² Nutzfläche eingereicht. Projekt umfasst Büro, Logistik und Retail. Baubeginn geplant für Q1 2026.',
|
||||||
|
sourceCategory: MarketSignalSourceCategory.BUILDING_PERMIT_REGISTER,
|
||||||
|
sourceLabel: 'Bau- und Gastgewerbeinspektorat Basel-Stadt',
|
||||||
|
detectedAt: '2025-05-08T14:00:00Z',
|
||||||
|
location: 'Basel, BS',
|
||||||
|
affectedAssetTypes: [AssetType.OFFICE, AssetType.LOGISTICS, AssetType.RETAIL],
|
||||||
|
signalType: SignalType.CONSTRUCTION_PROJECT,
|
||||||
|
rawEvidenceSummary: 'Baugesuch Nr. BS-2025-0312 – gemischte Gewerbefläche, 8\'000 m², Basel-Nord',
|
||||||
|
extractedEntities: [
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Basel-Nord', confidence: 0.97 },
|
||||||
|
{ type: ExtractedEntityType.ASSET, value: 'Gewerbeareal 8\'000 m²', confidence: 0.88 },
|
||||||
|
{ type: ExtractedEntityType.DATE, value: 'Q1 2026', confidence: 0.83 },
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'ev-002-1', signalId: 'sig-002',
|
||||||
|
evidenceType: EvidenceType.URL_REFERENCE,
|
||||||
|
content: 'Baugesuch Nr. BS-2025-0312, Bau- und Gastgewerbeinspektorat Basel-Stadt',
|
||||||
|
retrievedAt: '2025-05-08T14:00:00Z', confidence: 0.95,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReliabilityScore: 0.92,
|
||||||
|
confidenceScore: 0.88,
|
||||||
|
sensitivityLevel: SensitivityLevel.PUBLIC,
|
||||||
|
freshnessStatus: FreshnessStatus.FRESH,
|
||||||
|
processingStatus: SignalProcessingStatus.APPROVED_AS_SIGNAL,
|
||||||
|
analystNotes: 'Öffentliches Register. Hohe Verlässlichkeit. Eigentümerstruktur noch nicht ermittelt.',
|
||||||
|
createdAt: '2025-05-08T15:00:00Z',
|
||||||
|
updatedAt: '2025-05-12T09:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-003',
|
||||||
|
title: 'Crypto-Unternehmen in Zug verdreifacht Belegschaft – Expansionsbedarf',
|
||||||
|
summary: 'Ein in Zug ansässiges Blockchain-Unternehmen hat laut LinkedIn-Auswertung innerhalb von 6 Monaten 80 neue Stellen ausgeschrieben. Bisherige Bürofläche reicht nicht mehr aus. Umzug oder Erweiterung erwartet.',
|
||||||
|
sourceCategory: MarketSignalSourceCategory.JOB_GROWTH_SIGNAL,
|
||||||
|
sourceLabel: 'LinkedIn Hiring Data',
|
||||||
|
detectedAt: '2025-05-09T11:30:00Z',
|
||||||
|
location: 'Zug, ZG',
|
||||||
|
affectedAssetTypes: [AssetType.OFFICE],
|
||||||
|
signalType: SignalType.EXPANSION,
|
||||||
|
rawEvidenceSummary: '80 offene Stellen in 6 Monaten (LinkedIn), Mitarbeiterzahl +210% YoY',
|
||||||
|
extractedEntities: [
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Zug', confidence: 0.91 },
|
||||||
|
{ type: ExtractedEntityType.DATE, value: '6 Monate', confidence: 0.78 },
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'ev-003-1', signalId: 'sig-003',
|
||||||
|
evidenceType: EvidenceType.TEXT_EXCERPT,
|
||||||
|
content: '80 offene Stellen in den letzten 6 Monaten. Mitarbeiterwachstum +210% gegenüber Vorjahr.',
|
||||||
|
retrievedAt: '2025-05-09T11:30:00Z', confidence: 0.78,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReliabilityScore: 0.67,
|
||||||
|
confidenceScore: 0.61,
|
||||||
|
sensitivityLevel: SensitivityLevel.PUBLIC,
|
||||||
|
freshnessStatus: FreshnessStatus.FRESH,
|
||||||
|
processingStatus: SignalProcessingStatus.ENRICHED,
|
||||||
|
analystNotes: 'Unternehmensname nicht öffentlich kommuniziert. Weitere Verifizierung über HR-Netzwerke empfohlen.',
|
||||||
|
createdAt: '2025-05-09T12:00:00Z',
|
||||||
|
updatedAt: '2025-05-11T14:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-004',
|
||||||
|
title: 'Vertragslaufdaten: Grossmieter Hardturmstrasse 201 läuft 03/2026 aus',
|
||||||
|
summary: 'Laut internen Vertragsdaten läuft der Mietvertrag eines Grossmieters an der Hardturmstrasse 201, Zürich, im März 2026 aus. Kontakt für Verlängerung wurde noch nicht aufgenommen. Mietfläche: ca. 3\'200 m².',
|
||||||
|
sourceCategory: MarketSignalSourceCategory.LEASE_EXPIRY_DATA,
|
||||||
|
sourceLabel: 'Internes ERP – Vertragsverwaltung',
|
||||||
|
detectedAt: '2025-05-05T08:00:00Z',
|
||||||
|
location: 'Zürich-West, ZH',
|
||||||
|
affectedAssetTypes: [AssetType.OFFICE],
|
||||||
|
signalType: SignalType.POSSIBLE_MOVE_OUT,
|
||||||
|
rawEvidenceSummary: 'Vertrag ID V-2019-0481, Ablauf 31.03.2026, keine Verlängerungsoption',
|
||||||
|
extractedEntities: [
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Hardturmstrasse 201, Zürich', confidence: 0.99 },
|
||||||
|
{ type: ExtractedEntityType.DATE, value: '31.03.2026', confidence: 0.99 },
|
||||||
|
{ type: ExtractedEntityType.ASSET, value: '3\'200 m² Bürofläche', confidence: 0.97 },
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'ev-004-1', signalId: 'sig-004',
|
||||||
|
evidenceType: EvidenceType.DOCUMENT,
|
||||||
|
content: 'Mietvertrag V-2019-0481, Laufzeit bis 31.03.2026, keine Option auf Verlängerung.',
|
||||||
|
retrievedAt: '2025-05-05T08:00:00Z', confidence: 0.99,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReliabilityScore: 0.99,
|
||||||
|
confidenceScore: 0.97,
|
||||||
|
sensitivityLevel: SensitivityLevel.CONFIDENTIAL,
|
||||||
|
freshnessStatus: FreshnessStatus.FRESH,
|
||||||
|
processingStatus: SignalProcessingStatus.NEEDS_REVIEW,
|
||||||
|
analystNotes: 'VERTRAULICH – Nur intern. Mieteridentität darf nicht in Demand Feed erscheinen. Property Manager informieren.',
|
||||||
|
createdAt: '2025-05-05T08:30:00Z',
|
||||||
|
updatedAt: '2025-05-10T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-005',
|
||||||
|
title: 'Sitzverlegung: Logistikunternehmen wechselt von Bern nach Zürich-Flughafen',
|
||||||
|
summary: 'Laut Handelsregistermutation hat ein mittelgrosses Logistikunternehmen seinen Hauptsitz von Bern nach Kloten verlegt. Aktiver Suchprozess nach Lagerfläche am Flughafen Zürich wahrscheinlich.',
|
||||||
|
sourceCategory: MarketSignalSourceCategory.COMMERCIAL_REGISTER,
|
||||||
|
sourceLabel: 'Handelsregister Schweiz – Zefix',
|
||||||
|
sourceUrl: 'https://www.zefix.ch/',
|
||||||
|
detectedAt: '2025-05-07T16:20:00Z',
|
||||||
|
location: 'Kloten, ZH',
|
||||||
|
affectedAssetTypes: [AssetType.LOGISTICS],
|
||||||
|
signalType: SignalType.POSSIBLE_MOVE_OUT,
|
||||||
|
rawEvidenceSummary: 'HR-Mutation: Sitzverlegung von 3014 Bern nach 8302 Kloten, eingetragen 07.05.2025',
|
||||||
|
extractedEntities: [
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Kloten', confidence: 0.97 },
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Bern', confidence: 0.97 },
|
||||||
|
{ type: ExtractedEntityType.DATE, value: '07.05.2025', confidence: 0.99 },
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'ev-005-1', signalId: 'sig-005',
|
||||||
|
evidenceType: EvidenceType.URL_REFERENCE,
|
||||||
|
content: 'Handelsregistermutation: Sitzverlegung von 3014 Bern nach 8302 Kloten, eingetragen 07.05.2025',
|
||||||
|
sourceUrl: 'https://www.zefix.ch/',
|
||||||
|
retrievedAt: '2025-05-07T16:20:00Z', confidence: 0.97,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReliabilityScore: 0.91,
|
||||||
|
confidenceScore: 0.63,
|
||||||
|
sensitivityLevel: SensitivityLevel.PUBLIC,
|
||||||
|
freshnessStatus: FreshnessStatus.FRESH,
|
||||||
|
processingStatus: SignalProcessingStatus.NORMALIZED,
|
||||||
|
analystNotes: 'Sitzverlegung bestätigt. Flächenbedarf am Zielort noch zu validieren.',
|
||||||
|
createdAt: '2025-05-07T17:00:00Z',
|
||||||
|
updatedAt: '2025-05-08T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-006',
|
||||||
|
title: 'Neue Ausschreibung: 800 m² Lager-/Logistik in Winterthur',
|
||||||
|
summary: 'Auf Immoscout24 wurde eine neue Ausschreibung für 800 m² Lagerfläche in Winterthur publiziert. Suche aktiv, Einzug ab September 2025 gewünscht.',
|
||||||
|
sourceCategory: MarketSignalSourceCategory.PUBLIC_LISTING_PLATFORM,
|
||||||
|
sourceLabel: 'Immoscout24',
|
||||||
|
sourceUrl: 'https://www.immoscout24.ch/',
|
||||||
|
detectedAt: '2025-05-12T08:15:00Z',
|
||||||
|
location: 'Winterthur, ZH',
|
||||||
|
affectedAssetTypes: [AssetType.LOGISTICS],
|
||||||
|
signalType: SignalType.EXPANSION,
|
||||||
|
rawEvidenceSummary: 'Inseratstitel: "800m² Lager gesucht – Winterthur sofort" – Immoscout24, 12.05.2025',
|
||||||
|
extractedEntities: [
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Winterthur', confidence: 0.99 },
|
||||||
|
{ type: ExtractedEntityType.ASSET, value: '800 m² Lagerfläche', confidence: 0.96 },
|
||||||
|
{ type: ExtractedEntityType.DATE, value: 'September 2025', confidence: 0.88 },
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'ev-006-1', signalId: 'sig-006',
|
||||||
|
evidenceType: EvidenceType.URL_REFERENCE,
|
||||||
|
content: '"800m² Lager gesucht – Winterthur sofort" – Immoscout24, 12.05.2025',
|
||||||
|
sourceUrl: 'https://www.immoscout24.ch/',
|
||||||
|
retrievedAt: '2025-05-12T08:15:00Z', confidence: 0.96,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReliabilityScore: 0.78,
|
||||||
|
confidenceScore: 0.83,
|
||||||
|
sensitivityLevel: SensitivityLevel.PUBLIC,
|
||||||
|
freshnessStatus: FreshnessStatus.FRESH,
|
||||||
|
processingStatus: SignalProcessingStatus.DETECTED,
|
||||||
|
analystNotes: '',
|
||||||
|
createdAt: '2025-05-12T08:30:00Z',
|
||||||
|
updatedAt: '2025-05-12T08:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-007',
|
||||||
|
title: 'Neue S-Bahn-Station Schlieren 2027 – Aufwertung für Gewerbeflächen',
|
||||||
|
summary: 'Die SBB hat die neue Haltestelle Schlieren-West für 2027 bestätigt. Das umliegende Gewerbegebiet wird deutlich aufgewertet. Frühzeitige Positionierung im Büro- und Produktionssegment empfohlen.',
|
||||||
|
sourceCategory: MarketSignalSourceCategory.INFRASTRUCTURE_PROJECT,
|
||||||
|
sourceLabel: 'SBB Medienmitteilung',
|
||||||
|
sourceUrl: 'https://www.sbb.ch/de/medien.html',
|
||||||
|
detectedAt: '2025-05-06T10:00:00Z',
|
||||||
|
location: 'Schlieren, ZH',
|
||||||
|
affectedAssetTypes: [AssetType.OFFICE, AssetType.PRODUCTION],
|
||||||
|
signalType: SignalType.PROJECT_DEVELOPMENT,
|
||||||
|
rawEvidenceSummary: 'SBB bestätigt neue Haltestelle Schlieren-West, Inbetriebnahme Dezember 2027',
|
||||||
|
extractedEntities: [
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Schlieren-West', confidence: 0.98 },
|
||||||
|
{ type: ExtractedEntityType.DATE, value: 'Dezember 2027', confidence: 0.95 },
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'ev-007-1', signalId: 'sig-007',
|
||||||
|
evidenceType: EvidenceType.URL_REFERENCE,
|
||||||
|
content: 'SBB Medienmitteilung: Neue Haltestelle Schlieren-West, Inbetriebnahme Dezember 2027',
|
||||||
|
sourceUrl: 'https://www.sbb.ch/de/medien.html',
|
||||||
|
retrievedAt: '2025-05-06T10:00:00Z', confidence: 0.95,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReliabilityScore: 0.96,
|
||||||
|
confidenceScore: 0.79,
|
||||||
|
sensitivityLevel: SensitivityLevel.PUBLIC,
|
||||||
|
freshnessStatus: FreshnessStatus.FRESH,
|
||||||
|
processingStatus: SignalProcessingStatus.ENRICHED,
|
||||||
|
analystNotes: 'Infrastrukturverbesserung. Kein direktes Verfügbarkeitssignal, aber relevanter Standortfaktor.',
|
||||||
|
createdAt: '2025-05-06T11:00:00Z',
|
||||||
|
updatedAt: '2025-05-10T15:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sig-008',
|
||||||
|
title: 'Analyst-Beobachtung: Leerstehende Etage Tour de Berne, Lausanne',
|
||||||
|
summary: 'Eigene Marktbeobachtung: Die 4. Etage im Bürogebäude "Tour de Berne" in Lausanne steht seit mindestens 3 Monaten leer. Kein offizielles Inserat gefunden. Mögliche Vermarktung oder Leerstand durch Eigentümer.',
|
||||||
|
sourceCategory: MarketSignalSourceCategory.MANUAL_ANALYST_SIGNAL,
|
||||||
|
sourceLabel: 'Eigene Beobachtung – Marktanalyse Mai 2025',
|
||||||
|
detectedAt: '2025-05-03T14:30:00Z',
|
||||||
|
location: 'Lausanne, VD',
|
||||||
|
affectedAssetTypes: [AssetType.OFFICE],
|
||||||
|
signalType: SignalType.POSSIBLE_MOVE_OUT,
|
||||||
|
rawEvidenceSummary: 'Begehung 03.05.2025: 4. OG leer, keine Beschilderung, kein Inserat gefunden.',
|
||||||
|
extractedEntities: [
|
||||||
|
{ type: ExtractedEntityType.LOCATION, value: 'Tour de Berne, Lausanne', confidence: 0.94 },
|
||||||
|
{ type: ExtractedEntityType.ASSET, value: '4. Obergeschoss', confidence: 0.92 },
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
id: 'ev-008-1', signalId: 'sig-008',
|
||||||
|
evidenceType: EvidenceType.ANALYST_NOTE,
|
||||||
|
content: 'Vor-Ort-Begehung am 03.05.2025: 4. OG im Tour de Berne leer stehend, keine aktive Vermarktung erkennbar.',
|
||||||
|
retrievedAt: '2025-05-03T14:30:00Z', confidence: 0.82,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReliabilityScore: 0.72,
|
||||||
|
confidenceScore: 0.68,
|
||||||
|
sensitivityLevel: SensitivityLevel.INTERNAL,
|
||||||
|
freshnessStatus: FreshnessStatus.STALE,
|
||||||
|
processingStatus: SignalProcessingStatus.CONVERTED_TO_FUTURE_AVAILABILITY,
|
||||||
|
possibleFutureSignalId: 'fs-lausanne-001',
|
||||||
|
analystNotes: 'Zu Future Availability überführt. Eigentümer Kontaktaufnahme ausstehend.',
|
||||||
|
createdAt: '2025-05-03T15:00:00Z',
|
||||||
|
updatedAt: '2025-05-13T11:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
|
||||||
|
|
||||||
|
export interface IMarketIntelligenceProvider {
|
||||||
|
getSignals(filters?: MarketSignalFilters): Promise<MarketSignal[]>
|
||||||
|
getSignalById(id: string): Promise<MarketSignal | null>
|
||||||
|
updateSignalStatus(id: string, status: SignalProcessingStatus): Promise<MarketSignal>
|
||||||
|
convertToFutureSignal(id: string): Promise<{ futureSignalId: string }>
|
||||||
|
linkSignalToEntity(
|
||||||
|
id: string,
|
||||||
|
entityType: 'property' | 'need',
|
||||||
|
entityId: string,
|
||||||
|
): Promise<MarketSignal>
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { IMarketIntelligenceProvider } from './IMarketIntelligenceProvider'
|
||||||
|
import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
|
||||||
|
import { MOCK_MARKET_SIGNALS } from '../mock-data/marketSignals'
|
||||||
|
|
||||||
|
// Mutable in-memory copy for status updates
|
||||||
|
let signals: MarketSignal[] = [...MOCK_MARKET_SIGNALS]
|
||||||
|
|
||||||
|
function applyFilters(data: MarketSignal[], filters?: MarketSignalFilters): MarketSignal[] {
|
||||||
|
if (!filters) return data
|
||||||
|
return data.filter((s) => {
|
||||||
|
if (filters.sourceCategory && s.sourceCategory !== filters.sourceCategory) return false
|
||||||
|
if (filters.processingStatus && s.processingStatus !== filters.processingStatus) return false
|
||||||
|
if (filters.sensitivityLevel && s.sensitivityLevel !== filters.sensitivityLevel) return false
|
||||||
|
if (filters.signalType && s.signalType !== filters.signalType) return false
|
||||||
|
if (filters.search) {
|
||||||
|
const q = filters.search.toLowerCase()
|
||||||
|
const match = s.title.toLowerCase().includes(q)
|
||||||
|
|| s.summary.toLowerCase().includes(q)
|
||||||
|
|| s.location.toLowerCase().includes(q)
|
||||||
|
if (!match) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MockupMarketIntelligenceProvider: IMarketIntelligenceProvider = {
|
||||||
|
async getSignals(filters?: MarketSignalFilters): Promise<MarketSignal[]> {
|
||||||
|
return applyFilters(signals, filters)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSignalById(id: string): Promise<MarketSignal | null> {
|
||||||
|
return signals.find((s) => s.id === id) ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSignalStatus(id: string, status: SignalProcessingStatus): Promise<MarketSignal> {
|
||||||
|
const idx = signals.findIndex((s) => s.id === id)
|
||||||
|
if (idx === -1) throw new Error(`Signal ${id} not found`)
|
||||||
|
signals[idx] = { ...signals[idx], processingStatus: status, updatedAt: new Date().toISOString() }
|
||||||
|
return signals[idx]
|
||||||
|
},
|
||||||
|
|
||||||
|
async convertToFutureSignal(id: string): Promise<{ futureSignalId: string }> {
|
||||||
|
const futureSignalId = `fs-${id}-${Date.now()}`
|
||||||
|
const idx = signals.findIndex((s) => s.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
signals[idx] = {
|
||||||
|
...signals[idx],
|
||||||
|
processingStatus: 'CONVERTED_TO_FUTURE_AVAILABILITY',
|
||||||
|
possibleFutureSignalId: futureSignalId,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { futureSignalId }
|
||||||
|
},
|
||||||
|
|
||||||
|
async linkSignalToEntity(
|
||||||
|
id: string,
|
||||||
|
entityType: 'property' | 'need',
|
||||||
|
entityId: string,
|
||||||
|
): Promise<MarketSignal> {
|
||||||
|
const idx = signals.findIndex((s) => s.id === id)
|
||||||
|
if (idx === -1) throw new Error(`Signal ${id} not found`)
|
||||||
|
signals[idx] = {
|
||||||
|
...signals[idx],
|
||||||
|
...(entityType === 'property' ? { linkedPropertyId: entityId } : { linkedNeedId: entityId }),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
return signals[idx]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { MockupMarketIntelligenceProvider } from '../provider/MockupMarketIntelligenceProvider'
|
||||||
|
import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
|
const provider = MockupMarketIntelligenceProvider
|
||||||
|
|
||||||
|
export const marketIntelligenceService = {
|
||||||
|
async getSignals(filters?: MarketSignalFilters): Promise<ListResponse<MarketSignal>> {
|
||||||
|
const data = await provider.getSignals(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSignalDetail(id: string): Promise<ItemResponse<MarketSignal | null>> {
|
||||||
|
const data = await provider.getSignalById(id)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSignalStatus(
|
||||||
|
id: string,
|
||||||
|
status: SignalProcessingStatus,
|
||||||
|
): Promise<ItemResponse<MarketSignal>> {
|
||||||
|
const data = await provider.updateSignalStatus(id, status)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
|
||||||
|
async convertToFutureSignal(
|
||||||
|
id: string,
|
||||||
|
): Promise<ItemResponse<{ futureSignalId: string }>> {
|
||||||
|
const data = await provider.convertToFutureSignal(id)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
|
||||||
|
async linkSignalToEntity(
|
||||||
|
id: string,
|
||||||
|
entityType: 'property' | 'need',
|
||||||
|
entityId: string,
|
||||||
|
): Promise<ItemResponse<MarketSignal>> {
|
||||||
|
const data = await provider.linkSignalToEntity(id, entityType, entityId)
|
||||||
|
return { data }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,22 +1,61 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { WorkspaceType } from '../domain/enums'
|
import { WorkspaceType } from '../domain/enums'
|
||||||
|
|
||||||
|
export const RightPanelContentType = {
|
||||||
|
AI_CONTEXT: 'ai_context',
|
||||||
|
DETAIL_PREVIEW: 'detail_preview',
|
||||||
|
COMPARE_PREVIEW: 'compare_preview',
|
||||||
|
ACTIVITY_FEED: 'activity_feed',
|
||||||
|
} as const
|
||||||
|
export type RightPanelContentType = typeof RightPanelContentType[keyof typeof RightPanelContentType]
|
||||||
|
|
||||||
interface LayoutState {
|
interface LayoutState {
|
||||||
activeWorkspace: WorkspaceType
|
activeWorkspace: WorkspaceType
|
||||||
sidebarCollapsed: boolean
|
sidebarCollapsed: boolean
|
||||||
pinnedPanels: string[]
|
pinnedPanels: string[]
|
||||||
|
isRightPanelOpen: boolean
|
||||||
|
rightPanelContentType: RightPanelContentType | null
|
||||||
|
compareTrayVisible: boolean
|
||||||
|
selectedResultId: string | null
|
||||||
|
notificationsOpen: boolean
|
||||||
|
// Actions
|
||||||
setActiveWorkspace: (workspace: WorkspaceType) => void
|
setActiveWorkspace: (workspace: WorkspaceType) => void
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void
|
||||||
pinPanel: (panelId: string) => void
|
pinPanel: (panelId: string) => void
|
||||||
unpinPanel: (panelId: string) => void
|
unpinPanel: (panelId: string) => void
|
||||||
|
openRightPanel: (type: RightPanelContentType) => void
|
||||||
|
closeRightPanel: () => void
|
||||||
|
toggleRightPanel: (type: RightPanelContentType) => void
|
||||||
|
setCompareTrayVisible: (visible: boolean) => void
|
||||||
|
setSelectedResultId: (id: string | null) => void
|
||||||
|
toggleNotifications: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLayoutStore = create<LayoutState>((set) => ({
|
export const useLayoutStore = create<LayoutState>((set, get) => ({
|
||||||
activeWorkspace: WorkspaceType.SUPPLY,
|
activeWorkspace: WorkspaceType.SUPPLY,
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
pinnedPanels: [],
|
pinnedPanels: [],
|
||||||
|
isRightPanelOpen: false,
|
||||||
|
rightPanelContentType: null,
|
||||||
|
compareTrayVisible: false,
|
||||||
|
selectedResultId: null,
|
||||||
|
notificationsOpen: false,
|
||||||
|
|
||||||
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
|
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
|
||||||
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||||
pinPanel: (panelId) => set((state) => ({ pinnedPanels: [...state.pinnedPanels, panelId] })),
|
pinPanel: (panelId) => set((s) => ({ pinnedPanels: [...s.pinnedPanels, panelId] })),
|
||||||
unpinPanel: (panelId) => set((state) => ({ pinnedPanels: state.pinnedPanels.filter(id => id !== panelId) })),
|
unpinPanel: (panelId) => set((s) => ({ pinnedPanels: s.pinnedPanels.filter(id => id !== panelId) })),
|
||||||
|
openRightPanel: (type) => set({ isRightPanelOpen: true, rightPanelContentType: type }),
|
||||||
|
closeRightPanel: () => set({ isRightPanelOpen: false, rightPanelContentType: null }),
|
||||||
|
toggleRightPanel: (type) => {
|
||||||
|
const { isRightPanelOpen, rightPanelContentType } = get()
|
||||||
|
if (isRightPanelOpen && rightPanelContentType === type) {
|
||||||
|
set({ isRightPanelOpen: false, rightPanelContentType: null })
|
||||||
|
} else {
|
||||||
|
set({ isRightPanelOpen: true, rightPanelContentType: type })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setCompareTrayVisible: (visible) => set({ compareTrayVisible: visible }),
|
||||||
|
setSelectedResultId: (id) => set({ selectedResultId: id }),
|
||||||
|
toggleNotifications: () => set((s) => ({ notificationsOpen: !s.notificationsOpen })),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user