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:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,10 +17,7 @@ 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 }}>
|
||||
@@ -35,14 +31,5 @@ export function DashboardHeader({ orgName = 'Demo Organisation', 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')} 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 }) => (
|
||||
<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
|
||||
// ── Matchability tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function MatchabilityTabPanel({ propertyId }: { propertyId: string }) {
|
||||
const [matches, setMatches] = useState<PropertyNeedMatch[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
matchService.getNeedMatchesForProperty(propertyId, { minScore: 80 }).then(result => {
|
||||
if (!cancelled) { setMatches(result); setLoading(false) }
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [propertyId])
|
||||
|
||||
if (loading) {
|
||||
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')} m²`} />
|
||||
<Field label="Fläche Min (m²)" value={p.areaSqmMin ? `${p.areaSqmMin.toLocaleString('de-CH')} m²` : undefined} />
|
||||
<Field label="Fläche Max (m²)" value={p.areaSqmMax ? `${p.areaSqmMax.toLocaleString('de-CH')} m²` : 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 sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchabilityPanel({ matches }: { matches: Match[] }) {
|
||||
if (matches.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Matches für dieses Objekt vorhanden.
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
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}
|
||||
<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" sx={{ display: 'block' }}>
|
||||
{s.locationHint} · Konfidenz {Math.round(s.confidenceScore * 100)}% · {s.timeHorizonMonths} Monate
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Bedarfsprofile mit ≥ 80% Match-Score für dieses Objekt
|
||||
</Typography>
|
||||
{s.disclaimer && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', display: 'block', mt: 0.5 }}>
|
||||
{s.disclaimer}
|
||||
</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} sx={{ flexShrink: 0, ml: 1 }}>
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
<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={{
|
||||
fontWeight: 600,
|
||||
color: p.confidenceScore >= 0.85 ? '#1a7a4a' : p.confidenceScore >= 0.65 ? '#1e3a5f' : '#d97706',
|
||||
bgcolor: p.breakoutOption ? '#dcfce7' : '#f1f5f9',
|
||||
color: p.breakoutOption ? '#166534' : '#64748b',
|
||||
fontSize: '0.68rem',
|
||||
}}
|
||||
>
|
||||
{Math.round(p.confidenceScore * 100)}%
|
||||
/>
|
||||
}
|
||||
</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 }}>
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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 ? (
|
||||
{isReviewer && 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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} 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<StrongMatchItem[]> {
|
||||
const [matches, properties] = await Promise.all([
|
||||
provider.getAll(),
|
||||
|
||||
Reference in New Issue
Block a user