feat: Verwaltungszugang refactor — lease data, 3-tab detail, market signals

- 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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 15:43:52 +02:00
parent 52a2693cad
commit 920ce8eb09
17 changed files with 1036 additions and 476 deletions
+5 -5
View File
@@ -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) {
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
{/* Budget */}
<FieldLabel>Budget (max CHF/m²)</FieldLabel>
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 0.75 }}>
{BUDGET_PRESETS.map(p => (
<Chip
-2
View File
@@ -20,7 +20,6 @@ import {
LayoutDashboard,
Building2,
Target,
TrendingUp,
CheckSquare,
Search,
List,
@@ -84,7 +83,6 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ 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 },
],
},
+12 -25
View File
@@ -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 (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{orgName}
</Typography>
<Chip label="Demo" size="small" color="info" variant="outlined" />
</Box>
{lastUpdated && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" size="small" onClick={() => navigate('/supply/match-center')}>
Match Center
</Button>
<Button variant="outlined" size="small" onClick={() => navigate('/supply/data-quality')}>
Datenqualität
</Button>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{orgName}
</Typography>
<Chip label="Demo" size="small" color="info" variant="outlined" />
</Box>
{lastUpdated && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
</Typography>
)}
</Box>
)
}
+1 -19
View File
@@ -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) {
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 2,
}}
>
+124
View File
@@ -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 (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: bg,
border: `2px solid ${borderColor}`,
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, color, fontSize: '0.95rem' }}>
{score}
</Typography>
</Box>
)
}
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ color: '#94a3b8', display: 'flex', alignItems: 'center' }}>{icon}</Box>
<Typography variant="caption" sx={{ color: '#64748b', minWidth: 70 }}>{label}</Typography>
<Typography variant="caption" sx={{ color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
export function NeedMatchCard({ match }: Props) {
return (
<Card
elevation={0}
sx={{
p: 2,
mb: 1.5,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
'&:hover': { borderColor: '#cbd5e1', bgcolor: '#fafafa' },
transition: 'border-color 0.15s, background-color 0.15s',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
<ScoreBadge score={match.score} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
{match.company}
</Typography>
<Chip
label={match.score >= 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',
}}
/>
</Box>
<Stack spacing={0.4} sx={{ mb: 1 }}>
<InfoRow icon={<Ruler size={12} />} label="Fläche" value={match.areaRequired} />
<InfoRow icon={<Wallet size={12} />} label="Budget" value={match.budget} />
<InfoRow
icon={<MapPin size={12} />}
label="Standorte"
value={match.locations.slice(0, 3).join(', ') + (match.locations.length > 3 ? ' …' : '')}
/>
<InfoRow icon={<Calendar size={12} />} label="Einzug ab" value={match.timing} />
</Stack>
{match.matchHighlights.length > 0 && (
<Box sx={{ mb: 1 }}>
{match.matchHighlights.slice(0, 2).map((h, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
<CheckCircle size={11} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.4 }}>{h}</Typography>
</Box>
))}
</Box>
)}
{match.mustHaves.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{match.mustHaves.slice(0, 3).map(mh => (
<Chip
key={mh}
label={mh}
size="small"
sx={{ fontSize: '0.62rem', height: 18, bgcolor: '#f0fdf4', color: '#166534', border: '1px solid #bbf7d0' }}
/>
))}
{match.mustHaves.length > 3 && (
<Typography variant="caption" sx={{ color: '#94a3b8', alignSelf: 'center' }}>
+{match.mustHaves.length - 3}
</Typography>
)}
</Stack>
)}
</Box>
</Box>
</Card>
)
}
+231 -223
View File
@@ -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 (
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="caption" color="text.disabled"></Typography>
</Box>
<LinearProgress variant="determinate" value={0} sx={{ height: 4, borderRadius: 2, opacity: 0.3 }} />
</Box>
)
}
const pct = value > 1 ? value : value * 100
return (
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>{Math.round(pct)}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(100, pct)}
color={pct >= 70 ? 'success' : pct >= 40 ? 'warning' : 'error'}
sx={{ height: 4, borderRadius: 2 }}
/>
</Box>
)
// ── Ü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 (
<Box>
{/* Key metrics */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
{[
{ label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')}` },
{ 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 }) => (
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
@@ -118,16 +102,95 @@ function OverviewPanel({ p }: { p: Property }) {
</Box>
))}
</Box>
{p.description && (
<>
{/* Description */}
{editing ? (
<Box sx={{ mb: 2 }}>
<SectionTitle title="Beschreibung" />
<Typography variant="body2" sx={{ lineHeight: 1.6, color: 'text.secondary', mb: 2 }}>
<TextField
multiline
rows={3}
fullWidth
size="small"
value={draft.description ?? p.description ?? ''}
onChange={e => onDraftChange({ ...draft, description: e.target.value })}
placeholder="Beschreibung des Objekts…"
/>
</Box>
) : p.description ? (
<Box sx={{ mb: 2 }}>
<SectionTitle title="Beschreibung" />
<Typography variant="body2" sx={{ lineHeight: 1.6, color: 'text.secondary' }}>
{p.description}
</Typography>
</>
</Box>
) : null}
{/* Lease & Tenant */}
<SectionTitle title="Miet- & Mieterinformationen" />
{editing ? (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1 }}>
<TextField
label="Aktueller Mieter"
size="small"
value={draft.currentTenant ?? p.currentTenant ?? ''}
onChange={e => onDraftChange({ ...draft, currentTenant: e.target.value })}
/>
<TextField
label="Mietlaufzeit"
size="small"
value={draft.leaseTerm ?? p.leaseTerm ?? ''}
onChange={e => onDraftChange({ ...draft, leaseTerm: e.target.value })}
placeholder="z.B. 5 Jahre"
/>
<TextField
label="Mietbeginn"
size="small"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
value={draft.leaseStartDate ?? p.leaseStartDate ?? ''}
onChange={e => onDraftChange({ ...draft, leaseStartDate: e.target.value })}
/>
<TextField
label="Mietende"
size="small"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
value={draft.leaseEndDate ?? p.leaseEndDate ?? ''}
onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })}
/>
</Box>
</Box>
) : (
<FieldGrid>
<Field label="Aktueller Mieter" value={p.currentTenant} />
<Field label="Mietlaufzeit" value={p.leaseTerm} />
<Field label="Mietbeginn" value={p.leaseStartDate ? new Date(p.leaseStartDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Mietende" value={p.leaseEndDate ? new Date(p.leaseEndDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Breakoutoption" value={p.breakoutOption} />
<Field label="Breakoutoption Zeitpunkt" value={p.breakoutOptionDate ? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Importiert aus" value={p.importedFrom} />
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
</FieldGrid>
)}
<Divider sx={{ my: 2 }} />
{/* Object details */}
<SectionTitle title="Objekt & Lage" />
<FieldGrid>
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
</FieldGrid>
{/* Map */}
{p.location.coordinates && (
<Box sx={{ mb: 2.5, borderRadius: 1, overflow: 'hidden', border: '1px solid #e2e8f0' }}>
<Box sx={{ mb: 2.5, mt: 1.5, borderRadius: 1, overflow: 'hidden', border: '1px solid #e2e8f0' }}>
<Box sx={{ px: 1.5, pt: 1.25, pb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Standort
@@ -144,6 +207,10 @@ function OverviewPanel({ p }: { p: Property }) {
/>
</Box>
)}
<Divider sx={{ my: 1.5 }} />
{/* Data quality */}
<SectionTitle title="Datenqualität" />
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">Score</Typography>
@@ -166,176 +233,83 @@ function OverviewPanel({ p }: { p: Property }) {
)
}
function HardFactsPanel({ p }: { p: Property }) {
const hf = p.hardFacts
return (
<Box>
<SectionTitle title="Standort & Objekt" />
<FieldGrid>
<Field label="Titel" value={p.title} />
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
<Field label="Strasse" value={`${p.address.street} ${p.address.houseNumber}`} />
<Field label="PLZ / Stadt" value={`${p.address.postalCode} ${p.address.city}`} />
<Field label="Kanton" value={p.location.canton} />
<Field label="Region" value={p.location.region} />
<Field label="Land" value={p.address.country} />
<Field label="Nutzungsart" value={hf?.usageType} />
</FieldGrid>
<Divider sx={{ my: 2 }} />
<SectionTitle title="Fläche & Miete" />
<FieldGrid>
<Field label="Fläche (m²)" value={`${p.areaSqm.toLocaleString('de-CH')}`} />
<Field label="Fläche Min (m²)" value={p.areaSqmMin ? `${p.areaSqmMin.toLocaleString('de-CH')}` : undefined} />
<Field label="Fläche Max (m²)" value={p.areaSqmMax ? `${p.areaSqmMax.toLocaleString('de-CH')}` : undefined} />
<Field label="Miete/m²/Jahr" value={p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : undefined} />
<Field label="Jahresmiete (CHF/m²)" value={p.rentChfSqmYear ? `CHF ${p.rentChfSqmYear}` : undefined} />
<Field label="Monatsmiete gesamt" value={p.totalRentMonthly ? `CHF ${p.totalRentMonthly.toLocaleString('de-CH')}` : undefined} />
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
<Field label="Vertragsdauer" value={p.contractDurationMonths ? `${p.contractDurationMonths} Monate` : undefined} />
</FieldGrid>
<Divider sx={{ my: 2 }} />
<SectionTitle title="Verfügbarkeit" />
<FieldGrid>
<Field label="Status" value={getAvailabilityLabel(p.availabilityStatus)} />
<Field label="Verfügbar ab" value={p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Verfügbarkeitstyp" value={p.availabilityType} />
</FieldGrid>
{hf && (
<>
<Divider sx={{ my: 2 }} />
<SectionTitle title="Technische Details" />
<FieldGrid>
<Field label="Etage" value={hf.floor ?? p.floorLevel} />
<Field label="Ausbaustandard" value={hf.fitOut} />
<Field label="Parkplätze" value={hf.parking} />
<Field label="ÖV-Score" value={hf.publicTransportScore} />
<Field label="Deckenhöhe (m)" value={hf.ceilingHeightM} />
<Field label="Laderampen" value={hf.loadingDocksCount} />
<Field label="Stromanschluss (kVA)" value={hf.powerSupplyKva} />
<Field label="Serverraum" value={hf.hasServerRoom} />
<Field label="Barrierefrei" value={hf.isBarrierFree} />
</FieldGrid>
</>
)}
</Box>
)
}
// ── Matchability tab ──────────────────────────────────────────────────────────
function SoftFactorsPanel({ p }: { p: Property }) {
const sf = p.softFactors
if (!sf) {
return <Typography variant="body2" color="text.secondary">Keine Soft Factors vorhanden.</Typography>
}
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<PropertyNeedMatch[]>([])
const [loading, setLoading] = useState(true)
return (
<Box>
<SectionTitle title="Weiche Faktoren" />
<ScoreRow label="Prestige / Lage" value={prestige} />
<ScoreRow label="Sichtbarkeit" value={visibility} />
<ScoreRow label="Passantenfrequenz" value={footfall} />
<ScoreRow label="Pendlerzugang" value={commuter} />
<ScoreRow label="Talentpool-Zugang" value={talent} />
<ScoreRow label="ESG-Score" value={esg} />
<ScoreRow label="Flexibilität" value={flex} />
<ScoreRow label="Expansionspotenzial" value={expansion} />
<ScoreRow label="Steuerumfeld" value={tax} />
{sf.parkingSpots !== undefined && (
<Field label="Parkplätze" value={sf.parkingSpots} />
)}
{sf.publicTransportMinutes !== undefined && (
<Field label="ÖV-Erreichbarkeit (Min.)" value={sf.publicTransportMinutes} />
)}
{sf.infrastructureNotes && (
<Field label="Infrastruktur-Notizen" value={sf.infrastructureNotes} />
)}
</Box>
)
}
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 (
<Typography variant="body2" color="text.secondary">
Keine Matches für dieses Objekt vorhanden.
</Typography>
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box>
<SectionTitle title={`${matches.length} Matches gefunden`} />
{matches.map(m => (
<Box key={m.id} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Bedarf: {m.needId}</Typography>
<Chip
label={`Score ${m.matchScore}`}
size="small"
sx={{
fontWeight: 700,
bgcolor: m.matchScore >= 80 ? '#1a7a4a' : m.matchScore >= 60 ? '#d97706' : '#c0392b',
color: 'white',
}}
/>
</Box>
{m.positiveFactors?.slice(0, 3).map((f, i) => (
<Typography key={i} variant="caption" sx={{ display: 'block', color: '#1a7a4a' }}> {f.explanation ?? f.criterion}</Typography>
))}
{m.missingData && m.missingData.length > 0 && (
<Typography variant="caption" sx={{ color: 'warning.main', display: 'block', mt: 0.5 }}>
{m.missingData.length} fehlende Datenfelder
</Typography>
)}
</Box>
))}
</Box>
)
}
function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
if (signals.length === 0) {
return <Typography variant="body2" color="text.secondary">Keine Zukunftssignale für dieses Objekt.</Typography>
}
return (
<Box>
<SectionTitle title={`${signals.length} Zukunftssignale`} />
{signals.map(s => (
<Box key={s.id} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.25 }}>
{s.title ?? s.signalType}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{s.locationHint} · Konfidenz {Math.round(s.confidenceScore * 100)}% · {s.timeHorizonMonths} Monate
</Typography>
{s.disclaimer && (
<Typography variant="caption" sx={{ color: 'text.disabled', display: 'block', mt: 0.5 }}>
{s.disclaimer}
</Typography>
)}
</Box>
))}
<Box sx={{ p: 2.5 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a' }}>
Matchability
</Typography>
<Typography variant="caption" color="text.secondary">
Bedarfsprofile mit 80% Match-Score für dieses Objekt
</Typography>
</Box>
{matches.length === 0 ? (
<Alert severity="info" sx={{ fontSize: '0.8125rem' }}>
Keine Bedarfsprofile mit 80% Match-Score für dieses Objekt gefunden.
</Alert>
) : (
matches.map(m => <NeedMatchCard key={m.matchId} match={m} />)
)}
</Box>
)
}
// ── 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<UpdatePropertyInput>({})
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 <PropertyDetailSkeleton />
if (!property) {
@@ -355,16 +329,53 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
<Chip label={getAssetTypeLabel(property.assetType)} size="small" sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: '0.68rem' }} />
<Chip label={getResultTypeLabel(property.resultType)} size="small" sx={{ bgcolor: getResultTypeColor(property.resultType), color: 'white', fontSize: '0.68rem' }} />
<Chip label={getAvailabilityLabel(property.availabilityStatus)} size="small" color={getAvailabilityChipColor(property.availabilityStatus)} variant="outlined" />
<Chip
label={getAssetTypeLabel(property.assetType)}
size="small"
sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: '0.68rem' }}
/>
<Chip
label={getAvailabilityLabel(property.availabilityStatus)}
size="small"
color={getAvailabilityChipColor(property.availabilityStatus)}
variant="outlined"
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 1 }}>
{tab === 0 && !editing && (
<IconButton size="small" onClick={startEdit} title="Bearbeiten">
<Edit2 size={15} />
</IconButton>
)}
{tab === 0 && editing && (
<>
<Button
size="small"
variant="contained"
startIcon={saving ? <CircularProgress size={12} sx={{ color: 'white' }} /> : <Save size={13} />}
onClick={saveEdit}
disabled={saving}
sx={{ textTransform: 'none', fontSize: '0.75rem', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Speichern
</Button>
<Button
size="small"
onClick={() => { setEditing(false); setDraft({}) }}
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
>
Abbrechen
</Button>
</>
)}
{onClose && (
<IconButton size="small" onClick={onClose}>
<X size={16} />
</IconButton>
)}
</Box>
{onClose && (
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, ml: 1 }}>
<X size={16} />
</IconButton>
)}
</Box>
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, mb: 0.25 }}>
{property.title}
</Typography>
@@ -384,7 +395,7 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
)}
</Box>
{/* Photo only — map moved into Übersicht tab */}
{/* Photo */}
{property.images?.[0] && (
<Box sx={{ flexShrink: 0, height: 160, overflow: 'hidden' }}>
<img
@@ -399,14 +410,10 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
<Tabs
value={tab}
onChange={(_, v) => 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
</Tabs>
{/* Tab content */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
{tab === 0 && <OverviewPanel p={property} />}
{tab === 1 && <NegotiationInsightsPanel property={property} />}
{tab === 2 && <HardFactsPanel p={property} />}
{tab === 3 && <SoftFactorsPanel p={property} />}
{tab === 4 && <MatchabilityPanel matches={matches} />}
{tab === 5 && <DQPanel property={property} />}
{tab === 6 && <ProvenancePanel property={property} />}
{tab === 7 && <SignalsPanel signals={signals} />}
{tab === 8 && <PropertyActivityLogPanel propertyId={propertyId} />}
<Box sx={{ flex: 1, overflowY: 'auto', p: tab === 0 ? 2.5 : 0 }}>
{tab === 0 && (
<OverviewPanel
p={property}
editing={editing}
draft={draft}
onDraftChange={setDraft}
/>
)}
{tab === 1 && <MatchabilityTabPanel propertyId={propertyId} />}
{tab === 2 && <PropertyMarketSignalsTab propertyId={propertyId} />}
</Box>
</Box>
)
@@ -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: <Building2 size={16} />, color: '#1e3a5f' },
{ category: 'demand', label: 'Nachfrage 5 km', icon: <TrendingUp size={16} />, color: '#1a7a4a' },
{ category: 'supply', label: 'Angebot 5 km', icon: <BarChart2 size={16} />, color: '#b45309' },
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
]
function SignalCard({ signal }: { signal: PropertyMarketSignal }) {
return (
<Box
sx={{
p: 1.75,
mb: 1.25,
borderRadius: 1.5,
border: '1px solid #e2e8f0',
bgcolor: 'white',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1, mb: 0.75 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.35 }}>
{signal.title}
</Typography>
{signal.confidence != null && (
<Chip
label={`${Math.round(signal.confidence * 100)}%`}
size="small"
sx={{ fontSize: '0.62rem', height: 18, flexShrink: 0, bgcolor: '#f0f9ff', color: '#0369a1', border: '1px solid #bae6fd' }}
/>
)}
</Box>
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.5, display: 'block', mb: signal.source || signal.confidence ? 1 : 0 }}>
{signal.body}
</Typography>
{signal.confidence != null && (
<Box sx={{ mb: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.62rem' }}>KI-Konfidenz</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.62rem' }}>{Math.round(signal.confidence * 100)}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={signal.confidence * 100}
sx={{
height: 3,
borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: signal.confidence >= 0.8 ? '#1a7a4a' : signal.confidence >= 0.6 ? '#d97706' : '#64748b' },
}}
/>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{signal.date && (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.62rem' }}>
{new Date(signal.date).toLocaleDateString('de-CH')}
</Typography>
)}
{signal.source && (
<Box
component={signal.sourceUrl ? 'a' : 'span'}
href={signal.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.375,
fontSize: '0.62rem',
color: signal.sourceUrl ? '#1e3a5f' : '#94a3b8',
textDecoration: 'none',
'&:hover': signal.sourceUrl ? { textDecoration: 'underline' } : {},
}}
>
<FileText size={10} />
{signal.source}
</Box>
)}
</Box>
</Box>
)
}
export function PropertyMarketSignalsTab({ propertyId }: Props) {
const [report, setReport] = useState<MarketReport | null>(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 (
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a' }}>
Marktsignale & Standortintelligenz
</Typography>
{report?.generatedAt && (
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
Generiert {new Date(report.generatedAt).toLocaleDateString('de-CH')}
</Typography>
)}
</Box>
<Button
variant="outlined"
size="small"
startIcon={<FileText size={14} />}
onClick={() => showToast('PDF-Bericht wird generiert…', 'info')}
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
>
Bericht erstellen
</Button>
</Box>
{!report ? (
<Alert severity="info" sx={{ fontSize: '0.8125rem' }}>
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
</Alert>
) : (
SECTION_CONFIG.map(({ category, label, icon, color }) => {
const signals = report.signals.filter(s => s.category === category)
if (signals.length === 0) return null
return (
<Box key={category} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
<Box sx={{ color }}>{icon}</Box>
<Typography variant="body2" sx={{ fontWeight: 700, color }}>
{label}
</Typography>
<Chip
label={signals.length}
size="small"
sx={{ height: 18, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#64748b' }}
/>
</Box>
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
</Box>
)
})
)}
</Box>
)
}
+43 -37
View File
@@ -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({
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Standort</Typography></TableCell>
<TableCell><SortableHeader field="area" label="Fläche (m²)" /></TableCell>
<TableCell><SortableHeader field="rent" label="Miete/m²" /></TableCell>
<TableCell><SortableHeader field="availability" label="Verfügbarkeit" /></TableCell>
<TableCell><SortableHeader field="confidence" label="Konfidenz" /></TableCell>
<TableCell><SortableHeader field="rent" label="Miete CHF/m²/Jahr" /></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktueller Mieter</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Mietlaufzeit</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption Zeitpunkt</Typography></TableCell>
<TableCell><SortableHeader field="dataQuality" label="Datenqualität" /></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Quelle</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell>
</TableRow>
</TableHead>
@@ -121,7 +119,7 @@ export function PropertyTable({
<LoadingRows />
) : properties.length === 0 ? (
<TableRow>
<TableCell colSpan={10}>
<TableCell colSpan={11}>
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 4 }}>
Keine Objekte gefunden.
</Typography>
@@ -184,30 +182,47 @@ export function PropertyTable({
{/* Miete */}
<TableCell>
<Typography variant="body2">
{p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : <span style={{ color: '#94a3b8' }}>k.A.</span>}
{p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : <span style={{ color: '#94a3b8' }}>k.A.</span>}
</Typography>
</TableCell>
{/* Verfügbarkeit */}
{/* Aktueller Mieter */}
<TableCell>
<Chip
label={getAvailabilityLabel(p.availabilityStatus)}
size="small"
color={getAvailabilityChipColor(p.availabilityStatus)}
variant="outlined"
/>
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
{p.currentTenant ?? <span style={{ color: '#94a3b8' }}></span>}
</Typography>
</TableCell>
{/* Konfidenz */}
{/* Mietlaufzeit */}
<TableCell>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: p.confidenceScore >= 0.85 ? '#1a7a4a' : p.confidenceScore >= 0.65 ? '#1e3a5f' : '#d97706',
}}
>
{Math.round(p.confidenceScore * 100)}%
<Typography variant="body2">
{p.leaseTerm ?? <span style={{ color: '#94a3b8' }}></span>}
</Typography>
</TableCell>
{/* Breakoutoption */}
<TableCell>
{p.breakoutOption == null
? <Typography variant="body2" sx={{ color: '#94a3b8' }}></Typography>
: <Chip
label={p.breakoutOption ? 'Ja' : 'Nein'}
size="small"
sx={{
bgcolor: p.breakoutOption ? '#dcfce7' : '#f1f5f9',
color: p.breakoutOption ? '#166534' : '#64748b',
fontSize: '0.68rem',
}}
/>
}
</TableCell>
{/* Breakoutoption Zeitpunkt */}
<TableCell>
<Typography variant="body2">
{p.breakoutOptionDate
? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH')
: <span style={{ color: '#94a3b8' }}></span>
}
</Typography>
</TableCell>
@@ -231,15 +246,6 @@ export function PropertyTable({
</Tooltip>
</TableCell>
{/* Quelle */}
<TableCell>
<Chip
label={getResultTypeLabel(p.resultType)}
size="small"
sx={{ bgcolor: getResultTypeColor(p.resultType), color: 'white', fontSize: '0.68rem' }}
/>
</TableCell>
{/* Aktionen */}
<TableCell onClick={e => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.25 }}>
+18
View File
@@ -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[]
}
+18 -1
View File
@@ -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 ─────────────────────────────────────────────────────
+10
View File
@@ -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
+10 -10
View File
@@ -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',
+135 -52
View File
@@ -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',
+210
View File
@@ -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 (650950 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 58% 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 400550/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 400900 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 336420/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 480600/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 ZugZü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 150200/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,
},
],
},
]
+6 -101
View File
@@ -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 (
<Alert severity="error" sx={{ height: '100%' }}>
{label} konnte nicht geladen werden.
</Alert>
)
}
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 <DashboardSkeleton />
@@ -98,91 +82,12 @@ export default function SupplyDashboard() {
<KpiGrid data={data} />
)}
{/* REVIEWER: Review Queue first, then Matches */}
{isReviewer && (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
{data.reviewTasks !== null ? (
<ReviewTaskWidget
tasks={data.reviewTasks}
onNavigate={() => navigate('/ops/review-queue')}
/>
) : (
<WidgetError label="Review Tasks" />
)}
{canSeeMatches &&
(data.strongMatches !== null ? (
<StrongMatchOverview
matches={data.strongMatches}
onNavigate={() => navigate('/supply/match-center')}
/>
) : (
<WidgetError label="Starke Matches" />
))}
</Box>
{isReviewer && data.reviewTasks !== null && (
<ReviewTaskWidget
tasks={data.reviewTasks}
onNavigate={() => navigate('/ops/review-queue')}
/>
)}
{/* Default: Matches + Data Quality */}
{!isReviewer && canSeeMatches && (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
{data.strongMatches !== null ? (
<StrongMatchOverview
matches={data.strongMatches}
onNavigate={() => navigate('/supply/match-center')}
/>
) : (
<WidgetError label="Starke Matches" />
)}
{data.dataQuality !== null ? (
<DataQualityWidget
summary={data.dataQuality}
onNavigate={() => navigate('/supply/data-quality')}
/>
) : (
<WidgetError label="Datenqualität" />
)}
</Box>
)}
{canSeeMatches && data.strongMatches?.length === 0 && (
<Alert
severity="info"
action={
<Button size="small" onClick={() => navigate('/demand/ai-search')}>
Bedarfsprofil erstellen
</Button>
}
>
Noch keine Matches vorhanden. Erstellen Sie ein Bedarfsprofil, um passende Objekte zu
finden.
</Alert>
)}
{!isOwnerViewer && (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
{data.futureSignals !== null ? (
<FutureSignalWidget summary={data.futureSignals} />
) : (
<WidgetError label="Marktsignale" />
)}
{canSeeOperations && !isReviewer ? (
data.reviewTasks !== null ? (
<ReviewTaskWidget
tasks={data.reviewTasks}
onNavigate={() => navigate('/ops/review-queue')}
/>
) : (
<WidgetError label="Review Tasks" />
)
) : !canSeeOperations && data.dataQuality !== null ? (
<DataQualityWidget
summary={data.dataQuality}
onNavigate={() => navigate('/supply/data-quality')}
/>
) : null}
</Box>
)}
<QuickActionPanel />
</Box>
)
}
+8
View File
@@ -0,0 +1,8 @@
import type { MarketReport } from '../domain/marketReport'
import { mockPropertyMarketSignals } from '../mock-data/propertyMarketSignals'
export const marketReportService = {
async getByProperty(propertyId: string): Promise<MarketReport | null> {
return mockPropertyMarketSignals.find(r => r.propertyId === propertyId) ?? null
},
}
+34 -1
View File
@@ -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<PropertyNeedMatch[]> {
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}`,
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<StrongMatchItem[]> {
const [matches, properties] = await Promise.all([
provider.getAll(),