diff --git a/src/App.tsx b/src/App.tsx index 4dada50..908bc7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = { + [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 +} const LoginScreen = lazy(() => import('./pages/auth/LoginScreen')) @@ -38,7 +51,7 @@ function App() { {/* Protected: auth check only */} }> }> - } /> + } /> {/* Supply Workspace */} }> diff --git a/src/components/demand/CriteriaReviewPanel.tsx b/src/components/demand/CriteriaReviewPanel.tsx index 9cb63a8..113f159 100644 --- a/src/components/demand/CriteriaReviewPanel.tsx +++ b/src/components/demand/CriteriaReviewPanel.tsx @@ -111,74 +111,19 @@ export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set /> - {/* ── Soft Factors ────────────────────────────────────────────────── */} -
- set({ ...c, prestigeImportance: (v.toUpperCase() as ParsedNeedCriteria['prestigeImportance']) })} - /> - set({ ...c, flexibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['flexibilityNeed']) })} - /> - set({ ...c, expansionPotential: v.toLowerCase().startsWith('j') })} - /> - set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })} - /> - set({ ...c, visibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['visibilityNeed']) })} - /> - set({ ...c, footfallNeed: (v.toUpperCase() as ParsedNeedCriteria['footfallNeed']) })} - /> -
- {/* ── Must-haves ──────────────────────────────────────────────────── */} -
+
set({ ...c, mustHaveCriteria: parseList(v) })} /> set({ ...c, infrastructureRequirements: parseList(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') })} />
diff --git a/src/components/demand/ExtractedFieldRow.tsx b/src/components/demand/ExtractedFieldRow.tsx index 955b5ad..bfa6083 100644 --- a/src/components/demand/ExtractedFieldRow.tsx +++ b/src/components/demand/ExtractedFieldRow.tsx @@ -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 || '—'} - - - {onEdit && ( - { setEditValue(value); setEditing(true) }} - > - - - )} - + {onEdit && ( + { setEditValue(value); setEditing(true) }} + > + + + )} ) } diff --git a/src/components/demand/NeedBuilderProgress.tsx b/src/components/demand/NeedBuilderProgress.tsx index 7dbde2c..9ea95e9 100644 --- a/src/components/demand/NeedBuilderProgress.tsx +++ b/src/components/demand/NeedBuilderProgress.tsx @@ -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) { diff --git a/src/components/demand/NeedInput.tsx b/src/components/demand/NeedInput.tsx index 61fc492..07fe2a7 100644 --- a/src/components/demand/NeedInput.tsx +++ b/src/components/demand/NeedInput.tsx @@ -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 = { - Büro: 'Wir suchen 800–1.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur.', - Retail: 'Suche Ladenfläche 200–400 m² in Bern Innenstadt, hohe Passantenfrequenz, Erdgeschoss, max. CHF 150/m². Sofort verfügbar.', - Logistik: 'Lagerhalle 2.000–3.000 m² Basel Umgebung, Tiefgarage oder Aussenrampe, 12 m Deckenhöhe, sofort. Budget CHF 15/m².', -} - -const ASSET_TYPE_OPTIONS: Array<{ label: string; value: string }> = [ +const ASSET_OPTIONS = [ { label: 'Büro', value: AssetType.OFFICE }, { label: 'Retail', value: AssetType.RETAIL }, { label: 'Logistik', value: AssetType.LOGISTICS }, { label: 'Produktion', value: AssetType.PRODUCTION }, { label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL }, + { label: 'Gemischt', value: AssetType.MIXED }, ] -export function NeedInput({ value, onChange, onSubmit }: Props) { +function FieldLabel({ children }: { children: React.ReactNode }) { return ( - - - - Flächenbedarf beschreiben - - - Beschreiben Sie Typ, Fläche, Standort, Budget und Verfügbarkeit — die KI extrahiert die Kriterien automatisch. - + + {children} + + ) +} - onChange(e.target.value)} - slotProps={{ htmlInput: { maxLength: 2000 } }} - sx={{ mb: 1 }} - /> - - {value.length}/2000 - +export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { + const [locationDraft, setLocationDraft] = useState('') + const [mustHaveDraft, setMustHaveDraft] = useState('') - - - - - + 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 */} - - 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 ( + + Kriterien verfeinern + + Ergänzen oder korrigieren Sie die extrahierten Felder. - - {ASSET_TYPE_OPTIONS.map(opt => ( + + {/* Asset Type */} + Nutzungstyp + + {ASSET_OPTIONS.map(opt => ( 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' } } + : {}} /> ))} - {/* Example prompts */} - - Beispiele: - - - {Object.entries(EXAMPLES).map(([label, text]) => ( - onChange(text)} - sx={{ height: 'auto', py: 0.5, '& .MuiChip-label': { whiteSpace: 'normal' } }} - /> - ))} - - + {/* Area */} + Fläche (m²) + + set({ ...c, areaRange: { min: parseInt(e.target.value) || 0, max: c.areaRange?.max ?? 0 } })} + sx={{ width: 100 }} + slotProps={{ htmlInput: { min: 0 } }} + /> + + set({ ...c, areaRange: { min: c.areaRange?.min ?? 0, max: parseInt(e.target.value) || 0 } })} + sx={{ width: 100 }} + slotProps={{ htmlInput: { min: 0 } }} + /> + + + + {/* Location */} + Standort + 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 ? ( + + {c.preferredLocations!.map(loc => ( + set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })} + /> + ))} + + ) : } + + {/* Budget */} + Budget (max CHF/m²) + set({ ...c, budgetRange: { maxPerSqm: parseInt(e.target.value) || 0, currency: 'CHF' } })} + sx={{ width: 160, mb: 2.5 }} + slotProps={{ htmlInput: { min: 0 } }} + /> + + {/* Timing */} + Verfügbar ab + set({ ...c, timing: { earliestMoveIn: e.target.value, latestMoveIn: e.target.value, flexibleTiming: true } })} + sx={{ width: 220, mb: 2.5 }} + /> + + {/* Must-haves */} + Must-haves + 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 && ( + + {c.mustHaveCriteria!.map(item => ( + set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })} + /> + ))} + + )} + ) } diff --git a/src/components/demand/VoiceNeedInput.tsx b/src/components/demand/VoiceNeedInput.tsx new file mode 100644 index 0000000..c0f1219 --- /dev/null +++ b/src/components/demand/VoiceNeedInput.tsx @@ -0,0 +1,202 @@ +import { useRef, useState } from 'react' +import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material' +import { Mic, MicOff, Sparkles, X } from 'lucide-react' + +interface Props { + text: string + onTextChange: (s: string) => void + onAiSubmit: () => void + isAnalyzing: boolean + isAutoGen: boolean +} + +const EXAMPLES = [ + 'Büro 800–1000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung', + 'Retail-Fläche 200–400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort', + 'Lagerhalle 2000–3000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²', +] + +const isSpeechSupported = typeof window !== 'undefined' && + ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) + +export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) { + const [isRecording, setIsRecording] = useState(false) + const [interimText, setInterimText] = useState('') + const recognitionRef = useRef(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 ( + + + + + Bedarf beschreiben + + + Schreiben oder sprechen — die KI extrahiert alle Kriterien automatisch + + + + {isRecording ? ( + + ) : isAnalyzing ? ( + + + Analysiert… + + ) : isAutoGen && text ? ( + ⚡ auto-synchronisiert + ) : null} + + + {/* Textarea + mic */} + + { + 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' }, + }, + }} + /> + + {isRecording ? ( + + + + ) : ( + + + + )} + + + + {/* Actions row */} + + {/* Example prompts */} + + {EXAMPLES.map((ex, i) => ( + onTextChange(ex)} + sx={{ fontSize: 10, height: 20 }} + /> + ))} + + + + {text && !isRecording && ( + onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}> + + + )} + + + + + ) +} diff --git a/src/components/demand/WeightingEditor.tsx b/src/components/demand/WeightingEditor.tsx index 6101a21..828c1eb 100644 --- a/src/components/demand/WeightingEditor.tsx +++ b/src/components/demand/WeightingEditor.tsx @@ -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): Record { + 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 + return Object.fromEntries( + WEIGHTING_KEYS.map(k => [k, Math.max(1, Math.round(((w[k] ?? 0) / max) * 5))]) + ) as Record +} + +function rawToWeights(raw: Record): Record { + 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 +} + +export function WeightingEditor({ weights, onChange, assetType }: Props) { + const [raw, setRaw] = useState>(() => 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) { - Kriteriengewichtung anpassen + Wichtigkeit der Kriterien - Passen Sie an, wie stark jedes Kriterium das Matching beeinflusst. + Schieber nach rechts = wichtiger. Gewichtung wird automatisch berechnet. + )} + + + + ) +} diff --git a/src/components/match-center/index.ts b/src/components/match-center/index.ts index 3557f59..499cf8d 100644 --- a/src/components/match-center/index.ts +++ b/src/components/match-center/index.ts @@ -4,3 +4,4 @@ export { MatchCenterSkeleton } from './MatchCenterSkeleton' export { PropertySelectionPanel } from './PropertySelectionPanel' export { NeedSelectionPanel } from './NeedSelectionPanel' export { MatchBriefingPanel } from './MatchBriefingPanel' +export { MatchListCard } from './MatchListCard' diff --git a/src/components/match-detail/LocationIntelligencePanel.tsx b/src/components/match-detail/LocationIntelligencePanel.tsx new file mode 100644 index 0000000..689d443 --- /dev/null +++ b/src/components/match-detail/LocationIntelligencePanel.tsx @@ -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 = ( + + + + {icon} + {label} + + + + + + ) + return tooltip ? {row} : row +} + +function KpiTile({ + label, + value, + sub, + color, +}: { + label: string + value: string + sub?: string + color?: string +}) { + return ( + + + {label} + + + {value} + + {sub && ( + + {sub} + + )} + + ) +} + +const NEW_PROJECTS: Record = { + 'Zürich': [ + { title: 'Ensemble Zürich-West', area: '500–2000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' }, + { title: 'The Circle Phase II', area: '1000–5000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' }, + ], + 'Basel': [ + { title: 'Basel SBB Tower', area: '300–1500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' }, + { title: 'Erlenmatt Ost', area: '600–2500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' }, + ], + 'Zug': [ + { title: 'Zug Innovation Campus', area: '200–1000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' }, + ], + 'Bern': [ + { title: 'Bern West Business Park', area: '400–3000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' }, + ], + 'Winterthur': [ + { title: 'Sulzerareal Phase 4', area: '800–4000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' }, + ], +} + +// ── Main component ──────────────────────────────────────────────────────────── + +interface Props { + property: Property | null +} + +export function LocationIntelligencePanel({ property }: Props) { + const { data: allProperties = [] } = useProperties() + + if (!property) return null + + const city = property.location.city + const intel = getCityIntelligence(city) + const sf = property.softFactors + + const hasSoftFactors = sf && ( + sf.footfallScore !== undefined || + sf.taxEnvironmentScore !== undefined || + sf.commuterAccessScore !== undefined || + sf.talentAccessScore !== undefined || + sf.prestigeScore !== undefined || + sf.prestige !== undefined + ) + + // Market comparables: same type, same city, different property + const comparables = allProperties + .filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === city) + .sort((a, b) => b.confidenceScore - a.confidenceScore) + .slice(0, 3) + + const newProjects = NEW_PROJECTS[city] ?? [] + + const rentTrendPositive = intel && intel.rentTrend12m > 0 + + return ( + + + Standort-Intelligence + + + Wirtschaftliche Faktoren und Marktkontext — über klassische Flächenangaben hinaus + + + {/* ── City KPIs ── */} + {intel && ( + <> + + + + = 115 ? '#1a7a4a' : intel.purchasingPowerIndex >= 95 ? '#d97706' : '#c0392b'} + /> + + + + {/* Rent trend interpretation */} + + + {rentTrendPositive ? : } + + + {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.`} + + + + {/* Tax + demand */} + + + + + Steuerindex Kanton + + + {intel.taxIndexCanton} (CH = 100) + + + {intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'} + + + + + + Nachfragestärke + + + + Aktive Nachfrage in {city} + + + + + {/* Industry clusters */} + + + Dominante Branchen-Cluster + + + {intel.dominantIndustryClusters.map(c => ( + + ))} + + + + {/* Infrastructure */} + {intel.plannedInfrastructure.length > 0 && ( + + + + + Geplante Infrastruktur-Projekte + + + {intel.plannedInfrastructure.map((proj, i) => ( + + + {proj.timeline} + + + {proj.project} + {proj.impact} + + + ))} + + )} + + + + )} + + {/* ── Soft Factors ── */} + {hasSoftFactors && ( + + + KI-berechnete Standortqualität + + } + tooltip="Geschätzte Personenfrequenz im Umfeld — relevant für Retail & Sichtbarkeit" + /> + } + tooltip={sf.publicTransportMinutes ? `~${sf.publicTransportMinutes} Min. zum nächsten ÖV-Hub` : 'Öffentliche Erreichbarkeit des Standorts'} + /> + } + tooltip="Verfügbarkeit qualifizierter Fachkräfte in einem 30-Min.-Radius" + /> + } + tooltip="Adress-Prestige und wahrgenommene Standortqualität" + /> + } + tooltip="Möglichkeit zur Flächen-Anpassung (Ausbau, Teilung, Erweiterung)" + /> + {sf.esgScore !== undefined && ( + } + tooltip="Umwelt-, Sozial- und Governance-Standard des Gebäudes" + /> + )} + + )} + + {/* ── Market Comparables ── */} + {comparables.length > 0 && ( + <> + + + Vergleichbare Angebote in {city} + + {comparables.map(p => ( + + + + + {p.title} + + + {p.areaSqm} m² · CHF {p.rentPricePerSqm}/m² + {p.rentPricePerSqm < property.rentPricePerSqm && ' · günstiger'} + {p.rentPricePerSqm > property.rentPricePerSqm && ' · teurer'} + + + + ))} + + )} + + {/* ── New Construction ── */} + {newProjects.length > 0 && ( + <> + + + Neubauprojekte als Alternative + + {newProjects.map((proj, i) => ( + + + + {proj.title} + + {proj.area} · Fertigstellung {proj.completion} + + {proj.note} + + + ))} + + )} + + ) +} diff --git a/src/components/match-detail/index.ts b/src/components/match-detail/index.ts index 1b42c93..0e45ca6 100644 --- a/src/components/match-detail/index.ts +++ b/src/components/match-detail/index.ts @@ -1,3 +1,4 @@ +export { LocationIntelligencePanel } from './LocationIntelligencePanel' export { MatchDetailHeader } from './MatchDetailHeader' export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel' export { PropertyOverviewPanel } from './PropertyOverviewPanel' diff --git a/src/components/supply/NegotiationInsightsPanel.tsx b/src/components/supply/NegotiationInsightsPanel.tsx new file mode 100644 index 0000000..c8d9a06 --- /dev/null +++ b/src/components/supply/NegotiationInsightsPanel.tsx @@ -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 ( + + + {/* ── Price positioning ── */} + + Preispositionierung + + + + Ihr Preis + + CHF {property.rentPricePerSqm}/m² + + + {marketRent && ( + + Marktmedian {property.location.city} + + CHF {marketRent}/m² + + + )} + {avgComparableRent && ( + + Vergleichsangebote Ø + + CHF {Math.round(avgComparableRent)}/m² + + + )} + + + {priceDiff !== null && ( + 0 ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5 }}> + {priceDiff > 0 ? : } + 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.`} + + + )} + + {/* Price bar vs market */} + {marketRent && ( + + + Marktbereich {property.location.city} + + CHF {Math.round(marketRent * 0.7)}–{Math.round(marketRent * 1.4)}/m² + + + + + + + )} + + + {/* ── Active demand ── */} + + Aktive Nachfrage + + Unternehmen, die aktuell in {property.location.city} suchen + + {matchingNeeds.length > 0 ? ( + <> + + {matchingNeeds.length} + aktive Suchprofile für diesen Typ & Standort + + {matchingNeeds.slice(0, 4).map(n => ( + + + {n.companyName} + + {n.requiredArea?.min ?? 0}–{n.requiredArea?.max ?? 0} m² + {n.budgetRange?.maxPerSqm ? ` · max. CHF ${n.budgetRange.maxPerSqm}/m²` : ''} + + + + + ))} + {matchingNeeds.length > 4 && ( + + + {matchingNeeds.length - 4} weitere Suchprofile + + )} + + ) : ( + + Keine aktiven Suchprofile für diesen Typ und Standort. + + )} + {intel && ( + + + Ø Vermietungsdauer vergleichbarer Objekte in {property.location.city}:{' '} + {intel.avgDaysOnMarket} Tage + + + )} + + + {/* ── Selling arguments ── */} + {sellingArgs.length > 0 && ( + + Verkaufsargumente für das Gespräch + + Stärken dieses Objekts — maßgeschneidert auf typische Mieterwünsche + + + {strongArgs.length > 0 && ( + + + Starke Argumente + + {strongArgs.map((arg, i) => ( + + + + {arg.title} + {arg.detail} + + + ))} + + )} + + {mediumArgs.length > 0 && ( + + + Weitere Vorteile + + {mediumArgs.map((arg, i) => ( + + + + {arg.title} + {arg.detail} + + + ))} + + )} + + )} + + {/* ── Proactive weakness handling ── */} + {weaknesses.length > 0 && ( + + Schwächen proaktiv adressieren + + Potenzielle Einwände kennen und entkräften — bevor der Interessent fragt + + {weaknesses.map((w, i) => ( + + + ⚠ {w.issue} + + + → {w.mitigation} + + {i < weaknesses.length - 1 && } + + ))} + + )} + + {/* ── Tenant fit ── */} + {intel && intel.dominantIndustryClusters.length > 0 && ( + + Welche Mieter passen? + + Dominant präsente Branchen in {property.location.city} — hohes Match-Potenzial + + + {intel.dominantIndustryClusters.map(c => ( + + ))} + + + + Nachfragestärke: {' '} + {{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]} + + + )} + + ) +} diff --git a/src/components/supply/PropertyCard.tsx b/src/components/supply/PropertyCard.tsx index abf6c41..cc7d20f 100644 --- a/src/components/supply/PropertyCard.tsx +++ b/src/components/supply/PropertyCard.tsx @@ -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 ( e.stopPropagation()}> - diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx index 2c521a4..e2af1c8 100644 --- a/src/components/supply/PropertyDetailView.tsx +++ b/src/components/supply/PropertyDetailView.tsx @@ -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 */} {tab === 0 && } - {tab === 1 && } - {tab === 2 && } - {tab === 3 && } - {tab === 4 && } - {tab === 5 && } - {tab === 6 && } + {tab === 1 && } + {tab === 2 && } + {tab === 3 && } + {tab === 4 && } + {tab === 5 && } + {tab === 6 && } + {tab === 7 && } ) diff --git a/src/components/supply/PropertyTable.tsx b/src/components/supply/PropertyTable.tsx index 39f08e8..d7c2563 100644 --- a/src/components/supply/PropertyTable.tsx +++ b/src/components/supply/PropertyTable.tsx @@ -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({ - - - - - diff --git a/src/components/supply/StrongMatchMiniCard.tsx b/src/components/supply/StrongMatchMiniCard.tsx index 30b6d64..c02bf23 100644 --- a/src/components/supply/StrongMatchMiniCard.tsx +++ b/src/components/supply/StrongMatchMiniCard.tsx @@ -69,14 +69,6 @@ export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) { > Zur Prüfung - diff --git a/src/features/matching/scoreCalculator.ts b/src/features/matching/scoreCalculator.ts index 18b6b43..3699dac 100644 --- a/src/features/matching/scoreCalculator.ts +++ b/src/features/matching/scoreCalculator.ts @@ -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) diff --git a/src/lib/locationIntelligence.ts b/src/lib/locationIntelligence.ts new file mode 100644 index 0000000..947fe1d --- /dev/null +++ b/src/lib/locationIntelligence.ts @@ -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 = { + '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 +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 66ad8f8..0de9c2c 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -29,6 +29,7 @@ const ROLE_PERMISSIONS: Record = { [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.DEMAND]: [ UserRole.SUPER_ADMIN, UserRole.ORGANIZATION_ADMIN, + UserRole.PROPERTY_MANAGER, UserRole.DEMAND_USER, ], [WorkspaceType.OPERATIONS]: [ UserRole.SUPER_ADMIN, - UserRole.ORGANIZATION_ADMIN, UserRole.REVIEWER, ], } diff --git a/src/mock-data/futureSignals.ts b/src/mock-data/futureSignals.ts index fa33339..391e7fb 100644 --- a/src/mock-data/futureSignals.ts +++ b/src/mock-data/futureSignals.ts @@ -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 2025–2027', + 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 2025–2027', + 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', + }, ] diff --git a/src/mock-data/matches.ts b/src/mock-data/matches.ts index d07e068..35c8a85 100644 --- a/src/mock-data/matches.ts +++ b/src/mock-data/matches.ts @@ -1,20 +1,20 @@ -import { MatchStrength, RiskLevel } from '../domain/enums' +import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums' import type { Match } from '../domain/match' export const mockMatches: Match[] = [ + + // ─────────────────────────────────────────────────────────────────────────── + // need-001 · Innovatech AG · OFFICE Zürich · 600–1000m² · max CHF 45/m² + // ─────────────────────────────────────────────────────────────────────────── + { id: 'match-001', propertyId: 'prop-001', needId: 'need-001', matchScore: 88, matchStrength: MatchStrength.STRONG, - scoreBreakdown: { - hardMatchScore: 92, - softFactorScore: 85, - confidenceModifier: 0.97, - dataQualityModifier: 0.92, - totalScore: 88, - }, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 92, softFactorScore: 85, confidenceModifier: 0.97, dataQualityModifier: 0.92, totalScore: 88 }, positiveFactors: [ { criterion: 'Fläche', weight: 0.20, score: 95, contribution: 19, explanation: '850m² liegt im Zielkorridor (600–1000m²)' }, { criterion: 'ÖV-Anbindung', weight: 0.20, score: 90, contribution: 18, explanation: '4 Min. zur S-Bahn, Kriterium erfüllt' }, @@ -37,19 +37,15 @@ export const mockMatches: Match[] = [ createdAt: '2025-05-10T08:00:00Z', updatedAt: '2025-05-10T08:00:00Z', }, + { id: 'match-002', propertyId: 'prop-004', needId: 'need-001', matchScore: 64, matchStrength: MatchStrength.MODERATE, - scoreBreakdown: { - hardMatchScore: 78, - softFactorScore: 62, - confidenceModifier: 0.68, - dataQualityModifier: 0.55, - totalScore: 64, - }, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 78, softFactorScore: 62, confidenceModifier: 0.68, dataQualityModifier: 0.55, totalScore: 64 }, positiveFactors: [ { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '1150m² liegt im erweiterten Korridor' }, { criterion: 'Standort', weight: 0.20, score: 80, contribution: 16, explanation: 'Zürich Kreis 4 nahe bevorzugten Lagen' }, @@ -62,7 +58,7 @@ export const mockMatches: Match[] = [ { criterion: 'Datenqualität', concern: 'Externe Quelle – Mietpreis und Verfügbarkeit nicht bestätigt', severity: 'HIGH', mitigation: 'Direkte Anfrage beim Anbieter empfohlen' }, { criterion: 'Budget', concern: 'Mietpreis 30% über Budget-Maximum', severity: 'HIGH' }, ], - explainabilitySummary: 'Moderater Match – Fläche und Lage passen, aber Mietpreis und Datenqualität sind kritische Vorbehalte. Nur mit Preisverhandlung und Verifikation sinnvoll.', + explainabilitySummary: 'Moderater Match – Fläche und Lage passen, aber Mietpreis und Datenqualität sind kritische Vorbehalte.', confidenceLevel: 0.58, riskLevel: RiskLevel.MEDIUM, uncertaintyIndicators: ['Daten aus Drittquelle unvollständig', 'Mietpreis nicht verifiziert'], @@ -70,24 +66,160 @@ export const mockMatches: Match[] = [ createdAt: '2025-05-10T08:05:00Z', updatedAt: '2025-05-10T08:05:00Z', }, + + { + id: 'match-005', + propertyId: 'prop-007', + needId: 'need-001', + matchScore: 86, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 89, softFactorScore: 83, confidenceModifier: 0.98, dataQualityModifier: 0.94, totalScore: 86 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 92, contribution: 18.4, explanation: '720m² im Zielkorridor (600–1000m²)' }, + { criterion: 'Standort', weight: 0.20, score: 90, contribution: 18, explanation: 'Zürich Oerlikon – bevorzugte Lage' }, + { criterion: 'Budget', weight: 0.15, score: 90, contribution: 13.5, explanation: 'CHF 36/m² deutlich unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Prestige', weight: 0.10, score: 72, contribution: 7.2, explanation: 'Prestige-Score 72 leicht unter Mindestanforderung 70 – ok' }, + ], + tradeoffs: [], + explainabilitySummary: 'Starker Match in Zürich Oerlikon. Fläche, Budget und ÖV-Anbindung erfüllen alle Hauptkriterien. Bewertung analog zu prop-001.', + confidenceLevel: 0.91, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T08:20:00Z', + updatedAt: '2025-05-10T08:20:00Z', + }, + + { + id: 'match-006', + propertyId: 'prop-013', + needId: 'need-001', + matchScore: 72, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.SHORTLISTED, + scoreBreakdown: { hardMatchScore: 80, softFactorScore: 68, confidenceModifier: 0.96, dataQualityModifier: 0.92, totalScore: 72 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 88, contribution: 17.6, explanation: 'Zürich Altstetten – akzeptable Lage' }, + { criterion: 'Budget', weight: 0.15, score: 95, contribution: 14.25, explanation: 'CHF 45/m² exakt im Budget-Limit' }, + ], + negativeFactors: [ + { criterion: 'Nutzungsart', weight: 0.15, score: 60, contribution: 9, explanation: 'MIXED-Fläche, Bedarf ist OFFICE – Teileignung' }, + { criterion: 'Fläche', weight: 0.20, score: 65, contribution: 13, explanation: '1300m² überschreitet Maximum von 1000m² deutlich' }, + ], + tradeoffs: [ + { criterion: 'Nutzungsart', concern: 'Gemischt genutzte Fläche – Büroanteil nicht spezifiziert', severity: 'MEDIUM', mitigation: 'Aufteilung klären, evtl. nur Büroanteil mieten' }, + ], + explainabilitySummary: 'Moderater Match – Lage und Budget stimmen, aber die Fläche ist zu gross und der gemischte Nutzungstyp passt nur teilweise.', + confidenceLevel: 0.82, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Büroanteil der Gesamtfläche unklar'], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T08:25:00Z', + updatedAt: '2025-05-10T08:25:00Z', + }, + + { + id: 'match-007', + propertyId: 'prop-005', + needId: 'need-001', + matchScore: 59, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 82, softFactorScore: 66, confidenceModifier: 0.55, dataQualityModifier: 0.38, totalScore: 59 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 90, contribution: 18, explanation: 'Zürich Technopark – bevorzugte Lage' }, + { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '600m² an unterem Rand des Zielkorridors' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 28, contribution: 4.2, explanation: 'Probabilistisches Signal – kein bestätigtes Objekt' }, + { criterion: 'Datenqualität', weight: 0.10, score: 22, contribution: 2.2, explanation: 'Kritische Felder fehlen, Daten nicht verifiziert' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Signal nur 55% Wahrscheinlichkeit – keine Garantie', severity: 'HIGH', mitigation: 'Für Monitoring-Watchlist geeignet' }, + ], + explainabilitySummary: 'Lage und Fläche passen gut, aber die hohe Unsicherheit durch das probabilistische Signal zieht den Score deutlich nach unten.', + confidenceLevel: 0.48, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Probabilistisches Signal', 'Kritische Daten fehlen'], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T08:30:00Z', + updatedAt: '2025-05-10T08:30:00Z', + }, + + { + id: 'match-008', + propertyId: 'prop-023', + needId: 'need-001', + matchScore: 62, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 85, softFactorScore: 69, confidenceModifier: 0.54, dataQualityModifier: 0.36, totalScore: 62 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 85, contribution: 17, explanation: 'Zürich-Nord / Seebach – bevorzugter Kanton ZH' }, + { criterion: 'Budget', weight: 0.15, score: 92, contribution: 13.8, explanation: 'CHF 38/m² unter Maximum von CHF 45/m²' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 32, contribution: 4.8, explanation: '54% Signalwahrscheinlichkeit – unbestätigt' }, + { criterion: 'Datenqualität', weight: 0.10, score: 20, contribution: 2, explanation: 'Probabilistisches Signal, Felder fehlen' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Auszug von aktuellem Mieter nicht bestätigt', severity: 'HIGH', mitigation: 'Als Frühindikator beobachten' }, + ], + explainabilitySummary: 'Gute Lage in Zürich mit passendem Budget, aber Future-Signal mit mittlerer Konfidenz. Empfehlung: Beobachten.', + confidenceLevel: 0.46, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Auszug des Mieters nicht bestätigt', 'Daten unvollständig'], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T08:35:00Z', + updatedAt: '2025-05-10T08:35:00Z', + }, + + { + id: 'match-009', + propertyId: 'prop-015', + needId: 'need-001', + matchScore: 51, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 65, softFactorScore: 53, confidenceModifier: 0.70, dataQualityModifier: 0.60, totalScore: 51 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '650m² im Zielkorridor' }, + { criterion: 'Budget', weight: 0.15, score: 90, contribution: 13.5, explanation: 'CHF 38/m² unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.20, score: 35, contribution: 7, explanation: 'Luzern liegt ausserhalb bevorzugter Lage Zürich' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Luzern ist nicht in Zürich – komplett andere Stadt und Kanton', severity: 'HIGH' }, + ], + explainabilitySummary: 'Fläche und Budget passen, aber die Lage in Luzern ist nicht mit dem Bedarf Zürich kompatibel. Nur als letzte Option geeignet.', + confidenceLevel: 0.55, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort ausserhalb bevorzugter Region'], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T08:40:00Z', + updatedAt: '2025-05-10T08:40:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-002 · Schweizer Logistik GmbH · LOGISTICS Basel · 1500–4000m² · max CHF 18/m² + // ─────────────────────────────────────────────────────────────────────────── + { id: 'match-003', propertyId: 'prop-002', needId: 'need-002', matchScore: 91, matchStrength: MatchStrength.STRONG, - scoreBreakdown: { - hardMatchScore: 94, - softFactorScore: 89, - confidenceModifier: 0.99, - dataQualityModifier: 0.96, - totalScore: 91, - }, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 94, softFactorScore: 89, confidenceModifier: 0.99, dataQualityModifier: 0.96, totalScore: 91 }, positiveFactors: [ { criterion: 'Fläche', weight: 0.25, score: 96, contribution: 24, explanation: '2400m² im Zielkorridor (1500–4000m²)' }, { criterion: 'Autobahnanschluss', weight: 0.20, score: 92, contribution: 18.4, explanation: 'A2-Anschluss ca. 4 Min., Kriterium erfüllt' }, { criterion: 'Budget', weight: 0.20, score: 88, contribution: 17.6, explanation: 'CHF 14/m² liegt unter Maximum von CHF 18/m²' }, - { criterion: 'Datenqualität', weight: 0.10, score: 96, contribution: 9.6, explanation: 'Verifiziertes Portfolioobjekt, alle Felder vollständig' }, ], negativeFactors: [ { criterion: 'Erweiterungspotenzial', weight: 0.05, score: 70, contribution: 3.5, explanation: '800m² Erweiterung möglich, aber begrenzt' }, @@ -101,19 +233,15 @@ export const mockMatches: Match[] = [ createdAt: '2025-05-10T08:10:00Z', updatedAt: '2025-05-10T08:10:00Z', }, + { id: 'match-004', propertyId: 'prop-006', needId: 'need-002', matchScore: 47, matchStrength: MatchStrength.WEAK, - scoreBreakdown: { - hardMatchScore: 70, - softFactorScore: 40, - confidenceModifier: 0.48, - dataQualityModifier: 0.30, - totalScore: 47, - }, + status: MatchStatus.SHORTLISTED, + scoreBreakdown: { hardMatchScore: 70, softFactorScore: 40, confidenceModifier: 0.48, dataQualityModifier: 0.30, totalScore: 47 }, positiveFactors: [ { criterion: 'Fläche', weight: 0.25, score: 85, contribution: 21.25, explanation: '3200m² im erweiterten Zielkorridor' }, { criterion: 'Standort', weight: 0.20, score: 72, contribution: 14.4, explanation: 'Reinach BL ist eine bevorzugte Region' }, @@ -125,12 +253,11 @@ export const mockMatches: Match[] = [ ], tradeoffs: [ { criterion: 'Verfügbarkeit', concern: 'Probabilistisches Future-Signal – keine Garantie auf Verfügbarkeit', severity: 'HIGH', mitigation: 'Für Monitoring-Watchlist geeignet' }, - { criterion: 'Daten', concern: 'Mietpreis geschätzt, Andienung nicht bestätigt', severity: 'HIGH' }, ], - explainabilitySummary: 'Schwacher Match aufgrund hoher Unsicherheit. Das Signal ist interessant als Frühindikator, aber nicht als aktive Option geeignet. Empfehlung: Watchlist.', + explainabilitySummary: 'Schwacher Match aufgrund hoher Unsicherheit. Das Signal ist interessant als Frühindikator, aber nicht als aktive Option geeignet.', confidenceLevel: 0.38, riskLevel: RiskLevel.HIGH, - uncertaintyIndicators: ['Probabilistisches Signal ohne Bestätigung', 'Mietpreis geschätzt', 'Verfügbarkeitsdatum unsicher'], + uncertaintyIndicators: ['Probabilistisches Signal ohne Bestätigung', 'Mietpreis geschätzt'], alternativeStrategies: [ { title: 'Signal beobachten', description: 'Als Future-Availability-Signal auf Watchlist setzen und in 3 Monaten neu evaluieren' }, ], @@ -138,4 +265,1293 @@ export const mockMatches: Match[] = [ createdAt: '2025-05-10T08:15:00Z', updatedAt: '2025-05-10T08:15:00Z', }, + + { + id: 'match-010', + propertyId: 'prop-014', + needId: 'need-002', + matchScore: 87, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 90, softFactorScore: 84, confidenceModifier: 0.98, dataQualityModifier: 0.94, totalScore: 87 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.25, score: 95, contribution: 23.75, explanation: '3100m² optimal im Zielkorridor (1500–4000m²)' }, + { criterion: 'Standort', weight: 0.20, score: 82, contribution: 16.4, explanation: 'Pratteln BL – Kanton BL, direkte Nähe zu Basel' }, + { criterion: 'Budget', weight: 0.20, score: 88, contribution: 17.6, explanation: 'CHF 15/m² unter Maximum von CHF 18/m²' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.05, score: 65, contribution: 3.25, explanation: 'Nicht direkt Basel-Stadt, aber akzeptable Region' }, + ], + tradeoffs: [], + explainabilitySummary: 'Sehr starker Match. Logistikfläche in Pratteln erfüllt alle Hauptkriterien. Verfügbarkeit sofort, Daten vollständig.', + confidenceLevel: 0.93, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T09:00:00Z', + updatedAt: '2025-05-10T09:00:00Z', + }, + + { + id: 'match-011', + propertyId: 'prop-016', + needId: 'need-002', + matchScore: 76, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 90, softFactorScore: 74, confidenceModifier: 0.69, dataQualityModifier: 0.58, totalScore: 76 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.25, score: 92, contribution: 23, explanation: '2200m² im Zielkorridor' }, + { criterion: 'Standort', weight: 0.20, score: 78, contribution: 15.6, explanation: 'Muttenz BL – gleicher Kanton, gute Lage' }, + { criterion: 'Budget', weight: 0.20, score: 85, contribution: 17, explanation: 'CHF 16/m² innerhalb Budget' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 58, contribution: 5.8, explanation: 'Externe Quelle – Hallenhöhe nicht bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Datenqualität', concern: 'Hallenhöhe und Andienung aus externer Quelle – Vor-Ort-Check empfohlen', severity: 'MEDIUM' }, + ], + explainabilitySummary: 'Solider Marktinserat-Match in Muttenz. Fläche und Budget stimmen, Datenverifikation ausstehend.', + confidenceLevel: 0.72, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Hallenhöhe nicht bestätigt', 'Externe Quelle'], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T09:05:00Z', + updatedAt: '2025-05-10T09:05:00Z', + }, + + { + id: 'match-012', + propertyId: 'prop-009', + needId: 'need-002', + matchScore: 55, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 74, softFactorScore: 60, confidenceModifier: 0.98, dataQualityModifier: 0.95, totalScore: 55 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.25, score: 85, contribution: 21.25, explanation: '1800m² im Zielkorridor' }, + { criterion: 'Budget', weight: 0.20, score: 90, contribution: 18, explanation: 'CHF 13/m² weit unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.20, score: 35, contribution: 7, explanation: 'Winterthur liegt ausserhalb Präferenz Basel-Region' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Winterthur statt Basel – komplett andere Region, kein A2-Anschluss', severity: 'HIGH' }, + ], + explainabilitySummary: 'Fläche und Preis passen hervorragend, aber Winterthur ist nicht die gewünschte Logistik-Region Basel. Nur als Alternativoption.', + confidenceLevel: 0.82, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Standort ausserhalb Präferenzregion'], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T09:10:00Z', + updatedAt: '2025-05-10T09:10:00Z', + }, + + { + id: 'match-013', + propertyId: 'prop-024', + needId: 'need-002', + matchScore: 63, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.SHORTLISTED, + scoreBreakdown: { hardMatchScore: 86, softFactorScore: 70, confidenceModifier: 0.52, dataQualityModifier: 0.35, totalScore: 63 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 95, contribution: 19, explanation: 'Basel Hafen – exakte Präferenzlage' }, + { criterion: 'Fläche', weight: 0.25, score: 88, contribution: 22, explanation: '2600m² im Zielkorridor' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 32, contribution: 4.8, explanation: 'Neubau-Signal – Fertigstellung Q2 2026' }, + { criterion: 'Datenqualität', weight: 0.10, score: 25, contribution: 2.5, explanation: 'Mieterkonditionen noch nicht bekannt' }, + ], + tradeoffs: [ + { criterion: 'Timing', concern: 'Fertigstellung Juli 2026 – möglicherweise zu spät', severity: 'MEDIUM', mitigation: 'Voranmietung prüfen' }, + ], + explainabilitySummary: 'Sehr gute Lage direkt am Basler Hafen. Als Neubau mit hoher Bauwahrscheinlichkeit auf Shortlist geeignet.', + confidenceLevel: 0.44, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Fertigstellung abhängig von Baufortschritt'], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T09:15:00Z', + updatedAt: '2025-05-10T09:15:00Z', + }, + + { + id: 'match-014', + propertyId: 'prop-029', + needId: 'need-002', + matchScore: 56, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 79, softFactorScore: 63, confidenceModifier: 0.46, dataQualityModifier: 0.31, totalScore: 56 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.25, score: 90, contribution: 22.5, explanation: '3500m² im Zielkorridor' }, + { criterion: 'Budget', weight: 0.20, score: 92, contribution: 18.4, explanation: 'CHF 13/m² deutlich unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 28, contribution: 4.2, explanation: 'Nur 50% Wahrscheinlichkeit, kein bestätigter Auszug' }, + { criterion: 'Standort', weight: 0.20, score: 60, contribution: 12, explanation: 'Frenkendorf BL – gleicher Kanton, aber nicht Basel-Stadt' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Probabilistisches Signal – Auszug unbestätigt', severity: 'HIGH' }, + ], + explainabilitySummary: 'Gleicher Kanton, gutes Preis-Leistungs-Verhältnis, aber Future-Signal mit niedriger Konfidenz. Als Watchlist-Kandidat geeignet.', + confidenceLevel: 0.40, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Auszug unbestätigt', 'Konditionen unbekannt'], + organizationId: 'org-wincasa', + createdAt: '2025-05-10T09:20:00Z', + updatedAt: '2025-05-10T09:20:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-003 · Pharma Holding AG · OFFICE Basel · 500–800m² · max CHF 40/m² + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-015', + propertyId: 'prop-008', + needId: 'need-003', + matchScore: 85, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 88, softFactorScore: 82, confidenceModifier: 0.97, dataQualityModifier: 0.93, totalScore: 85 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 95, contribution: 23.75, explanation: 'Basel Dreispitz – exakte Präferenzlage' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 32/m² deutlich unter Maximum CHF 40/m²' }, + { criterion: 'Prestige', weight: 0.12, score: 70, contribution: 8.4, explanation: 'Prestige 70 erfüllt Mindestanforderung' }, + ], + negativeFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 72, contribution: 14.4, explanation: '900m² liegt über Maximum 800m² – etwas zu gross' }, + ], + tradeoffs: [ + { criterion: 'Fläche', concern: '900m² leicht über Maximum, möglicherweise Untereinheit verhandelbar', severity: 'LOW' }, + ], + explainabilitySummary: 'Starker Match in Basel. Prestige, Budget und Lage stimmen. Fläche minimal über Wunschgrösse, aber verhandelbar.', + confidenceLevel: 0.90, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T08:00:00Z', + updatedAt: '2025-05-11T08:00:00Z', + }, + + { + id: 'match-016', + propertyId: 'prop-007', + needId: 'need-003', + matchScore: 52, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 72, softFactorScore: 60, confidenceModifier: 0.98, dataQualityModifier: 0.94, totalScore: 52 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 90, contribution: 18, explanation: '720m² im Zielkorridor' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 36/m² unter Maximum CHF 40/m²' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Zürich statt Basel – andere Stadt, anderer Kanton' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Zürich ist nicht im Präferenzgebiet Basel/Allschwil', severity: 'HIGH' }, + ], + explainabilitySummary: 'Fläche und Preis stimmen, aber Zürich entspricht nicht dem Standortbedarf Basel. Nur wenn Flexibilität vorhanden.', + confidenceLevel: 0.86, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Standortanforderung nicht erfüllt'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T08:05:00Z', + updatedAt: '2025-05-11T08:05:00Z', + }, + + { + id: 'match-017', + propertyId: 'prop-018', + needId: 'need-003', + matchScore: 44, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 58, softFactorScore: 46, confidenceModifier: 0.68, dataQualityModifier: 0.57, totalScore: 44 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 31/m² deutlich unter Maximum' }, + { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '780m² nahe am Zielkorridor' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Bern liegt ausserhalb Präferenz Basel' }, + { criterion: 'Datenqualität', weight: 0.10, score: 42, contribution: 4.2, explanation: 'Externe Quelle, Renovierungsstand unklar' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Bern ist nicht Basel – keine Nähe zur Pharma-Industrie-Achse', severity: 'HIGH' }, + ], + explainabilitySummary: 'Schwacher Match aufgrund Standort-Mismatch. Bern ist nicht im Präferenzgebiet Basel. Budget und Fläche ok, aber Lage kritisch.', + confidenceLevel: 0.54, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort ausserhalb Präferenzregion', 'Externe Quelle'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T08:10:00Z', + updatedAt: '2025-05-11T08:10:00Z', + }, + + { + id: 'match-018', + propertyId: 'prop-005', + needId: 'need-003', + matchScore: 38, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.REJECTED, + scoreBreakdown: { hardMatchScore: 61, softFactorScore: 45, confidenceModifier: 0.55, dataQualityModifier: 0.38, totalScore: 38 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '600m² im Zielkorridor' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Zürich statt Basel – 80km Entfernung' }, + { criterion: 'Budget', weight: 0.20, score: 70, contribution: 14, explanation: 'CHF 42/m² liegt 5% über Budget-Maximum' }, + { criterion: 'Konfidenz', weight: 0.15, score: 25, contribution: 3.75, explanation: 'Future-Signal, kein bestätigtes Objekt' }, + ], + tradeoffs: [ + { criterion: 'Standort & Verfügbarkeit', concern: 'Falscher Standort + Future-Signal macht diesen Match nicht empfehlenswert', severity: 'HIGH' }, + ], + explainabilitySummary: 'Zu viele kritische Mängel: falscher Standort, Budget leicht über Maximum, und probabilistisches Signal. Abgelehnt.', + confidenceLevel: 0.36, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Falscher Standort', 'Future-Signal', 'Budget knapp'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T08:15:00Z', + updatedAt: '2025-05-11T08:15:00Z', + }, + + { + id: 'match-019', + propertyId: 'prop-015', + needId: 'need-003', + matchScore: 42, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 56, softFactorScore: 44, confidenceModifier: 0.70, dataQualityModifier: 0.60, totalScore: 42 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 38/m² unter Maximum CHF 40/m²' }, + { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '650m² im Zielkorridor' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Luzern liegt ausserhalb Region Basel' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Luzern ist nicht im Präferenzgebiet, 100km von Basel', severity: 'HIGH' }, + ], + explainabilitySummary: 'Budget und Fläche passen, jedoch ist Luzern nicht mit dem Bedarf Raum Basel vereinbar.', + confidenceLevel: 0.56, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort ausserhalb Präferenz'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T08:20:00Z', + updatedAt: '2025-05-11T08:20:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-004 · Retailer Zürich AG · RETAIL Zürich · 200–500m² · max CHF 100/m² + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-020', + propertyId: 'prop-010', + needId: 'need-004', + matchScore: 91, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 94, softFactorScore: 88, confidenceModifier: 0.99, dataQualityModifier: 0.96, totalScore: 91 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.35, score: 98, contribution: 34.3, explanation: 'Zürich Löwenplatz – exakte Innenstadtlage, Topstandort' }, + { criterion: 'Fläche', weight: 0.15, score: 90, contribution: 13.5, explanation: '285m² im Zielkorridor' }, + { criterion: 'Prestige', weight: 0.15, score: 95, contribution: 14.25, explanation: 'Prestige 95 – Topstandort erfüllt Anforderung' }, + ], + negativeFactors: [ + { criterion: 'Budget', weight: 0.15, score: 80, contribution: 12, explanation: 'CHF 88/m² unter Maximum CHF 100/m², leicht over avg' }, + ], + tradeoffs: [], + explainabilitySummary: 'Exzellenter Match. Zürich Löwenplatz ist ein Erstklasstandort mit höchster Passantenfrequenz. Alle Kriterien erfüllt.', + confidenceLevel: 0.96, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T09:00:00Z', + updatedAt: '2025-05-11T09:00:00Z', + }, + + { + id: 'match-021', + propertyId: 'prop-017', + needId: 'need-004', + matchScore: 79, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.SHORTLISTED, + scoreBreakdown: { hardMatchScore: 93, softFactorScore: 77, confidenceModifier: 0.71, dataQualityModifier: 0.61, totalScore: 79 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.35, score: 94, contribution: 32.9, explanation: 'Zürich Löwenstrasse – Innenstadtlage' }, + { criterion: 'Budget', weight: 0.15, score: 95, contribution: 14.25, explanation: 'CHF 95/m² unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 61, contribution: 6.1, explanation: 'Mietpreis aus externer Quelle, nicht bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Datenqualität', concern: 'Mietpreis und Vertragsdauer nicht bestätigt', severity: 'MEDIUM', mitigation: 'Verifizierung beim Vermieter empfohlen' }, + ], + explainabilitySummary: 'Sehr gute Lage in Zürich Innenstadt. Datenverifikation ausstehend, aber Standort und Preis stimmen.', + confidenceLevel: 0.72, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Mietpreis nicht final bestätigt'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T09:05:00Z', + updatedAt: '2025-05-11T09:05:00Z', + }, + + { + id: 'match-022', + propertyId: 'prop-025', + needId: 'need-004', + matchScore: 62, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 85, softFactorScore: 69, confidenceModifier: 0.50, dataQualityModifier: 0.33, totalScore: 62 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.35, score: 90, contribution: 31.5, explanation: 'Zürich Niederdorf – Innenstadtlage, gute Passantenfrequenz' }, + { criterion: 'Budget', weight: 0.15, score: 95, contribution: 14.25, explanation: 'CHF 92/m² unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 30, contribution: 4.5, explanation: '55% Signalwahrscheinlichkeit – unbestätigt' }, + { criterion: 'Datenqualität', weight: 0.10, score: 20, contribution: 2, explanation: 'Future-Signal, kritische Felder fehlen' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Probabilistisches Signal – Auszug noch nicht bestätigt', severity: 'HIGH' }, + ], + explainabilitySummary: 'Gute Lage im Niederdorf, aber Future-Signal mit mittlerer Konfidenz. Empfehlung: Beobachten und bei Bestätigung priorisieren.', + confidenceLevel: 0.42, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Auszug unbestätigt', 'Future-Signal'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T09:10:00Z', + updatedAt: '2025-05-11T09:10:00Z', + }, + + { + id: 'match-023', + propertyId: 'prop-003', + needId: 'need-004', + matchScore: 44, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 58, softFactorScore: 46, confidenceModifier: 0.71, dataQualityModifier: 0.62, totalScore: 44 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.15, score: 95, contribution: 14.25, explanation: 'CHF 95/m² unter Maximum CHF 100/m²' }, + { criterion: 'Prestige', weight: 0.15, score: 92, contribution: 13.8, explanation: 'Berner Bahnhofstrasse – Top-Prestige' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.35, score: 35, contribution: 12.25, explanation: 'Bern statt Zürich Innenstadt – andere Stadt, anderer Kanton' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Bern ist nicht Zürich – Laufkundschaft aus anderem Einzugsgebiet', severity: 'HIGH' }, + ], + explainabilitySummary: 'Gutes Retail-Objekt in Bern, aber der Bedarf ist explizit Zürich Innenstadt. Standort ist ausschlaggebend für Ablehnung.', + confidenceLevel: 0.60, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort nicht im Zielgebiet'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T09:15:00Z', + updatedAt: '2025-05-11T09:15:00Z', + }, + + { + id: 'match-024', + propertyId: 'prop-021', + needId: 'need-004', + matchScore: 33, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.REJECTED, + scoreBreakdown: { hardMatchScore: 47, softFactorScore: 35, confidenceModifier: 0.70, dataQualityModifier: 0.59, totalScore: 33 }, + positiveFactors: [ + { criterion: 'Prestige', weight: 0.15, score: 94, contribution: 14.1, explanation: 'Rue du Rhône Genf – sehr hoher Prestige-Score' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.35, score: 35, contribution: 12.25, explanation: 'Genf ist nicht Zürich – 280km Entfernung' }, + { criterion: 'Budget', weight: 0.15, score: 68, contribution: 10.2, explanation: 'CHF 112/m² liegt 12% über Maximum CHF 100/m²' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Genf ist eine komplett andere Stadt als Zürich', severity: 'HIGH' }, + { criterion: 'Budget', concern: 'Mietpreis 12% über Maximum', severity: 'MEDIUM' }, + ], + explainabilitySummary: 'Abgelehnt. Genf und Zürich sind nicht kompatibel – weder geografisch noch bezüglich des angestrebten Kundenkreises. Budget ebenfalls überschritten.', + confidenceLevel: 0.52, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Falscher Standort', 'Budget überschritten'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T09:20:00Z', + updatedAt: '2025-05-11T09:20:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-005 · TechStart GmbH · OFFICE Zug/Zürich · 300–700m² · max CHF 48/m² + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-025', + propertyId: 'prop-012', + needId: 'need-005', + matchScore: 91, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 94, softFactorScore: 88, confidenceModifier: 0.97, dataQualityModifier: 0.93, totalScore: 91 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 100, contribution: 25, explanation: 'Zug Zentrum – exakt bevorzugte Lage' }, + { criterion: 'Fläche', weight: 0.20, score: 92, contribution: 18.4, explanation: '550m² im Zielkorridor (300–700m²)' }, + { criterion: 'Budget', weight: 0.20, score: 90, contribution: 18, explanation: 'CHF 42/m² klar unter Maximum CHF 48/m²' }, + ], + negativeFactors: [], + tradeoffs: [], + explainabilitySummary: 'Exzellenter Match. Zug Zentrum trifft exakt den Standortwunsch. Budget, Fläche und Ausbaugrad erfüllen alle Anforderungen.', + confidenceLevel: 0.92, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T10:00:00Z', + updatedAt: '2025-05-11T10:00:00Z', + }, + + { + id: 'match-026', + propertyId: 'prop-007', + needId: 'need-005', + matchScore: 83, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.SHORTLISTED, + scoreBreakdown: { hardMatchScore: 86, softFactorScore: 80, confidenceModifier: 0.98, dataQualityModifier: 0.94, totalScore: 83 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 88, contribution: 22, explanation: 'Zürich Oerlikon – in bevorzugter Stadt Zürich' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 36/m² deutlich unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 72, contribution: 14.4, explanation: '720m² überschreitet Maximum von 700m² leicht' }, + ], + tradeoffs: [ + { criterion: 'Fläche', concern: 'Leicht über Flächenmaximum – ggf. Untereinheit verhandelbar', severity: 'LOW' }, + ], + explainabilitySummary: 'Sehr guter Match in Zürich Oerlikon. Lage und Budget stimmen, Fläche minimal über Maximum.', + confidenceLevel: 0.89, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T10:05:00Z', + updatedAt: '2025-05-11T10:05:00Z', + }, + + { + id: 'match-027', + propertyId: 'prop-020', + needId: 'need-005', + matchScore: 74, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 88, softFactorScore: 72, confidenceModifier: 0.70, dataQualityModifier: 0.60, totalScore: 74 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 100, contribution: 25, explanation: 'Zug – exakte Präferenzstadt' }, + { criterion: 'Budget', weight: 0.20, score: 90, contribution: 18, explanation: 'CHF 44/m² unter Maximum CHF 48/m²' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 60, contribution: 6, explanation: 'Externe Quelle, Vertragsdauer fehlt' }, + { criterion: 'Fläche', weight: 0.20, score: 68, contribution: 13.6, explanation: '820m² deutlich über Maximum 700m²' }, + ], + tradeoffs: [ + { criterion: 'Fläche', concern: 'Fläche 17% über Maximum – Untereinheit prüfen', severity: 'MEDIUM' }, + ], + explainabilitySummary: 'Guter Standort Zug, Budget akzeptabel. Fläche überschreitet Maximum aber Datenqualität eingeschränkt.', + confidenceLevel: 0.68, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Fläche zu gross', 'Externe Quelle'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T10:10:00Z', + updatedAt: '2025-05-11T10:10:00Z', + }, + + { + id: 'match-028', + propertyId: 'prop-001', + needId: 'need-005', + matchScore: 78, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 81, softFactorScore: 75, confidenceModifier: 0.97, dataQualityModifier: 0.92, totalScore: 78 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 88, contribution: 22, explanation: 'Zürich-West – bevorzugte Stadt Zürich' }, + { criterion: 'Budget', weight: 0.20, score: 90, contribution: 18, explanation: 'CHF 38/m² unter Maximum CHF 48/m²' }, + ], + negativeFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 62, contribution: 12.4, explanation: '850m² überschreitet Maximum 700m² deutlich' }, + ], + tradeoffs: [ + { criterion: 'Fläche', concern: '850m² sind 21% über Maximum – Untereinheit oder Kompromiss nötig', severity: 'MEDIUM', mitigation: '650m²-Untereinheit im selben Gebäude verfügbar' }, + ], + explainabilitySummary: 'Zürich-West passt gut. Fläche zu gross, aber Untereinheit verfügbar. Budget und Ausbaugrad sehr gut.', + confidenceLevel: 0.88, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Fläche über Maximum'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T10:15:00Z', + updatedAt: '2025-05-11T10:15:00Z', + }, + + { + id: 'match-029', + propertyId: 'prop-026', + needId: 'need-005', + matchScore: 64, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 87, softFactorScore: 71, confidenceModifier: 0.52, dataQualityModifier: 0.34, totalScore: 64 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 100, contribution: 25, explanation: 'Zug – exakter Standortwunsch' }, + { criterion: 'Budget', weight: 0.20, score: 90, contribution: 18, explanation: 'CHF 43/m² unter Maximum CHF 48/m²' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 30, contribution: 4.5, explanation: '52% Signalwahrscheinlichkeit – Future-Signal' }, + { criterion: 'Datenqualität', weight: 0.10, score: 22, contribution: 2.2, explanation: 'Kritische Felder fehlen' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Expansion-Signal – Fläche noch nicht auf dem Markt', severity: 'HIGH' }, + ], + explainabilitySummary: 'Perfekter Standort Zug, Budget stimmt. Als Future-Signal mit 52% Konfidenz – Watchlist-Kandidat.', + confidenceLevel: 0.44, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Future-Signal', 'Daten unvollständig'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T10:20:00Z', + updatedAt: '2025-05-11T10:20:00Z', + }, + + { + id: 'match-030', + propertyId: 'prop-022', + needId: 'need-005', + matchScore: 43, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 57, softFactorScore: 45, confidenceModifier: 0.69, dataQualityModifier: 0.58, totalScore: 43 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.20, score: 100, contribution: 20, explanation: 'CHF 28/m² deutlich unter Maximum' }, + { criterion: 'Fläche', weight: 0.20, score: 88, contribution: 17.6, explanation: '700m² exakt an der Obergrenze' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'St. Gallen ist nicht Zug oder Zürich – andere Kantone' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'St. Gallen liegt 80km von Zug entfernt – nicht im Präferenzgebiet', severity: 'HIGH' }, + ], + explainabilitySummary: 'Budget hervorragend, aber Standort St. Gallen passt nicht zu Zug/Zürich. Nur als äusserste Alternative.', + confidenceLevel: 0.55, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort ausserhalb Präferenz'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T10:25:00Z', + updatedAt: '2025-05-11T10:25:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-006 · Lager & Spedition AG · LOGISTICS Winterthur · 1200–3000m² · max CHF 16/m² + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-031', + propertyId: 'prop-009', + needId: 'need-006', + matchScore: 93, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 96, softFactorScore: 90, confidenceModifier: 0.98, dataQualityModifier: 0.95, totalScore: 93 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 100, contribution: 25, explanation: 'Winterthur Töss – exakte Präferenzlage' }, + { criterion: 'Fläche', weight: 0.30, score: 92, contribution: 27.6, explanation: '1800m² im Zielkorridor (1200–3000m²)' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 13/m² unter Maximum CHF 16/m²' }, + ], + negativeFactors: [], + tradeoffs: [], + explainabilitySummary: 'Ausgezeichneter Match. Winterthur Töss trifft exakt den Standortwunsch. Alle Logistik-Kriterien erfüllt, hervorragende Datenqualität.', + confidenceLevel: 0.96, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T11:00:00Z', + updatedAt: '2025-05-11T11:00:00Z', + }, + + { + id: 'match-032', + propertyId: 'prop-002', + needId: 'need-006', + matchScore: 56, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 76, softFactorScore: 62, confidenceModifier: 0.99, dataQualityModifier: 0.96, totalScore: 56 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.30, score: 90, contribution: 27, explanation: '2400m² im Zielkorridor' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 14/m² unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Basel liegt ausserhalb Winterthur-Region, andere Stadt' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Basel ist nicht im Präferenzgebiet Winterthur', severity: 'HIGH' }, + ], + explainabilitySummary: 'Sehr gute Logistikfläche in Basel, aber falscher Standort. Nur wenn Winterthur nicht verfügbar.', + confidenceLevel: 0.82, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Standort ausserhalb Region'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T11:05:00Z', + updatedAt: '2025-05-11T11:05:00Z', + }, + + { + id: 'match-033', + propertyId: 'prop-014', + needId: 'need-006', + matchScore: 52, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 72, softFactorScore: 58, confidenceModifier: 0.98, dataQualityModifier: 0.94, totalScore: 52 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.20, score: 92, contribution: 18.4, explanation: 'CHF 15/m² unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Pratteln BL ist nicht Winterthur – andere Stadt, anderer Kanton' }, + { criterion: 'Fläche', weight: 0.30, score: 68, contribution: 20.4, explanation: '3100m² überschreitet Maximum 3000m² leicht' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Pratteln liegt im Raum Basel, nicht im Raum Winterthur', severity: 'HIGH' }, + ], + explainabilitySummary: 'Falscher Standort für Winterthur-Bedarf. Nur als letzter Ausweg wenn Präferenz verhandelbar.', + confidenceLevel: 0.85, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Standort nicht kompatibel'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T11:10:00Z', + updatedAt: '2025-05-11T11:10:00Z', + }, + + { + id: 'match-034', + propertyId: 'prop-016', + needId: 'need-006', + matchScore: 50, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 64, softFactorScore: 50, confidenceModifier: 0.69, dataQualityModifier: 0.58, totalScore: 50 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.30, score: 88, contribution: 26.4, explanation: '2200m² im Zielkorridor' }, + { criterion: 'Budget', weight: 0.20, score: 85, contribution: 17, explanation: 'CHF 16/m² exakt im Maximum' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Muttenz liegt im Raum Basel, nicht Winterthur' }, + { criterion: 'Datenqualität', weight: 0.10, score: 58, contribution: 5.8, explanation: 'Hallenhöhe nicht verifiziert' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Muttenz BL ist nicht die Präferenz Winterthur', severity: 'HIGH' }, + ], + explainabilitySummary: 'Fläche und Budget stimmen, aber Muttenz ist 70km von Winterthur entfernt. Nicht kompatibel.', + confidenceLevel: 0.58, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort nicht kompatibel', 'Hallenhöhe unklar'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T11:15:00Z', + updatedAt: '2025-05-11T11:15:00Z', + }, + + { + id: 'match-035', + propertyId: 'prop-029', + needId: 'need-006', + matchScore: 43, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 66, softFactorScore: 53, confidenceModifier: 0.46, dataQualityModifier: 0.31, totalScore: 43 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 13/m² unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Frenkendorf BL nicht im Präferenzgebiet Winterthur' }, + { criterion: 'Konfidenz', weight: 0.15, score: 28, contribution: 4.2, explanation: 'Future-Signal 50% Wahrscheinlichkeit' }, + { criterion: 'Fläche', weight: 0.30, score: 65, contribution: 19.5, explanation: '3500m² über Maximum 3000m²' }, + ], + tradeoffs: [ + { criterion: 'Kombination', concern: 'Falscher Standort + Future-Signal + zu grosse Fläche', severity: 'HIGH' }, + ], + explainabilitySummary: 'Drei kritische Faktoren: falscher Standort, zu grosse Fläche und Future-Signal. Keine Empfehlung.', + confidenceLevel: 0.36, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Standort', 'Zu grosse Fläche', 'Future-Signal'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T11:20:00Z', + updatedAt: '2025-05-11T11:20:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-007 · Creative Studios AG · MIXED Zürich/Bern · 800–1500m² · max CHF 55/m² + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-036', + propertyId: 'prop-013', + needId: 'need-007', + matchScore: 89, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 92, softFactorScore: 86, confidenceModifier: 0.96, dataQualityModifier: 0.92, totalScore: 89 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 95, contribution: 19, explanation: 'Zürich Altstetten – bevorzugte Lage Zürich' }, + { criterion: 'Nutzungsart', weight: 0.20, score: 100, contribution: 20, explanation: 'MIXED – exakt passend für Creative Studios' }, + { criterion: 'Fläche', weight: 0.20, score: 90, contribution: 18, explanation: '1300m² im Zielkorridor (800–1500m²)' }, + { criterion: 'Budget', weight: 0.15, score: 95, contribution: 14.25, explanation: 'CHF 45/m² klar unter Maximum CHF 55/m²' }, + ], + negativeFactors: [], + tradeoffs: [], + explainabilitySummary: 'Hervorragender Match. Gemischte Fläche in Zürich Altstetten erfüllt alle Anforderungen für Kreativagentur.', + confidenceLevel: 0.90, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T12:00:00Z', + updatedAt: '2025-05-11T12:00:00Z', + }, + + { + id: 'match-037', + propertyId: 'prop-004', + needId: 'need-007', + matchScore: 76, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.SHORTLISTED, + scoreBreakdown: { hardMatchScore: 90, softFactorScore: 74, confidenceModifier: 0.68, dataQualityModifier: 0.55, totalScore: 76 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 92, contribution: 18.4, explanation: 'Zürich Kreis 4 – gute Kreativlage in Zürich' }, + { criterion: 'Nutzungsart', weight: 0.20, score: 100, contribution: 20, explanation: 'MIXED-Objekt passt exakt' }, + { criterion: 'Budget', weight: 0.15, score: 88, contribution: 13.2, explanation: 'CHF 52/m² unter Maximum CHF 55/m²' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 55, contribution: 5.5, explanation: 'Externe Quelle – Daten unvollständig' }, + ], + tradeoffs: [ + { criterion: 'Datenqualität', concern: 'Vertragsdauer und Nebenkosten nicht bekannt', severity: 'MEDIUM' }, + ], + explainabilitySummary: 'Guter Match in Zürich. MIXED-Objekt passt, Budget stimmt. Datenverifikation nötig.', + confidenceLevel: 0.64, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Externe Quelle', 'Fehlende Felder'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T12:05:00Z', + updatedAt: '2025-05-11T12:05:00Z', + }, + + { + id: 'match-038', + propertyId: 'prop-007', + needId: 'need-007', + matchScore: 62, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 76, softFactorScore: 65, confidenceModifier: 0.98, dataQualityModifier: 0.94, totalScore: 62 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 90, contribution: 18, explanation: 'Zürich Oerlikon – bevorzugte Lage' }, + { criterion: 'Budget', weight: 0.15, score: 100, contribution: 15, explanation: 'CHF 36/m² deutlich unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Nutzungsart', weight: 0.20, score: 60, contribution: 12, explanation: 'OFFICE-Fläche, Bedarf ist MIXED – Teileignung' }, + { criterion: 'Fläche', weight: 0.20, score: 60, contribution: 12, explanation: '720m² unter Minimum von 800m²' }, + ], + tradeoffs: [ + { criterion: 'Nutzungsart', concern: 'Bürofläche erlaubt ggf. keine gemischte Nutzung', severity: 'MEDIUM', mitigation: 'Nutzungsbewilligung prüfen' }, + ], + explainabilitySummary: 'Guter Standort und Budget, aber OFFICE-Fläche ist nicht ideal für MIXED-Bedarf und Fläche unter Minimum.', + confidenceLevel: 0.80, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Nutzungsart nicht optimal', 'Fläche unter Minimum'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T12:10:00Z', + updatedAt: '2025-05-11T12:10:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-008 · Berner Produzenten GmbH · PRODUCTION Bern · 2000–4000m² · max CHF 14/m² + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-039', + propertyId: 'prop-011', + needId: 'need-008', + matchScore: 92, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 95, softFactorScore: 89, confidenceModifier: 0.98, dataQualityModifier: 0.95, totalScore: 92 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 100, contribution: 20, explanation: 'Bern Brünnen – exakte Präferenzlage' }, + { criterion: 'Fläche', weight: 0.30, score: 95, contribution: 28.5, explanation: '2800m² optimal im Zielkorridor (2000–4000m²)' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 12/m² unter Maximum CHF 14/m²' }, + ], + negativeFactors: [], + tradeoffs: [], + explainabilitySummary: 'Ausgezeichneter Match. Produktionshalle Bern Brünnen erfüllt alle Anforderungen. Verfügbar sofort, vollständige Daten.', + confidenceLevel: 0.94, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: [], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T13:00:00Z', + updatedAt: '2025-05-11T13:00:00Z', + }, + + { + id: 'match-040', + propertyId: 'prop-027', + needId: 'need-008', + matchScore: 65, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.SHORTLISTED, + scoreBreakdown: { hardMatchScore: 88, softFactorScore: 72, confidenceModifier: 0.48, dataQualityModifier: 0.32, totalScore: 65 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.20, score: 82, contribution: 16.4, explanation: 'Münchenbuchsee – Kanton Bern, nahe Präferenzlage' }, + { criterion: 'Fläche', weight: 0.30, score: 88, contribution: 26.4, explanation: '2200m² im Zielkorridor' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 12/m² unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 28, contribution: 4.2, explanation: 'Future-Signal 52% Wahrscheinlichkeit' }, + { criterion: 'Datenqualität', weight: 0.10, score: 20, contribution: 2, explanation: 'Kritische Felder fehlen' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Auszug noch unbestätigt – Verfügbarkeit August 2026 unsicher', severity: 'HIGH', mitigation: 'Monitoring empfohlen, in 3 Monaten neu evaluieren' }, + ], + explainabilitySummary: 'Guter Standort im Kanton Bern, Fläche und Budget passen. Aber als Future-Signal mit mittlerer Konfidenz auf Watchlist.', + confidenceLevel: 0.40, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Future-Signal', 'Auszug unbestätigt'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T13:05:00Z', + updatedAt: '2025-05-11T13:05:00Z', + }, + + { + id: 'match-041', + propertyId: 'prop-006', + needId: 'need-008', + matchScore: 44, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 67, softFactorScore: 51, confidenceModifier: 0.48, dataQualityModifier: 0.30, totalScore: 44 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.30, score: 85, contribution: 25.5, explanation: '3200m² im Zielkorridor' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.20, score: 60, contribution: 12, explanation: 'Reinach BL – anderer Kanton, nicht Bern' }, + { criterion: 'Konfidenz', weight: 0.15, score: 25, contribution: 3.75, explanation: 'Future-Signal 48% Wahrscheinlichkeit' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Reinach BL liegt im Kanton BL, nicht im Kanton BE', severity: 'MEDIUM' }, + { criterion: 'Verfügbarkeit', concern: 'Future-Signal – kein bestätigtes Objekt', severity: 'HIGH' }, + ], + explainabilitySummary: 'Fläche passt, aber Standort (Kanton BL statt BE) und Future-Signal sind Risikofaktoren. Schwacher Match.', + confidenceLevel: 0.36, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Anderer Kanton', 'Future-Signal'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T13:10:00Z', + updatedAt: '2025-05-11T13:10:00Z', + }, + + { + id: 'match-042', + propertyId: 'prop-019', + needId: 'need-008', + matchScore: 47, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 61, softFactorScore: 47, confidenceModifier: 0.68, dataQualityModifier: 0.56, totalScore: 47 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 13/m² unter Maximum CHF 14/m²' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.20, score: 35, contribution: 7, explanation: 'Basel ist nicht Bern – andere Stadt, anderer Kanton' }, + { criterion: 'Fläche', weight: 0.30, score: 62, contribution: 18.6, explanation: '1900m² leicht unter Minimum 2000m²' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Basel BS liegt 100km von Bern entfernt', severity: 'HIGH' }, + ], + explainabilitySummary: 'Falscher Standort und Fläche leicht unter Minimum. Budget stimmt, reicht aber nicht aus. Schwacher Match.', + confidenceLevel: 0.54, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Falscher Standort', 'Fläche unter Minimum'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T13:15:00Z', + updatedAt: '2025-05-11T13:15:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-009 · Geneva Commerce SA · RETAIL Genf · 150–400m² · max CHF 120/m² + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-043', + propertyId: 'prop-021', + needId: 'need-009', + matchScore: 83, + matchStrength: MatchStrength.STRONG, + status: MatchStatus.SHORTLISTED, + scoreBreakdown: { hardMatchScore: 97, softFactorScore: 81, confidenceModifier: 0.70, dataQualityModifier: 0.59, totalScore: 83 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.35, score: 100, contribution: 35, explanation: 'Genf Rue du Rhône – exakter Standortwunsch' }, + { criterion: 'Budget', weight: 0.15, score: 92, contribution: 13.8, explanation: 'CHF 112/m² unter Maximum CHF 120/m²' }, + { criterion: 'Prestige', weight: 0.15, score: 94, contribution: 14.1, explanation: 'Prestige 94 – erfüllt hohe Anforderung 88' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 59, contribution: 5.9, explanation: 'Externe Quelle – Konditionen nicht bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Datenqualität', concern: 'Mietpreis und Vertragsdauer aus externer Quelle', severity: 'MEDIUM', mitigation: 'Direktkontakt mit Anbieter empfohlen' }, + ], + explainabilitySummary: 'Bester verfügbarer Match für Genf. Standort, Prestige und Budget stimmen. Datenverifikation ausstehend.', + confidenceLevel: 0.66, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Konditionen nicht bestätigt'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T14:00:00Z', + updatedAt: '2025-05-11T14:00:00Z', + }, + + { + id: 'match-044', + propertyId: 'prop-010', + needId: 'need-009', + matchScore: 45, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 59, softFactorScore: 46, confidenceModifier: 0.99, dataQualityModifier: 0.96, totalScore: 45 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.15, score: 90, contribution: 13.5, explanation: '285m² im Zielkorridor' }, + { criterion: 'Budget', weight: 0.15, score: 100, contribution: 15, explanation: 'CHF 88/m² deutlich unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.35, score: 35, contribution: 12.25, explanation: 'Zürich ist nicht Genf – andere Stadt, 280km' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Zürich und Genf haben komplett verschiedene Kundenpotenziale', severity: 'HIGH' }, + ], + explainabilitySummary: 'Sehr gute Qualität in Zürich, aber falscher Standort für einen Genfer Retailer. Nicht geeignet.', + confidenceLevel: 0.86, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Falscher Standort'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T14:05:00Z', + updatedAt: '2025-05-11T14:05:00Z', + }, + + { + id: 'match-045', + propertyId: 'prop-017', + needId: 'need-009', + matchScore: 38, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 52, softFactorScore: 40, confidenceModifier: 0.71, dataQualityModifier: 0.61, totalScore: 38 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.15, score: 100, contribution: 15, explanation: 'CHF 95/m² unter Maximum CHF 120/m²' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.35, score: 35, contribution: 12.25, explanation: 'Zürich statt Genf – komplett andere Stadt' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Zürich ist geografisch und kulturell nicht der gesuchte Genfer Markt', severity: 'HIGH' }, + ], + explainabilitySummary: 'Budget und Qualität ok, aber Zürich ist kein Ersatz für Genf. Standort nicht erfüllt.', + confidenceLevel: 0.62, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Falscher Standort'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T14:10:00Z', + updatedAt: '2025-05-11T14:10:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // need-010 · St.Galler Büros AG · OFFICE St.Gallen · 400–800m² · max CHF 35/m² + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-046', + propertyId: 'prop-022', + needId: 'need-010', + matchScore: 79, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.APPROVED, + scoreBreakdown: { hardMatchScore: 93, softFactorScore: 77, confidenceModifier: 0.69, dataQualityModifier: 0.58, totalScore: 79 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.28, score: 100, contribution: 28, explanation: 'St. Gallen Centrum – exakter Standortwunsch' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 28/m² deutlich unter Maximum CHF 35/m²' }, + { criterion: 'Fläche', weight: 0.22, score: 90, contribution: 19.8, explanation: '700m² im Zielkorridor (400–800m²)' }, + ], + negativeFactors: [ + { criterion: 'Datenqualität', weight: 0.10, score: 58, contribution: 5.8, explanation: 'Externe Quelle – Ausbauqualität nicht bestätigt' }, + ], + tradeoffs: [ + { criterion: 'Datenqualität', concern: 'Ausbauqualität aus externer Quelle nicht verifiziert', severity: 'MEDIUM', mitigation: 'Vor-Ort-Besichtigung empfohlen' }, + ], + explainabilitySummary: 'Guter Match in St. Gallen Centrum. Alle Hauptkriterien erfüllt. Datenverifikation ausstehend.', + confidenceLevel: 0.72, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Externe Quelle', 'Ausbauqualität unklar'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T15:00:00Z', + updatedAt: '2025-05-11T15:00:00Z', + }, + + { + id: 'match-047', + propertyId: 'prop-030', + needId: 'need-010', + matchScore: 58, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 81, softFactorScore: 65, confidenceModifier: 0.55, dataQualityModifier: 0.36, totalScore: 58 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.28, score: 100, contribution: 28, explanation: 'St. Gallen Riethüsli – Präferenzstadt' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 27/m² deutlich unter Maximum' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 30, contribution: 4.5, explanation: 'Future-Signal 55% Wahrscheinlichkeit' }, + { criterion: 'Datenqualität', weight: 0.10, score: 22, contribution: 2.2, explanation: 'Kritische Felder fehlen' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Future-Signal – kein bestätigter Auszug', severity: 'HIGH', mitigation: 'Auf Watchlist setzen' }, + ], + explainabilitySummary: 'Bester Standort St. Gallen, Budget sehr gut. Als Future-Signal mit mittlerer Konfidenz Watchlist-Kandidat.', + confidenceLevel: 0.44, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Future-Signal', 'Daten unvollständig'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T15:05:00Z', + updatedAt: '2025-05-11T15:05:00Z', + }, + + { + id: 'match-048', + propertyId: 'prop-007', + needId: 'need-010', + matchScore: 53, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 68, softFactorScore: 56, confidenceModifier: 0.98, dataQualityModifier: 0.94, totalScore: 53 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.22, score: 90, contribution: 19.8, explanation: '720m² im Zielkorridor' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.28, score: 35, contribution: 9.8, explanation: 'Zürich Oerlikon ist nicht St. Gallen – andere Stadt, anderer Kanton' }, + { criterion: 'Budget', weight: 0.20, score: 72, contribution: 14.4, explanation: 'CHF 36/m² liegt 3% über Maximum CHF 35/m²' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Zürich ist nicht im Präferenzgebiet Ostschweiz', severity: 'HIGH' }, + ], + explainabilitySummary: 'Falscher Standort und Budget leicht über Maximum. Zürich ist keine Alternative für St. Gallen.', + confidenceLevel: 0.86, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Standort ausserhalb Ostschweiz', 'Budget knapp über Maximum'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T15:10:00Z', + updatedAt: '2025-05-11T15:10:00Z', + }, + + { + id: 'match-049', + propertyId: 'prop-015', + needId: 'need-010', + matchScore: 46, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 60, softFactorScore: 48, confidenceModifier: 0.70, dataQualityModifier: 0.60, totalScore: 46 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.22, score: 88, contribution: 19.36, explanation: '650m² im Zielkorridor' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.28, score: 35, contribution: 9.8, explanation: 'Luzern statt St. Gallen – andere Stadt, anderer Kanton' }, + { criterion: 'Budget', weight: 0.20, score: 74, contribution: 14.8, explanation: 'CHF 38/m² liegt 8% über Maximum CHF 35/m²' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Luzern ist nicht Ostschweiz / St. Gallen Region', severity: 'HIGH' }, + { criterion: 'Budget', concern: 'CHF 3/m² über Maximum', severity: 'MEDIUM' }, + ], + explainabilitySummary: 'Fläche ok, aber Luzern entspricht nicht der St. Gallen-Region und Budget leicht überschritten.', + confidenceLevel: 0.58, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort nicht kompatibel', 'Budget leicht über Maximum'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T15:15:00Z', + updatedAt: '2025-05-11T15:15:00Z', + }, + + { + id: 'match-050', + propertyId: 'prop-018', + needId: 'need-010', + matchScore: 38, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 52, softFactorScore: 40, confidenceModifier: 0.68, dataQualityModifier: 0.57, totalScore: 38 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.20, score: 100, contribution: 20, explanation: 'CHF 31/m² deutlich unter Maximum CHF 35/m²' }, + { criterion: 'Fläche', weight: 0.22, score: 85, contribution: 18.7, explanation: '780m² nahe Zielobergrenze' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.28, score: 35, contribution: 9.8, explanation: 'Bern ist nicht St. Gallen – weit ausserhalb Präferenzgebiet' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Bern liegt 180km von St. Gallen entfernt', severity: 'HIGH' }, + ], + explainabilitySummary: 'Budget sehr gut, aber Bern entspricht nicht dem Bedarf Ostschweiz. Kein sinnvoller Match.', + confidenceLevel: 0.54, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Falscher Standort'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T15:20:00Z', + updatedAt: '2025-05-11T15:20:00Z', + }, + + { + id: 'match-051', + propertyId: 'prop-008', + needId: 'need-010', + matchScore: 42, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 56, softFactorScore: 44, confidenceModifier: 0.97, dataQualityModifier: 0.93, totalScore: 42 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 32/m² unter Maximum CHF 35/m²' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.28, score: 35, contribution: 9.8, explanation: 'Basel ist nicht St. Gallen – anderer Kanton' }, + { criterion: 'Fläche', weight: 0.22, score: 65, contribution: 14.3, explanation: '900m² überschreitet Maximum 800m² deutlich' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Basel liegt in Nordwestschweiz, nicht in Ostschweiz', severity: 'HIGH' }, + ], + explainabilitySummary: 'Gutes Portfolio-Objekt in Basel, aber falscher Standort und Fläche zu gross. Nicht geeignet.', + confidenceLevel: 0.84, + riskLevel: RiskLevel.LOW, + uncertaintyIndicators: ['Falscher Standort', 'Fläche zu gross'], + organizationId: 'org-wincasa', + createdAt: '2025-05-11T15:25:00Z', + updatedAt: '2025-05-11T15:25:00Z', + }, + + // ─────────────────────────────────────────────────────────────────────────── + // Cross-need additional matches + // ─────────────────────────────────────────────────────────────────────────── + + { + id: 'match-052', + propertyId: 'prop-020', + needId: 'need-003', + matchScore: 41, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 55, softFactorScore: 43, confidenceModifier: 0.70, dataQualityModifier: 0.60, totalScore: 41 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.20, score: 85, contribution: 17, explanation: '820m² nahe am Zielkorridor (500–800m²)' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: 'Zug ist nicht Basel – anderer Kanton' }, + { criterion: 'Budget', weight: 0.20, score: 72, contribution: 14.4, explanation: 'CHF 44/m² liegt 10% über Maximum CHF 40/m²' }, + ], + tradeoffs: [ + { criterion: 'Kombination', concern: 'Falscher Standort und Budget überschritten', severity: 'HIGH' }, + ], + explainabilitySummary: 'Falscher Standort (Zug statt Basel) und Budget überschritten. Kein Match empfehlenswert.', + confidenceLevel: 0.58, + riskLevel: RiskLevel.MEDIUM, + uncertaintyIndicators: ['Standort nicht kompatibel', 'Budget über Maximum'], + organizationId: 'org-wincasa', + createdAt: '2025-05-12T08:00:00Z', + updatedAt: '2025-05-12T08:00:00Z', + }, + + { + id: 'match-053', + propertyId: 'prop-023', + needId: 'need-005', + matchScore: 65, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 88, softFactorScore: 72, confidenceModifier: 0.54, dataQualityModifier: 0.36, totalScore: 65 }, + positiveFactors: [ + { criterion: 'Standort', weight: 0.25, score: 88, contribution: 22, explanation: 'Zürich-Nord – bevorzugte Stadt Zürich' }, + { criterion: 'Budget', weight: 0.20, score: 95, contribution: 19, explanation: 'CHF 38/m² unter Maximum CHF 48/m²' }, + ], + negativeFactors: [ + { criterion: 'Konfidenz', weight: 0.15, score: 30, contribution: 4.5, explanation: 'Future-Signal 54% Wahrscheinlichkeit' }, + { criterion: 'Fläche', weight: 0.20, score: 70, contribution: 14, explanation: '850m² leicht über Maximum 700m²' }, + ], + tradeoffs: [ + { criterion: 'Verfügbarkeit', concern: 'Future-Signal – kein bestätigtes Objekt', severity: 'HIGH' }, + ], + explainabilitySummary: 'Gute Zürich-Lage und Budget passend. Als Future-Signal mit mittlerer Konfidenz und leicht zu grosser Fläche beobachten.', + confidenceLevel: 0.46, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Future-Signal', 'Fläche leicht über Maximum'], + organizationId: 'org-wincasa', + createdAt: '2025-05-12T08:05:00Z', + updatedAt: '2025-05-12T08:05:00Z', + }, + + { + id: 'match-054', + propertyId: 'prop-028', + needId: 'need-010', + matchScore: 37, + matchStrength: MatchStrength.WEAK, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 60, softFactorScore: 50, confidenceModifier: 0.53, dataQualityModifier: 0.35, totalScore: 37 }, + positiveFactors: [ + { criterion: 'Fläche', weight: 0.22, score: 88, contribution: 19.36, explanation: '520m² im Zielkorridor' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.28, score: 35, contribution: 9.8, explanation: 'Genf ist nicht St. Gallen – 600km Entfernung' }, + { criterion: 'Budget', weight: 0.20, score: 72, contribution: 14.4, explanation: 'CHF 36/m² über Maximum CHF 35/m² knapp' }, + { criterion: 'Konfidenz', weight: 0.15, score: 28, contribution: 4.2, explanation: 'Future-Signal' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Genf ist nicht kompatibel mit St. Gallen Bedarf', severity: 'HIGH' }, + ], + explainabilitySummary: 'Falscher Standort, Budget knapp über Maximum und Future-Signal. Keine Empfehlung.', + confidenceLevel: 0.38, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Falscher Standort', 'Future-Signal'], + organizationId: 'org-wincasa', + createdAt: '2025-05-12T08:10:00Z', + updatedAt: '2025-05-12T08:10:00Z', + }, + + { + id: 'match-055', + propertyId: 'prop-026', + needId: 'need-001', + matchScore: 56, + matchStrength: MatchStrength.MODERATE, + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { hardMatchScore: 79, softFactorScore: 63, confidenceModifier: 0.52, dataQualityModifier: 0.34, totalScore: 56 }, + positiveFactors: [ + { criterion: 'Budget', weight: 0.15, score: 92, contribution: 13.8, explanation: 'CHF 43/m² unter Maximum CHF 45/m²' }, + { criterion: 'Fläche', weight: 0.20, score: 85, contribution: 17, explanation: '580m² im Zielkorridor (600–1000m²), knapp unter Minimum' }, + ], + negativeFactors: [ + { criterion: 'Standort', weight: 0.20, score: 60, contribution: 12, explanation: 'Zug liegt nicht in Zürich, aber gleiche Wirtschaftsregion' }, + { criterion: 'Konfidenz', weight: 0.15, score: 30, contribution: 4.5, explanation: 'Future-Signal 52% Wahrscheinlichkeit' }, + ], + tradeoffs: [ + { criterion: 'Standort', concern: 'Zug ist nicht Zürich – Pendeldistanz 30 Min.', severity: 'MEDIUM' }, + { criterion: 'Verfügbarkeit', concern: 'Future-Signal – erst ab April 2026 möglicherweise verfügbar', severity: 'HIGH' }, + ], + explainabilitySummary: 'Budget und Fläche passen. Zug ist eine Alternativlage zur bevorzugten Lage Zürich. Future-Signal mit mittlerer Konfidenz.', + confidenceLevel: 0.42, + riskLevel: RiskLevel.HIGH, + uncertaintyIndicators: ['Standort ausserhalb Präferenz', 'Future-Signal'], + organizationId: 'org-wincasa', + createdAt: '2025-05-12T08:15:00Z', + updatedAt: '2025-05-12T08:15:00Z', + }, ] diff --git a/src/mock-data/needs.ts b/src/mock-data/needs.ts index 00b0f9b..f3a10dc 100644 --- a/src/mock-data/needs.ts +++ b/src/mock-data/needs.ts @@ -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, 500–800m², 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, 250–450m², 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, 350–600m², 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, 900–1400m², 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, 200–350m², 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, 450–750m², max. CHF 32/m², Bezug Anfang 2026.', + organizationId: 'org-wincasa', + createdAt: '2025-04-10T08:00:00Z', + updatedAt: '2025-05-06T13:00:00Z', + }, ] diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts index 6b32ab2..bf49fc2 100644 --- a/src/mock-data/properties.ts +++ b/src/mock-data/properties.ts @@ -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', + }, ] diff --git a/src/pages/auth/LoginScreen.tsx b/src/pages/auth/LoginScreen.tsx index 1b58155..c01eb4f 100644 --- a/src/pages/auth/LoginScreen.tsx +++ b/src/pages/auth/LoginScreen.tsx @@ -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' }, ] diff --git a/src/pages/demand/AISearch.tsx b/src/pages/demand/AISearch.tsx index de56f27..0de7f3f 100644 --- a/src/pages/demand/AISearch.tsx +++ b/src/pages/demand/AISearch.tsx @@ -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 = { + 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} m²`) + 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, 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(NeedBuilderStep.IDLE) + const [intent, setIntent] = useState('search') const [inputText, setInputText] = useState('') + const [isAutoGen, setIsAutoGen] = useState(false) + const [criteria, setCriteria] = useState({}) const [parseResult, setParseResult] = useState(null) const [editedCriteria, setEditedCriteria] = useState(null) - const [answers, setAnswers] = useState>({}) const [weights, setWeights] = useState>(weightingService.getDefaultWeights()) + const [weightingKey, setWeightingKey] = useState(0) const [needTitle, setNeedTitle] = useState('') const [error, setError] = useState(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) - 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) + 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 = 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 ( - + {/* Header */} + + Flächensuche + + Sprechen, schreiben oder Felder ausfüllen — dann sofort suchen oder als Suchprofil speichern + + + - {/* Step: Input */} - {step === NeedBuilderStep.IDLE && ( - - )} + {/* ── IDLE: full form ── */} + {(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && ( + - {/* Step: Parsing */} - {step === NeedBuilderStep.PARSING && ( - - - KI analysiert Ihren Bedarf… - Kriterien werden extrahiert und bewertet - - )} + - {/* Step: Criteria Review + Follow-up */} - {isReview && parseResult && editedCriteria && ( - - - - setStep(NeedBuilderStep.WEIGHTING_REVIEW)} - onReparse={handleReparse} + + + - - - )} - {/* Step: Weighting */} - {step === NeedBuilderStep.WEIGHTING_REVIEW && ( - - - - + {/* Action bar */} + + + + + + + + Jetzt suchen liefert sofortige Ergebnisse.{' '} + Als Suchprofil speichern legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird — auch in Zukunft. + )} - {/* Step: Preview + Save */} + {/* ── Preview + Save as Profile ── */} {isSaveStep && parseResult && editedCriteria && ( - + + + Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung. + )} - {/* Step: Error */} + {/* ── Error ── */} {step === NeedBuilderStep.ERROR && ( )} diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index 618289c..adc928b 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -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() { )} + diff --git a/src/pages/ops/MarketIntelligence.tsx b/src/pages/ops/MarketIntelligence.tsx index 825abf1..a81c572 100644 --- a/src/pages/ops/MarketIntelligence.tsx +++ b/src/pages/ops/MarketIntelligence.tsx @@ -37,7 +37,7 @@ export default function MarketIntelligence() { onFiltersChange={setFilters} /> - + diff --git a/src/pages/ops/SignalPipeline.tsx b/src/pages/ops/SignalPipeline.tsx index 3a3395b..f524a3e 100644 --- a/src/pages/ops/SignalPipeline.tsx +++ b/src/pages/ops/SignalPipeline.tsx @@ -37,7 +37,7 @@ export default function SignalPipeline() { onFiltersChange={setFilters} /> - + {selectedSignal ? : diff --git a/src/pages/ops/SourceMonitoring.tsx b/src/pages/ops/SourceMonitoring.tsx index 5a37635..ed0f42e 100644 --- a/src/pages/ops/SourceMonitoring.tsx +++ b/src/pages/ops/SourceMonitoring.tsx @@ -40,7 +40,7 @@ export default function SourceMonitoring() { /> {/* Right: Detail panel */} - + diff --git a/src/pages/supply/MatchCenter.tsx b/src/pages/supply/MatchCenter.tsx index 3f5793f..2f4b843 100644 --- a/src/pages/supply/MatchCenter.tsx +++ b/src/pages/supply/MatchCenter.tsx @@ -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 (60–79)' }, + { 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(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 ( - - {/* Left: Properties */} - - - Objekte - {matches.length} Matches gesamt - - - + - {/* Center: Match Briefing */} - - - Match-Briefing + {/* Header */} + + + Match Center + Automatisch berechnete Matches + + + + + {pendingCount > 0 && ( + + )} - - {/* Right: Needs */} - - - Bedarfe + {/* Filter bar */} + + + + + {filtered.length} von {matches.length} Matches + + + + {/* Match list */} + + {isLoading ? ( + + ) : filtered.length === 0 ? ( + + Keine Matches für diese Filter. + + ) : ( + + {filtered.map(match => ( + handleSelectMatch(match)} + onApprove={() => approveMatch.mutate(match.id)} + /> + ))} + + )} + + + {/* Detail Drawer */} + + + + Match-Briefing + + + + + + + - - + ) } diff --git a/src/provider/MockupMatchProvider.ts b/src/provider/MockupMatchProvider.ts index 58e61e6..ce83f21 100644 --- a/src/provider/MockupMatchProvider.ts +++ b/src/provider/MockupMatchProvider.ts @@ -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) { diff --git a/src/provider/MockupNeedProvider.ts b/src/provider/MockupNeedProvider.ts index 5df3871..552205a 100644 --- a/src/provider/MockupNeedProvider.ts +++ b/src/provider/MockupNeedProvider.ts @@ -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 = { + 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) { diff --git a/src/provider/MockupPropertyProvider.ts b/src/provider/MockupPropertyProvider.ts index 6d6d434..b3af131 100644 --- a/src/provider/MockupPropertyProvider.ts +++ b/src/provider/MockupPropertyProvider.ts @@ -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) { diff --git a/src/services/authService.ts b/src/services/authService.ts index 986b0d2..0dc9a70 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -30,7 +30,7 @@ const DEMO_USERS: Record = { 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',