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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 800–1.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 200–400 m² in Bern Innenstadt, hohe Passantenfrequenz, Erdgeschoss, max. CHF 150/m². Sofort verfügbar.',
|
||||
Logistik: 'Lagerhalle 2.000–3.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 800–1.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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 800–1000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung',
|
||||
'Retail-Fläche 200–400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort',
|
||||
'Lagerhalle 2000–3000 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 800–1.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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} m²` : 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>
|
||||
)
|
||||
}
|
||||
@@ -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: '500–2000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' },
|
||||
{ title: 'The Circle Phase II', area: '1000–5000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' },
|
||||
],
|
||||
'Basel': [
|
||||
{ title: 'Basel SBB Tower', area: '300–1500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' },
|
||||
{ title: 'Erlenmatt Ost', area: '600–2500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' },
|
||||
],
|
||||
'Zug': [
|
||||
{ title: 'Zug Innovation Campus', area: '200–1000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' },
|
||||
],
|
||||
'Bern': [
|
||||
{ title: 'Bern West Business Park', area: '400–3000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' },
|
||||
],
|
||||
'Winterthur': [
|
||||
{ title: 'Sulzerareal Phase 4', area: '800–4000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── 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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user