Files
property-match/src/components/supply/NegotiationInsightsPanel.tsx
T
Benjamin Sutter ee71ecc881 refactor: extract sub-components from PropertyMarketSignalsTab + NegotiationInsightsPanel
- PropertyMarketSignalsTab (416→100 lines): SignalCard, BerichtDialog → own files
- NegotiationInsightsPanel (345→260 lines): pure logic → negotiationInsightsUtils.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:08:45 +02:00

261 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Box, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
import { CheckCircle, TrendingDown, TrendingUp } from 'lucide-react'
import { useProperties } from '../../hooks/useProperties'
import { useNeeds } from '../../hooks/useNeeds'
import { getCityIntelligence, getMarketRent } from '../../lib/locationIntelligence'
import type { Property } from '../../domain/property'
import { generateSellingArguments, generateWeaknesses } from './negotiationInsightsUtils'
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
property: Property
}
export function NegotiationInsightsPanel({ property }: Props) {
const { data: allProperties = [] } = useProperties()
const { data: needs = [] } = useNeeds()
const intel = getCityIntelligence(property.location.city)
const marketRent = getMarketRent(property.location.city, property.assetType)
const priceDiff = marketRent ? ((property.rentPricePerSqm - marketRent) / marketRent) * 100 : null
// Comparable properties for price positioning
const comparables = allProperties
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === property.location.city)
const avgComparableRent = comparables.length > 0
? comparables.reduce((s, p) => s + p.rentPricePerSqm, 0) / comparables.length
: null
// Active needs matching this property type/location
const matchingNeeds = needs.filter(n =>
n.assetType === property.assetType &&
(n.preferredLocations?.some(loc => loc.toLowerCase().includes(property.location.city.toLowerCase())) ?? false)
)
const sellingArgs = generateSellingArguments(property)
const weaknesses = generateWeaknesses(property)
const strongArgs = sellingArgs.filter(a => a.strength === 'strong')
const mediumArgs = sellingArgs.filter(a => a.strength === 'medium')
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* ── Price positioning ── */}
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Preispositionierung</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5 }}>
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Ihr Preis</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f1923' }}>
CHF {property.rentPricePerSqm}/m²
</Typography>
</Box>
{marketRent && (
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Marktmedian {property.location.city}</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}>
CHF {marketRent}/m²
</Typography>
</Box>
)}
{avgComparableRent && (
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Vergleichsangebote Ø</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}>
CHF {Math.round(avgComparableRent)}/m²
</Typography>
</Box>
)}
</Box>
{priceDiff !== null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: Math.abs(priceDiff) < 10 ? '#f0fdf4' : priceDiff > 0 ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5 }}>
{priceDiff > 0 ? <TrendingUp size={15} color="#d97706" /> : <TrendingDown size={15} color="#1a7a4a" />}
<Typography variant="body2" sx={{ color: priceDiff > 15 ? '#92400e' : priceDiff > 0 ? '#d97706' : '#1a7a4a', fontWeight: 500 }}>
{priceDiff > 15
? `Ihr Preis liegt ${Math.round(priceDiff)}% über dem Marktmedian — starke USPs nötig zur Rechtfertigung.`
: priceDiff > 5
? `Leicht über Marktmedian (+${Math.round(priceDiff)}%) — gut durch Qualität begründbar.`
: priceDiff > -5
? 'Im Marktdurchschnitt — gute Ausgangsposition.'
: `${Math.round(Math.abs(priceDiff))}% unter Marktmedian — Preiserhöhung oder schnelle Vermietung möglich.`}
</Typography>
</Box>
)}
{/* Price bar vs market */}
{marketRent && (
<Box sx={{ mt: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">Marktbereich {property.location.city}</Typography>
<Typography variant="caption" color="text.secondary">
CHF {Math.round(marketRent * 0.7)}{Math.round(marketRent * 1.4)}/m²
</Typography>
</Box>
<Box sx={{ position: 'relative', height: 8, bgcolor: '#e2e8f0', borderRadius: 4 }}>
<Box sx={{
position: 'absolute',
left: `${Math.min(90, Math.max(5, ((property.rentPricePerSqm - marketRent * 0.7) / (marketRent * 0.7)) * 100))}%`,
top: -2,
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: '#1e3a5f',
border: '2px solid white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</Box>
</Box>
)}
</Paper>
{/* ── Active demand ── */}
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Aktive Nachfrage</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Unternehmen, die aktuell in {property.location.city} suchen
</Typography>
{matchingNeeds.length > 0 ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{matchingNeeds.length}</Typography>
<Typography variant="body2" color="text.secondary">aktive Suchprofile für diesen Typ & Standort</Typography>
</Box>
{matchingNeeds.slice(0, 4).map(n => (
<Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 500 }}>{n.companyName}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{n.requiredArea?.min ?? 0}{n.requiredArea?.max ?? 0} m²
{n.budgetRange?.maxPerSqm ? ` · max. CHF ${n.budgetRange.maxPerSqm}/m²` : ''}
</Typography>
</Box>
<Chip
label={n.status === 'ACTIVE' ? 'Aktiv' : n.status === 'DRAFT' ? 'Entwurf' : n.status}
size="small"
sx={{
bgcolor: n.status === 'ACTIVE' ? '#f0fdf4' : '#f1f5f9',
color: n.status === 'ACTIVE' ? '#1a7a4a' : '#64748b',
fontSize: 10, height: 18,
}}
/>
</Box>
))}
{matchingNeeds.length > 4 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.75, display: 'block' }}>
+ {matchingNeeds.length - 4} weitere Suchprofile
</Typography>
)}
</>
) : (
<Typography variant="body2" color="text.secondary">
Keine aktiven Suchprofile für diesen Typ und Standort.
</Typography>
)}
{intel && (
<Box sx={{ mt: 1.5, p: 1.25, bgcolor: '#f8fafc', borderRadius: 1.5 }}>
<Typography variant="caption" color="text.secondary">
Ø Vermietungsdauer vergleichbarer Objekte in {property.location.city}:{' '}
<strong style={{ color: '#1e3a5f' }}>{intel.avgDaysOnMarket} Tage</strong>
</Typography>
</Box>
)}
</Paper>
{/* ── Selling arguments ── */}
{sellingArgs.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Verkaufsargumente für das Gespräch</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Stärken dieses Objekts maßgeschneidert auf typische Mieterwünsche
</Typography>
{strongArgs.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a', display: 'block', mb: 0.75 }}>
Starke Argumente
</Typography>
{strongArgs.map((arg, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}>
<CheckCircle size={15} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{arg.title}</Typography>
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
</Box>
</Box>
))}
</Box>
)}
{mediumArgs.length > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706', display: 'block', mb: 0.75 }}>
Weitere Vorteile
</Typography>
{mediumArgs.map((arg, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75 }}>
<CheckCircle size={14} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{arg.title}</Typography>
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
</Box>
</Box>
))}
</Box>
)}
</Paper>
)}
{/* ── Proactive weakness handling ── */}
{weaknesses.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Schwächen proaktiv adressieren</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Potenzielle Einwände kennen und entkräften bevor der Interessent fragt
</Typography>
{weaknesses.map((w, i) => (
<Box key={i} sx={{ mb: 1.25 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#c0392b', mb: 0.25 }}>
{w.issue}
</Typography>
<Box sx={{ pl: 1.5, borderLeft: '2px solid #e2e8f0' }}>
<Typography variant="caption" color="text.secondary"> {w.mitigation}</Typography>
</Box>
{i < weaknesses.length - 1 && <Divider sx={{ mt: 1.25 }} />}
</Box>
))}
</Paper>
)}
{/* ── Tenant fit ── */}
{intel && intel.dominantIndustryClusters.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Welche Mieter passen?</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Dominant präsente Branchen in {property.location.city} hohes Match-Potenzial
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{intel.dominantIndustryClusters.map(c => (
<Chip key={c} label={c} size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 24, fontWeight: 500 }}
/>
))}
</Box>
<LinearProgress
variant="determinate"
value={intel.demandStrength === 'VERY_HIGH' ? 95 : intel.demandStrength === 'HIGH' ? 75 : intel.demandStrength === 'MEDIUM' ? 50 : 25}
sx={{ mt: 1.5, height: 6, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Nachfragestärke: {' '}
<strong>{{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}</strong>
</Typography>
</Paper>
)}
</Box>
)
}