feat: role-aware UX, market intelligence, score fix & layout scroll
- Role-based workspace access: Property Manager gets Supply+Demand, Operations restricted to Super Admin + Reviewer only - Root redirect routes each persona to their first workspace - Match Center: replaced 3-panel with auto-sorted flat list + drawer - AI Search: unified form with dual-action (Jetzt suchen / Als Suchprofil speichern) - CompareTray hidden on non-Demand routes; Vergleichen removed from Supply - LocationIntelligencePanel: city KPIs, rent trends, soft factors, comparables - NegotiationInsightsPanel: price positioning, active demand, selling arguments - scoreCalculator: cap hardMatchScore and softFactorScore to max 100 - Layout: add display:flex to overflow:hidden wrappers so inner scroll works (MatchCenter drawer, MarketIntelligence, SourceMonitoring, SignalPipeline) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+14
-1
@@ -4,6 +4,19 @@ import { LoadingPage, AppErrorBoundary } from './components/ui'
|
||||
import { AppShell } from './components/layout'
|
||||
import { ProtectedRoute } from './components/auth'
|
||||
import { WorkspaceType } from './domain/enums'
|
||||
import { useSessionStore } from './stores/sessionStore'
|
||||
|
||||
const WORKSPACE_HOME: Record<string, string> = {
|
||||
[WorkspaceType.SUPPLY]: '/supply/dashboard',
|
||||
[WorkspaceType.DEMAND]: '/demand/ai-search',
|
||||
[WorkspaceType.OPERATIONS]: '/ops/review-queue',
|
||||
}
|
||||
|
||||
function RoleRedirect() {
|
||||
const { currentUser } = useSessionStore()
|
||||
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
|
||||
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
|
||||
}
|
||||
|
||||
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
|
||||
|
||||
@@ -38,7 +51,7 @@ function App() {
|
||||
{/* Protected: auth check only */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<Navigate to="/supply/dashboard" replace />} />
|
||||
<Route path="/" element={<RoleRedirect />} />
|
||||
|
||||
{/* Supply Workspace */}
|
||||
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
|
||||
|
||||
@@ -111,74 +111,19 @@ export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── Soft Factors ────────────────────────────────────────────────── */}
|
||||
<Section title="Soft Factors">
|
||||
<ExtractedFieldRow
|
||||
label="Prestige-Anforderung"
|
||||
value={c.prestigeImportance ?? ''}
|
||||
confidence={conf.prestigeImportance ?? 0.2}
|
||||
missing={!c.prestigeImportance}
|
||||
onEdit={v => set({ ...c, prestigeImportance: (v.toUpperCase() as ParsedNeedCriteria['prestigeImportance']) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Flexibilitätsbedarf"
|
||||
value={c.flexibilityNeed ?? ''}
|
||||
confidence={0.5}
|
||||
missing={!c.flexibilityNeed}
|
||||
onEdit={v => set({ ...c, flexibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['flexibilityNeed']) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Expansionspotenzial"
|
||||
value={c.expansionPotential === true ? 'Ja' : c.expansionPotential === false ? 'Nein' : ''}
|
||||
confidence={0.5}
|
||||
missing={c.expansionPotential === undefined}
|
||||
onEdit={v => set({ ...c, expansionPotential: v.toLowerCase().startsWith('j') })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Parkplatzbedarf"
|
||||
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
|
||||
confidence={conf.parkingNeed ?? 0.3}
|
||||
missing={c.parkingNeed === undefined}
|
||||
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Sichtbarkeit"
|
||||
value={c.visibilityNeed ?? ''}
|
||||
confidence={0.4}
|
||||
missing={!c.visibilityNeed}
|
||||
onEdit={v => set({ ...c, visibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['visibilityNeed']) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Passantenfrequenz"
|
||||
value={c.footfallNeed ?? ''}
|
||||
confidence={0.4}
|
||||
missing={!c.footfallNeed}
|
||||
onEdit={v => set({ ...c, footfallNeed: (v.toUpperCase() as ParsedNeedCriteria['footfallNeed']) })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── Must-haves ──────────────────────────────────────────────────── */}
|
||||
<Section title="Must-haves & Infrastruktur">
|
||||
<Section title="Must-haves">
|
||||
<ExtractedFieldRow
|
||||
label="Must-have Kriterien (Komma-getrennt)"
|
||||
label="Pflichtkriterien (Komma-getrennt)"
|
||||
value={c.mustHaveCriteria?.join(', ') ?? ''}
|
||||
confidence={conf.mustHaveCriteria ?? 0.1}
|
||||
missing={!c.mustHaveCriteria?.length}
|
||||
onEdit={v => set({ ...c, mustHaveCriteria: parseList(v) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Infrastrukturanforderungen (Komma-getrennt)"
|
||||
value={c.infrastructureRequirements?.join(', ') ?? ''}
|
||||
confidence={0.5}
|
||||
missing={!c.infrastructureRequirements?.length}
|
||||
onEdit={v => set({ ...c, infrastructureRequirements: parseList(v) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Barrierefreiheit (Komma-getrennt)"
|
||||
value={c.accessibilityRequirements?.join(', ') ?? ''}
|
||||
confidence={0.5}
|
||||
missing={!c.accessibilityRequirements?.length}
|
||||
onEdit={v => set({ ...c, accessibilityRequirements: parseList(v) })}
|
||||
label="Parkplatzbedarf"
|
||||
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
|
||||
missing={c.parkingNeed === undefined}
|
||||
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, IconButton, TextField, Typography } from '@mui/material'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string
|
||||
confidence: number
|
||||
confidence?: number
|
||||
missing?: boolean
|
||||
onEdit?: (value: string) => void
|
||||
}
|
||||
|
||||
export function ExtractedFieldRow({ label, value, confidence, missing = false, onEdit }: Props) {
|
||||
export function ExtractedFieldRow({ label, value, missing = false, onEdit }: Props) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
|
||||
@@ -60,19 +59,16 @@ export function ExtractedFieldRow({ label, value, confidence, missing = false, o
|
||||
{value || '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||
<ConfidenceFieldBadge confidence={confidence} />
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
size="small"
|
||||
className="edit-btn"
|
||||
sx={{ visibility: 'hidden', p: 0.25 }}
|
||||
onClick={() => { setEditValue(value); setEditing(true) }}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
size="small"
|
||||
className="edit-btn"
|
||||
sx={{ visibility: 'hidden', p: 0.25, flexShrink: 0 }}
|
||||
onClick={() => { setEditValue(value); setEditing(true) }}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ interface Props {
|
||||
step: NeedBuilderStep
|
||||
}
|
||||
|
||||
const STEPS = ['Bedarf eingeben', 'Kriterien prüfen', 'Gewichtung', 'Speichern']
|
||||
const STEPS = ['Suchkriterien & Gewichtung', 'Vorschau & Speichern']
|
||||
|
||||
function toStepIndex(step: NeedBuilderStep): number {
|
||||
if (step === S.IDLE || step === S.PARSING) return 0
|
||||
if (step === S.PARSED_REQUIRES_REVIEW || step === S.CLARIFICATION_REQUIRED) return 1
|
||||
if (step === S.WEIGHTING_REVIEW) return 2
|
||||
return 3
|
||||
if (
|
||||
step === S.IDLE ||
|
||||
step === S.PARSING ||
|
||||
step === S.PARSED_REQUIRES_REVIEW ||
|
||||
step === S.CLARIFICATION_REQUIRED
|
||||
) return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
export function NeedBuilderProgress({ step }: Props) {
|
||||
|
||||
@@ -1,108 +1,155 @@
|
||||
import { Box, Button, Card, Chip, TextField, Typography, Stack } from '@mui/material'
|
||||
import { Sparkles, RotateCcw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit: () => void
|
||||
criteria: ParsedNeedCriteria
|
||||
onCriteriaChange: (c: ParsedNeedCriteria) => void
|
||||
}
|
||||
|
||||
const EXAMPLES: Record<string, string> = {
|
||||
Büro: 'Wir suchen 800–1.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur.',
|
||||
Retail: 'Suche Ladenfläche 200–400 m² in Bern Innenstadt, hohe Passantenfrequenz, Erdgeschoss, max. CHF 150/m². Sofort verfügbar.',
|
||||
Logistik: 'Lagerhalle 2.000–3.000 m² Basel Umgebung, Tiefgarage oder Aussenrampe, 12 m Deckenhöhe, sofort. Budget CHF 15/m².',
|
||||
}
|
||||
|
||||
const ASSET_TYPE_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
const ASSET_OPTIONS = [
|
||||
{ label: 'Büro', value: AssetType.OFFICE },
|
||||
{ label: 'Retail', value: AssetType.RETAIL },
|
||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||
]
|
||||
|
||||
export function NeedInput({ value, onChange, onSubmit }: Props) {
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
<Card sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Flächenbedarf beschreiben
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Beschreiben Sie Typ, Fläche, Standort, Budget und Verfügbarkeit — die KI extrahiert die Kriterien automatisch.
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
<TextField
|
||||
multiline
|
||||
rows={5}
|
||||
fullWidth
|
||||
placeholder="Beispiel: Wir suchen 800–1.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'right', mb: 2 }}>
|
||||
{value.length}/2000
|
||||
</Typography>
|
||||
export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
const [locationDraft, setLocationDraft] = useState('')
|
||||
const [mustHaveDraft, setMustHaveDraft] = useState('')
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={value.length < 20}
|
||||
onClick={onSubmit}
|
||||
endIcon={<Sparkles size={16} />}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
KI-Analyse starten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => onChange('')}
|
||||
startIcon={<RotateCcw size={16} />}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
function addLocations(raw: string) {
|
||||
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
|
||||
if (!tokens.length) return
|
||||
set({ ...c, preferredLocations: [...new Set([...(c.preferredLocations ?? []), ...tokens])] })
|
||||
setLocationDraft('')
|
||||
}
|
||||
|
||||
{/* Asset-Type Quick Select */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Schnellauswahl Nutzungstyp:
|
||||
function addMustHaves(raw: string) {
|
||||
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
|
||||
if (!tokens.length) return
|
||||
set({ ...c, mustHaveCriteria: [...new Set([...(c.mustHaveCriteria ?? []), ...tokens])] })
|
||||
setMustHaveDraft('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Card elevation={0} sx={{ p: 3, border: '1px solid #e2e8f0', height: '100%' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>Kriterien verfeinern</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2.5 }}>
|
||||
Ergänzen oder korrigieren Sie die extrahierten Felder.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
|
||||
{ASSET_TYPE_OPTIONS.map(opt => (
|
||||
|
||||
{/* Asset Type */}
|
||||
<FieldLabel>Nutzungstyp</FieldLabel>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
|
||||
{ASSET_OPTIONS.map(opt => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
variant={c.assetType === opt.value ? 'filled' : 'outlined'}
|
||||
clickable
|
||||
onClick={() => onChange(EXAMPLES[opt.label] ?? value)}
|
||||
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
|
||||
sx={c.assetType === opt.value
|
||||
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
|
||||
: {}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Example prompts */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Beispiele:
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{Object.entries(EXAMPLES).map(([label, text]) => (
|
||||
<Chip
|
||||
key={label}
|
||||
label={`${label}: ${text.slice(0, 60)}…`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => onChange(text)}
|
||||
sx={{ height: 'auto', py: 0.5, '& .MuiChip-label': { whiteSpace: 'normal' } }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
{/* Area */}
|
||||
<FieldLabel>Fläche (m²)</FieldLabel>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
|
||||
<TextField
|
||||
size="small" type="number" placeholder="Min"
|
||||
value={c.areaRange?.min || ''}
|
||||
onChange={e => set({ ...c, areaRange: { min: parseInt(e.target.value) || 0, max: c.areaRange?.max ?? 0 } })}
|
||||
sx={{ width: 100 }}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">–</Typography>
|
||||
<TextField
|
||||
size="small" type="number" placeholder="Max"
|
||||
value={c.areaRange?.max || ''}
|
||||
onChange={e => set({ ...c, areaRange: { min: c.areaRange?.min ?? 0, max: parseInt(e.target.value) || 0 } })}
|
||||
sx={{ width: 100 }}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">m²</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Location */}
|
||||
<FieldLabel>Standort</FieldLabel>
|
||||
<TextField
|
||||
size="small" fullWidth
|
||||
placeholder="Stadt oder Region — Enter zum Hinzufügen"
|
||||
value={locationDraft}
|
||||
onChange={e => setLocationDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && locationDraft.trim()) addLocations(locationDraft) }}
|
||||
onBlur={() => { if (locationDraft.trim()) addLocations(locationDraft) }}
|
||||
sx={{ mb: 0.75 }}
|
||||
/>
|
||||
{(c.preferredLocations?.length ?? 0) > 0 ? (
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
|
||||
{c.preferredLocations!.map(loc => (
|
||||
<Chip key={loc} label={loc} size="small"
|
||||
onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : <Box sx={{ mb: 2.5 }} />}
|
||||
|
||||
{/* Budget */}
|
||||
<FieldLabel>Budget (max CHF/m²)</FieldLabel>
|
||||
<TextField
|
||||
size="small" type="number" placeholder="z.B. 45"
|
||||
value={c.budgetRange?.maxPerSqm || ''}
|
||||
onChange={e => set({ ...c, budgetRange: { maxPerSqm: parseInt(e.target.value) || 0, currency: 'CHF' } })}
|
||||
sx={{ width: 160, mb: 2.5 }}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
|
||||
{/* Timing */}
|
||||
<FieldLabel>Verfügbar ab</FieldLabel>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="z.B. Q3 2025 oder 01.09.2025"
|
||||
value={c.timing?.earliestMoveIn ?? ''}
|
||||
onChange={e => set({ ...c, timing: { earliestMoveIn: e.target.value, latestMoveIn: e.target.value, flexibleTiming: true } })}
|
||||
sx={{ width: 220, mb: 2.5 }}
|
||||
/>
|
||||
|
||||
{/* Must-haves */}
|
||||
<FieldLabel>Must-haves</FieldLabel>
|
||||
<TextField
|
||||
size="small" fullWidth
|
||||
placeholder="z.B. ÖV-Anbindung, Parkplätze — Enter zum Hinzufügen"
|
||||
value={mustHaveDraft}
|
||||
onChange={e => setMustHaveDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
|
||||
onBlur={() => { if (mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
|
||||
sx={{ mb: 0.75 }}
|
||||
/>
|
||||
{(c.mustHaveCriteria?.length ?? 0) > 0 && (
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{c.mustHaveCriteria!.map(item => (
|
||||
<Chip key={item} label={item} size="small"
|
||||
onDelete={() => set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
|
||||
import { Mic, MicOff, Sparkles, X } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
onTextChange: (s: string) => void
|
||||
onAiSubmit: () => void
|
||||
isAnalyzing: boolean
|
||||
isAutoGen: boolean
|
||||
}
|
||||
|
||||
const EXAMPLES = [
|
||||
'Büro 800–1000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung',
|
||||
'Retail-Fläche 200–400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort',
|
||||
'Lagerhalle 2000–3000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
|
||||
]
|
||||
|
||||
const isSpeechSupported = typeof window !== 'undefined' &&
|
||||
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
|
||||
|
||||
export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [interimText, setInterimText] = useState('')
|
||||
const recognitionRef = useRef<any>(null)
|
||||
const accumulatedRef = useRef('')
|
||||
|
||||
function startRecording() {
|
||||
const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition
|
||||
if (!SpeechAPI) return
|
||||
|
||||
accumulatedRef.current = text
|
||||
const rec = new SpeechAPI()
|
||||
rec.lang = 'de-DE'
|
||||
rec.continuous = true
|
||||
rec.interimResults = true
|
||||
|
||||
rec.onresult = (e: any) => {
|
||||
let finalPart = ''
|
||||
let interimPart = ''
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const t = e.results[i][0].transcript
|
||||
if (e.results[i].isFinal) finalPart += t
|
||||
else interimPart += t
|
||||
}
|
||||
if (finalPart) {
|
||||
accumulatedRef.current = (accumulatedRef.current + ' ' + finalPart).trim()
|
||||
onTextChange(accumulatedRef.current)
|
||||
}
|
||||
setInterimText(interimPart)
|
||||
}
|
||||
|
||||
rec.onend = () => {
|
||||
setIsRecording(false)
|
||||
setInterimText('')
|
||||
if (accumulatedRef.current.length >= 15) onAiSubmit()
|
||||
}
|
||||
|
||||
rec.onerror = () => { setIsRecording(false); setInterimText('') }
|
||||
rec.start()
|
||||
recognitionRef.current = rec
|
||||
setIsRecording(true)
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
recognitionRef.current?.stop()
|
||||
}
|
||||
|
||||
// Show interim text inside the field while recording
|
||||
const displayValue = isRecording && interimText
|
||||
? (text + (text ? ' ' : '') + interimText)
|
||||
: text
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
border: '1px solid',
|
||||
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
|
||||
transition: 'border-color 0.2s',
|
||||
bgcolor: isRecording ? '#fff5f5' : 'white',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
|
||||
Bedarf beschreiben
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Schreiben oder sprechen — die KI extrahiert alle Kriterien automatisch
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isRecording ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label="🎤 Aufnahme läuft…"
|
||||
onClick={stopRecording}
|
||||
sx={{ bgcolor: '#fef2f2', color: '#dc2626', fontWeight: 600, fontSize: 11, cursor: 'pointer' }}
|
||||
/>
|
||||
) : isAnalyzing ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} />
|
||||
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert…</Typography>
|
||||
</Box>
|
||||
) : isAutoGen && text ? (
|
||||
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}>⚡ auto-synchronisiert</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{/* Textarea + mic */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<TextField
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
placeholder="Wir suchen 800–1.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
|
||||
value={displayValue}
|
||||
onChange={e => {
|
||||
setInterimText('')
|
||||
onTextChange(e.target.value)
|
||||
}}
|
||||
disabled={isRecording || isAnalyzing}
|
||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
pr: '52px',
|
||||
bgcolor: isAutoGen && !isRecording ? '#f0f7ff' : 'transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
'& textarea': { color: isRecording && interimText ? '#64748b' : 'inherit' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', bottom: 10, right: 10 }}>
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
onClick={stopRecording}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: '#dc2626', color: 'white', '&:hover': { bgcolor: '#b91c1c' },
|
||||
animation: 'micPulse 1.2s ease-in-out infinite',
|
||||
'@keyframes micPulse': {
|
||||
'0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' },
|
||||
'50%': { boxShadow: '0 0 0 6px rgba(220,38,38,0)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MicOff size={16} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={isSpeechSupported ? startRecording : undefined}
|
||||
size="small"
|
||||
disabled={isAnalyzing || !isSpeechSupported}
|
||||
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
|
||||
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e2e8f0' }, '&:disabled': { opacity: 0.35 } }}
|
||||
>
|
||||
<Mic size={16} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Actions row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5 }}>
|
||||
{/* Example prompts */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{EXAMPLES.map((ex, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={ex.slice(0, 32) + '…'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => onTextChange(ex)}
|
||||
sx={{ fontSize: 10, height: 20 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}>
|
||||
{text && !isRecording && (
|
||||
<IconButton size="small" onClick={() => onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}>
|
||||
<X size={14} />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!text.trim() || isAnalyzing || isRecording}
|
||||
onClick={onAiSubmit}
|
||||
endIcon={<Sparkles size={13} />}
|
||||
sx={{ fontSize: 12, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
KI Auto-fill
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, Card, Slider, Typography } from '@mui/material'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
|
||||
@@ -10,17 +11,36 @@ interface Props {
|
||||
assetType?: string
|
||||
}
|
||||
|
||||
export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
||||
const total = WEIGHTING_KEYS.reduce((sum, k) => sum + (weights[k] ?? 0), 0)
|
||||
const totalPct = Math.round(total * 100)
|
||||
const isBalanced = totalPct >= 95 && totalPct <= 105
|
||||
const IMPORTANCE_LABELS = ['', 'Unwichtig', 'Wenig wichtig', 'Wichtig', 'Sehr wichtig', 'Entscheidend']
|
||||
|
||||
function handleSlider(key: WeightingKey, pct: number) {
|
||||
onChange({ ...weights, [key]: pct / 100 })
|
||||
function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> {
|
||||
const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0))
|
||||
if (max === 0) return Object.fromEntries(WEIGHTING_KEYS.map(k => [k, 3])) as Record<WeightingKey, number>
|
||||
return Object.fromEntries(
|
||||
WEIGHTING_KEYS.map(k => [k, Math.max(1, Math.round(((w[k] ?? 0) / max) * 5))])
|
||||
) as Record<WeightingKey, number>
|
||||
}
|
||||
|
||||
function rawToWeights(raw: Record<WeightingKey, number>): Record<WeightingKey, number> {
|
||||
const total = WEIGHTING_KEYS.reduce((s, k) => s + (raw[k] ?? 1), 0)
|
||||
return Object.fromEntries(
|
||||
WEIGHTING_KEYS.map(k => [k, (raw[k] ?? 1) / total])
|
||||
) as Record<WeightingKey, number>
|
||||
}
|
||||
|
||||
export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
||||
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
|
||||
|
||||
function handleSlider(key: WeightingKey, value: number) {
|
||||
const updated = { ...raw, [key]: value }
|
||||
setRaw(updated)
|
||||
onChange(rawToWeights(updated))
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
onChange(weightingService.getDefaultWeights(assetType))
|
||||
const defaults = weightingService.getDefaultWeights(assetType)
|
||||
setRaw(toRaw(defaults))
|
||||
onChange(defaults)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -28,10 +48,10 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Kriteriengewichtung anpassen
|
||||
Wichtigkeit der Kriterien
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Passen Sie an, wie stark jedes Kriterium das Matching beeinflusst.
|
||||
Schieber nach rechts = wichtiger. Gewichtung wird automatisch berechnet.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -45,24 +65,25 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
||||
</Box>
|
||||
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{WEIGHTING_KEYS.map(key => {
|
||||
const pct = Math.round((weights[key] ?? 0) * 100)
|
||||
const importance = raw[key] ?? 3
|
||||
return (
|
||||
<Box key={key}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{WEIGHTING_LABELS[key]}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{pct}%
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{IMPORTANCE_LABELS[importance]}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={pct}
|
||||
min={0}
|
||||
max={40}
|
||||
value={importance}
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
marks
|
||||
onChange={(_, v) => handleSlider(key, v as number)}
|
||||
size="small"
|
||||
sx={{ color: '#1e3a5f' }}
|
||||
@@ -71,27 +92,6 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
pt: 2,
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gesamt
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: isBalanced ? '#1a7a4a' : '#c0392b' }}
|
||||
>
|
||||
{totalPct}%{!isBalanced && ' — Summe sollte ~100% ergeben'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -7,4 +7,5 @@ export { NeedBuilderErrorState } from './NeedBuilderErrorState'
|
||||
export { NeedBuilderProgress } from './NeedBuilderProgress'
|
||||
export { NeedCardPreview } from './NeedCardPreview'
|
||||
export { NeedInput } from './NeedInput'
|
||||
export { VoiceNeedInput } from './VoiceNeedInput'
|
||||
export { WeightingEditor } from './WeightingEditor'
|
||||
|
||||
@@ -65,35 +65,35 @@ interface WorkspaceConfig {
|
||||
|
||||
const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
[WorkspaceType.SUPPLY]: {
|
||||
label: 'Supply',
|
||||
abbreviation: 'SUP',
|
||||
label: 'Verwaltung',
|
||||
abbreviation: 'VW',
|
||||
icon: Building2,
|
||||
firstPath: '/supply/dashboard',
|
||||
chipColor: '#1e3a5f',
|
||||
navItems: [
|
||||
{ path: '/supply/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/supply/properties', label: 'Objekte', icon: Building2 },
|
||||
{ path: '/supply/match-center', label: 'Match Center', icon: Target },
|
||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||
{ path: '/supply/match-center', label: 'Eingehende Bedarfe', icon: Target },
|
||||
{ path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp },
|
||||
{ path: '/supply/data-quality', label: 'Datenqualität', icon: CheckSquare },
|
||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||
],
|
||||
},
|
||||
[WorkspaceType.DEMAND]: {
|
||||
label: 'Demand',
|
||||
abbreviation: 'DEM',
|
||||
label: 'Suche',
|
||||
abbreviation: 'SU',
|
||||
icon: Search,
|
||||
firstPath: '/demand/ai-search',
|
||||
chipColor: '#1a7a4a',
|
||||
navItems: [
|
||||
{ path: '/demand/ai-search', label: 'AI Suche', icon: Search },
|
||||
{ path: '/demand/ai-search', label: 'Flächensuche', icon: Search },
|
||||
{ path: '/demand/results', label: 'Ergebnisse', icon: List },
|
||||
{ path: '/demand/compare', label: 'Vergleich', icon: Columns2 },
|
||||
{ path: '/demand/shortlists', label: 'Shortlists', icon: Bookmark },
|
||||
],
|
||||
},
|
||||
[WorkspaceType.OPERATIONS]: {
|
||||
label: 'Operations',
|
||||
abbreviation: 'OPS',
|
||||
label: 'Administration',
|
||||
abbreviation: 'ADM',
|
||||
icon: Shield,
|
||||
firstPath: '/ops/review-queue',
|
||||
chipColor: '#7c3aed',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/material'
|
||||
import { X } from 'lucide-react'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
@@ -15,10 +15,14 @@ export function CompareTray() {
|
||||
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
|
||||
const { setCompareTrayVisible } = useLayoutStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const isDemand = location.pathname.startsWith('/demand')
|
||||
|
||||
useEffect(() => {
|
||||
setCompareTrayVisible(compareItems.length > 0)
|
||||
}, [compareItems.length, setCompareTrayVisible])
|
||||
setCompareTrayVisible(compareItems.length > 0 && isDemand)
|
||||
}, [compareItems.length, setCompareTrayVisible, isDemand])
|
||||
|
||||
if (!isDemand) return null
|
||||
|
||||
const getTitle = (item: (typeof compareItems)[number]) => {
|
||||
if (item.resultType === 'FUTURE_AVAILABILITY') {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||
import { MatchStatusBadge } from './MatchStatusBadge'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Need } from '../../domain/need'
|
||||
|
||||
const STRENGTH_META: Record<string, { label: string; bg: string; color: string }> = {
|
||||
STRONG: { label: 'Stark', bg: '#f0fdf4', color: '#1a7a4a' },
|
||||
MODERATE: { label: 'Mittel', bg: '#fefce8', color: '#d97706' },
|
||||
WEAK: { label: 'Schwach', bg: '#fff1f2', color: '#c0392b' },
|
||||
}
|
||||
|
||||
function scoreColor(score: number) {
|
||||
if (score >= 80) return '#1a7a4a'
|
||||
if (score >= 60) return '#d97706'
|
||||
return '#c0392b'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
property: Property | undefined
|
||||
need: Need | undefined
|
||||
onSelect: () => void
|
||||
onApprove: () => void
|
||||
}
|
||||
|
||||
export function MatchListCard({ match, property, need, onSelect, onApprove }: Props) {
|
||||
const strength = STRENGTH_META[match.matchStrength] ?? { label: match.matchStrength, bg: '#f1f5f9', color: '#64748b' }
|
||||
const summary = match.explainabilitySummary ?? ''
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onSelect}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
bgcolor: 'white',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
}}
|
||||
>
|
||||
{/* Score bubble */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 2,
|
||||
bgcolor: scoreColor(match.matchScore),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 700, lineHeight: 1 }}>
|
||||
{match.matchScore}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Property + need */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||
{property?.title ?? match.propertyId}
|
||||
</Typography>
|
||||
<MatchStatusBadge status={match.status} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{[property?.location.city, property?.areaSqm ? `${property.areaSqm} m²` : null].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{need && (
|
||||
<Chip
|
||||
label={need.companyName}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={strength.label}
|
||||
size="small"
|
||||
sx={{ bgcolor: strength.bg, color: strength.color, fontSize: 11, height: 20 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Summary excerpt */}
|
||||
{summary && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
display: { xs: 'none', lg: 'block' },
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{summary.length > 90 ? summary.slice(0, 90) + '…' : summary}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
{match.status !== 'APPROVED' && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={onApprove}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' } }}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={onSelect}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export { MatchCenterSkeleton } from './MatchCenterSkeleton'
|
||||
export { PropertySelectionPanel } from './PropertySelectionPanel'
|
||||
export { NeedSelectionPanel } from './NeedSelectionPanel'
|
||||
export { MatchBriefingPanel } from './MatchBriefingPanel'
|
||||
export { MatchListCard } from './MatchListCard'
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Box, Chip, Divider, LinearProgress, Paper, Tooltip, Typography } from '@mui/material'
|
||||
import {
|
||||
Activity, Building2, HardHat, MapPin, Percent,
|
||||
TrendingDown, TrendingUp, Train, Users, Zap,
|
||||
} from 'lucide-react'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function scoreColor(v: number) {
|
||||
if (v >= 0.72) return '#1a7a4a'
|
||||
if (v >= 0.48) return '#d97706'
|
||||
return '#c0392b'
|
||||
}
|
||||
|
||||
function scoreLabel(v: number) {
|
||||
if (v >= 0.82) return 'Sehr gut'
|
||||
if (v >= 0.65) return 'Gut'
|
||||
if (v >= 0.45) return 'Mittel'
|
||||
return 'Schwach'
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function SoftFactorBar({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
tooltip,
|
||||
}: {
|
||||
label: string
|
||||
value: number | undefined | null
|
||||
icon: React.ReactNode
|
||||
tooltip?: string
|
||||
}) {
|
||||
if (value === undefined || value === null) return null
|
||||
const color = scoreColor(value)
|
||||
const row = (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ color: '#64748b', display: 'flex' }}>{icon}</Box>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={scoreLabel(value)}
|
||||
size="small"
|
||||
sx={{ bgcolor: color, color: 'white', fontSize: 10, height: 18, fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={value * 100}
|
||||
sx={{ height: 5, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
return tooltip ? <Tooltip title={tooltip} placement="left">{row}</Tooltip> : row
|
||||
}
|
||||
|
||||
function KpiTile({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 1.5,
|
||||
bgcolor: '#f8fafc',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: color ?? '#0f1923', lineHeight: 1.2 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{sub && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{sub}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const NEW_PROJECTS: Record<string, { title: string; area: string; completion: string; note: string }[]> = {
|
||||
'Zürich': [
|
||||
{ title: 'Ensemble Zürich-West', area: '500–2000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' },
|
||||
{ title: 'The Circle Phase II', area: '1000–5000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' },
|
||||
],
|
||||
'Basel': [
|
||||
{ title: 'Basel SBB Tower', area: '300–1500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' },
|
||||
{ title: 'Erlenmatt Ost', area: '600–2500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' },
|
||||
],
|
||||
'Zug': [
|
||||
{ title: 'Zug Innovation Campus', area: '200–1000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' },
|
||||
],
|
||||
'Bern': [
|
||||
{ title: 'Bern West Business Park', area: '400–3000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' },
|
||||
],
|
||||
'Winterthur': [
|
||||
{ title: 'Sulzerareal Phase 4', area: '800–4000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
property: Property | null
|
||||
}
|
||||
|
||||
export function LocationIntelligencePanel({ property }: Props) {
|
||||
const { data: allProperties = [] } = useProperties()
|
||||
|
||||
if (!property) return null
|
||||
|
||||
const city = property.location.city
|
||||
const intel = getCityIntelligence(city)
|
||||
const sf = property.softFactors
|
||||
|
||||
const hasSoftFactors = sf && (
|
||||
sf.footfallScore !== undefined ||
|
||||
sf.taxEnvironmentScore !== undefined ||
|
||||
sf.commuterAccessScore !== undefined ||
|
||||
sf.talentAccessScore !== undefined ||
|
||||
sf.prestigeScore !== undefined ||
|
||||
sf.prestige !== undefined
|
||||
)
|
||||
|
||||
// Market comparables: same type, same city, different property
|
||||
const comparables = allProperties
|
||||
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === city)
|
||||
.sort((a, b) => b.confidenceScore - a.confidenceScore)
|
||||
.slice(0, 3)
|
||||
|
||||
const newProjects = NEW_PROJECTS[city] ?? []
|
||||
|
||||
const rentTrendPositive = intel && intel.rentTrend12m > 0
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.25 }}>
|
||||
Standort-Intelligence
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Wirtschaftliche Faktoren und Marktkontext — über klassische Flächenangaben hinaus
|
||||
</Typography>
|
||||
|
||||
{/* ── City KPIs ── */}
|
||||
{intel && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<KpiTile
|
||||
label="Leerstandsquote"
|
||||
value={`${intel.vacancyRatePct}%`}
|
||||
sub={intel.vacancyRatePct < 3 ? 'Angespannter Markt' : intel.vacancyRatePct < 5 ? 'Ausgeglichen' : 'Käufermarkt'}
|
||||
color={intel.vacancyRatePct < 3 ? '#c0392b' : intel.vacancyRatePct < 5 ? '#d97706' : '#1a7a4a'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Mietpreis-Trend"
|
||||
value={`${rentTrendPositive ? '+' : ''}${intel.rentTrend12m}%`}
|
||||
sub="letzte 12 Monate"
|
||||
color={rentTrendPositive ? '#c0392b' : '#1a7a4a'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Kaufkraft-Index"
|
||||
value={`${intel.purchasingPowerIndex}`}
|
||||
sub="CH-Mittel = 100"
|
||||
color={intel.purchasingPowerIndex >= 115 ? '#1a7a4a' : intel.purchasingPowerIndex >= 95 ? '#d97706' : '#c0392b'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Ø Vermietungsdauer"
|
||||
value={`${intel.avgDaysOnMarket}T`}
|
||||
sub="Tage auf dem Markt"
|
||||
color={intel.avgDaysOnMarket < 40 ? '#c0392b' : intel.avgDaysOnMarket < 65 ? '#d97706' : '#1a7a4a'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Rent trend interpretation */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.5, bgcolor: rentTrendPositive ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5, mb: 2 }}>
|
||||
<Box sx={{ color: rentTrendPositive ? '#d97706' : '#1a7a4a', mt: 0.1 }}>
|
||||
{rentTrendPositive ? <TrendingUp size={16} /> : <TrendingDown size={16} />}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: rentTrendPositive ? '#92400e' : '#14532d' }}>
|
||||
{rentTrendPositive
|
||||
? `Mietpreise in ${city} sind in den letzten 12 Monaten um ${intel.rentTrend12m}% gestiegen. Frühzeitig abschliessen kann vorteilhaft sein.`
|
||||
: `Mietpreise in ${city} sind leicht rückläufig. Verhandlungsspielraum nutzen.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Tax + demand */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<Percent size={13} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">Steuerindex Kanton</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: intel.taxIndexCanton <= 80 ? '#1a7a4a' : intel.taxIndexCanton <= 105 ? '#d97706' : '#c0392b' }}>
|
||||
{intel.taxIndexCanton} <Typography component="span" variant="caption" color="text.secondary">(CH = 100)</Typography>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
||||
{intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<Activity size={13} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">Nachfragestärke</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: { LOW: '#f1f5f9', MEDIUM: '#fef3c7', HIGH: '#dcfce7', VERY_HIGH: '#f0fdf4' }[intel.demandStrength],
|
||||
color: { LOW: '#64748b', MEDIUM: '#d97706', HIGH: '#16a34a', VERY_HIGH: '#1a7a4a' }[intel.demandStrength],
|
||||
fontWeight: 600, fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Aktive Nachfrage in {city}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Industry clusters */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
|
||||
Dominante Branchen-Cluster
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{intel.dominantIndustryClusters.map(c => (
|
||||
<Chip key={c} label={c} size="small" sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 22 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Infrastructure */}
|
||||
{intel.plannedInfrastructure.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||
<Train size={13} color="#64748b" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
|
||||
Geplante Infrastruktur-Projekte
|
||||
</Typography>
|
||||
</Box>
|
||||
{intel.plannedInfrastructure.map((proj, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75, pl: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||
{proj.timeline}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>{proj.project}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{proj.impact}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Soft Factors ── */}
|
||||
{hasSoftFactors && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 1 }}>
|
||||
KI-berechnete Standortqualität
|
||||
</Typography>
|
||||
<SoftFactorBar
|
||||
label="Passantenfrequenz"
|
||||
value={sf.footfallScore ?? (sf.passerbyFrequency ? { LOW: 0.25, MEDIUM: 0.5, HIGH: 0.78, VERY_HIGH: 0.95 }[sf.passerbyFrequency] : undefined)}
|
||||
icon={<Users size={14} />}
|
||||
tooltip="Geschätzte Personenfrequenz im Umfeld — relevant für Retail & Sichtbarkeit"
|
||||
/>
|
||||
<SoftFactorBar
|
||||
label="ÖV-Anbindung"
|
||||
value={sf.commuterAccessScore}
|
||||
icon={<Train size={14} />}
|
||||
tooltip={sf.publicTransportMinutes ? `~${sf.publicTransportMinutes} Min. zum nächsten ÖV-Hub` : 'Öffentliche Erreichbarkeit des Standorts'}
|
||||
/>
|
||||
<SoftFactorBar
|
||||
label="Talentpool-Zugang"
|
||||
value={sf.talentAccessScore ?? (sf.talentAccess as number | undefined)}
|
||||
icon={<Users size={14} />}
|
||||
tooltip="Verfügbarkeit qualifizierter Fachkräfte in einem 30-Min.-Radius"
|
||||
/>
|
||||
<SoftFactorBar
|
||||
label="Prestige / Lagequalität"
|
||||
value={sf.prestigeScore ?? (sf.prestige as number | undefined)}
|
||||
icon={<MapPin size={14} />}
|
||||
tooltip="Adress-Prestige und wahrgenommene Standortqualität"
|
||||
/>
|
||||
<SoftFactorBar
|
||||
label="Flexibilitätspotenzial"
|
||||
value={sf.flexibilityScore}
|
||||
icon={<Zap size={14} />}
|
||||
tooltip="Möglichkeit zur Flächen-Anpassung (Ausbau, Teilung, Erweiterung)"
|
||||
/>
|
||||
{sf.esgScore !== undefined && (
|
||||
<SoftFactorBar
|
||||
label="ESG-Bewertung"
|
||||
value={sf.esgScore}
|
||||
icon={<Activity size={14} />}
|
||||
tooltip="Umwelt-, Sozial- und Governance-Standard des Gebäudes"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Market Comparables ── */}
|
||||
{comparables.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
|
||||
Vergleichbare Angebote in {city}
|
||||
</Typography>
|
||||
{comparables.map(p => (
|
||||
<Box
|
||||
key={p.id}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9' }}
|
||||
>
|
||||
<Building2 size={13} color="#64748b" />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{p.areaSqm} m² · CHF {p.rentPricePerSqm}/m²
|
||||
{p.rentPricePerSqm < property.rentPricePerSqm && ' · günstiger'}
|
||||
{p.rentPricePerSqm > property.rentPricePerSqm && ' · teurer'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── New Construction ── */}
|
||||
{newProjects.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#7c3aed', display: 'block', mb: 0.75 }}>
|
||||
Neubauprojekte als Alternative
|
||||
</Typography>
|
||||
{newProjects.map((proj, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1.5, py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<HardHat size={13} color="#7c3aed" />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, display: 'block' }}>{proj.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{proj.area} · Fertigstellung {proj.completion}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{proj.note}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
||||
export { MatchDetailHeader } from './MatchDetailHeader'
|
||||
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
||||
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Box, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||
import { CheckCircle, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { useNeeds } from '../../hooks/useNeeds'
|
||||
import { getCityIntelligence, getMarketRent } from '../../lib/locationIntelligence'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
// ── Selling argument generator ────────────────────────────────────────────────
|
||||
|
||||
interface Argument {
|
||||
title: string
|
||||
detail: string
|
||||
strength: 'strong' | 'medium'
|
||||
}
|
||||
|
||||
function generateSellingArguments(p: Property): Argument[] {
|
||||
const args: Argument[] = []
|
||||
const sf = p.softFactors
|
||||
const intel = getCityIntelligence(p.location.city)
|
||||
|
||||
if (sf) {
|
||||
const footfall = sf.footfallScore ?? (sf.passerbyFrequency ? { LOW: 0.25, MEDIUM: 0.5, HIGH: 0.78, VERY_HIGH: 0.95 }[sf.passerbyFrequency] ?? 0 : 0)
|
||||
if (footfall >= 0.72)
|
||||
args.push({ title: 'Hervorragende Frequenzlage', detail: 'Überdurchschnittliche Passantenfrequenz — sichert Sichtbarkeit und Kundenzugang.', strength: 'strong' })
|
||||
|
||||
const taxScore = sf.taxEnvironmentScore ?? (intel ? Math.max(0, 1 - intel.taxIndexCanton / 150) : undefined)
|
||||
if (taxScore !== undefined && taxScore >= 0.65)
|
||||
args.push({ title: 'Steuerattraktiver Standort', detail: `${p.location.city} bietet eine günstige Steuerlast${intel ? ` (Index ${intel.taxIndexCanton}, CH = 100)` : ''} — relevant für Unternehmensansiedlungen.`, strength: 'strong' })
|
||||
|
||||
const ov = sf.commuterAccessScore
|
||||
if (ov !== undefined && ov >= 0.72)
|
||||
args.push({ title: 'Sehr gute ÖV-Anbindung', detail: sf.publicTransportMinutes ? `Ca. ${sf.publicTransportMinutes} Min. zum nächsten Bahnhof.` : 'Ausgezeichnete öffentliche Erreichbarkeit.', strength: 'strong' })
|
||||
|
||||
const prestige = sf.prestigeScore ?? (typeof sf.prestige === 'number' ? sf.prestige : undefined)
|
||||
if (prestige !== undefined && prestige >= 0.7)
|
||||
args.push({ title: 'Repräsentativer Standort', detail: 'Hoher Prestige-Wert — ideal für Unternehmen mit Repräsentationsanspruch und Aussenauftritt.', strength: 'strong' })
|
||||
|
||||
const talent = sf.talentAccessScore ?? (typeof sf.talentAccess === 'number' ? sf.talentAccess : undefined)
|
||||
if (talent !== undefined && talent >= 0.65)
|
||||
args.push({ title: 'Grosser Talentpool', detail: 'Zugang zu gut ausgebildeten Fachkräften im Einzugsgebiet — entscheidend für wachsende Unternehmen.', strength: 'medium' })
|
||||
|
||||
if (sf.flexibilityScore !== undefined && sf.flexibilityScore >= 0.65)
|
||||
args.push({ title: 'Flexible Flächengestaltung', detail: 'Grundriss und Ausbaustandard ermöglichen individuelle Anpassungen.', strength: 'medium' })
|
||||
|
||||
if (sf.esgScore !== undefined && sf.esgScore >= 0.7)
|
||||
args.push({ title: 'Nachhaltigkeitszertifizierung', detail: 'Guter ESG-Score — relevant für Unternehmen mit Nachhaltigkeitszielen und ESG-Reporting.', strength: 'medium' })
|
||||
}
|
||||
|
||||
if (p.hardFacts?.isBarrierFree)
|
||||
args.push({ title: 'Barrierefrei', detail: 'Vollständig rollstuhlgängig — gesetzlich zunehmend gefordert.', strength: 'medium' })
|
||||
|
||||
if (p.hardFacts?.parking && p.hardFacts.parking > 0)
|
||||
args.push({ title: `${p.hardFacts.parking} Parkplätze inkl.`, detail: 'Eigene Parkierungsmöglichkeiten — in Städten ein knappes Gut.', strength: 'medium' })
|
||||
|
||||
if (p.hardFacts?.hasServerRoom)
|
||||
args.push({ title: 'Serverraum vorhanden', detail: 'Sofortig nutzbare IT-Infrastruktur — spart Einrichtungskosten.', strength: 'medium' })
|
||||
|
||||
if (intel?.demandStrength === 'VERY_HIGH' || intel?.demandStrength === 'HIGH')
|
||||
args.push({ title: 'Stark nachgefragter Markt', detail: `${p.location.city} verzeichnet ${intel.demandStrength === 'VERY_HIGH' ? 'sehr hohe' : 'hohe'} Nachfrage — kurze Leerstandszeiten zu erwarten.`, strength: 'strong' })
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// ── Proactive weakness acknowledgement ───────────────────────────────────────
|
||||
|
||||
interface Weakness {
|
||||
issue: string
|
||||
mitigation: string
|
||||
}
|
||||
|
||||
function generateWeaknesses(p: Property): Weakness[] {
|
||||
const ws: Weakness[] = []
|
||||
const sf = p.softFactors
|
||||
const intel = getCityIntelligence(p.location.city)
|
||||
|
||||
if (intel && intel.vacancyRatePct >= 5)
|
||||
ws.push({ issue: 'Hohe Leerstandsquote in der Region', mitigation: 'Mietfreie Zeit oder Ausbaukostenbeteiligung als Anreiz anbieten.' })
|
||||
|
||||
if (intel && intel.taxIndexCanton >= 115)
|
||||
ws.push({ issue: 'Überdurchschnittliche Steuerlast', mitigation: 'Andere Standortvorteile (Prestige, ÖV) gezielt hervorheben.' })
|
||||
|
||||
if (sf?.commuterAccessScore !== undefined && sf.commuterAccessScore < 0.45)
|
||||
ws.push({ issue: 'Eingeschränkte ÖV-Anbindung', mitigation: 'Parkplatz-Angebot und Veloinfrastruktur als Alternative betonen.' })
|
||||
|
||||
if (p.hardFacts?.parking === 0 || (p.hardFacts?.parking === undefined && !sf?.parkingSpots))
|
||||
ws.push({ issue: 'Keine eigenen Parkplätze', mitigation: 'Öffentliche Parkhäuser in der Nähe aufzeigen. Ggf. Parkabonnement als Mietbonus anbieten.' })
|
||||
|
||||
if (p.dataQuality.score < 0.65)
|
||||
ws.push({ issue: 'Unvollständige Objektdaten', mitigation: 'Fehlende Angaben vor dem Gespräch vervollständigen, um Vertrauen zu stärken.' })
|
||||
|
||||
return ws
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
}
|
||||
|
||||
export function NegotiationInsightsPanel({ property }: Props) {
|
||||
const { data: allProperties = [] } = useProperties()
|
||||
const { data: needs = [] } = useNeeds()
|
||||
|
||||
const intel = getCityIntelligence(property.location.city)
|
||||
const marketRent = getMarketRent(property.location.city, property.assetType)
|
||||
const priceDiff = marketRent ? ((property.rentPricePerSqm - marketRent) / marketRent) * 100 : null
|
||||
|
||||
// Comparable properties for price positioning
|
||||
const comparables = allProperties
|
||||
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === property.location.city)
|
||||
|
||||
const avgComparableRent = comparables.length > 0
|
||||
? comparables.reduce((s, p) => s + p.rentPricePerSqm, 0) / comparables.length
|
||||
: null
|
||||
|
||||
// Active needs matching this property type/location
|
||||
const matchingNeeds = needs.filter(n =>
|
||||
n.assetType === property.assetType &&
|
||||
(n.preferredLocations?.some(loc => loc.toLowerCase().includes(property.location.city.toLowerCase())) ?? false)
|
||||
)
|
||||
|
||||
const sellingArgs = generateSellingArguments(property)
|
||||
const weaknesses = generateWeaknesses(property)
|
||||
const strongArgs = sellingArgs.filter(a => a.strength === 'strong')
|
||||
const mediumArgs = sellingArgs.filter(a => a.strength === 'medium')
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
{/* ── Price positioning ── */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Preispositionierung</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5 }}>
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
|
||||
<Typography variant="caption" color="text.secondary">Ihr Preis</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f1923' }}>
|
||||
CHF {property.rentPricePerSqm}/m²
|
||||
</Typography>
|
||||
</Box>
|
||||
{marketRent && (
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
|
||||
<Typography variant="caption" color="text.secondary">Marktmedian {property.location.city}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}>
|
||||
CHF {marketRent}/m²
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{avgComparableRent && (
|
||||
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}>
|
||||
<Typography variant="caption" color="text.secondary">Vergleichsangebote Ø</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}>
|
||||
CHF {Math.round(avgComparableRent)}/m²
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{priceDiff !== null && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: Math.abs(priceDiff) < 10 ? '#f0fdf4' : priceDiff > 0 ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5 }}>
|
||||
{priceDiff > 0 ? <TrendingUp size={15} color="#d97706" /> : <TrendingDown size={15} color="#1a7a4a" />}
|
||||
<Typography variant="body2" sx={{ color: priceDiff > 15 ? '#92400e' : priceDiff > 0 ? '#d97706' : '#1a7a4a', fontWeight: 500 }}>
|
||||
{priceDiff > 15
|
||||
? `Ihr Preis liegt ${Math.round(priceDiff)}% über dem Marktmedian — starke USPs nötig zur Rechtfertigung.`
|
||||
: priceDiff > 5
|
||||
? `Leicht über Marktmedian (+${Math.round(priceDiff)}%) — gut durch Qualität begründbar.`
|
||||
: priceDiff > -5
|
||||
? 'Im Marktdurchschnitt — gute Ausgangsposition.'
|
||||
: `${Math.round(Math.abs(priceDiff))}% unter Marktmedian — Preiserhöhung oder schnelle Vermietung möglich.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Price bar vs market */}
|
||||
{marketRent && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">Marktbereich {property.location.city}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
CHF {Math.round(marketRent * 0.7)}–{Math.round(marketRent * 1.4)}/m²
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ position: 'relative', height: 8, bgcolor: '#e2e8f0', borderRadius: 4 }}>
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
left: `${Math.min(90, Math.max(5, ((property.rentPricePerSqm - marketRent * 0.7) / (marketRent * 0.7)) * 100))}%`,
|
||||
top: -2,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#1e3a5f',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Active demand ── */}
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Aktive Nachfrage</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Unternehmen, die aktuell in {property.location.city} suchen
|
||||
</Typography>
|
||||
{matchingNeeds.length > 0 ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{matchingNeeds.length}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">aktive Suchprofile für diesen Typ & Standort</Typography>
|
||||
</Box>
|
||||
{matchingNeeds.slice(0, 4).map(n => (
|
||||
<Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>{n.companyName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{n.requiredArea?.min ?? 0}–{n.requiredArea?.max ?? 0} m²
|
||||
{n.budgetRange?.maxPerSqm ? ` · max. CHF ${n.budgetRange.maxPerSqm}/m²` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={n.status === 'ACTIVE' ? 'Aktiv' : n.status === 'DRAFT' ? 'Entwurf' : n.status}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: n.status === 'ACTIVE' ? '#f0fdf4' : '#f1f5f9',
|
||||
color: n.status === 'ACTIVE' ? '#1a7a4a' : '#64748b',
|
||||
fontSize: 10, height: 18,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
{matchingNeeds.length > 4 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.75, display: 'block' }}>
|
||||
+ {matchingNeeds.length - 4} weitere Suchprofile
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine aktiven Suchprofile für diesen Typ und Standort.
|
||||
</Typography>
|
||||
)}
|
||||
{intel && (
|
||||
<Box sx={{ mt: 1.5, p: 1.25, bgcolor: '#f8fafc', borderRadius: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Ø Vermietungsdauer vergleichbarer Objekte in {property.location.city}:{' '}
|
||||
<strong style={{ color: '#1e3a5f' }}>{intel.avgDaysOnMarket} Tage</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Selling arguments ── */}
|
||||
{sellingArgs.length > 0 && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Verkaufsargumente für das Gespräch</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Stärken dieses Objekts — maßgeschneidert auf typische Mieterwünsche
|
||||
</Typography>
|
||||
|
||||
{strongArgs.length > 0 && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a', display: 'block', mb: 0.75 }}>
|
||||
Starke Argumente
|
||||
</Typography>
|
||||
{strongArgs.map((arg, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<CheckCircle size={15} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{arg.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{mediumArgs.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706', display: 'block', mb: 0.75 }}>
|
||||
Weitere Vorteile
|
||||
</Typography>
|
||||
{mediumArgs.map((arg, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75 }}>
|
||||
<CheckCircle size={14} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{arg.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── Proactive weakness handling ── */}
|
||||
{weaknesses.length > 0 && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Schwächen proaktiv adressieren</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Potenzielle Einwände kennen und entkräften — bevor der Interessent fragt
|
||||
</Typography>
|
||||
{weaknesses.map((w, i) => (
|
||||
<Box key={i} sx={{ mb: 1.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#c0392b', mb: 0.25 }}>
|
||||
⚠ {w.issue}
|
||||
</Typography>
|
||||
<Box sx={{ pl: 1.5, borderLeft: '2px solid #e2e8f0' }}>
|
||||
<Typography variant="caption" color="text.secondary">→ {w.mitigation}</Typography>
|
||||
</Box>
|
||||
{i < weaknesses.length - 1 && <Divider sx={{ mt: 1.25 }} />}
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── Tenant fit ── */}
|
||||
{intel && intel.dominantIndustryClusters.length > 0 && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Welche Mieter passen?</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Dominant präsente Branchen in {property.location.city} — hohes Match-Potenzial
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{intel.dominantIndustryClusters.map(c => (
|
||||
<Chip key={c} label={c} size="small"
|
||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 24, fontWeight: 500 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={intel.demandStrength === 'VERY_HIGH' ? 95 : intel.demandStrength === 'HIGH' ? 75 : intel.demandStrength === 'MEDIUM' ? 50 : 25}
|
||||
sx={{ mt: 1.5, height: 6, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
Nachfragestärke: {' '}
|
||||
<strong>{{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}</strong>
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -17,10 +17,8 @@ export interface PropertyCardProps {
|
||||
positiveFactors?: string[]
|
||||
topTradeoff?: string
|
||||
selected?: boolean
|
||||
compareSelected?: boolean
|
||||
onSelect?: () => void
|
||||
onViewDetail?: () => void
|
||||
onAddToCompare?: () => void
|
||||
onSaveToShortlist?: () => void
|
||||
onFindMatches?: () => void
|
||||
}
|
||||
@@ -33,21 +31,15 @@ export function PropertyCard({
|
||||
positiveFactors,
|
||||
topTradeoff,
|
||||
selected,
|
||||
compareSelected,
|
||||
onSelect,
|
||||
onViewDetail,
|
||||
onAddToCompare,
|
||||
onSaveToShortlist,
|
||||
onFindMatches,
|
||||
}: PropertyCardProps) {
|
||||
const isStale = STALE_STATUSES.includes(p.dataQuality.freshness)
|
||||
const isLowConfidence = p.confidenceScore < 0.65
|
||||
|
||||
const borderLeft = compareSelected
|
||||
? '4px solid #1a7a4a'
|
||||
: selected
|
||||
? '4px solid #1e3a5f'
|
||||
: '4px solid transparent'
|
||||
const borderLeft = selected ? '4px solid #1e3a5f' : '4px solid transparent'
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -150,7 +142,6 @@ export function PropertyCard({
|
||||
{/* Action Zone */}
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }} onClick={e => e.stopPropagation()}>
|
||||
<Button size="small" variant="contained" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onViewDetail}>Details</Button>
|
||||
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onAddToCompare}>Vergleichen</Button>
|
||||
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onSaveToShortlist}>Shortlist</Button>
|
||||
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onFindMatches}>Matches finden</Button>
|
||||
</Box>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import { NegotiationInsightsPanel } from './NegotiationInsightsPanel'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
@@ -411,7 +412,7 @@ function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = ['Übersicht', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale'] as const
|
||||
const TABS = ['Übersicht', 'Verhandlung & Markt', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale'] as const
|
||||
|
||||
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
|
||||
const [tab, setTab] = useState(0)
|
||||
@@ -486,12 +487,13 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
{/* Tab content */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
||||
{tab === 0 && <OverviewPanel p={property} />}
|
||||
{tab === 1 && <HardFactsPanel p={property} />}
|
||||
{tab === 2 && <SoftFactorsPanel p={property} />}
|
||||
{tab === 3 && <MatchabilityPanel matches={matches} />}
|
||||
{tab === 4 && <DataQualityPanel p={property} />}
|
||||
{tab === 5 && <SourcePanel p={property} />}
|
||||
{tab === 6 && <SignalsPanel signals={signals} />}
|
||||
{tab === 1 && <NegotiationInsightsPanel property={property} />}
|
||||
{tab === 2 && <HardFactsPanel p={property} />}
|
||||
{tab === 3 && <SoftFactorsPanel p={property} />}
|
||||
{tab === 4 && <MatchabilityPanel matches={matches} />}
|
||||
{tab === 5 && <DataQualityPanel p={property} />}
|
||||
{tab === 6 && <SourcePanel p={property} />}
|
||||
{tab === 7 && <SignalsPanel signals={signals} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Bookmark, Eye, Plus } from 'lucide-react'
|
||||
import { Bookmark, Eye } from 'lucide-react'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { PropertyTableFilters } from './PropertyFilterBar'
|
||||
import {
|
||||
@@ -248,11 +248,6 @@ export function PropertyTable({
|
||||
<Eye size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Zum Vergleich hinzufügen">
|
||||
<IconButton size="small">
|
||||
<Plus size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Zur Shortlist">
|
||||
<IconButton size="small">
|
||||
<Bookmark size={15} />
|
||||
|
||||
@@ -69,14 +69,6 @@ export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
|
||||
>
|
||||
Zur Prüfung
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }}
|
||||
onClick={() => navigate('/demand/compare')}
|
||||
>
|
||||
Vergleichen
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Static location intelligence data per city — mock values based on Swiss market context
|
||||
|
||||
export interface CityIntelligence {
|
||||
vacancyRatePct: number // Leerstandsquote %
|
||||
rentTrend12m: number // Mietpreisveränderung % (letztes Jahr)
|
||||
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
|
||||
dominantIndustryClusters: string[]
|
||||
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
|
||||
medianRentOffice: number // CHF/m² für Bürofläche
|
||||
medianRentLogistics: number
|
||||
medianRentRetail: number
|
||||
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
|
||||
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
||||
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
|
||||
}
|
||||
|
||||
export const CITY_INTELLIGENCE: Record<string, CityIntelligence> = {
|
||||
'Zürich': {
|
||||
vacancyRatePct: 2.8,
|
||||
rentTrend12m: +4.2,
|
||||
purchasingPowerIndex: 128,
|
||||
dominantIndustryClusters: ['Finanz & Banking', 'Tech & Startups', 'Medien & Kreativ', 'Pharma & Life Science'],
|
||||
plannedInfrastructure: [
|
||||
{ project: 'Tram Hardbrücke-Verlängerung', timeline: '2027', impact: 'Bessere ÖV-Anbindung Industriequartier' },
|
||||
{ project: 'Rosengarten-Tunnel', timeline: '2030', impact: 'Entlastung Kreis 5/6, weniger Durchgangsverkehr' },
|
||||
],
|
||||
medianRentOffice: 42,
|
||||
medianRentLogistics: 14,
|
||||
medianRentRetail: 180,
|
||||
avgDaysOnMarket: 38,
|
||||
demandStrength: 'VERY_HIGH',
|
||||
taxIndexCanton: 100,
|
||||
},
|
||||
'Basel': {
|
||||
vacancyRatePct: 4.1,
|
||||
rentTrend12m: +1.8,
|
||||
purchasingPowerIndex: 112,
|
||||
dominantIndustryClusters: ['Pharma & Chemie', 'Logistik & Handel', 'Medizintechnik', 'Finanzdienstleistungen'],
|
||||
plannedInfrastructure: [
|
||||
{ project: 'Basel SBB Südeingang Neubau', timeline: '2026', impact: 'Aufwertung Bahnhofumgebung' },
|
||||
{ project: 'Regio-S-Bahn Ausbau', timeline: '2028', impact: 'Bessere Grenzpendler-Anbindung' },
|
||||
],
|
||||
medianRentOffice: 32,
|
||||
medianRentLogistics: 11,
|
||||
medianRentRetail: 120,
|
||||
avgDaysOnMarket: 52,
|
||||
demandStrength: 'HIGH',
|
||||
taxIndexCanton: 98,
|
||||
},
|
||||
'Bern': {
|
||||
vacancyRatePct: 3.5,
|
||||
rentTrend12m: +2.1,
|
||||
purchasingPowerIndex: 108,
|
||||
dominantIndustryClusters: ['Bundesverwaltung & NPO', 'Gesundheit', 'Bildung & Forschung', 'Versicherungen'],
|
||||
plannedInfrastructure: [
|
||||
{ project: 'Bernmobil Netzausbau West', timeline: '2026', impact: 'Erschliessung Entwicklungsgebiet Ausserholligen' },
|
||||
],
|
||||
medianRentOffice: 28,
|
||||
medianRentLogistics: 10,
|
||||
medianRentRetail: 95,
|
||||
avgDaysOnMarket: 61,
|
||||
demandStrength: 'MEDIUM',
|
||||
taxIndexCanton: 112,
|
||||
},
|
||||
'Zug': {
|
||||
vacancyRatePct: 1.9,
|
||||
rentTrend12m: +5.1,
|
||||
purchasingPowerIndex: 148,
|
||||
dominantIndustryClusters: ['Rohstoffhandel', 'Crypto & Blockchain', 'Holding & Finanzen', 'Tech-Unternehmen'],
|
||||
plannedInfrastructure: [
|
||||
{ project: 'Metrobahn Zug-Luzern', timeline: '2029', impact: 'Direktverbindung Luzern in 18 Min.' },
|
||||
],
|
||||
medianRentOffice: 38,
|
||||
medianRentLogistics: 13,
|
||||
medianRentRetail: 140,
|
||||
avgDaysOnMarket: 24,
|
||||
demandStrength: 'VERY_HIGH',
|
||||
taxIndexCanton: 60,
|
||||
},
|
||||
'Winterthur': {
|
||||
vacancyRatePct: 5.8,
|
||||
rentTrend12m: +0.9,
|
||||
purchasingPowerIndex: 98,
|
||||
dominantIndustryClusters: ['Industrie & Maschinenbau', 'Logistik', 'Gesundheit & Soziales'],
|
||||
plannedInfrastructure: [
|
||||
{ project: 'Stadtraum HB Winterthur', timeline: '2027', impact: 'Aufwertung Bahnhofsumgebung, mehr Frequenz' },
|
||||
],
|
||||
medianRentOffice: 22,
|
||||
medianRentLogistics: 9,
|
||||
medianRentRetail: 75,
|
||||
avgDaysOnMarket: 74,
|
||||
demandStrength: 'MEDIUM',
|
||||
taxIndexCanton: 119,
|
||||
},
|
||||
'Geneva': {
|
||||
vacancyRatePct: 2.2,
|
||||
rentTrend12m: +3.6,
|
||||
purchasingPowerIndex: 135,
|
||||
dominantIndustryClusters: ['Internationale Organisationen', 'Luxusgüter', 'Banking & Private Equity', 'Uhrenindustrie'],
|
||||
plannedInfrastructure: [
|
||||
{ project: 'CEVA Linie Verlängerung', timeline: '2026', impact: 'Bessere Verbindung Lancy-Pont-Rouge' },
|
||||
],
|
||||
medianRentOffice: 55,
|
||||
medianRentLogistics: 18,
|
||||
medianRentRetail: 220,
|
||||
avgDaysOnMarket: 31,
|
||||
demandStrength: 'HIGH',
|
||||
taxIndexCanton: 125,
|
||||
},
|
||||
'St.Gallen': {
|
||||
vacancyRatePct: 6.2,
|
||||
rentTrend12m: -0.5,
|
||||
purchasingPowerIndex: 95,
|
||||
dominantIndustryClusters: ['Textil & Mode', 'KMU', 'Logistik', 'Gesundheit'],
|
||||
plannedInfrastructure: [],
|
||||
medianRentOffice: 19,
|
||||
medianRentLogistics: 8,
|
||||
medianRentRetail: 65,
|
||||
avgDaysOnMarket: 88,
|
||||
demandStrength: 'LOW',
|
||||
taxIndexCanton: 107,
|
||||
},
|
||||
}
|
||||
|
||||
export function getCityIntelligence(city: string): CityIntelligence | null {
|
||||
// Try exact match first, then partial
|
||||
if (CITY_INTELLIGENCE[city]) return CITY_INTELLIGENCE[city]
|
||||
const key = Object.keys(CITY_INTELLIGENCE).find(k => city.toLowerCase().includes(k.toLowerCase()))
|
||||
return key ? CITY_INTELLIGENCE[key] : null
|
||||
}
|
||||
|
||||
export function getMarketRent(city: string, assetType: string): number | null {
|
||||
const intel = getCityIntelligence(city)
|
||||
if (!intel) return null
|
||||
if (assetType === 'OFFICE') return intel.medianRentOffice
|
||||
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
|
||||
if (assetType === 'RETAIL') return intel.medianRentRetail
|
||||
return intel.medianRentOffice
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
|
||||
[UserRole.PROPERTY_MANAGER]: [
|
||||
Permission.SUPPLY_VIEW,
|
||||
Permission.SUPPLY_EDIT,
|
||||
Permission.DEMAND_VIEW,
|
||||
Permission.FUTURE_SIGNAL_VIEW,
|
||||
Permission.FUTURE_SIGNAL_REVIEW,
|
||||
Permission.CONTACT_RELEASE_APPROVE,
|
||||
@@ -63,11 +64,11 @@ const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
|
||||
[WorkspaceType.DEMAND]: [
|
||||
UserRole.SUPER_ADMIN,
|
||||
UserRole.ORGANIZATION_ADMIN,
|
||||
UserRole.PROPERTY_MANAGER,
|
||||
UserRole.DEMAND_USER,
|
||||
],
|
||||
[WorkspaceType.OPERATIONS]: [
|
||||
UserRole.SUPER_ADMIN,
|
||||
UserRole.ORGANIZATION_ADMIN,
|
||||
UserRole.REVIEWER,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
+1450
-34
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
+786
-3
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -18,10 +18,10 @@ import { UserRole } from '../../domain/enums'
|
||||
|
||||
const DEMO_ROLES: { role: UserRole; label: string; description: string }[] = [
|
||||
{ role: UserRole.ORGANIZATION_ADMIN, label: 'Org Admin', description: 'Vollzugriff Supply + Demand + Ops' },
|
||||
{ role: UserRole.PROPERTY_MANAGER, label: 'Property Manager', description: 'Supply Workspace' },
|
||||
{ role: UserRole.DEMAND_USER, label: 'Demand User', description: 'Demand Workspace' },
|
||||
{ role: UserRole.PROPERTY_MANAGER, label: 'Verwaltung', description: 'Portfolio verwalten + Markt durchsuchen' },
|
||||
{ role: UserRole.DEMAND_USER, label: 'Bürosuche', description: 'Nur Marktsuche — kein Portfolio' },
|
||||
{ role: UserRole.REVIEWER, label: 'Reviewer', description: 'Operations Workspace' },
|
||||
{ role: UserRole.OWNER_VIEWER, label: 'Owner Viewer', description: 'Supply (eingeschränkt)' },
|
||||
{ role: UserRole.OWNER_VIEWER, label: 'Eigentümer', description: 'Supply (eingeschränkt)' },
|
||||
{ role: UserRole.SUPER_ADMIN, label: 'Super Admin', description: 'Plattform-Administrator' },
|
||||
]
|
||||
|
||||
|
||||
+236
-106
@@ -1,13 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||
import { ArrowRight, ArrowLeft, Save } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { ArrowRight, Bookmark, Save, Search } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
NeedBuilderProgress,
|
||||
NeedInput,
|
||||
CriteriaReviewPanel,
|
||||
FollowUpPanel,
|
||||
VoiceNeedInput,
|
||||
WeightingEditor,
|
||||
NeedCardPreview,
|
||||
NeedBuilderErrorState,
|
||||
@@ -20,16 +26,34 @@ import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../do
|
||||
import { AssetType } from '../../domain/enums'
|
||||
import type { CreateNeedInput } from '../../domain/need'
|
||||
|
||||
// ── Map ParsedNeedCriteria → CreateNeedInput ───────────────────────────────────
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const ASSET_LABELS_TEXT: Record<string, string> = {
|
||||
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
|
||||
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
|
||||
}
|
||||
|
||||
function generateSummary(c: ParsedNeedCriteria): string {
|
||||
const parts: string[] = []
|
||||
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
|
||||
if (c.areaRange && (c.areaRange.min > 0 || c.areaRange.max > 0))
|
||||
parts.push(`${c.areaRange.min}–${c.areaRange.max} 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<WeightingKey, number>,
|
||||
needTitle: string,
|
||||
overallConfidence: number,
|
||||
status: 'DRAFT' | 'ACTIVE',
|
||||
): CreateNeedInput {
|
||||
return {
|
||||
companyName: needTitle || criteria.companyName || 'Neuer Bedarf',
|
||||
companyName: needTitle || criteria.companyName || 'Neue Suche',
|
||||
assetType: criteria.assetType ?? AssetType.UNKNOWN,
|
||||
requiredArea: criteria.areaRange ?? { min: 0, max: 0 },
|
||||
preferredLocations: criteria.preferredLocations ?? [],
|
||||
@@ -42,77 +66,157 @@ function buildNeedInput(
|
||||
},
|
||||
weightingProfile: weights,
|
||||
confidenceInCriteria: overallConfidence,
|
||||
status: overallConfidence < 0.6 ? 'DRAFT' : 'ACTIVE',
|
||||
status,
|
||||
mustCriteriaText: criteria.mustHaveCriteria ?? [],
|
||||
notes: criteria.notes,
|
||||
extractedFromText: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action intent ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ActionIntent = 'search' | 'save-profile'
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AISearch() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE)
|
||||
const [intent, setIntent] = useState<ActionIntent>('search')
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [isAutoGen, setIsAutoGen] = useState(false)
|
||||
const [criteria, setCriteria] = useState<ParsedNeedCriteria>({})
|
||||
const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null)
|
||||
const [editedCriteria, setEditedCriteria] = useState<ParsedNeedCriteria | null>(null)
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({})
|
||||
const [weights, setWeights] = useState<Record<WeightingKey, number>>(weightingService.getDefaultWeights())
|
||||
const [weightingKey, setWeightingKey] = useState(0)
|
||||
const [needTitle, setNeedTitle] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleAnalyze() {
|
||||
const isManualTextRef = useRef(false)
|
||||
|
||||
function handleCriteriaChange(next: ParsedNeedCriteria) {
|
||||
setCriteria(next)
|
||||
if (!isManualTextRef.current) {
|
||||
const summary = generateSummary(next)
|
||||
setInputText(summary)
|
||||
setIsAutoGen(!!summary)
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextChange(text: string) {
|
||||
isManualTextRef.current = text !== ''
|
||||
setIsAutoGen(false)
|
||||
setInputText(text)
|
||||
}
|
||||
|
||||
async function handleAiAutofill() {
|
||||
setStep(NeedBuilderStep.PARSING)
|
||||
setError(null)
|
||||
try {
|
||||
const resp = await aiService.parseNeed(inputText)
|
||||
const result = resp.data
|
||||
setParseResult(result)
|
||||
setEditedCriteria({ ...result.extractedCriteria })
|
||||
setCriteria({ ...result.extractedCriteria })
|
||||
setWeights(result.suggestedWeights as Record<WeightingKey, number>)
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
setWeightingKey(k => k + 1)
|
||||
isManualTextRef.current = false
|
||||
setInputText('')
|
||||
setIsAutoGen(false)
|
||||
setStep(NeedBuilderStep.IDLE)
|
||||
} catch {
|
||||
setError('Die KI-Analyse ist fehlgeschlagen. Bitte versuchen Sie es erneut.')
|
||||
setError('Die KI-Analyse ist fehlgeschlagen.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReparse() {
|
||||
if (!editedCriteria) return
|
||||
setStep(NeedBuilderStep.PARSING)
|
||||
try {
|
||||
const resp = await aiService.generateFollowUpQuestions(editedCriteria)
|
||||
if (parseResult) {
|
||||
setParseResult({ ...parseResult, followUpQuestionCandidates: resp.data })
|
||||
// Resolve criteria (parse text if needed), then either search or show save preview
|
||||
async function handleAction(chosenIntent: ActionIntent) {
|
||||
setIntent(chosenIntent)
|
||||
setError(null)
|
||||
|
||||
let resolved: ParsedNeedCriteria = criteria
|
||||
let resolvedResult: ParseNeedResult | null = parseResult
|
||||
|
||||
if (!hasStructuredData && inputText.trim()) {
|
||||
setStep(NeedBuilderStep.PARSING)
|
||||
try {
|
||||
const resp = await aiService.parseNeed(inputText)
|
||||
resolved = resp.data.extractedCriteria
|
||||
resolvedResult = resp.data
|
||||
setCriteria(resolved)
|
||||
setWeights(resp.data.suggestedWeights as Record<WeightingKey, number>)
|
||||
setWeightingKey(k => k + 1)
|
||||
isManualTextRef.current = false
|
||||
setInputText('')
|
||||
setIsAutoGen(false)
|
||||
setParseResult(resp.data)
|
||||
} catch {
|
||||
setError('Die KI-Analyse ist fehlgeschlagen.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
return
|
||||
}
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
} catch {
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
}
|
||||
|
||||
if (chosenIntent === 'search') {
|
||||
// Save as DRAFT and navigate immediately
|
||||
setStep(NeedBuilderStep.SAVING)
|
||||
try {
|
||||
const conf = resolvedResult
|
||||
? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) /
|
||||
Math.max(Object.values(resolvedResult.confidenceByField).length, 1)
|
||||
: 0.5
|
||||
const input = buildNeedInput(resolved, weights, needTitle, conf, 'DRAFT')
|
||||
const created = await needService.create(input)
|
||||
await queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
|
||||
} catch {
|
||||
setError('Suche fehlgeschlagen.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// save-profile: show preview step
|
||||
const confidenceByField: Record<string, number> = resolvedResult?.confidenceByField ?? {}
|
||||
if (!resolvedResult) {
|
||||
if (resolved.assetType) confidenceByField.assetType = 1.0
|
||||
if (resolved.areaRange?.min) confidenceByField.areaRange = 1.0
|
||||
if (resolved.preferredLocations?.length) confidenceByField.preferredLocations = 1.0
|
||||
if (resolved.budgetRange?.maxPerSqm) confidenceByField.budgetRange = 1.0
|
||||
if (resolved.timing?.earliestMoveIn) confidenceByField.timing = 1.0
|
||||
}
|
||||
setEditedCriteria({ ...resolved })
|
||||
setParseResult(resolvedResult ?? {
|
||||
extractedCriteria: resolved,
|
||||
confidenceByField,
|
||||
missingFields: [],
|
||||
assumptions: [],
|
||||
suggestedWeights: weights,
|
||||
followUpQuestionCandidates: [],
|
||||
rawSummary: 'Manuell eingegeben',
|
||||
promptVersion: 'manual',
|
||||
schemaVersion: '1.0',
|
||||
})
|
||||
setStep(NeedBuilderStep.READY_TO_SAVE)
|
||||
}
|
||||
|
||||
function handleAnswer(id: string, ans: string) {
|
||||
setAnswers(prev => ({ ...prev, [id]: ans }))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
async function handleSaveProfile() {
|
||||
if (!editedCriteria || !parseResult) return
|
||||
setStep(NeedBuilderStep.SAVING)
|
||||
|
||||
const fieldEntries = Object.entries(parseResult.confidenceByField)
|
||||
const overallConfidence = fieldEntries.length > 0
|
||||
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
|
||||
: 0
|
||||
|
||||
const entries = Object.entries(parseResult.confidenceByField)
|
||||
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
|
||||
try {
|
||||
const input = buildNeedInput(editedCriteria, weights, needTitle, overallConfidence)
|
||||
await needService.create(input)
|
||||
setStep(NeedBuilderStep.SAVED)
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true } })
|
||||
const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE')
|
||||
const created = await needService.create(input)
|
||||
await queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
|
||||
} catch {
|
||||
setError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.')
|
||||
setError('Speichern fehlgeschlagen.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +228,13 @@ export default function AISearch() {
|
||||
setEditedCriteria(null)
|
||||
}
|
||||
|
||||
const isReview = step === NeedBuilderStep.PARSED_REQUIRES_REVIEW || step === NeedBuilderStep.CLARIFICATION_REQUIRED
|
||||
const hasStructuredData = !!(
|
||||
criteria.assetType ||
|
||||
(criteria.areaRange?.min ?? 0) > 0 ||
|
||||
(criteria.preferredLocations?.length ?? 0) > 0
|
||||
)
|
||||
const canProceed = hasStructuredData || inputText.trim().length > 0
|
||||
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
|
||||
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
|
||||
|
||||
const overallConfidence = parseResult
|
||||
@@ -136,82 +246,103 @@ export default function AISearch() {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<PageHeader
|
||||
title="AI Bedarfsanalyse"
|
||||
subtitle="Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache"
|
||||
/>
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Flächensuche</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Sprechen, schreiben oder Felder ausfüllen — dann sofort suchen oder als Suchprofil speichern
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<NeedBuilderProgress step={step} />
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
||||
|
||||
{/* Step: Input */}
|
||||
{step === NeedBuilderStep.IDLE && (
|
||||
<NeedInput value={inputText} onChange={setInputText} onSubmit={handleAnalyze} />
|
||||
)}
|
||||
{/* ── IDLE: full form ── */}
|
||||
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
|
||||
|
||||
{/* Step: Parsing */}
|
||||
{step === NeedBuilderStep.PARSING && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 16, gap: 3 }}>
|
||||
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
|
||||
<Typography variant="h6" color="text.secondary">KI analysiert Ihren Bedarf…</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Kriterien werden extrahiert und bewertet</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<VoiceNeedInput
|
||||
text={inputText}
|
||||
onTextChange={handleTextChange}
|
||||
isAutoGen={isAutoGen}
|
||||
onAiSubmit={handleAiAutofill}
|
||||
isAnalyzing={step === NeedBuilderStep.PARSING}
|
||||
/>
|
||||
|
||||
{/* Step: Criteria Review + Follow-up */}
|
||||
{isReview && parseResult && editedCriteria && (
|
||||
<Box>
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 2 }}>
|
||||
<CriteriaReviewPanel
|
||||
result={parseResult}
|
||||
criteria={editedCriteria}
|
||||
onCriteriaChange={setEditedCriteria}
|
||||
/>
|
||||
<FollowUpPanel
|
||||
questions={parseResult.followUpQuestionCandidates}
|
||||
answers={answers}
|
||||
onAnswer={handleAnswer}
|
||||
onContinue={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
|
||||
onReparse={handleReparse}
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
|
||||
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
|
||||
<WeightingEditor
|
||||
key={weightingKey}
|
||||
weights={weights}
|
||||
onChange={setWeights}
|
||||
assetType={criteria.assetType}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.IDLE)}
|
||||
>
|
||||
Neu eingeben
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step: Weighting */}
|
||||
{step === NeedBuilderStep.WEIGHTING_REVIEW && (
|
||||
<Box>
|
||||
<WeightingEditor
|
||||
weights={weights}
|
||||
onChange={setWeights}
|
||||
assetType={editedCriteria?.assetType}
|
||||
/>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button variant="outlined" startIcon={<ArrowLeft size={16} />} onClick={() => setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)}>
|
||||
Zurück
|
||||
</Button>
|
||||
{/* Action bar */}
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
endIcon={<ArrowRight size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.READY_TO_SAVE)}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
size="large"
|
||||
disabled={!canProceed || isProcessing}
|
||||
onClick={() => handleAction('search')}
|
||||
endIcon={
|
||||
isProcessing && intent === 'search'
|
||||
? <CircularProgress size={18} color="inherit" />
|
||||
: <Search size={18} />
|
||||
}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
bgcolor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#162d4a' },
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
Vorschau & Speichern
|
||||
{isProcessing && intent === 'search' ? 'Sucht…' : 'Jetzt suchen'}
|
||||
</Button>
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
disabled={!canProceed || isProcessing}
|
||||
onClick={() => handleAction('save-profile')}
|
||||
startIcon={<Bookmark size={16} />}
|
||||
endIcon={
|
||||
isProcessing && intent === 'save-profile'
|
||||
? <CircularProgress size={18} color="inherit" />
|
||||
: <ArrowRight size={18} />
|
||||
}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isProcessing && intent === 'save-profile' ? 'Analysiert…' : 'Als Suchprofil speichern'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mt: -1 }}>
|
||||
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
|
||||
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird — auch in Zukunft.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step: Preview + Save */}
|
||||
{/* ── Preview + Save as Profile ── */}
|
||||
{isSaveStep && parseResult && editedCriteria && (
|
||||
<Box>
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
|
||||
</Alert>
|
||||
<NeedCardPreview
|
||||
criteria={editedCriteria}
|
||||
weights={weights}
|
||||
@@ -223,34 +354,33 @@ export default function AISearch() {
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
|
||||
onClick={() => setStep(NeedBuilderStep.IDLE)}
|
||||
disabled={step === NeedBuilderStep.SAVING}
|
||||
>
|
||||
Zurück
|
||||
← Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
onClick={handleSaveProfile}
|
||||
disabled={step === NeedBuilderStep.SAVING}
|
||||
endIcon={
|
||||
step === NeedBuilderStep.SAVING
|
||||
? <CircularProgress size={16} color="inherit" />
|
||||
: <Save size={16} />
|
||||
}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
|
||||
>
|
||||
{step === NeedBuilderStep.SAVING
|
||||
? 'Wird gespeichert…'
|
||||
: overallConfidence < 0.6
|
||||
? 'Als Entwurf speichern'
|
||||
: 'Bedarf speichern & Suche starten'}
|
||||
: 'Suchprofil speichern & Matching starten'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step: Error */}
|
||||
{/* ── Error ── */}
|
||||
{step === NeedBuilderStep.ERROR && (
|
||||
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useShortlistStore } from '../../stores/shortlistStore'
|
||||
import { AddToShortlistDialog } from '../../components/shortlist'
|
||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||
import {
|
||||
LocationIntelligencePanel,
|
||||
MatchDetailHeader,
|
||||
ExecutiveSummaryPanel,
|
||||
PropertyOverviewPanel,
|
||||
@@ -170,6 +171,7 @@ export default function MatchDetail() {
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<LocationIntelligencePanel property={property} />
|
||||
<TradeoffPanel match={match} />
|
||||
<RiskPanel match={match} />
|
||||
<MissingInformationPanel match={match} />
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function MarketIntelligence() {
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<MarketSignalDetailPanel signal={selectedSignal} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function SignalPipeline() {
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{selectedSignal
|
||||
? <SignalPipelineView signal={selectedSignal} />
|
||||
: <MarketSignalEmptyState variant="no-selection" />
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function SourceMonitoring() {
|
||||
/>
|
||||
</Box>
|
||||
{/* Right: Detail panel */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<SourceDetailPanel source={selectedSource} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [filterStrength, setFilterStrength] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
|
||||
const propMap = useMemo(() => new Map(properties.map(p => [p.id, p])), [properties])
|
||||
const needMap = useMemo(() => new Map(needs.map(n => [n.id, n])), [needs])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return matches
|
||||
.filter(m => {
|
||||
if (filterStrength && m.matchStrength !== filterStrength) return false
|
||||
if (filterStatus && m.status !== filterStatus) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => b.matchScore - a.matchScore)
|
||||
}, [matches, filterStrength, filterStatus])
|
||||
|
||||
const strongCount = matches.filter(m => m.matchScore >= 80).length
|
||||
const pendingCount = matches.filter(m => m.status === 'PENDING_REVIEW').length
|
||||
|
||||
function handleSelectMatch(match: Match) {
|
||||
setSelectedMatchId(match.id)
|
||||
setSelectedProperty(match.propertyId)
|
||||
setSelectedNeed(match.needId)
|
||||
}
|
||||
|
||||
function handleCloseDrawer() {
|
||||
setSelectedMatchId(null)
|
||||
setSelectedProperty(null)
|
||||
setSelectedNeed(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
||||
{/* Left: Properties */}
|
||||
<Paper elevation={0} sx={{
|
||||
width: 260,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 0,
|
||||
borderRight: '1px solid #e2e8f0',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={PANEL_HEADER_SX}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Objekte</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{matches.length} Matches gesamt</Typography>
|
||||
</Box>
|
||||
<PropertySelectionPanel matches={matches} />
|
||||
</Paper>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
||||
|
||||
{/* Center: Match Briefing */}
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#f8fafc',
|
||||
}}>
|
||||
<Box sx={PANEL_HEADER_SX}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Match-Briefing</Typography>
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2, mb: 1 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Match Center</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Automatisch berechnete Matches</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label={`${matches.length} Matches`} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} />
|
||||
<Chip
|
||||
label={`${strongCount} Stark`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600 }}
|
||||
/>
|
||||
{pendingCount > 0 && (
|
||||
<Chip
|
||||
label={`${pendingCount} Ausstehend`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#fef3c7', color: '#d97706', fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<MatchBriefingPanel />
|
||||
</Box>
|
||||
|
||||
{/* Right: Needs */}
|
||||
<Paper elevation={0} sx={{
|
||||
width: 260,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 0,
|
||||
borderLeft: '1px solid #e2e8f0',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={PANEL_HEADER_SX}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Bedarfe</Typography>
|
||||
{/* Filter bar */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'white',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
px: 3,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
size="small"
|
||||
value={filterStrength}
|
||||
onChange={e => setFilterStrength(e.target.value)}
|
||||
displayEmpty
|
||||
sx={{ fontSize: '0.8125rem', minWidth: 160 }}
|
||||
>
|
||||
{STRENGTH_OPTIONS.map(o => (
|
||||
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>{o.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="small"
|
||||
value={filterStatus}
|
||||
onChange={e => setFilterStatus(e.target.value)}
|
||||
displayEmpty
|
||||
sx={{ fontSize: '0.8125rem', minWidth: 160 }}
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>{o.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
||||
{filtered.length} von {matches.length} Matches
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Match list */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: '#f8fafc' }}>
|
||||
{isLoading ? (
|
||||
<MatchCenterSkeleton />
|
||||
) : filtered.length === 0 ? (
|
||||
<Box sx={{ p: 6, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">Keine Matches für diese Filter.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ bgcolor: 'white' }}>
|
||||
{filtered.map(match => (
|
||||
<MatchListCard
|
||||
key={match.id}
|
||||
match={match}
|
||||
property={propMap.get(match.propertyId)}
|
||||
need={needMap.get(match.needId)}
|
||||
onSelect={() => handleSelectMatch(match)}
|
||||
onApprove={() => approveMatch.mutate(match.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Detail Drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!selectedMatchId}
|
||||
onClose={handleCloseDrawer}
|
||||
slotProps={{ paper: { sx: { width: 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Match-Briefing</Typography>
|
||||
<IconButton size="small" onClick={handleCloseDrawer} sx={{ color: '#94a3b8' }}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<MatchBriefingPanel />
|
||||
</Box>
|
||||
</Box>
|
||||
<NeedSelectionPanel matches={matches} />
|
||||
</Paper>
|
||||
</Drawer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,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) {
|
||||
|
||||
@@ -1,9 +1,127 @@
|
||||
import type { INeedProvider, NeedFilters } from './INeedProvider'
|
||||
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||
import { mockNeeds } from '../mock-data/needs'
|
||||
import { matchStore } from './MockupMatchProvider'
|
||||
import { propertyStore } from './MockupPropertyProvider'
|
||||
import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums'
|
||||
import type { Match } from '../domain/match'
|
||||
|
||||
const store: Need[] = [...mockNeeds]
|
||||
|
||||
// ── Location scoring ───────────────────────────────────────────────────────────
|
||||
|
||||
const CANTON_MAP: Record<string, string> = {
|
||||
zürich: 'zh', zug: 'zg', winterthur: 'zh', uster: 'zh', bülach: 'zh', oerlikon: 'zh',
|
||||
bern: 'be', biel: 'be', thun: 'be', köniz: 'be',
|
||||
basel: 'bs', muttenz: 'bl', pratteln: 'bl', reinach: 'bl', allschwil: 'bl', binningen: 'bl',
|
||||
genf: 'ge', genève: 'ge', carouge: 'ge', lancy: 'ge',
|
||||
'st. gallen': 'sg', 'st.gallen': 'sg', rapperswil: 'sg',
|
||||
}
|
||||
|
||||
function locationScore(propCity: string, preferredLocations: string[]): number {
|
||||
const pc = propCity.toLowerCase()
|
||||
for (const pref of preferredLocations) {
|
||||
const p = pref.toLowerCase()
|
||||
if (pc.includes(p) || p.includes(pc)) return 1.0
|
||||
}
|
||||
// Same canton check
|
||||
const propCanton = CANTON_MAP[pc]
|
||||
if (propCanton) {
|
||||
for (const pref of preferredLocations) {
|
||||
const prefCanton = CANTON_MAP[pref.toLowerCase()]
|
||||
if (prefCanton && prefCanton === propCanton) return 0.55
|
||||
}
|
||||
}
|
||||
return 0.30
|
||||
}
|
||||
|
||||
function computeScore(prop: { assetType: string; areaSqm: number; rentPricePerSqm: number; location: { city: string } }, need: Need): number | null {
|
||||
if (need.assetType && prop.assetType !== need.assetType) return null
|
||||
|
||||
const locScore = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
|
||||
// Location dominates: same city → 50-90 base, different → 25-45
|
||||
let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28
|
||||
|
||||
// Area overlap (+0-20)
|
||||
if (need.requiredArea && prop.areaSqm) {
|
||||
const { min, max } = need.requiredArea
|
||||
if (prop.areaSqm >= min && prop.areaSqm <= max) score += 20
|
||||
else if (prop.areaSqm >= min * 0.7 && prop.areaSqm <= max * 1.5) score += 10
|
||||
else if (prop.areaSqm < min * 0.5 || prop.areaSqm > max * 2) score -= 10
|
||||
}
|
||||
|
||||
// Budget fit (+0-10)
|
||||
if (need.budgetRange?.maxPerSqm && prop.rentPricePerSqm) {
|
||||
if (prop.rentPricePerSqm <= need.budgetRange.maxPerSqm) score += 10
|
||||
else if (prop.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.2) score += 3
|
||||
else score -= 8
|
||||
}
|
||||
|
||||
// Small jitter so results look natural
|
||||
score += Math.floor(Math.random() * 6) - 2
|
||||
|
||||
return Math.min(97, Math.max(22, score))
|
||||
}
|
||||
|
||||
function strengthFromScore(s: number): string {
|
||||
if (s >= 75) return MatchStrength.STRONG
|
||||
if (s >= 55) return MatchStrength.MODERATE
|
||||
return MatchStrength.WEAK
|
||||
}
|
||||
|
||||
function generateSyntheticMatches(need: Need) {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
for (const prop of propertyStore) {
|
||||
const score = computeScore(prop, need)
|
||||
if (score === null || score < 25) continue
|
||||
|
||||
const locS = locationScore(prop.location.city, need.preferredLocations ?? [])
|
||||
const isGoodLoc = locS >= 0.9
|
||||
|
||||
const match: Match = {
|
||||
id: crypto.randomUUID(),
|
||||
propertyId: prop.id,
|
||||
needId: need.id,
|
||||
resultId: prop.id,
|
||||
resultType: prop.resultType ?? 'VERIFIED_PORTFOLIO',
|
||||
matchScore: score,
|
||||
matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength],
|
||||
status: score >= 75 ? MatchStatus.PENDING_REVIEW : MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: score + 5,
|
||||
softFactorScore: score - 5,
|
||||
confidenceModifier: isGoodLoc ? 0.96 : 0.82,
|
||||
dataQualityModifier: 0.92,
|
||||
totalScore: score,
|
||||
},
|
||||
positiveFactors: isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }]
|
||||
: [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${prop.areaSqm} m² verfügbar` }],
|
||||
negativeFactors: !isGoodLoc
|
||||
? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }]
|
||||
: [],
|
||||
tradeoffs: !isGoodLoc
|
||||
? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }]
|
||||
: [],
|
||||
explainabilitySummary: isGoodLoc
|
||||
? `${prop.location.city} trifft den Standortwunsch. Objekt entspricht den Kernkriterien.`
|
||||
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
|
||||
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
|
||||
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
matchStore.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MockupNeedProvider: INeedProvider = {
|
||||
async getAll(filters?: NeedFilters) {
|
||||
let results = [...store]
|
||||
@@ -18,6 +136,7 @@ export const MockupNeedProvider: INeedProvider = {
|
||||
async create(data: CreateNeedInput) {
|
||||
const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
||||
store.push(next)
|
||||
generateSyntheticMatches(next)
|
||||
return next
|
||||
},
|
||||
async update(id, data: UpdateNeedInput) {
|
||||
|
||||
@@ -2,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) {
|
||||
|
||||
@@ -30,7 +30,7 @@ const DEMO_USERS: Record<UserRole, MockUser> = {
|
||||
role: UserRole.ORGANIZATION_ADMIN,
|
||||
organizationId: 'org-wincasa',
|
||||
organizationName: 'Wincasa AG',
|
||||
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
|
||||
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND],
|
||||
},
|
||||
[UserRole.PROPERTY_MANAGER]: {
|
||||
id: 'user-pm',
|
||||
|
||||
Reference in New Issue
Block a user