diff --git a/src/components/ops/MarketSignalCard.tsx b/src/components/ops/MarketSignalCard.tsx new file mode 100644 index 0000000..46b7b07 --- /dev/null +++ b/src/components/ops/MarketSignalCard.tsx @@ -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 = { + 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 = { + [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 ( + + {/* Row 1: source + sensitivity */} + + + + + {MARKET_SIGNAL_SOURCE_LABELS[signal.sourceCategory as MarketSignalSourceCategory]} + + + {sensitivityColor && ( + + )} + + + {/* Row 2: title */} + + {signal.title} + + + {/* Row 3: location + date */} + + {signal.location} · {formatDate(signal.detectedAt)} + + + {/* Row 4: status + confidence */} + + + + + + ) +} diff --git a/src/components/ops/MarketSignalEmptyState.tsx b/src/components/ops/MarketSignalEmptyState.tsx new file mode 100644 index 0000000..aa421d4 --- /dev/null +++ b/src/components/ops/MarketSignalEmptyState.tsx @@ -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 = { + '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 ( + + + + + + + {title} + + + {subtitle} + + + + ) +} diff --git a/src/components/ops/MarketSignalFilterBar.tsx b/src/components/ops/MarketSignalFilterBar.tsx new file mode 100644 index 0000000..560e805 --- /dev/null +++ b/src/components/ops/MarketSignalFilterBar.tsx @@ -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 ( + + {/* Source category */} + + Quelle + + + {SOURCE_OPTIONS.map((cat) => { + const active = filters.sourceCategory === cat + return ( + + 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)' }, + }} + /> + ) + })} + + + {/* Processing status */} + + Status + + + {STATUS_OPTIONS.map((status) => { + const active = filters.processingStatus === status + const { bg, fg } = SIGNAL_PROCESSING_STATUS_COLORS[status] + return ( + + 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 }, + }} + /> + ) + })} + + + {hasFilters && ( + + )} + + ) +} diff --git a/src/components/ops/MarketSignalSkeleton.tsx b/src/components/ops/MarketSignalSkeleton.tsx new file mode 100644 index 0000000..c5b18d3 --- /dev/null +++ b/src/components/ops/MarketSignalSkeleton.tsx @@ -0,0 +1,26 @@ +import { Box, Skeleton } from '@mui/material' + +export function MarketSignalSkeleton() { + return ( + + + + + + + + + + + + + ) +} diff --git a/src/components/ops/SensitivityWarningPanel.tsx b/src/components/ops/SensitivityWarningPanel.tsx new file mode 100644 index 0000000..f54fc21 --- /dev/null +++ b/src/components/ops/SensitivityWarningPanel.tsx @@ -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 ( + } + sx={{ mb: 2 }} + > + + {isConfidential ? 'Vertrauliche Daten' : 'Zugriff eingeschränkt'} + + {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.'} + + ) +} diff --git a/src/components/ops/SignalConfidenceBadge.tsx b/src/components/ops/SignalConfidenceBadge.tsx new file mode 100644 index 0000000..4cff95e --- /dev/null +++ b/src/components/ops/SignalConfidenceBadge.tsx @@ -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 ( + } + label={`Konfidenz ${Math.round(score * 100)}%`} + sx={{ + bgcolor: bg, + color: fg, + border: 'none', + fontWeight: 600, + fontSize: size === 'small' ? '0.7rem' : '0.75rem', + }} + /> + ) +} diff --git a/src/components/ops/SignalConversionPanel.tsx b/src/components/ops/SignalConversionPanel.tsx new file mode 100644 index 0000000..86834cf --- /dev/null +++ b/src/components/ops/SignalConversionPanel.tsx @@ -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 ( + + + + Bereits in Future Availability überführt + {signal.possibleFutureSignalId && ` (${signal.possibleFutureSignalId})`} + + + ) + } + + return ( + + + + + In Future Availability überführen + + + + + + + Vorgeschlagener Titel: {signal.title} + + + Begründung: Signal hat ausreichend Evidenz und Source Reliability, um als + Future Availability Kandidat geführt zu werden. Manueller Review empfohlen. + + + + + ) +} diff --git a/src/components/ops/SignalEvidenceList.tsx b/src/components/ops/SignalEvidenceList.tsx new file mode 100644 index 0000000..d3bdce2 --- /dev/null +++ b/src/components/ops/SignalEvidenceList.tsx @@ -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 = { + [EvidenceType.TEXT_EXCERPT]: FileText, + [EvidenceType.URL_REFERENCE]: Globe, + [EvidenceType.ANALYST_NOTE]: PenLine, + [EvidenceType.DOCUMENT]: FileArchive, +} + +const EVIDENCE_LABELS: Record = { + [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 ( + + Keine Evidenz vorhanden. + + ) + } + + return ( + + {evidence.map((ev) => { + const Icon = EVIDENCE_ICONS[ev.evidenceType] ?? FileText + return ( + + + + + {EVIDENCE_LABELS[ev.evidenceType]} + + + + + + {ev.content} + + + {ev.sourceUrl && ( + + {ev.sourceUrl} + + )} + + + Abgerufen: {formatDate(ev.retrievedAt)} + + + ) + })} + + ) +} diff --git a/src/components/ops/SignalInbox.tsx b/src/components/ops/SignalInbox.tsx new file mode 100644 index 0000000..f7a8875 --- /dev/null +++ b/src/components/ops/SignalInbox.tsx @@ -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 ( + + {/* Inbox header */} + + + Signal-Inbox + + + + + {/* Search */} + + onFiltersChange({ ...filters, search: e.target.value || undefined })} + slotProps={{ + input: { + startAdornment: , + }, + }} + sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem' } }} + /> + + + {/* Filters */} + + + + + {/* Signal list */} + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ) + ) : signals.length === 0 ? ( + + ) : ( + signals.map((signal) => ( + onSelect(signal.id)} + /> + )) + )} + + + ) +} diff --git a/src/components/ops/SourceReliabilityBadge.tsx b/src/components/ops/SourceReliabilityBadge.tsx new file mode 100644 index 0000000..6f1b9c1 --- /dev/null +++ b/src/components/ops/SourceReliabilityBadge.tsx @@ -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 ( + } + label={`Quelle ${Math.round(score * 100)}%`} + sx={{ + bgcolor: bg, + color: fg, + border: 'none', + fontWeight: 600, + fontSize: size === 'small' ? '0.7rem' : '0.75rem', + }} + /> + ) +} diff --git a/src/domain/marketSignal.ts b/src/domain/marketSignal.ts new file mode 100644 index 0000000..d45822d --- /dev/null +++ b/src/domain/marketSignal.ts @@ -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 = { + 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 = { + 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 = { + 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 +} diff --git a/src/hooks/useMarketSignals.ts b/src/hooks/useMarketSignals.ts new file mode 100644 index 0000000..7e033a1 --- /dev/null +++ b/src/hooks/useMarketSignals.ts @@ -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), + }) +} diff --git a/src/mock-data/marketSignals.ts b/src/mock-data/marketSignals.ts new file mode 100644 index 0000000..408ff3a --- /dev/null +++ b/src/mock-data/marketSignals.ts @@ -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', + }, +] diff --git a/src/provider/IMarketIntelligenceProvider.ts b/src/provider/IMarketIntelligenceProvider.ts new file mode 100644 index 0000000..3c59fef --- /dev/null +++ b/src/provider/IMarketIntelligenceProvider.ts @@ -0,0 +1,13 @@ +import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal' + +export interface IMarketIntelligenceProvider { + getSignals(filters?: MarketSignalFilters): Promise + getSignalById(id: string): Promise + updateSignalStatus(id: string, status: SignalProcessingStatus): Promise + convertToFutureSignal(id: string): Promise<{ futureSignalId: string }> + linkSignalToEntity( + id: string, + entityType: 'property' | 'need', + entityId: string, + ): Promise +} diff --git a/src/provider/MockupMarketIntelligenceProvider.ts b/src/provider/MockupMarketIntelligenceProvider.ts new file mode 100644 index 0000000..52cd866 --- /dev/null +++ b/src/provider/MockupMarketIntelligenceProvider.ts @@ -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 { + return applyFilters(signals, filters) + }, + + async getSignalById(id: string): Promise { + return signals.find((s) => s.id === id) ?? null + }, + + async updateSignalStatus(id: string, status: SignalProcessingStatus): Promise { + 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 { + 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] + }, +} diff --git a/src/services/marketIntelligenceService.ts b/src/services/marketIntelligenceService.ts new file mode 100644 index 0000000..39adf27 --- /dev/null +++ b/src/services/marketIntelligenceService.ts @@ -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> { + const data = await provider.getSignals(filters) + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + }, + + async getSignalDetail(id: string): Promise> { + const data = await provider.getSignalById(id) + return { data } + }, + + async updateSignalStatus( + id: string, + status: SignalProcessingStatus, + ): Promise> { + const data = await provider.updateSignalStatus(id, status) + return { data } + }, + + async convertToFutureSignal( + id: string, + ): Promise> { + const data = await provider.convertToFutureSignal(id) + return { data } + }, + + async linkSignalToEntity( + id: string, + entityType: 'property' | 'need', + entityId: string, + ): Promise> { + const data = await provider.linkSignalToEntity(id, entityType, entityId) + return { data } + }, +} diff --git a/src/stores/layoutStore.ts b/src/stores/layoutStore.ts index d4ebc41..e3b8d55 100644 --- a/src/stores/layoutStore.ts +++ b/src/stores/layoutStore.ts @@ -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((set) => ({ +export const useLayoutStore = create((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 })), }))