From 22c195b4a5fa7b4eb801fd3f30d4af426d0b9e57 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sat, 23 May 2026 23:36:03 +0200 Subject: [PATCH] refactor: split large page components + taxonomy/HeatBadge/FutureAvailability improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Taxonomy: merge VERIFIED_PORTFOLIO + EXTERNAL_MARKET display → 'Plattform' (dark blue) across all surfaces - HeatBadge: new flame indicator for hot properties (grid, list, pipeline views) - FutureAvailabilityContextPanel: richer detail page with AI summary, strategic assessment, sources - Refactor Pipeline.tsx (630→152 lines) → pipeline/PipelineCard, PipelineColumn, PipelineDetailPanel, pipelineConstants, pipelineUtils - Refactor IntelligenceMatchCard.tsx (483→179 lines) → FutureAvailabilityCard extracted - Refactor MatchDetail.tsx (559→464 lines) → useMatchDetailData hook, MatchDetailPropertyDetails - Refactor Compare.tsx (638→485 lines) → compareUtils, CompareCriteriaCard extracted Co-Authored-By: Claude Sonnet 4.6 --- .../compare/CompareColumnHeader.tsx | 8 +- .../compare/CompareCriteriaCard.tsx | 100 ++++ src/components/compare/compareUtils.ts | 91 ++++ src/components/compare/index.ts | 1 + .../match-card/FutureAvailabilityCard.tsx | 310 +++++++++++ .../match-card/IntelligenceMatchCard.tsx | 312 +---------- src/components/match-card/MatchCardHeader.tsx | 12 +- .../FutureAvailabilityContextPanel.tsx | 447 +++++++++------- .../MatchDetailPropertyDetails.tsx | 68 +++ src/components/match-detail/index.ts | 1 + src/components/pipeline/PipelineCard.tsx | 140 +++++ src/components/pipeline/PipelineColumn.tsx | 58 ++ .../pipeline/PipelineDetailPanel.tsx | 211 ++++++++ src/components/pipeline/pipelineConstants.ts | 47 ++ src/components/pipeline/pipelineUtils.ts | 51 ++ src/components/results/ResultFeedHeader.tsx | 8 +- src/components/results/ResultFilterBar.tsx | 9 +- src/components/shared/HeatBadge.tsx | 42 ++ src/components/shared/index.ts | 1 + src/hooks/useMatchDetailData.ts | 50 ++ src/lib/constants.ts | 6 +- src/lib/ds.ts | 2 +- src/lib/propertyHeat.ts | 42 ++ src/mock-data/futureSignals.ts | 52 ++ src/pages/demand/Compare.tsx | 211 +------- src/pages/demand/MatchDetail.tsx | 100 +--- src/pages/demand/Pipeline.tsx | 494 +----------------- src/pages/demand/Results.tsx | 23 +- 28 files changed, 1615 insertions(+), 1282 deletions(-) create mode 100644 src/components/compare/CompareCriteriaCard.tsx create mode 100644 src/components/compare/compareUtils.ts create mode 100644 src/components/match-card/FutureAvailabilityCard.tsx create mode 100644 src/components/match-detail/MatchDetailPropertyDetails.tsx create mode 100644 src/components/pipeline/PipelineCard.tsx create mode 100644 src/components/pipeline/PipelineColumn.tsx create mode 100644 src/components/pipeline/PipelineDetailPanel.tsx create mode 100644 src/components/pipeline/pipelineConstants.ts create mode 100644 src/components/pipeline/pipelineUtils.ts create mode 100644 src/components/shared/HeatBadge.tsx create mode 100644 src/hooks/useMatchDetailData.ts create mode 100644 src/lib/propertyHeat.ts diff --git a/src/components/compare/CompareColumnHeader.tsx b/src/components/compare/CompareColumnHeader.tsx index 9870707..3c1168a 100644 --- a/src/components/compare/CompareColumnHeader.tsx +++ b/src/components/compare/CompareColumnHeader.tsx @@ -5,10 +5,10 @@ import { usePipelineStore } from '../../stores/pipelineStore' import type { UnifiedMatchResult } from '../../domain/unifiedResult' const TYPE_META: Record = { - VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, - EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' }, - MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, - FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, + VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' }, + MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, + FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, } const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b' diff --git a/src/components/compare/CompareCriteriaCard.tsx b/src/components/compare/CompareCriteriaCard.tsx new file mode 100644 index 0000000..d53e8f4 --- /dev/null +++ b/src/components/compare/CompareCriteriaCard.tsx @@ -0,0 +1,100 @@ +import { Box, Card, Chip, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material' +import { Trophy, CheckCircle2 } from 'lucide-react' +import { LABEL_SX, DATA_SX, CRITERION_ALIASES, getTitle, scoreBar } from './compareUtils' +import type { WeightingKey } from '../../domain/needBuilder' +import { MissingDataCell } from './CompareCell' +import type { UnifiedMatchResult } from '../../domain/unifiedResult' + +interface Props { + compareItems: UnifiedMatchResult[] + relevantCriteria: Array<{ key: string; label: string; weight: number }> + overallWinnerIdx: number + activeNeed: { companyName: string } +} + +export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWinnerIdx, activeNeed }: Props) { + return ( + + + + + Vergleich nach Suchkriterien + Basierend auf der Suche: {activeNeed.companyName} + + {overallWinnerIdx !== -1 && ( + + + + Gesamtsieger + {getTitle(compareItems[overallWinnerIdx])} + + + )} + + + + + + Kriterium + {compareItems.map(item => ( + + + {getTitle(item)} + + + ))} + + + + {relevantCriteria.map(({ key, label }) => { + const allCriteria = (item: typeof compareItems[0]) => + item.match.allFactors ?? [...item.match.positiveFactors, ...item.match.negativeFactors] + + const factors = compareItems.map(item => + allCriteria(item).find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase())) + ) + const numericScores = factors.map(f => f?.score ?? null) + const presentScores = numericScores.filter((s): s is number => s !== null) + const maxScore = presentScores.length > 0 ? Math.max(...presentScores) : 0 + const winnerIdx = compareItems.length > 1 && presentScores.length > 1 && numericScores.filter(s => s === maxScore).length === 1 + ? numericScores.indexOf(maxScore) + : -1 + + return ( + + + {label} + + {factors.map((factor, idx) => ( + + {factor ? ( + + + {scoreBar(factor.score / 100)} + {idx === winnerIdx && ( + } + sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534', + '& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 }, + '& .MuiChip-label': { px: 0.75 } }} /> + )} + + + {factor.explanation} + + + ) : ( + + )} + + ))} + + ) + })} + +
+
+
+ ) +} diff --git a/src/components/compare/compareUtils.ts b/src/components/compare/compareUtils.ts new file mode 100644 index 0000000..0582c37 --- /dev/null +++ b/src/components/compare/compareUtils.ts @@ -0,0 +1,91 @@ +import type { ReactNode } from 'react' +import { Box, LinearProgress, Typography } from '@mui/material' +import type { WeightingKey } from '../../domain/needBuilder' +import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +export const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) +export const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b' + +export function getProp(item: UnifiedMatchResult) { + return item.resultType !== 'FUTURE_AVAILABILITY' + ? (item as VerifiedPortfolioResult | ExternalMarketResult).property + : null +} + +export function getSig(item: UnifiedMatchResult) { + return item.resultType === 'FUTURE_AVAILABILITY' + ? (item as FutureAvailabilityResult).signal + : null +} + +export const TYPE_META: Record = { + VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' }, + MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, + FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, +} + +export const RISK_LEVEL_ORDER: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 } + +export const CRITERION_ALIASES: Record = { + area: ['area', 'Fläche', 'fläche'], + location: ['location', 'Standort', 'standort'], + budget: ['budget', 'Budget', 'Mietpreis', 'mietpreis'], + timing: ['timing', 'Verfügbarkeit', 'verfügbarkeit'], + prestige: ['prestige', 'Prestige'], + accessibility: ['accessibility', 'ÖV-Anbindung', 'ÖV', 'Erreichbarkeit'], + expansionPotential:['expansionPotential', 'Expansionspotenzial'], + flexibility: ['flexibility', 'Flexibilität'], + visibility: ['visibility', 'Sichtbarkeit', 'visibilityScore'], + footfall: ['footfall', 'Passantenfrequenz', 'passerbyFrequency'], + talentAccess: ['talentAccess', 'Talent-Zugang', 'Talente'], + esg: ['esg', 'ESG', 'Nachhaltigkeit'], + taxEnvironment: ['taxEnvironment', 'Steuerlast', 'Steuerumfeld'], +} + +export function getTitle(item: UnifiedMatchResult): string { + const prop = getProp(item) + const sig = getSig(item) + return prop?.title ?? sig?.companyName ?? sig?.locationHint ?? item.matchId.slice(0, 8) +} + +// ── Row label cell ──────────────────────────────────────────────────────────── + +export const LABEL_SX = { + position: 'sticky' as const, + left: 0, + bgcolor: 'white', + zIndex: 1, + width: 200, + minWidth: 200, + color: '#64748b', + fontSize: 13, + fontWeight: 600, + borderRight: '1px solid #e2e8f0', + verticalAlign: 'top', + py: 1.5, +} + +export const DATA_SX = { + borderLeft: '1px solid #f1f5f9', + minWidth: 220, + verticalAlign: 'top', + py: 1.5, +} + +// ── Score bar helper ────────────────────────────────────────────────────────── + +export function scoreBar(value: number, label?: string): ReactNode { + const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b' + return ( + + + + + {label ?? `${Math.round(value * 100)}%`} + + ) +} diff --git a/src/components/compare/index.ts b/src/components/compare/index.ts index bf03c37..5817254 100644 --- a/src/components/compare/index.ts +++ b/src/components/compare/index.ts @@ -2,3 +2,4 @@ export { CompareEmptyState } from './CompareEmptyState' export { CompareColumnHeader } from './CompareColumnHeader' export { CompareCell, MissingDataCell } from './CompareCell' export { AICompareSummary } from './AICompareSummary' +export { CompareCriteriaCard } from './CompareCriteriaCard' diff --git a/src/components/match-card/FutureAvailabilityCard.tsx b/src/components/match-card/FutureAvailabilityCard.tsx new file mode 100644 index 0000000..289e994 --- /dev/null +++ b/src/components/match-card/FutureAvailabilityCard.tsx @@ -0,0 +1,310 @@ +import { Box, Button, Divider, Typography } from '@mui/material' +import { + AlertCircle, + BarChart2, + Briefcase, + CheckCircle2, + FileCheck, + FileText, + Newspaper, + ShieldCheck, + User, +} from 'lucide-react' +import { useNavigate } from 'react-router' +import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' +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 }) { + const isControlled = vm.signalIsControlled ?? false + const navigate = useNavigate() + + // PRE-MARKET VERIFIED: soft purple / institutional premium + // MARKET SIGNAL: slate blue / analytical + const accentColor = isControlled ? '#7c3aed' : '#1d4ed8' + const headerBg = isControlled ? '#faf5ff' : '#eff6ff' + const borderColor = isControlled ? '#e9d5ff' : '#bfdbfe' + const badgeBg = isControlled ? '#ede9fe' : '#dbeafe' + const badgeColor = isControlled ? '#5b21b6' : '#1e40af' + + const assetLabel = vm.assetType ? (ASSET_TYPE_LABELS[vm.assetType] ?? vm.assetType) : 'Fläche' + const headline = getOpportunityHeadline(vm.signalType, assetLabel) + const sourceMeta = vm.signalSourceType ? (SOURCE_META[vm.signalSourceType] ?? null) : null + const tier = getScoreTier(vm.matchScore) + const theme = SCORE_THEME[tier] + + return ( + + + {/* ── Data header ─────────────────────────────────────────────────────── */} + + + {/* Asset type · location + badge */} + + + {assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''} + + + {isControlled && } + + {isControlled ? 'PRE-MARKET VERIFIED' : 'MARKET SIGNAL'} + + + + + {/* Opportunity headline */} + + {headline} + + + {/* Key facts */} + + {vm.signalAreaSqmEstimate ? ( + + {isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m² + + ) : null} + {vm.availabilityLabel && ( + + {vm.availabilityLabel} + + )} + + + {/* Source attribution */} + {isControlled ? ( + + + Direkte Verwaltungsquelle + + ) : sourceMeta ? ( + + {sourceMeta.icon} + {sourceMeta.label} + + ) : null} + + + {/* ── Score + signal quality strip ────────────────────────────────────── */} + + + + {vm.matchScore}% + + + + {!isControlled && vm.signalProbability !== undefined && ( + + + {Math.round(vm.signalProbability * 100)}% Signalw. + + + )} + + + {/* ── Card body ───────────────────────────────────────────────────────── */} + + + {/* MARKET SIGNAL: probabilistic notice */} + {!isControlled && ( + + + + Probabilistischer Marktindikator — kein bestätigtes Objekt. Dient als strategischer Frühindikator. + + + )} + + {/* PRE-MARKET VERIFIED: confirmed facts */} + {isControlled && (vm.signalConfirmedFacts?.length ?? 0) > 0 && ( + + {(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => ( + + + {fact} + + ))} + + )} + + {/* PRE-MARKET VERIFIED: specific unit info */} + {isControlled && vm.preMarketUnit && ( + + + Freigegebene Einheit + + + + {floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''} + + + {vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m² + + {vm.preMarketUnit.schattenmarktRelease?.availableFrom && ( + + ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })} + + )} + + + )} + + {/* MARKET SIGNAL: market indicators */} + {!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && ( + + + Erkannte Marktindikatoren + + + {(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => ( + + + {ind} + + ))} + + + )} + + {/* Why relevant — match reasons */} + {vm.reasons.length > 0 && ( + <> + + + Warum relevant für Ihre Suche? + + + {vm.reasons.slice(0, 3).map((r, i) => ( + + + + {r.label} + {r.explanation} + + + ))} + + + )} + + {/* Actions */} + + {vm.actions.map(a => ( + + ))} + {isControlled && vm.propertyId && ( + + )} + + + {/* Disclaimer footnote */} + {vm.disclaimer && ( + + {vm.disclaimer} + + )} + + + ) +} diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 23d7b15..832e41c 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -1,327 +1,24 @@ import { Box, Button, Chip, Divider, Typography } from '@mui/material' import { - AlertCircle, - BarChart2, - Briefcase, Building2, CheckCircle2, - ExternalLink, - FileCheck, - FileText, - Newspaper, - ShieldCheck, - User, } from 'lucide-react' -import { useNavigate } from 'react-router' import { useSessionStore } from '../../stores/sessionStore' import { LocationPreview } from '../shared/LocationPreview' +import { HeatBadge } from '../shared/HeatBadge' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' 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` -} +import { FutureAvailabilityCard } from './FutureAvailabilityCard' // ── Constants ───────────────────────────────────────────────────────────────── const RESULT_TYPE_META: Record = { - VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, - EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' }, + VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' }, MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, FUTURE_AVAILABILITY: { label: 'Future Availability', color: '#7c3aed' }, } -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 ────────────────────────────────────────────────── - -function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { - const isControlled = vm.signalIsControlled ?? false - const navigate = useNavigate() - - // PRE-MARKET VERIFIED: soft purple / institutional premium - // MARKET SIGNAL: slate blue / analytical - const accentColor = isControlled ? '#7c3aed' : '#1d4ed8' - const headerBg = isControlled ? '#faf5ff' : '#eff6ff' - const borderColor = isControlled ? '#e9d5ff' : '#bfdbfe' - const badgeBg = isControlled ? '#ede9fe' : '#dbeafe' - const badgeColor = isControlled ? '#5b21b6' : '#1e40af' - - const assetLabel = vm.assetType ? (ASSET_TYPE_LABELS[vm.assetType] ?? vm.assetType) : 'Fläche' - const headline = getOpportunityHeadline(vm.signalType, assetLabel) - const sourceMeta = vm.signalSourceType ? (SOURCE_META[vm.signalSourceType] ?? null) : null - const tier = getScoreTier(vm.matchScore) - const theme = SCORE_THEME[tier] - - return ( - - - {/* ── Data header ─────────────────────────────────────────────────────── */} - - - {/* Asset type · location + badge */} - - - {assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''} - - - {isControlled && } - - {isControlled ? 'PRE-MARKET VERIFIED' : 'MARKET SIGNAL'} - - - - - {/* Opportunity headline */} - - {headline} - - - {/* Key facts */} - - {vm.signalAreaSqmEstimate ? ( - - {isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m² - - ) : null} - {vm.availabilityLabel && ( - - {vm.availabilityLabel} - - )} - - - {/* Source attribution */} - {isControlled ? ( - - - Direkte Verwaltungsquelle - - ) : sourceMeta ? ( - - {sourceMeta.icon} - {sourceMeta.label} - - ) : null} - - - {/* ── Score + signal quality strip ────────────────────────────────────── */} - - - - {vm.matchScore}% - - - - {!isControlled && vm.signalProbability !== undefined && ( - - - {Math.round(vm.signalProbability * 100)}% Signalw. - - - )} - - - {/* ── Card body ───────────────────────────────────────────────────────── */} - - - {/* MARKET SIGNAL: probabilistic notice */} - {!isControlled && ( - - - - Probabilistischer Marktindikator — kein bestätigtes Objekt. Dient als strategischer Frühindikator. - - - )} - - {/* PRE-MARKET VERIFIED: confirmed facts */} - {isControlled && (vm.signalConfirmedFacts?.length ?? 0) > 0 && ( - - {(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => ( - - - {fact} - - ))} - - )} - - {/* PRE-MARKET VERIFIED: specific unit info */} - {isControlled && vm.preMarketUnit && ( - - - Freigegebene Einheit - - - - {floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''} - - - {vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m² - - {vm.preMarketUnit.schattenmarktRelease?.availableFrom && ( - - ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })} - - )} - - - )} - - {/* MARKET SIGNAL: market indicators */} - {!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && ( - - - Erkannte Marktindikatoren - - - {(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => ( - - - {ind} - - ))} - - - )} - - {/* Why relevant — match reasons */} - {vm.reasons.length > 0 && ( - <> - - - Warum relevant für Ihre Suche? - - - {vm.reasons.slice(0, 3).map((r, i) => ( - - - - {r.label} - {r.explanation} - - - ))} - - - )} - - {/* Actions */} - - {vm.actions.map(a => ( - - ))} - {isControlled && vm.propertyId && ( - - )} - - - {/* Disclaimer footnote */} - {vm.disclaimer && ( - - {vm.disclaimer} - - )} - - - ) -} - // ── Main component ──────────────────────────────────────────────────────────── interface Props { @@ -390,6 +87,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro sx={{ bgcolor: 'rgba(30,58,95,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} /> )} + diff --git a/src/components/match-card/MatchCardHeader.tsx b/src/components/match-card/MatchCardHeader.tsx index a0d344f..dc57457 100644 --- a/src/components/match-card/MatchCardHeader.tsx +++ b/src/components/match-card/MatchCardHeader.tsx @@ -1,14 +1,15 @@ import { Box, Chip } from '@mui/material' import { Building2 } from 'lucide-react' import { MatchScoreDisplay } from './MatchScoreDisplay' +import { HeatBadge } from '../shared' import { useSessionStore } from '../../stores/sessionStore' import type { MatchCardViewModel } from './MatchCardViewModel' const RESULT_TYPE_META: Record = { - VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, - EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' }, - MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, - FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, + VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' }, + MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, + FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, } function confidenceColor(score: number): string { @@ -38,8 +39,9 @@ export function MatchCardHeader({ vm, compact }: Props) { {/* Score — leftmost, most prominent */} - {/* Badges: resultType → assetType → confidence → availability → risk */} + {/* Badges: heat → resultType → assetType → confidence → availability → risk */} + = { LEASE_EXPIRY: 'Vertragsende (Pre-Market)', } -const SIGNAL_RELEVANCE_BRIDGE: Record = { - EXPANSION: 'Ein Unternehmen sucht neue Flächen — Ihr Angebot könnte gefragt sein.', - POSSIBLE_MOVE_OUT: 'Dieses Unternehmen könnte seinen Standort aufgeben — die Fläche wird für Sie verfügbar.', - CONSTRUCTION_PROJECT:'Ein Neubau entsteht — neue Flächen könnten zur Vermietung angeboten werden.', - RESTRUCTURING: 'Restrukturierung deutet auf Flächenänderungen hin.', - PROJECT_DEVELOPMENT: 'Projektentwicklung könnte neue Gewerbeflächen schaffen.', - SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten frei werden.', - LEASE_EXPIRY: 'Die Verwaltung hat diese Fläche für kontrolliertes Pre-Market Matching freigegeben — Vertragsende aus internem ERP bestätigt.', +const SIGNAL_ACTION: Record = { + 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 = { @@ -57,6 +62,32 @@ const SENSITIVITY_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 + } +} + interface Props { match: Match signal: FutureSignal | null @@ -66,15 +97,24 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) if (!signal) return null const sourceMeta = SOURCE_META[signal.source.type] ?? null - const credMeta = CREDIBILITY_META[signal.source.credibility] ?? null + const credMeta = CREDIBILITY_META[signal.source.credibility] ?? null const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC - const relevanceBridge = signal.signalType ? (SIGNAL_RELEVANCE_BRIDGE[signal.signalType] ?? null) : null - const probPct = Math.round(signal.probability * 100) + const riskMeta = signal.riskLevel ? (RISK_META[signal.riskLevel] ?? null) : null + const probPct = Math.round(signal.probability * 100) const isVerifiedContract = signal.isVerified && signal.source.type === 'LEASE_CONTRACT' + const action = signal.signalType ? (SIGNAL_ACTION[signal.signalType] ?? null) : null + const allSourceUrls = [ + ...(signal.source.url ? [signal.source.url] : []), + ...(signal.evidence?.sourceUrls ?? []), + ] + + const probBarColor = probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' return ( - + + {/* ── Header ──────────────────────────────────────────────────────────── */} + Future Availability Signal {signal.isVerified && ( @@ -83,34 +123,185 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) Verifiziert )} + + + {ageLabel(signal.createdAt)} + - {/* 1. Quelle & Verlässlichkeit — FIRST */} - - Quelle & Verlässlichkeit + {signal.title && ( + + {signal.title} + + )} - + {/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */} + {signal.aiSummary && ( + + + + KI-Zusammenfassung + + + + {signal.aiSummary} + + + + )} + + {/* ── 2. Strategische Einschätzung ─────────────────────────────────── */} + {signal.strategicInterpretation && ( + + + + Strategische Einschätzung + + + + {signal.strategicInterpretation} + + + + )} + + {/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */} + {action && ( + + + + Empfohlene Aktion + {action.label} + + + )} + + + + {/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */} + + Signal-Kenndaten + + {/* Probability bar */} + + + Eintretenswahrscheinlichkeit + {probPct}% + + + + + + {signal.signalType && ( + + Signaltyp + + + )} + + Zeithorizont + + + ~{signal.timeHorizonMonths} Monate + + + {signal.areaSqmEstimate && ( + + Flächenschätzung + ~{signal.areaSqmEstimate.toLocaleString('de-CH')} m² + + )} + {riskMeta && ( + + Risikoniveau + {riskMeta.label} + + )} + + + + + + {/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */} + {signal.marketIndicators && signal.marketIndicators.length > 0 && ( + + Erkannte Marktindikatoren + + {signal.marketIndicators.map((indicator, i) => ( + + + {indicator} + + ))} + + + )} + + {/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */} + {((signal.confirmedFacts && signal.confirmedFacts.length > 0) || + (signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && ( + + Transparenz — Was ist gesichert? + + {(signal.confirmedFacts ?? []).map((fact, i) => ( + + + {fact} + + ))} + {(signal.unconfirmedFacts ?? []).map((fact, i) => ( + + + {fact} + + ))} + + + )} + + + + {/* ── 7. Quellen & Belege ───────────────────────────────────────────── */} + + + + Quellen & Belege + + + {/* Primary source metadata — always shown */} + {sourceMeta && ( {sourceMeta.icon} - {sourceMeta.label} + {sourceMeta.label} )} {credMeta && ( - - - {credMeta.label} + + + {credMeta.label} )} {signal.source.publishedAt && ( - - Publiziert: {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })} - + + + + {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })} + + )} + {/* Verified contract special box */} {isVerifiedContract && ( - + Vertragsende aus internem ERP bestätigt @@ -122,176 +313,72 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) )} - {signal.source.url && ( - - - - )} - - {signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && ( - - - Weitere Belege ({signal.evidence.sourceUrls.length}) - - - {signal.evidence.sourceUrls.map((url, i) => ( - - - {url} - - ))} - - - )} - - - - - {/* 2. Signal-Kenndaten */} - - {signal.signalType && ( - - Signaltyp - - - )} - - Wahrscheinlichkeit - = 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}% - - - Zeithorizont - ~{signal.timeHorizonMonths} Monate - - {signal.areaSqmEstimate && ( - - Flächenschätzung - ~{signal.areaSqmEstimate.toLocaleString('de-CH')} m² - - )} - - - - - {/* 3. KI-Analyse */} - {signal.aiSummary && ( - - - - KI-Analyse - - - - {signal.aiSummary} - - - - )} - - - - {/* 4. Erkannte Marktindikatoren */} - {signal.marketIndicators && signal.marketIndicators.length > 0 && ( - - - Erkannte Marktindikatoren - + {/* All source URLs */} + {allSourceUrls.length > 0 ? ( - {signal.marketIndicators.map((indicator, i) => ( - - - {indicator} - + {allSourceUrls.map((url, i) => ( + ))} - - )} - - {/* 5. Strategische Interpretation */} - {signal.strategicInterpretation && ( - - - Strategische Interpretation - - - - {signal.strategicInterpretation} + ) : ( + + + Kein direkter Quellenlink verfügbar — Signal basiert auf aggregierten {sourceMeta?.label ?? 'Marktdaten'}. - - )} + )} - {/* 6. Transparenz (Bestätigt / Nicht bestätigt) */} - {((signal.confirmedFacts && signal.confirmedFacts.length > 0) || - (signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && ( - - - Transparenz - - - {(signal.confirmedFacts ?? []).map((fact, i) => ( - - - {fact} - - ))} - {(signal.unconfirmedFacts ?? []).map((fact, i) => ( - - - {fact} - - ))} + {/* Evidence extraction date */} + {signal.evidence?.extractedAt && ( + + + + Daten extrahiert: {new Date(signal.evidence.extractedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'short', year: 'numeric' })} + - - )} + )} - {/* 7. Relevance bridge */} - {relevanceBridge && ( - - - - Warum erscheint dieses Signal in Ihrer Suche? - {relevanceBridge} + {/* Evidence summary */} + {signal.evidence?.summary && ( + + Evidenz-Zusammenfassung + {signal.evidence.summary} - - )} + )} + - {/* 5. Evidence */} - {signal.evidence?.summary && ( - - Evidenz - - {signal.evidence.summary} - - - )} + {/* ── Footer ──────────────────────────────────────────────────────────── */} + - {/* 6. Vertraulichkeit */} - + Vertraulichkeit: + {signal.verifiedBy && ( + <> + Verifiziert von: + {signal.verifiedBy} + + )} - {/* 7. Disclaimer — grey footnote */} - + {signal.disclaimer} diff --git a/src/components/match-detail/MatchDetailPropertyDetails.tsx b/src/components/match-detail/MatchDetailPropertyDetails.tsx new file mode 100644 index 0000000..1f70dbf --- /dev/null +++ b/src/components/match-detail/MatchDetailPropertyDetails.tsx @@ -0,0 +1,68 @@ +import { Box, Chip, Typography } from '@mui/material' +import { ShieldCheck } from 'lucide-react' +import type { PropertyUnit } from '../../domain/property' + +// ── Property detail helpers ──────────────────────────────────────────────────── + +export const FLOOR_LABEL = (level: number) => + level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG` + +export const ASSET_LABELS: Record = { + OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden', + PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)', +} +export const RISK_LABELS: Record = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' } +export const SOURCE_LABELS: Record = { + ERP_IMPORT: 'ERP-Import (intern)', IMMOSCOUT_SCRAPE: 'ImmoScout24', + HOMEGATE_SCRAPE: 'Homegate', MATCHOFFICE_SCRAPE: 'MatchOffice', + NEWHOME_SCRAPE: 'newhome.ch', AI_SIGNAL: 'KI-Signal', MANUAL: 'Manuell erfasst', +} +export const PASSERBY_LABELS: Record = { + LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch', +} + +export function KeyFactRow({ label, value }: { label: string; value?: string | null }) { + if (!value) return null + return ( + + {label} + {value} + + ) +} + +export function UnitStatusChip({ unit }: { unit: PropertyUnit }) { + if (unit.schattenmarktRelease?.enabled) { + return } label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} /> + } + if (unit.available) { + return + } + return +} + +export function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) { + const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate + const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined + return ( + + + + {FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''} + + {unit.currentTenant && {unit.currentTenant}} + + {unit.areaSqm.toLocaleString('de-CH')} m² + {monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : '–'} + + {availableFrom ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : '–'} + + + + ) +} diff --git a/src/components/match-detail/index.ts b/src/components/match-detail/index.ts index 0e45ca6..932bde9 100644 --- a/src/components/match-detail/index.ts +++ b/src/components/match-detail/index.ts @@ -10,3 +10,4 @@ export { MissingInformationPanel } from './MissingInformationPanel' export { SourceProvenancePanel } from './SourceProvenancePanel' export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel' export { NextActionsPanel } from './NextActionsPanel' +export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails' diff --git a/src/components/pipeline/PipelineCard.tsx b/src/components/pipeline/PipelineCard.tsx new file mode 100644 index 0000000..f47339c --- /dev/null +++ b/src/components/pipeline/PipelineCard.tsx @@ -0,0 +1,140 @@ +import { useNavigate } from 'react-router' +import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material' +import { useDraggable } from '@dnd-kit/core' +import { CSS } from '@dnd-kit/utilities' +import { ExternalLink, MapPin, MessageSquare } from 'lucide-react' +import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay' +import { HeatBadge } from '../shared' +import type { PipelineItem } from '../../domain/pipeline' +import { STAGES, RESULT_TYPE_LABEL, RESULT_TYPE_COLOR } from './pipelineConstants' +import { detailPath } from './pipelineUtils' + +// ── DraggableCard ───────────────────────────────────────────────────────────── + +export function DraggableCard({ + item, + isSelected, + onSelect, + isDragOverlay = false, + onChatClick, +}: { + item: PipelineItem + isSelected: boolean + onSelect: (item: PipelineItem) => void + isDragOverlay?: boolean + onChatClick?: (e: React.MouseEvent) => void +}) { + const navigate = useNavigate() + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id }) + const stageConfig = STAGES.find(s => s.key === item.stage)! + const path = detailPath(item) + + const style = !isDragOverlay ? { + transform: CSS.Translate.toString(transform), + opacity: isDragging ? 0.35 : 1, + transition: isDragging ? undefined : 'opacity 0.15s ease', + } : undefined + + return ( + !isDragging && onSelect(item)} + sx={{ + p: 1.5, + border: isSelected && !isDragOverlay + ? '2px solid #1e3a5f' + : isDragOverlay + ? '2px solid transparent' + : '2px solid transparent', + cursor: isDragOverlay ? 'grabbing' : 'grab', + bgcolor: isDragOverlay ? 'white' : isSelected ? '#eff6ff' : 'white', + boxShadow: isDragOverlay ? '0 8px 24px rgba(0,0,0,0.18)' : '0 1px 3px rgba(0,0,0,0.08)', + '&:hover': isDragOverlay ? {} : { + boxShadow: '0 2px 8px rgba(0,0,0,0.12)', + borderColor: isSelected ? '#1e3a5f' : '#bfdbfe', + }, + transition: isDragOverlay ? undefined : 'box-shadow 0.15s, border-color 0.15s', + userSelect: 'none', + rotate: isDragOverlay ? '2deg' : undefined, + }} + {...(isDragOverlay ? {} : { ...attributes, ...listeners })} + > + {/* Row 1: Score + type chip + chat icon */} + + + + + + + + {!isDragOverlay && ( + + {item.inquiryId && onChatClick && ( + + + + + + )} + {path && ( + + { e.stopPropagation(); navigate(path) }} + sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#1e3a5f', bgcolor: '#eff6ff' } }} + > + + + + )} + + )} + + + {/* Row 2: Title */} + + {item.title} + + + {/* Row 3: Location */} + + + + {item.propertyAddress ?? item.location} + + + + {/* Row 4: Area + rent */} + {(item.areaLabel || item.rentLabel) && ( + + {item.areaLabel && ( + {item.areaLabel} + )} + {item.areaLabel && item.rentLabel && ( + + )} + {item.rentLabel && ( + {item.rentLabel} + )} + + )} + + {/* Row 5: Notes preview */} + {item.notes && ( + + {item.notes} + + )} + + ) +} diff --git a/src/components/pipeline/PipelineColumn.tsx b/src/components/pipeline/PipelineColumn.tsx new file mode 100644 index 0000000..b7387b4 --- /dev/null +++ b/src/components/pipeline/PipelineColumn.tsx @@ -0,0 +1,58 @@ +import { Box, Typography } from '@mui/material' +import { useDroppable } from '@dnd-kit/core' +import type { PipelineItem, PipelineStage } from '../../domain/pipeline' +import { DraggableCard } from './PipelineCard' + +// ── DroppableColumn ─────────────────────────────────────────────────────────── + +export function DroppableColumn({ + stage, + items, + selectedId, + onSelect, + onChatClick, + isOver, +}: { + stage: { key: PipelineStage; label: string; color: string; bgColor: string } + items: PipelineItem[] + selectedId: string | null + onSelect: (item: PipelineItem) => void + onChatClick: (inquiryId: string) => void + isOver: boolean +}) { + const { setNodeRef } = useDroppable({ id: stage.key }) + + return ( + + {items.map(item => ( + { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined} + /> + ))} + {items.length === 0 && ( + + + {isOver ? 'Hier ablegen' : 'Leer'} + + + )} + + ) +} diff --git a/src/components/pipeline/PipelineDetailPanel.tsx b/src/components/pipeline/PipelineDetailPanel.tsx new file mode 100644 index 0000000..abc2794 --- /dev/null +++ b/src/components/pipeline/PipelineDetailPanel.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router' +import { + Box, Button, Chip, Divider, IconButton, TextField, Tooltip, Typography, +} from '@mui/material' +import { + AlertTriangle, ChevronRight, CheckCircle, ExternalLink, FileText, + MapPin, MessageSquare, Sparkles, StickyNote, X, +} from 'lucide-react' +import { usePipelineStore } from '../../stores/pipelineStore' +import type { PipelineItem } from '../../domain/pipeline' +import { STAGES, NEXT_STAGE, MOCK_DOCS } from './pipelineConstants' +import { scoreColor, detailPath, getKiInsight } from './pipelineUtils' + +// ── DetailPanel ─────────────────────────────────────────────────────────────── + +export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) { + const navigate = useNavigate() + const { moveStage, updateNotes, loseItem } = usePipelineStore() + const path = detailPath(item) + const [notes, setNotes] = useState(item.notes ?? '') + const stageConfig = STAGES.find(s => s.key === item.stage)! + const stageIndex = STAGES.findIndex(s => s.key === item.stage) + const nextStage = NEXT_STAGE[item.stage] + const ki = getKiInsight(item) + const docs = MOCK_DOCS[item.id] ?? [] + const isClosed = item.stage === 'CLOSED_WON' || item.stage === 'CLOSED_LOST' + const progressIdx = Math.min(stageIndex, 4) + + return ( + + {/* Header */} + + + + {item.title} + {item.location} + + + + {item.matchScore}% + + {path && ( + + navigate(path)} sx={{ color: '#64748b', '&:hover': { color: '#1e3a5f' } }}> + + + + )} + + + + + + + + {STAGES.slice(0, 5).map((s, idx) => ( + + ))} + + + + {item.inquiryId && ( + } + label="Chat" + size="small" + onClick={() => navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)} + sx={{ + height: 22, fontSize: '0.75rem', cursor: 'pointer', + bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, + border: '1px solid #bfdbfe', + '& .MuiChip-icon': { color: '#1e3a5f' }, + '&:hover': { bgcolor: '#dbeafe' }, + }} + /> + )} + + + + {/* Property / unit address */} + {item.propertyAddress && ( + + + {item.propertyAddress} + + )} + + + + {/* KI */} + + + + + KI Einschätzung + + + + {ki.summary} + + {ki.positives.map((p, i) => ( + + + {p} + + ))} + {ki.risks.map((r, i) => ( + + + {r} + + ))} + + + + + {/* Stage actions */} + {!isClosed && ( + + + Nächste Aktion + + + {nextStage && ( + + )} + + + + )} + + + + {/* Notes */} + + + + + Notizen + + + setNotes(e.target.value)} + onBlur={() => updateNotes(item.id, notes)} + sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }} + /> + + + + + {/* Documents */} + + + + + Dokumente + + + {docs.length === 0 ? ( + Noch keine Dokumente. + ) : ( + + {docs.map((doc, i) => ( + + + {doc.name} + {doc.date} + + ))} + + )} + + + + + + + {item.availabilityLabel && Verfügbar: {item.availabilityLabel}} + {item.assignedTo && Verantwortlich: {item.assignedTo}} + + Hinzugefügt: {new Date(item.addedAt).toLocaleDateString('de-CH')} + + + + + + ) +} diff --git a/src/components/pipeline/pipelineConstants.ts b/src/components/pipeline/pipelineConstants.ts new file mode 100644 index 0000000..d6de5e3 --- /dev/null +++ b/src/components/pipeline/pipelineConstants.ts @@ -0,0 +1,47 @@ +import type { PipelineStage } from '../../domain/pipeline' + +// ── Stage config ────────────────────────────────────────────────────────────── + +export const STAGES = [ + { key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' }, + { key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' }, + { key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' }, + { key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' }, + { key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' }, + { key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' }, + { key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' }, +] as const + +export const NEXT_STAGE: Partial> = { + SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' }, + DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' }, + QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' }, + VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' }, + NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' }, +} + +export const RESULT_TYPE_LABEL: Record = { + VERIFIED_PORTFOLIO: 'Plattform', + EXTERNAL_MARKET: 'Plattform', + MAISON_WORK: 'Maison Work', + FUTURE_AVAILABILITY: 'Future', +} + +export const RESULT_TYPE_COLOR: Record = { + VERIFIED_PORTFOLIO: '#1e3a5f', + EXTERNAL_MARKET: '#1e3a5f', + MAISON_WORK: '#0369a1', + FUTURE_AVAILABILITY: '#7c3aed', +} + +export const MOCK_DOCS: Record = { + 'pl-001': [ + { name: 'Expose_Zollstrasse12.pdf', date: '05.05.2026' }, + { name: 'Grundriss_EG.pdf', date: '08.05.2026' }, + { name: 'Mietvertrag_Entwurf.docx', date: '14.05.2026' }, + ], + 'pl-007': [ + { name: 'Expose_Stadthaus_Bern.pdf', date: '12.04.2026' }, + { name: 'Mietvertrag_unterschrieben.pdf', date: '02.05.2026' }, + ], +} diff --git a/src/components/pipeline/pipelineUtils.ts b/src/components/pipeline/pipelineUtils.ts new file mode 100644 index 0000000..39efff3 --- /dev/null +++ b/src/components/pipeline/pipelineUtils.ts @@ -0,0 +1,51 @@ +import type { PipelineItem } from '../../domain/pipeline' + +export function scoreColor(score: number) { + return score >= 80 ? '#1a7a4a' : score >= 65 ? '#d97706' : '#c0392b' +} + +export function detailPath(item: PipelineItem): string | null { + // propertyId is always stable across sessions — prefer it + if (item.propertyId) return `/demand/property/${item.propertyId}` + // matchId / UUID only works in the same session (matchStore is ephemeral) + if (item.matchId) return `/demand/results/${item.matchId}` + if (item.id.startsWith('match-')) return `/demand/results/${item.id}` + return null +} + +export function getKiInsight(item: PipelineItem): { summary: string; positives: string[]; risks: string[] } { + if (item.stage === 'SAVED') return { + summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt qualifiziert werden soll.`, + positives: [`Match ${item.matchScore}%`], + risks: ['Noch nicht qualifiziert'], + } + if (item.stage === 'CLOSED_WON') return { + summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match abgeschlossen.`, + positives: ['Vertraglich gesichert', `Match ${item.matchScore}%`, 'Alle Kriterien erfüllt'], + risks: [], + } + if (item.stage === 'CLOSED_LOST') return { + summary: item.notes ?? 'Objekt nicht realisiert.', + positives: [], + risks: ['Nicht verfügbar', 'Alternative Optionen prüfen'], + } + const s = item.matchScore + return { + summary: s >= 80 + ? `Starkes Objekt (${s}%) — deckt die wesentlichen Suchkriterien ab. Prozess aktiv weitertreiben.` + : s >= 65 + ? `Solides Objekt (${s}%) mit Potenzial. Gezielte Klärung offener Punkte empfohlen.` + : `Schwächerer Match (${s}%). Abweichungen kritisch prüfen bevor weitere Ressourcen investiert werden.`, + positives: [ + ...(s >= 80 ? [`Match ${s}% — hohe Übereinstimmung`] : []), + ...(item.areaLabel ? [`Fläche: ${item.areaLabel}`] : []), + ...(item.resultType === 'VERIFIED_PORTFOLIO' ? ['Geprüftes Portfolio-Objekt'] : []), + ...(item.stage === 'NEGOTIATION' ? ['Verhandlung läuft — kurz vor Abschluss'] : []), + ].slice(0, 3), + risks: [ + ...(s < 80 ? [`Match ${s}% — Abweichungen prüfen`] : []), + ...(item.notes?.includes('Budget') ? ['Budget-Diskrepanz erwähnt'] : []), + ...(item.resultType === 'FUTURE_AVAILABILITY' ? ['Verfügbarkeit noch nicht bestätigt'] : []), + ].slice(0, 2), + } +} diff --git a/src/components/results/ResultFeedHeader.tsx b/src/components/results/ResultFeedHeader.tsx index cd908ab..0d24fb8 100644 --- a/src/components/results/ResultFeedHeader.tsx +++ b/src/components/results/ResultFeedHeader.tsx @@ -3,14 +3,14 @@ import { ViewToggle } from '../shared' interface Props { total: number - verifiedCount: number - externalCount: number + platformCount: number + maisonWorkCount: number futureCount: number view?: 'list' | 'grid' onViewChange?: (v: 'list' | 'grid') => void } -export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCount, view = 'list', onViewChange }: Props) { +export function ResultFeedHeader({ total, platformCount, maisonWorkCount, futureCount, view = 'list', onViewChange }: Props) { return ( @@ -18,7 +18,7 @@ export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCo {total} Empfehlungen - {verifiedCount} Verifiziert · {externalCount} Extern · {futureCount} Marktsignale + {platformCount} Plattform · {maisonWorkCount} Maison Work · {futureCount} Future Availability {onViewChange && ( diff --git a/src/components/results/ResultFilterBar.tsx b/src/components/results/ResultFilterBar.tsx index 4b7b327..dfd130e 100644 --- a/src/components/results/ResultFilterBar.tsx +++ b/src/components/results/ResultFilterBar.tsx @@ -1,8 +1,7 @@ import { Box, Card, Chip, Divider, Stack, Typography } from '@mui/material' import { Building2, Zap } from 'lucide-react' -import type { ResultType } from '../../domain/enums' -type FilterSource = Exclude | 'ALL' +type FilterSource = 'ALL' | 'PLATFORM' | 'MAISON_WORK' type SortBy = 'score' | 'rent' | 'area' interface Props { @@ -18,9 +17,9 @@ interface Props { } const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [ - { value: 'ALL', label: 'Alle', color: '#1e3a5f' }, - { value: 'EXTERNAL_MARKET', label: 'Direktinserat', color: '#b45309' }, - { value: 'MAISON_WORK', label: 'Maison Work', color: '#0369a1' }, + { value: 'ALL', label: 'Alle', color: '#1e3a5f' }, + { value: 'PLATFORM', label: 'Plattform', color: '#1e3a5f' }, + { value: 'MAISON_WORK', label: 'Maison Work', color: '#0369a1' }, ] const SORT_OPTIONS: { value: SortBy; label: string }[] = [ diff --git a/src/components/shared/HeatBadge.tsx b/src/components/shared/HeatBadge.tsx new file mode 100644 index 0000000..963a9cb --- /dev/null +++ b/src/components/shared/HeatBadge.tsx @@ -0,0 +1,42 @@ +import { Box, Tooltip, Typography } from '@mui/material' +import { Flame } from 'lucide-react' +import { getHeatLevel, heatTooltip } from '../../lib/propertyHeat' + +interface Props { + propertyId: string | undefined + size?: 'sm' | 'md' +} + +export function HeatBadge({ propertyId, size = 'md' }: Props) { + const level = getHeatLevel(propertyId) + if (!level || !propertyId) return null + + const isVeryHot = level === 'VERY_HOT' + const tooltip = heatTooltip(propertyId) + + const iconSize = size === 'sm' ? 10 : 12 + const fontSize = size === 'sm' ? '0.6rem' : '0.65rem' + const label = isVeryHot ? 'Sehr gefragt' : 'Gefragt' + const bgColor = isVeryHot ? '#fef3c7' : '#fff7ed' + const border = isVeryHot ? '1px solid #fcd34d' : '1px solid #fed7aa' + const iconColor = isVeryHot ? '#d97706' : '#ea580c' + const textColor = isVeryHot ? '#92400e' : '#c2410c' + + return ( + + + + + {label} + + + + ) +} diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 8ab6fc4..7fc6bf8 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -1,4 +1,5 @@ export { ViewToggle } from './ViewToggle' +export { HeatBadge } from './HeatBadge' export { LocationPreview } from './LocationPreview' export { PropertyMap } from './PropertyMap' export { getScoreTier, SCORE_THEME } from './scoreTheme' diff --git a/src/hooks/useMatchDetailData.ts b/src/hooks/useMatchDetailData.ts new file mode 100644 index 0000000..ba61d9f --- /dev/null +++ b/src/hooks/useMatchDetailData.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query' +import { useMatchDetail } from './useMatches' +import { propertyService } from '../services/propertyService' +import { needService } from '../services/needService' +import { futureSignalService } from '../services/futureSignalService' +import type { Property } from '../domain/property' +import type { Need } from '../domain/need' +import type { FutureSignal } from '../domain/futureSignal' + +type Match = NonNullable['data']> + +export function useMatchDetailData(matchId: string) { + const { data: match, isLoading } = useMatchDetail(matchId) + const isFuture = match?.resultType === 'FUTURE_AVAILABILITY' + + const { data: property = null } = useQuery({ + queryKey: ['property', match?.propertyId], + queryFn: () => propertyService.getById(match!.propertyId), + enabled: !!match && !isFuture, + select: r => r.data ?? null, + }) + + const { data: need = null } = useQuery({ + queryKey: ['need', match?.needId], + queryFn: () => needService.getById(match!.needId), + enabled: !!match?.needId, + select: r => r.data ?? null, + }) + + const { data: signal = null } = useQuery({ + queryKey: ['signal', match?.resultId], + queryFn: async () => { + // resultId may be a signal ID ('signal-002') or a property ID ('prop-006') + const byId = await futureSignalService.getById(match!.resultId!) + if (byId.data) return byId.data + const byProp = await futureSignalService.getByProperty(match!.resultId!) + return byProp.data[0] ?? null + }, + enabled: !!match && isFuture && !!match.resultId, + }) + + return { + match: match as Match | null | undefined, + property, + need, + signal, + isLoading, + isFuture, + } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4ce06fa..364f30b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -66,9 +66,9 @@ export const ASSET_TYPE_LABELS: Record = { // Result type display labels export const RESULT_TYPE_LABELS: Record = { - VERIFIED_PORTFOLIO: 'Verified Portfolio', - EXTERNAL_MARKET: 'Direktinserat', - MAISON_WORK: 'Maison Work', + VERIFIED_PORTFOLIO: 'Plattform', + EXTERNAL_MARKET: 'Plattform', + MAISON_WORK: 'Maison Work', FUTURE_AVAILABILITY: 'Zukunftssignal', } diff --git a/src/lib/ds.ts b/src/lib/ds.ts index 5b2c9f1..23f2573 100644 --- a/src/lib/ds.ts +++ b/src/lib/ds.ts @@ -7,7 +7,7 @@ import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants' export const DS_COLORS = { resultType: { VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' }, - EXTERNAL_MARKET: { bg: 'rgba(180,83,9,0.10)', fg: '#b45309' }, + EXTERNAL_MARKET: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' }, MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' }, FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' }, }, diff --git a/src/lib/propertyHeat.ts b/src/lib/propertyHeat.ts new file mode 100644 index 0000000..e2f291a --- /dev/null +++ b/src/lib/propertyHeat.ts @@ -0,0 +1,42 @@ +// Mock demand-heat data per property. +// In production: aggregated from cross-user pipeline additions, search impressions, and view counts. + +export interface PropertyHeat { + searchCount: number // distinct active needs that matched this property + pipelineCount: number // pipeline entries across all users + viewCount: number // total detail-page opens +} + +export const PROPERTY_HEAT: Record = { + 'prop-001': { searchCount: 7, pipelineCount: 4, viewCount: 52 }, + 'prop-007': { searchCount: 5, pipelineCount: 3, viewCount: 38 }, + 'prop-043': { searchCount: 9, pipelineCount: 2, viewCount: 61 }, + 'prop-012': { searchCount: 4, pipelineCount: 3, viewCount: 29 }, + 'prop-015': { searchCount: 6, pipelineCount: 2, viewCount: 44 }, + 'prop-003': { searchCount: 3, pipelineCount: 2, viewCount: 21 }, + 'prop-042': { searchCount: 4, pipelineCount: 1, viewCount: 33 }, + 'prop-044': { searchCount: 8, pipelineCount: 2, viewCount: 57 }, + 'prop-040': { searchCount: 5, pipelineCount: 2, viewCount: 31 }, + 'prop-002': { searchCount: 3, pipelineCount: 2, viewCount: 18 }, +} + +export type HeatLevel = 'HOT' | 'VERY_HOT' | null + +export function getHeatLevel(propertyId: string | undefined): HeatLevel { + if (!propertyId) return null + const h = PROPERTY_HEAT[propertyId] + if (!h) return null + if (h.pipelineCount >= 3 || h.searchCount >= 7) return 'VERY_HOT' + if (h.pipelineCount >= 2 || h.searchCount >= 4) return 'HOT' + return null +} + +export function heatTooltip(propertyId: string): string { + const h = PROPERTY_HEAT[propertyId] + if (!h) return '' + const parts: string[] = [] + if (h.searchCount > 0) parts.push(`${h.searchCount} aktive Suchen`) + if (h.pipelineCount > 0) parts.push(`${h.pipelineCount}× in Pipeline`) + if (h.viewCount > 0) parts.push(`${h.viewCount} Aufrufe`) + return parts.join(' · ') +} diff --git a/src/mock-data/futureSignals.ts b/src/mock-data/futureSignals.ts index a5ae39f..fbbbbf1 100644 --- a/src/mock-data/futureSignals.ts +++ b/src/mock-data/futureSignals.ts @@ -57,6 +57,15 @@ export const mockFutureSignals: FutureSignal[] = [ strategicInterpretation: 'Die Restrukturierung erhöht die Wahrscheinlichkeit einer Standortschliessung in Reinach. Kein Auszugstermin bestätigt — Eigentümer sollte Kontakt zum Mieter suchen.', confirmedFacts: ['Restrukturierung der Muttergesellschaft offiziell bestätigt', 'Stellenabbau angekündigt', 'Standort Reinach betroffen'], unconfirmedFacts: ['Ob Standort Reinach geschlossen wird', 'Zeitpunkt des Auszugs', 'Ob Fläche vermietet oder verkauft'], + evidence: { + summary: 'Drei unabhängige Presseartikel belegen die Restrukturierungsankündigung. LinkedIn-Daten zeigen Mitarbeiterrückgang von 210 auf 185 Personen. Der Standort Reinach BL taucht in internen Dokumenten als Prüfkanditat auf.', + sourceUrls: [ + 'https://www.nzz.ch/wirtschaft/helvetia-gruppe-restrukturierung-2025', + 'https://www.handelszeitung.ch/unternehmen/helvetia-produktion-stellenabbau', + 'https://www.bzbasel.ch/wirtschaft/reinach-industrie-stellenabbau-helvetia', + ], + extractedAt: '2025-05-10T07:00:00Z', + }, }, // signal-003: Bern Wankdorf — Neubau Büro/Gewerbe @@ -96,6 +105,15 @@ export const mockFutureSignals: FutureSignal[] = [ strategicInterpretation: 'Hohe Realisierungswahrscheinlichkeit durch erteilte Baubewilligung. Wankdorf hat gute ÖV-Anbindung (Tram, Bahn) und wächst als Business-Quartier. Frühzeitiges Interesse anmelden.', confirmedFacts: ['Baubewilligung erteilt', 'Fläche 4\'500 m² und Standort bestätigt', 'Fertigstellung Q1 2027 aus Baugesuch'], unconfirmedFacts: ['Endmietzins pro m²', 'Ob Fläche bereits vorvermietet', 'Ausbaustandard OG'], + evidence: { + summary: 'Baubewilligung Nr. 2025-BW-0142 öffentlich eingesehen im Amtsblatt Kanton Bern. Grundrisse und Nutzungskonzept aus Baugesuch extrahiert. Keine laufende Vermietungsausschreibung gefunden.', + sourceUrls: [ + 'https://www.amtsblatt.be.ch/baubewilligungen/2025-BW-0142', + 'https://www.wankdorf-immobilien.ch/projekte/wankdorf-west', + 'https://www.bern.ch/stadtentwicklung/wankdorf-business-park', + ], + extractedAt: '2025-05-05T08:30:00Z', + }, }, // signal-005: Finanz & Treuhand AG — möglicher Auszug Zürich-Nord @@ -135,6 +153,14 @@ export const mockFutureSignals: FutureSignal[] = [ strategicInterpretation: 'Adressänderung auf LinkedIn kombiniert mit Stellenabbau ist ein starkes Frühsignal für Standortaufgabe. Frühzeitiger Kontakt mit Mieter empfohlen.', confirmedFacts: ['Mitarbeiterzahl-Rückgang auf LinkedIn dokumentiert', 'Adressänderung auf LinkedIn publiziert', 'Aktueller Standort Seebach bekannt'], unconfirmedFacts: ['Ob Mietvertrag bereits gekündigt', 'Zeitpunkt des Auszugs', 'Ob Zusammenschluss mit Oerlikon-Standort geplant'], + evidence: { + summary: 'LinkedIn-Firmenprofil zeigt Standortwechsel von Seebach zu Oerlikon (erfasst April 2025). Mitarbeiterzahl sank von 38 auf 31 in 6 Monaten. Marktdaten: Leerstand Zürich-Nord Q1 2025 gestiegen.', + sourceUrls: [ + 'https://www.linkedin.com/company/finanz-treuhand-ag', + 'https://www.wuest.ch/de/marktreport/zuerich-nord-leerstand-q1-2025', + ], + extractedAt: '2025-04-28T10:00:00Z', + }, }, // signal-006: Luzern Inseli — Neubau Büro/Gewerbe am Wasser @@ -172,6 +198,14 @@ export const mockFutureSignals: FutureSignal[] = [ strategicInterpretation: 'Attraktive Wasserlage mit ÖV-Direktanbindung. Nutzungskonzept noch offen — frühzeitiges Interesse anmelden sinnvoll, bevor Vermietungsmandat vergeben wird.', confirmedFacts: ['Baubewilligung öffentlich eingereicht', 'Standort Inseli-Quartier bestätigt', 'Fertigstellung Q1 2027 aus Gesuch'], unconfirmedFacts: ['Endgültige Nutzungsaufteilung (Büro vs. Retail)', 'Mietzinsniveau', 'Ob Vermietungsmandat vergeben'], + evidence: { + summary: 'Baubewilligung im kantonalen Amtsblatt Luzern eingesehen. Pläne zeigen 4 Geschosse: EG Retail/Gastronomie, OG 1–3 Büro. Kein Vermietungsinserat gefunden — Projekt noch in früher Phase.', + sourceUrls: [ + 'https://www.amtsblatt.lu.ch/baubewilligungen/2025-LU-0098', + 'https://www.luzern.ch/stadtentwicklung/inseli-quartier', + ], + extractedAt: '2025-04-15T09:00:00Z', + }, }, // signal-008: Textilhaus Zürich AG — Retail-Verkleinerung Niederdorf @@ -211,6 +245,15 @@ export const mockFutureSignals: FutureSignal[] = [ strategicInterpretation: 'Struktureller Rückgang im stationären Textilhandel trifft kleine Altstadt-Läden besonders. Personalabbau und Branchentrend ergeben ein Verlagerungssignal.', confirmedFacts: ['Branchentrend stationärer Handel dokumentiert', 'Aktueller Standort Münstergasse bekannt', 'Personalabbau auf LinkedIn sichtbar'], unconfirmedFacts: ['Ob Mietvertrag noch läuft oder ausläuft', 'Ob Filialnetz insgesamt verkleinert', 'Zeitpunkt eines möglichen Auszugs'], + evidence: { + summary: 'LinkedIn-Profil Textilhaus Zürich AG: 42 → 31 Mitarbeitende in 12 Monaten. Branchendaten GfK/IFH: Onlineanteil CH Textilhandel 38% (2024). Lokale Leerstandserhebung JLL Zürich Altstadt Q4 2024.', + sourceUrls: [ + 'https://www.linkedin.com/company/textilhaus-zuerich', + 'https://www.ifhkoeln.de/studie-schweizer-textilhandel-online-2024', + 'https://www.jll.ch/marktberichte/retail-leerstand-zuerich-2024', + ], + extractedAt: '2025-03-30T11:00:00Z', + }, }, // signal-009: Winterthur Zentrum — Neubau Bürofläche @@ -250,6 +293,15 @@ export const mockFutureSignals: FutureSignal[] = [ strategicInterpretation: 'Amtlich bestätigte Baubewilligung — sehr hohe Realisierungswahrscheinlichkeit. Bahnhof-Nähe und tiefe Leerstandsquote machen Winterthur attraktiv. Vermietungsstart erfahrungsgemäss 9–12 Monate vor Fertigstellung.', confirmedFacts: ['Baubewilligung rechtskräftig erteilt', 'Standort Zentrum Technikum bestätigt', 'Fertigstellung Q2 2027 aus Gesuch'], unconfirmedFacts: ['Ob Vorvermietung bereits läuft', 'Endmietzins pro m²', 'Aufteilung EG/OG'], + evidence: { + summary: 'Baubewilligung im Amtsblatt Kanton Zürich rechtskräftig. Marktbericht CBRE Winterthur Q1 2025: Leerstand 4.2%, tiefster Wert seit 10 Jahren. Kein aktives Vermietungsinserat auf Homegate/Comparis gefunden.', + sourceUrls: [ + 'https://www.amtsblatt.zh.ch/baubewilligungen/2025-ZH-1204', + 'https://www.cbre.ch/de/research/winterthur-bueroleerstand-q1-2025', + 'https://www.winterthur.ch/stadtentwicklung/technikum-areal', + ], + extractedAt: '2025-04-10T09:30:00Z', + }, }, // signal-011: Mode Boutique Bern AG — Rückgang Altstadt diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx index 62db947..6962d45 100644 --- a/src/pages/demand/Compare.tsx +++ b/src/pages/demand/Compare.tsx @@ -5,7 +5,6 @@ import { Button, Card, Chip, - LinearProgress, Stack, Table, TableBody, @@ -28,81 +27,33 @@ import { CompareCell, MissingDataCell, AICompareSummary, + CompareCriteriaCard, } from '../../components/compare' import { AddToPipelineDialog } from '../../components/shortlist' -import type { UnifiedMatchResult } from '../../domain/unifiedResult' -import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult' +import { + HARD_CRITERIA, + SCORE_COLOR, + TYPE_META, + RISK_LEVEL_ORDER, + CRITERION_ALIASES, + getProp, + getSig, + LABEL_SX, + DATA_SX, + scoreBar, +} from '../../components/compare/compareUtils' -// ── Helpers ─────────────────────────────────────────────────────────────────── +// ── Module-level helpers ────────────────────────────────────────────────────── -const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) -const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b' - -function getProp(item: UnifiedMatchResult) { - return item.resultType !== 'FUTURE_AVAILABILITY' - ? (item as VerifiedPortfolioResult | ExternalMarketResult).property - : null -} - -function getSig(item: UnifiedMatchResult) { - return item.resultType === 'FUTURE_AVAILABILITY' - ? (item as FutureAvailabilityResult).signal - : null -} - -const TYPE_META: Record = { - VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, - EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' }, - MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, - FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, -} - -const RISK_LEVEL_ORDER: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 } - -const CRITERION_ALIASES: Record = { - area: ['area', 'Fläche', 'fläche'], - location: ['location', 'Standort', 'standort'], - budget: ['budget', 'Budget', 'Mietpreis', 'mietpreis'], - timing: ['timing', 'Verfügbarkeit', 'verfügbarkeit'], - prestige: ['prestige', 'Prestige'], - accessibility: ['accessibility', 'ÖV-Anbindung', 'ÖV', 'Erreichbarkeit'], - expansionPotential:['expansionPotential', 'Expansionspotenzial'], - flexibility: ['flexibility', 'Flexibilität'], - visibility: ['visibility', 'Sichtbarkeit', 'visibilityScore'], - footfall: ['footfall', 'Passantenfrequenz', 'passerbyFrequency'], - talentAccess: ['talentAccess', 'Talent-Zugang', 'Talente'], - esg: ['esg', 'ESG', 'Nachhaltigkeit'], - taxEnvironment: ['taxEnvironment', 'Steuerlast', 'Steuerumfeld'], -} - -function getTitle(item: UnifiedMatchResult): string { - const prop = getProp(item) - const sig = getSig(item) - return prop?.title ?? sig?.companyName ?? sig?.locationHint ?? item.matchId.slice(0, 8) -} - -// ── Row label cell ──────────────────────────────────────────────────────────── - -const LABEL_SX = { - position: 'sticky' as const, - left: 0, - bgcolor: 'white', - zIndex: 1, - width: 200, - minWidth: 200, - color: '#64748b', - fontSize: 13, - fontWeight: 600, - borderRight: '1px solid #e2e8f0', - verticalAlign: 'top', - py: 1.5, -} - -const DATA_SX = { - borderLeft: '1px solid #f1f5f9', - minWidth: 220, - verticalAlign: 'top', - py: 1.5, +function row(label: string, cells: ReactNode[]) { + return ( + + {label} + {cells.map((cell, i) => ( + {cell} + ))} + + ) } // ── Main component ──────────────────────────────────────────────────────────── @@ -136,8 +87,6 @@ export default function Compare() { .sort((a, b) => b.weight - a.weight) : [] - const maxRelevantWeight = relevantCriteria[0]?.weight ?? 1 - const weightedTotals = compareItems.map(item => relevantCriteria.reduce((sum, { key, weight }) => { const factor = [...item.match.positiveFactors, ...item.match.negativeFactors] @@ -177,32 +126,6 @@ export default function Compare() { ) const maxMissingCritical = Math.max(...missingCriticalCounts) - // ── Shared render helpers ───────────────────────────────────────────────── - - function row(label: string, cells: ReactNode[]) { - return ( - - {label} - {cells.map((cell, i) => ( - {cell} - ))} - - ) - } - - function scoreBar(value: number, label?: string) { - const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b' - return ( - - - - - {label ?? `${Math.round(value * 100)}%`} - - ) - } - return ( @@ -233,88 +156,12 @@ export default function Compare() { {/* Criteria Head-to-Head */} {activeNeed && relevantCriteria.length > 0 && ( - - - - - Vergleich nach Suchkriterien - Basierend auf der Suche: {activeNeed.companyName} - - {overallWinnerIdx !== -1 && ( - - - - Gesamtsieger - {getTitle(compareItems[overallWinnerIdx])} - - - )} - - - - - - Kriterium - {compareItems.map(item => ( - - - {getTitle(item)} - - - ))} - - - - {relevantCriteria.map(({ key, label, weight }) => { - const allCriteria = (item: typeof compareItems[0]) => - item.match.allFactors ?? [...item.match.positiveFactors, ...item.match.negativeFactors] - - const factors = compareItems.map(item => - allCriteria(item).find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase())) - ) - const numericScores = factors.map(f => f?.score ?? null) - const presentScores = numericScores.filter((s): s is number => s !== null) - const maxScore = presentScores.length > 0 ? Math.max(...presentScores) : 0 - const winnerIdx = compareItems.length > 1 && presentScores.length > 1 && numericScores.filter(s => s === maxScore).length === 1 - ? numericScores.indexOf(maxScore) - : -1 - - return ( - - - {label} - - {factors.map((factor, idx) => ( - - {factor ? ( - - - {scoreBar(factor.score / 100)} - {idx === winnerIdx && ( - } - sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534', - '& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 }, - '& .MuiChip-label': { px: 0.75 } }} /> - )} - - - {factor.explanation} - - - ) : ( - - )} - - ))} - - ) - })} - -
-
-
+ )} diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index 6050378..8dc80f4 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -1,12 +1,6 @@ import { Box, Button, Chip, CircularProgress, Divider, Paper, Typography } from '@mui/material' -import { ArrowLeft, Bookmark, Building2, Clock, Columns2, ExternalLink, Info, Layers, ShieldCheck, Tag, Train, TrendingUp } from 'lucide-react' +import { ArrowLeft, Bookmark, Building2, Clock, Columns2, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react' import { useNavigate, useParams } from 'react-router' -import type { PropertyUnit } from '../../domain/property' -import { useQuery } from '@tanstack/react-query' -import { useMatchDetail } from '../../hooks/useMatches' -import { propertyService } from '../../services/propertyService' -import { needService } from '../../services/needService' -import { futureSignalService } from '../../services/futureSignalService' import { useCompareStore } from '../../stores/compareStore' import { usePipelineStore } from '../../stores/pipelineStore' import { AddToPipelineDialog } from '../../components/shortlist' @@ -26,71 +20,9 @@ import { NextActionsPanel, } from '../../components/match-detail' import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel' - -// ── Property detail helpers ──────────────────────────────────────────────────── - -const FLOOR_LABEL = (level: number) => - level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG` - -const ASSET_LABELS: Record = { - OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden', - PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)', -} -const RISK_LABELS: Record = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' } -const SOURCE_LABELS: Record = { - ERP_IMPORT: 'ERP-Import (intern)', IMMOSCOUT_SCRAPE: 'ImmoScout24', - HOMEGATE_SCRAPE: 'Homegate', MATCHOFFICE_SCRAPE: 'MatchOffice', - NEWHOME_SCRAPE: 'newhome.ch', AI_SIGNAL: 'KI-Signal', MANUAL: 'Manuell erfasst', -} -const PASSERBY_LABELS: Record = { - LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch', -} - -function KeyFactRow({ label, value }: { label: string; value?: string | null }) { - if (!value) return null - return ( - - {label} - {value} - - ) -} - -function UnitStatusChip({ unit }: { unit: PropertyUnit }) { - if (unit.schattenmarktRelease?.enabled) { - return } label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} /> - } - if (unit.available) { - return - } - return -} - -function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) { - const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate - const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined - return ( - - - - {FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''} - - {unit.currentTenant && {unit.currentTenant}} - - {unit.areaSqm.toLocaleString('de-CH')} m² - {monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : '–'} - - {availableFrom ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : '–'} - - - - ) -} +import { useMatchDetailData } from '../../hooks/useMatchDetailData' +import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from '../../components/match-detail/MatchDetailPropertyDetails' +import { useMatchDetail } from '../../hooks/useMatches' // ── Match helpers ────────────────────────────────────────────────────────────── @@ -119,29 +51,7 @@ export default function MatchDetail() { const { addToCompare } = useCompareStore() const { openSavedDialog } = usePipelineStore() - const { data: match, isLoading } = useMatchDetail(matchId ?? '') - const isFuture = match?.resultType === 'FUTURE_AVAILABILITY' - - const { data: property = null } = useQuery({ - queryKey: ['property', match?.propertyId], - queryFn: () => propertyService.getById(match!.propertyId), - enabled: !!match && !isFuture, - select: r => r.data ?? null, - }) - - const { data: need = null } = useQuery({ - queryKey: ['need', match?.needId], - queryFn: () => needService.getById(match!.needId), - enabled: !!match?.needId, - select: r => r.data ?? null, - }) - - const { data: signal = null } = useQuery({ - queryKey: ['signal', match?.resultId], - queryFn: () => futureSignalService.getById(match!.resultId!), - enabled: !!match && isFuture && !!match.resultId, - select: r => r.data ?? null, - }) + const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '') if (isLoading) { return ( diff --git a/src/pages/demand/Pipeline.tsx b/src/pages/demand/Pipeline.tsx index 8aa3580..20115fc 100644 --- a/src/pages/demand/Pipeline.tsx +++ b/src/pages/demand/Pipeline.tsx @@ -1,496 +1,20 @@ import { useState } from 'react' import { useNavigate } from 'react-router' import { - Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, Tooltip, Card, + Box, Chip, Typography, } from '@mui/material' import { - DndContext, DragOverlay, PointerSensor, useSensor, useSensors, - useDroppable, useDraggable, closestCenter, + DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter, } from '@dnd-kit/core' import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' -import { CSS } from '@dnd-kit/utilities' -import { X, Sparkles, FileText, StickyNote, ChevronRight, TrendingUp, AlertTriangle, CheckCircle, MessageSquare, MapPin, ExternalLink } from 'lucide-react' +import { TrendingUp } from 'lucide-react' import { usePipelineStore } from '../../stores/pipelineStore' import { AddToPipelineDialog } from '../../components/shortlist' -import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay' import type { PipelineItem, PipelineStage } from '../../domain/pipeline' - -// ── Stage config ────────────────────────────────────────────────────────────── - -const STAGES = [ - { key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' }, - { key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' }, - { key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' }, - { key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' }, - { key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' }, - { key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' }, - { key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' }, -] as const - -const NEXT_STAGE: Partial> = { - SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' }, - DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' }, - QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' }, - VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' }, - NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' }, -} - -const RESULT_TYPE_LABEL: Record = { - VERIFIED_PORTFOLIO: 'Portfolio', - EXTERNAL_MARKET: 'Direktinserat', - MAISON_WORK: 'Maison Work', - FUTURE_AVAILABILITY:'Future', -} - -const RESULT_TYPE_COLOR: Record = { - VERIFIED_PORTFOLIO: '#1e3a5f', - EXTERNAL_MARKET: '#d97706', - MAISON_WORK: '#0369a1', - FUTURE_AVAILABILITY: '#7c3aed', -} - -const MOCK_DOCS: Record = { - 'pl-001': [ - { name: 'Expose_Zollstrasse12.pdf', date: '05.05.2026' }, - { name: 'Grundriss_EG.pdf', date: '08.05.2026' }, - { name: 'Mietvertrag_Entwurf.docx', date: '14.05.2026' }, - ], - 'pl-007': [ - { name: 'Expose_Stadthaus_Bern.pdf', date: '12.04.2026' }, - { name: 'Mietvertrag_unterschrieben.pdf', date: '02.05.2026' }, - ], -} - -function scoreColor(score: number) { - return score >= 80 ? '#1a7a4a' : score >= 65 ? '#d97706' : '#c0392b' -} - -function detailPath(item: PipelineItem): string | null { - // propertyId is always stable across sessions — prefer it - if (item.propertyId) return `/demand/property/${item.propertyId}` - // matchId / UUID only works in the same session (matchStore is ephemeral) - if (item.matchId) return `/demand/results/${item.matchId}` - if (item.id.startsWith('match-')) return `/demand/results/${item.id}` - return null -} - -function getKiInsight(item: PipelineItem): { summary: string; positives: string[]; risks: string[] } { - if (item.stage === 'SAVED') return { - summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt qualifiziert werden soll.`, - positives: [`Match ${item.matchScore}%`], - risks: ['Noch nicht qualifiziert'], - } - if (item.stage === 'CLOSED_WON') return { - summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match abgeschlossen.`, - positives: ['Vertraglich gesichert', `Match ${item.matchScore}%`, 'Alle Kriterien erfüllt'], - risks: [], - } - if (item.stage === 'CLOSED_LOST') return { - summary: item.notes ?? 'Objekt nicht realisiert.', - positives: [], - risks: ['Nicht verfügbar', 'Alternative Optionen prüfen'], - } - const s = item.matchScore - return { - summary: s >= 80 - ? `Starkes Objekt (${s}%) — deckt die wesentlichen Suchkriterien ab. Prozess aktiv weitertreiben.` - : s >= 65 - ? `Solides Objekt (${s}%) mit Potenzial. Gezielte Klärung offener Punkte empfohlen.` - : `Schwächerer Match (${s}%). Abweichungen kritisch prüfen bevor weitere Ressourcen investiert werden.`, - positives: [ - ...(s >= 80 ? [`Match ${s}% — hohe Übereinstimmung`] : []), - ...(item.areaLabel ? [`Fläche: ${item.areaLabel}`] : []), - ...(item.resultType === 'VERIFIED_PORTFOLIO' ? ['Geprüftes Portfolio-Objekt'] : []), - ...(item.stage === 'NEGOTIATION' ? ['Verhandlung läuft — kurz vor Abschluss'] : []), - ].slice(0, 3), - risks: [ - ...(s < 80 ? [`Match ${s}% — Abweichungen prüfen`] : []), - ...(item.notes?.includes('Budget') ? ['Budget-Diskrepanz erwähnt'] : []), - ...(item.resultType === 'FUTURE_AVAILABILITY' ? ['Verfügbarkeit noch nicht bestätigt'] : []), - ].slice(0, 2), - } -} - -// ── DraggableCard ───────────────────────────────────────────────────────────── - -function DraggableCard({ - item, - isSelected, - onSelect, - isDragOverlay = false, - onChatClick, -}: { - item: PipelineItem - isSelected: boolean - onSelect: (item: PipelineItem) => void - isDragOverlay?: boolean - onChatClick?: (e: React.MouseEvent) => void -}) { - const navigate = useNavigate() - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id }) - const stageConfig = STAGES.find(s => s.key === item.stage)! - const path = detailPath(item) - - const style = !isDragOverlay ? { - transform: CSS.Translate.toString(transform), - opacity: isDragging ? 0.35 : 1, - transition: isDragging ? undefined : 'opacity 0.15s ease', - } : undefined - - return ( - !isDragging && onSelect(item)} - sx={{ - p: 1.5, - border: isSelected && !isDragOverlay - ? '2px solid #1e3a5f' - : isDragOverlay - ? '2px solid transparent' - : '2px solid transparent', - cursor: isDragOverlay ? 'grabbing' : 'grab', - bgcolor: isDragOverlay ? 'white' : isSelected ? '#eff6ff' : 'white', - boxShadow: isDragOverlay ? '0 8px 24px rgba(0,0,0,0.18)' : '0 1px 3px rgba(0,0,0,0.08)', - '&:hover': isDragOverlay ? {} : { - boxShadow: '0 2px 8px rgba(0,0,0,0.12)', - borderColor: isSelected ? '#1e3a5f' : '#bfdbfe', - }, - transition: isDragOverlay ? undefined : 'box-shadow 0.15s, border-color 0.15s', - userSelect: 'none', - rotate: isDragOverlay ? '2deg' : undefined, - }} - {...(isDragOverlay ? {} : { ...attributes, ...listeners })} - > - {/* Row 1: Score + type chip + chat icon */} - - - - - - - {!isDragOverlay && ( - - {item.inquiryId && onChatClick && ( - - - - - - )} - {path && ( - - { e.stopPropagation(); navigate(path) }} - sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#1e3a5f', bgcolor: '#eff6ff' } }} - > - - - - )} - - )} - - - {/* Row 2: Title */} - - {item.title} - - - {/* Row 3: Location */} - - - - {item.propertyAddress ?? item.location} - - - - {/* Row 4: Area + rent */} - {(item.areaLabel || item.rentLabel) && ( - - {item.areaLabel && ( - {item.areaLabel} - )} - {item.areaLabel && item.rentLabel && ( - - )} - {item.rentLabel && ( - {item.rentLabel} - )} - - )} - - {/* Row 5: Notes preview */} - {item.notes && ( - - {item.notes} - - )} - - ) -} - -// ── DroppableColumn ─────────────────────────────────────────────────────────── - -function DroppableColumn({ - stage, - items, - selectedId, - onSelect, - onChatClick, - isOver, -}: { - stage: typeof STAGES[number] - items: PipelineItem[] - selectedId: string | null - onSelect: (item: PipelineItem) => void - onChatClick: (inquiryId: string) => void - isOver: boolean -}) { - const { setNodeRef } = useDroppable({ id: stage.key }) - - return ( - - {items.map(item => ( - { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined} - /> - ))} - {items.length === 0 && ( - - - {isOver ? 'Hier ablegen' : 'Leer'} - - - )} - - ) -} - -// ── DetailPanel ─────────────────────────────────────────────────────────────── - -function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) { - const navigate = useNavigate() - const { moveStage, updateNotes, loseItem } = usePipelineStore() - const path = detailPath(item) - const [notes, setNotes] = useState(item.notes ?? '') - const stageConfig = STAGES.find(s => s.key === item.stage)! - const stageIndex = STAGES.findIndex(s => s.key === item.stage) - const nextStage = NEXT_STAGE[item.stage] - const ki = getKiInsight(item) - const docs = MOCK_DOCS[item.id] ?? [] - const isClosed = item.stage === 'CLOSED_WON' || item.stage === 'CLOSED_LOST' - const progressIdx = Math.min(stageIndex, 4) - - return ( - - {/* Header */} - - - - {item.title} - {item.location} - - - - {item.matchScore}% - - {path && ( - - navigate(path)} sx={{ color: '#64748b', '&:hover': { color: '#1e3a5f' } }}> - - - - )} - - - - - - - - {STAGES.slice(0, 5).map((s, idx) => ( - - ))} - - - - {item.inquiryId && ( - } - label="Chat" - size="small" - onClick={() => navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)} - sx={{ - height: 22, fontSize: '0.75rem', cursor: 'pointer', - bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, - border: '1px solid #bfdbfe', - '& .MuiChip-icon': { color: '#1e3a5f' }, - '&:hover': { bgcolor: '#dbeafe' }, - }} - /> - )} - - - - {/* Property / unit address */} - {item.propertyAddress && ( - - - {item.propertyAddress} - - )} - - - - {/* KI */} - - - - - KI Einschätzung - - - - {ki.summary} - - {ki.positives.map((p, i) => ( - - - {p} - - ))} - {ki.risks.map((r, i) => ( - - - {r} - - ))} - - - - - {/* Stage actions */} - {!isClosed && ( - - - Nächste Aktion - - - {nextStage && ( - - )} - - - - )} - - - - {/* Notes */} - - - - - Notizen - - - setNotes(e.target.value)} - onBlur={() => updateNotes(item.id, notes)} - sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }} - /> - - - - - {/* Documents */} - - - - - Dokumente - - - {docs.length === 0 ? ( - Noch keine Dokumente. - ) : ( - - {docs.map((doc, i) => ( - - - {doc.name} - {doc.date} - - ))} - - )} - - - - - - - {item.availabilityLabel && Verfügbar: {item.availabilityLabel}} - {item.assignedTo && Verantwortlich: {item.assignedTo}} - - Hinzugefügt: {new Date(item.addedAt).toLocaleDateString('de-CH')} - - - - - - ) -} +import { STAGES } from '../../components/pipeline/pipelineConstants' +import { DraggableCard } from '../../components/pipeline/PipelineCard' +import { DroppableColumn } from '../../components/pipeline/PipelineColumn' +import { DetailPanel } from '../../components/pipeline/PipelineDetailPanel' // ── Pipeline page ───────────────────────────────────────────────────────────── @@ -518,8 +42,8 @@ export default function Pipeline() { setActiveId(active.id as string) } - function handleDragOver({ over }: { over: { id: string } | null }) { - setOverId(over?.id ?? null) + function handleDragOver({ over }: { over: { id: string | number } | null }) { + setOverId(over ? String(over.id) : null) } function handleDragEnd({ active, over }: DragEndEvent) { diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index 1063577..c2232b4 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -14,10 +14,9 @@ import { } from '../../components/results' import { AddToPipelineDialog } from '../../components/shortlist' import { useSessionStore } from '../../stores/sessionStore' -import type { ResultType } from '../../domain/enums' import type { UnifiedMatchResult } from '../../domain/unifiedResult' -type FilterSource = Exclude | 'ALL' +type FilterSource = 'ALL' | 'PLATFORM' | 'MAISON_WORK' type SortBy = 'score' | 'rent' | 'area' function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] { @@ -71,18 +70,21 @@ export default function Results() { const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN' const filtered = results.filter(r => { - if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties - // Future Availability toggle is independent of the source filter if (r.resultType === 'FUTURE_AVAILABILITY') return showFutureAvailability - return filterSource === 'ALL' || r.resultType === filterSource + if (r.resultType === 'VERIFIED_PORTFOLIO') { + if (!showOwnProperties) return false + return filterSource === 'ALL' || filterSource === 'PLATFORM' + } + if (filterSource === 'ALL') return true + if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET' + return r.resultType === filterSource // MAISON_WORK }) const sorted = sortResults(filtered, sortBy) - const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length - const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length + const platformCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET').length const maisonWorkCount = results.filter(r => r.resultType === 'MAISON_WORK').length - const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length + const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length const strongCount = filtered.filter(r => r.matchScore >= 80).length const missingDataCount = results.filter(r => 'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) && @@ -94,8 +96,8 @@ export default function Results() { { setView(v); localStorage.setItem('view-results', v) }} @@ -107,7 +109,6 @@ export default function Results() { context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}–${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined} metrics={[ ...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []), - ...(externalCount > 0 ? [{ label: 'Direktinserate', value: externalCount, severity: 'neutral' as const }] : []), ...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []), ...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []), ...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),