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:
Benjamin Sutter
2026-05-15 17:07:58 +02:00
parent 3cd2e83b25
commit cb18f3c381
17 changed files with 1389 additions and 4 deletions
+123
View File
@@ -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>
)
}
+90
View File
@@ -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>
)
}
+97
View File
@@ -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',
}}
/>
)
}
+180
View File
@@ -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 // 01
confidenceScore: number // 01
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
}
+73
View File
@@ -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),
})
}
+281
View File
@@ -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]
},
}
+41
View File
@@ -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 }
},
}
+43 -4
View File
@@ -1,22 +1,61 @@
import { create } from 'zustand'
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 {
activeWorkspace: WorkspaceType
sidebarCollapsed: boolean
pinnedPanels: string[]
isRightPanelOpen: boolean
rightPanelContentType: RightPanelContentType | null
compareTrayVisible: boolean
selectedResultId: string | null
notificationsOpen: boolean
// Actions
setActiveWorkspace: (workspace: WorkspaceType) => void
toggleSidebar: () => void
pinPanel: (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,
sidebarCollapsed: false,
pinnedPanels: [],
isRightPanelOpen: false,
rightPanelContentType: null,
compareTrayVisible: false,
selectedResultId: null,
notificationsOpen: false,
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
pinPanel: (panelId) => set((state) => ({ pinnedPanels: [...state.pinnedPanels, panelId] })),
unpinPanel: (panelId) => set((state) => ({ pinnedPanels: state.pinnedPanels.filter(id => id !== panelId) })),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
pinPanel: (panelId) => set((s) => ({ pinnedPanels: [...s.pinnedPanels, 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 })),
}))