feat: role-aware UX, market intelligence, score fix & layout scroll

- Role-based workspace access: Property Manager gets Supply+Demand,
  Operations restricted to Super Admin + Reviewer only
- Root redirect routes each persona to their first workspace
- Match Center: replaced 3-panel with auto-sorted flat list + drawer
- AI Search: unified form with dual-action (Jetzt suchen / Als Suchprofil speichern)
- CompareTray hidden on non-Demand routes; Vergleichen removed from Supply
- LocationIntelligencePanel: city KPIs, rent trends, soft factors, comparables
- NegotiationInsightsPanel: price positioning, active demand, selling arguments
- scoreCalculator: cap hardMatchScore and softFactorScore to max 100
- Layout: add display:flex to overflow:hidden wrappers so inner scroll works
  (MatchCenter drawer, MarketIntelligence, SourceMonitoring, SignalPipeline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-17 00:58:10 +02:00
parent e13fe470fb
commit efc406ace9
37 changed files with 4871 additions and 459 deletions
+6 -61
View File
@@ -111,74 +111,19 @@ export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set
/>
</Section>
{/* ── Soft Factors ────────────────────────────────────────────────── */}
<Section title="Soft Factors">
<ExtractedFieldRow
label="Prestige-Anforderung"
value={c.prestigeImportance ?? ''}
confidence={conf.prestigeImportance ?? 0.2}
missing={!c.prestigeImportance}
onEdit={v => set({ ...c, prestigeImportance: (v.toUpperCase() as ParsedNeedCriteria['prestigeImportance']) })}
/>
<ExtractedFieldRow
label="Flexibilitätsbedarf"
value={c.flexibilityNeed ?? ''}
confidence={0.5}
missing={!c.flexibilityNeed}
onEdit={v => set({ ...c, flexibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['flexibilityNeed']) })}
/>
<ExtractedFieldRow
label="Expansionspotenzial"
value={c.expansionPotential === true ? 'Ja' : c.expansionPotential === false ? 'Nein' : ''}
confidence={0.5}
missing={c.expansionPotential === undefined}
onEdit={v => set({ ...c, expansionPotential: v.toLowerCase().startsWith('j') })}
/>
<ExtractedFieldRow
label="Parkplatzbedarf"
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
confidence={conf.parkingNeed ?? 0.3}
missing={c.parkingNeed === undefined}
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
/>
<ExtractedFieldRow
label="Sichtbarkeit"
value={c.visibilityNeed ?? ''}
confidence={0.4}
missing={!c.visibilityNeed}
onEdit={v => set({ ...c, visibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['visibilityNeed']) })}
/>
<ExtractedFieldRow
label="Passantenfrequenz"
value={c.footfallNeed ?? ''}
confidence={0.4}
missing={!c.footfallNeed}
onEdit={v => set({ ...c, footfallNeed: (v.toUpperCase() as ParsedNeedCriteria['footfallNeed']) })}
/>
</Section>
{/* ── Must-haves ──────────────────────────────────────────────────── */}
<Section title="Must-haves & Infrastruktur">
<Section title="Must-haves">
<ExtractedFieldRow
label="Must-have Kriterien (Komma-getrennt)"
label="Pflichtkriterien (Komma-getrennt)"
value={c.mustHaveCriteria?.join(', ') ?? ''}
confidence={conf.mustHaveCriteria ?? 0.1}
missing={!c.mustHaveCriteria?.length}
onEdit={v => set({ ...c, mustHaveCriteria: parseList(v) })}
/>
<ExtractedFieldRow
label="Infrastrukturanforderungen (Komma-getrennt)"
value={c.infrastructureRequirements?.join(', ') ?? ''}
confidence={0.5}
missing={!c.infrastructureRequirements?.length}
onEdit={v => set({ ...c, infrastructureRequirements: parseList(v) })}
/>
<ExtractedFieldRow
label="Barrierefreiheit (Komma-getrennt)"
value={c.accessibilityRequirements?.join(', ') ?? ''}
confidence={0.5}
missing={!c.accessibilityRequirements?.length}
onEdit={v => set({ ...c, accessibilityRequirements: parseList(v) })}
label="Parkplatzbedarf"
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
missing={c.parkingNeed === undefined}
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
/>
</Section>
+12 -16
View File
@@ -1,17 +1,16 @@
import { useState } from 'react'
import { Box, IconButton, TextField, Typography } from '@mui/material'
import { Pencil } from 'lucide-react'
import { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
interface Props {
label: string
value: string
confidence: number
confidence?: number
missing?: boolean
onEdit?: (value: string) => void
}
export function ExtractedFieldRow({ label, value, confidence, missing = false, onEdit }: Props) {
export function ExtractedFieldRow({ label, value, missing = false, onEdit }: Props) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value)
@@ -60,19 +59,16 @@ export function ExtractedFieldRow({ label, value, confidence, missing = false, o
{value || '—'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<ConfidenceFieldBadge confidence={confidence} />
{onEdit && (
<IconButton
size="small"
className="edit-btn"
sx={{ visibility: 'hidden', p: 0.25 }}
onClick={() => { setEditValue(value); setEditing(true) }}
>
<Pencil size={12} />
</IconButton>
)}
</Box>
{onEdit && (
<IconButton
size="small"
className="edit-btn"
sx={{ visibility: 'hidden', p: 0.25, flexShrink: 0 }}
onClick={() => { setEditValue(value); setEditing(true) }}
>
<Pencil size={12} />
</IconButton>
)}
</Box>
)
}
@@ -6,13 +6,16 @@ interface Props {
step: NeedBuilderStep
}
const STEPS = ['Bedarf eingeben', 'Kriterien prüfen', 'Gewichtung', 'Speichern']
const STEPS = ['Suchkriterien & Gewichtung', 'Vorschau & Speichern']
function toStepIndex(step: NeedBuilderStep): number {
if (step === S.IDLE || step === S.PARSING) return 0
if (step === S.PARSED_REQUIRES_REVIEW || step === S.CLARIFICATION_REQUIRED) return 1
if (step === S.WEIGHTING_REVIEW) return 2
return 3
if (
step === S.IDLE ||
step === S.PARSING ||
step === S.PARSED_REQUIRES_REVIEW ||
step === S.CLARIFICATION_REQUIRED
) return 0
return 1
}
export function NeedBuilderProgress({ step }: Props) {
+127 -80
View File
@@ -1,108 +1,155 @@
import { Box, Button, Card, Chip, TextField, Typography, Stack } from '@mui/material'
import { Sparkles, RotateCcw } from 'lucide-react'
import { useState } from 'react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
interface Props {
value: string
onChange: (v: string) => void
onSubmit: () => void
criteria: ParsedNeedCriteria
onCriteriaChange: (c: ParsedNeedCriteria) => void
}
const EXAMPLES: Record<string, string> = {
Büro: 'Wir suchen 8001.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur.',
Retail: 'Suche Ladenfläche 200400 m² in Bern Innenstadt, hohe Passantenfrequenz, Erdgeschoss, max. CHF 150/m². Sofort verfügbar.',
Logistik: 'Lagerhalle 2.0003.000 m² Basel Umgebung, Tiefgarage oder Aussenrampe, 12 m Deckenhöhe, sofort. Budget CHF 15/m².',
}
const ASSET_TYPE_OPTIONS: Array<{ label: string; value: string }> = [
const ASSET_OPTIONS = [
{ label: 'Büro', value: AssetType.OFFICE },
{ label: 'Retail', value: AssetType.RETAIL },
{ label: 'Logistik', value: AssetType.LOGISTICS },
{ label: 'Produktion', value: AssetType.PRODUCTION },
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gemischt', value: AssetType.MIXED },
]
export function NeedInput({ value, onChange, onSubmit }: Props) {
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
<Card sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
Flächenbedarf beschreiben
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
Beschreiben Sie Typ, Fläche, Standort, Budget und Verfügbarkeit die KI extrahiert die Kriterien automatisch.
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
{children}
</Typography>
)
}
<TextField
multiline
rows={5}
fullWidth
placeholder="Beispiel: Wir suchen 8001.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
value={value}
onChange={e => onChange(e.target.value)}
slotProps={{ htmlInput: { maxLength: 2000 } }}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'right', mb: 2 }}>
{value.length}/2000
</Typography>
export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
const [locationDraft, setLocationDraft] = useState('')
const [mustHaveDraft, setMustHaveDraft] = useState('')
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
fullWidth
disabled={value.length < 20}
onClick={onSubmit}
endIcon={<Sparkles size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
KI-Analyse starten
</Button>
<Button
variant="outlined"
onClick={() => onChange('')}
startIcon={<RotateCcw size={16} />}
sx={{ flexShrink: 0 }}
>
Reset
</Button>
</Box>
</Card>
function addLocations(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return
set({ ...c, preferredLocations: [...new Set([...(c.preferredLocations ?? []), ...tokens])] })
setLocationDraft('')
}
{/* Asset-Type Quick Select */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Schnellauswahl Nutzungstyp:
function addMustHaves(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return
set({ ...c, mustHaveCriteria: [...new Set([...(c.mustHaveCriteria ?? []), ...tokens])] })
setMustHaveDraft('')
}
return (
<Card elevation={0} sx={{ p: 3, border: '1px solid #e2e8f0', height: '100%' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>Kriterien verfeinern</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2.5 }}>
Ergänzen oder korrigieren Sie die extrahierten Felder.
</Typography>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
{ASSET_TYPE_OPTIONS.map(opt => (
{/* Asset Type */}
<FieldLabel>Nutzungstyp</FieldLabel>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{ASSET_OPTIONS.map(opt => (
<Chip
key={opt.value}
label={opt.label}
size="small"
variant="outlined"
variant={c.assetType === opt.value ? 'filled' : 'outlined'}
clickable
onClick={() => onChange(EXAMPLES[opt.label] ?? value)}
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
sx={c.assetType === opt.value
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
: {}}
/>
))}
</Stack>
{/* Example prompts */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Beispiele:
</Typography>
<Stack spacing={0.5}>
{Object.entries(EXAMPLES).map(([label, text]) => (
<Chip
key={label}
label={`${label}: ${text.slice(0, 60)}`}
size="small"
variant="outlined"
clickable
onClick={() => onChange(text)}
sx={{ height: 'auto', py: 0.5, '& .MuiChip-label': { whiteSpace: 'normal' } }}
/>
))}
</Stack>
</Box>
{/* Area */}
<FieldLabel>Fläche (m²)</FieldLabel>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
<TextField
size="small" type="number" placeholder="Min"
value={c.areaRange?.min || ''}
onChange={e => set({ ...c, areaRange: { min: parseInt(e.target.value) || 0, max: c.areaRange?.max ?? 0 } })}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
<Typography variant="body2" color="text.secondary"></Typography>
<TextField
size="small" type="number" placeholder="Max"
value={c.areaRange?.max || ''}
onChange={e => set({ ...c, areaRange: { min: c.areaRange?.min ?? 0, max: parseInt(e.target.value) || 0 } })}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
<Typography variant="caption" color="text.secondary">m²</Typography>
</Box>
{/* Location */}
<FieldLabel>Standort</FieldLabel>
<TextField
size="small" fullWidth
placeholder="Stadt oder Region — Enter zum Hinzufügen"
value={locationDraft}
onChange={e => setLocationDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && locationDraft.trim()) addLocations(locationDraft) }}
onBlur={() => { if (locationDraft.trim()) addLocations(locationDraft) }}
sx={{ mb: 0.75 }}
/>
{(c.preferredLocations?.length ?? 0) > 0 ? (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{c.preferredLocations!.map(loc => (
<Chip key={loc} label={loc} size="small"
onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })}
/>
))}
</Stack>
) : <Box sx={{ mb: 2.5 }} />}
{/* Budget */}
<FieldLabel>Budget (max CHF/m²)</FieldLabel>
<TextField
size="small" type="number" placeholder="z.B. 45"
value={c.budgetRange?.maxPerSqm || ''}
onChange={e => set({ ...c, budgetRange: { maxPerSqm: parseInt(e.target.value) || 0, currency: 'CHF' } })}
sx={{ width: 160, mb: 2.5 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
{/* Timing */}
<FieldLabel>Verfügbar ab</FieldLabel>
<TextField
size="small"
placeholder="z.B. Q3 2025 oder 01.09.2025"
value={c.timing?.earliestMoveIn ?? ''}
onChange={e => set({ ...c, timing: { earliestMoveIn: e.target.value, latestMoveIn: e.target.value, flexibleTiming: true } })}
sx={{ width: 220, mb: 2.5 }}
/>
{/* Must-haves */}
<FieldLabel>Must-haves</FieldLabel>
<TextField
size="small" fullWidth
placeholder="z.B. ÖV-Anbindung, Parkplätze — Enter zum Hinzufügen"
value={mustHaveDraft}
onChange={e => setMustHaveDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
onBlur={() => { if (mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
sx={{ mb: 0.75 }}
/>
{(c.mustHaveCriteria?.length ?? 0) > 0 && (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{c.mustHaveCriteria!.map(item => (
<Chip key={item} label={item} size="small"
onDelete={() => set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })}
/>
))}
</Stack>
)}
</Card>
)
}
+202
View File
@@ -0,0 +1,202 @@
import { useRef, useState } from 'react'
import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
import { Mic, MicOff, Sparkles, X } from 'lucide-react'
interface Props {
text: string
onTextChange: (s: string) => void
onAiSubmit: () => void
isAnalyzing: boolean
isAutoGen: boolean
}
const EXAMPLES = [
'Büro 8001000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung',
'Retail-Fläche 200400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort',
'Lagerhalle 20003000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
]
const isSpeechSupported = typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) {
const [isRecording, setIsRecording] = useState(false)
const [interimText, setInterimText] = useState('')
const recognitionRef = useRef<any>(null)
const accumulatedRef = useRef('')
function startRecording() {
const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition
if (!SpeechAPI) return
accumulatedRef.current = text
const rec = new SpeechAPI()
rec.lang = 'de-DE'
rec.continuous = true
rec.interimResults = true
rec.onresult = (e: any) => {
let finalPart = ''
let interimPart = ''
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript
if (e.results[i].isFinal) finalPart += t
else interimPart += t
}
if (finalPart) {
accumulatedRef.current = (accumulatedRef.current + ' ' + finalPart).trim()
onTextChange(accumulatedRef.current)
}
setInterimText(interimPart)
}
rec.onend = () => {
setIsRecording(false)
setInterimText('')
if (accumulatedRef.current.length >= 15) onAiSubmit()
}
rec.onerror = () => { setIsRecording(false); setInterimText('') }
rec.start()
recognitionRef.current = rec
setIsRecording(true)
}
function stopRecording() {
recognitionRef.current?.stop()
}
// Show interim text inside the field while recording
const displayValue = isRecording && interimText
? (text + (text ? ' ' : '') + interimText)
: text
return (
<Card
elevation={0}
sx={{
p: 3,
border: '1px solid',
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
transition: 'border-color 0.2s',
bgcolor: isRecording ? '#fff5f5' : 'white',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
Bedarf beschreiben
</Typography>
<Typography variant="caption" color="text.secondary">
Schreiben oder sprechen die KI extrahiert alle Kriterien automatisch
</Typography>
</Box>
{isRecording ? (
<Chip
size="small"
label="🎤 Aufnahme läuft…"
onClick={stopRecording}
sx={{ bgcolor: '#fef2f2', color: '#dc2626', fontWeight: 600, fontSize: 11, cursor: 'pointer' }}
/>
) : isAnalyzing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} />
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert</Typography>
</Box>
) : isAutoGen && text ? (
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}> auto-synchronisiert</Typography>
) : null}
</Box>
{/* Textarea + mic */}
<Box sx={{ position: 'relative' }}>
<TextField
multiline
rows={4}
fullWidth
placeholder="Wir suchen 8001.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
value={displayValue}
onChange={e => {
setInterimText('')
onTextChange(e.target.value)
}}
disabled={isRecording || isAnalyzing}
slotProps={{ htmlInput: { maxLength: 2000 } }}
sx={{
'& .MuiOutlinedInput-root': {
pr: '52px',
bgcolor: isAutoGen && !isRecording ? '#f0f7ff' : 'transparent',
transition: 'background-color 0.2s',
'& textarea': { color: isRecording && interimText ? '#64748b' : 'inherit' },
},
}}
/>
<Box sx={{ position: 'absolute', bottom: 10, right: 10 }}>
{isRecording ? (
<IconButton
onClick={stopRecording}
size="small"
sx={{
bgcolor: '#dc2626', color: 'white', '&:hover': { bgcolor: '#b91c1c' },
animation: 'micPulse 1.2s ease-in-out infinite',
'@keyframes micPulse': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' },
'50%': { boxShadow: '0 0 0 6px rgba(220,38,38,0)' },
},
}}
>
<MicOff size={16} />
</IconButton>
) : (
<IconButton
onClick={isSpeechSupported ? startRecording : undefined}
size="small"
disabled={isAnalyzing || !isSpeechSupported}
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e2e8f0' }, '&:disabled': { opacity: 0.35 } }}
>
<Mic size={16} />
</IconButton>
)}
</Box>
</Box>
{/* Actions row */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5 }}>
{/* Example prompts */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{EXAMPLES.map((ex, i) => (
<Chip
key={i}
label={ex.slice(0, 32) + '…'}
size="small"
variant="outlined"
clickable
onClick={() => onTextChange(ex)}
sx={{ fontSize: 10, height: 20 }}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}>
{text && !isRecording && (
<IconButton size="small" onClick={() => onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}>
<X size={14} />
</IconButton>
)}
<Button
size="small"
variant="outlined"
disabled={!text.trim() || isAnalyzing || isRecording}
onClick={onAiSubmit}
endIcon={<Sparkles size={13} />}
sx={{ fontSize: 12, whiteSpace: 'nowrap' }}
>
KI Auto-fill
</Button>
</Box>
</Box>
</Card>
)
}
+37 -37
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Box, Button, Card, Slider, Typography } from '@mui/material'
import { RotateCcw } from 'lucide-react'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
@@ -10,17 +11,36 @@ interface Props {
assetType?: string
}
export function WeightingEditor({ weights, onChange, assetType }: Props) {
const total = WEIGHTING_KEYS.reduce((sum, k) => sum + (weights[k] ?? 0), 0)
const totalPct = Math.round(total * 100)
const isBalanced = totalPct >= 95 && totalPct <= 105
const IMPORTANCE_LABELS = ['', 'Unwichtig', 'Wenig wichtig', 'Wichtig', 'Sehr wichtig', 'Entscheidend']
function handleSlider(key: WeightingKey, pct: number) {
onChange({ ...weights, [key]: pct / 100 })
function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> {
const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0))
if (max === 0) return Object.fromEntries(WEIGHTING_KEYS.map(k => [k, 3])) as Record<WeightingKey, number>
return Object.fromEntries(
WEIGHTING_KEYS.map(k => [k, Math.max(1, Math.round(((w[k] ?? 0) / max) * 5))])
) as Record<WeightingKey, number>
}
function rawToWeights(raw: Record<WeightingKey, number>): Record<WeightingKey, number> {
const total = WEIGHTING_KEYS.reduce((s, k) => s + (raw[k] ?? 1), 0)
return Object.fromEntries(
WEIGHTING_KEYS.map(k => [k, (raw[k] ?? 1) / total])
) as Record<WeightingKey, number>
}
export function WeightingEditor({ weights, onChange, assetType }: Props) {
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
function handleSlider(key: WeightingKey, value: number) {
const updated = { ...raw, [key]: value }
setRaw(updated)
onChange(rawToWeights(updated))
}
function handleReset() {
onChange(weightingService.getDefaultWeights(assetType))
const defaults = weightingService.getDefaultWeights(assetType)
setRaw(toRaw(defaults))
onChange(defaults)
}
return (
@@ -28,10 +48,10 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Kriteriengewichtung anpassen
Wichtigkeit der Kriterien
</Typography>
<Typography variant="caption" color="text.secondary">
Passen Sie an, wie stark jedes Kriterium das Matching beeinflusst.
Schieber nach rechts = wichtiger. Gewichtung wird automatisch berechnet.
</Typography>
</Box>
<Button
@@ -45,24 +65,25 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
</Box>
<Card sx={{ p: 3 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{WEIGHTING_KEYS.map(key => {
const pct = Math.round((weights[key] ?? 0) * 100)
const importance = raw[key] ?? 3
return (
<Box key={key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{WEIGHTING_LABELS[key]}
</Typography>
<Typography variant="body2" color="text.secondary">
{pct}%
<Typography variant="caption" color="text.secondary">
{IMPORTANCE_LABELS[importance]}
</Typography>
</Box>
<Slider
value={pct}
min={0}
max={40}
value={importance}
min={1}
max={5}
step={1}
marks
onChange={(_, v) => handleSlider(key, v as number)}
size="small"
sx={{ color: '#1e3a5f' }}
@@ -71,27 +92,6 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
)
})}
</Box>
<Box
sx={{
mt: 3,
pt: 2,
borderTop: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="body2" color="text.secondary">
Gesamt
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 700, color: isBalanced ? '#1a7a4a' : '#c0392b' }}
>
{totalPct}%{!isBalanced && ' — Summe sollte ~100% ergeben'}
</Typography>
</Box>
</Card>
</Box>
)
+1
View File
@@ -7,4 +7,5 @@ export { NeedBuilderErrorState } from './NeedBuilderErrorState'
export { NeedBuilderProgress } from './NeedBuilderProgress'
export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput'
export { VoiceNeedInput } from './VoiceNeedInput'
export { WeightingEditor } from './WeightingEditor'
+11 -11
View File
@@ -65,35 +65,35 @@ interface WorkspaceConfig {
const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
[WorkspaceType.SUPPLY]: {
label: 'Supply',
abbreviation: 'SUP',
label: 'Verwaltung',
abbreviation: 'VW',
icon: Building2,
firstPath: '/supply/dashboard',
chipColor: '#1e3a5f',
navItems: [
{ path: '/supply/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Objekte', icon: Building2 },
{ path: '/supply/match-center', label: 'Match Center', icon: Target },
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
{ path: '/supply/match-center', label: 'Eingehende Bedarfe', icon: Target },
{ path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp },
{ path: '/supply/data-quality', label: 'Datenqualität', icon: CheckSquare },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
],
},
[WorkspaceType.DEMAND]: {
label: 'Demand',
abbreviation: 'DEM',
label: 'Suche',
abbreviation: 'SU',
icon: Search,
firstPath: '/demand/ai-search',
chipColor: '#1a7a4a',
navItems: [
{ path: '/demand/ai-search', label: 'AI Suche', icon: Search },
{ path: '/demand/ai-search', label: 'Flächensuche', icon: Search },
{ path: '/demand/results', label: 'Ergebnisse', icon: List },
{ path: '/demand/compare', label: 'Vergleich', icon: Columns2 },
{ path: '/demand/shortlists', label: 'Shortlists', icon: Bookmark },
],
},
[WorkspaceType.OPERATIONS]: {
label: 'Operations',
abbreviation: 'OPS',
label: 'Administration',
abbreviation: 'ADM',
icon: Shield,
firstPath: '/ops/review-queue',
chipColor: '#7c3aed',
+7 -3
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router'
import { useNavigate, useLocation } from 'react-router'
import { Box, Button, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore'
@@ -15,10 +15,14 @@ export function CompareTray() {
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
const { setCompareTrayVisible } = useLayoutStore()
const navigate = useNavigate()
const location = useLocation()
const isDemand = location.pathname.startsWith('/demand')
useEffect(() => {
setCompareTrayVisible(compareItems.length > 0)
}, [compareItems.length, setCompareTrayVisible])
setCompareTrayVisible(compareItems.length > 0 && isDemand)
}, [compareItems.length, setCompareTrayVisible, isDemand])
if (!isDemand) return null
const getTitle = (item: (typeof compareItems)[number]) => {
if (item.resultType === 'FUTURE_AVAILABILITY') {
@@ -0,0 +1,131 @@
import { Box, Button, Chip, Typography } from '@mui/material'
import { MatchStatusBadge } from './MatchStatusBadge'
import type { Match } from '../../domain/match'
import type { Property } from '../../domain/property'
import type { Need } from '../../domain/need'
const STRENGTH_META: Record<string, { label: string; bg: string; color: string }> = {
STRONG: { label: 'Stark', bg: '#f0fdf4', color: '#1a7a4a' },
MODERATE: { label: 'Mittel', bg: '#fefce8', color: '#d97706' },
WEAK: { label: 'Schwach', bg: '#fff1f2', color: '#c0392b' },
}
function scoreColor(score: number) {
if (score >= 80) return '#1a7a4a'
if (score >= 60) return '#d97706'
return '#c0392b'
}
interface Props {
match: Match
property: Property | undefined
need: Need | undefined
onSelect: () => void
onApprove: () => void
}
export function MatchListCard({ match, property, need, onSelect, onApprove }: Props) {
const strength = STRENGTH_META[match.matchStrength] ?? { label: match.matchStrength, bg: '#f1f5f9', color: '#64748b' }
const summary = match.explainabilitySummary ?? ''
return (
<Box
onClick={onSelect}
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
px: 3,
py: 2,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' },
}}
>
{/* Score bubble */}
<Box
sx={{
width: 52,
height: 52,
borderRadius: 2,
bgcolor: scoreColor(match.matchScore),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 700, lineHeight: 1 }}>
{match.matchScore}
</Typography>
</Box>
{/* Property + need */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
{property?.title ?? match.propertyId}
</Typography>
<MatchStatusBadge status={match.status} />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{[property?.location.city, property?.areaSqm ? `${property.areaSqm}` : null].filter(Boolean).join(' · ')}
</Typography>
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{need && (
<Chip
label={need.companyName}
size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }}
/>
)}
<Chip
label={strength.label}
size="small"
sx={{ bgcolor: strength.bg, color: strength.color, fontSize: 11, height: 20 }}
/>
</Box>
</Box>
{/* Summary excerpt */}
{summary && (
<Typography
variant="caption"
color="text.secondary"
sx={{
maxWidth: 220,
display: { xs: 'none', lg: 'block' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{summary.length > 90 ? summary.slice(0, 90) + '…' : summary}
</Typography>
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{match.status !== 'APPROVED' && (
<Button
size="small"
variant="contained"
onClick={onApprove}
sx={{ textTransform: 'none', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' } }}
>
Genehmigen
</Button>
)}
<Button
size="small"
variant="outlined"
onClick={onSelect}
sx={{ textTransform: 'none' }}
>
Details
</Button>
</Box>
</Box>
)
}
+1
View File
@@ -4,3 +4,4 @@ export { MatchCenterSkeleton } from './MatchCenterSkeleton'
export { PropertySelectionPanel } from './PropertySelectionPanel'
export { NeedSelectionPanel } from './NeedSelectionPanel'
export { MatchBriefingPanel } from './MatchBriefingPanel'
export { MatchListCard } from './MatchListCard'
@@ -0,0 +1,376 @@
import { Box, Chip, Divider, LinearProgress, Paper, Tooltip, Typography } from '@mui/material'
import {
Activity, Building2, HardHat, MapPin, Percent,
TrendingDown, TrendingUp, Train, Users, Zap,
} from 'lucide-react'
import { useProperties } from '../../hooks/useProperties'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import type { Property } from '../../domain/property'
// ── Helpers ───────────────────────────────────────────────────────────────────
function scoreColor(v: number) {
if (v >= 0.72) return '#1a7a4a'
if (v >= 0.48) return '#d97706'
return '#c0392b'
}
function scoreLabel(v: number) {
if (v >= 0.82) return 'Sehr gut'
if (v >= 0.65) return 'Gut'
if (v >= 0.45) return 'Mittel'
return 'Schwach'
}
// ── Sub-components ────────────────────────────────────────────────────────────
function SoftFactorBar({
label,
value,
icon,
tooltip,
}: {
label: string
value: number | undefined | null
icon: React.ReactNode
tooltip?: string
}) {
if (value === undefined || value === null) return null
const color = scoreColor(value)
const row = (
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ color: '#64748b', display: 'flex' }}>{icon}</Box>
<Typography variant="body2">{label}</Typography>
</Box>
<Chip
label={scoreLabel(value)}
size="small"
sx={{ bgcolor: color, color: 'white', fontSize: 10, height: 18, fontWeight: 600 }}
/>
</Box>
<LinearProgress
variant="determinate"
value={value * 100}
sx={{ height: 5, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
/>
</Box>
)
return tooltip ? <Tooltip title={tooltip} placement="left">{row}</Tooltip> : row
}
function KpiTile({
label,
value,
sub,
color,
}: {
label: string
value: string
sub?: string
color?: string
}) {
return (
<Box
sx={{
flex: 1,
p: 1.5,
bgcolor: '#f8fafc',
borderRadius: 1.5,
border: '1px solid #e2e8f0',
minWidth: 100,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
{label}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: color ?? '#0f1923', lineHeight: 1.2 }}>
{value}
</Typography>
{sub && (
<Typography variant="caption" color="text.secondary">
{sub}
</Typography>
)}
</Box>
)
}
const NEW_PROJECTS: Record<string, { title: string; area: string; completion: string; note: string }[]> = {
'Zürich': [
{ title: 'Ensemble Zürich-West', area: '5002000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' },
{ title: 'The Circle Phase II', area: '10005000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' },
],
'Basel': [
{ title: 'Basel SBB Tower', area: '3001500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' },
{ title: 'Erlenmatt Ost', area: '6002500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' },
],
'Zug': [
{ title: 'Zug Innovation Campus', area: '2001000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' },
],
'Bern': [
{ title: 'Bern West Business Park', area: '4003000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' },
],
'Winterthur': [
{ title: 'Sulzerareal Phase 4', area: '8004000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' },
],
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
property: Property | null
}
export function LocationIntelligencePanel({ property }: Props) {
const { data: allProperties = [] } = useProperties()
if (!property) return null
const city = property.location.city
const intel = getCityIntelligence(city)
const sf = property.softFactors
const hasSoftFactors = sf && (
sf.footfallScore !== undefined ||
sf.taxEnvironmentScore !== undefined ||
sf.commuterAccessScore !== undefined ||
sf.talentAccessScore !== undefined ||
sf.prestigeScore !== undefined ||
sf.prestige !== undefined
)
// Market comparables: same type, same city, different property
const comparables = allProperties
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === city)
.sort((a, b) => b.confidenceScore - a.confidenceScore)
.slice(0, 3)
const newProjects = NEW_PROJECTS[city] ?? []
const rentTrendPositive = intel && intel.rentTrend12m > 0
return (
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.25 }}>
Standort-Intelligence
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wirtschaftliche Faktoren und Marktkontext über klassische Flächenangaben hinaus
</Typography>
{/* ── City KPIs ── */}
{intel && (
<>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<KpiTile
label="Leerstandsquote"
value={`${intel.vacancyRatePct}%`}
sub={intel.vacancyRatePct < 3 ? 'Angespannter Markt' : intel.vacancyRatePct < 5 ? 'Ausgeglichen' : 'Käufermarkt'}
color={intel.vacancyRatePct < 3 ? '#c0392b' : intel.vacancyRatePct < 5 ? '#d97706' : '#1a7a4a'}
/>
<KpiTile
label="Mietpreis-Trend"
value={`${rentTrendPositive ? '+' : ''}${intel.rentTrend12m}%`}
sub="letzte 12 Monate"
color={rentTrendPositive ? '#c0392b' : '#1a7a4a'}
/>
<KpiTile
label="Kaufkraft-Index"
value={`${intel.purchasingPowerIndex}`}
sub="CH-Mittel = 100"
color={intel.purchasingPowerIndex >= 115 ? '#1a7a4a' : intel.purchasingPowerIndex >= 95 ? '#d97706' : '#c0392b'}
/>
<KpiTile
label="Ø Vermietungsdauer"
value={`${intel.avgDaysOnMarket}T`}
sub="Tage auf dem Markt"
color={intel.avgDaysOnMarket < 40 ? '#c0392b' : intel.avgDaysOnMarket < 65 ? '#d97706' : '#1a7a4a'}
/>
</Box>
{/* Rent trend interpretation */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.5, bgcolor: rentTrendPositive ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5, mb: 2 }}>
<Box sx={{ color: rentTrendPositive ? '#d97706' : '#1a7a4a', mt: 0.1 }}>
{rentTrendPositive ? <TrendingUp size={16} /> : <TrendingDown size={16} />}
</Box>
<Typography variant="body2" sx={{ color: rentTrendPositive ? '#92400e' : '#14532d' }}>
{rentTrendPositive
? `Mietpreise in ${city} sind in den letzten 12 Monaten um ${intel.rentTrend12m}% gestiegen. Frühzeitig abschliessen kann vorteilhaft sein.`
: `Mietpreise in ${city} sind leicht rückläufig. Verhandlungsspielraum nutzen.`}
</Typography>
</Box>
{/* Tax + demand */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Percent size={13} color="#64748b" />
<Typography variant="caption" color="text.secondary">Steuerindex Kanton</Typography>
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: intel.taxIndexCanton <= 80 ? '#1a7a4a' : intel.taxIndexCanton <= 105 ? '#d97706' : '#c0392b' }}>
{intel.taxIndexCanton} <Typography component="span" variant="caption" color="text.secondary">(CH = 100)</Typography>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'}
</Typography>
</Box>
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Activity size={13} color="#64748b" />
<Typography variant="caption" color="text.secondary">Nachfragestärke</Typography>
</Box>
<Chip
label={{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}
size="small"
sx={{
bgcolor: { LOW: '#f1f5f9', MEDIUM: '#fef3c7', HIGH: '#dcfce7', VERY_HIGH: '#f0fdf4' }[intel.demandStrength],
color: { LOW: '#64748b', MEDIUM: '#d97706', HIGH: '#16a34a', VERY_HIGH: '#1a7a4a' }[intel.demandStrength],
fontWeight: 600, fontSize: 11,
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Aktive Nachfrage in {city}
</Typography>
</Box>
</Box>
{/* Industry clusters */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
Dominante Branchen-Cluster
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{intel.dominantIndustryClusters.map(c => (
<Chip key={c} label={c} size="small" sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 22 }} />
))}
</Box>
</Box>
{/* Infrastructure */}
{intel.plannedInfrastructure.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<Train size={13} color="#64748b" />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
Geplante Infrastruktur-Projekte
</Typography>
</Box>
{intel.plannedInfrastructure.map((proj, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75, pl: 1.5 }}>
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, whiteSpace: 'nowrap' }}>
{proj.timeline}
</Typography>
<Box>
<Typography variant="caption" sx={{ fontWeight: 500 }}>{proj.project}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{proj.impact}</Typography>
</Box>
</Box>
))}
</Box>
)}
<Divider sx={{ my: 1.5 }} />
</>
)}
{/* ── Soft Factors ── */}
{hasSoftFactors && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 1 }}>
KI-berechnete Standortqualität
</Typography>
<SoftFactorBar
label="Passantenfrequenz"
value={sf.footfallScore ?? (sf.passerbyFrequency ? { LOW: 0.25, MEDIUM: 0.5, HIGH: 0.78, VERY_HIGH: 0.95 }[sf.passerbyFrequency] : undefined)}
icon={<Users size={14} />}
tooltip="Geschätzte Personenfrequenz im Umfeld — relevant für Retail & Sichtbarkeit"
/>
<SoftFactorBar
label="ÖV-Anbindung"
value={sf.commuterAccessScore}
icon={<Train size={14} />}
tooltip={sf.publicTransportMinutes ? `~${sf.publicTransportMinutes} Min. zum nächsten ÖV-Hub` : 'Öffentliche Erreichbarkeit des Standorts'}
/>
<SoftFactorBar
label="Talentpool-Zugang"
value={sf.talentAccessScore ?? (sf.talentAccess as number | undefined)}
icon={<Users size={14} />}
tooltip="Verfügbarkeit qualifizierter Fachkräfte in einem 30-Min.-Radius"
/>
<SoftFactorBar
label="Prestige / Lagequalität"
value={sf.prestigeScore ?? (sf.prestige as number | undefined)}
icon={<MapPin size={14} />}
tooltip="Adress-Prestige und wahrgenommene Standortqualität"
/>
<SoftFactorBar
label="Flexibilitätspotenzial"
value={sf.flexibilityScore}
icon={<Zap size={14} />}
tooltip="Möglichkeit zur Flächen-Anpassung (Ausbau, Teilung, Erweiterung)"
/>
{sf.esgScore !== undefined && (
<SoftFactorBar
label="ESG-Bewertung"
value={sf.esgScore}
icon={<Activity size={14} />}
tooltip="Umwelt-, Sozial- und Governance-Standard des Gebäudes"
/>
)}
</Box>
)}
{/* ── Market Comparables ── */}
{comparables.length > 0 && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
Vergleichbare Angebote in {city}
</Typography>
{comparables.map(p => (
<Box
key={p.id}
sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9' }}
>
<Building2 size={13} color="#64748b" />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.title}
</Typography>
<Typography variant="caption" color="text.secondary">
{p.areaSqm} m² · CHF {p.rentPricePerSqm}/m²
{p.rentPricePerSqm < property.rentPricePerSqm && ' · günstiger'}
{p.rentPricePerSqm > property.rentPricePerSqm && ' · teurer'}
</Typography>
</Box>
</Box>
))}
</>
)}
{/* ── New Construction ── */}
{newProjects.length > 0 && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#7c3aed', display: 'block', mb: 0.75 }}>
Neubauprojekte als Alternative
</Typography>
{newProjects.map((proj, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
<HardHat size={13} color="#7c3aed" />
<Box>
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block' }}>{proj.title}</Typography>
<Typography variant="caption" color="text.secondary">
{proj.area} · Fertigstellung {proj.completion}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{proj.note}</Typography>
</Box>
</Box>
))}
</>
)}
</Paper>
)
}
+1
View File
@@ -1,3 +1,4 @@
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
export { MatchDetailHeader } from './MatchDetailHeader'
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
@@ -0,0 +1,345 @@
import { Box, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
import { CheckCircle, TrendingDown, TrendingUp } from 'lucide-react'
import { useProperties } from '../../hooks/useProperties'
import { useNeeds } from '../../hooks/useNeeds'
import { getCityIntelligence, getMarketRent } from '../../lib/locationIntelligence'
import type { Property } from '../../domain/property'
// ── Selling argument generator ────────────────────────────────────────────────
interface Argument {
title: string
detail: string
strength: 'strong' | 'medium'
}
function generateSellingArguments(p: Property): Argument[] {
const args: Argument[] = []
const sf = p.softFactors
const intel = getCityIntelligence(p.location.city)
if (sf) {
const footfall = sf.footfallScore ?? (sf.passerbyFrequency ? { LOW: 0.25, MEDIUM: 0.5, HIGH: 0.78, VERY_HIGH: 0.95 }[sf.passerbyFrequency] ?? 0 : 0)
if (footfall >= 0.72)
args.push({ title: 'Hervorragende Frequenzlage', detail: 'Überdurchschnittliche Passantenfrequenz — sichert Sichtbarkeit und Kundenzugang.', strength: 'strong' })
const taxScore = sf.taxEnvironmentScore ?? (intel ? Math.max(0, 1 - intel.taxIndexCanton / 150) : undefined)
if (taxScore !== undefined && taxScore >= 0.65)
args.push({ title: 'Steuerattraktiver Standort', detail: `${p.location.city} bietet eine günstige Steuerlast${intel ? ` (Index ${intel.taxIndexCanton}, CH = 100)` : ''} — relevant für Unternehmensansiedlungen.`, strength: 'strong' })
const ov = sf.commuterAccessScore
if (ov !== undefined && ov >= 0.72)
args.push({ title: 'Sehr gute ÖV-Anbindung', detail: sf.publicTransportMinutes ? `Ca. ${sf.publicTransportMinutes} Min. zum nächsten Bahnhof.` : 'Ausgezeichnete öffentliche Erreichbarkeit.', strength: 'strong' })
const prestige = sf.prestigeScore ?? (typeof sf.prestige === 'number' ? sf.prestige : undefined)
if (prestige !== undefined && prestige >= 0.7)
args.push({ title: 'Repräsentativer Standort', detail: 'Hoher Prestige-Wert — ideal für Unternehmen mit Repräsentationsanspruch und Aussenauftritt.', strength: 'strong' })
const talent = sf.talentAccessScore ?? (typeof sf.talentAccess === 'number' ? sf.talentAccess : undefined)
if (talent !== undefined && talent >= 0.65)
args.push({ title: 'Grosser Talentpool', detail: 'Zugang zu gut ausgebildeten Fachkräften im Einzugsgebiet — entscheidend für wachsende Unternehmen.', strength: 'medium' })
if (sf.flexibilityScore !== undefined && sf.flexibilityScore >= 0.65)
args.push({ title: 'Flexible Flächengestaltung', detail: 'Grundriss und Ausbaustandard ermöglichen individuelle Anpassungen.', strength: 'medium' })
if (sf.esgScore !== undefined && sf.esgScore >= 0.7)
args.push({ title: 'Nachhaltigkeitszertifizierung', detail: 'Guter ESG-Score — relevant für Unternehmen mit Nachhaltigkeitszielen und ESG-Reporting.', strength: 'medium' })
}
if (p.hardFacts?.isBarrierFree)
args.push({ title: 'Barrierefrei', detail: 'Vollständig rollstuhlgängig — gesetzlich zunehmend gefordert.', strength: 'medium' })
if (p.hardFacts?.parking && p.hardFacts.parking > 0)
args.push({ title: `${p.hardFacts.parking} Parkplätze inkl.`, detail: 'Eigene Parkierungsmöglichkeiten — in Städten ein knappes Gut.', strength: 'medium' })
if (p.hardFacts?.hasServerRoom)
args.push({ title: 'Serverraum vorhanden', detail: 'Sofortig nutzbare IT-Infrastruktur — spart Einrichtungskosten.', strength: 'medium' })
if (intel?.demandStrength === 'VERY_HIGH' || intel?.demandStrength === 'HIGH')
args.push({ title: 'Stark nachgefragter Markt', detail: `${p.location.city} verzeichnet ${intel.demandStrength === 'VERY_HIGH' ? 'sehr hohe' : 'hohe'} Nachfrage — kurze Leerstandszeiten zu erwarten.`, strength: 'strong' })
return args
}
// ── Proactive weakness acknowledgement ───────────────────────────────────────
interface Weakness {
issue: string
mitigation: string
}
function generateWeaknesses(p: Property): Weakness[] {
const ws: Weakness[] = []
const sf = p.softFactors
const intel = getCityIntelligence(p.location.city)
if (intel && intel.vacancyRatePct >= 5)
ws.push({ issue: 'Hohe Leerstandsquote in der Region', mitigation: 'Mietfreie Zeit oder Ausbaukostenbeteiligung als Anreiz anbieten.' })
if (intel && intel.taxIndexCanton >= 115)
ws.push({ issue: 'Überdurchschnittliche Steuerlast', mitigation: 'Andere Standortvorteile (Prestige, ÖV) gezielt hervorheben.' })
if (sf?.commuterAccessScore !== undefined && sf.commuterAccessScore < 0.45)
ws.push({ issue: 'Eingeschränkte ÖV-Anbindung', mitigation: 'Parkplatz-Angebot und Veloinfrastruktur als Alternative betonen.' })
if (p.hardFacts?.parking === 0 || (p.hardFacts?.parking === undefined && !sf?.parkingSpots))
ws.push({ issue: 'Keine eigenen Parkplätze', mitigation: 'Öffentliche Parkhäuser in der Nähe aufzeigen. Ggf. Parkabonnement als Mietbonus anbieten.' })
if (p.dataQuality.score < 0.65)
ws.push({ issue: 'Unvollständige Objektdaten', mitigation: 'Fehlende Angaben vor dem Gespräch vervollständigen, um Vertrauen zu stärken.' })
return ws
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
property: Property
}
export function NegotiationInsightsPanel({ property }: Props) {
const { data: allProperties = [] } = useProperties()
const { data: needs = [] } = useNeeds()
const intel = getCityIntelligence(property.location.city)
const marketRent = getMarketRent(property.location.city, property.assetType)
const priceDiff = marketRent ? ((property.rentPricePerSqm - marketRent) / marketRent) * 100 : null
// Comparable properties for price positioning
const comparables = allProperties
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === property.location.city)
const avgComparableRent = comparables.length > 0
? comparables.reduce((s, p) => s + p.rentPricePerSqm, 0) / comparables.length
: null
// Active needs matching this property type/location
const matchingNeeds = needs.filter(n =>
n.assetType === property.assetType &&
(n.preferredLocations?.some(loc => loc.toLowerCase().includes(property.location.city.toLowerCase())) ?? false)
)
const sellingArgs = generateSellingArguments(property)
const weaknesses = generateWeaknesses(property)
const strongArgs = sellingArgs.filter(a => a.strength === 'strong')
const mediumArgs = sellingArgs.filter(a => a.strength === 'medium')
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* ── Price positioning ── */}
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Preispositionierung</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5 }}>
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Ihr Preis</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f1923' }}>
CHF {property.rentPricePerSqm}/m²
</Typography>
</Box>
{marketRent && (
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Marktmedian {property.location.city}</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}>
CHF {marketRent}/m²
</Typography>
</Box>
)}
{avgComparableRent && (
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Vergleichsangebote Ø</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}>
CHF {Math.round(avgComparableRent)}/m²
</Typography>
</Box>
)}
</Box>
{priceDiff !== null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: Math.abs(priceDiff) < 10 ? '#f0fdf4' : priceDiff > 0 ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5 }}>
{priceDiff > 0 ? <TrendingUp size={15} color="#d97706" /> : <TrendingDown size={15} color="#1a7a4a" />}
<Typography variant="body2" sx={{ color: priceDiff > 15 ? '#92400e' : priceDiff > 0 ? '#d97706' : '#1a7a4a', fontWeight: 500 }}>
{priceDiff > 15
? `Ihr Preis liegt ${Math.round(priceDiff)}% über dem Marktmedian — starke USPs nötig zur Rechtfertigung.`
: priceDiff > 5
? `Leicht über Marktmedian (+${Math.round(priceDiff)}%) — gut durch Qualität begründbar.`
: priceDiff > -5
? 'Im Marktdurchschnitt — gute Ausgangsposition.'
: `${Math.round(Math.abs(priceDiff))}% unter Marktmedian — Preiserhöhung oder schnelle Vermietung möglich.`}
</Typography>
</Box>
)}
{/* Price bar vs market */}
{marketRent && (
<Box sx={{ mt: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">Marktbereich {property.location.city}</Typography>
<Typography variant="caption" color="text.secondary">
CHF {Math.round(marketRent * 0.7)}{Math.round(marketRent * 1.4)}/m²
</Typography>
</Box>
<Box sx={{ position: 'relative', height: 8, bgcolor: '#e2e8f0', borderRadius: 4 }}>
<Box sx={{
position: 'absolute',
left: `${Math.min(90, Math.max(5, ((property.rentPricePerSqm - marketRent * 0.7) / (marketRent * 0.7)) * 100))}%`,
top: -2,
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: '#1e3a5f',
border: '2px solid white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</Box>
</Box>
)}
</Paper>
{/* ── Active demand ── */}
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Aktive Nachfrage</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Unternehmen, die aktuell in {property.location.city} suchen
</Typography>
{matchingNeeds.length > 0 ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{matchingNeeds.length}</Typography>
<Typography variant="body2" color="text.secondary">aktive Suchprofile für diesen Typ & Standort</Typography>
</Box>
{matchingNeeds.slice(0, 4).map(n => (
<Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 500 }}>{n.companyName}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{n.requiredArea?.min ?? 0}{n.requiredArea?.max ?? 0} m²
{n.budgetRange?.maxPerSqm ? ` · max. CHF ${n.budgetRange.maxPerSqm}/m²` : ''}
</Typography>
</Box>
<Chip
label={n.status === 'ACTIVE' ? 'Aktiv' : n.status === 'DRAFT' ? 'Entwurf' : n.status}
size="small"
sx={{
bgcolor: n.status === 'ACTIVE' ? '#f0fdf4' : '#f1f5f9',
color: n.status === 'ACTIVE' ? '#1a7a4a' : '#64748b',
fontSize: 10, height: 18,
}}
/>
</Box>
))}
{matchingNeeds.length > 4 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.75, display: 'block' }}>
+ {matchingNeeds.length - 4} weitere Suchprofile
</Typography>
)}
</>
) : (
<Typography variant="body2" color="text.secondary">
Keine aktiven Suchprofile für diesen Typ und Standort.
</Typography>
)}
{intel && (
<Box sx={{ mt: 1.5, p: 1.25, bgcolor: '#f8fafc', borderRadius: 1.5 }}>
<Typography variant="caption" color="text.secondary">
Ø Vermietungsdauer vergleichbarer Objekte in {property.location.city}:{' '}
<strong style={{ color: '#1e3a5f' }}>{intel.avgDaysOnMarket} Tage</strong>
</Typography>
</Box>
)}
</Paper>
{/* ── Selling arguments ── */}
{sellingArgs.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Verkaufsargumente für das Gespräch</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Stärken dieses Objekts maßgeschneidert auf typische Mieterwünsche
</Typography>
{strongArgs.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a', display: 'block', mb: 0.75 }}>
Starke Argumente
</Typography>
{strongArgs.map((arg, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}>
<CheckCircle size={15} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{arg.title}</Typography>
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
</Box>
</Box>
))}
</Box>
)}
{mediumArgs.length > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706', display: 'block', mb: 0.75 }}>
Weitere Vorteile
</Typography>
{mediumArgs.map((arg, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75 }}>
<CheckCircle size={14} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{arg.title}</Typography>
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
</Box>
</Box>
))}
</Box>
)}
</Paper>
)}
{/* ── Proactive weakness handling ── */}
{weaknesses.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Schwächen proaktiv adressieren</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Potenzielle Einwände kennen und entkräften bevor der Interessent fragt
</Typography>
{weaknesses.map((w, i) => (
<Box key={i} sx={{ mb: 1.25 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#c0392b', mb: 0.25 }}>
{w.issue}
</Typography>
<Box sx={{ pl: 1.5, borderLeft: '2px solid #e2e8f0' }}>
<Typography variant="caption" color="text.secondary"> {w.mitigation}</Typography>
</Box>
{i < weaknesses.length - 1 && <Divider sx={{ mt: 1.25 }} />}
</Box>
))}
</Paper>
)}
{/* ── Tenant fit ── */}
{intel && intel.dominantIndustryClusters.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Welche Mieter passen?</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Dominant präsente Branchen in {property.location.city} hohes Match-Potenzial
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{intel.dominantIndustryClusters.map(c => (
<Chip key={c} label={c} size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 24, fontWeight: 500 }}
/>
))}
</Box>
<LinearProgress
variant="determinate"
value={intel.demandStrength === 'VERY_HIGH' ? 95 : intel.demandStrength === 'HIGH' ? 75 : intel.demandStrength === 'MEDIUM' ? 50 : 25}
sx={{ mt: 1.5, height: 6, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Nachfragestärke: {' '}
<strong>{{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}</strong>
</Typography>
</Paper>
)}
</Box>
)
}
+1 -10
View File
@@ -17,10 +17,8 @@ export interface PropertyCardProps {
positiveFactors?: string[]
topTradeoff?: string
selected?: boolean
compareSelected?: boolean
onSelect?: () => void
onViewDetail?: () => void
onAddToCompare?: () => void
onSaveToShortlist?: () => void
onFindMatches?: () => void
}
@@ -33,21 +31,15 @@ export function PropertyCard({
positiveFactors,
topTradeoff,
selected,
compareSelected,
onSelect,
onViewDetail,
onAddToCompare,
onSaveToShortlist,
onFindMatches,
}: PropertyCardProps) {
const isStale = STALE_STATUSES.includes(p.dataQuality.freshness)
const isLowConfidence = p.confidenceScore < 0.65
const borderLeft = compareSelected
? '4px solid #1a7a4a'
: selected
? '4px solid #1e3a5f'
: '4px solid transparent'
const borderLeft = selected ? '4px solid #1e3a5f' : '4px solid transparent'
return (
<Card
@@ -150,7 +142,6 @@ export function PropertyCard({
{/* Action Zone */}
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }} onClick={e => e.stopPropagation()}>
<Button size="small" variant="contained" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onViewDetail}>Details</Button>
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onAddToCompare}>Vergleichen</Button>
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onSaveToShortlist}>Shortlist</Button>
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onFindMatches}>Matches finden</Button>
</Box>
+9 -7
View File
@@ -12,6 +12,7 @@ import {
Typography,
} from '@mui/material'
import { X, ExternalLink } from 'lucide-react'
import { NegotiationInsightsPanel } from './NegotiationInsightsPanel'
import type { Property } from '../../domain/property'
import type { Match } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal'
@@ -411,7 +412,7 @@ function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
// ── Main component ────────────────────────────────────────────────────────────
const TABS = ['Übersicht', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale'] as const
const TABS = ['Übersicht', 'Verhandlung & Markt', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale'] as const
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
const [tab, setTab] = useState(0)
@@ -486,12 +487,13 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
{/* Tab content */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
{tab === 0 && <OverviewPanel p={property} />}
{tab === 1 && <HardFactsPanel p={property} />}
{tab === 2 && <SoftFactorsPanel p={property} />}
{tab === 3 && <MatchabilityPanel matches={matches} />}
{tab === 4 && <DataQualityPanel p={property} />}
{tab === 5 && <SourcePanel p={property} />}
{tab === 6 && <SignalsPanel signals={signals} />}
{tab === 1 && <NegotiationInsightsPanel property={property} />}
{tab === 2 && <HardFactsPanel p={property} />}
{tab === 3 && <SoftFactorsPanel p={property} />}
{tab === 4 && <MatchabilityPanel matches={matches} />}
{tab === 5 && <DataQualityPanel p={property} />}
{tab === 6 && <SourcePanel p={property} />}
{tab === 7 && <SignalsPanel signals={signals} />}
</Box>
</Box>
)
+1 -6
View File
@@ -13,7 +13,7 @@ import {
Tooltip,
Typography,
} from '@mui/material'
import { Bookmark, Eye, Plus } from 'lucide-react'
import { Bookmark, Eye } from 'lucide-react'
import type { Property } from '../../domain/property'
import type { PropertyTableFilters } from './PropertyFilterBar'
import {
@@ -248,11 +248,6 @@ export function PropertyTable({
<Eye size={15} />
</IconButton>
</Tooltip>
<Tooltip title="Zum Vergleich hinzufügen">
<IconButton size="small">
<Plus size={15} />
</IconButton>
</Tooltip>
<Tooltip title="Zur Shortlist">
<IconButton size="small">
<Bookmark size={15} />
@@ -69,14 +69,6 @@ export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
>
Zur Prüfung
</Button>
<Button
size="small"
variant="outlined"
sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }}
onClick={() => navigate('/demand/compare')}
>
Vergleichen
</Button>
</Box>
</CardContent>
</Card>