refactor: split AppShell, match-detail panels, extract useCompareData
AppShell.tsx: 627→116 lines - appShellConfig.ts: NavItem/WorkspaceConfig types, WORKSPACE_CONFIG, nav helpers - AppShellSidebar.tsx: Sidebar component with visual constants - AppShellTopBar.tsx: TopBar component Match-detail panels: - ScoreBreakdownPanel: 377→303 lines (scoreBreakdownConstants.ts + CriterionRow.tsx extracted) - LocationIntelligencePanel: 383→333 lines (SoftFactorBar.tsx extracted) - FutureAvailabilityContextPanel: 386→351 lines (futureAvailabilityConstants.tsx extracted) Compare.tsx: 485→441 lines - useCompareData hook: all queries and derived state extracted to hooks/useCompareData.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { Box, LinearProgress, Typography } from '@mui/material'
|
||||
import type { ScoreFactor } from '../../domain/match'
|
||||
import { factorLabel, importanceLabel } from './scoreBreakdownConstants'
|
||||
|
||||
function scoreColor(v: number): 'success' | 'warning' | 'error' {
|
||||
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
|
||||
}
|
||||
|
||||
function scoreTextColor(v: number): string {
|
||||
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
||||
}
|
||||
|
||||
export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
|
||||
const label = factorLabel(factor.criterion)
|
||||
const pct = Math.round(factor.weight * 100)
|
||||
const color = scoreColor(factor.score)
|
||||
const imp = importanceLabel(factor.weight, maxWeight)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>
|
||||
{imp.label}
|
||||
</Typography>
|
||||
{factor.estimated && (
|
||||
<Typography variant="caption" sx={{ fontSize: '0.62rem', color: '#7c3aed', bgcolor: '#f5f3ff', px: 0.5, borderRadius: 0.5, border: '1px solid #e9d5ff', lineHeight: 1.6 }}>
|
||||
Schätzung
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
|
||||
{factor.score}/100
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
|
||||
{factor.contribution.toFixed(1)} Pkt
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={factor.score}
|
||||
color={color}
|
||||
sx={{ height: 5, borderRadius: 3, mb: 0.4 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3 }}>
|
||||
{factor.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +1,19 @@
|
||||
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart2,
|
||||
Briefcase,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FileCheck,
|
||||
FileText,
|
||||
Globe,
|
||||
Newspaper,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
User,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
|
||||
const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
EXPANSION: 'Expansion',
|
||||
POSSIBLE_MOVE_OUT: 'Möglicher Auszug',
|
||||
CONSTRUCTION_PROJECT: 'Bauvorhaben',
|
||||
RESTRUCTURING: 'Restrukturierung',
|
||||
PROJECT_DEVELOPMENT: 'Projektentwicklung',
|
||||
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
||||
LEASE_EXPIRY: 'Vertragsende (Pre-Market)',
|
||||
}
|
||||
|
||||
const SIGNAL_ACTION: Record<string, { label: string; urgency: 'high' | 'medium' | 'low' }> = {
|
||||
EXPANSION: { label: 'Unternehmen proaktiv kontaktieren — aktive Flächensuche wahrscheinlich', urgency: 'high' },
|
||||
POSSIBLE_MOVE_OUT: { label: 'Mieter ansprechen und Verlängerungsgespräch initiieren', urgency: 'high' },
|
||||
CONSTRUCTION_PROJECT: { label: 'Frühzeitiges Interesse beim Bauherrn anmelden, bevor Vermietungsmandat vergeben', urgency: 'medium' },
|
||||
RESTRUCTURING: { label: 'Situation beobachten, bei Bestätigung sofort handeln', urgency: 'medium' },
|
||||
PROJECT_DEVELOPMENT: { label: 'Entwicklungsfortschritt monitoren und Kontakt zum Projektentwickler suchen', urgency: 'medium' },
|
||||
SPACE_CONSOLIDATION: { label: 'Teilflächen-Anforderungen klären, Gespräch mit Verwaltung suchen', urgency: 'medium' },
|
||||
LEASE_EXPIRY: { label: 'Anfrage direkt über die Verwaltung stellen — Fläche ist für Matching freigegeben', urgency: 'high' },
|
||||
}
|
||||
|
||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={13} /> },
|
||||
PRESS: { label: 'Pressebericht', icon: <Newspaper size={13} /> },
|
||||
CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: <FileCheck size={13} /> },
|
||||
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={13} /> },
|
||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={13} /> },
|
||||
MANUAL: { label: 'Analyst', icon: <User size={13} /> },
|
||||
LEASE_CONTRACT: { label: 'Vertrag verifiziert (ERP)', icon: <ShieldCheck size={13} /> },
|
||||
}
|
||||
import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants'
|
||||
|
||||
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
||||
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Chip, Divider, LinearProgress, Paper, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Chip, Divider, Paper, Typography } from '@mui/material'
|
||||
import {
|
||||
Activity, Building2, HardHat, MapPin, Percent,
|
||||
TrendingDown, TrendingUp, Train, Users, Zap,
|
||||
@@ -7,60 +7,10 @@ import { useNavigate } from 'react-router'
|
||||
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'
|
||||
}
|
||||
import { SoftFactorBar } from './SoftFactorBar'
|
||||
|
||||
// ── 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,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ShieldCheck, X } from 'lucide-react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { Match, ScoreFactor } from '../../domain/match'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
import { CREDIBILITY_LABELS, HARD_KEYS, factorLabel } from './scoreBreakdownConstants'
|
||||
import { CriterionRow } from './CriterionRow'
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,82 +16,6 @@ function scoreTextColor(v: number): string {
|
||||
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
|
||||
}
|
||||
|
||||
const CREDIBILITY_LABELS: Record<string, string> = {
|
||||
HIGH: 'Hohe Quellenqualität',
|
||||
MEDIUM: 'Mittlere Quellenqualität',
|
||||
LOW: 'Niedrige Quellenqualität',
|
||||
}
|
||||
|
||||
const CRITERION_LABEL: Record<string, string> = {
|
||||
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit',
|
||||
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
|
||||
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
|
||||
talentAccess: 'Talent-Zugang', esg: 'ESG / Nachhaltigkeit', taxEnvironment: 'Steuerumfeld',
|
||||
}
|
||||
|
||||
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
|
||||
|
||||
function factorLabel(criterion: string): string {
|
||||
return CRITERION_LABEL[criterion] ?? criterion
|
||||
}
|
||||
|
||||
// Convert normalised weight to 1-5 importance level relative to other factors in the same set
|
||||
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
|
||||
const ratio = maxWeight > 0 ? weight / maxWeight : 0
|
||||
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
|
||||
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
|
||||
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
|
||||
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
|
||||
return { label: 'Unwichtig', color: '#cbd5e1' }
|
||||
}
|
||||
|
||||
// ── Single criterion row ───────────────────────────────────────────────────────
|
||||
|
||||
function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
|
||||
const label = factorLabel(factor.criterion)
|
||||
const pct = Math.round(factor.weight * 100)
|
||||
const color = scoreColor(factor.score)
|
||||
const imp = importanceLabel(factor.weight, maxWeight)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>
|
||||
{imp.label}
|
||||
</Typography>
|
||||
{factor.estimated && (
|
||||
<Typography variant="caption" sx={{ fontSize: '0.62rem', color: '#7c3aed', bgcolor: '#f5f3ff', px: 0.5, borderRadius: 0.5, border: '1px solid #e9d5ff', lineHeight: 1.6 }}>
|
||||
Schätzung
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
|
||||
{factor.score}/100
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
|
||||
{factor.contribution.toFixed(1)} Pkt
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={factor.score}
|
||||
color={color}
|
||||
sx={{ height: 5, borderRadius: 3, mb: 0.4 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3 }}>
|
||||
{factor.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
|
||||
|
||||
interface StandardBreakdownProps {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box, Chip, LinearProgress, Tooltip, Typography } from '@mui/material'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BarChart2, Briefcase, FileCheck, FileText, Newspaper, ShieldCheck, User } from 'lucide-react'
|
||||
|
||||
export const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
EXPANSION: 'Expansion',
|
||||
POSSIBLE_MOVE_OUT: 'Möglicher Auszug',
|
||||
CONSTRUCTION_PROJECT: 'Bauvorhaben',
|
||||
RESTRUCTURING: 'Restrukturierung',
|
||||
PROJECT_DEVELOPMENT: 'Projektentwicklung',
|
||||
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
||||
LEASE_EXPIRY: 'Vertragsende (Pre-Market)',
|
||||
}
|
||||
|
||||
export const SIGNAL_ACTION: Record<string, { label: string; urgency: 'high' | 'medium' | 'low' }> = {
|
||||
EXPANSION: { label: 'Unternehmen proaktiv kontaktieren — aktive Flächensuche wahrscheinlich', urgency: 'high' },
|
||||
POSSIBLE_MOVE_OUT: { label: 'Mieter ansprechen und Verlängerungsgespräch initiieren', urgency: 'high' },
|
||||
CONSTRUCTION_PROJECT: { label: 'Frühzeitiges Interesse beim Bauherrn anmelden, bevor Vermietungsmandat vergeben', urgency: 'medium' },
|
||||
RESTRUCTURING: { label: 'Situation beobachten, bei Bestätigung sofort handeln', urgency: 'medium' },
|
||||
PROJECT_DEVELOPMENT: { label: 'Entwicklungsfortschritt monitoren und Kontakt zum Projektentwickler suchen', urgency: 'medium' },
|
||||
SPACE_CONSOLIDATION: { label: 'Teilflächen-Anforderungen klären, Gespräch mit Verwaltung suchen', urgency: 'medium' },
|
||||
LEASE_EXPIRY: { label: 'Anfrage direkt über die Verwaltung stellen — Fläche ist für Matching freigegeben', urgency: 'high' },
|
||||
}
|
||||
|
||||
export const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={13} /> },
|
||||
PRESS: { label: 'Pressebericht', icon: <Newspaper size={13} /> },
|
||||
CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: <FileCheck size={13} /> },
|
||||
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={13} /> },
|
||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={13} /> },
|
||||
MANUAL: { label: 'Analyst', icon: <User size={13} /> },
|
||||
LEASE_CONTRACT: { label: 'Vertrag verifiziert (ERP)', icon: <ShieldCheck size={13} /> },
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export const CREDIBILITY_LABELS: Record<string, string> = {
|
||||
HIGH: 'Hohe Quellenqualität',
|
||||
MEDIUM: 'Mittlere Quellenqualität',
|
||||
LOW: 'Niedrige Quellenqualität',
|
||||
}
|
||||
|
||||
export const CRITERION_LABEL: Record<string, string> = {
|
||||
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit',
|
||||
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
|
||||
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
|
||||
talentAccess: 'Talent-Zugang', esg: 'ESG / Nachhaltigkeit', taxEnvironment: 'Steuerumfeld',
|
||||
}
|
||||
|
||||
export const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
|
||||
|
||||
export function factorLabel(criterion: string): string {
|
||||
return CRITERION_LABEL[criterion] ?? criterion
|
||||
}
|
||||
|
||||
// Convert normalised weight to 1-5 importance level relative to other factors in the same set
|
||||
export function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
|
||||
const ratio = maxWeight > 0 ? weight / maxWeight : 0
|
||||
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
|
||||
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
|
||||
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
|
||||
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
|
||||
return { label: 'Unwichtig', color: '#cbd5e1' }
|
||||
}
|
||||
Reference in New Issue
Block a user