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
+14 -1
View File
@@ -4,6 +4,19 @@ import { LoadingPage, AppErrorBoundary } from './components/ui'
import { AppShell } from './components/layout'
import { ProtectedRoute } from './components/auth'
import { WorkspaceType } from './domain/enums'
import { useSessionStore } from './stores/sessionStore'
const WORKSPACE_HOME: Record<string, string> = {
[WorkspaceType.SUPPLY]: '/supply/dashboard',
[WorkspaceType.DEMAND]: '/demand/ai-search',
[WorkspaceType.OPERATIONS]: '/ops/review-queue',
}
function RoleRedirect() {
const { currentUser } = useSessionStore()
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
}
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
@@ -38,7 +51,7 @@ function App() {
{/* Protected: auth check only */}
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/supply/dashboard" replace />} />
<Route path="/" element={<RoleRedirect />} />
{/* Supply Workspace */}
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
+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>
+2 -2
View File
@@ -353,7 +353,7 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
]
const hardWeightSum = HARD_CRITERION_KEYS.reduce((s, k) => s + profile[k], 0)
const hardRaw = hardFactors.reduce((s, f) => s + f.contribution, 0)
const hardMatchScore = hardWeightSum > 0 ? Math.round(hardRaw / hardWeightSum) : 0
const hardMatchScore = hardWeightSum > 0 ? Math.min(100, Math.round(hardRaw / hardWeightSum)) : 0
// ── Soft factor scoring ────────────────────────────────────────────────────
const softFactors: ScoreFactor[] = SOFT_FACTOR_KEYS
@@ -361,7 +361,7 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
.map(k => scoreSoftFactor(k, profile[k], property))
const softWeightSum = SOFT_FACTOR_KEYS.reduce((s, k) => s + (profile[k] ?? 0), 0)
const softRaw = softFactors.reduce((s, f) => s + f.contribution, 0)
const softFactorScore = softWeightSum > 0 ? Math.round(softRaw / softWeightSum) : 50
const softFactorScore = softWeightSum > 0 ? Math.min(100, Math.round(softRaw / softWeightSum)) : 50
// ── Modifiers ──────────────────────────────────────────────────────────────
const dqMod = calcDataQualityModifier(property)
+139
View File
@@ -0,0 +1,139 @@
// Static location intelligence data per city — mock values based on Swiss market context
export interface CityIntelligence {
vacancyRatePct: number // Leerstandsquote %
rentTrend12m: number // Mietpreisveränderung % (letztes Jahr)
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
dominantIndustryClusters: string[]
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
medianRentOffice: number // CHF/m² für Bürofläche
medianRentLogistics: number
medianRentRetail: number
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
}
export const CITY_INTELLIGENCE: Record<string, CityIntelligence> = {
'Zürich': {
vacancyRatePct: 2.8,
rentTrend12m: +4.2,
purchasingPowerIndex: 128,
dominantIndustryClusters: ['Finanz & Banking', 'Tech & Startups', 'Medien & Kreativ', 'Pharma & Life Science'],
plannedInfrastructure: [
{ project: 'Tram Hardbrücke-Verlängerung', timeline: '2027', impact: 'Bessere ÖV-Anbindung Industriequartier' },
{ project: 'Rosengarten-Tunnel', timeline: '2030', impact: 'Entlastung Kreis 5/6, weniger Durchgangsverkehr' },
],
medianRentOffice: 42,
medianRentLogistics: 14,
medianRentRetail: 180,
avgDaysOnMarket: 38,
demandStrength: 'VERY_HIGH',
taxIndexCanton: 100,
},
'Basel': {
vacancyRatePct: 4.1,
rentTrend12m: +1.8,
purchasingPowerIndex: 112,
dominantIndustryClusters: ['Pharma & Chemie', 'Logistik & Handel', 'Medizintechnik', 'Finanzdienstleistungen'],
plannedInfrastructure: [
{ project: 'Basel SBB Südeingang Neubau', timeline: '2026', impact: 'Aufwertung Bahnhofumgebung' },
{ project: 'Regio-S-Bahn Ausbau', timeline: '2028', impact: 'Bessere Grenzpendler-Anbindung' },
],
medianRentOffice: 32,
medianRentLogistics: 11,
medianRentRetail: 120,
avgDaysOnMarket: 52,
demandStrength: 'HIGH',
taxIndexCanton: 98,
},
'Bern': {
vacancyRatePct: 3.5,
rentTrend12m: +2.1,
purchasingPowerIndex: 108,
dominantIndustryClusters: ['Bundesverwaltung & NPO', 'Gesundheit', 'Bildung & Forschung', 'Versicherungen'],
plannedInfrastructure: [
{ project: 'Bernmobil Netzausbau West', timeline: '2026', impact: 'Erschliessung Entwicklungsgebiet Ausserholligen' },
],
medianRentOffice: 28,
medianRentLogistics: 10,
medianRentRetail: 95,
avgDaysOnMarket: 61,
demandStrength: 'MEDIUM',
taxIndexCanton: 112,
},
'Zug': {
vacancyRatePct: 1.9,
rentTrend12m: +5.1,
purchasingPowerIndex: 148,
dominantIndustryClusters: ['Rohstoffhandel', 'Crypto & Blockchain', 'Holding & Finanzen', 'Tech-Unternehmen'],
plannedInfrastructure: [
{ project: 'Metrobahn Zug-Luzern', timeline: '2029', impact: 'Direktverbindung Luzern in 18 Min.' },
],
medianRentOffice: 38,
medianRentLogistics: 13,
medianRentRetail: 140,
avgDaysOnMarket: 24,
demandStrength: 'VERY_HIGH',
taxIndexCanton: 60,
},
'Winterthur': {
vacancyRatePct: 5.8,
rentTrend12m: +0.9,
purchasingPowerIndex: 98,
dominantIndustryClusters: ['Industrie & Maschinenbau', 'Logistik', 'Gesundheit & Soziales'],
plannedInfrastructure: [
{ project: 'Stadtraum HB Winterthur', timeline: '2027', impact: 'Aufwertung Bahnhofsumgebung, mehr Frequenz' },
],
medianRentOffice: 22,
medianRentLogistics: 9,
medianRentRetail: 75,
avgDaysOnMarket: 74,
demandStrength: 'MEDIUM',
taxIndexCanton: 119,
},
'Geneva': {
vacancyRatePct: 2.2,
rentTrend12m: +3.6,
purchasingPowerIndex: 135,
dominantIndustryClusters: ['Internationale Organisationen', 'Luxusgüter', 'Banking & Private Equity', 'Uhrenindustrie'],
plannedInfrastructure: [
{ project: 'CEVA Linie Verlängerung', timeline: '2026', impact: 'Bessere Verbindung Lancy-Pont-Rouge' },
],
medianRentOffice: 55,
medianRentLogistics: 18,
medianRentRetail: 220,
avgDaysOnMarket: 31,
demandStrength: 'HIGH',
taxIndexCanton: 125,
},
'St.Gallen': {
vacancyRatePct: 6.2,
rentTrend12m: -0.5,
purchasingPowerIndex: 95,
dominantIndustryClusters: ['Textil & Mode', 'KMU', 'Logistik', 'Gesundheit'],
plannedInfrastructure: [],
medianRentOffice: 19,
medianRentLogistics: 8,
medianRentRetail: 65,
avgDaysOnMarket: 88,
demandStrength: 'LOW',
taxIndexCanton: 107,
},
}
export function getCityIntelligence(city: string): CityIntelligence | null {
// Try exact match first, then partial
if (CITY_INTELLIGENCE[city]) return CITY_INTELLIGENCE[city]
const key = Object.keys(CITY_INTELLIGENCE).find(k => city.toLowerCase().includes(k.toLowerCase()))
return key ? CITY_INTELLIGENCE[key] : null
}
export function getMarketRent(city: string, assetType: string): number | null {
const intel = getCityIntelligence(city)
if (!intel) return null
if (assetType === 'OFFICE') return intel.medianRentOffice
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
if (assetType === 'RETAIL') return intel.medianRentRetail
return intel.medianRentOffice
}
+2 -1
View File
@@ -29,6 +29,7 @@ const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
[UserRole.PROPERTY_MANAGER]: [
Permission.SUPPLY_VIEW,
Permission.SUPPLY_EDIT,
Permission.DEMAND_VIEW,
Permission.FUTURE_SIGNAL_VIEW,
Permission.FUTURE_SIGNAL_REVIEW,
Permission.CONTACT_RELEASE_APPROVE,
@@ -63,11 +64,11 @@ const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
[WorkspaceType.DEMAND]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.PROPERTY_MANAGER,
UserRole.DEMAND_USER,
],
[WorkspaceType.OPERATIONS]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.REVIEWER,
],
}
+344
View File
@@ -2,6 +2,7 @@ import { SignalType, RiskLevel } from '../domain/enums'
import type { FutureSignal } from '../domain/futureSignal'
export const mockFutureSignals: FutureSignal[] = [
// --- signal-001: DataCloud Expansion Zürich-West ---
{
id: 'signal-001',
signalType: SignalType.EXPANSION,
@@ -28,6 +29,8 @@ export const mockFutureSignals: FutureSignal[] = [
createdAt: '2025-05-01T07:00:00Z',
updatedAt: '2025-05-10T07:00:00Z',
},
// --- signal-002: Helvetia Produktion possible move-out Reinach ---
{
id: 'signal-002',
signalType: SignalType.POSSIBLE_MOVE_OUT,
@@ -55,6 +58,8 @@ export const mockFutureSignals: FutureSignal[] = [
createdAt: '2025-05-03T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
// --- signal-003: Bern Wankdorf Neubau Büro/Gewerbe ---
{
id: 'signal-003',
signalType: SignalType.CONSTRUCTION_PROJECT,
@@ -81,4 +86,343 @@ export const mockFutureSignals: FutureSignal[] = [
createdAt: '2025-02-12T10:00:00Z',
updatedAt: '2025-05-05T09:00:00Z',
},
// --- signal-004: Pharma-Biotech Basel Expansion ---
{
id: 'signal-004',
signalType: SignalType.EXPANSION,
companyName: 'Novabio Pharma AG',
locationHint: 'Basel, Allschwil',
areaSqmEstimate: 700,
probability: 0.63,
confidenceScore: 0.60,
timeHorizonMonths: 14,
source: {
type: 'COMPANY_REPORT',
url: 'https://example.com/annual/novabio-2025',
publishedAt: '2025-03-28',
credibility: 'HIGH',
},
sensitivityLevel: 'INTERNAL',
disclaimer: 'Signal basiert auf Geschäftsbericht und Expansionsplänen. Kein bestätigtes Mietobjekt.',
riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Pharmastandort Basel: +22% Beschäftigte Life-Sciences 2024',
relevanceScore: 0.70,
isVerified: false,
expiresAt: '2026-07-01',
organizationId: 'org-wincasa',
createdAt: '2025-04-02T09:00:00Z',
updatedAt: '2025-05-08T10:00:00Z',
},
// --- signal-005: Finanz AG Zürich-Nord possible move-out → prop-023 ---
{
id: 'signal-005',
signalType: SignalType.POSSIBLE_MOVE_OUT,
companyName: 'Finanz & Treuhand AG',
propertyId: 'prop-023',
locationHint: 'Zürich-Nord, Seebach',
areaSqmEstimate: 850,
probability: 0.58,
confidenceScore: 0.54,
timeHorizonMonths: 8,
source: {
type: 'MARKET_DATA',
publishedAt: '2025-04-10',
credibility: 'MEDIUM',
},
sensitivityLevel: 'INTERNAL',
disclaimer: 'Marktdaten deuten auf mögliche Standortverlagerung hin. Kein bestätigter Auszug.',
riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Leerstand Zürich-Nord Q1 2025: +12% QoQ',
relevanceScore: 0.65,
isVerified: false,
expiresAt: '2026-01-01',
organizationId: 'org-wincasa',
createdAt: '2025-04-12T08:00:00Z',
updatedAt: '2025-05-10T09:00:00Z',
},
// --- signal-006: Luzern Inseli Neubau Gewerbe ---
{
id: 'signal-006',
signalType: SignalType.CONSTRUCTION_PROJECT,
locationHint: 'Luzern, Inseli-Quartier',
areaSqmEstimate: 2000,
probability: 0.78,
confidenceScore: 0.74,
timeHorizonMonths: 22,
source: {
type: 'CONSTRUCTION_PERMIT',
publishedAt: '2025-01-20',
credibility: 'HIGH',
},
sensitivityLevel: 'PUBLIC',
disclaimer: 'Baubewilligung eingereicht. Fertigstellung ca. Q1 2027. Nutzungskonzept noch nicht endgültig.',
riskLevel: RiskLevel.LOW,
marketIndicator: 'Neubauprojekte Luzern Innenstadt 20252027',
relevanceScore: 0.72,
isVerified: false,
expiresAt: '2027-02-01',
organizationId: 'org-wincasa',
createdAt: '2025-01-25T11:00:00Z',
updatedAt: '2025-05-06T14:00:00Z',
},
// --- signal-007: E-Commerce Zug Expansion → prop-026 ---
{
id: 'signal-007',
signalType: SignalType.EXPANSION,
companyName: 'SwissCart E-Commerce GmbH',
propertyId: 'prop-026',
locationHint: 'Zug, Industriestrasse',
areaSqmEstimate: 580,
probability: 0.66,
confidenceScore: 0.62,
timeHorizonMonths: 9,
source: {
type: 'JOB_POSTING',
url: 'https://example.com/jobs/swisscart-zug',
publishedAt: '2025-04-18',
credibility: 'MEDIUM',
},
sensitivityLevel: 'INTERNAL',
disclaimer: 'Signal basiert auf massivem Stellenaufbau. Expansion in Zug sehr wahrscheinlich, aber noch kein Mietobjekt identifiziert.',
riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'E-Commerce Zug: Stellenwachstum +55% YoY',
relevanceScore: 0.69,
isVerified: false,
expiresAt: '2026-02-01',
organizationId: 'org-wincasa',
createdAt: '2025-04-20T07:00:00Z',
updatedAt: '2025-05-09T08:00:00Z',
},
// --- signal-008: Retail Zürich Niederdorf possible move-out → prop-025 ---
{
id: 'signal-008',
signalType: SignalType.POSSIBLE_MOVE_OUT,
companyName: 'Textilhaus Zürich AG',
propertyId: 'prop-025',
locationHint: 'Zürich Niederdorf, Münstergasse',
areaSqmEstimate: 280,
probability: 0.55,
confidenceScore: 0.50,
timeHorizonMonths: 18,
source: {
type: 'MARKET_DATA',
publishedAt: '2025-03-30',
credibility: 'MEDIUM',
},
sensitivityLevel: 'CONFIDENTIAL',
disclaimer: 'Brancheninformationen deuten auf Verkleinerung hin. Kein bestätigter Auszug. Vertraulich.',
riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Stationärer Handel Zürich Altstadt: Leerstand +8% 2024',
relevanceScore: 0.60,
isVerified: false,
expiresAt: '2026-10-01',
organizationId: 'org-wincasa',
createdAt: '2025-04-01T10:00:00Z',
updatedAt: '2025-05-07T11:00:00Z',
},
// --- signal-009: Winterthur Zentrum Neubau Büro ---
{
id: 'signal-009',
signalType: SignalType.CONSTRUCTION_PROJECT,
locationHint: 'Winterthur, Zentrum Technikum',
areaSqmEstimate: 1200,
probability: 0.80,
confidenceScore: 0.76,
timeHorizonMonths: 20,
source: {
type: 'CONSTRUCTION_PERMIT',
publishedAt: '2025-02-28',
credibility: 'HIGH',
},
sensitivityLevel: 'PUBLIC',
disclaimer: 'Baubewilligung öffentlich. Fertigstellung ca. Q2 2027.',
riskLevel: RiskLevel.LOW,
marketIndicator: 'Winterthur Stadtentwicklung: Büroflächenneubau 20252027',
relevanceScore: 0.75,
isVerified: true,
verifiedBy: 'admin@ideal-sharing.ch',
verifiedAt: '2025-04-10T10:00:00Z',
expiresAt: '2027-05-01',
organizationId: 'org-wincasa',
createdAt: '2025-03-05T09:00:00Z',
updatedAt: '2025-04-10T10:00:00Z',
},
// --- signal-010: TechHub St.Gallen Expansion → prop-030 ---
{
id: 'signal-010',
signalType: SignalType.EXPANSION,
companyName: 'Ostschweiz Digital AG',
propertyId: 'prop-030',
locationHint: 'St. Gallen, Riethüsli',
areaSqmEstimate: 480,
probability: 0.60,
confidenceScore: 0.55,
timeHorizonMonths: 10,
source: {
type: 'JOB_POSTING',
url: 'https://example.com/jobs/ostschweiz-digital',
publishedAt: '2025-04-22',
credibility: 'MEDIUM',
},
sensitivityLevel: 'INTERNAL',
disclaimer: 'Expansion basiert auf Analyse von Stellenanzeigen und Unternehmensankündigungen. Kein bestätigtes Objekt.',
riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024',
relevanceScore: 0.62,
isVerified: false,
expiresAt: '2026-03-01',
organizationId: 'org-wincasa',
createdAt: '2025-04-25T08:00:00Z',
updatedAt: '2025-05-10T07:00:00Z',
},
// --- signal-011: Produktion Münchenbuchsee possible move-out → prop-027 ---
{
id: 'signal-011',
signalType: SignalType.POSSIBLE_MOVE_OUT,
companyName: 'Präzisionsmechanik Bern AG',
propertyId: 'prop-027',
locationHint: 'Münchenbuchsee BE, Industriezone',
areaSqmEstimate: 2200,
probability: 0.52,
confidenceScore: 0.48,
timeHorizonMonths: 14,
source: {
type: 'PRESS',
url: 'https://example.com/news/pmbern-verlagerung',
publishedAt: '2025-03-10',
credibility: 'HIGH',
},
sensitivityLevel: 'CONFIDENTIAL',
disclaimer: 'Pressemeldungen über Verlagerung der Produktion ins Ausland. Kein bestätigter Auszug. Vertraulich behandeln.',
riskLevel: RiskLevel.HIGH,
marketIndicator: 'Verlagerungsdruck Schweizer Maschinenbau 2025',
relevanceScore: 0.58,
isVerified: false,
expiresAt: '2026-08-01',
organizationId: 'org-wincasa',
createdAt: '2025-03-12T10:00:00Z',
updatedAt: '2025-05-09T09:00:00Z',
},
// --- signal-012: Basel Hafen Neubau Logistik → prop-024 ---
{
id: 'signal-012',
signalType: SignalType.CONSTRUCTION_PROJECT,
propertyId: 'prop-024',
locationHint: 'Basel, Hafen Klybeck',
areaSqmEstimate: 2600,
probability: 0.82,
confidenceScore: 0.78,
timeHorizonMonths: 12,
source: {
type: 'CONSTRUCTION_PERMIT',
publishedAt: '2025-01-15',
credibility: 'HIGH',
},
sensitivityLevel: 'PUBLIC',
disclaimer: 'Baubewilligung erteilt. Logistikneubau am Rheinhafen. Fertigstellung gemäss Baugesuch Q2 2026.',
riskLevel: RiskLevel.LOW,
marketIndicator: 'Hafenerweiterung Basel Klybeck: Logistikflächen 2026',
relevanceScore: 0.80,
isVerified: true,
verifiedBy: 'admin@ideal-sharing.ch',
verifiedAt: '2025-04-20T14:00:00Z',
expiresAt: '2026-08-01',
organizationId: 'org-wincasa',
createdAt: '2025-01-18T10:00:00Z',
updatedAt: '2025-04-20T14:00:00Z',
},
// --- signal-013: Genf La Praille Office Expansion → prop-028 ---
{
id: 'signal-013',
signalType: SignalType.EXPANSION,
companyName: 'Geneva Finance Partners SA',
propertyId: 'prop-028',
locationHint: 'Genf, La Praille',
areaSqmEstimate: 520,
probability: 0.58,
confidenceScore: 0.53,
timeHorizonMonths: 20,
source: {
type: 'COMPANY_REPORT',
url: 'https://example.com/annual/gfp-2025',
publishedAt: '2025-03-05',
credibility: 'MEDIUM',
},
sensitivityLevel: 'INTERNAL',
disclaimer: 'Expansionspläne aus Jahresbericht. Standort La Praille wahrscheinlich, aber noch nicht definitiv.',
riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Büroflächennachfrage Genf: +15% 2025',
relevanceScore: 0.60,
isVerified: false,
expiresAt: '2027-01-01',
organizationId: 'org-wincasa',
createdAt: '2025-03-08T11:00:00Z',
updatedAt: '2025-05-07T10:00:00Z',
},
// --- signal-014: Frenkendorf Lager possible move-out → prop-029 ---
{
id: 'signal-014',
signalType: SignalType.POSSIBLE_MOVE_OUT,
companyName: 'Schweizer Grosshandel AG',
propertyId: 'prop-029',
locationHint: 'Frenkendorf BL, Lager Nord',
areaSqmEstimate: 3500,
probability: 0.50,
confidenceScore: 0.46,
timeHorizonMonths: 15,
source: {
type: 'MARKET_DATA',
publishedAt: '2025-04-05',
credibility: 'MEDIUM',
},
sensitivityLevel: 'CONFIDENTIAL',
disclaimer: 'Marktdaten deuten auf mögliche Konsolidierung hin. Kein bestätigter Auszug. Vertraulich.',
riskLevel: RiskLevel.HIGH,
marketIndicator: 'Grosshandel Nordwestschweiz: Konsolidierungstrend 2025',
relevanceScore: 0.55,
isVerified: false,
expiresAt: '2026-09-01',
organizationId: 'org-wincasa',
createdAt: '2025-04-07T08:00:00Z',
updatedAt: '2025-05-08T09:00:00Z',
},
// --- signal-015: Bern Tech Campus Expansion ---
{
id: 'signal-015',
signalType: SignalType.EXPANSION,
companyName: 'BernTech Innovation AG',
locationHint: 'Bern, Breitenrain',
areaSqmEstimate: 1800,
probability: 0.68,
confidenceScore: 0.64,
timeHorizonMonths: 16,
source: {
type: 'JOB_POSTING',
url: 'https://example.com/jobs/berntech',
publishedAt: '2025-04-25',
credibility: 'MEDIUM',
},
sensitivityLevel: 'INTERNAL',
disclaimer: 'Wachstumssignal aus Stellenanzeigen und Social-Media-Analyse. Kein bestätigtes Objekt.',
riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Bern Tech-Ökosystem: Risikokapital +40% 2024',
relevanceScore: 0.66,
isVerified: false,
expiresAt: '2026-10-01',
organizationId: 'org-wincasa',
createdAt: '2025-04-28T09:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
]
+1450 -34
View File
File diff suppressed because it is too large Load Diff
+311
View File
@@ -2,6 +2,7 @@ import { AssetType } from '../domain/enums'
import type { Need } from '../domain/need'
export const mockNeeds: Need[] = [
// --- need-001: Innovatech AG — OFFICE Zürich ---
{
id: 'need-001',
companyName: 'Innovatech AG',
@@ -40,6 +41,8 @@ export const mockNeeds: Need[] = [
createdAt: '2025-04-15T10:00:00Z',
updatedAt: '2025-05-01T09:00:00Z',
},
// --- need-002: Schweizer Logistik GmbH — LOGISTICS Basel ---
{
id: 'need-002',
companyName: 'Schweizer Logistik GmbH',
@@ -73,4 +76,312 @@ export const mockNeeds: Need[] = [
createdAt: '2025-03-20T14:00:00Z',
updatedAt: '2025-04-10T11:00:00Z',
},
// --- need-003: Pharma Holding AG — OFFICE Basel ---
{
id: 'need-003',
companyName: 'Pharma Holding AG',
contactName: 'Ursula Schmid',
assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 800 },
preferredLocations: ['Basel', 'Allschwil', 'Binningen', 'Dreispitz'],
excludedLocations: [],
budgetRange: { maxPerSqm: 40, maxMonthlyTotal: 32000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-10-01',
latestMoveIn: '2026-04-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Repräsentative Lage', 'Ausbaugrad gehoben', 'Konferenzräume vorhanden'],
softFactors: {
minPrestige: 75,
minAccessibility: 75,
requireParking: true,
maxPublicTransportMinutes: 10,
},
weightingProfile: {
area: 0.20,
location: 0.25,
budget: 0.20,
timing: 0.10,
prestige: 0.12,
accessibility: 0.08,
expansionPotential: 0.03,
flexibility: 0.02,
},
confidenceInCriteria: 0.91,
extractedFromText: 'Suche repräsentative Büroflächen im Raum Basel/Allschwil, 500800m², max. CHF 38/m², Bezug Q4 2025.',
organizationId: 'org-wincasa',
createdAt: '2025-04-20T09:00:00Z',
updatedAt: '2025-05-05T14:00:00Z',
},
// --- need-004: Retailer Zürich AG — RETAIL Zürich ---
{
id: 'need-004',
companyName: 'Retailer Zürich AG',
contactName: 'Marco Colombo',
assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 500 },
preferredLocations: ['Zürich Innenstadt', 'Zürich Bahnhofstrasse', 'Zürich Niederdorf', 'Zürich City'],
excludedLocations: [],
budgetRange: { maxPerSqm: 100, maxMonthlyTotal: 50000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-09-01',
latestMoveIn: '2026-03-01',
contractDurationMonths: 60,
flexibleTiming: false,
},
mustCriteriaText: ['Laufkundschaft', 'Schaufensterfront', 'Erdgeschoss', 'Hohe Passantenfrequenz'],
softFactors: {
minPrestige: 85,
requireParking: false,
maxPublicTransportMinutes: 5,
},
weightingProfile: {
area: 0.15,
location: 0.35,
budget: 0.15,
timing: 0.10,
prestige: 0.15,
accessibility: 0.05,
expansionPotential: 0.02,
flexibility: 0.03,
},
confidenceInCriteria: 0.96,
extractedFromText: 'Exklusive Retailfläche in Zürich Innenstadt gesucht, 250450m², Schaufenster, max. CHF 95/m².',
organizationId: 'org-wincasa',
createdAt: '2025-03-10T11:00:00Z',
updatedAt: '2025-04-22T08:00:00Z',
},
// --- need-005: TechStart GmbH — OFFICE Zug/Zürich ---
{
id: 'need-005',
companyName: 'TechStart GmbH',
contactName: 'Florian Keller',
assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 700 },
preferredLocations: ['Zug', 'Zürich', 'Baar', 'Steinhausen'],
excludedLocations: [],
budgetRange: { maxPerSqm: 48, maxMonthlyTotal: 33000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-10-01',
latestMoveIn: '2026-06-01',
contractDurationMonths: 36,
flexibleTiming: true,
},
mustCriteriaText: ['Moderner Ausbau', 'Schnelles Internet', 'Fahrradabstellplätze'],
softFactors: {
minPrestige: 65,
minAccessibility: 75,
requireParking: false,
maxPublicTransportMinutes: 10,
},
weightingProfile: {
area: 0.20,
location: 0.25,
budget: 0.20,
timing: 0.15,
prestige: 0.05,
accessibility: 0.10,
expansionPotential: 0.03,
flexibility: 0.02,
},
confidenceInCriteria: 0.85,
extractedFromText: 'Junges Tech-Unternehmen sucht Büro in Zug oder Zürich, 350600m², moderner Ausbau, max. CHF 45/m².',
organizationId: 'org-wincasa',
createdAt: '2025-04-28T13:00:00Z',
updatedAt: '2025-05-08T10:00:00Z',
},
// --- need-006: Lager & Spedition AG — LOGISTICS Winterthur ---
{
id: 'need-006',
companyName: 'Lager & Spedition AG',
contactName: 'Beat Zimmermann',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 1200, max: 3000 },
preferredLocations: ['Winterthur', 'Wülflingen', 'Oberwinterthur', 'Töss'],
budgetRange: { maxPerSqm: 16, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-11-01',
latestMoveIn: '2026-05-01',
contractDurationMonths: 60,
flexibleTiming: false,
},
mustCriteriaText: ['Autobahn A1 < 10 Min', 'Ebenerdig', 'Lkw-Zufahrt', 'Sprinkleranlage'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.30,
location: 0.25,
budget: 0.20,
timing: 0.10,
prestige: 0.01,
accessibility: 0.10,
expansionPotential: 0.02,
flexibility: 0.02,
},
confidenceInCriteria: 0.92,
organizationId: 'org-wincasa',
createdAt: '2025-05-02T08:30:00Z',
updatedAt: '2025-05-09T16:00:00Z',
},
// --- need-007: Creative Studios AG — MIXED Zürich/Bern ---
{
id: 'need-007',
companyName: 'Creative Studios AG',
contactName: 'Nora Hauser',
assetType: AssetType.MIXED,
requiredArea: { min: 800, max: 1500 },
preferredLocations: ['Zürich', 'Zürich-West', 'Zürich Altstetten', 'Bern'],
excludedLocations: [],
budgetRange: { maxPerSqm: 55, maxMonthlyTotal: 75000, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-01-01',
latestMoveIn: '2026-07-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Gemischte Nutzung möglich', 'Hohe Decken', 'Kreative Atmosphäre'],
softFactors: {
minPrestige: 55,
minAccessibility: 70,
requireParking: false,
maxPublicTransportMinutes: 12,
},
weightingProfile: {
area: 0.20,
location: 0.20,
budget: 0.15,
timing: 0.15,
prestige: 0.10,
accessibility: 0.10,
expansionPotential: 0.05,
flexibility: 0.05,
},
confidenceInCriteria: 0.82,
extractedFromText: 'Kreativagentur sucht Gewerbe-/Bürofläche in Zürich oder Bern, 9001400m², gemischte Nutzung, Budget max. CHF 50/m².',
organizationId: 'org-wincasa',
createdAt: '2025-04-05T10:00:00Z',
updatedAt: '2025-05-03T11:00:00Z',
},
// --- need-008: Berner Produzenten GmbH — PRODUCTION Bern ---
{
id: 'need-008',
companyName: 'Berner Produzenten GmbH',
contactName: 'Hans Lüthi',
assetType: AssetType.PRODUCTION,
requiredArea: { min: 2000, max: 4000 },
preferredLocations: ['Bern', 'Brünnen', 'Münchenbuchsee', 'Bern West'],
budgetRange: { maxPerSqm: 14, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-12-01',
latestMoveIn: '2026-09-01',
contractDurationMonths: 120,
flexibleTiming: false,
},
mustCriteriaText: ['Kranbahn möglich', 'Hallenhöhe min 8m', 'Drehstrom 400V', 'Lkw-Andienung'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.30,
location: 0.20,
budget: 0.20,
timing: 0.10,
prestige: 0.01,
accessibility: 0.10,
expansionPotential: 0.07,
flexibility: 0.02,
},
confidenceInCriteria: 0.95,
organizationId: 'org-wincasa',
createdAt: '2025-03-15T09:00:00Z',
updatedAt: '2025-04-20T12:00:00Z',
},
// --- need-009: Geneva Commerce SA — RETAIL Genf ---
{
id: 'need-009',
companyName: 'Geneva Commerce SA',
contactName: 'Pierre Dupont',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 400 },
preferredLocations: ['Genf', 'Genf Rive', 'Genf Centre'],
excludedLocations: [],
budgetRange: { maxPerSqm: 120, maxMonthlyTotal: 48000, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-01-01',
latestMoveIn: '2026-06-01',
contractDurationMonths: 60,
flexibleTiming: false,
},
mustCriteriaText: ['Centre-ville Genève', 'Vitrine', 'Rez-de-chaussée', 'Passage piétonnier'],
softFactors: {
minPrestige: 88,
requireParking: false,
maxPublicTransportMinutes: 5,
},
weightingProfile: {
area: 0.15,
location: 0.35,
budget: 0.15,
timing: 0.10,
prestige: 0.15,
accessibility: 0.05,
expansionPotential: 0.02,
flexibility: 0.03,
},
confidenceInCriteria: 0.93,
extractedFromText: 'Recherche surface commerciale en centre-ville de Genève, 200350m², vitrine obligatoire, budget max CHF 115/m².',
organizationId: 'org-wincasa',
createdAt: '2025-02-28T15:00:00Z',
updatedAt: '2025-04-18T09:00:00Z',
},
// --- need-010: St.Galler Büros AG — OFFICE St.Gallen ---
{
id: 'need-010',
companyName: 'St.Galler Büros AG',
contactName: 'Brigitte Fässler',
assetType: AssetType.OFFICE,
requiredArea: { min: 400, max: 800 },
preferredLocations: ['St. Gallen', 'St. Gallen Centrum', 'Riethüsli', 'Ostschweiz'],
excludedLocations: [],
budgetRange: { maxPerSqm: 35, maxMonthlyTotal: 28000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-11-01',
latestMoveIn: '2026-04-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Stadtzentrumsnähe', 'ÖV < 8 Min', 'Helligkeit und Ausbauqualität'],
softFactors: {
minPrestige: 60,
minAccessibility: 70,
requireParking: true,
maxPublicTransportMinutes: 8,
},
weightingProfile: {
area: 0.22,
location: 0.28,
budget: 0.20,
timing: 0.12,
prestige: 0.08,
accessibility: 0.06,
expansionPotential: 0.02,
flexibility: 0.02,
},
confidenceInCriteria: 0.87,
extractedFromText: 'Büroflächen in St. Gallen oder Umgebung gesucht, 450750m², max. CHF 32/m², Bezug Anfang 2026.',
organizationId: 'org-wincasa',
createdAt: '2025-04-10T08:00:00Z',
updatedAt: '2025-05-06T13:00:00Z',
},
]
+786 -3
View File
@@ -2,7 +2,11 @@ import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } f
import type { Property } from '../domain/property'
export const mockProperties: Property[] = [
// --- VERIFIED_PORTFOLIO ---
// ─────────────────────────────────────────────────────────────────────────────
// VERIFIED_PORTFOLIO (10)
// ─────────────────────────────────────────────────────────────────────────────
{
id: 'prop-001',
title: 'Bürofläche Zollstrasse 12',
@@ -41,6 +45,7 @@ export const mockProperties: Property[] = [
createdAt: '2025-01-10T08:00:00Z',
updatedAt: '2025-04-28T10:30:00Z',
},
{
id: 'prop-002',
title: 'Lagerfläche Hardstrasse 44',
@@ -79,7 +84,319 @@ export const mockProperties: Property[] = [
updatedAt: '2025-05-05T11:00:00Z',
},
// --- EXTERNAL_MARKET ---
{
id: 'prop-007',
title: 'Bürofläche Thurgauerstrasse 40',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4115, lng: 8.5502 } },
address: { street: 'Thurgauerstrasse', houseNumber: '40', postalCode: '8050', city: 'Zürich', country: 'CH' },
areaSqm: 720,
rentPricePerSqm: 36,
totalRentMonthly: 25920,
availabilityDate: '2025-10-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.98,
dataQuality: {
score: 0.94,
missingCriticalFields: [],
missingOptionalFields: [],
lastVerifiedAt: '2025-05-01',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 72,
accessibility: 88,
visibilityScore: 60,
talentAccess: 80,
parkingSpots: 8,
publicTransportMinutes: 5,
},
floorLevel: 2,
expansionPotentialSqm: 200,
contractDurationMonths: 48,
ancillaryCosts: 5.0,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2025-02-01T09:00:00Z',
updatedAt: '2025-05-01T08:00:00Z',
},
{
id: 'prop-008',
title: 'Bürofläche Dreispitz Areal 9',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Basel', district: 'Dreispitz', canton: 'BS', country: 'CH', coordinates: { lat: 47.5398, lng: 7.5812 } },
address: { street: 'Hochbergerstrasse', houseNumber: '9', postalCode: '4057', city: 'Basel', country: 'CH' },
areaSqm: 900,
rentPricePerSqm: 32,
totalRentMonthly: 28800,
availabilityDate: '2025-09-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.97,
dataQuality: {
score: 0.93,
missingCriticalFields: [],
missingOptionalFields: ['expansionPotentialSqm'],
lastVerifiedAt: '2025-04-30',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 70,
accessibility: 82,
visibilityScore: 58,
talentAccess: 72,
parkingSpots: 14,
publicTransportMinutes: 8,
},
floorLevel: 4,
contractDurationMonths: 48,
ancillaryCosts: 4.5,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2025-01-20T10:00:00Z',
updatedAt: '2025-04-30T09:00:00Z',
},
{
id: 'prop-009',
title: 'Logistikzentrum Tössfeldstrasse 18',
assetType: AssetType.LOGISTICS,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Winterthur', district: 'Töss', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4952, lng: 8.7082 } },
address: { street: 'Tössfeldstrasse', houseNumber: '18', postalCode: '8406', city: 'Winterthur', country: 'CH' },
areaSqm: 1800,
rentPricePerSqm: 13,
totalRentMonthly: 23400,
availabilityDate: '2025-07-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.98,
dataQuality: {
score: 0.95,
missingCriticalFields: [],
missingOptionalFields: [],
lastVerifiedAt: '2025-05-02',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 38,
accessibility: 85,
parkingSpots: 25,
publicTransportMinutes: 14,
},
floorLevel: 0,
expansionPotentialSqm: 600,
contractDurationMonths: 60,
ancillaryCosts: 2.8,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2024-12-10T08:00:00Z',
updatedAt: '2025-05-02T10:00:00Z',
},
{
id: 'prop-010',
title: 'Retailfläche Löwenplatz 3',
assetType: AssetType.RETAIL,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3758, lng: 8.5352 } },
address: { street: 'Löwenplatz', houseNumber: '3', postalCode: '8001', city: 'Zürich', country: 'CH' },
areaSqm: 285,
rentPricePerSqm: 88,
totalRentMonthly: 25080,
availabilityDate: '2025-08-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.99,
dataQuality: {
score: 0.96,
missingCriticalFields: [],
missingOptionalFields: [],
lastVerifiedAt: '2025-05-06',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 95,
visibilityScore: 98,
passerbyFrequency: 'VERY_HIGH',
accessibility: 96,
publicTransportMinutes: 2,
},
floorLevel: 0,
contractDurationMonths: 60,
ancillaryCosts: 8.0,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2025-01-05T10:00:00Z',
updatedAt: '2025-05-06T11:00:00Z',
},
{
id: 'prop-011',
title: 'Produktionshalle Brünnen West 22',
assetType: AssetType.PRODUCTION,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Bern', district: 'Brünnen', canton: 'BE', country: 'CH', coordinates: { lat: 46.9562, lng: 7.3818 } },
address: { street: 'Brünnenstrasse', houseNumber: '22', postalCode: '3018', city: 'Bern', country: 'CH' },
areaSqm: 2800,
rentPricePerSqm: 12,
totalRentMonthly: 33600,
availabilityDate: '2025-07-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.98,
dataQuality: {
score: 0.95,
missingCriticalFields: [],
missingOptionalFields: [],
lastVerifiedAt: '2025-05-03',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 35,
accessibility: 80,
parkingSpots: 40,
publicTransportMinutes: 18,
},
floorLevel: 0,
expansionPotentialSqm: 1200,
contractDurationMonths: 120,
ancillaryCosts: 2.5,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2024-10-15T09:00:00Z',
updatedAt: '2025-05-03T10:00:00Z',
},
{
id: 'prop-012',
title: 'Bürofläche Stadtturm Zug',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zug', district: 'Zentrum', canton: 'ZG', country: 'CH', coordinates: { lat: 47.1712, lng: 8.5150 } },
address: { street: 'Industriestrasse', houseNumber: '2', postalCode: '6300', city: 'Zug', country: 'CH' },
areaSqm: 550,
rentPricePerSqm: 42,
totalRentMonthly: 23100,
availabilityDate: '2025-10-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.97,
dataQuality: {
score: 0.93,
missingCriticalFields: [],
missingOptionalFields: [],
lastVerifiedAt: '2025-04-29',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 82,
accessibility: 88,
visibilityScore: 70,
talentAccess: 78,
parkingSpots: 6,
publicTransportMinutes: 6,
},
floorLevel: 5,
contractDurationMonths: 36,
ancillaryCosts: 6.0,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2025-02-10T11:00:00Z',
updatedAt: '2025-04-29T08:00:00Z',
},
{
id: 'prop-013',
title: 'Gewerbe-/Bürofläche Altstetten Park',
assetType: AssetType.MIXED,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3908, lng: 8.4888 } },
address: { street: 'Badenerstrasse', houseNumber: '810', postalCode: '8048', city: 'Zürich', country: 'CH' },
areaSqm: 1300,
rentPricePerSqm: 45,
totalRentMonthly: 58500,
availabilityDate: '2025-11-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.96,
dataQuality: {
score: 0.92,
missingCriticalFields: [],
missingOptionalFields: ['expansionPotentialSqm'],
lastVerifiedAt: '2025-04-25',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 62,
accessibility: 84,
visibilityScore: 55,
talentAccess: 72,
parkingSpots: 18,
publicTransportMinutes: 7,
},
floorLevel: 1,
contractDurationMonths: 48,
ancillaryCosts: 5.0,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2025-01-15T09:00:00Z',
updatedAt: '2025-04-25T10:00:00Z',
},
{
id: 'prop-014',
title: 'Logistikhalle Pratteln Nord',
assetType: AssetType.LOGISTICS,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.5228, lng: 7.6958 } },
address: { street: 'Industriestrasse', houseNumber: '55', postalCode: '4133', city: 'Pratteln', country: 'CH' },
areaSqm: 3100,
rentPricePerSqm: 15,
totalRentMonthly: 46500,
availabilityDate: '2025-07-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
sourceType: 'ERP_IMPORT',
confidenceScore: 0.98,
dataQuality: {
score: 0.94,
missingCriticalFields: [],
missingOptionalFields: [],
lastVerifiedAt: '2025-05-04',
freshness: DataFreshness.FRESH,
warnings: [],
},
softFactors: {
prestige: 42,
accessibility: 90,
parkingSpots: 45,
publicTransportMinutes: 16,
},
floorLevel: 0,
expansionPotentialSqm: 1500,
contractDurationMonths: 60,
ancillaryCosts: 2.8,
riskLevel: RiskLevel.LOW,
organizationId: 'org-wincasa',
createdAt: '2024-12-01T08:00:00Z',
updatedAt: '2025-05-04T09:00:00Z',
},
// ─────────────────────────────────────────────────────────────────────────────
// EXTERNAL_MARKET (10)
// ─────────────────────────────────────────────────────────────────────────────
{
id: 'prop-003',
title: 'Retail-Fläche Bahnhofstrasse 88',
@@ -113,6 +430,7 @@ export const mockProperties: Property[] = [
createdAt: '2025-02-15T14:00:00Z',
updatedAt: '2025-04-10T09:00:00Z',
},
{
id: 'prop-004',
title: 'Gemischte Gewerbeeinheit Europaallee',
@@ -140,7 +458,271 @@ export const mockProperties: Property[] = [
updatedAt: '2025-03-20T15:00:00Z',
},
// --- FUTURE_AVAILABILITY ---
{
id: 'prop-015',
title: 'Bürofläche Kasernenplatz Luzern',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Luzern', district: 'Innenstadt', canton: 'LU', country: 'CH', coordinates: { lat: 47.0502, lng: 8.3093 } },
address: { street: 'Kasernenplatz', houseNumber: '3', postalCode: '6003', city: 'Luzern', country: 'CH' },
areaSqm: 650,
rentPricePerSqm: 38,
totalRentMonthly: 24700,
availabilityDate: '2025-10-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'HOMEGATE_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-015',
confidenceScore: 0.70,
dataQuality: {
score: 0.60,
missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'expansionPotentialSqm'],
lastVerifiedAt: '2025-04-05',
freshness: DataFreshness.STALE,
warnings: ['Verfügbarkeit aus Drittquelle nicht bestätigt'],
},
softFactors: {
prestige: 74,
accessibility: 86,
publicTransportMinutes: 5,
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-03-12T11:00:00Z',
updatedAt: '2025-04-05T10:00:00Z',
},
{
id: 'prop-016',
title: 'Logistikhalle Muttenz Rheinfelderstrasse',
assetType: AssetType.LOGISTICS,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Muttenz', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.5202, lng: 7.6422 } },
address: { street: 'Rheinfelderstrasse', houseNumber: '80', postalCode: '4132', city: 'Muttenz', country: 'CH' },
areaSqm: 2200,
rentPricePerSqm: 16,
totalRentMonthly: 35200,
availabilityDate: '2025-09-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'IMMOSCOUT_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-016',
confidenceScore: 0.69,
dataQuality: {
score: 0.58,
missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts'],
lastVerifiedAt: '2025-03-28',
freshness: DataFreshness.STALE,
warnings: ['Hallenhöhe nicht angegeben', 'Daten nicht verifiziert'],
},
softFactors: {
prestige: 42,
accessibility: 88,
parkingSpots: 35,
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-02-20T09:00:00Z',
updatedAt: '2025-03-28T12:00:00Z',
},
{
id: 'prop-017',
title: 'Ladenfläche Löwenstrasse 28',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } },
address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' },
areaSqm: 350,
rentPricePerSqm: 95,
totalRentMonthly: 33250,
availabilityDate: '2025-09-15',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'MATCHOFFICE_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-017',
confidenceScore: 0.71,
dataQuality: {
score: 0.61,
missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['floorLevel', 'ancillaryCosts'],
lastVerifiedAt: '2025-04-18',
freshness: DataFreshness.STALE,
warnings: ['Mietpreis nicht final bestätigt'],
},
softFactors: {
prestige: 90,
visibilityScore: 94,
passerbyFrequency: 'VERY_HIGH',
publicTransportMinutes: 3,
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-03-05T13:00:00Z',
updatedAt: '2025-04-18T11:00:00Z',
},
{
id: 'prop-018',
title: 'Bürofläche Breitenrain 14',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } },
address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' },
areaSqm: 780,
rentPricePerSqm: 31,
totalRentMonthly: 24180,
availabilityDate: '2025-10-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'NEWHOME_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-018',
confidenceScore: 0.68,
dataQuality: {
score: 0.57,
missingCriticalFields: ['contractDurationMonths', 'ancillaryCosts'],
missingOptionalFields: ['floorLevel'],
lastVerifiedAt: '2025-04-02',
freshness: DataFreshness.STALE,
warnings: ['Daten aus Drittquelle', 'Renovierungsstand unklar'],
},
softFactors: {
prestige: 62,
accessibility: 78,
publicTransportMinutes: 8,
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-02-28T10:00:00Z',
updatedAt: '2025-04-02T09:00:00Z',
},
{
id: 'prop-019',
title: 'Produktionsfläche Voltastrasse Basel',
assetType: AssetType.PRODUCTION,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5720, lng: 7.5882 } },
address: { street: 'Voltastrasse', houseNumber: '62', postalCode: '4056', city: 'Basel', country: 'CH' },
areaSqm: 1900,
rentPricePerSqm: 13,
totalRentMonthly: 24700,
availabilityDate: '2025-11-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'IMMOSCOUT_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-019',
confidenceScore: 0.68,
dataQuality: {
score: 0.56,
missingCriticalFields: ['contractDurationMonths', 'ancillaryCosts'],
missingOptionalFields: ['softFactors'],
lastVerifiedAt: '2025-03-25',
freshness: DataFreshness.STALE,
warnings: ['Hallenhöhe nicht verifiziert', 'Kranbahn Status unklar'],
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-03-10T08:00:00Z',
updatedAt: '2025-03-25T14:00:00Z',
},
{
id: 'prop-020',
title: 'Bürofläche Industriestrasse Zug',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Zug', district: 'Industrie', canton: 'ZG', country: 'CH', coordinates: { lat: 47.1688, lng: 8.5228 } },
address: { street: 'Industriestrasse', houseNumber: '45', postalCode: '6300', city: 'Zug', country: 'CH' },
areaSqm: 820,
rentPricePerSqm: 44,
totalRentMonthly: 36080,
availabilityDate: '2025-11-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'HOMEGATE_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-020',
confidenceScore: 0.70,
dataQuality: {
score: 0.60,
missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'expansionPotentialSqm'],
lastVerifiedAt: '2025-04-12',
freshness: DataFreshness.STALE,
warnings: ['Ausbaustandard nicht bestätigt'],
},
softFactors: {
prestige: 68,
accessibility: 82,
publicTransportMinutes: 9,
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-03-18T09:00:00Z',
updatedAt: '2025-04-12T11:00:00Z',
},
{
id: 'prop-021',
title: 'Surface commerciale Rue du Rhône',
assetType: AssetType.RETAIL,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'Genf', district: 'Centre', canton: 'GE', country: 'CH', coordinates: { lat: 46.2044, lng: 6.1432 } },
address: { street: 'Rue du Rhône', houseNumber: '48', postalCode: '1204', city: 'Genf', country: 'CH' },
areaSqm: 250,
rentPricePerSqm: 112,
totalRentMonthly: 28000,
availabilityDate: '2026-01-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'MATCHOFFICE_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-021',
confidenceScore: 0.70,
dataQuality: {
score: 0.59,
missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
lastVerifiedAt: '2025-04-08',
freshness: DataFreshness.STALE,
warnings: ['Prix non confirmé', 'Disponibilité à vérifier'],
},
softFactors: {
prestige: 94,
visibilityScore: 96,
passerbyFrequency: 'VERY_HIGH',
publicTransportMinutes: 3,
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-02-10T10:00:00Z',
updatedAt: '2025-04-08T09:00:00Z',
},
{
id: 'prop-022',
title: 'Bürofläche St.Gallen Centrum 7',
assetType: AssetType.OFFICE,
resultType: ResultType.EXTERNAL_MARKET,
location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } },
address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
areaSqm: 700,
rentPricePerSqm: 28,
totalRentMonthly: 19600,
availabilityDate: '2025-11-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'NEWHOME_SCRAPE',
sourceUrl: 'https://example.com/listing/prop-022',
confidenceScore: 0.69,
dataQuality: {
score: 0.58,
missingCriticalFields: ['contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
lastVerifiedAt: '2025-04-14',
freshness: DataFreshness.STALE,
warnings: ['Daten aus Drittquelle', 'Ausbauqualität nicht bestätigt'],
},
softFactors: {
prestige: 66,
accessibility: 80,
publicTransportMinutes: 6,
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-03-08T08:00:00Z',
updatedAt: '2025-04-14T10:00:00Z',
},
// ─────────────────────────────────────────────────────────────────────────────
// FUTURE_AVAILABILITY (10)
// ─────────────────────────────────────────────────────────────────────────────
{
id: 'prop-005',
title: 'Bürofläche Technoparkstrasse (Signal: Expansion)',
@@ -165,6 +747,7 @@ export const mockProperties: Property[] = [
createdAt: '2025-05-01T07:00:00Z',
updatedAt: '2025-05-10T07:00:00Z',
},
{
id: 'prop-006',
title: 'Produktionsfläche Reinach (Signal: möglicher Auszug)',
@@ -189,4 +772,204 @@ export const mockProperties: Property[] = [
createdAt: '2025-05-03T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
{
id: 'prop-023',
title: 'Bürofläche Zürich-Nord Seebach (Signal: Auszug)',
assetType: AssetType.OFFICE,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Zürich', district: 'Seebach', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4298, lng: 8.5362 } },
address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' },
areaSqm: 850,
rentPricePerSqm: 38,
availabilityDate: '2026-02-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.54,
dataQuality: {
score: 0.36,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel', 'softFactors'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Mietpreis geschätzt'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-04-14T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
{
id: 'prop-024',
title: 'Logistikneubau Basel Hafen Klybeck (Signal: Neubau)',
assetType: AssetType.LOGISTICS,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Basel', district: 'Klybeck', canton: 'BS', country: 'CH', coordinates: { lat: 47.5762, lng: 7.5918 } },
address: { street: 'Klybeckstrasse', houseNumber: '180', postalCode: '4057', city: 'Basel', country: 'CH' },
areaSqm: 2600,
rentPricePerSqm: 14,
availabilityDate: '2026-07-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.52,
dataQuality: {
score: 0.35,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'softFactors'],
freshness: DataFreshness.FRESH,
warnings: ['Baubewilligung erteilt, Mieter noch nicht bekannt', 'Konditionen geschätzt'],
},
riskLevel: RiskLevel.MEDIUM,
createdAt: '2025-01-20T09:00:00Z',
updatedAt: '2025-05-10T09:00:00Z',
},
{
id: 'prop-025',
title: 'Retailfläche Zürich Niederdorf (Signal: Auszug)',
assetType: AssetType.RETAIL,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Zürich', district: 'Niederdorf', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3748, lng: 8.5418 } },
address: { street: 'Münstergasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' },
areaSqm: 280,
rentPricePerSqm: 92,
availabilityDate: '2026-09-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.50,
dataQuality: {
score: 0.33,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Preis geschätzt'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-04-02T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
{
id: 'prop-026',
title: 'Bürofläche Zug Industriestrasse (Signal: Expansion)',
assetType: AssetType.OFFICE,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Zug', district: 'Industrie', canton: 'ZG', country: 'CH', coordinates: { lat: 47.1672, lng: 8.5198 } },
address: { street: 'Industriestrasse', houseNumber: '60', postalCode: '6300', city: 'Zug', country: 'CH' },
areaSqm: 580,
rentPricePerSqm: 43,
availabilityDate: '2026-04-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.52,
dataQuality: {
score: 0.34,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel', 'softFactors'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Lage approximiert'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-04-22T07:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
{
id: 'prop-027',
title: 'Produktionsfläche Münchenbuchsee BE (Signal: Auszug)',
assetType: AssetType.PRODUCTION,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Münchenbuchsee', district: 'Industriezone', canton: 'BE', country: 'CH', coordinates: { lat: 47.0038, lng: 7.4542 } },
address: { street: 'Bernstrasse', houseNumber: '42', postalCode: '3053', city: 'Münchenbuchsee', country: 'CH' },
areaSqm: 2200,
rentPricePerSqm: 12,
availabilityDate: '2026-08-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.48,
dataQuality: {
score: 0.32,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'softFactors'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Daten nicht verifiziert'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-03-14T08:00:00Z',
updatedAt: '2025-05-09T09:00:00Z',
},
{
id: 'prop-028',
title: 'Bürofläche Genf La Praille (Signal: Expansion)',
assetType: AssetType.OFFICE,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Genf', district: 'La Praille', canton: 'GE', country: 'CH', coordinates: { lat: 46.1912, lng: 6.1285 } },
address: { street: 'Route de la Praille', houseNumber: '30', postalCode: '1227', city: 'Genf', country: 'CH' },
areaSqm: 520,
rentPricePerSqm: 36,
availabilityDate: '2027-01-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.53,
dataQuality: {
score: 0.35,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal Standort approximiert', 'Mietzins geschätzt'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-03-10T09:00:00Z',
updatedAt: '2025-05-09T10:00:00Z',
},
{
id: 'prop-029',
title: 'Logistiklager Frenkendorf BL (Signal: Auszug)',
assetType: AssetType.LOGISTICS,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'Frenkendorf', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.5098, lng: 7.7182 } },
address: { street: 'Frenkenstrasse', houseNumber: '28', postalCode: '4402', city: 'Frenkendorf', country: 'CH' },
areaSqm: 3500,
rentPricePerSqm: 13,
availabilityDate: '2026-10-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.46,
dataQuality: {
score: 0.31,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'softFactors'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Konditionen unbekannt'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-04-08T08:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
{
id: 'prop-030',
title: 'Bürofläche St.Gallen Riethüsli (Signal: Expansion)',
assetType: AssetType.OFFICE,
resultType: ResultType.FUTURE_AVAILABILITY,
location: { city: 'St. Gallen', district: 'Riethüsli', canton: 'SG', country: 'CH', coordinates: { lat: 47.4182, lng: 9.3888 } },
address: { street: 'Riethüslistrasse', houseNumber: '40', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
areaSqm: 480,
rentPricePerSqm: 27,
availabilityDate: '2026-05-01',
availabilityStatus: AvailabilityStatus.FUTURE_SIGNAL,
sourceType: 'AI_SIGNAL',
confidenceScore: 0.55,
dataQuality: {
score: 0.36,
missingCriticalFields: ['totalRentMonthly', 'contractDurationMonths'],
missingOptionalFields: ['ancillaryCosts', 'floorLevel', 'softFactors'],
freshness: DataFreshness.FRESH,
warnings: ['Probabilistisches Signal kein bestätigtes Objekt', 'Lage und Preis approximiert'],
},
riskLevel: RiskLevel.HIGH,
createdAt: '2025-04-26T07:00:00Z',
updatedAt: '2025-05-10T08:00:00Z',
},
]
+3 -3
View File
@@ -18,10 +18,10 @@ import { UserRole } from '../../domain/enums'
const DEMO_ROLES: { role: UserRole; label: string; description: string }[] = [
{ role: UserRole.ORGANIZATION_ADMIN, label: 'Org Admin', description: 'Vollzugriff Supply + Demand + Ops' },
{ role: UserRole.PROPERTY_MANAGER, label: 'Property Manager', description: 'Supply Workspace' },
{ role: UserRole.DEMAND_USER, label: 'Demand User', description: 'Demand Workspace' },
{ role: UserRole.PROPERTY_MANAGER, label: 'Verwaltung', description: 'Portfolio verwalten + Markt durchsuchen' },
{ role: UserRole.DEMAND_USER, label: 'Bürosuche', description: 'Nur Marktsuche — kein Portfolio' },
{ role: UserRole.REVIEWER, label: 'Reviewer', description: 'Operations Workspace' },
{ role: UserRole.OWNER_VIEWER, label: 'Owner Viewer', description: 'Supply (eingeschränkt)' },
{ role: UserRole.OWNER_VIEWER, label: 'Eigentümer', description: 'Supply (eingeschränkt)' },
{ role: UserRole.SUPER_ADMIN, label: 'Super Admin', description: 'Plattform-Administrator' },
]
+236 -106
View File
@@ -1,13 +1,19 @@
import { useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight, ArrowLeft, Save } from 'lucide-react'
import { useRef, useState } from 'react'
import {
Alert,
Box,
Button,
CircularProgress,
Divider,
Typography,
} from '@mui/material'
import { ArrowRight, Bookmark, Save, Search } from 'lucide-react'
import { useNavigate } from 'react-router'
import { PageHeader } from '../../components/layout'
import { useQueryClient } from '@tanstack/react-query'
import {
NeedBuilderProgress,
NeedInput,
CriteriaReviewPanel,
FollowUpPanel,
VoiceNeedInput,
WeightingEditor,
NeedCardPreview,
NeedBuilderErrorState,
@@ -20,16 +26,34 @@ import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../do
import { AssetType } from '../../domain/enums'
import type { CreateNeedInput } from '../../domain/need'
// ── Map ParsedNeedCriteria → CreateNeedInput ───────────────────────────────────
// ── Helpers ───────────────────────────────────────────────────────────────────
const ASSET_LABELS_TEXT: Record<string, string> = {
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
}
function generateSummary(c: ParsedNeedCriteria): string {
const parts: string[] = []
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
if (c.areaRange && (c.areaRange.min > 0 || c.areaRange.max > 0))
parts.push(`${c.areaRange.min}${c.areaRange.max}`)
if (c.preferredLocations?.length) parts.push(`in ${c.preferredLocations.join(', ')}`)
if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`)
if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`)
if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`)
return parts.join(', ')
}
function buildNeedInput(
criteria: ParsedNeedCriteria,
weights: Record<WeightingKey, number>,
needTitle: string,
overallConfidence: number,
status: 'DRAFT' | 'ACTIVE',
): CreateNeedInput {
return {
companyName: needTitle || criteria.companyName || 'Neuer Bedarf',
companyName: needTitle || criteria.companyName || 'Neue Suche',
assetType: criteria.assetType ?? AssetType.UNKNOWN,
requiredArea: criteria.areaRange ?? { min: 0, max: 0 },
preferredLocations: criteria.preferredLocations ?? [],
@@ -42,77 +66,157 @@ function buildNeedInput(
},
weightingProfile: weights,
confidenceInCriteria: overallConfidence,
status: overallConfidence < 0.6 ? 'DRAFT' : 'ACTIVE',
status,
mustCriteriaText: criteria.mustHaveCriteria ?? [],
notes: criteria.notes,
extractedFromText: undefined,
}
}
// ── Action intent ─────────────────────────────────────────────────────────────
type ActionIntent = 'search' | 'save-profile'
// ── Page ──────────────────────────────────────────────────────────────────────
export default function AISearch() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE)
const [intent, setIntent] = useState<ActionIntent>('search')
const [inputText, setInputText] = useState('')
const [isAutoGen, setIsAutoGen] = useState(false)
const [criteria, setCriteria] = useState<ParsedNeedCriteria>({})
const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null)
const [editedCriteria, setEditedCriteria] = useState<ParsedNeedCriteria | null>(null)
const [answers, setAnswers] = useState<Record<string, string>>({})
const [weights, setWeights] = useState<Record<WeightingKey, number>>(weightingService.getDefaultWeights())
const [weightingKey, setWeightingKey] = useState(0)
const [needTitle, setNeedTitle] = useState('')
const [error, setError] = useState<string | null>(null)
async function handleAnalyze() {
const isManualTextRef = useRef(false)
function handleCriteriaChange(next: ParsedNeedCriteria) {
setCriteria(next)
if (!isManualTextRef.current) {
const summary = generateSummary(next)
setInputText(summary)
setIsAutoGen(!!summary)
}
}
function handleTextChange(text: string) {
isManualTextRef.current = text !== ''
setIsAutoGen(false)
setInputText(text)
}
async function handleAiAutofill() {
setStep(NeedBuilderStep.PARSING)
setError(null)
try {
const resp = await aiService.parseNeed(inputText)
const result = resp.data
setParseResult(result)
setEditedCriteria({ ...result.extractedCriteria })
setCriteria({ ...result.extractedCriteria })
setWeights(result.suggestedWeights as Record<WeightingKey, number>)
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
setWeightingKey(k => k + 1)
isManualTextRef.current = false
setInputText('')
setIsAutoGen(false)
setStep(NeedBuilderStep.IDLE)
} catch {
setError('Die KI-Analyse ist fehlgeschlagen. Bitte versuchen Sie es erneut.')
setError('Die KI-Analyse ist fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
}
}
async function handleReparse() {
if (!editedCriteria) return
setStep(NeedBuilderStep.PARSING)
try {
const resp = await aiService.generateFollowUpQuestions(editedCriteria)
if (parseResult) {
setParseResult({ ...parseResult, followUpQuestionCandidates: resp.data })
// Resolve criteria (parse text if needed), then either search or show save preview
async function handleAction(chosenIntent: ActionIntent) {
setIntent(chosenIntent)
setError(null)
let resolved: ParsedNeedCriteria = criteria
let resolvedResult: ParseNeedResult | null = parseResult
if (!hasStructuredData && inputText.trim()) {
setStep(NeedBuilderStep.PARSING)
try {
const resp = await aiService.parseNeed(inputText)
resolved = resp.data.extractedCriteria
resolvedResult = resp.data
setCriteria(resolved)
setWeights(resp.data.suggestedWeights as Record<WeightingKey, number>)
setWeightingKey(k => k + 1)
isManualTextRef.current = false
setInputText('')
setIsAutoGen(false)
setParseResult(resp.data)
} catch {
setError('Die KI-Analyse ist fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
return
}
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
} catch {
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
}
if (chosenIntent === 'search') {
// Save as DRAFT and navigate immediately
setStep(NeedBuilderStep.SAVING)
try {
const conf = resolvedResult
? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) /
Math.max(Object.values(resolvedResult.confidenceByField).length, 1)
: 0.5
const input = buildNeedInput(resolved, weights, needTitle, conf, 'DRAFT')
const created = await needService.create(input)
await queryClient.invalidateQueries({ queryKey: ['needs'] })
await queryClient.invalidateQueries({ queryKey: ['matches'] })
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
} catch {
setError('Suche fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
}
return
}
// save-profile: show preview step
const confidenceByField: Record<string, number> = resolvedResult?.confidenceByField ?? {}
if (!resolvedResult) {
if (resolved.assetType) confidenceByField.assetType = 1.0
if (resolved.areaRange?.min) confidenceByField.areaRange = 1.0
if (resolved.preferredLocations?.length) confidenceByField.preferredLocations = 1.0
if (resolved.budgetRange?.maxPerSqm) confidenceByField.budgetRange = 1.0
if (resolved.timing?.earliestMoveIn) confidenceByField.timing = 1.0
}
setEditedCriteria({ ...resolved })
setParseResult(resolvedResult ?? {
extractedCriteria: resolved,
confidenceByField,
missingFields: [],
assumptions: [],
suggestedWeights: weights,
followUpQuestionCandidates: [],
rawSummary: 'Manuell eingegeben',
promptVersion: 'manual',
schemaVersion: '1.0',
})
setStep(NeedBuilderStep.READY_TO_SAVE)
}
function handleAnswer(id: string, ans: string) {
setAnswers(prev => ({ ...prev, [id]: ans }))
}
async function handleSave() {
async function handleSaveProfile() {
if (!editedCriteria || !parseResult) return
setStep(NeedBuilderStep.SAVING)
const fieldEntries = Object.entries(parseResult.confidenceByField)
const overallConfidence = fieldEntries.length > 0
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
: 0
const entries = Object.entries(parseResult.confidenceByField)
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
try {
const input = buildNeedInput(editedCriteria, weights, needTitle, overallConfidence)
await needService.create(input)
setStep(NeedBuilderStep.SAVED)
navigate('/demand/results', { state: { fromNeedBuilder: true } })
const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE')
const created = await needService.create(input)
await queryClient.invalidateQueries({ queryKey: ['needs'] })
await queryClient.invalidateQueries({ queryKey: ['matches'] })
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
} catch {
setError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.')
setError('Speichern fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
}
}
@@ -124,7 +228,13 @@ export default function AISearch() {
setEditedCriteria(null)
}
const isReview = step === NeedBuilderStep.PARSED_REQUIRES_REVIEW || step === NeedBuilderStep.CLARIFICATION_REQUIRED
const hasStructuredData = !!(
criteria.assetType ||
(criteria.areaRange?.min ?? 0) > 0 ||
(criteria.preferredLocations?.length ?? 0) > 0
)
const canProceed = hasStructuredData || inputText.trim().length > 0
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
const overallConfidence = parseResult
@@ -136,82 +246,103 @@ export default function AISearch() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<PageHeader
title="AI Bedarfsanalyse"
subtitle="Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache"
/>
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Flächensuche</Typography>
<Typography variant="body2" color="text.secondary">
Sprechen, schreiben oder Felder ausfüllen dann sofort suchen oder als Suchprofil speichern
</Typography>
</Box>
<NeedBuilderProgress step={step} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* Step: Input */}
{step === NeedBuilderStep.IDLE && (
<NeedInput value={inputText} onChange={setInputText} onSubmit={handleAnalyze} />
)}
{/* ── IDLE: full form ── */}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
{/* Step: Parsing */}
{step === NeedBuilderStep.PARSING && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 16, gap: 3 }}>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="h6" color="text.secondary">KI analysiert Ihren Bedarf</Typography>
<Typography variant="caption" color="text.secondary">Kriterien werden extrahiert und bewertet</Typography>
</Box>
)}
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
isAutoGen={isAutoGen}
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
{/* Step: Criteria Review + Follow-up */}
{isReview && parseResult && editedCriteria && (
<Box>
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 2 }}>
<CriteriaReviewPanel
result={parseResult}
criteria={editedCriteria}
onCriteriaChange={setEditedCriteria}
/>
<FollowUpPanel
questions={parseResult.followUpQuestionCandidates}
answers={answers}
onAnswer={handleAnswer}
onContinue={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
onReparse={handleReparse}
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
key={weightingKey}
weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
/>
</Box>
<Button
variant="outlined"
startIcon={<ArrowLeft size={16} />}
onClick={() => setStep(NeedBuilderStep.IDLE)}
>
Neu eingeben
</Button>
</Box>
)}
{/* Step: Weighting */}
{step === NeedBuilderStep.WEIGHTING_REVIEW && (
<Box>
<WeightingEditor
weights={weights}
onChange={setWeights}
assetType={editedCriteria?.assetType}
/>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button variant="outlined" startIcon={<ArrowLeft size={16} />} onClick={() => setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)}>
Zurück
</Button>
{/* Action bar */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
variant="contained"
endIcon={<ArrowRight size={16} />}
onClick={() => setStep(NeedBuilderStep.READY_TO_SAVE)}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
size="large"
disabled={!canProceed || isProcessing}
onClick={() => handleAction('search')}
endIcon={
isProcessing && intent === 'search'
? <CircularProgress size={18} color="inherit" />
: <Search size={18} />
}
sx={{
flex: 1,
py: 1.5,
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
textTransform: 'none',
}}
>
Vorschau & Speichern
{isProcessing && intent === 'search' ? 'Sucht…' : 'Jetzt suchen'}
</Button>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Button
variant="outlined"
size="large"
disabled={!canProceed || isProcessing}
onClick={() => handleAction('save-profile')}
startIcon={<Bookmark size={16} />}
endIcon={
isProcessing && intent === 'save-profile'
? <CircularProgress size={18} color="inherit" />
: <ArrowRight size={18} />
}
sx={{
py: 1.5,
fontSize: 15,
fontWeight: 500,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{isProcessing && intent === 'save-profile' ? 'Analysiert…' : 'Als Suchprofil speichern'}
</Button>
</Box>
<Alert severity="info" sx={{ mt: -1 }}>
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird auch in Zukunft.
</Alert>
</Box>
)}
{/* Step: Preview + Save */}
{/* ── Preview + Save as Profile ── */}
{isSaveStep && parseResult && editedCriteria && (
<Box>
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
<Alert severity="success" sx={{ mb: 3 }}>
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
</Alert>
<NeedCardPreview
criteria={editedCriteria}
weights={weights}
@@ -223,34 +354,33 @@ export default function AISearch() {
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
variant="outlined"
startIcon={<ArrowLeft size={16} />}
onClick={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
onClick={() => setStep(NeedBuilderStep.IDLE)}
disabled={step === NeedBuilderStep.SAVING}
>
Zurück
Zurück
</Button>
<Button
variant="contained"
onClick={handleSave}
onClick={handleSaveProfile}
disabled={step === NeedBuilderStep.SAVING}
endIcon={
step === NeedBuilderStep.SAVING
? <CircularProgress size={16} color="inherit" />
: <Save size={16} />
}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
{step === NeedBuilderStep.SAVING
? 'Wird gespeichert…'
: overallConfidence < 0.6
? 'Als Entwurf speichern'
: 'Bedarf speichern & Suche starten'}
: 'Suchprofil speichern & Matching starten'}
</Button>
</Box>
</Box>
)}
{/* Step: Error */}
{/* ── Error ── */}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
+2
View File
@@ -10,6 +10,7 @@ import { useShortlistStore } from '../../stores/shortlistStore'
import { AddToShortlistDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import {
LocationIntelligencePanel,
MatchDetailHeader,
ExecutiveSummaryPanel,
PropertyOverviewPanel,
@@ -170,6 +171,7 @@ export default function MatchDetail() {
</Paper>
)}
<LocationIntelligencePanel property={property} />
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
+1 -1
View File
@@ -37,7 +37,7 @@ export default function MarketIntelligence() {
onFiltersChange={setFilters}
/>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<MarketSignalDetailPanel signal={selectedSignal} />
</Box>
</Box>
+1 -1
View File
@@ -37,7 +37,7 @@ export default function SignalPipeline() {
onFiltersChange={setFilters}
/>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{selectedSignal
? <SignalPipelineView signal={selectedSignal} />
: <MarketSignalEmptyState variant="no-selection" />
+1 -1
View File
@@ -40,7 +40,7 @@ export default function SourceMonitoring() {
/>
</Box>
{/* Right: Detail panel */}
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SourceDetailPanel source={selectedSource} />
</Box>
</Box>
+179 -59
View File
@@ -1,73 +1,193 @@
import { Box, Paper, Typography } from '@mui/material'
import { useMatches } from '../../hooks/useMatches'
import { useMemo, useState } from 'react'
import {
PropertySelectionPanel,
NeedSelectionPanel,
MatchBriefingPanel,
} from '../../components/match-center'
Box,
Chip,
Drawer,
IconButton,
MenuItem,
Select,
Typography,
} from '@mui/material'
import { X } from 'lucide-react'
import { useMatches, useApproveMatch } from '../../hooks/useMatches'
import { useProperties } from '../../hooks/useProperties'
import { useNeeds } from '../../hooks/useNeeds'
import { useMatchCenterStore } from '../../stores/matchCenterStore'
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
import type { Match } from '../../domain/match'
const PANEL_HEADER_SX = {
px: 2,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
position: 'sticky' as const,
top: 0,
zIndex: 1,
flexShrink: 0,
}
const STRENGTH_OPTIONS = [
{ value: '', label: 'Alle Stärken' },
{ value: 'STRONG', label: 'Stark (≥80)' },
{ value: 'MODERATE', label: 'Mittel (6079)' },
{ value: 'WEAK', label: 'Schwach (<60)' },
]
const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' },
{ value: 'PENDING_REVIEW', label: 'Ausstehend' },
{ value: 'APPROVED', label: 'Genehmigt' },
{ value: 'REJECTED', label: 'Abgelehnt' },
]
export default function MatchCenter() {
const { data: matches = [] } = useMatches()
const { data: matches = [], isLoading } = useMatches()
const { data: properties = [] } = useProperties()
const { data: needs = [] } = useNeeds()
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
const approveMatch = useApproveMatch()
const [selectedMatchId, setSelectedMatchId] = useState<string | null>(null)
const [filterStrength, setFilterStrength] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const propMap = useMemo(() => new Map(properties.map(p => [p.id, p])), [properties])
const needMap = useMemo(() => new Map(needs.map(n => [n.id, n])), [needs])
const filtered = useMemo(() => {
return matches
.filter(m => {
if (filterStrength && m.matchStrength !== filterStrength) return false
if (filterStatus && m.status !== filterStatus) return false
return true
})
.sort((a, b) => b.matchScore - a.matchScore)
}, [matches, filterStrength, filterStatus])
const strongCount = matches.filter(m => m.matchScore >= 80).length
const pendingCount = matches.filter(m => m.status === 'PENDING_REVIEW').length
function handleSelectMatch(match: Match) {
setSelectedMatchId(match.id)
setSelectedProperty(match.propertyId)
setSelectedNeed(match.needId)
}
function handleCloseDrawer() {
setSelectedMatchId(null)
setSelectedProperty(null)
setSelectedNeed(null)
}
return (
<Box sx={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
{/* Left: Properties */}
<Paper elevation={0} sx={{
width: 260,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
borderRadius: 0,
borderRight: '1px solid #e2e8f0',
overflow: 'hidden',
}}>
<Box sx={PANEL_HEADER_SX}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Objekte</Typography>
<Typography variant="caption" color="text.secondary">{matches.length} Matches gesamt</Typography>
</Box>
<PropertySelectionPanel matches={matches} />
</Paper>
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
{/* Center: Match Briefing */}
<Box sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
bgcolor: '#f8fafc',
}}>
<Box sx={PANEL_HEADER_SX}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Match-Briefing</Typography>
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2, mb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Match Center</Typography>
<Typography variant="body2" color="text.secondary">Automatisch berechnete Matches</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`${matches.length} Matches`} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} />
<Chip
label={`${strongCount} Stark`}
size="small"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600 }}
/>
{pendingCount > 0 && (
<Chip
label={`${pendingCount} Ausstehend`}
size="small"
sx={{ bgcolor: '#fef3c7', color: '#d97706', fontWeight: 600 }}
/>
)}
</Box>
<MatchBriefingPanel />
</Box>
{/* Right: Needs */}
<Paper elevation={0} sx={{
width: 260,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
borderRadius: 0,
borderLeft: '1px solid #e2e8f0',
overflow: 'hidden',
}}>
<Box sx={PANEL_HEADER_SX}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Bedarfe</Typography>
{/* Filter bar */}
<Box
sx={{
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 1,
display: 'flex',
gap: 1.5,
alignItems: 'center',
flexShrink: 0,
}}
>
<Select
size="small"
value={filterStrength}
onChange={e => setFilterStrength(e.target.value)}
displayEmpty
sx={{ fontSize: '0.8125rem', minWidth: 160 }}
>
{STRENGTH_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>{o.label}</MenuItem>
))}
</Select>
<Select
size="small"
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
displayEmpty
sx={{ fontSize: '0.8125rem', minWidth: 160 }}
>
{STATUS_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>{o.label}</MenuItem>
))}
</Select>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{filtered.length} von {matches.length} Matches
</Typography>
</Box>
{/* Match list */}
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: '#f8fafc' }}>
{isLoading ? (
<MatchCenterSkeleton />
) : filtered.length === 0 ? (
<Box sx={{ p: 6, textAlign: 'center' }}>
<Typography color="text.secondary">Keine Matches für diese Filter.</Typography>
</Box>
) : (
<Box sx={{ bgcolor: 'white' }}>
{filtered.map(match => (
<MatchListCard
key={match.id}
match={match}
property={propMap.get(match.propertyId)}
need={needMap.get(match.needId)}
onSelect={() => handleSelectMatch(match)}
onApprove={() => approveMatch.mutate(match.id)}
/>
))}
</Box>
)}
</Box>
{/* Detail Drawer */}
<Drawer
anchor="right"
open={!!selectedMatchId}
onClose={handleCloseDrawer}
slotProps={{ paper: { sx: { width: 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Match-Briefing</Typography>
<IconButton size="small" onClick={handleCloseDrawer} sx={{ color: '#94a3b8' }}>
<X size={16} />
</IconButton>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<MatchBriefingPanel />
</Box>
</Box>
<NeedSelectionPanel matches={matches} />
</Paper>
</Drawer>
</Box>
)
}
+2 -1
View File
@@ -2,7 +2,8 @@ import type { IMatchProvider, MatchFilters } from './IMatchProvider'
import type { Match } from '../domain/match'
import { mockMatches } from '../mock-data/matches'
const store: Match[] = [...mockMatches]
export const matchStore: Match[] = [...mockMatches]
const store = matchStore
export const MockupMatchProvider: IMatchProvider = {
async getAll(filters?: MatchFilters) {
+119
View File
@@ -1,9 +1,127 @@
import type { INeedProvider, NeedFilters } from './INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import { mockNeeds } from '../mock-data/needs'
import { matchStore } from './MockupMatchProvider'
import { propertyStore } from './MockupPropertyProvider'
import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums'
import type { Match } from '../domain/match'
const store: Need[] = [...mockNeeds]
// ── Location scoring ───────────────────────────────────────────────────────────
const CANTON_MAP: Record<string, string> = {
zürich: 'zh', zug: 'zg', winterthur: 'zh', uster: 'zh', bülach: 'zh', oerlikon: 'zh',
bern: 'be', biel: 'be', thun: 'be', köniz: 'be',
basel: 'bs', muttenz: 'bl', pratteln: 'bl', reinach: 'bl', allschwil: 'bl', binningen: 'bl',
genf: 'ge', genève: 'ge', carouge: 'ge', lancy: 'ge',
'st. gallen': 'sg', 'st.gallen': 'sg', rapperswil: 'sg',
}
function locationScore(propCity: string, preferredLocations: string[]): number {
const pc = propCity.toLowerCase()
for (const pref of preferredLocations) {
const p = pref.toLowerCase()
if (pc.includes(p) || p.includes(pc)) return 1.0
}
// Same canton check
const propCanton = CANTON_MAP[pc]
if (propCanton) {
for (const pref of preferredLocations) {
const prefCanton = CANTON_MAP[pref.toLowerCase()]
if (prefCanton && prefCanton === propCanton) return 0.55
}
}
return 0.30
}
function computeScore(prop: { assetType: string; areaSqm: number; rentPricePerSqm: number; location: { city: string } }, need: Need): number | null {
if (need.assetType && prop.assetType !== need.assetType) return null
const locScore = locationScore(prop.location.city, need.preferredLocations ?? [])
// Location dominates: same city → 50-90 base, different → 25-45
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
// Area overlap (+0-20)
if (need.requiredArea && prop.areaSqm) {
const { min, max } = need.requiredArea
if (prop.areaSqm >= min && prop.areaSqm <= max) score += 20
else if (prop.areaSqm >= min * 0.7 && prop.areaSqm <= max * 1.5) score += 10
else if (prop.areaSqm < min * 0.5 || prop.areaSqm > max * 2) score -= 10
}
// Budget fit (+0-10)
if (need.budgetRange?.maxPerSqm && prop.rentPricePerSqm) {
if (prop.rentPricePerSqm <= need.budgetRange.maxPerSqm) score += 10
else if (prop.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.2) score += 3
else score -= 8
}
// Small jitter so results look natural
score += Math.floor(Math.random() * 6) - 2
return Math.min(97, Math.max(22, score))
}
function strengthFromScore(s: number): string {
if (s >= 75) return MatchStrength.STRONG
if (s >= 55) return MatchStrength.MODERATE
return MatchStrength.WEAK
}
function generateSyntheticMatches(need: Need) {
const now = new Date().toISOString()
for (const prop of propertyStore) {
const score = computeScore(prop, need)
if (score === null || score < 25) continue
const locS = locationScore(prop.location.city, need.preferredLocations ?? [])
const isGoodLoc = locS >= 0.9
const match: Match = {
id: crypto.randomUUID(),
propertyId: prop.id,
needId: need.id,
resultId: prop.id,
resultType: prop.resultType ?? 'VERIFIED_PORTFOLIO',
matchScore: score,
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
status: score >= 75 ? MatchStatus.PENDING_REVIEW : MatchStatus.PENDING_REVIEW,
scoreBreakdown: {
hardMatchScore: score + 5,
softFactorScore: score - 5,
confidenceModifier: isGoodLoc ? 0.96 : 0.82,
dataQualityModifier: 0.92,
totalScore: score,
},
positiveFactors: isGoodLoc
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} bevorzugter Standort` }]
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${prop.areaSqm} m² verfügbar` }],
negativeFactors: !isGoodLoc
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
: [],
tradeoffs: !isGoodLoc
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
: [],
explainabilitySummary: isGoodLoc
? `${prop.location.city} trifft den Standortwunsch. Objekt entspricht den Kernkriterien.`
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'],
organizationId: 'org-wincasa',
createdAt: now,
updatedAt: now,
}
matchStore.push(match)
}
}
// ── Provider ───────────────────────────────────────────────────────────────────
export const MockupNeedProvider: INeedProvider = {
async getAll(filters?: NeedFilters) {
let results = [...store]
@@ -18,6 +136,7 @@ export const MockupNeedProvider: INeedProvider = {
async create(data: CreateNeedInput) {
const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
store.push(next)
generateSyntheticMatches(next)
return next
},
async update(id, data: UpdateNeedInput) {
+2 -1
View File
@@ -2,7 +2,8 @@ import type { IPropertyProvider, PropertyFilters } from './IPropertyProvider'
import type { Property, CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
import { mockProperties } from '../mock-data/properties'
const store: Property[] = [...mockProperties]
export const propertyStore: Property[] = [...mockProperties]
const store = propertyStore
export const MockupPropertyProvider: IPropertyProvider = {
async getAll(filters?: PropertyFilters) {
+1 -1
View File
@@ -30,7 +30,7 @@ const DEMO_USERS: Record<UserRole, MockUser> = {
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND],
},
[UserRole.PROPERTY_MANAGER]: {
id: 'user-pm',