From d8ff380d6ee5960bbf8fd12362fe21f42cba2071 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 23:22:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20merge=20Markt-Leads=20+=20Markt=20Intel?= =?UTF-8?q?ligence=20=E2=86=92=20Marktchancen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-pane UX (from Market Intelligence) with supply-relevant data (FutureSignal + portfolio property matches from Markt-Leads): - Left pane: filterable signal list with probability badge + portfolio match count - Right pane: company overview, KI-Analyse, market indicators, Faktencheck, portfolio matches (click → Meine Objekte), Reminder erstellen action - No OPS-level actions (approve/reject/review stripped out) - Nav: remove Markt-Leads entry, rename to Marktchancen Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 4 +- src/components/layout/appShellConfig.ts | 4 +- src/pages/supply/MarketIntelligence.tsx | 289 +++++++++++++++++++++++ src/pages/supply/MarketLeads.tsx | 296 ------------------------ 4 files changed, 291 insertions(+), 302 deletions(-) create mode 100644 src/pages/supply/MarketIntelligence.tsx delete mode 100644 src/pages/supply/MarketLeads.tsx diff --git a/src/App.tsx b/src/App.tsx index d71627e..914d7d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter')) const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability')) const DataQuality = lazy(() => import('./pages/supply/DataQuality')) const ReminderManager = lazy(() => import('./pages/supply/ReminderManager')) -const MarketLeads = lazy(() => import('./pages/supply/MarketLeads')) +const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence')) const NewListing = lazy(() => import('./pages/supply/NewListing')) const MyListings = lazy(() => import('./pages/supply/MyListings')) @@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen')) const Pipeline = lazy(() => import('./pages/demand/Pipeline')) const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail')) -const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence')) function App() { return ( @@ -62,7 +61,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/components/layout/appShellConfig.ts b/src/components/layout/appShellConfig.ts index c871d9a..ce136bb 100644 --- a/src/components/layout/appShellConfig.ts +++ b/src/components/layout/appShellConfig.ts @@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react' import { LayoutDashboard, Building2, - Target, CheckSquare, Search, List, @@ -49,10 +48,9 @@ export const WORKSPACE_CONFIG: Record = { { path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard }, { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, { path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing }, - { path: '/supply/market-leads', label: 'Markt-Leads', icon: Target }, { path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare }, { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, - { path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar }, + { path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar }, { path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList }, ], }, diff --git a/src/pages/supply/MarketIntelligence.tsx b/src/pages/supply/MarketIntelligence.tsx new file mode 100644 index 0000000..8c9ae3b --- /dev/null +++ b/src/pages/supply/MarketIntelligence.tsx @@ -0,0 +1,289 @@ +import { memo, useState, useEffect } from 'react' +import { useNavigate } from 'react-router' +import { Box, Chip, Divider, Skeleton, Typography, Button } from '@mui/material' +import { AlertCircle, Bell, Building2, CheckCircle2, Sparkles, TrendingUp } from 'lucide-react' +import { useMarketLeads } from '../../hooks/useMarketLeads' +import type { MarketLead } from '../../hooks/useMarketLeads' + +const SOURCE_LABELS: Record = { + JOB_POSTING: 'Stelleninserate', + PRESS: 'Pressebericht', + COMPANY_REPORT: 'Geschäftsbericht', + MARKET_DATA: 'Marktdaten', + MANUAL: 'Manuell', + CONSTRUCTION_PERMIT: 'Baugenehmigung', + LEASE_CONTRACT: 'Mietvertrag', +} + +function probColor(p: number): string { + if (p >= 0.70) return '#1a7a4a' + if (p >= 0.50) return '#d97706' + return '#dc2626' +} + +const LeadListItem = memo(function LeadListItem({ + lead, selected, onClick, +}: { + lead: MarketLead + selected: boolean + onClick: () => void +}) { + const { signal, matchingProperties } = lead + const prob = Math.round(signal.probability * 100) + const color = probColor(signal.probability) + + return ( + + + + {signal.companyName ?? signal.locationHint} + + + + {signal.title && ( + + {signal.title} + + )} + + + {signal.locationHint} · {signal.timeHorizonMonths} Mo. + + {matchingProperties.length > 0 && ( + + )} + + + ) +}) + +function LeadDetail({ lead }: { lead: MarketLead }) { + const navigate = useNavigate() + const { signal, matchingProperties } = lead + const prob = Math.round(signal.probability * 100) + const color = probColor(signal.probability) + const sourceLabel = SOURCE_LABELS[signal.source.type] ?? signal.source.type + + return ( + + {/* Header */} + + + {signal.companyName ?? signal.locationHint} + + {signal.title && ( + + {signal.title} + + )} + + + + {signal.areaSqmEstimate && ( + + )} + + + + + + + {/* AI summary */} + {(signal.aiSummary ?? signal.strategicInterpretation) && ( + + + + + KI-Analyse + + + + {signal.aiSummary ?? signal.strategicInterpretation} + + + )} + + {/* Market indicators */} + {(signal.marketIndicators?.length ?? 0) > 0 && ( + + + Erkannte Nachfragesignale + + + {signal.marketIndicators!.map((ind, i) => ( + + + {ind} + + ))} + + + )} + + {/* Confirmed / unconfirmed facts */} + {((signal.confirmedFacts?.length ?? 0) + (signal.unconfirmedFacts?.length ?? 0)) > 0 && ( + + + Faktencheck + + + {(signal.confirmedFacts ?? []).map((f, i) => ( + + + {f} + + ))} + {(signal.unconfirmedFacts ?? []).map((f, i) => ( + + + {f} + + ))} + + + )} + + + + {/* Portfolio matches */} + + + Passende Objekte im Portfolio ({matchingProperties.length}) + + {matchingProperties.length === 0 ? ( + + Kein passendes Objekt gefunden — manuelle Prüfung empfohlen + + ) : ( + + {matchingProperties.slice(0, 3).map(p => ( + navigate('/supply/properties')} + sx={{ + display: 'flex', alignItems: 'center', gap: 1, + px: 1.25, py: 0.875, borderRadius: 1, + bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', + cursor: 'pointer', '&:hover': { bgcolor: '#dcfce7' }, + }} + > + + + {p.title} + + + {p.areaSqm.toLocaleString('de-CH')} m² + + + ))} + {matchingProperties.length > 3 && ( + + +{matchingProperties.length - 3} weitere Objekte + + )} + + )} + + + {/* Action */} + + + + + {signal.disclaimer && ( + + {signal.disclaimer} + + )} + + + ) +} + +export default function MarketIntelligence() { + const [selectedId, setSelectedId] = useState(null) + const { data: leads, isLoading } = useMarketLeads() + + useEffect(() => { + if (!selectedId && leads.length > 0) setSelectedId(leads[0].signal.id) + }, [leads, selectedId]) + + const selectedLead = leads.find(l => l.signal.id === selectedId) ?? null + + return ( + + + Marktchancen + + Erkannte Unternehmen mit aktivem Flächenbedarf — potenzielle Mieter für Ihr Portfolio + + + + + {/* Left pane */} + + + Markt-Signale + + + + {isLoading + ? [1, 2, 3, 4].map(i => ( + + + + + + )) + : leads.map(lead => ( + setSelectedId(lead.signal.id)} + /> + ))} + + + + {/* Right pane */} + + {selectedLead ? ( + + ) : ( + + Signal aus der Liste auswählen + + )} + + + + ) +} diff --git a/src/pages/supply/MarketLeads.tsx b/src/pages/supply/MarketLeads.tsx deleted file mode 100644 index f1690f8..0000000 --- a/src/pages/supply/MarketLeads.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { - Box, - Chip, - Divider, - Paper, - Skeleton, - Typography, -} from '@mui/material' -import { - AlertCircle, - BarChart2, - Briefcase, - Building2, - CheckCircle2, - FileText, - Newspaper, - Target, - TrendingUp, - Users, -} from 'lucide-react' -import { useNavigate } from 'react-router' -import { useMarketLeads } from '../../hooks/useMarketLeads' -import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds' -import { DecisionContextPanel } from '../../components/ui' -import type { MarketLead } from '../../hooks/useMarketLeads' -import type { Property } from '../../domain/property' - -// ── Source meta ────────────────────────────────────────────────────────────── - -const SOURCE_META: Record = { - JOB_POSTING: { label: 'Stelleninserate', icon: }, - PRESS: { label: 'Pressebericht', icon: }, - COMPANY_REPORT: { label: 'Geschäftsbericht',icon: }, - MARKET_DATA: { label: 'Marktdaten', icon: }, -} - -const QUALITY_META: Record = { - HIGH: { label: 'Hohe Signalqualität', color: DS_TEXT.success }, - MEDIUM: { label: 'Mittlere Signalqualität', color: DS_TEXT.warning }, - LOW: { label: 'Niedrige Signalqualität', color: DS_TEXT.error }, -} - -function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' { - return probability >= 0.70 ? 'HIGH' : probability >= 0.50 ? 'MEDIUM' : 'LOW' -} - -// ── Property match chip ─────────────────────────────────────────────────────── - -function PropertyMatchRow({ p }: { p: Property }) { - const navigate = useNavigate() - return ( - navigate('/supply/properties')} - sx={{ - display: 'flex', alignItems: 'center', gap: 1, - px: 1.25, py: 0.75, borderRadius: 1, - bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, - cursor: 'pointer', '&:hover': { bgcolor: DS_SURFACE.success.bg }, - }} - > - - - {p.title} - - - {p.areaSqm.toLocaleString('de-CH')} m² - - - ) -} - -// ── Lead card ───────────────────────────────────────────────────────────────── - -function LeadCard({ lead }: { lead: MarketLead }) { - const { signal, matchingProperties } = lead - const probPct = Math.round(signal.probability * 100) - const qual = signalQuality(signal.probability) - const qualMeta = QUALITY_META[qual] - const sourceMeta = SOURCE_META[signal.source.type] ?? null - - return ( - - {/* Header */} - - - - - - - - {signal.companyName ?? signal.locationHint} - - - {signal.title} - - - - - - - {/* Key facts strip */} - - {signal.areaSqmEstimate && ( - - Flächenbedarf - ~{signal.areaSqmEstimate.toLocaleString('de-CH')} m² - - )} - - Zeithorizont - ~{signal.timeHorizonMonths} Monate - - - Wahrscheinlichkeit - {probPct}% - - - Standort - {signal.locationHint} - - {sourceMeta && ( - - Quelle - - {sourceMeta.icon} - {sourceMeta.label} - - - )} - - - - - {/* Market indicators */} - {signal.marketIndicators && signal.marketIndicators.length > 0 && ( - - - Erkannte Nachfragesignale - - - {signal.marketIndicators.map((ind, i) => ( - - - {ind} - - ))} - - - )} - - {/* Confirmed / unconfirmed facts */} - {((signal.confirmedFacts?.length ?? 0) > 0 || (signal.unconfirmedFacts?.length ?? 0) > 0) && ( - - - {(signal.confirmedFacts ?? []).map((f, i) => ( - - - {f} - - ))} - {(signal.unconfirmedFacts ?? []).map((f, i) => ( - - - {f} - - ))} - - - )} - - - - {/* Portfolio matches */} - - - Passende Objekte im Portfolio ({matchingProperties.length}) - - {matchingProperties.length === 0 ? ( - - Kein passendes Portfolioobjekt gefunden — manuell prüfen empfohlen - - ) : ( - - {matchingProperties.slice(0, 3).map(p => ( - - ))} - {matchingProperties.length > 3 && ( - - +{matchingProperties.length - 3} weitere Objekte - - )} - - )} - - - {/* Disclaimer */} - - {signal.disclaimer} - - - ) -} - -// ── Loading skeleton ────────────────────────────────────────────────────────── - -function LeadSkeleton() { - return ( - - - - - - - ) -} - -// ── Page ────────────────────────────────────────────────────────────────────── - -export default function MarketLeads() { - const { data: leads, isLoading } = useMarketLeads() - - const leadsWithMatch = leads.filter(l => l.matchingProperties.length > 0) - - return ( - - - {/* Header */} - - - - - Markt-Leads - - Erkannte Unternehmen mit aktivem Flächenbedarf — potenzielle Mieter für Ihr Portfolio - - - {!isLoading && ( - - )} - - - - - - {/* Decision context */} - {!isLoading && leads.length > 0 && ( - 0 - ? [{ label: 'mit passendem Objekt', value: leadsWithMatch.length, severity: 'positive' as const }] - : []), - ]} - risks={leads.length - leadsWithMatch.length > 0 - ? [`${leads.length - leadsWithMatch.length} Leads ohne direkten Portfolio-Match — Leerstandsanalyse empfohlen`] - : [] - } - actions={[]} - /> - )} - - {isLoading ? ( - <> - - - - - ) : leads.length === 0 ? ( - - - Keine aktiven Markt-Leads gefunden. - Signale werden laufend aus öffentlichen Quellen erkannt. - - ) : ( - leads.map(lead => ) - )} - - - ) -}