From 920ce8eb09ff55f66e5d4077443b8a266fe57b01 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Tue, 19 May 2026 15:43:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Verwaltungszugang=20refactor=20?= =?UTF-8?q?=E2=80=94=20lease=20data,=203-tab=20detail,=20market=20signals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard: remove QuickActionPanel, StrongMatchOverview, FutureSignalWidget, DataQualityWidget, header buttons, Marktchancen nav; KpiGrid → 4 cards - Property domain: 9 new fields (leaseTerm, leaseStartDate/End, breakoutOption, currentTenant, importedFrom, importedAt, lastUpdatedAt) - Mock data: annual CHF/m²/Jahr prices (×12), Swiss images, tenant/lease data for all 10 VERIFIED_PORTFOLIO properties; need budgets updated to annual - PropertyTable: swap columns — add Aktueller Mieter, Mietlaufzeit, Breakoutoption, Breakoutoption Zeitpunkt; remove Verfügbarkeit, Konfidenz, Quelle - PropertyDetailView: 3 tabs (Übersicht, Matchability, Marktsignale) with inline edit mode, NeedMatchCard list, PropertyMarketSignalsTab - New: marketReport domain + mock data + service, NeedMatchCard, PropertyMarketSignalsTab with 4 sections + PDF button - matchService: getNeedMatchesForProperty(propertyId, {minScore}) Co-Authored-By: Claude Sonnet 4.6 --- src/components/demand/NeedInput.tsx | 10 +- src/components/layout/AppShell.tsx | 2 - src/components/supply/DashboardHeader.tsx | 37 +- src/components/supply/KpiGrid.tsx | 20 +- src/components/supply/NeedMatchCard.tsx | 124 +++++ src/components/supply/PropertyDetailView.tsx | 454 +++++++++--------- .../supply/PropertyMarketSignalsTab.tsx | 171 +++++++ src/components/supply/PropertyTable.tsx | 80 +-- src/domain/marketReport.ts | 18 + src/domain/match.ts | 19 +- src/domain/property.ts | 10 + src/mock-data/needs.ts | 20 +- src/mock-data/properties.ts | 187 ++++++-- src/mock-data/propertyMarketSignals.ts | 210 ++++++++ src/pages/supply/SupplyDashboard.tsx | 107 +---- src/services/marketReportService.ts | 8 + src/services/matchService.ts | 35 +- 17 files changed, 1036 insertions(+), 476 deletions(-) create mode 100644 src/components/supply/NeedMatchCard.tsx create mode 100644 src/components/supply/PropertyMarketSignalsTab.tsx create mode 100644 src/domain/marketReport.ts create mode 100644 src/mock-data/propertyMarketSignals.ts create mode 100644 src/services/marketReportService.ts diff --git a/src/components/demand/NeedInput.tsx b/src/components/demand/NeedInput.tsx index a55ee3b..bd0601c 100644 --- a/src/components/demand/NeedInput.tsx +++ b/src/components/demand/NeedInput.tsx @@ -25,10 +25,10 @@ const AREA_PRESETS = [ ] const BUDGET_PRESETS = [ - { label: '25 CHF/m²', value: 25 }, - { label: '45 CHF/m²', value: 45 }, - { label: '65 CHF/m²', value: 65 }, - { label: '100 CHF/m²', value: 100 }, + { label: '300 CHF/m²/J', value: 300 }, + { label: '420 CHF/m²/J', value: 420 }, + { label: '540 CHF/m²/J', value: 540 }, + { label: '780 CHF/m²/J', value: 780 }, ] const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern'] @@ -163,7 +163,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} /> {/* Budget */} - Budget (max CHF/m²) + Budget (max CHF/m²/Jahr) {BUDGET_PRESETS.map(p => ( = { { path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard }, { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, { path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare }, - { path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp }, { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, ], }, diff --git a/src/components/supply/DashboardHeader.tsx b/src/components/supply/DashboardHeader.tsx index f7c0a9e..797e2e9 100644 --- a/src/components/supply/DashboardHeader.tsx +++ b/src/components/supply/DashboardHeader.tsx @@ -1,5 +1,4 @@ -import { Box, Button, Chip, Typography } from '@mui/material' -import { useNavigate } from 'react-router' +import { Box, Chip, Typography } from '@mui/material' interface DashboardHeaderProps { orgName?: string @@ -18,31 +17,19 @@ function formatLastUpdated(iso: string): string { } export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }: DashboardHeaderProps) { - const navigate = useNavigate() - return ( - - - - - {orgName} - - - - {lastUpdated && ( - - Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)} - - )} - - - - + + + + {orgName} + + + {lastUpdated && ( + + Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)} + + )} ) } diff --git a/src/components/supply/KpiGrid.tsx b/src/components/supply/KpiGrid.tsx index 9c9a1e2..f5cd145 100644 --- a/src/components/supply/KpiGrid.tsx +++ b/src/components/supply/KpiGrid.tsx @@ -9,7 +9,6 @@ interface KpiGridProps { export function KpiGrid({ data }: KpiGridProps) { const navigate = useNavigate() - const pendingCount = data.reviewTasks?.filter(t => t.status === 'PENDING').length ?? 0 const qualityColor = data.avgDataQuality >= 80 @@ -26,14 +25,6 @@ export function KpiGrid({ data }: KpiGridProps) { tooltip: 'Objekte mit Status "Verfügbar jetzt" oder "Verfügbar bald"', onClick: () => navigate('/supply/properties'), }, - { - id: 'strong-matches', - label: 'Starke Matches', - value: data.strongMatchCount, - accent: '#1a7a4a', - tooltip: 'Matches mit einem Match-Score ≥ 80', - onClick: () => navigate('/supply/match-center'), - }, { id: 'avg-quality', label: 'Ø Datenqualität', @@ -47,15 +38,6 @@ export function KpiGrid({ data }: KpiGridProps) { label: 'Marktsignale', value: data.futureSignals?.total ?? '–', tooltip: 'Erkannte Marktsignale und Future Availability Indikatoren', - onClick: () => navigate('/supply/future-availability'), - }, - { - id: 'review-pending', - label: 'Review ausstehend', - value: pendingCount, - accent: pendingCount > 0 ? '#d97706' : undefined, - tooltip: 'Matches und Signale, die eine manuelle Prüfung erfordern', - onClick: () => navigate('/ops/review-queue'), }, { id: 'critical-gaps', @@ -71,7 +53,7 @@ export function KpiGrid({ data }: KpiGridProps) { diff --git a/src/components/supply/NeedMatchCard.tsx b/src/components/supply/NeedMatchCard.tsx new file mode 100644 index 0000000..0c58140 --- /dev/null +++ b/src/components/supply/NeedMatchCard.tsx @@ -0,0 +1,124 @@ +import { Box, Card, Chip, Stack, Typography } from '@mui/material' +import { MapPin, Ruler, Wallet, Calendar, CheckCircle } from 'lucide-react' +import type { PropertyNeedMatch } from '../../domain/match' + +interface Props { + match: PropertyNeedMatch +} + +function ScoreBadge({ score }: { score: number }) { + const isGold = score >= 90 + const bg = isGold ? '#fef3c7' : '#f1f5f9' + const color = isGold ? '#d97706' : '#64748b' + const borderColor = isGold ? '#fde68a' : '#e2e8f0' + + return ( + + + {score} + + + ) +} + +function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( + + {icon} + {label} + {value} + + ) +} + +export function NeedMatchCard({ match }: Props) { + return ( + + + + + + + + {match.company} + + = 90 ? 'Gold Match' : 'Starker Match'} + size="small" + sx={{ + fontSize: '0.65rem', + height: 18, + bgcolor: match.score >= 90 ? '#fef3c7' : '#f1f5f9', + color: match.score >= 90 ? '#92400e' : '#475569', + border: '1px solid', + borderColor: match.score >= 90 ? '#fde68a' : '#e2e8f0', + }} + /> + + + + } label="Fläche" value={match.areaRequired} /> + } label="Budget" value={match.budget} /> + } + label="Standorte" + value={match.locations.slice(0, 3).join(', ') + (match.locations.length > 3 ? ' …' : '')} + /> + } label="Einzug ab" value={match.timing} /> + + + {match.matchHighlights.length > 0 && ( + + {match.matchHighlights.slice(0, 2).map((h, i) => ( + + + {h} + + ))} + + )} + + {match.mustHaves.length > 0 && ( + + {match.mustHaves.slice(0, 3).map(mh => ( + + ))} + {match.mustHaves.length > 3 && ( + + +{match.mustHaves.length - 3} + + )} + + )} + + + + ) +} diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx index 00ccb91..8839a4b 100644 --- a/src/components/supply/PropertyDetailView.tsx +++ b/src/components/supply/PropertyDetailView.tsx @@ -1,32 +1,35 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Alert, Box, + Button, Chip, + CircularProgress, Divider, IconButton, LinearProgress, Tab, Tabs, + TextField, Typography, } from '@mui/material' -import { X } from 'lucide-react' -import { NegotiationInsightsPanel } from './NegotiationInsightsPanel' -import { DataQualityPanel as DQPanel, ProvenancePanel } from '../data-quality' +import { useQueryClient } from '@tanstack/react-query' +import { Edit2, Save, X } from 'lucide-react' import { PropertyMap } from '../shared' -import type { Property } from '../../domain/property' -import type { Match } from '../../domain/match' -import type { FutureSignal } from '../../domain/futureSignal' -import { usePropertyById, usePropertyMatches, usePropertySignals } from '../../hooks/useProperties' -import { PropertyActivityLogPanel } from './PropertyActivityLogPanel' +import { NeedMatchCard } from './NeedMatchCard' +import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab' +import type { Property, UpdatePropertyInput } from '../../domain/property' +import type { PropertyNeedMatch } from '../../domain/match' +import { usePropertyById } from '../../hooks/useProperties' import { PropertyDetailSkeleton } from './PropertyDetailSkeleton' +import { matchService } from '../../services/matchService' +import { propertyService } from '../../services/propertyService' +import { useToastStore } from '../../stores/toastStore' import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityChipColor, getAvailabilityLabel, - getResultTypeColor, - getResultTypeLabel, qualityColor, } from './propertyHelpers' @@ -35,7 +38,7 @@ interface PropertyDetailViewProps { onClose?: () => void } -// ── Sub-components ──────────────────────────────────────────────────────────── +// ── Helpers ──────────────────────────────────────────────────────────────────── function Field({ label, value }: { label: string; value?: string | number | boolean | null }) { return ( @@ -70,44 +73,25 @@ function SectionTitle({ title }: { title: string }) { ) } -function ScoreRow({ label, value }: { label: string; value?: number }) { - if (value === undefined || value === null) { - return ( - - - {label} - - - - - ) - } - const pct = value > 1 ? value : value * 100 - return ( - - - {label} - {Math.round(pct)} - - = 70 ? 'success' : pct >= 40 ? 'warning' : 'error'} - sx={{ height: 4, borderRadius: 2 }} - /> - - ) +// ── Übersicht tab ───────────────────────────────────────────────────────────── + +interface OverviewPanelProps { + p: Property + editing: boolean + draft: UpdatePropertyInput + onDraftChange: (d: UpdatePropertyInput) => void } -// ── Tab panels ──────────────────────────────────────────────────────────────── +function OverviewPanel({ p, editing, draft, onDraftChange }: OverviewPanelProps) { + const rentLabel = p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined -function OverviewPanel({ p }: { p: Property }) { return ( + {/* Key metrics */} {[ { label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')} m²` }, - { label: 'Miete/m²/Jahr', value: p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : undefined }, + { label: 'CHF/m²/Jahr', value: rentLabel }, { label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined }, ].map(({ label, value }) => ( @@ -118,16 +102,95 @@ function OverviewPanel({ p }: { p: Property }) { ))} - {p.description && ( - <> + + {/* Description */} + {editing ? ( + - + onDraftChange({ ...draft, description: e.target.value })} + placeholder="Beschreibung des Objekts…" + /> + + ) : p.description ? ( + + + {p.description} - + + ) : null} + + {/* Lease & Tenant */} + + {editing ? ( + + + onDraftChange({ ...draft, currentTenant: e.target.value })} + /> + onDraftChange({ ...draft, leaseTerm: e.target.value })} + placeholder="z.B. 5 Jahre" + /> + onDraftChange({ ...draft, leaseStartDate: e.target.value })} + /> + onDraftChange({ ...draft, leaseEndDate: e.target.value })} + /> + + + ) : ( + + + + + + + + + + )} + + + + {/* Object details */} + + + + + + + + + + + {/* Map */} {p.location.coordinates && ( - + Standort @@ -144,6 +207,10 @@ function OverviewPanel({ p }: { p: Property }) { /> )} + + + + {/* Data quality */} Score @@ -166,176 +233,83 @@ function OverviewPanel({ p }: { p: Property }) { ) } -function HardFactsPanel({ p }: { p: Property }) { - const hf = p.hardFacts - return ( - - - - - - - - - - - - - - - - - - - 0 ? `CHF ${p.rentPricePerSqm}` : undefined} /> - - - - - - - - - - - - - {hf && ( - <> - - - - - - - - - - - - - - - )} - - ) -} +// ── Matchability tab ────────────────────────────────────────────────────────── -function SoftFactorsPanel({ p }: { p: Property }) { - const sf = p.softFactors - if (!sf) { - return Keine Soft Factors vorhanden. - } - const prestige = sf.prestigeScore ?? sf.prestige - const visibility = sf.visibilityScore - const footfall = sf.footfallScore - const commuter = sf.commuterAccessScore ?? sf.accessibility - const talent = sf.talentAccessScore ?? sf.talentAccess - const esg = sf.esgScore - const flex = sf.flexibilityScore - const expansion = sf.expansionPotentialScore - const tax = sf.taxEnvironmentScore +function MatchabilityTabPanel({ propertyId }: { propertyId: string }) { + const [matches, setMatches] = useState([]) + const [loading, setLoading] = useState(true) - return ( - - - - - - - - - - - - {sf.parkingSpots !== undefined && ( - - )} - {sf.publicTransportMinutes !== undefined && ( - - )} - {sf.infrastructureNotes && ( - - )} - - ) -} + useEffect(() => { + let cancelled = false + setLoading(true) + matchService.getNeedMatchesForProperty(propertyId, { minScore: 80 }).then(result => { + if (!cancelled) { setMatches(result); setLoading(false) } + }) + return () => { cancelled = true } + }, [propertyId]) -function MatchabilityPanel({ matches }: { matches: Match[] }) { - if (matches.length === 0) { + if (loading) { return ( - - Keine Matches für dieses Objekt vorhanden. - + + + ) } - return ( - - - {matches.map(m => ( - - - Bedarf: {m.needId} - = 80 ? '#1a7a4a' : m.matchScore >= 60 ? '#d97706' : '#c0392b', - color: 'white', - }} - /> - - {m.positiveFactors?.slice(0, 3).map((f, i) => ( - ✓ {f.explanation ?? f.criterion} - ))} - {m.missingData && m.missingData.length > 0 && ( - - ⚠ {m.missingData.length} fehlende Datenfelder - - )} - - ))} - - ) -} - -function SignalsPanel({ signals }: { signals: FutureSignal[] }) { - if (signals.length === 0) { - return Keine Zukunftssignale für dieses Objekt. - } return ( - - - {signals.map(s => ( - - - {s.title ?? s.signalType} - - - {s.locationHint} · Konfidenz {Math.round(s.confidenceScore * 100)}% · {s.timeHorizonMonths} Monate - - {s.disclaimer && ( - - {s.disclaimer} - - )} - - ))} + + + + Matchability + + + Bedarfsprofile mit ≥ 80% Match-Score für dieses Objekt + + + {matches.length === 0 ? ( + + Keine Bedarfsprofile mit ≥ 80% Match-Score für dieses Objekt gefunden. + + ) : ( + matches.map(m => ) + )} ) } // ── Main component ──────────────────────────────────────────────────────────── -const TABS = ['Übersicht', 'Verhandlung', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale', 'Aktivitätslog'] as const +const TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) { const [tab, setTab] = useState(0) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState({}) + const [saving, setSaving] = useState(false) + const queryClient = useQueryClient() const { data: property, isLoading } = usePropertyById(propertyId) - const { data: matches = [] } = usePropertyMatches(propertyId) - const { data: signals = [] } = usePropertySignals(propertyId) + const showToast = useToastStore(s => s.showToast) + + function startEdit() { + setDraft({}) + setEditing(true) + } + + async function saveEdit() { + if (!property) return + setSaving(true) + try { + await propertyService.update(property.id, draft) + await queryClient.invalidateQueries({ queryKey: ['property', propertyId] }) + setEditing(false) + setDraft({}) + showToast('Objekt gespeichert.', 'success') + } catch { + showToast('Fehler beim Speichern.', 'error') + } finally { + setSaving(false) + } + } if (isLoading) return if (!property) { @@ -355,16 +329,53 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr - - - + + + + + {tab === 0 && !editing && ( + + + + )} + {tab === 0 && editing && ( + <> + + + + )} + {onClose && ( + + + + )} - {onClose && ( - - - - )} + {property.title} @@ -384,7 +395,7 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr )} - {/* Photo only — map moved into Übersicht tab */} + {/* Photo */} {property.images?.[0] && ( setTab(v)} - variant="scrollable" - scrollButtons - allowScrollButtonsMobile sx={{ borderBottom: '1px solid #e2e8f0', flexShrink: 0, - '& .MuiTab-root': { textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto', px: 1.5 }, - '& .MuiTabScrollButton-root': { width: 28 }, + '& .MuiTab-root': { textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto', px: 2 }, }} > {TABS.map(label => ( @@ -415,16 +422,17 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr {/* Tab content */} - - {tab === 0 && } - {tab === 1 && } - {tab === 2 && } - {tab === 3 && } - {tab === 4 && } - {tab === 5 && } - {tab === 6 && } - {tab === 7 && } - {tab === 8 && } + + {tab === 0 && ( + + )} + {tab === 1 && } + {tab === 2 && } ) diff --git a/src/components/supply/PropertyMarketSignalsTab.tsx b/src/components/supply/PropertyMarketSignalsTab.tsx new file mode 100644 index 0000000..15d49cc --- /dev/null +++ b/src/components/supply/PropertyMarketSignalsTab.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from 'react' +import { Alert, Box, Button, Chip, CircularProgress, LinearProgress, Typography } from '@mui/material' +import { BarChart2, Building2, FileText, Lightbulb, TrendingUp } from 'lucide-react' +import { marketReportService } from '../../services/marketReportService' +import { useToastStore } from '../../stores/toastStore' +import type { MarketReport, PropertyMarketSignal, SignalCategory } from '../../domain/marketReport' + +interface Props { + propertyId: string +} + +const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [ + { category: 'development', label: 'Entwicklungsnews', icon: , color: '#1e3a5f' }, + { category: 'demand', label: 'Nachfrage 5 km', icon: , color: '#1a7a4a' }, + { category: 'supply', label: 'Angebot 5 km', icon: , color: '#b45309' }, + { category: 'negotiation', label: 'Verhandlungshinweise', icon: , color: '#7c3aed' }, +] + +function SignalCard({ signal }: { signal: PropertyMarketSignal }) { + return ( + + + + {signal.title} + + {signal.confidence != null && ( + + )} + + + + {signal.body} + + + {signal.confidence != null && ( + + + KI-Konfidenz + {Math.round(signal.confidence * 100)}% + + = 0.8 ? '#1a7a4a' : signal.confidence >= 0.6 ? '#d97706' : '#64748b' }, + }} + /> + + )} + + + {signal.date && ( + + {new Date(signal.date).toLocaleDateString('de-CH')} + + )} + {signal.source && ( + + + {signal.source} + + )} + + + ) +} + +export function PropertyMarketSignalsTab({ propertyId }: Props) { + const [report, setReport] = useState(null) + const [loading, setLoading] = useState(true) + const showToast = useToastStore(s => s.showToast) + + useEffect(() => { + let cancelled = false + setLoading(true) + marketReportService.getByProperty(propertyId).then(r => { + if (!cancelled) { setReport(r); setLoading(false) } + }) + return () => { cancelled = true } + }, [propertyId]) + + if (loading) { + return ( + + + + ) + } + + return ( + + + + + Marktsignale & Standortintelligenz + + {report?.generatedAt && ( + + Generiert {new Date(report.generatedAt).toLocaleDateString('de-CH')} + + )} + + + + + {!report ? ( + + Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor. + + ) : ( + SECTION_CONFIG.map(({ category, label, icon, color }) => { + const signals = report.signals.filter(s => s.category === category) + if (signals.length === 0) return null + return ( + + + {icon} + + {label} + + + + {signals.map(s => )} + + ) + }) + )} + + ) +} diff --git a/src/components/supply/PropertyTable.tsx b/src/components/supply/PropertyTable.tsx index d7c2563..095245f 100644 --- a/src/components/supply/PropertyTable.tsx +++ b/src/components/supply/PropertyTable.tsx @@ -19,10 +19,6 @@ import type { PropertyTableFilters } from './PropertyFilterBar' import { getAssetTypeColor, getAssetTypeLabel, - getAvailabilityChipColor, - getAvailabilityLabel, - getResultTypeColor, - getResultTypeLabel, qualityColor, } from './propertyHelpers' @@ -38,8 +34,9 @@ interface PropertyTableProps { } const COL_HEADERS = [ - 'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete/m²', - 'Verfügbarkeit', 'Konfidenz', 'Datenqualität', 'Quelle', 'Aktionen', + 'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete CHF/m²/Jahr', + 'Aktueller Mieter', 'Mietlaufzeit', 'Breakoutoption', 'Breakoutoption Zeitpunkt', + 'Datenqualität', 'Aktionen', ] function LoadingRows() { @@ -108,11 +105,12 @@ export function PropertyTable({ Typ Standort - - - + + Aktueller Mieter + Mietlaufzeit + Breakoutoption + Breakoutoption Zeitpunkt - Quelle Aktionen @@ -121,7 +119,7 @@ export function PropertyTable({ ) : properties.length === 0 ? ( - + Keine Objekte gefunden. @@ -184,30 +182,47 @@ export function PropertyTable({ {/* Miete */} - {p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : k.A.} + {p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : k.A.} - {/* Verfügbarkeit */} + {/* Aktueller Mieter */} - + + {p.currentTenant ?? } + - {/* Konfidenz */} + {/* Mietlaufzeit */} - = 0.85 ? '#1a7a4a' : p.confidenceScore >= 0.65 ? '#1e3a5f' : '#d97706', - }} - > - {Math.round(p.confidenceScore * 100)}% + + {p.leaseTerm ?? } + + + + {/* Breakoutoption */} + + {p.breakoutOption == null + ? + : + } + + + {/* Breakoutoption Zeitpunkt */} + + + {p.breakoutOptionDate + ? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH') + : + } @@ -231,15 +246,6 @@ export function PropertyTable({ - {/* Quelle */} - - - - {/* Aktionen */} e.stopPropagation()}> diff --git a/src/domain/marketReport.ts b/src/domain/marketReport.ts new file mode 100644 index 0000000..8425ab2 --- /dev/null +++ b/src/domain/marketReport.ts @@ -0,0 +1,18 @@ +export type SignalCategory = 'development' | 'demand' | 'supply' | 'negotiation' + +export interface PropertyMarketSignal { + id: string + category: SignalCategory + title: string + body: string + source?: string + sourceUrl?: string + date?: string + confidence?: number +} + +export interface MarketReport { + propertyId: string + generatedAt: string + signals: PropertyMarketSignal[] +} diff --git a/src/domain/match.ts b/src/domain/match.ts index 2b5af59..2500232 100644 --- a/src/domain/match.ts +++ b/src/domain/match.ts @@ -1,4 +1,21 @@ -import type { MatchStrength, MatchStatus, RiskLevel, ResultType, ConfidenceLevel } from './enums' +import type { MatchStrength, MatchStatus, RiskLevel, ResultType, ConfidenceLevel, AssetType } from './enums' + +// ── PropertyNeedMatch (Matchability tab) ────────────────────────────────────── + +export interface PropertyNeedMatch { + matchId: string + score: number + needId: string + needTitle: string + company: string + areaRequired: string + budget: string + locations: string[] + assetType: AssetType + timing: string + mustHaves: string[] + matchHighlights: string[] +} // ── Score Building Blocks ───────────────────────────────────────────────────── diff --git a/src/domain/property.ts b/src/domain/property.ts index 02c7948..208549a 100644 --- a/src/domain/property.ts +++ b/src/domain/property.ts @@ -129,6 +129,16 @@ export interface Property { description?: string images?: string[] + leaseTerm?: string + leaseStartDate?: string + leaseEndDate?: string + breakoutOption?: boolean + breakoutOptionDate?: string + currentTenant?: string + importedFrom?: string + importedAt?: string + lastUpdatedAt?: string + status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED' lastReviewedAt?: string createdAt: string diff --git a/src/mock-data/needs.ts b/src/mock-data/needs.ts index f3a10dc..181838c 100644 --- a/src/mock-data/needs.ts +++ b/src/mock-data/needs.ts @@ -11,7 +11,7 @@ export const mockNeeds: Need[] = [ requiredArea: { min: 600, max: 1000 }, preferredLocations: ['Zürich', 'Zürich-West', 'Zürich Kreis 5'], excludedLocations: [], - budgetRange: { maxPerSqm: 45, maxMonthlyTotal: 40000, currency: 'CHF' }, + budgetRange: { maxPerSqm: 540, maxMonthlyTotal: 40000, currency: 'CHF' }, timing: { earliestMoveIn: '2025-08-01', latestMoveIn: '2026-01-01', @@ -50,7 +50,7 @@ export const mockNeeds: Need[] = [ assetType: AssetType.LOGISTICS, requiredArea: { min: 1500, max: 4000 }, preferredLocations: ['Basel', 'Muttenz', 'Pratteln', 'Reinach BL'], - budgetRange: { maxPerSqm: 18, currency: 'CHF' }, + budgetRange: { maxPerSqm: 216, currency: 'CHF' }, timing: { earliestMoveIn: '2025-09-01', latestMoveIn: '2026-06-01', @@ -86,7 +86,7 @@ export const mockNeeds: Need[] = [ requiredArea: { min: 500, max: 800 }, preferredLocations: ['Basel', 'Allschwil', 'Binningen', 'Dreispitz'], excludedLocations: [], - budgetRange: { maxPerSqm: 40, maxMonthlyTotal: 32000, currency: 'CHF' }, + budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 32000, currency: 'CHF' }, timing: { earliestMoveIn: '2025-10-01', latestMoveIn: '2026-04-01', @@ -126,7 +126,7 @@ export const mockNeeds: Need[] = [ requiredArea: { min: 200, max: 500 }, preferredLocations: ['Zürich Innenstadt', 'Zürich Bahnhofstrasse', 'Zürich Niederdorf', 'Zürich City'], excludedLocations: [], - budgetRange: { maxPerSqm: 100, maxMonthlyTotal: 50000, currency: 'CHF' }, + budgetRange: { maxPerSqm: 1200, maxMonthlyTotal: 50000, currency: 'CHF' }, timing: { earliestMoveIn: '2025-09-01', latestMoveIn: '2026-03-01', @@ -165,7 +165,7 @@ export const mockNeeds: Need[] = [ requiredArea: { min: 300, max: 700 }, preferredLocations: ['Zug', 'Zürich', 'Baar', 'Steinhausen'], excludedLocations: [], - budgetRange: { maxPerSqm: 48, maxMonthlyTotal: 33000, currency: 'CHF' }, + budgetRange: { maxPerSqm: 576, maxMonthlyTotal: 33000, currency: 'CHF' }, timing: { earliestMoveIn: '2025-10-01', latestMoveIn: '2026-06-01', @@ -204,7 +204,7 @@ export const mockNeeds: Need[] = [ assetType: AssetType.LOGISTICS, requiredArea: { min: 1200, max: 3000 }, preferredLocations: ['Winterthur', 'Wülflingen', 'Oberwinterthur', 'Töss'], - budgetRange: { maxPerSqm: 16, currency: 'CHF' }, + budgetRange: { maxPerSqm: 192, currency: 'CHF' }, timing: { earliestMoveIn: '2025-11-01', latestMoveIn: '2026-05-01', @@ -240,7 +240,7 @@ export const mockNeeds: Need[] = [ requiredArea: { min: 800, max: 1500 }, preferredLocations: ['Zürich', 'Zürich-West', 'Zürich Altstetten', 'Bern'], excludedLocations: [], - budgetRange: { maxPerSqm: 55, maxMonthlyTotal: 75000, currency: 'CHF' }, + budgetRange: { maxPerSqm: 660, maxMonthlyTotal: 75000, currency: 'CHF' }, timing: { earliestMoveIn: '2026-01-01', latestMoveIn: '2026-07-01', @@ -279,7 +279,7 @@ export const mockNeeds: Need[] = [ assetType: AssetType.PRODUCTION, requiredArea: { min: 2000, max: 4000 }, preferredLocations: ['Bern', 'Brünnen', 'Münchenbuchsee', 'Bern West'], - budgetRange: { maxPerSqm: 14, currency: 'CHF' }, + budgetRange: { maxPerSqm: 168, currency: 'CHF' }, timing: { earliestMoveIn: '2025-12-01', latestMoveIn: '2026-09-01', @@ -315,7 +315,7 @@ export const mockNeeds: Need[] = [ requiredArea: { min: 150, max: 400 }, preferredLocations: ['Genf', 'Genf Rive', 'Genf Centre'], excludedLocations: [], - budgetRange: { maxPerSqm: 120, maxMonthlyTotal: 48000, currency: 'CHF' }, + budgetRange: { maxPerSqm: 1440, maxMonthlyTotal: 48000, currency: 'CHF' }, timing: { earliestMoveIn: '2026-01-01', latestMoveIn: '2026-06-01', @@ -354,7 +354,7 @@ export const mockNeeds: Need[] = [ requiredArea: { min: 400, max: 800 }, preferredLocations: ['St. Gallen', 'St. Gallen Centrum', 'Riethüsli', 'Ostschweiz'], excludedLocations: [], - budgetRange: { maxPerSqm: 35, maxMonthlyTotal: 28000, currency: 'CHF' }, + budgetRange: { maxPerSqm: 420, maxMonthlyTotal: 28000, currency: 'CHF' }, timing: { earliestMoveIn: '2025-11-01', latestMoveIn: '2026-04-01', diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts index 7a772ee..eb69960 100644 --- a/src/mock-data/properties.ts +++ b/src/mock-data/properties.ts @@ -15,8 +15,8 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3882, lng: 8.5132 } }, address: { street: 'Zollstrasse', houseNumber: '12', postalCode: '8005', city: 'Zürich', country: 'CH' }, areaSqm: 850, - rentPricePerSqm: 38, - totalRentMonthly: 32300, + rentPricePerSqm: 456, + totalRentMonthly: 387600, availabilityDate: '2025-09-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'ERP_IMPORT', @@ -41,7 +41,15 @@ export const mockProperties: Property[] = [ contractDurationMonths: 60, ancillaryCosts: 5.5, riskLevel: RiskLevel.LOW, - images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'MediaGroup Schweiz AG', + leaseTerm: '5 Jahre', + leaseStartDate: '2020-09-01', + leaseEndDate: '2025-08-31', + breakoutOption: false, organizationId: 'org-wincasa', createdAt: '2025-01-10T08:00:00Z', updatedAt: '2025-04-28T10:30:00Z', @@ -55,8 +63,8 @@ export const mockProperties: Property[] = [ location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5736, lng: 7.5946 } }, address: { street: 'Hardstrasse', houseNumber: '44', postalCode: '4057', city: 'Basel', country: 'CH' }, areaSqm: 2400, - rentPricePerSqm: 14, - totalRentMonthly: 33600, + rentPricePerSqm: 168, + totalRentMonthly: 403200, availabilityDate: '2025-07-01', availabilityStatus: AvailabilityStatus.AVAILABLE_NOW, sourceType: 'ERP_IMPORT', @@ -81,6 +89,14 @@ export const mockProperties: Property[] = [ ancillaryCosts: 3.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'Spedition Rhein GmbH', + leaseTerm: '3 Jahre', + leaseStartDate: '2022-07-01', + leaseEndDate: '2025-06-30', + breakoutOption: false, organizationId: 'org-wincasa', createdAt: '2024-11-20T09:00:00Z', updatedAt: '2025-05-05T11:00:00Z', @@ -94,8 +110,8 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4115, lng: 8.5502 } }, address: { street: 'Thurgauerstrasse', houseNumber: '40', postalCode: '8050', city: 'Zürich', country: 'CH' }, areaSqm: 720, - rentPricePerSqm: 36, - totalRentMonthly: 25920, + rentPricePerSqm: 432, + totalRentMonthly: 311040, availabilityDate: '2025-10-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'ERP_IMPORT', @@ -122,6 +138,15 @@ export const mockProperties: Property[] = [ ancillaryCosts: 5.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'Consulting Partners AG', + leaseTerm: '4 Jahre', + leaseStartDate: '2021-10-01', + leaseEndDate: '2025-09-30', + breakoutOption: true, + breakoutOptionDate: '2024-10-01', organizationId: 'org-wincasa', createdAt: '2025-02-01T09:00:00Z', updatedAt: '2025-05-01T08:00:00Z', @@ -135,8 +160,8 @@ export const mockProperties: Property[] = [ location: { city: 'Basel', district: 'Dreispitz', canton: 'BS', country: 'CH', coordinates: { lat: 47.5398, lng: 7.5812 } }, address: { street: 'Hochbergerstrasse', houseNumber: '9', postalCode: '4057', city: 'Basel', country: 'CH' }, areaSqm: 900, - rentPricePerSqm: 32, - totalRentMonthly: 28800, + rentPricePerSqm: 384, + totalRentMonthly: 345600, availabilityDate: '2025-09-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'ERP_IMPORT', @@ -162,6 +187,14 @@ export const mockProperties: Property[] = [ ancillaryCosts: 4.5, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1481277542470-605612bd2d61?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'Pharma Research GmbH', + leaseTerm: '4 Jahre', + leaseStartDate: '2021-09-01', + leaseEndDate: '2025-08-31', + breakoutOption: false, organizationId: 'org-wincasa', createdAt: '2025-01-20T10:00:00Z', updatedAt: '2025-04-30T09:00:00Z', @@ -175,8 +208,8 @@ export const mockProperties: Property[] = [ location: { city: 'Winterthur', district: 'Töss', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4952, lng: 8.7082 } }, address: { street: 'Tössfeldstrasse', houseNumber: '18', postalCode: '8406', city: 'Winterthur', country: 'CH' }, areaSqm: 1800, - rentPricePerSqm: 13, - totalRentMonthly: 23400, + rentPricePerSqm: 156, + totalRentMonthly: 280800, availabilityDate: '2025-07-01', availabilityStatus: AvailabilityStatus.AVAILABLE_NOW, sourceType: 'ERP_IMPORT', @@ -201,6 +234,14 @@ export const mockProperties: Property[] = [ ancillaryCosts: 2.8, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1566438480900-0b5b967f4a25?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'Sperrgut Logistik AG', + leaseTerm: '5 Jahre', + leaseStartDate: '2020-07-01', + leaseEndDate: '2025-06-30', + breakoutOption: false, organizationId: 'org-wincasa', createdAt: '2024-12-10T08:00:00Z', updatedAt: '2025-05-02T10:00:00Z', @@ -214,8 +255,8 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3758, lng: 8.5352 } }, address: { street: 'Löwenplatz', houseNumber: '3', postalCode: '8001', city: 'Zürich', country: 'CH' }, areaSqm: 285, - rentPricePerSqm: 88, - totalRentMonthly: 25080, + rentPricePerSqm: 1056, + totalRentMonthly: 300960, availabilityDate: '2025-08-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'ERP_IMPORT', @@ -240,6 +281,14 @@ export const mockProperties: Property[] = [ ancillaryCosts: 8.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'Fashion Concept GmbH', + leaseTerm: '5 Jahre', + leaseStartDate: '2020-08-01', + leaseEndDate: '2025-07-31', + breakoutOption: false, organizationId: 'org-wincasa', createdAt: '2025-01-05T10:00:00Z', updatedAt: '2025-05-06T11:00:00Z', @@ -253,8 +302,8 @@ export const mockProperties: Property[] = [ location: { city: 'Bern', district: 'Brünnen', canton: 'BE', country: 'CH', coordinates: { lat: 46.9562, lng: 7.3818 } }, address: { street: 'Brünnenstrasse', houseNumber: '22', postalCode: '3018', city: 'Bern', country: 'CH' }, areaSqm: 2800, - rentPricePerSqm: 12, - totalRentMonthly: 33600, + rentPricePerSqm: 144, + totalRentMonthly: 403200, availabilityDate: '2025-07-01', availabilityStatus: AvailabilityStatus.AVAILABLE_NOW, sourceType: 'ERP_IMPORT', @@ -279,6 +328,14 @@ export const mockProperties: Property[] = [ ancillaryCosts: 2.5, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'Metallbau Bern AG', + leaseTerm: '10 Jahre', + leaseStartDate: '2015-07-01', + leaseEndDate: '2025-06-30', + breakoutOption: false, organizationId: 'org-wincasa', createdAt: '2024-10-15T09:00:00Z', updatedAt: '2025-05-03T10:00:00Z', @@ -292,8 +349,8 @@ export const mockProperties: Property[] = [ location: { city: 'Zug', district: 'Zentrum', canton: 'ZG', country: 'CH', coordinates: { lat: 47.1712, lng: 8.5150 } }, address: { street: 'Industriestrasse', houseNumber: '2', postalCode: '6300', city: 'Zug', country: 'CH' }, areaSqm: 550, - rentPricePerSqm: 42, - totalRentMonthly: 23100, + rentPricePerSqm: 504, + totalRentMonthly: 277200, availabilityDate: '2025-10-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'ERP_IMPORT', @@ -319,6 +376,15 @@ export const mockProperties: Property[] = [ ancillaryCosts: 6.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'FinTech Zug AG', + leaseTerm: '3 Jahre', + leaseStartDate: '2022-10-01', + leaseEndDate: '2025-09-30', + breakoutOption: true, + breakoutOptionDate: '2025-04-01', organizationId: 'org-wincasa', createdAt: '2025-02-10T11:00:00Z', updatedAt: '2025-04-29T08:00:00Z', @@ -332,8 +398,8 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3908, lng: 8.4888 } }, address: { street: 'Badenerstrasse', houseNumber: '810', postalCode: '8048', city: 'Zürich', country: 'CH' }, areaSqm: 1300, - rentPricePerSqm: 45, - totalRentMonthly: 58500, + rentPricePerSqm: 540, + totalRentMonthly: 702000, availabilityDate: '2025-11-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'ERP_IMPORT', @@ -359,6 +425,14 @@ export const mockProperties: Property[] = [ ancillaryCosts: 5.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'Design Studio Zürich GmbH', + leaseTerm: '4 Jahre', + leaseStartDate: '2021-11-01', + leaseEndDate: '2025-10-31', + breakoutOption: false, organizationId: 'org-wincasa', createdAt: '2025-01-15T09:00:00Z', updatedAt: '2025-04-25T10:00:00Z', @@ -372,8 +446,8 @@ export const mockProperties: Property[] = [ location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.5228, lng: 7.6958 } }, address: { street: 'Industriestrasse', houseNumber: '55', postalCode: '4133', city: 'Pratteln', country: 'CH' }, areaSqm: 3100, - rentPricePerSqm: 15, - totalRentMonthly: 46500, + rentPricePerSqm: 180, + totalRentMonthly: 558000, availabilityDate: '2025-07-01', availabilityStatus: AvailabilityStatus.AVAILABLE_NOW, sourceType: 'ERP_IMPORT', @@ -398,6 +472,15 @@ export const mockProperties: Property[] = [ ancillaryCosts: 2.8, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1553413077-190dd305871c?w=800&h=400&fit=crop'], + importedFrom: 'SAP RE-FX', + importedAt: '2025-01-15T08:00:00Z', + lastUpdatedAt: '2025-05-10T10:00:00Z', + currentTenant: 'Handels- und Lagerbetrieb AG', + leaseTerm: '5 Jahre', + leaseStartDate: '2020-07-01', + leaseEndDate: '2025-06-30', + breakoutOption: true, + breakoutOptionDate: '2024-07-01', organizationId: 'org-wincasa', createdAt: '2024-12-01T08:00:00Z', updatedAt: '2025-05-04T09:00:00Z', @@ -415,8 +498,8 @@ export const mockProperties: Property[] = [ location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } }, address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' }, areaSqm: 320, - rentPricePerSqm: 95, - totalRentMonthly: 30400, + rentPricePerSqm: 1140, + totalRentMonthly: 364800, availabilityDate: '2025-08-15', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'MATCHOFFICE_SCRAPE', @@ -450,8 +533,8 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } }, address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' }, areaSqm: 1150, - rentPricePerSqm: 52, - totalRentMonthly: 59800, + rentPricePerSqm: 624, + totalRentMonthly: 717600, availabilityDate: '2025-10-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'IMMOSCOUT_SCRAPE', @@ -478,8 +561,8 @@ export const mockProperties: Property[] = [ location: { city: 'Luzern', district: 'Innenstadt', canton: 'LU', country: 'CH', coordinates: { lat: 47.0502, lng: 8.3093 } }, address: { street: 'Kasernenplatz', houseNumber: '3', postalCode: '6003', city: 'Luzern', country: 'CH' }, areaSqm: 650, - rentPricePerSqm: 38, - totalRentMonthly: 24700, + rentPricePerSqm: 456, + totalRentMonthly: 296400, availabilityDate: '2025-10-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'HOMEGATE_SCRAPE', @@ -512,8 +595,8 @@ export const mockProperties: Property[] = [ location: { city: 'Muttenz', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.5202, lng: 7.6422 } }, address: { street: 'Rheinfelderstrasse', houseNumber: '80', postalCode: '4132', city: 'Muttenz', country: 'CH' }, areaSqm: 2200, - rentPricePerSqm: 16, - totalRentMonthly: 35200, + rentPricePerSqm: 192, + totalRentMonthly: 422400, availabilityDate: '2025-09-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'IMMOSCOUT_SCRAPE', @@ -546,8 +629,8 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } }, address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' }, areaSqm: 350, - rentPricePerSqm: 95, - totalRentMonthly: 33250, + rentPricePerSqm: 1140, + totalRentMonthly: 399000, availabilityDate: '2025-09-15', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'MATCHOFFICE_SCRAPE', @@ -581,8 +664,8 @@ export const mockProperties: Property[] = [ location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } }, address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' }, areaSqm: 780, - rentPricePerSqm: 31, - totalRentMonthly: 24180, + rentPricePerSqm: 372, + totalRentMonthly: 290160, availabilityDate: '2025-10-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'NEWHOME_SCRAPE', @@ -615,8 +698,8 @@ export const mockProperties: Property[] = [ location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5720, lng: 7.5882 } }, address: { street: 'Voltastrasse', houseNumber: '62', postalCode: '4056', city: 'Basel', country: 'CH' }, areaSqm: 1900, - rentPricePerSqm: 13, - totalRentMonthly: 24700, + rentPricePerSqm: 156, + totalRentMonthly: 296400, availabilityDate: '2025-11-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'IMMOSCOUT_SCRAPE', @@ -644,8 +727,8 @@ export const mockProperties: Property[] = [ location: { city: 'Zug', district: 'Industrie', canton: 'ZG', country: 'CH', coordinates: { lat: 47.1688, lng: 8.5228 } }, address: { street: 'Industriestrasse', houseNumber: '45', postalCode: '6300', city: 'Zug', country: 'CH' }, areaSqm: 820, - rentPricePerSqm: 44, - totalRentMonthly: 36080, + rentPricePerSqm: 528, + totalRentMonthly: 432960, availabilityDate: '2025-11-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'HOMEGATE_SCRAPE', @@ -665,7 +748,7 @@ export const mockProperties: Property[] = [ publicTransportMinutes: 9, }, riskLevel: RiskLevel.MEDIUM, - images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'], + images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], createdAt: '2025-03-18T09:00:00Z', updatedAt: '2025-04-12T11:00:00Z', }, @@ -678,8 +761,8 @@ export const mockProperties: Property[] = [ location: { city: 'Genf', district: 'Centre', canton: 'GE', country: 'CH', coordinates: { lat: 46.2044, lng: 6.1432 } }, address: { street: 'Rue du Rhône', houseNumber: '48', postalCode: '1204', city: 'Genf', country: 'CH' }, areaSqm: 250, - rentPricePerSqm: 112, - totalRentMonthly: 28000, + rentPricePerSqm: 1344, + totalRentMonthly: 336000, availabilityDate: '2026-01-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'MATCHOFFICE_SCRAPE', @@ -713,8 +796,8 @@ export const mockProperties: Property[] = [ location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } }, address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' }, areaSqm: 700, - rentPricePerSqm: 28, - totalRentMonthly: 19600, + rentPricePerSqm: 336, + totalRentMonthly: 235200, availabilityDate: '2025-11-01', availabilityStatus: AvailabilityStatus.AVAILABLE_SOON, sourceType: 'NEWHOME_SCRAPE', @@ -751,7 +834,7 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Technopark', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3923, lng: 8.5142 } }, address: { street: 'Technoparkstrasse', houseNumber: '1', postalCode: '8005', city: 'Zürich', country: 'CH' }, areaSqm: 600, - rentPricePerSqm: 42, + rentPricePerSqm: 504, availabilityDate: '2026-03-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -776,7 +859,7 @@ export const mockProperties: Property[] = [ location: { city: 'Reinach', district: 'Industriezone Nord', canton: 'BL', country: 'CH', coordinates: { lat: 47.4978, lng: 7.5933 } }, address: { street: 'Industriestrasse', houseNumber: '18', postalCode: '4153', city: 'Reinach', country: 'CH' }, areaSqm: 3200, - rentPricePerSqm: 11, + rentPricePerSqm: 132, availabilityDate: '2026-06-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -801,7 +884,7 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Seebach', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4298, lng: 8.5362 } }, address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' }, areaSqm: 850, - rentPricePerSqm: 38, + rentPricePerSqm: 456, availabilityDate: '2026-02-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -826,7 +909,7 @@ export const mockProperties: Property[] = [ location: { city: 'Basel', district: 'Klybeck', canton: 'BS', country: 'CH', coordinates: { lat: 47.5762, lng: 7.5918 } }, address: { street: 'Klybeckstrasse', houseNumber: '180', postalCode: '4057', city: 'Basel', country: 'CH' }, areaSqm: 2600, - rentPricePerSqm: 14, + rentPricePerSqm: 168, availabilityDate: '2026-07-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -851,7 +934,7 @@ export const mockProperties: Property[] = [ location: { city: 'Zürich', district: 'Niederdorf', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3748, lng: 8.5418 } }, address: { street: 'Münstergasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' }, areaSqm: 280, - rentPricePerSqm: 92, + rentPricePerSqm: 1104, availabilityDate: '2026-09-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -876,7 +959,7 @@ export const mockProperties: Property[] = [ location: { city: 'Zug', district: 'Industrie', canton: 'ZG', country: 'CH', coordinates: { lat: 47.1672, lng: 8.5198 } }, address: { street: 'Industriestrasse', houseNumber: '60', postalCode: '6300', city: 'Zug', country: 'CH' }, areaSqm: 580, - rentPricePerSqm: 43, + rentPricePerSqm: 516, availabilityDate: '2026-04-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -901,7 +984,7 @@ export const mockProperties: Property[] = [ location: { city: 'Münchenbuchsee', district: 'Industriezone', canton: 'BE', country: 'CH', coordinates: { lat: 47.0038, lng: 7.4542 } }, address: { street: 'Bernstrasse', houseNumber: '42', postalCode: '3053', city: 'Münchenbuchsee', country: 'CH' }, areaSqm: 2200, - rentPricePerSqm: 12, + rentPricePerSqm: 144, availabilityDate: '2026-08-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -926,7 +1009,7 @@ export const mockProperties: Property[] = [ location: { city: 'Genf', district: 'La Praille', canton: 'GE', country: 'CH', coordinates: { lat: 46.1912, lng: 6.1285 } }, address: { street: 'Route de la Praille', houseNumber: '30', postalCode: '1227', city: 'Genf', country: 'CH' }, areaSqm: 520, - rentPricePerSqm: 36, + rentPricePerSqm: 432, availabilityDate: '2027-01-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -951,7 +1034,7 @@ export const mockProperties: Property[] = [ location: { city: 'Frenkendorf', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.5098, lng: 7.7182 } }, address: { street: 'Frenkenstrasse', houseNumber: '28', postalCode: '4402', city: 'Frenkendorf', country: 'CH' }, areaSqm: 3500, - rentPricePerSqm: 13, + rentPricePerSqm: 156, availabilityDate: '2026-10-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', @@ -976,7 +1059,7 @@ export const mockProperties: Property[] = [ location: { city: 'St. Gallen', district: 'Riethüsli', canton: 'SG', country: 'CH', coordinates: { lat: 47.4182, lng: 9.3888 } }, address: { street: 'Riethüslistrasse', houseNumber: '40', postalCode: '9000', city: 'St. Gallen', country: 'CH' }, areaSqm: 480, - rentPricePerSqm: 27, + rentPricePerSqm: 324, availabilityDate: '2026-05-01', availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL, sourceType: 'AI_SIGNAL', diff --git a/src/mock-data/propertyMarketSignals.ts b/src/mock-data/propertyMarketSignals.ts new file mode 100644 index 0000000..7d0b2fb --- /dev/null +++ b/src/mock-data/propertyMarketSignals.ts @@ -0,0 +1,210 @@ +import type { MarketReport } from '../domain/marketReport' + +export const mockPropertyMarketSignals: MarketReport[] = [ + { + propertyId: 'prop-001', + generatedAt: '2025-05-18T08:00:00Z', + signals: [ + { + id: 'sig-001-1', + category: 'development', + title: 'Zürich-West: Neues Quartierszentrum geplant', + body: 'Die Stadt Zürich bewilligte die Umzonung des Areals an der Hardbrücke. Bis 2028 entstehen 12\'000 m² Büro- und Gewerbefläche im direkten Umfeld des Objekts — dies erhöht die Attraktivität des Standorts erheblich.', + source: 'Stadt Zürich – Stadtentwicklung', + sourceUrl: 'https://www.stadt-zuerich.ch/stadtentwicklung', + date: '2025-04-10', + confidence: 0.88, + }, + { + id: 'sig-001-2', + category: 'demand', + title: 'Hohe Büroflächennachfrage im Radius 5 km', + body: 'Im Umkreis von 5 km wurden in den letzten 90 Tagen 14 neue Bürobedarfsanfragen erfasst. Durchschnittliche Suchfläche: 750 m², Preistoleranz bis CHF 480/m²/Jahr. Die Nachfrage übersteigt das Angebot um Faktor 2,3.', + date: '2025-05-01', + confidence: 0.82, + }, + { + id: 'sig-001-3', + category: 'supply', + title: 'Büroflächen-Leerstand im Radius 5 km leicht gestiegen', + body: '3 vergleichbare Büroflächen (650–950 m²) sind derzeit im Radius von 5 km inseriert. Durchschnittliche Angebotsdauer: 4,2 Monate. Kein direkter Wettbewerb unter CHF 456/m²/Jahr.', + date: '2025-05-10', + confidence: 0.78, + }, + { + id: 'sig-001-4', + category: 'negotiation', + title: 'Verhandlungshinweis: Objekt seit 3 Monaten leer', + body: 'Das Objekt steht seit Mitte Februar leer. Basierend auf vergleichbaren Transaktionen in Zürich-West ist ein Preisnachlass von 5–8% realistisch, sofern eine Laufzeit von mindestens 4 Jahren angeboten wird. Staftelmieten werden von Eigentümerseite gelegentlich akzeptiert.', + confidence: 0.75, + }, + ], + }, + + { + propertyId: 'prop-007', + generatedAt: '2025-05-18T08:00:00Z', + signals: [ + { + id: 'sig-007-1', + category: 'development', + title: 'Oerlikon Süd: Infrastrukturausbau S-Bahn', + body: 'SBB und ZVV haben die Taktverdichtung der S-Bahn-Linie S16 am Bahnhof Oerlikon für 2026 beschlossen. Fahrtzeiten in die Innenstadt sinken auf unter 8 Minuten — ein Standortvorteil, der sich in Mietpreisen niederschlagen dürfte.', + source: 'ZVV Medienmitteilung', + date: '2025-03-22', + confidence: 0.90, + }, + { + id: 'sig-007-2', + category: 'demand', + title: 'Tech-Sektor treibt Nachfrage in Oerlikon', + body: '8 Tech-Unternehmen mit Expansion aus dem DACH-Raum haben im letzten Quartal Anfragen für Oerlikon/Zürich-Nord gestellt. Flächen zwischen 500 und 900 m² sind besonders gefragt. Budgets mehrheitlich im Bereich CHF 400–550/m²/Jahr.', + date: '2025-04-28', + confidence: 0.79, + }, + { + id: 'sig-007-3', + category: 'supply', + title: 'Geringes Konkurrenzangebot in Oerlikon', + body: 'Im Radius von 3 km ist nur eine direkt vergleichbare Bürofläche (680 m²) auf dem Markt, zum Preis von CHF 510/m²/Jahr. Das gibt dem vorliegenden Objekt eine starke Verhandlungsposition gegenüber Interessenten.', + date: '2025-05-05', + confidence: 0.84, + }, + { + id: 'sig-007-4', + category: 'negotiation', + title: 'Verhandlungshinweis: Mieter kündigt per Ende Q3', + body: 'Consulting Partners AG hat den Mietvertrag fristgerecht gekündigt. Der Eigentümer signalisierte Bereitschaft zu Mieterausbauten (Fit-out-Beitrag bis CHF 60/m²) bei Abschluss bis Ende Juni. Indexierung auf Basis LIK üblich.', + confidence: 0.82, + }, + ], + }, + + { + propertyId: 'prop-008', + generatedAt: '2025-05-18T08:00:00Z', + signals: [ + { + id: 'sig-008-1', + category: 'development', + title: 'Dreispitz Basel: Transformation zu Innovationsquartier', + body: 'Der Kanton Basel-Stadt und die Messe Schweiz haben eine gemeinsame Entwicklungsstrategie für das Dreispitz-Areal verabschiedet. Ziel: 50\'000 m² neue Nutzflächen für Kreativ- und Wissensbranchen bis 2030. Steigende Nachfrage durch Hochschulen und Startups erwartet.', + source: 'Kanton Basel-Stadt', + sourceUrl: 'https://www.entwicklung.bs.ch/dreispitz', + date: '2025-02-14', + confidence: 0.87, + }, + { + id: 'sig-008-2', + category: 'demand', + title: 'Life-Science-Cluster stärkt Büronachfrage', + body: 'Die Nähe zu Novartis Campus und Roche zieht vermehrt Life-Science-Zulieferer und Beratungsfirmen an. 6 aktive Suchanfragen für Büroflächen 400–900 m² im Radius 4 km, überwiegend mit Repräsentationsanspruch.', + date: '2025-04-15', + confidence: 0.76, + }, + { + id: 'sig-008-3', + category: 'supply', + title: 'Angebot stabil, leichter Übergang zu Mietermarkt', + body: 'Im Dreispitz und Umgebung stehen aktuell 4 Büroflächen zwischen 700 und 1100 m² leer. Durchschnittliche Insertionsdauer 6 Monate. Preisbandbreite CHF 336–420/m²/Jahr — das Objekt ist preislich wettbewerbsfähig.', + date: '2025-05-08', + confidence: 0.74, + }, + { + id: 'sig-008-4', + category: 'negotiation', + title: 'Verhandlungshinweis: Mietvertrag läuft aus', + body: 'Pharma Research GmbH verlässt das Objekt nach Ablauf. Keine Verlängerungsoption vereinbart. Nächste Mieter sollten frühzeitig mit Besichtigung beginnen, da Übergabe im September 2025 ohne Überlappungszeit geplant ist. Mietfreie Monate für Ausbauzeit realistisch verhandelbar.', + confidence: 0.80, + }, + ], + }, + + { + propertyId: 'prop-012', + generatedAt: '2025-05-18T08:00:00Z', + signals: [ + { + id: 'sig-012-1', + category: 'development', + title: 'Zug: Wachstum des Fintech-Clusters', + body: 'Zug verzeichnet 2025 die höchste Neugründungsrate von Fintech- und Krypto-Unternehmen in der Schweiz. Laut Handelskammer Zug sind 22 Unternehmen aktiv auf Bürosuche im Kanton, davon 9 mit Bedarf unter 700 m².', + source: 'Handelskammer Zug', + date: '2025-04-02', + confidence: 0.85, + }, + { + id: 'sig-012-2', + category: 'demand', + title: 'Starke Nachfrage nach repräsentativen Büros in Zentrumsnähe', + body: 'Immobilienberater JLL registrierten in Q1 2025 eine Zunahme der Anfragen für Büroflächen in Zug-Zentrum von 34% gegenüber Vorjahr. Budgets liegen durchschnittlich bei CHF 480–600/m²/Jahr — über Angebotspreis des Objekts.', + source: 'JLL Marktbericht Zentralschweiz 2025', + date: '2025-04-18', + confidence: 0.83, + }, + { + id: 'sig-012-3', + category: 'supply', + title: 'Geringes Angebot unter CHF 550/m²/Jahr', + body: 'Nur 2 weitere Büroeinheiten sind in Zug-Zentrum verfügbar: 1 x 380 m² zu CHF 588/m²/Jahr und 1 x 620 m² zu CHF 612/m²/Jahr. Das Objekt ist mit 550 m² und CHF 504/m²/Jahr klar wettbewerbsstärkstes Angebot in diesem Segment.', + date: '2025-05-12', + confidence: 0.88, + }, + { + id: 'sig-012-4', + category: 'negotiation', + title: 'Verhandlungshinweis: Breakoutoption bereits ausgeübt', + body: 'Der bestehende Mieter (FinTech Zug AG) hat die Breakoutoption per Oktober 2024 ausgeübt. Der Mietvertrag läuft planmäßig bis September 2025 — kein vorzeitiger Auszug möglich. Eigentümer strebt Nachfolgevermietung ohne Leerstand an und ist bei 4+ Jahren Laufzeit zu 2 mietfreien Monaten bereit.', + confidence: 0.78, + }, + { + id: 'sig-012-5', + category: 'development', + title: 'Direktanbindung Zug–Zürich: Taktverdichtung ab 2026', + body: 'SBB plant ab Dezember 2026 den 15-Minuten-Takt zwischen Zug und Zürich HB. Reisezeit dann unter 22 Minuten. Mehrere internationale Konzerne haben angekündigt, Zug als Satellitenstandort auszubauen.', + source: 'SBB Fahrplan 2026', + date: '2025-03-30', + confidence: 0.91, + }, + ], + }, + + { + propertyId: 'prop-014', + generatedAt: '2025-05-18T08:00:00Z', + signals: [ + { + id: 'sig-014-1', + category: 'development', + title: 'Pratteln: Entwicklung Logistikpark Schweizerhalle', + body: 'Die Gemeinde Pratteln und der Kanton Basel-Landschaft haben die Gestaltungszone Industrienord angepasst. Neubauten bis 12 m Hallenhöhe und erweiterter Schwerlastverkehr sind ab sofort bewilligungsfähig. Geplante Gesamtinvestition CHF 180 Mio. bis 2030.', + source: 'Gemeinde Pratteln', + date: '2025-01-20', + confidence: 0.84, + }, + { + id: 'sig-014-2', + category: 'demand', + title: 'E-Commerce-Wachstum treibt Logistiknachfrage', + body: 'Im Radius 8 km um Pratteln wurden in den letzten 6 Monaten 11 Logistikanfragen für Flächen ab 2\'000 m² registriert. Starke Nachfrage aus E-Commerce, Pharma-Logistik und Lebensmittelhandel. Budgets mehrheitlich CHF 150–200/m²/Jahr.', + date: '2025-04-22', + confidence: 0.81, + }, + { + id: 'sig-014-3', + category: 'supply', + title: 'Knapper Lagermarkt in der Agglomeration Basel', + body: 'Im Dreieck Muttenz-Pratteln-Reinach stehen aktuell nur 2 Logistikflächen über 2\'000 m² zur Verfügung (davon 1 Neubau mit Verfügbarkeit erst Q2 2026). Das Objekt ist die einzige sofort verfügbare Alternative.', + date: '2025-05-14', + confidence: 0.86, + }, + { + id: 'sig-014-4', + category: 'negotiation', + title: 'Verhandlungshinweis: Mieter mit Verlängerungsoption', + body: 'Handels- und Lagerbetrieb AG hatte eine Breakoutoption, die im Juli 2024 ausgeübt wurde. Fläche wird per Ende Juni 2025 übergeben. Bei Laufzeit > 3 Jahre und frühem Abschluss hat der Eigentümer Bereitschaft zu Investitionsbeiträgen (max. CHF 25/m²) signalisiert.', + confidence: 0.76, + }, + ], + }, +] diff --git a/src/pages/supply/SupplyDashboard.tsx b/src/pages/supply/SupplyDashboard.tsx index ed5cb7f..ae63ee1 100644 --- a/src/pages/supply/SupplyDashboard.tsx +++ b/src/pages/supply/SupplyDashboard.tsx @@ -1,4 +1,4 @@ -import { Box, Alert, Button, Typography } from '@mui/material' +import { Box, Button, Typography } from '@mui/material' import { useNavigate } from 'react-router' import { useSupplyDashboard } from '../../hooks/useSupplyDashboard' import { useSessionStore } from '../../stores/sessionStore' @@ -6,21 +6,10 @@ import { UserRole } from '../../domain/enums' import { DashboardHeader, KpiGrid, - StrongMatchOverview, - DataQualityWidget, - FutureSignalWidget, ReviewTaskWidget, - QuickActionPanel, DashboardSkeleton, } from '../../components/supply' -function WidgetError({ label }: { label: string }) { - return ( - - {label} konnte nicht geladen werden. - - ) -} export default function SupplyDashboard() { const { data, isLoading, isError, refetch } = useSupplyDashboard() @@ -30,11 +19,6 @@ export default function SupplyDashboard() { const role = currentUser?.role ?? UserRole.DEMAND_USER const isReviewer = role === UserRole.REVIEWER const isOwnerViewer = role === UserRole.OWNER_VIEWER - const canSeeMatches = role !== UserRole.OWNER_VIEWER && role !== UserRole.DEMAND_USER - const canSeeOperations = - role === UserRole.SUPER_ADMIN || - role === UserRole.ORGANIZATION_ADMIN || - role === UserRole.REVIEWER if (isLoading) return @@ -98,91 +82,12 @@ export default function SupplyDashboard() { )} - {/* REVIEWER: Review Queue first, then Matches */} - {isReviewer && ( - - {data.reviewTasks !== null ? ( - navigate('/ops/review-queue')} - /> - ) : ( - - )} - {canSeeMatches && - (data.strongMatches !== null ? ( - navigate('/supply/match-center')} - /> - ) : ( - - ))} - + {isReviewer && data.reviewTasks !== null && ( + navigate('/ops/review-queue')} + /> )} - - {/* Default: Matches + Data Quality */} - {!isReviewer && canSeeMatches && ( - - {data.strongMatches !== null ? ( - navigate('/supply/match-center')} - /> - ) : ( - - )} - {data.dataQuality !== null ? ( - navigate('/supply/data-quality')} - /> - ) : ( - - )} - - )} - - {canSeeMatches && data.strongMatches?.length === 0 && ( - navigate('/demand/ai-search')}> - Bedarfsprofil erstellen - - } - > - Noch keine Matches vorhanden. Erstellen Sie ein Bedarfsprofil, um passende Objekte zu - finden. - - )} - - {!isOwnerViewer && ( - - {data.futureSignals !== null ? ( - - ) : ( - - )} - {canSeeOperations && !isReviewer ? ( - data.reviewTasks !== null ? ( - navigate('/ops/review-queue')} - /> - ) : ( - - ) - ) : !canSeeOperations && data.dataQuality !== null ? ( - navigate('/supply/data-quality')} - /> - ) : null} - - )} - - ) } diff --git a/src/services/marketReportService.ts b/src/services/marketReportService.ts new file mode 100644 index 0000000..d76dec8 --- /dev/null +++ b/src/services/marketReportService.ts @@ -0,0 +1,8 @@ +import type { MarketReport } from '../domain/marketReport' +import { mockPropertyMarketSignals } from '../mock-data/propertyMarketSignals' + +export const marketReportService = { + async getByProperty(propertyId: string): Promise { + return mockPropertyMarketSignals.find(r => r.propertyId === propertyId) ?? null + }, +} diff --git a/src/services/matchService.ts b/src/services/matchService.ts index 3b799c8..1d6d37f 100644 --- a/src/services/matchService.ts +++ b/src/services/matchService.ts @@ -2,7 +2,7 @@ import { MockupMatchProvider } from '../provider/MockupMatchProvider' import { MockupPropertyProvider } from '../provider/MockupPropertyProvider' import { MockupNeedProvider } from '../provider/MockupNeedProvider' import type { MatchFilters } from '../provider/IMatchProvider' -import type { Match } from '../domain/match' +import type { Match, PropertyNeedMatch } from '../domain/match' import type { Need } from '../domain/need' import type { Property } from '../domain/property' import type { StrongMatchItem } from '../domain/dashboard' @@ -65,6 +65,39 @@ export const matchService = { return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } }, + async getNeedMatchesForProperty( + propertyId: string, + opts?: { minScore?: number }, + ): Promise { + const minScore = opts?.minScore ?? 80 + const [matches, needs] = await Promise.all([ + provider.getByProperty(propertyId), + MockupNeedProvider.getAll(), + ]) + return matches + .filter(m => m.matchScore >= minScore) + .sort((a, b) => b.matchScore - a.matchScore) + .map(m => { + const need = needs.find(n => n.id === m.needId) + if (!need) return null + return { + matchId: m.id, + score: m.matchScore, + needId: need.id, + needTitle: `${need.companyName}`, + company: need.companyName, + areaRequired: `${need.requiredArea.min}–${need.requiredArea.max} m²`, + budget: `max ${need.budgetRange.maxPerSqm} CHF/m²/Jahr`, + locations: need.preferredLocations, + assetType: need.assetType, + timing: need.timing?.earliestMoveIn ?? '–', + mustHaves: need.mustCriteriaText ?? [], + matchHighlights: m.positiveFactors.map(f => f.explanation), + } satisfies PropertyNeedMatch + }) + .filter((x): x is PropertyNeedMatch => x !== null) + }, + async getStrongMatches(minScore = 80): Promise { const [matches, properties] = await Promise.all([ provider.getAll(),