refactor: split large page components + taxonomy/HeatBadge/FutureAvailability improvements
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,10 @@ import { usePipelineStore } from '../../stores/pipelineStore'
|
|||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
const TYPE_META: Record<string, { label: string; color: string }> = {
|
const TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
|
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Card sx={{ mb: 3, overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Trophy size={18} color="#d4920e" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Vergleich nach Suchkriterien</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Basierend auf der Suche: {activeNeed.companyName}</Typography>
|
||||||
|
</Box>
|
||||||
|
{overallWinnerIdx !== -1 && (
|
||||||
|
<Box sx={{ ml: 'auto', bgcolor: '#fffbeb', border: '1px solid #fbbf24', borderRadius: 2, px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Trophy size={16} color="#d4920e" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 800, color: '#7a4f00' }}>{getTitle(compareItems[overallWinnerIdx])}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table sx={{ tableLayout: 'auto' }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: '#f8fafc' }}>
|
||||||
|
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
|
||||||
|
{compareItems.map(item => (
|
||||||
|
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }} noWrap>
|
||||||
|
{getTitle(item)}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{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 (
|
||||||
|
<TableRow key={key} hover>
|
||||||
|
<TableCell sx={LABEL_SX}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{label}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
{factors.map((factor, idx) => (
|
||||||
|
<TableCell key={idx} sx={{ ...DATA_SX, bgcolor: idx === winnerIdx ? 'rgba(26,122,74,0.05)' : undefined }}>
|
||||||
|
{factor ? (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
{scoreBar(factor.score / 100)}
|
||||||
|
{idx === winnerIdx && (
|
||||||
|
<Chip label="Besser" size="small"
|
||||||
|
icon={<CheckCircle2 size={10} />}
|
||||||
|
sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534',
|
||||||
|
'& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 },
|
||||||
|
'& .MuiChip-label': { px: 0.75 } }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary"
|
||||||
|
sx={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', lineHeight: 1.4 }}>
|
||||||
|
{factor.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<MissingDataCell reason="Kein Score für dieses Kriterium" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, { label: string; color: string }> = {
|
||||||
|
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<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||||
|
|
||||||
|
export const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
|
||||||
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Box sx={{ width: 70 }}>
|
||||||
|
<LinearProgress variant="determinate" value={value * 100}
|
||||||
|
sx={{ height: 6, borderRadius: 3, '& .MuiLinearProgress-bar': { bgcolor: color } }} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color }}>{label ?? `${Math.round(value * 100)}%`}</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export { CompareEmptyState } from './CompareEmptyState'
|
|||||||
export { CompareColumnHeader } from './CompareColumnHeader'
|
export { CompareColumnHeader } from './CompareColumnHeader'
|
||||||
export { CompareCell, MissingDataCell } from './CompareCell'
|
export { CompareCell, MissingDataCell } from './CompareCell'
|
||||||
export { AICompareSummary } from './AICompareSummary'
|
export { AICompareSummary } from './AICompareSummary'
|
||||||
|
export { CompareCriteriaCard } from './CompareCriteriaCard'
|
||||||
|
|||||||
@@ -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<string, { label: string; icon: React.ReactNode }> = {
|
||||||
|
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={11} /> },
|
||||||
|
PRESS: { label: 'Pressebericht', icon: <Newspaper size={11} /> },
|
||||||
|
CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: <FileCheck size={11} /> },
|
||||||
|
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={11} /> },
|
||||||
|
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={11} /> },
|
||||||
|
MANUAL: { label: 'Analyst', icon: <User size={11} /> },
|
||||||
|
LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: <ShieldCheck size={11} /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{c.dots.map((filled, i) => (
|
||||||
|
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
|
||||||
|
))}
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
|
||||||
|
{c.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<Box sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
borderLeft: `3px solid ${accentColor}`,
|
||||||
|
background: 'white',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
|
||||||
|
transition: 'box-shadow 0.15s, transform 0.1s',
|
||||||
|
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.10)', transform: 'translateY(-1px)' },
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* ── Data header ─────────────────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ bgcolor: headerBg, px: 2, pt: 1.75, pb: 1.5, borderBottom: `1px solid ${borderColor}` }}>
|
||||||
|
|
||||||
|
{/* Asset type · location + badge */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||||
|
{assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: badgeBg, color: badgeColor, px: 0.875, py: 0.25, borderRadius: 1, flexShrink: 0 }}>
|
||||||
|
{isControlled && <ShieldCheck size={9} />}
|
||||||
|
<Typography sx={{ fontSize: '0.6rem', fontWeight: 800, letterSpacing: 0.5 }}>
|
||||||
|
{isControlled ? 'PRE-MARKET VERIFIED' : 'MARKET SIGNAL'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Opportunity headline */}
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#1e293b', mb: 0.75 }}>
|
||||||
|
{headline}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Key facts */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 0.875 }}>
|
||||||
|
{vm.signalAreaSqmEstimate ? (
|
||||||
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#1e293b' }}>
|
||||||
|
{isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{vm.availabilityLabel && (
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
||||||
|
{vm.availabilityLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Source attribution */}
|
||||||
|
{isControlled ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 0.875, py: 0.3, width: 'fit-content' }}>
|
||||||
|
<ShieldCheck size={10} color="#1a7a4a" />
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#1a7a4a' }}>Direkte Verwaltungsquelle</Typography>
|
||||||
|
</Box>
|
||||||
|
) : sourceMeta ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, width: 'fit-content' }}>
|
||||||
|
<Box sx={{ color: '#64748b', display: 'flex' }}>{sourceMeta.icon}</Box>
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', color: '#64748b', fontWeight: 500 }}>{sourceMeta.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Score + signal quality strip ────────────────────────────────────── */}
|
||||||
|
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: '1px solid #f1f5f9', bgcolor: 'white' }}>
|
||||||
|
<Box sx={{
|
||||||
|
background: theme.gradient, borderRadius: '8px',
|
||||||
|
px: 1.25, py: 0.4, border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: `0 2px 8px ${theme.glow}`,
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontWeight: 900, fontSize: '1.25rem', color: theme.text, lineHeight: 1 }}>
|
||||||
|
{vm.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<SignalQualityDots quality={vm.signalQuality} />
|
||||||
|
{!isControlled && vm.signalProbability !== undefined && (
|
||||||
|
<Box sx={{ ml: 'auto', bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.75, py: 0.2 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.63rem', color: '#64748b', fontWeight: 600 }}>
|
||||||
|
{Math.round(vm.signalProbability * 100)}% Signalw.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Card body ───────────────────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ px: 2, pt: 1.5, pb: 1.5, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
|
||||||
|
{/* MARKET SIGNAL: probabilistic notice */}
|
||||||
|
{!isControlled && (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 0.6, mb: 1.25,
|
||||||
|
bgcolor: '#fefce8', border: '1px solid #fde68a', borderRadius: 1, px: 1, py: 0.625,
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={11} color="#92400e" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontSize: '0.67rem', color: '#78350f', lineHeight: 1.45 }}>
|
||||||
|
Probabilistischer Marktindikator — kein bestätigtes Objekt. Dient als strategischer Frühindikator.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRE-MARKET VERIFIED: confirmed facts */}
|
||||||
|
{isControlled && (vm.signalConfirmedFacts?.length ?? 0) > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mb: 1.25 }}>
|
||||||
|
{(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
|
||||||
|
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRE-MARKET VERIFIED: specific unit info */}
|
||||||
|
{isControlled && vm.preMarketUnit && (
|
||||||
|
<Box sx={{ bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#1a7a4a', textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
|
||||||
|
Freigegebene Einheit
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', color: '#1e293b', fontWeight: 600 }}>
|
||||||
|
{floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
||||||
|
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
{vm.preMarketUnit.schattenmarktRelease?.availableFrom && (
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
||||||
|
ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MARKET SIGNAL: market indicators */}
|
||||||
|
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
|
||||||
|
<Box sx={{ mb: 1.25 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
|
||||||
|
Erkannte Marktindikatoren
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
|
||||||
|
{(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
|
||||||
|
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', color: '#475569', lineHeight: 1.3 }}>{ind}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Why relevant — match reasons */}
|
||||||
|
{vm.reasons.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ mb: 1 }} />
|
||||||
|
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
|
||||||
|
Warum relevant für Ihre Suche?
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.25 }}>
|
||||||
|
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
|
||||||
|
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>{r.label}</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>{r.explanation}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 'auto', pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)', flexWrap: 'wrap' }}>
|
||||||
|
{vm.actions.map(a => (
|
||||||
|
<Button
|
||||||
|
key={a.id}
|
||||||
|
size="small"
|
||||||
|
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||||
|
onClick={a.onClick}
|
||||||
|
disabled={a.disabled}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
||||||
|
...(a.variant === 'primary' && {
|
||||||
|
bgcolor: accentColor,
|
||||||
|
'&:hover': { bgcolor: isControlled ? '#6d28d9' : '#1e40af' },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{isControlled && vm.propertyId && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => navigate(`/demand/property/${vm.propertyId}${vm.unitId ? `?unit=${vm.unitId}` : ''}`)}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
||||||
|
bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, ml: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zur Einheit →
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Disclaimer footnote */}
|
||||||
|
{vm.disclaimer && (
|
||||||
|
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1, fontSize: '0.63rem', lineHeight: 1.4, borderTop: '1px solid #f1f5f9', pt: 0.75 }}>
|
||||||
|
{vm.disclaimer}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,327 +1,24 @@
|
|||||||
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
|
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
|
||||||
BarChart2,
|
|
||||||
Briefcase,
|
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ExternalLink,
|
|
||||||
FileCheck,
|
|
||||||
FileText,
|
|
||||||
Newspaper,
|
|
||||||
ShieldCheck,
|
|
||||||
User,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||||
function floorLabel(level: number): string {
|
|
||||||
if (level === 0) return 'EG'
|
|
||||||
if (level < 0) return `UG ${Math.abs(level)}`
|
|
||||||
return `${level}.OG`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Future Availability', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Future Availability', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
|
||||||
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={11} /> },
|
|
||||||
PRESS: { label: 'Pressebericht', icon: <Newspaper size={11} /> },
|
|
||||||
CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: <FileCheck size={11} /> },
|
|
||||||
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={11} /> },
|
|
||||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={11} /> },
|
|
||||||
MANUAL: { label: 'Analyst', icon: <User size={11} /> },
|
|
||||||
LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: <ShieldCheck size={11} /> },
|
|
||||||
}
|
|
||||||
|
|
||||||
const ASSET_TYPE_LABELS: Record<string, string> = {
|
|
||||||
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 (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
{c.dots.map((filled, i) => (
|
|
||||||
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
|
|
||||||
))}
|
|
||||||
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
|
|
||||||
{c.label}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<Box sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: `1px solid ${borderColor}`,
|
|
||||||
borderLeft: `3px solid ${accentColor}`,
|
|
||||||
background: 'white',
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
|
|
||||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
|
||||||
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.10)', transform: 'translateY(-1px)' },
|
|
||||||
}}>
|
|
||||||
|
|
||||||
{/* ── Data header ─────────────────────────────────────────────────────── */}
|
|
||||||
<Box sx={{ bgcolor: headerBg, px: 2, pt: 1.75, pb: 1.5, borderBottom: `1px solid ${borderColor}` }}>
|
|
||||||
|
|
||||||
{/* Asset type · location + badge */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
|
||||||
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
|
||||||
{assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: badgeBg, color: badgeColor, px: 0.875, py: 0.25, borderRadius: 1, flexShrink: 0 }}>
|
|
||||||
{isControlled && <ShieldCheck size={9} />}
|
|
||||||
<Typography sx={{ fontSize: '0.6rem', fontWeight: 800, letterSpacing: 0.5 }}>
|
|
||||||
{isControlled ? 'PRE-MARKET VERIFIED' : 'MARKET SIGNAL'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Opportunity headline */}
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#1e293b', mb: 0.75 }}>
|
|
||||||
{headline}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Key facts */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 0.875 }}>
|
|
||||||
{vm.signalAreaSqmEstimate ? (
|
|
||||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#1e293b' }}>
|
|
||||||
{isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m²
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{vm.availabilityLabel && (
|
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
|
||||||
{vm.availabilityLabel}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Source attribution */}
|
|
||||||
{isControlled ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 0.875, py: 0.3, width: 'fit-content' }}>
|
|
||||||
<ShieldCheck size={10} color="#1a7a4a" />
|
|
||||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#1a7a4a' }}>Direkte Verwaltungsquelle</Typography>
|
|
||||||
</Box>
|
|
||||||
) : sourceMeta ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, width: 'fit-content' }}>
|
|
||||||
<Box sx={{ color: '#64748b', display: 'flex' }}>{sourceMeta.icon}</Box>
|
|
||||||
<Typography sx={{ fontSize: '0.65rem', color: '#64748b', fontWeight: 500 }}>{sourceMeta.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Score + signal quality strip ────────────────────────────────────── */}
|
|
||||||
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: '1px solid #f1f5f9', bgcolor: 'white' }}>
|
|
||||||
<Box sx={{
|
|
||||||
background: theme.gradient, borderRadius: '8px',
|
|
||||||
px: 1.25, py: 0.4, border: `1px solid ${theme.border}`,
|
|
||||||
boxShadow: `0 2px 8px ${theme.glow}`,
|
|
||||||
}}>
|
|
||||||
<Typography sx={{ fontWeight: 900, fontSize: '1.25rem', color: theme.text, lineHeight: 1 }}>
|
|
||||||
{vm.matchScore}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<SignalQualityDots quality={vm.signalQuality} />
|
|
||||||
{!isControlled && vm.signalProbability !== undefined && (
|
|
||||||
<Box sx={{ ml: 'auto', bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.75, py: 0.2 }}>
|
|
||||||
<Typography sx={{ fontSize: '0.63rem', color: '#64748b', fontWeight: 600 }}>
|
|
||||||
{Math.round(vm.signalProbability * 100)}% Signalw.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Card body ───────────────────────────────────────────────────────── */}
|
|
||||||
<Box sx={{ px: 2, pt: 1.5, pb: 1.5, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
||||||
|
|
||||||
{/* MARKET SIGNAL: probabilistic notice */}
|
|
||||||
{!isControlled && (
|
|
||||||
<Box sx={{
|
|
||||||
display: 'flex', alignItems: 'flex-start', gap: 0.6, mb: 1.25,
|
|
||||||
bgcolor: '#fefce8', border: '1px solid #fde68a', borderRadius: 1, px: 1, py: 0.625,
|
|
||||||
}}>
|
|
||||||
<AlertCircle size={11} color="#92400e" style={{ marginTop: 2, flexShrink: 0 }} />
|
|
||||||
<Typography sx={{ fontSize: '0.67rem', color: '#78350f', lineHeight: 1.45 }}>
|
|
||||||
Probabilistischer Marktindikator — kein bestätigtes Objekt. Dient als strategischer Frühindikator.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PRE-MARKET VERIFIED: confirmed facts */}
|
|
||||||
{isControlled && (vm.signalConfirmedFacts?.length ?? 0) > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mb: 1.25 }}>
|
|
||||||
{(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => (
|
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
|
|
||||||
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PRE-MARKET VERIFIED: specific unit info */}
|
|
||||||
{isControlled && vm.preMarketUnit && (
|
|
||||||
<Box sx={{ bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
|
|
||||||
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#1a7a4a', textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
|
|
||||||
Freigegebene Einheit
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#1e293b', fontWeight: 600 }}>
|
|
||||||
{floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''}
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
|
||||||
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
|
|
||||||
</Typography>
|
|
||||||
{vm.preMarketUnit.schattenmarktRelease?.availableFrom && (
|
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
|
|
||||||
ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* MARKET SIGNAL: market indicators */}
|
|
||||||
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
|
|
||||||
<Box sx={{ mb: 1.25 }}>
|
|
||||||
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
|
|
||||||
Erkannte Marktindikatoren
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
|
|
||||||
{(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => (
|
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
|
|
||||||
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
|
|
||||||
<Typography sx={{ fontSize: '0.72rem', color: '#475569', lineHeight: 1.3 }}>{ind}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Why relevant — match reasons */}
|
|
||||||
{vm.reasons.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Divider sx={{ mb: 1 }} />
|
|
||||||
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
|
|
||||||
Warum relevant für Ihre Suche?
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.25 }}>
|
|
||||||
{vm.reasons.slice(0, 3).map((r, i) => (
|
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
|
|
||||||
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>{r.label}</Typography>
|
|
||||||
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>{r.explanation}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 'auto', pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)', flexWrap: 'wrap' }}>
|
|
||||||
{vm.actions.map(a => (
|
|
||||||
<Button
|
|
||||||
key={a.id}
|
|
||||||
size="small"
|
|
||||||
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
|
||||||
onClick={a.onClick}
|
|
||||||
disabled={a.disabled}
|
|
||||||
sx={{
|
|
||||||
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
|
||||||
...(a.variant === 'primary' && {
|
|
||||||
bgcolor: accentColor,
|
|
||||||
'&:hover': { bgcolor: isControlled ? '#6d28d9' : '#1e40af' },
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{a.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{isControlled && vm.propertyId && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => navigate(`/demand/property/${vm.propertyId}${vm.unitId ? `?unit=${vm.unitId}` : ''}`)}
|
|
||||||
sx={{
|
|
||||||
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
|
|
||||||
bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, ml: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Zur Einheit →
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Disclaimer footnote */}
|
|
||||||
{vm.disclaimer && (
|
|
||||||
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1, fontSize: '0.63rem', lineHeight: 1.4, borderTop: '1px solid #f1f5f9', pt: 0.75 }}>
|
|
||||||
{vm.disclaimer}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
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 } }}
|
sx={{ bgcolor: 'rgba(30,58,95,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Box, Chip } from '@mui/material'
|
import { Box, Chip } from '@mui/material'
|
||||||
import { Building2 } from 'lucide-react'
|
import { Building2 } from 'lucide-react'
|
||||||
import { MatchScoreDisplay } from './MatchScoreDisplay'
|
import { MatchScoreDisplay } from './MatchScoreDisplay'
|
||||||
|
import { HeatBadge } from '../shared'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function confidenceColor(score: number): string {
|
function confidenceColor(score: number): string {
|
||||||
@@ -38,8 +39,9 @@ export function MatchCardHeader({ vm, compact }: Props) {
|
|||||||
{/* Score — leftmost, most prominent */}
|
{/* Score — leftmost, most prominent */}
|
||||||
<MatchScoreDisplay score={vm.matchScore} size={compact ? 'sm' : 'md'} />
|
<MatchScoreDisplay score={vm.matchScore} size={compact ? 'sm' : 'md'} />
|
||||||
|
|
||||||
{/* Badges: resultType → assetType → confidence → availability → risk */}
|
{/* Badges: heat → resultType → assetType → confidence → availability → risk */}
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
<HeatBadge propertyId={vm.propertyId} />
|
||||||
<Chip
|
<Chip
|
||||||
label={rt.label}
|
label={rt.label}
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
FileText,
|
FileText,
|
||||||
|
Globe,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
User,
|
User,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -25,14 +30,14 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
|||||||
LEASE_EXPIRY: 'Vertragsende (Pre-Market)',
|
LEASE_EXPIRY: 'Vertragsende (Pre-Market)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
|
const SIGNAL_ACTION: Record<string, { label: string; urgency: 'high' | 'medium' | 'low' }> = {
|
||||||
EXPANSION: 'Ein Unternehmen sucht neue Flächen — Ihr Angebot könnte gefragt sein.',
|
EXPANSION: { label: 'Unternehmen proaktiv kontaktieren — aktive Flächensuche wahrscheinlich', urgency: 'high' },
|
||||||
POSSIBLE_MOVE_OUT: 'Dieses Unternehmen könnte seinen Standort aufgeben — die Fläche wird für Sie verfügbar.',
|
POSSIBLE_MOVE_OUT: { label: 'Mieter ansprechen und Verlängerungsgespräch initiieren', urgency: 'high' },
|
||||||
CONSTRUCTION_PROJECT:'Ein Neubau entsteht — neue Flächen könnten zur Vermietung angeboten werden.',
|
CONSTRUCTION_PROJECT: { label: 'Frühzeitiges Interesse beim Bauherrn anmelden, bevor Vermietungsmandat vergeben', urgency: 'medium' },
|
||||||
RESTRUCTURING: 'Restrukturierung deutet auf Flächenänderungen hin.',
|
RESTRUCTURING: { label: 'Situation beobachten, bei Bestätigung sofort handeln', urgency: 'medium' },
|
||||||
PROJECT_DEVELOPMENT: 'Projektentwicklung könnte neue Gewerbeflächen schaffen.',
|
PROJECT_DEVELOPMENT: { label: 'Entwicklungsfortschritt monitoren und Kontakt zum Projektentwickler suchen', urgency: 'medium' },
|
||||||
SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten frei werden.',
|
SPACE_CONSOLIDATION: { label: 'Teilflächen-Anforderungen klären, Gespräch mit Verwaltung suchen', urgency: 'medium' },
|
||||||
LEASE_EXPIRY: 'Die Verwaltung hat diese Fläche für kontrolliertes Pre-Market Matching freigegeben — Vertragsende aus internem ERP bestätigt.',
|
LEASE_EXPIRY: { label: 'Anfrage direkt über die Verwaltung stellen — Fläche ist für Matching freigegeben', urgency: 'high' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||||
@@ -57,6 +62,32 @@ const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warnin
|
|||||||
PUBLIC: { label: 'Öffentlich', color: 'default' },
|
PUBLIC: { label: 'Öffentlich', color: 'default' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RISK_META: Record<string, { label: string; color: string }> = {
|
||||||
|
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 {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
signal: FutureSignal | null
|
signal: FutureSignal | null
|
||||||
@@ -66,15 +97,24 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
if (!signal) return null
|
if (!signal) return null
|
||||||
|
|
||||||
const sourceMeta = SOURCE_META[signal.source.type] ?? 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 sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC
|
||||||
const relevanceBridge = signal.signalType ? (SIGNAL_RELEVANCE_BRIDGE[signal.signalType] ?? null) : null
|
const riskMeta = signal.riskLevel ? (RISK_META[signal.riskLevel] ?? null) : null
|
||||||
const probPct = Math.round(signal.probability * 100)
|
const probPct = Math.round(signal.probability * 100)
|
||||||
const isVerifiedContract = signal.isVerified && signal.source.type === 'LEASE_CONTRACT'
|
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 (
|
return (
|
||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
||||||
|
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75, flexWrap: 'wrap' }}>
|
||||||
<Zap size={16} color="#7c3aed" />
|
<Zap size={16} color="#7c3aed" />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
|
||||||
{signal.isVerified && (
|
{signal.isVerified && (
|
||||||
@@ -83,34 +123,185 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
|
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: '#94a3b8' }}>
|
||||||
|
<Clock size={12} />
|
||||||
|
<Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 1. Quelle & Verlässlichkeit — FIRST */}
|
{signal.title && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: '#1e293b', mb: 2, lineHeight: 1.4 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Quelle & Verlässlichkeit</Typography>
|
{signal.title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: isVerifiedContract ? 1 : 0 }}>
|
{/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */}
|
||||||
|
{signal.aiSummary && (
|
||||||
|
<Box sx={{ mb: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
||||||
|
<Sparkles size={14} color="#7c3aed" />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95', fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1.5, p: 1.75 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.7 }}>
|
||||||
|
{signal.aiSummary}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 2. Strategische Einschätzung ─────────────────────────────────── */}
|
||||||
|
{signal.strategicInterpretation && (
|
||||||
|
<Box sx={{ mb: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
|
||||||
|
<TrendingUp size={14} color="#0369a1" />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Strategische Einschätzung</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1.5, p: 1.75 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.7 }}>
|
||||||
|
{signal.strategicInterpretation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */}
|
||||||
|
{action && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: action.urgency === 'high' ? '#fff7ed' : '#f8fafc', border: `1px solid ${action.urgency === 'high' ? '#fed7aa' : '#e2e8f0'}`, borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5 }}>
|
||||||
|
<Zap size={14} color={action.urgency === 'high' ? '#c2410c' : '#64748b'} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? '#9a3412' : '#475569', mb: 0.25 }}>Empfohlene Aktion</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? '#7c2d12' : '#334155', lineHeight: 1.5 }}>{action.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ mb: 2.5 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography>
|
||||||
|
|
||||||
|
{/* Probability bar */}
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: probBarColor }}>{probPct}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={probPct}
|
||||||
|
sx={{
|
||||||
|
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
|
||||||
|
'& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
|
||||||
|
{signal.signalType && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
|
||||||
|
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Calendar size={13} color="#64748b" />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{signal.areaSqmEstimate && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Flächenschätzung</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{riskMeta && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Risikoniveau</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: riskMeta.color }}>{riskMeta.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */}
|
||||||
|
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
||||||
|
<Box sx={{ mb: 2.5 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
|
{signal.marketIndicators.map((indicator, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.875 }}>
|
||||||
|
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '6px', flexShrink: 0 }} />
|
||||||
|
<Typography variant="body2" sx={{ color: '#334155', lineHeight: 1.55 }}>{indicator}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */}
|
||||||
|
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
|
||||||
|
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
|
||||||
|
<Box sx={{ mb: 2.5 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Transparenz — Was ist gesichert?</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||||
|
{(signal.confirmedFacts ?? []).map((fact, i) => (
|
||||||
|
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
|
<CheckCircle2 size={14} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 1 }} />
|
||||||
|
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
|
||||||
|
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
|
<AlertTriangle size={14} color="#d97706" style={{ flexShrink: 0, marginTop: 1 }} />
|
||||||
|
<Typography variant="body2" sx={{ color: '#92400e', lineHeight: 1.45 }}>{fact}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{/* ── 7. Quellen & Belege ───────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ mb: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
|
||||||
|
<Globe size={14} color="#0369a1" />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Quellen & Belege</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Primary source metadata — always shown */}
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.25 }}>
|
||||||
{sourceMeta && (
|
{sourceMeta && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: isVerifiedContract ? '#f0fdf4' : '#ede9fe', color: isVerifiedContract ? '#1a7a4a' : '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: isVerifiedContract ? '#f0fdf4' : '#ede9fe', color: isVerifiedContract ? '#1a7a4a' : '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
|
||||||
{sourceMeta.icon}
|
{sourceMeta.icon}
|
||||||
<Typography sx={{ fontSize: '0.8rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{credMeta && (
|
{credMeta && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
|
||||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: credMeta.color }} />
|
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: credMeta.color }} />
|
||||||
<Typography variant="body2" sx={{ color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
|
<Typography sx={{ fontSize: '0.75rem', color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{signal.source.publishedAt && (
|
{signal.source.publishedAt && (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
|
||||||
Publiziert: {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
|
<Calendar size={11} color="#64748b" />
|
||||||
</Typography>
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Verified contract special box */}
|
||||||
{isVerifiedContract && (
|
{isVerifiedContract && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 1, mt: 0.75 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 1, mb: 1.25 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<ShieldCheck size={13} color="#1a7a4a" />
|
<ShieldCheck size={13} color="#1a7a4a" />
|
||||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Vertragsende aus internem ERP bestätigt</Typography>
|
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Vertragsende aus internem ERP bestätigt</Typography>
|
||||||
@@ -122,176 +313,72 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{signal.source.url && (
|
{/* All source URLs */}
|
||||||
<Box sx={{ mt: 1 }}>
|
{allSourceUrls.length > 0 ? (
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={signal.source.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<ExternalLink size={13} />}
|
|
||||||
sx={{ textTransform: 'none', color: '#7c3aed', borderColor: '#c4b5fd', '&:hover': { borderColor: '#7c3aed', bgcolor: '#faf5ff' } }}
|
|
||||||
>
|
|
||||||
Originalquelle öffnen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && (
|
|
||||||
<Box sx={{ mt: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
|
||||||
Weitere Belege ({signal.evidence.sourceUrls.length})
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{signal.evidence.sourceUrls.map((url, i) => (
|
|
||||||
<Box
|
|
||||||
key={i}
|
|
||||||
component="a"
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#7c3aed', fontSize: '0.75rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={11} />
|
|
||||||
<Typography variant="caption" sx={{ color: 'inherit' }}>{url}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
|
|
||||||
{/* 2. Signal-Kenndaten */}
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mb: 2 }}>
|
|
||||||
{signal.signalType && (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
|
|
||||||
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Wahrscheinlichkeit</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}%</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
|
||||||
</Box>
|
|
||||||
{signal.areaSqmEstimate && (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Flächenschätzung</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
|
|
||||||
{/* 3. KI-Analyse */}
|
|
||||||
{signal.aiSummary && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
|
||||||
<Zap size={14} color="#7c3aed" />
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95' }}>KI-Analyse</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, p: 1.5 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.65 }}>
|
|
||||||
{signal.aiSummary}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
|
|
||||||
{/* 4. Erkannte Marktindikatoren */}
|
|
||||||
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
|
|
||||||
Erkannte Marktindikatoren
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||||
{signal.marketIndicators.map((indicator, i) => (
|
{allSourceUrls.map((url, i) => (
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
<Button
|
||||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
|
key={i}
|
||||||
<Typography variant="body2" sx={{ color: '#374151', lineHeight: 1.45 }}>{indicator}</Typography>
|
component="a"
|
||||||
</Box>
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ExternalLink size={12} />}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none', justifyContent: 'flex-start',
|
||||||
|
color: '#0369a1', borderColor: '#bae6fd',
|
||||||
|
'&:hover': { borderColor: '#0369a1', bgcolor: '#f0f9ff' },
|
||||||
|
fontSize: '0.78rem', fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{domainLabel(url)}
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
) : (
|
||||||
)}
|
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 1.25, py: 0.875 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
{/* 5. Strategische Interpretation */}
|
Kein direkter Quellenlink verfügbar — Signal basiert auf aggregierten {sourceMeta?.label ?? 'Marktdaten'}.
|
||||||
{signal.strategicInterpretation && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75, fontSize: '0.8125rem' }}>
|
|
||||||
Strategische Interpretation
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: '#374151', lineHeight: 1.65, fontStyle: 'italic' }}>
|
|
||||||
{signal.strategicInterpretation}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 6. Transparenz (Bestätigt / Nicht bestätigt) */}
|
{/* Evidence extraction date */}
|
||||||
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
|
{signal.evidence?.extractedAt && (
|
||||||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.875 }}>
|
||||||
<Box sx={{ mb: 2 }}>
|
<Clock size={11} color="#94a3b8" />
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
|
<Typography variant="caption" color="text.secondary">
|
||||||
Transparenz
|
Daten extrahiert: {new Date(signal.evidence.extractedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{(signal.confirmedFacts ?? []).map((fact, i) => (
|
|
||||||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
||||||
<CheckCircle2 size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
|
|
||||||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
||||||
<AlertTriangle size={13} color="#d97706" style={{ flexShrink: 0 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: '#d97706', fontWeight: 500 }}>{fact}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 7. Relevance bridge */}
|
{/* Evidence summary */}
|
||||||
{relevanceBridge && (
|
{signal.evidence?.summary && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, px: 1.5, py: 1.25, mb: 2 }}>
|
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.25, mt: 1 }}>
|
||||||
<Zap size={14} color="#7c3aed" style={{ flexShrink: 0, marginTop: 2 }} />
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.3 }}>Evidenz-Zusammenfassung</Typography>
|
||||||
<Box>
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.55 }}>{signal.evidence.summary}</Typography>
|
||||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#4c1d95', mb: 0.25 }}>Warum erscheint dieses Signal in Ihrer Suche?</Typography>
|
|
||||||
<Typography sx={{ fontSize: '0.8rem', color: '#4c1d95', lineHeight: 1.5 }}>{relevanceBridge}</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
</Box>
|
||||||
|
|
||||||
{/* 5. Evidence */}
|
{/* ── Footer ──────────────────────────────────────────────────────────── */}
|
||||||
{signal.evidence?.summary && (
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75 }}>Evidenz</Typography>
|
|
||||||
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 1, p: 1.5 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 6. Vertraulichkeit */}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25, flexWrap: 'wrap' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">Vertraulichkeit:</Typography>
|
<Typography variant="caption" color="text.secondary">Vertraulichkeit:</Typography>
|
||||||
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
|
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
|
||||||
|
{signal.verifiedBy && (
|
||||||
|
<>
|
||||||
|
<Typography variant="caption" color="text.secondary">Verifiziert von:</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>{signal.verifiedBy}</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 7. Disclaimer — grey footnote */}
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: '#f8fafc', borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.5, borderTop: '1px solid #f1f5f9', pt: 1.5 }}>
|
|
||||||
{signal.disclaimer}
|
{signal.disclaimer}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
|
||||||
|
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
|
||||||
|
}
|
||||||
|
export const RISK_LABELS: Record<string, string> = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' }
|
||||||
|
export const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
|
||||||
|
if (unit.schattenmarktRelease?.enabled) {
|
||||||
|
return <Chip size="small" icon={<ShieldCheck size={11} />} label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} />
|
||||||
|
}
|
||||||
|
if (unit.available) {
|
||||||
|
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
|
||||||
|
}
|
||||||
|
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto',
|
||||||
|
gap: 1.5, alignItems: 'center', px: 2, py: 1.5, borderRadius: 1, mb: 1,
|
||||||
|
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
|
||||||
|
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
|
||||||
|
}}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
|
||||||
|
</Typography>
|
||||||
|
{unit.currentTenant && <Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : '–'}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{availableFrom ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : '–'}
|
||||||
|
</Typography>
|
||||||
|
<UnitStatusChip unit={unit} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@ export { MissingInformationPanel } from './MissingInformationPanel'
|
|||||||
export { SourceProvenancePanel } from './SourceProvenancePanel'
|
export { SourceProvenancePanel } from './SourceProvenancePanel'
|
||||||
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
||||||
export { NextActionsPanel } from './NextActionsPanel'
|
export { NextActionsPanel } from './NextActionsPanel'
|
||||||
|
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Card
|
||||||
|
ref={!isDragOverlay ? setNodeRef : undefined}
|
||||||
|
style={style}
|
||||||
|
elevation={isDragOverlay ? 6 : 0}
|
||||||
|
onClick={() => !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 */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 0.5, mb: 0.75 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||||
|
<MatchScoreDisplay score={item.matchScore} size="sm" />
|
||||||
|
<HeatBadge propertyId={item.propertyId} size="sm" />
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
|
||||||
|
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 20, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={stageConfig.label}
|
||||||
|
sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontSize: 10, height: 20, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{!isDragOverlay && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.125, flexShrink: 0 }}>
|
||||||
|
{item.inquiryId && onChatClick && (
|
||||||
|
<Tooltip title="Chat öffnen">
|
||||||
|
<IconButton size="small" onClick={onChatClick} sx={{ p: 0.25, color: '#1e3a5f', '&:hover': { bgcolor: '#eff6ff' } }}>
|
||||||
|
<MessageSquare size={13} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{path && (
|
||||||
|
<Tooltip title="Objekt öffnen">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
||||||
|
sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#1e3a5f', bgcolor: '#eff6ff' } }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Row 2: Title */}
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }} noWrap>
|
||||||
|
{item.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Row 3: Location */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
|
<MapPin size={11} color="#64748b" />
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }} noWrap>
|
||||||
|
{item.propertyAddress ?? item.location}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Row 4: Area + rent */}
|
||||||
|
{(item.areaLabel || item.rentLabel) && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
|
{item.areaLabel && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.72rem' }}>{item.areaLabel}</Typography>
|
||||||
|
)}
|
||||||
|
{item.areaLabel && item.rentLabel && (
|
||||||
|
<Box sx={{ width: 3, height: 3, borderRadius: '50%', bgcolor: '#cbd5e1' }} />
|
||||||
|
)}
|
||||||
|
{item.rentLabel && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.72rem' }}>{item.rentLabel}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 5: Notes preview */}
|
||||||
|
{item.notes && (
|
||||||
|
<Typography variant="caption" sx={{ fontStyle: 'italic', color: '#94a3b8', mt: 0.5, display: 'block', fontSize: '0.7rem' }} noWrap>
|
||||||
|
{item.notes}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Box
|
||||||
|
ref={setNodeRef}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
minHeight: 80,
|
||||||
|
p: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: isOver ? `${stage.color}10` : 'transparent',
|
||||||
|
border: isOver ? `2px dashed ${stage.color}60` : '2px solid transparent',
|
||||||
|
transition: 'background-color 0.15s, border-color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map(item => (
|
||||||
|
<DraggableCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isSelected={item.id === selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<Box sx={{ py: 2, textAlign: 'center' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||||
|
{isOver ? 'Hier ablegen' : 'Leer'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Box sx={{
|
||||||
|
width: { xs: '100%', md: 340 }, flexShrink: 0,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
bgcolor: 'white', borderLeft: '1px solid #e2e8f0', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ px: 2, py: 2, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>{item.title}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{item.location}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 900, fontSize: '1.1rem', color: scoreColor(item.matchScore) }}>
|
||||||
|
{item.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
{path && (
|
||||||
|
<Tooltip title="Vollständige Detailansicht öffnen">
|
||||||
|
<IconButton size="small" onClick={() => navigate(path)} sx={{ color: '#64748b', '&:hover': { color: '#1e3a5f' } }}>
|
||||||
|
<ExternalLink size={15} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<IconButton size="small" onClick={onClose} sx={{ color: '#94a3b8' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.25, mb: 1 }}>
|
||||||
|
{STAGES.slice(0, 5).map((s, idx) => (
|
||||||
|
<Box key={s.key} sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: idx <= progressIdx ? stageConfig.color : '#e2e8f0' }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip label={stageConfig.label} size="small" sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontWeight: 700, height: 22, fontSize: '0.75rem' }} />
|
||||||
|
{item.inquiryId && (
|
||||||
|
<Chip
|
||||||
|
icon={<MessageSquare size={11} />}
|
||||||
|
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' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Property / unit address */}
|
||||||
|
{item.propertyAddress && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 1.25 }}>
|
||||||
|
<MapPin size={12} color="#64748b" />
|
||||||
|
<Typography variant="caption" color="text.secondary">{item.propertyAddress}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{/* KI */}
|
||||||
|
<Box sx={{ px: 2, pt: 2, pb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||||
|
<Sparkles size={13} color="#7c3aed" />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#7c3aed', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
||||||
|
KI Einschätzung
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #ddd6fe', borderRadius: 2, p: 1.5, mb: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#4c1d95', lineHeight: 1.6 }}>{ki.summary}</Typography>
|
||||||
|
</Box>
|
||||||
|
{ki.positives.map((p, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.375 }}>
|
||||||
|
<CheckCircle size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#166534', lineHeight: 1.4 }}>{p}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{ki.risks.map((r, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.375 }}>
|
||||||
|
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#92400e', lineHeight: 1.4 }}>{r}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Stage actions */}
|
||||||
|
{!isClosed && (
|
||||||
|
<Box sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem', display: 'block', mb: 1 }}>
|
||||||
|
Nächste Aktion
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{nextStage && (
|
||||||
|
<Button size="small" variant="contained" endIcon={<ChevronRight size={14} />}
|
||||||
|
onClick={() => moveStage(item.id, nextStage.key)}
|
||||||
|
sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }}
|
||||||
|
>
|
||||||
|
{nextStage.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="small" variant="outlined" onClick={() => loseItem(item.id)}
|
||||||
|
sx={{ color: '#c0392b', borderColor: '#c0392b', fontSize: '0.75rem', py: 0.5 }}>
|
||||||
|
Ablehnen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Box sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||||
|
<StickyNote size={13} color="#475569" />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
||||||
|
Notizen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
size="small" multiline minRows={3} fullWidth
|
||||||
|
placeholder="Notiz hinzufügen…"
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
onBlur={() => updateNotes(item.id, notes)}
|
||||||
|
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Documents */}
|
||||||
|
<Box sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||||
|
<FileText size={13} color="#475569" />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
||||||
|
Dokumente
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>Noch keine Dokumente.</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{docs.map((doc, i) => (
|
||||||
|
<Box key={i} sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1,
|
||||||
|
px: 1.5, py: 0.75, bgcolor: '#f8fafc',
|
||||||
|
borderRadius: 1.5, border: '1px solid #e2e8f0',
|
||||||
|
cursor: 'pointer', '&:hover': { bgcolor: '#f1f5f9' },
|
||||||
|
}}>
|
||||||
|
<FileText size={13} color="#475569" />
|
||||||
|
<Typography variant="caption" sx={{ flex: 1, color: '#1e293b' }} noWrap>{doc.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{doc.date}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Button size="small" variant="text" sx={{ mt: 0.75, color: '#1e3a5f', fontSize: '0.75rem', p: 0 }}>
|
||||||
|
+ Dokument hochladen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 2, pb: 2 }}>
|
||||||
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{item.availabilityLabel && <Typography variant="caption" color="text.secondary"><strong>Verfügbar:</strong> {item.availabilityLabel}</Typography>}
|
||||||
|
{item.assignedTo && <Typography variant="caption" color="text.secondary"><strong>Verantwortlich:</strong> {item.assignedTo}</Typography>}
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
<strong>Hinzugefügt:</strong> {new Date(item.addedAt).toLocaleDateString('de-CH')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
||||||
|
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<string, string> = {
|
||||||
|
VERIFIED_PORTFOLIO: 'Plattform',
|
||||||
|
EXTERNAL_MARKET: 'Plattform',
|
||||||
|
MAISON_WORK: 'Maison Work',
|
||||||
|
FUTURE_AVAILABILITY: 'Future',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESULT_TYPE_COLOR: Record<string, string> = {
|
||||||
|
VERIFIED_PORTFOLIO: '#1e3a5f',
|
||||||
|
EXTERNAL_MARKET: '#1e3a5f',
|
||||||
|
MAISON_WORK: '#0369a1',
|
||||||
|
FUTURE_AVAILABILITY: '#7c3aed',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_DOCS: Record<string, { name: string; date: string }[]> = {
|
||||||
|
'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' },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@ import { ViewToggle } from '../shared'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
total: number
|
total: number
|
||||||
verifiedCount: number
|
platformCount: number
|
||||||
externalCount: number
|
maisonWorkCount: number
|
||||||
futureCount: number
|
futureCount: number
|
||||||
view?: 'list' | 'grid'
|
view?: 'list' | 'grid'
|
||||||
onViewChange?: (v: 'list' | 'grid') => void
|
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 (
|
return (
|
||||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -18,7 +18,7 @@ export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCo
|
|||||||
{total} Empfehlungen
|
{total} Empfehlungen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{verifiedCount} Verifiziert · {externalCount} Extern · {futureCount} Marktsignale
|
{platformCount} Plattform · {maisonWorkCount} Maison Work · {futureCount} Future Availability
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{onViewChange && (
|
{onViewChange && (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Box, Card, Chip, Divider, Stack, Typography } from '@mui/material'
|
import { Box, Card, Chip, Divider, Stack, Typography } from '@mui/material'
|
||||||
import { Building2, Zap } from 'lucide-react'
|
import { Building2, Zap } from 'lucide-react'
|
||||||
import type { ResultType } from '../../domain/enums'
|
|
||||||
|
|
||||||
type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY' | 'VERIFIED_PORTFOLIO'> | 'ALL'
|
type FilterSource = 'ALL' | 'PLATFORM' | 'MAISON_WORK'
|
||||||
type SortBy = 'score' | 'rent' | 'area'
|
type SortBy = 'score' | 'rent' | 'area'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,9 +17,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [
|
const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [
|
||||||
{ value: 'ALL', label: 'Alle', color: '#1e3a5f' },
|
{ value: 'ALL', label: 'Alle', color: '#1e3a5f' },
|
||||||
{ value: 'EXTERNAL_MARKET', label: 'Direktinserat', color: '#b45309' },
|
{ value: 'PLATFORM', label: 'Plattform', color: '#1e3a5f' },
|
||||||
{ value: 'MAISON_WORK', label: 'Maison Work', color: '#0369a1' },
|
{ value: 'MAISON_WORK', label: 'Maison Work', color: '#0369a1' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: SortBy; label: string }[] = [
|
const SORT_OPTIONS: { value: SortBy; label: string }[] = [
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Tooltip title={tooltip} placement="top" arrow>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||||
|
bgcolor: bgColor, border, borderRadius: 1,
|
||||||
|
px: size === 'sm' ? 0.6 : 0.75, py: 0.15,
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flame size={iconSize} color={iconColor} fill={isVeryHot ? iconColor : 'none'} />
|
||||||
|
<Typography sx={{ fontSize, fontWeight: 700, color: textColor, lineHeight: 1.2 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { ViewToggle } from './ViewToggle'
|
export { ViewToggle } from './ViewToggle'
|
||||||
|
export { HeatBadge } from './HeatBadge'
|
||||||
export { LocationPreview } from './LocationPreview'
|
export { LocationPreview } from './LocationPreview'
|
||||||
export { PropertyMap } from './PropertyMap'
|
export { PropertyMap } from './PropertyMap'
|
||||||
export { getScoreTier, SCORE_THEME } from './scoreTheme'
|
export { getScoreTier, SCORE_THEME } from './scoreTheme'
|
||||||
|
|||||||
@@ -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<ReturnType<typeof useMatchDetail>['data']>
|
||||||
|
|
||||||
|
export function useMatchDetailData(matchId: string) {
|
||||||
|
const { data: match, isLoading } = useMatchDetail(matchId)
|
||||||
|
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
|
||||||
|
const { data: property = null } = useQuery<Property | null>({
|
||||||
|
queryKey: ['property', match?.propertyId],
|
||||||
|
queryFn: () => propertyService.getById(match!.propertyId),
|
||||||
|
enabled: !!match && !isFuture,
|
||||||
|
select: r => r.data ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: need = null } = useQuery<Need | null>({
|
||||||
|
queryKey: ['need', match?.needId],
|
||||||
|
queryFn: () => needService.getById(match!.needId),
|
||||||
|
enabled: !!match?.needId,
|
||||||
|
select: r => r.data ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: signal = null } = useQuery<FutureSignal | null>({
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,9 +66,9 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
// Result type display labels
|
// Result type display labels
|
||||||
export const RESULT_TYPE_LABELS: Record<string, string> = {
|
export const RESULT_TYPE_LABELS: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: 'Verified Portfolio',
|
VERIFIED_PORTFOLIO: 'Plattform',
|
||||||
EXTERNAL_MARKET: 'Direktinserat',
|
EXTERNAL_MARKET: 'Plattform',
|
||||||
MAISON_WORK: 'Maison Work',
|
MAISON_WORK: 'Maison Work',
|
||||||
FUTURE_AVAILABILITY: 'Zukunftssignal',
|
FUTURE_AVAILABILITY: 'Zukunftssignal',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants'
|
|||||||
export const DS_COLORS = {
|
export const DS_COLORS = {
|
||||||
resultType: {
|
resultType: {
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
|
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' },
|
MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
|
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<string, PropertyHeat> = {
|
||||||
|
'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(' · ')
|
||||||
|
}
|
||||||
@@ -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.',
|
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'],
|
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'],
|
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
|
// 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.',
|
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'],
|
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'],
|
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
|
// 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.',
|
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'],
|
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'],
|
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
|
// 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.',
|
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'],
|
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'],
|
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
|
// 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.',
|
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'],
|
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'],
|
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
|
// 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.',
|
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'],
|
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'],
|
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
|
// signal-011: Mode Boutique Bern AG — Rückgang Altstadt
|
||||||
|
|||||||
+29
-182
@@ -5,7 +5,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Chip,
|
Chip,
|
||||||
LinearProgress,
|
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -28,81 +27,33 @@ import {
|
|||||||
CompareCell,
|
CompareCell,
|
||||||
MissingDataCell,
|
MissingDataCell,
|
||||||
AICompareSummary,
|
AICompareSummary,
|
||||||
|
CompareCriteriaCard,
|
||||||
} from '../../components/compare'
|
} from '../../components/compare'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import {
|
||||||
import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
|
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'])
|
function row(label: string, cells: ReactNode[]) {
|
||||||
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
|
return (
|
||||||
|
<TableRow hover key={label}>
|
||||||
function getProp(item: UnifiedMatchResult) {
|
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
||||||
return item.resultType !== 'FUTURE_AVAILABILITY'
|
{cells.map((cell, i) => (
|
||||||
? (item as VerifiedPortfolioResult | ExternalMarketResult).property
|
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
||||||
: null
|
))}
|
||||||
}
|
</TableRow>
|
||||||
|
)
|
||||||
function getSig(item: UnifiedMatchResult) {
|
|
||||||
return item.resultType === 'FUTURE_AVAILABILITY'
|
|
||||||
? (item as FutureAvailabilityResult).signal
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_META: Record<string, { label: string; color: string }> = {
|
|
||||||
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<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
|
||||||
|
|
||||||
const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
@@ -136,8 +87,6 @@ export default function Compare() {
|
|||||||
.sort((a, b) => b.weight - a.weight)
|
.sort((a, b) => b.weight - a.weight)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const maxRelevantWeight = relevantCriteria[0]?.weight ?? 1
|
|
||||||
|
|
||||||
const weightedTotals = compareItems.map(item =>
|
const weightedTotals = compareItems.map(item =>
|
||||||
relevantCriteria.reduce((sum, { key, weight }) => {
|
relevantCriteria.reduce((sum, { key, weight }) => {
|
||||||
const factor = [...item.match.positiveFactors, ...item.match.negativeFactors]
|
const factor = [...item.match.positiveFactors, ...item.match.negativeFactors]
|
||||||
@@ -177,32 +126,6 @@ export default function Compare() {
|
|||||||
)
|
)
|
||||||
const maxMissingCritical = Math.max(...missingCriticalCounts)
|
const maxMissingCritical = Math.max(...missingCriticalCounts)
|
||||||
|
|
||||||
// ── Shared render helpers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function row(label: string, cells: ReactNode[]) {
|
|
||||||
return (
|
|
||||||
<TableRow hover key={label}>
|
|
||||||
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
|
||||||
{cells.map((cell, i) => (
|
|
||||||
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreBar(value: number, label?: string) {
|
|
||||||
const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b'
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Box sx={{ width: 70 }}>
|
|
||||||
<LinearProgress variant="determinate" value={value * 100}
|
|
||||||
sx={{ height: 6, borderRadius: 3, '& .MuiLinearProgress-bar': { bgcolor: color } }} />
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" sx={{ color }}>{label ?? `${Math.round(value * 100)}%`}</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
@@ -233,88 +156,12 @@ export default function Compare() {
|
|||||||
|
|
||||||
{/* Criteria Head-to-Head */}
|
{/* Criteria Head-to-Head */}
|
||||||
{activeNeed && relevantCriteria.length > 0 && (
|
{activeNeed && relevantCriteria.length > 0 && (
|
||||||
<Card sx={{ mb: 3, overflow: 'hidden' }}>
|
<CompareCriteriaCard
|
||||||
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', display: 'flex', alignItems: 'center', gap: 2 }}>
|
compareItems={compareItems}
|
||||||
<Trophy size={18} color="#d4920e" />
|
relevantCriteria={relevantCriteria}
|
||||||
<Box>
|
overallWinnerIdx={overallWinnerIdx}
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Vergleich nach Suchkriterien</Typography>
|
activeNeed={activeNeed}
|
||||||
<Typography variant="caption" color="text.secondary">Basierend auf der Suche: {activeNeed.companyName}</Typography>
|
/>
|
||||||
</Box>
|
|
||||||
{overallWinnerIdx !== -1 && (
|
|
||||||
<Box sx={{ ml: 'auto', bgcolor: '#fffbeb', border: '1px solid #fbbf24', borderRadius: 2, px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Trophy size={16} color="#d4920e" />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800, color: '#7a4f00' }}>{getTitle(compareItems[overallWinnerIdx])}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
|
||||||
<Table sx={{ tableLayout: 'auto' }}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow sx={{ bgcolor: '#f8fafc' }}>
|
|
||||||
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
|
|
||||||
{compareItems.map(item => (
|
|
||||||
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }} noWrap>
|
|
||||||
{getTitle(item)}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{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 (
|
|
||||||
<TableRow key={key} hover>
|
|
||||||
<TableCell sx={LABEL_SX}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{label}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
{factors.map((factor, idx) => (
|
|
||||||
<TableCell key={idx} sx={{ ...DATA_SX, bgcolor: idx === winnerIdx ? 'rgba(26,122,74,0.05)' : undefined }}>
|
|
||||||
{factor ? (
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
|
||||||
{scoreBar(factor.score / 100)}
|
|
||||||
{idx === winnerIdx && (
|
|
||||||
<Chip label="Besser" size="small"
|
|
||||||
icon={<CheckCircle2 size={10} />}
|
|
||||||
sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534',
|
|
||||||
'& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 },
|
|
||||||
'& .MuiChip-label': { px: 0.75 } }} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" color="text.secondary"
|
|
||||||
sx={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', lineHeight: 1.4 }}>
|
|
||||||
{factor.explanation}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<MissingDataCell reason="Kein Score für dieses Kriterium" />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card sx={{ overflowX: 'auto' }}>
|
<Card sx={{ overflowX: 'auto' }}>
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { Box, Button, Chip, CircularProgress, Divider, Paper, Typography } from '@mui/material'
|
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 { 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 { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
@@ -26,71 +20,9 @@ import {
|
|||||||
NextActionsPanel,
|
NextActionsPanel,
|
||||||
} from '../../components/match-detail'
|
} from '../../components/match-detail'
|
||||||
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
||||||
|
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
||||||
// ── Property detail helpers ────────────────────────────────────────────────────
|
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from '../../components/match-detail/MatchDetailPropertyDetails'
|
||||||
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
const FLOOR_LABEL = (level: number) =>
|
|
||||||
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
|
|
||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
|
|
||||||
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
|
|
||||||
}
|
|
||||||
const RISK_LABELS: Record<string, string> = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' }
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
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<string, string> = {
|
|
||||||
LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch',
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyFactRow({ label, value }: { label: string; value?: string | null }) {
|
|
||||||
if (!value) return null
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
|
|
||||||
if (unit.schattenmarktRelease?.enabled) {
|
|
||||||
return <Chip size="small" icon={<ShieldCheck size={11} />} label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} />
|
|
||||||
}
|
|
||||||
if (unit.available) {
|
|
||||||
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
|
|
||||||
}
|
|
||||||
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Box sx={{
|
|
||||||
display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto',
|
|
||||||
gap: 1.5, alignItems: 'center', px: 2, py: 1.5, borderRadius: 1, mb: 1,
|
|
||||||
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
|
|
||||||
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
|
|
||||||
}}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
|
|
||||||
</Typography>
|
|
||||||
{unit.currentTenant && <Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>}
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : '–'}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{availableFrom ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : '–'}
|
|
||||||
</Typography>
|
|
||||||
<UnitStatusChip unit={unit} />
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Match helpers ──────────────────────────────────────────────────────────────
|
// ── Match helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -119,29 +51,7 @@ export default function MatchDetail() {
|
|||||||
const { addToCompare } = useCompareStore()
|
const { addToCompare } = useCompareStore()
|
||||||
const { openSavedDialog } = usePipelineStore()
|
const { openSavedDialog } = usePipelineStore()
|
||||||
|
|
||||||
const { data: match, isLoading } = useMatchDetail(matchId ?? '')
|
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(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,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,496 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, Tooltip, Card,
|
Box, Chip, Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
DndContext, DragOverlay, PointerSensor, useSensor, useSensors,
|
DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter,
|
||||||
useDroppable, useDraggable, closestCenter,
|
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { TrendingUp } from 'lucide-react'
|
||||||
import { X, Sparkles, FileText, StickyNote, ChevronRight, TrendingUp, AlertTriangle, CheckCircle, MessageSquare, MapPin, ExternalLink } from 'lucide-react'
|
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay'
|
|
||||||
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
||||||
|
import { STAGES } from '../../components/pipeline/pipelineConstants'
|
||||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
import { DraggableCard } from '../../components/pipeline/PipelineCard'
|
||||||
|
import { DroppableColumn } from '../../components/pipeline/PipelineColumn'
|
||||||
const STAGES = [
|
import { DetailPanel } from '../../components/pipeline/PipelineDetailPanel'
|
||||||
{ 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<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
|
||||||
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<string, string> = {
|
|
||||||
VERIFIED_PORTFOLIO: 'Portfolio',
|
|
||||||
EXTERNAL_MARKET: 'Direktinserat',
|
|
||||||
MAISON_WORK: 'Maison Work',
|
|
||||||
FUTURE_AVAILABILITY:'Future',
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESULT_TYPE_COLOR: Record<string, string> = {
|
|
||||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
|
||||||
EXTERNAL_MARKET: '#d97706',
|
|
||||||
MAISON_WORK: '#0369a1',
|
|
||||||
FUTURE_AVAILABILITY: '#7c3aed',
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_DOCS: Record<string, { name: string; date: string }[]> = {
|
|
||||||
'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 (
|
|
||||||
<Card
|
|
||||||
ref={!isDragOverlay ? setNodeRef : undefined}
|
|
||||||
style={style}
|
|
||||||
elevation={isDragOverlay ? 6 : 0}
|
|
||||||
onClick={() => !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 */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 0.5, mb: 0.75 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
|
||||||
<MatchScoreDisplay score={item.matchScore} size="sm" />
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
|
|
||||||
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 20, fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={stageConfig.label}
|
|
||||||
sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontSize: 10, height: 20, fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{!isDragOverlay && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.125, flexShrink: 0 }}>
|
|
||||||
{item.inquiryId && onChatClick && (
|
|
||||||
<Tooltip title="Chat öffnen">
|
|
||||||
<IconButton size="small" onClick={onChatClick} sx={{ p: 0.25, color: '#1e3a5f', '&:hover': { bgcolor: '#eff6ff' } }}>
|
|
||||||
<MessageSquare size={13} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{path && (
|
|
||||||
<Tooltip title="Objekt öffnen">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
|
||||||
sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#1e3a5f', bgcolor: '#eff6ff' } }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={13} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Row 2: Title */}
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }} noWrap>
|
|
||||||
{item.title}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Row 3: Location */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
|
||||||
<MapPin size={11} color="#64748b" />
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }} noWrap>
|
|
||||||
{item.propertyAddress ?? item.location}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Row 4: Area + rent */}
|
|
||||||
{(item.areaLabel || item.rentLabel) && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
|
||||||
{item.areaLabel && (
|
|
||||||
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.72rem' }}>{item.areaLabel}</Typography>
|
|
||||||
)}
|
|
||||||
{item.areaLabel && item.rentLabel && (
|
|
||||||
<Box sx={{ width: 3, height: 3, borderRadius: '50%', bgcolor: '#cbd5e1' }} />
|
|
||||||
)}
|
|
||||||
{item.rentLabel && (
|
|
||||||
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.72rem' }}>{item.rentLabel}</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Row 5: Notes preview */}
|
|
||||||
{item.notes && (
|
|
||||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: '#94a3b8', mt: 0.5, display: 'block', fontSize: '0.7rem' }} noWrap>
|
|
||||||
{item.notes}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<Box
|
|
||||||
ref={setNodeRef}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
minHeight: 80,
|
|
||||||
p: 0.75,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: isOver ? `${stage.color}10` : 'transparent',
|
|
||||||
border: isOver ? `2px dashed ${stage.color}60` : '2px solid transparent',
|
|
||||||
transition: 'background-color 0.15s, border-color 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map(item => (
|
|
||||||
<DraggableCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
isSelected={item.id === selectedId}
|
|
||||||
onSelect={onSelect}
|
|
||||||
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{items.length === 0 && (
|
|
||||||
<Box sx={{ py: 2, textAlign: 'center' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
|
||||||
{isOver ? 'Hier ablegen' : 'Leer'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<Box sx={{
|
|
||||||
width: { xs: '100%', md: 340 }, flexShrink: 0,
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
bgcolor: 'white', borderLeft: '1px solid #e2e8f0', overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ px: 2, py: 2, borderBottom: '1px solid #e2e8f0' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>{item.title}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{item.location}</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
|
|
||||||
<Typography sx={{ fontWeight: 900, fontSize: '1.1rem', color: scoreColor(item.matchScore) }}>
|
|
||||||
{item.matchScore}%
|
|
||||||
</Typography>
|
|
||||||
{path && (
|
|
||||||
<Tooltip title="Vollständige Detailansicht öffnen">
|
|
||||||
<IconButton size="small" onClick={() => navigate(path)} sx={{ color: '#64748b', '&:hover': { color: '#1e3a5f' } }}>
|
|
||||||
<ExternalLink size={15} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<IconButton size="small" onClick={onClose} sx={{ color: '#94a3b8' }}>
|
|
||||||
<X size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.25, mb: 1 }}>
|
|
||||||
{STAGES.slice(0, 5).map((s, idx) => (
|
|
||||||
<Box key={s.key} sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: idx <= progressIdx ? stageConfig.color : '#e2e8f0' }} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Chip label={stageConfig.label} size="small" sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontWeight: 700, height: 22, fontSize: '0.75rem' }} />
|
|
||||||
{item.inquiryId && (
|
|
||||||
<Chip
|
|
||||||
icon={<MessageSquare size={11} />}
|
|
||||||
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' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Property / unit address */}
|
|
||||||
{item.propertyAddress && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 1.25 }}>
|
|
||||||
<MapPin size={12} color="#64748b" />
|
|
||||||
<Typography variant="caption" color="text.secondary">{item.propertyAddress}</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
|
||||||
{/* KI */}
|
|
||||||
<Box sx={{ px: 2, pt: 2, pb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
|
||||||
<Sparkles size={13} color="#7c3aed" />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#7c3aed', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
|
||||||
KI Einschätzung
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #ddd6fe', borderRadius: 2, p: 1.5, mb: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ color: '#4c1d95', lineHeight: 1.6 }}>{ki.summary}</Typography>
|
|
||||||
</Box>
|
|
||||||
{ki.positives.map((p, i) => (
|
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.375 }}>
|
|
||||||
<CheckCircle size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: '#166534', lineHeight: 1.4 }}>{p}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{ki.risks.map((r, i) => (
|
|
||||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.375 }}>
|
|
||||||
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: '#92400e', lineHeight: 1.4 }}>{r}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Stage actions */}
|
|
||||||
{!isClosed && (
|
|
||||||
<Box sx={{ px: 2, py: 1.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem', display: 'block', mb: 1 }}>
|
|
||||||
Nächste Aktion
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
{nextStage && (
|
|
||||||
<Button size="small" variant="contained" endIcon={<ChevronRight size={14} />}
|
|
||||||
onClick={() => moveStage(item.id, nextStage.key)}
|
|
||||||
sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }}
|
|
||||||
>
|
|
||||||
{nextStage.label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button size="small" variant="outlined" onClick={() => loseItem(item.id)}
|
|
||||||
sx={{ color: '#c0392b', borderColor: '#c0392b', fontSize: '0.75rem', py: 0.5 }}>
|
|
||||||
Ablehnen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<Box sx={{ px: 2, py: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
|
||||||
<StickyNote size={13} color="#475569" />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
|
||||||
Notizen
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<TextField
|
|
||||||
size="small" multiline minRows={3} fullWidth
|
|
||||||
placeholder="Notiz hinzufügen…"
|
|
||||||
value={notes}
|
|
||||||
onChange={e => setNotes(e.target.value)}
|
|
||||||
onBlur={() => updateNotes(item.id, notes)}
|
|
||||||
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Documents */}
|
|
||||||
<Box sx={{ px: 2, py: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
|
||||||
<FileText size={13} color="#475569" />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
|
||||||
Dokumente
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{docs.length === 0 ? (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>Noch keine Dokumente.</Typography>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{docs.map((doc, i) => (
|
|
||||||
<Box key={i} sx={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 1,
|
|
||||||
px: 1.5, py: 0.75, bgcolor: '#f8fafc',
|
|
||||||
borderRadius: 1.5, border: '1px solid #e2e8f0',
|
|
||||||
cursor: 'pointer', '&:hover': { bgcolor: '#f1f5f9' },
|
|
||||||
}}>
|
|
||||||
<FileText size={13} color="#475569" />
|
|
||||||
<Typography variant="caption" sx={{ flex: 1, color: '#1e293b' }} noWrap>{doc.name}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{doc.date}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Button size="small" variant="text" sx={{ mt: 0.75, color: '#1e3a5f', fontSize: '0.75rem', p: 0 }}>
|
|
||||||
+ Dokument hochladen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ px: 2, pb: 2 }}>
|
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{item.availabilityLabel && <Typography variant="caption" color="text.secondary"><strong>Verfügbar:</strong> {item.availabilityLabel}</Typography>}
|
|
||||||
{item.assignedTo && <Typography variant="caption" color="text.secondary"><strong>Verantwortlich:</strong> {item.assignedTo}</Typography>}
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
<strong>Hinzugefügt:</strong> {new Date(item.addedAt).toLocaleDateString('de-CH')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pipeline page ─────────────────────────────────────────────────────────────
|
// ── Pipeline page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -518,8 +42,8 @@ export default function Pipeline() {
|
|||||||
setActiveId(active.id as string)
|
setActiveId(active.id as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver({ over }: { over: { id: string } | null }) {
|
function handleDragOver({ over }: { over: { id: string | number } | null }) {
|
||||||
setOverId(over?.id ?? null)
|
setOverId(over ? String(over.id) : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnd({ active, over }: DragEndEvent) {
|
function handleDragEnd({ active, over }: DragEndEvent) {
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ import {
|
|||||||
} from '../../components/results'
|
} from '../../components/results'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import type { ResultType } from '../../domain/enums'
|
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY' | 'VERIFIED_PORTFOLIO'> | 'ALL'
|
type FilterSource = 'ALL' | 'PLATFORM' | 'MAISON_WORK'
|
||||||
type SortBy = 'score' | 'rent' | 'area'
|
type SortBy = 'score' | 'rent' | 'area'
|
||||||
|
|
||||||
function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] {
|
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 isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
|
|
||||||
const filtered = results.filter(r => {
|
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
|
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 sorted = sortResults(filtered, sortBy)
|
||||||
|
|
||||||
const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length
|
const platformCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET').length
|
||||||
const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length
|
|
||||||
const maisonWorkCount = results.filter(r => r.resultType === 'MAISON_WORK').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 strongCount = filtered.filter(r => r.matchScore >= 80).length
|
||||||
const missingDataCount = results.filter(r =>
|
const missingDataCount = results.filter(r =>
|
||||||
'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
|
||||||
@@ -94,8 +96,8 @@ export default function Results() {
|
|||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
<ResultFeedHeader
|
<ResultFeedHeader
|
||||||
total={sorted.length}
|
total={sorted.length}
|
||||||
verifiedCount={verifiedCount}
|
platformCount={platformCount}
|
||||||
externalCount={externalCount}
|
maisonWorkCount={maisonWorkCount}
|
||||||
futureCount={futureCount}
|
futureCount={futureCount}
|
||||||
view={view}
|
view={view}
|
||||||
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
|
onViewChange={v => { 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}
|
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}–${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
|
||||||
metrics={[
|
metrics={[
|
||||||
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
|
...(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 }] : []),
|
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
|
||||||
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []),
|
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []),
|
||||||
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
|
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
|
||||||
|
|||||||
Reference in New Issue
Block a user