From 01c0f8b06fbf2005156f5482b6ebeb076f3cde9d Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 01:16:32 +0200 Subject: [PATCH] refactor: extract helpers from 3 match-detail/match-card components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocationIntelligencePanel (333→275): KpiTile + NEW_PROJECTS → own files - FutureAvailabilityContextPanel (351→315): constants + utils → own files - FutureAvailabilityCard (310→244): helpers + SignalQualityDots → own files Co-Authored-By: Claude Sonnet 4.6 --- .../match-card/FutureAvailabilityCard.tsx | 72 +------------------ .../match-card/SignalQualityDots.tsx | 20 ++++++ .../futureAvailabilityCardHelpers.tsx | 38 ++++++++++ .../FutureAvailabilityContextPanel.tsx | 39 +--------- src/components/match-detail/KpiTile.tsx | 38 ++++++++++ .../LocationIntelligencePanel.tsx | 61 +--------------- .../futureAvailabilityContextUtils.ts | 37 ++++++++++ .../locationIntelligenceConstants.ts | 19 +++++ 8 files changed, 158 insertions(+), 166 deletions(-) create mode 100644 src/components/match-card/SignalQualityDots.tsx create mode 100644 src/components/match-card/futureAvailabilityCardHelpers.tsx create mode 100644 src/components/match-detail/KpiTile.tsx create mode 100644 src/components/match-detail/futureAvailabilityContextUtils.ts create mode 100644 src/components/match-detail/locationIntelligenceConstants.ts diff --git a/src/components/match-card/FutureAvailabilityCard.tsx b/src/components/match-card/FutureAvailabilityCard.tsx index 289e994..a9c4e39 100644 --- a/src/components/match-card/FutureAvailabilityCard.tsx +++ b/src/components/match-card/FutureAvailabilityCard.tsx @@ -1,77 +1,11 @@ import { Box, Button, Divider, Typography } from '@mui/material' -import { - AlertCircle, - BarChart2, - Briefcase, - CheckCircle2, - FileCheck, - FileText, - Newspaper, - ShieldCheck, - User, -} from 'lucide-react' +import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react' import { useNavigate } from 'react-router' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' +import { floorLabel, SOURCE_META, ASSET_TYPE_LABELS, getOpportunityHeadline } from './futureAvailabilityCardHelpers' +import { SignalQualityDots } from './SignalQualityDots' import type { MatchCardViewModel } from './MatchCardViewModel' -function floorLabel(level: number): string { - if (level === 0) return 'EG' - if (level < 0) return `UG ${Math.abs(level)}` - return `${level}.OG` -} - -const SOURCE_META: Record = { - JOB_POSTING: { label: 'Stelleninserate', icon: }, - PRESS: { label: 'Pressebericht', icon: }, - CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: }, - COMPANY_REPORT: { label: 'Geschäftsbericht', icon: }, - MARKET_DATA: { label: 'Marktdaten', icon: }, - MANUAL: { label: 'Analyst', icon: }, - LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: }, -} - -const ASSET_TYPE_LABELS: Record = { - OFFICE: 'Bürofläche', - LOGISTICS: 'Lagerfläche', - RETAIL: 'Retailfläche', - PRODUCTION: 'Produktionsfläche', - MIXED: 'Gewerbefläche', -} - -// Signal type → opportunity headline (with asset type) -function getOpportunityHeadline(signalType: string | undefined, assetTypeLabel: string): string { - switch (signalType) { - case 'LEASE_EXPIRY': return `${assetTypeLabel} wird verfügbar` - case 'POSSIBLE_MOVE_OUT': return `Mögliche ${assetTypeLabel} erkannt` - case 'EXPANSION': return `Unternehmen sucht ${assetTypeLabel}` - case 'CONSTRUCTION_PROJECT': return `Neubau: ${assetTypeLabel} in Planung` - case 'RESTRUCTURING': return `Mögliche Flächenfreigabe erkannt` - case 'SPACE_CONSOLIDATION': return `Mögliche Teilfläche erkannt` - case 'PROJECT_DEVELOPMENT': return `Neue Fläche in Projektentwicklung` - default: return `Potenzielle ${assetTypeLabel} erkannt` - } -} - -// Signal quality dots display -function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | undefined }) { - const config = { - HIGH: { dots: [1, 1, 1, 1], color: '#1a7a4a', label: 'Hohe Signalqualität' }, - MEDIUM: { dots: [1, 1, 1, 0], color: '#d97706', label: 'Mittlere Signalqualität' }, - LOW: { dots: [1, 1, 0, 0], color: '#c0392b', label: 'Niedrige Signalqualität' }, - } - const c = quality ? config[quality] : config.LOW - return ( - - {c.dots.map((filled, i) => ( - - ))} - - {c.label} - - - ) -} - // ── Future Availability Card ────────────────────────────────────────────────── export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { diff --git a/src/components/match-card/SignalQualityDots.tsx b/src/components/match-card/SignalQualityDots.tsx new file mode 100644 index 0000000..3b037bf --- /dev/null +++ b/src/components/match-card/SignalQualityDots.tsx @@ -0,0 +1,20 @@ +import { Box, Typography } from '@mui/material' + +export function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | undefined }) { + const config = { + HIGH: { dots: [1, 1, 1, 1], color: '#1a7a4a', label: 'Hohe Signalqualität' }, + MEDIUM: { dots: [1, 1, 1, 0], color: '#d97706', label: 'Mittlere Signalqualität' }, + LOW: { dots: [1, 1, 0, 0], color: '#c0392b', label: 'Niedrige Signalqualität' }, + } + const c = quality ? config[quality] : config.LOW + return ( + + {c.dots.map((filled, i) => ( + + ))} + + {c.label} + + + ) +} diff --git a/src/components/match-card/futureAvailabilityCardHelpers.tsx b/src/components/match-card/futureAvailabilityCardHelpers.tsx new file mode 100644 index 0000000..da761c0 --- /dev/null +++ b/src/components/match-card/futureAvailabilityCardHelpers.tsx @@ -0,0 +1,38 @@ +import { Briefcase, BarChart2, FileCheck, FileText, User, ShieldCheck, Newspaper } from 'lucide-react' + +export function floorLabel(level: number): string { + if (level === 0) return 'EG' + if (level < 0) return `UG ${Math.abs(level)}` + return `${level}.OG` +} + +export const SOURCE_META: Record = { + JOB_POSTING: { label: 'Stelleninserate', icon: }, + PRESS: { label: 'Pressebericht', icon: }, + CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: }, + COMPANY_REPORT: { label: 'Geschäftsbericht', icon: }, + MARKET_DATA: { label: 'Marktdaten', icon: }, + MANUAL: { label: 'Analyst', icon: }, + LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: }, +} + +export const ASSET_TYPE_LABELS: Record = { + OFFICE: 'Bürofläche', + LOGISTICS: 'Lagerfläche', + RETAIL: 'Retailfläche', + PRODUCTION: 'Produktionsfläche', + MIXED: 'Gewerbefläche', +} + +export function getOpportunityHeadline(signalType: string | undefined, assetTypeLabel: string): string { + switch (signalType) { + case 'LEASE_EXPIRY': return `${assetTypeLabel} wird verfügbar` + case 'POSSIBLE_MOVE_OUT': return `Mögliche ${assetTypeLabel} erkannt` + case 'EXPANSION': return `Unternehmen sucht ${assetTypeLabel}` + case 'CONSTRUCTION_PROJECT': return `Neubau: ${assetTypeLabel} in Planung` + case 'RESTRUCTURING': return `Mögliche Flächenfreigabe erkannt` + case 'SPACE_CONSOLIDATION': return `Mögliche Teilfläche erkannt` + case 'PROJECT_DEVELOPMENT': return `Neue Fläche in Projektentwicklung` + default: return `Potenzielle ${assetTypeLabel} erkannt` + } +} diff --git a/src/components/match-detail/FutureAvailabilityContextPanel.tsx b/src/components/match-detail/FutureAvailabilityContextPanel.tsx index 7c1a36b..19c0432 100644 --- a/src/components/match-detail/FutureAvailabilityContextPanel.tsx +++ b/src/components/match-detail/FutureAvailabilityContextPanel.tsx @@ -14,44 +14,7 @@ import { import type { Match } from '../../domain/match' import type { FutureSignal } from '../../domain/futureSignal' import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants' - -const CREDIBILITY_META: Record = { - HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' }, - MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' }, - LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' }, -} - -const SENSITIVITY_META: Record = { - CONFIDENTIAL: { label: 'Vertraulich', color: 'error' }, - INTERNAL: { label: 'Intern', color: 'warning' }, - PUBLIC: { label: 'Öffentlich', color: 'default' }, -} - -const RISK_META: Record = { - LOW: { label: 'Niedrig', color: '#1a7a4a' }, - MEDIUM: { label: 'Mittel', color: '#d97706' }, - HIGH: { label: 'Hoch', color: '#c0392b' }, - CRITICAL: { label: 'Kritisch', color: '#7f1d1d' }, -} - -function ageLabel(isoDate: string): string { - const diffMs = Date.now() - new Date(isoDate).getTime() - const days = Math.floor(diffMs / 86400000) - if (days < 1) return 'Heute erkannt' - if (days < 7) return `Vor ${days} Tag${days === 1 ? '' : 'en'} erkannt` - if (days < 30) return `Vor ${Math.floor(days / 7)} Woche${Math.floor(days / 7) === 1 ? '' : 'n'} erkannt` - if (days < 365) return `Vor ${Math.floor(days / 30)} Monat${Math.floor(days / 30) === 1 ? '' : 'en'} erkannt` - return `Vor ${Math.floor(days / 365)} Jahr${Math.floor(days / 365) === 1 ? '' : 'en'} erkannt` -} - -function domainLabel(url: string): string { - try { - const u = new URL(url) - return u.hostname.replace(/^www\./, '') - } catch { - return url.length > 60 ? url.slice(0, 57) + '…' : url - } -} +import { CREDIBILITY_META, SENSITIVITY_META, RISK_META, ageLabel, domainLabel } from './futureAvailabilityContextUtils' interface Props { match: Match diff --git a/src/components/match-detail/KpiTile.tsx b/src/components/match-detail/KpiTile.tsx new file mode 100644 index 0000000..f86db3d --- /dev/null +++ b/src/components/match-detail/KpiTile.tsx @@ -0,0 +1,38 @@ +import { Box, Typography } from '@mui/material' + +export function KpiTile({ + label, + value, + sub, + color, +}: { + label: string + value: string + sub?: string + color?: string +}) { + return ( + + + {label} + + + {value} + + {sub && ( + + {sub} + + )} + + ) +} diff --git a/src/components/match-detail/LocationIntelligencePanel.tsx b/src/components/match-detail/LocationIntelligencePanel.tsx index 834286a..7350279 100644 --- a/src/components/match-detail/LocationIntelligencePanel.tsx +++ b/src/components/match-detail/LocationIntelligencePanel.tsx @@ -8,65 +8,8 @@ import { useProperties } from '../../hooks/useProperties' import { getCityIntelligence } from '../../lib/locationIntelligence' import type { Property } from '../../domain/property' import { SoftFactorBar } from './SoftFactorBar' - -// ── Sub-components ──────────────────────────────────────────────────────────── - -function KpiTile({ - label, - value, - sub, - color, -}: { - label: string - value: string - sub?: string - color?: string -}) { - return ( - - - {label} - - - {value} - - {sub && ( - - {sub} - - )} - - ) -} - -const NEW_PROJECTS: Record = { - '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' }, - ], -} +import { KpiTile } from './KpiTile' +import { NEW_PROJECTS } from './locationIntelligenceConstants' // ── Main component ──────────────────────────────────────────────────────────── diff --git a/src/components/match-detail/futureAvailabilityContextUtils.ts b/src/components/match-detail/futureAvailabilityContextUtils.ts new file mode 100644 index 0000000..bcce5bf --- /dev/null +++ b/src/components/match-detail/futureAvailabilityContextUtils.ts @@ -0,0 +1,37 @@ +export const CREDIBILITY_META: Record = { + HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' }, + MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' }, + LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' }, +} + +export const SENSITIVITY_META: Record = { + CONFIDENTIAL: { label: 'Vertraulich', color: 'error' }, + INTERNAL: { label: 'Intern', color: 'warning' }, + PUBLIC: { label: 'Öffentlich', color: 'default' }, +} + +export const RISK_META: Record = { + LOW: { label: 'Niedrig', color: '#1a7a4a' }, + MEDIUM: { label: 'Mittel', color: '#d97706' }, + HIGH: { label: 'Hoch', color: '#c0392b' }, + CRITICAL: { label: 'Kritisch', color: '#7f1d1d' }, +} + +export function ageLabel(isoDate: string): string { + const diffMs = Date.now() - new Date(isoDate).getTime() + const days = Math.floor(diffMs / 86400000) + if (days < 1) return 'Heute erkannt' + if (days < 7) return `Vor ${days} Tag${days === 1 ? '' : 'en'} erkannt` + if (days < 30) return `Vor ${Math.floor(days / 7)} Woche${Math.floor(days / 7) === 1 ? '' : 'n'} erkannt` + if (days < 365) return `Vor ${Math.floor(days / 30)} Monat${Math.floor(days / 30) === 1 ? '' : 'en'} erkannt` + return `Vor ${Math.floor(days / 365)} Jahr${Math.floor(days / 365) === 1 ? '' : 'en'} erkannt` +} + +export function domainLabel(url: string): string { + try { + const u = new URL(url) + return u.hostname.replace(/^www\./, '') + } catch { + return url.length > 60 ? url.slice(0, 57) + '…' : url + } +} diff --git a/src/components/match-detail/locationIntelligenceConstants.ts b/src/components/match-detail/locationIntelligenceConstants.ts new file mode 100644 index 0000000..e7b6a8c --- /dev/null +++ b/src/components/match-detail/locationIntelligenceConstants.ts @@ -0,0 +1,19 @@ +export const NEW_PROJECTS: Record = { + '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' }, + ], +}