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,31 +17,19 @@ function formatLastUpdated(iso: string): string {
|
||||
}
|
||||
|
||||
export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }: DashboardHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{orgName}
|
||||
</Typography>
|
||||
<Chip label="Demo" size="small" color="info" variant="outlined" />
|
||||
</Box>
|
||||
{lastUpdated && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="contained" size="small" onClick={() => navigate('/supply/match-center')}>
|
||||
Match Center
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" onClick={() => navigate('/supply/data-quality')}>
|
||||
Datenqualität
|
||||
</Button>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{orgName}
|
||||
</Typography>
|
||||
<Chip label="Demo" size="small" color="info" variant="outlined" />
|
||||
</Box>
|
||||
{lastUpdated && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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>
|
||||
)
|
||||
}
|
||||
// ── Matchability tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function SoftFactorsPanel({ p }: { p: Property }) {
|
||||
const sf = p.softFactors
|
||||
if (!sf) {
|
||||
return <Typography variant="body2" color="text.secondary">Keine Soft Factors vorhanden.</Typography>
|
||||
}
|
||||
const prestige = sf.prestigeScore ?? sf.prestige
|
||||
const visibility = sf.visibilityScore
|
||||
const footfall = sf.footfallScore
|
||||
const commuter = sf.commuterAccessScore ?? sf.accessibility
|
||||
const talent = sf.talentAccessScore ?? sf.talentAccess
|
||||
const esg = sf.esgScore
|
||||
const flex = sf.flexibilityScore
|
||||
const expansion = sf.expansionPotentialScore
|
||||
const tax = sf.taxEnvironmentScore
|
||||
function MatchabilityTabPanel({ propertyId }: { propertyId: string }) {
|
||||
const [matches, setMatches] = useState<PropertyNeedMatch[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title="Weiche Faktoren" />
|
||||
<ScoreRow label="Prestige / Lage" value={prestige} />
|
||||
<ScoreRow label="Sichtbarkeit" value={visibility} />
|
||||
<ScoreRow label="Passantenfrequenz" value={footfall} />
|
||||
<ScoreRow label="Pendlerzugang" value={commuter} />
|
||||
<ScoreRow label="Talentpool-Zugang" value={talent} />
|
||||
<ScoreRow label="ESG-Score" value={esg} />
|
||||
<ScoreRow label="Flexibilität" value={flex} />
|
||||
<ScoreRow label="Expansionspotenzial" value={expansion} />
|
||||
<ScoreRow label="Steuerumfeld" value={tax} />
|
||||
{sf.parkingSpots !== undefined && (
|
||||
<Field label="Parkplätze" value={sf.parkingSpots} />
|
||||
)}
|
||||
{sf.publicTransportMinutes !== undefined && (
|
||||
<Field label="ÖV-Erreichbarkeit (Min.)" value={sf.publicTransportMinutes} />
|
||||
)}
|
||||
{sf.infrastructureNotes && (
|
||||
<Field label="Infrastruktur-Notizen" value={sf.infrastructureNotes} />
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
matchService.getNeedMatchesForProperty(propertyId, { minScore: 80 }).then(result => {
|
||||
if (!cancelled) { setMatches(result); setLoading(false) }
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [propertyId])
|
||||
|
||||
function MatchabilityPanel({ matches }: { matches: Match[] }) {
|
||||
if (matches.length === 0) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Matches für dieses Objekt vorhanden.
|
||||
</Typography>
|
||||
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title={`${matches.length} Matches gefunden`} />
|
||||
{matches.map(m => (
|
||||
<Box key={m.id} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Bedarf: {m.needId}</Typography>
|
||||
<Chip
|
||||
label={`Score ${m.matchScore}`}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: m.matchScore >= 80 ? '#1a7a4a' : m.matchScore >= 60 ? '#d97706' : '#c0392b',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{m.positiveFactors?.slice(0, 3).map((f, i) => (
|
||||
<Typography key={i} variant="caption" sx={{ display: 'block', color: '#1a7a4a' }}>✓ {f.explanation ?? f.criterion}</Typography>
|
||||
))}
|
||||
{m.missingData && m.missingData.length > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'warning.main', display: 'block', mt: 0.5 }}>
|
||||
⚠ {m.missingData.length} fehlende Datenfelder
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
|
||||
if (signals.length === 0) {
|
||||
return <Typography variant="body2" color="text.secondary">Keine Zukunftssignale für dieses Objekt.</Typography>
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle title={`${signals.length} Zukunftssignale`} />
|
||||
{signals.map(s => (
|
||||
<Box key={s.id} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.25 }}>
|
||||
{s.title ?? s.signalType}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{s.locationHint} · Konfidenz {Math.round(s.confidenceScore * 100)}% · {s.timeHorizonMonths} Monate
|
||||
</Typography>
|
||||
{s.disclaimer && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', display: 'block', mt: 0.5 }}>
|
||||
{s.disclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
Matchability
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Bedarfsprofile mit ≥ 80% Match-Score für dieses Objekt
|
||||
</Typography>
|
||||
</Box>
|
||||
{matches.length === 0 ? (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8125rem' }}>
|
||||
Keine Bedarfsprofile mit ≥ 80% Match-Score für dieses Objekt gefunden.
|
||||
</Alert>
|
||||
) : (
|
||||
matches.map(m => <NeedMatchCard key={m.matchId} match={m} />)
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = ['Übersicht', 'Verhandlung', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale', 'Aktivitätslog'] as const
|
||||
const TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const
|
||||
|
||||
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
|
||||
const [tab, setTab] = useState(0)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<UpdatePropertyInput>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||
const { data: matches = [] } = usePropertyMatches(propertyId)
|
||||
const { data: signals = [] } = usePropertySignals(propertyId)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
function startEdit() {
|
||||
setDraft({})
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!property) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await propertyService.update(property.id, draft)
|
||||
await queryClient.invalidateQueries({ queryKey: ['property', propertyId] })
|
||||
setEditing(false)
|
||||
setDraft({})
|
||||
showToast('Objekt gespeichert.', 'success')
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern.', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <PropertyDetailSkeleton />
|
||||
if (!property) {
|
||||
@@ -355,16 +329,53 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Chip label={getAssetTypeLabel(property.assetType)} size="small" sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: '0.68rem' }} />
|
||||
<Chip label={getResultTypeLabel(property.resultType)} size="small" sx={{ bgcolor: getResultTypeColor(property.resultType), color: 'white', fontSize: '0.68rem' }} />
|
||||
<Chip label={getAvailabilityLabel(property.availabilityStatus)} size="small" color={getAvailabilityChipColor(property.availabilityStatus)} variant="outlined" />
|
||||
<Chip
|
||||
label={getAssetTypeLabel(property.assetType)}
|
||||
size="small"
|
||||
sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: '0.68rem' }}
|
||||
/>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(property.availabilityStatus)}
|
||||
size="small"
|
||||
color={getAvailabilityChipColor(property.availabilityStatus)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 1 }}>
|
||||
{tab === 0 && !editing && (
|
||||
<IconButton size="small" onClick={startEdit} title="Bearbeiten">
|
||||
<Edit2 size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
{tab === 0 && editing && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={12} sx={{ color: 'white' }} /> : <Save size={13} />}
|
||||
onClick={saveEdit}
|
||||
disabled={saving}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => { setEditing(false); setDraft({}) }}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{onClose && (
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{onClose && (
|
||||
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, ml: 1 }}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, mb: 0.25 }}>
|
||||
{property.title}
|
||||
</Typography>
|
||||
@@ -384,7 +395,7 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Photo only — map moved into Übersicht tab */}
|
||||
{/* Photo */}
|
||||
{property.images?.[0] && (
|
||||
<Box sx={{ flexShrink: 0, height: 160, overflow: 'hidden' }}>
|
||||
<img
|
||||
@@ -399,14 +410,10 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons
|
||||
allowScrollButtonsMobile
|
||||
sx={{
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
flexShrink: 0,
|
||||
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto', px: 1.5 },
|
||||
'& .MuiTabScrollButton-root': { width: 28 },
|
||||
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto', px: 2 },
|
||||
}}
|
||||
>
|
||||
{TABS.map(label => (
|
||||
@@ -415,16 +422,17 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
</Tabs>
|
||||
|
||||
{/* Tab content */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
||||
{tab === 0 && <OverviewPanel p={property} />}
|
||||
{tab === 1 && <NegotiationInsightsPanel property={property} />}
|
||||
{tab === 2 && <HardFactsPanel p={property} />}
|
||||
{tab === 3 && <SoftFactorsPanel p={property} />}
|
||||
{tab === 4 && <MatchabilityPanel matches={matches} />}
|
||||
{tab === 5 && <DQPanel property={property} />}
|
||||
{tab === 6 && <ProvenancePanel property={property} />}
|
||||
{tab === 7 && <SignalsPanel signals={signals} />}
|
||||
{tab === 8 && <PropertyActivityLogPanel propertyId={propertyId} />}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: tab === 0 ? 2.5 : 0 }}>
|
||||
{tab === 0 && (
|
||||
<OverviewPanel
|
||||
p={property}
|
||||
editing={editing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
{tab === 1 && <MatchabilityTabPanel propertyId={propertyId} />}
|
||||
{tab === 2 && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, Box, Button, Chip, CircularProgress, LinearProgress, Typography } from '@mui/material'
|
||||
import { BarChart2, Building2, FileText, Lightbulb, TrendingUp } from 'lucide-react'
|
||||
import { marketReportService } from '../../services/marketReportService'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { MarketReport, PropertyMarketSignal, SignalCategory } from '../../domain/marketReport'
|
||||
|
||||
interface Props {
|
||||
propertyId: string
|
||||
}
|
||||
|
||||
const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
{ category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' },
|
||||
{ category: 'demand', label: 'Nachfrage 5 km', icon: <TrendingUp size={16} />, color: '#1a7a4a' },
|
||||
{ category: 'supply', label: 'Angebot 5 km', icon: <BarChart2 size={16} />, color: '#b45309' },
|
||||
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
|
||||
]
|
||||
|
||||
function SignalCard({ signal }: { signal: PropertyMarketSignal }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.75,
|
||||
mb: 1.25,
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
bgcolor: 'white',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1, mb: 0.75 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.35 }}>
|
||||
{signal.title}
|
||||
</Typography>
|
||||
{signal.confidence != null && (
|
||||
<Chip
|
||||
label={`${Math.round(signal.confidence * 100)}%`}
|
||||
size="small"
|
||||
sx={{ fontSize: '0.62rem', height: 18, flexShrink: 0, bgcolor: '#f0f9ff', color: '#0369a1', border: '1px solid #bae6fd' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.5, display: 'block', mb: signal.source || signal.confidence ? 1 : 0 }}>
|
||||
{signal.body}
|
||||
</Typography>
|
||||
|
||||
{signal.confidence != null && (
|
||||
<Box sx={{ mb: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.62rem' }}>KI-Konfidenz</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.62rem' }}>{Math.round(signal.confidence * 100)}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={signal.confidence * 100}
|
||||
sx={{
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
bgcolor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: signal.confidence >= 0.8 ? '#1a7a4a' : signal.confidence >= 0.6 ? '#d97706' : '#64748b' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{signal.date && (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.62rem' }}>
|
||||
{new Date(signal.date).toLocaleDateString('de-CH')}
|
||||
</Typography>
|
||||
)}
|
||||
{signal.source && (
|
||||
<Box
|
||||
component={signal.sourceUrl ? 'a' : 'span'}
|
||||
href={signal.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.375,
|
||||
fontSize: '0.62rem',
|
||||
color: signal.sourceUrl ? '#1e3a5f' : '#94a3b8',
|
||||
textDecoration: 'none',
|
||||
'&:hover': signal.sourceUrl ? { textDecoration: 'underline' } : {},
|
||||
}}
|
||||
>
|
||||
<FileText size={10} />
|
||||
{signal.source}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||
const [report, setReport] = useState<MarketReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
marketReportService.getByProperty(propertyId).then(r => {
|
||||
if (!cancelled) { setReport(r); setLoading(false) }
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [propertyId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
Marktsignale & Standortintelligenz
|
||||
</Typography>
|
||||
{report?.generatedAt && (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
Generiert {new Date(report.generatedAt).toLocaleDateString('de-CH')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<FileText size={14} />}
|
||||
onClick={() => showToast('PDF-Bericht wird generiert…', 'info')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
|
||||
>
|
||||
Bericht erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!report ? (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8125rem' }}>
|
||||
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
|
||||
</Alert>
|
||||
) : (
|
||||
SECTION_CONFIG.map(({ category, label, icon, color }) => {
|
||||
const signals = report.signals.filter(s => s.category === category)
|
||||
if (signals.length === 0) return null
|
||||
return (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
|
||||
<Box sx={{ color }}>{icon}</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={signals.length}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#64748b' }}
|
||||
/>
|
||||
</Box>
|
||||
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -19,10 +19,6 @@ import type { PropertyTableFilters } from './PropertyFilterBar'
|
||||
import {
|
||||
getAssetTypeColor,
|
||||
getAssetTypeLabel,
|
||||
getAvailabilityChipColor,
|
||||
getAvailabilityLabel,
|
||||
getResultTypeColor,
|
||||
getResultTypeLabel,
|
||||
qualityColor,
|
||||
} from './propertyHelpers'
|
||||
|
||||
@@ -38,8 +34,9 @@ interface PropertyTableProps {
|
||||
}
|
||||
|
||||
const COL_HEADERS = [
|
||||
'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete/m²',
|
||||
'Verfügbarkeit', 'Konfidenz', 'Datenqualität', 'Quelle', 'Aktionen',
|
||||
'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete CHF/m²/Jahr',
|
||||
'Aktueller Mieter', 'Mietlaufzeit', 'Breakoutoption', 'Breakoutoption Zeitpunkt',
|
||||
'Datenqualität', 'Aktionen',
|
||||
]
|
||||
|
||||
function LoadingRows() {
|
||||
@@ -108,11 +105,12 @@ export function PropertyTable({
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Standort</Typography></TableCell>
|
||||
<TableCell><SortableHeader field="area" label="Fläche (m²)" /></TableCell>
|
||||
<TableCell><SortableHeader field="rent" label="Miete/m²" /></TableCell>
|
||||
<TableCell><SortableHeader field="availability" label="Verfügbarkeit" /></TableCell>
|
||||
<TableCell><SortableHeader field="confidence" label="Konfidenz" /></TableCell>
|
||||
<TableCell><SortableHeader field="rent" label="Miete CHF/m²/Jahr" /></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktueller Mieter</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Mietlaufzeit</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption Zeitpunkt</Typography></TableCell>
|
||||
<TableCell><SortableHeader field="dataQuality" label="Datenqualität" /></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Quelle</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -121,7 +119,7 @@ export function PropertyTable({
|
||||
<LoadingRows />
|
||||
) : properties.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10}>
|
||||
<TableCell colSpan={11}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 4 }}>
|
||||
Keine Objekte gefunden.
|
||||
</Typography>
|
||||
@@ -184,30 +182,47 @@ export function PropertyTable({
|
||||
{/* Miete */}
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : <span style={{ color: '#94a3b8' }}>k.A.</span>}
|
||||
{p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : <span style={{ color: '#94a3b8' }}>k.A.</span>}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Verfügbarkeit */}
|
||||
{/* Aktueller Mieter */}
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||
size="small"
|
||||
color={getAvailabilityChipColor(p.availabilityStatus)}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{p.currentTenant ?? <span style={{ color: '#94a3b8' }}>–</span>}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Konfidenz */}
|
||||
{/* Mietlaufzeit */}
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: p.confidenceScore >= 0.85 ? '#1a7a4a' : p.confidenceScore >= 0.65 ? '#1e3a5f' : '#d97706',
|
||||
}}
|
||||
>
|
||||
{Math.round(p.confidenceScore * 100)}%
|
||||
<Typography variant="body2">
|
||||
{p.leaseTerm ?? <span style={{ color: '#94a3b8' }}>–</span>}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Breakoutoption */}
|
||||
<TableCell>
|
||||
{p.breakoutOption == null
|
||||
? <Typography variant="body2" sx={{ color: '#94a3b8' }}>–</Typography>
|
||||
: <Chip
|
||||
label={p.breakoutOption ? 'Ja' : 'Nein'}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: p.breakoutOption ? '#dcfce7' : '#f1f5f9',
|
||||
color: p.breakoutOption ? '#166534' : '#64748b',
|
||||
fontSize: '0.68rem',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</TableCell>
|
||||
|
||||
{/* Breakoutoption Zeitpunkt */}
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{p.breakoutOptionDate
|
||||
? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH')
|
||||
: <span style={{ color: '#94a3b8' }}>–</span>
|
||||
}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
@@ -231,15 +246,6 @@ export function PropertyTable({
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
{/* Quelle */}
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={getResultTypeLabel(p.resultType)}
|
||||
size="small"
|
||||
sx={{ bgcolor: getResultTypeColor(p.resultType), color: 'white', fontSize: '0.68rem' }}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Aktionen */}
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<Box sx={{ display: 'flex', gap: 0.25 }}>
|
||||
|
||||
Reference in New Issue
Block a user