feat: role-aware UX, market intelligence, score fix & layout scroll
- Role-based workspace access: Property Manager gets Supply+Demand, Operations restricted to Super Admin + Reviewer only - Root redirect routes each persona to their first workspace - Match Center: replaced 3-panel with auto-sorted flat list + drawer - AI Search: unified form with dual-action (Jetzt suchen / Als Suchprofil speichern) - CompareTray hidden on non-Demand routes; Vergleichen removed from Supply - LocationIntelligencePanel: city KPIs, rent trends, soft factors, comparables - NegotiationInsightsPanel: price positioning, active demand, selling arguments - scoreCalculator: cap hardMatchScore and softFactorScore to max 100 - Layout: add display:flex to overflow:hidden wrappers so inner scroll works (MatchCenter drawer, MarketIntelligence, SourceMonitoring, SignalPipeline) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
import { Box, Chip, Divider, LinearProgress, Paper, Tooltip, Typography } from '@mui/material'
|
||||
import {
|
||||
Activity, Building2, HardHat, MapPin, Percent,
|
||||
TrendingDown, TrendingUp, Train, Users, Zap,
|
||||
} from 'lucide-react'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function scoreColor(v: number) {
|
||||
if (v >= 0.72) return '#1a7a4a'
|
||||
if (v >= 0.48) return '#d97706'
|
||||
return '#c0392b'
|
||||
}
|
||||
|
||||
function scoreLabel(v: number) {
|
||||
if (v >= 0.82) return 'Sehr gut'
|
||||
if (v >= 0.65) return 'Gut'
|
||||
if (v >= 0.45) return 'Mittel'
|
||||
return 'Schwach'
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function SoftFactorBar({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
tooltip,
|
||||
}: {
|
||||
label: string
|
||||
value: number | undefined | null
|
||||
icon: React.ReactNode
|
||||
tooltip?: string
|
||||
}) {
|
||||
if (value === undefined || value === null) return null
|
||||
const color = scoreColor(value)
|
||||
const row = (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ color: '#64748b', display: 'flex' }}>{icon}</Box>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={scoreLabel(value)}
|
||||
size="small"
|
||||
sx={{ bgcolor: color, color: 'white', fontSize: 10, height: 18, fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={value * 100}
|
||||
sx={{ height: 5, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
return tooltip ? <Tooltip title={tooltip} placement="left">{row}</Tooltip> : row
|
||||
}
|
||||
|
||||
function KpiTile({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 1.5,
|
||||
bgcolor: '#f8fafc',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: color ?? '#0f1923', lineHeight: 1.2 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{sub && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{sub}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const NEW_PROJECTS: Record<string, { title: string; area: string; completion: string; note: string }[]> = {
|
||||
'Zürich': [
|
||||
{ title: 'Ensemble Zürich-West', area: '500–2000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' },
|
||||
{ title: 'The Circle Phase II', area: '1000–5000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' },
|
||||
],
|
||||
'Basel': [
|
||||
{ title: 'Basel SBB Tower', area: '300–1500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' },
|
||||
{ title: 'Erlenmatt Ost', area: '600–2500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' },
|
||||
],
|
||||
'Zug': [
|
||||
{ title: 'Zug Innovation Campus', area: '200–1000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' },
|
||||
],
|
||||
'Bern': [
|
||||
{ title: 'Bern West Business Park', area: '400–3000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' },
|
||||
],
|
||||
'Winterthur': [
|
||||
{ title: 'Sulzerareal Phase 4', area: '800–4000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
property: Property | null
|
||||
}
|
||||
|
||||
export function LocationIntelligencePanel({ property }: Props) {
|
||||
const { data: allProperties = [] } = useProperties()
|
||||
|
||||
if (!property) return null
|
||||
|
||||
const city = property.location.city
|
||||
const intel = getCityIntelligence(city)
|
||||
const sf = property.softFactors
|
||||
|
||||
const hasSoftFactors = sf && (
|
||||
sf.footfallScore !== undefined ||
|
||||
sf.taxEnvironmentScore !== undefined ||
|
||||
sf.commuterAccessScore !== undefined ||
|
||||
sf.talentAccessScore !== undefined ||
|
||||
sf.prestigeScore !== undefined ||
|
||||
sf.prestige !== undefined
|
||||
)
|
||||
|
||||
// Market comparables: same type, same city, different property
|
||||
const comparables = allProperties
|
||||
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === city)
|
||||
.sort((a, b) => b.confidenceScore - a.confidenceScore)
|
||||
.slice(0, 3)
|
||||
|
||||
const newProjects = NEW_PROJECTS[city] ?? []
|
||||
|
||||
const rentTrendPositive = intel && intel.rentTrend12m > 0
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.25 }}>
|
||||
Standort-Intelligence
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Wirtschaftliche Faktoren und Marktkontext — über klassische Flächenangaben hinaus
|
||||
</Typography>
|
||||
|
||||
{/* ── City KPIs ── */}
|
||||
{intel && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<KpiTile
|
||||
label="Leerstandsquote"
|
||||
value={`${intel.vacancyRatePct}%`}
|
||||
sub={intel.vacancyRatePct < 3 ? 'Angespannter Markt' : intel.vacancyRatePct < 5 ? 'Ausgeglichen' : 'Käufermarkt'}
|
||||
color={intel.vacancyRatePct < 3 ? '#c0392b' : intel.vacancyRatePct < 5 ? '#d97706' : '#1a7a4a'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Mietpreis-Trend"
|
||||
value={`${rentTrendPositive ? '+' : ''}${intel.rentTrend12m}%`}
|
||||
sub="letzte 12 Monate"
|
||||
color={rentTrendPositive ? '#c0392b' : '#1a7a4a'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Kaufkraft-Index"
|
||||
value={`${intel.purchasingPowerIndex}`}
|
||||
sub="CH-Mittel = 100"
|
||||
color={intel.purchasingPowerIndex >= 115 ? '#1a7a4a' : intel.purchasingPowerIndex >= 95 ? '#d97706' : '#c0392b'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Ø Vermietungsdauer"
|
||||
value={`${intel.avgDaysOnMarket}T`}
|
||||
sub="Tage auf dem Markt"
|
||||
color={intel.avgDaysOnMarket < 40 ? '#c0392b' : intel.avgDaysOnMarket < 65 ? '#d97706' : '#1a7a4a'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Rent trend interpretation */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.5, bgcolor: rentTrendPositive ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5, mb: 2 }}>
|
||||
<Box sx={{ color: rentTrendPositive ? '#d97706' : '#1a7a4a', mt: 0.1 }}>
|
||||
{rentTrendPositive ? <TrendingUp size={16} /> : <TrendingDown size={16} />}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: rentTrendPositive ? '#92400e' : '#14532d' }}>
|
||||
{rentTrendPositive
|
||||
? `Mietpreise in ${city} sind in den letzten 12 Monaten um ${intel.rentTrend12m}% gestiegen. Frühzeitig abschliessen kann vorteilhaft sein.`
|
||||
: `Mietpreise in ${city} sind leicht rückläufig. Verhandlungsspielraum nutzen.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Tax + demand */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<Percent size={13} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">Steuerindex Kanton</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: intel.taxIndexCanton <= 80 ? '#1a7a4a' : intel.taxIndexCanton <= 105 ? '#d97706' : '#c0392b' }}>
|
||||
{intel.taxIndexCanton} <Typography component="span" variant="caption" color="text.secondary">(CH = 100)</Typography>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
||||
{intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<Activity size={13} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">Nachfragestärke</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: { LOW: '#f1f5f9', MEDIUM: '#fef3c7', HIGH: '#dcfce7', VERY_HIGH: '#f0fdf4' }[intel.demandStrength],
|
||||
color: { LOW: '#64748b', MEDIUM: '#d97706', HIGH: '#16a34a', VERY_HIGH: '#1a7a4a' }[intel.demandStrength],
|
||||
fontWeight: 600, fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Aktive Nachfrage in {city}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Industry clusters */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
|
||||
Dominante Branchen-Cluster
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{intel.dominantIndustryClusters.map(c => (
|
||||
<Chip key={c} label={c} size="small" sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 22 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Infrastructure */}
|
||||
{intel.plannedInfrastructure.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||
<Train size={13} color="#64748b" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
|
||||
Geplante Infrastruktur-Projekte
|
||||
</Typography>
|
||||
</Box>
|
||||
{intel.plannedInfrastructure.map((proj, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75, pl: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||
{proj.timeline}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>{proj.project}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{proj.impact}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Soft Factors ── */}
|
||||
{hasSoftFactors && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 1 }}>
|
||||
KI-berechnete Standortqualität
|
||||
</Typography>
|
||||
<SoftFactorBar
|
||||
label="Passantenfrequenz"
|
||||
value={sf.footfallScore ?? (sf.passerbyFrequency ? { LOW: 0.25, MEDIUM: 0.5, HIGH: 0.78, VERY_HIGH: 0.95 }[sf.passerbyFrequency] : undefined)}
|
||||
icon={<Users size={14} />}
|
||||
tooltip="Geschätzte Personenfrequenz im Umfeld — relevant für Retail & Sichtbarkeit"
|
||||
/>
|
||||
<SoftFactorBar
|
||||
label="ÖV-Anbindung"
|
||||
value={sf.commuterAccessScore}
|
||||
icon={<Train size={14} />}
|
||||
tooltip={sf.publicTransportMinutes ? `~${sf.publicTransportMinutes} Min. zum nächsten ÖV-Hub` : 'Öffentliche Erreichbarkeit des Standorts'}
|
||||
/>
|
||||
<SoftFactorBar
|
||||
label="Talentpool-Zugang"
|
||||
value={sf.talentAccessScore ?? (sf.talentAccess as number | undefined)}
|
||||
icon={<Users size={14} />}
|
||||
tooltip="Verfügbarkeit qualifizierter Fachkräfte in einem 30-Min.-Radius"
|
||||
/>
|
||||
<SoftFactorBar
|
||||
label="Prestige / Lagequalität"
|
||||
value={sf.prestigeScore ?? (sf.prestige as number | undefined)}
|
||||
icon={<MapPin size={14} />}
|
||||
tooltip="Adress-Prestige und wahrgenommene Standortqualität"
|
||||
/>
|
||||
<SoftFactorBar
|
||||
label="Flexibilitätspotenzial"
|
||||
value={sf.flexibilityScore}
|
||||
icon={<Zap size={14} />}
|
||||
tooltip="Möglichkeit zur Flächen-Anpassung (Ausbau, Teilung, Erweiterung)"
|
||||
/>
|
||||
{sf.esgScore !== undefined && (
|
||||
<SoftFactorBar
|
||||
label="ESG-Bewertung"
|
||||
value={sf.esgScore}
|
||||
icon={<Activity size={14} />}
|
||||
tooltip="Umwelt-, Sozial- und Governance-Standard des Gebäudes"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Market Comparables ── */}
|
||||
{comparables.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
|
||||
Vergleichbare Angebote in {city}
|
||||
</Typography>
|
||||
{comparables.map(p => (
|
||||
<Box
|
||||
key={p.id}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9' }}
|
||||
>
|
||||
<Building2 size={13} color="#64748b" />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{p.areaSqm} m² · CHF {p.rentPricePerSqm}/m²
|
||||
{p.rentPricePerSqm < property.rentPricePerSqm && ' · günstiger'}
|
||||
{p.rentPricePerSqm > property.rentPricePerSqm && ' · teurer'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── New Construction ── */}
|
||||
{newProjects.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#7c3aed', display: 'block', mb: 0.75 }}>
|
||||
Neubauprojekte als Alternative
|
||||
</Typography>
|
||||
{newProjects.map((proj, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<HardHat size={13} color="#7c3aed" />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block' }}>{proj.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{proj.area} · Fertigstellung {proj.completion}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{proj.note}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user