refactor: split PropertyDetail, Anfragen, AISearch god components

PropertyDetail.tsx: 565→118 lines
- Removed duplicate constants (import from MatchDetailPropertyDetails)
- PropertyDetailPublicSections: Preis/Hauptangaben/Eigenschaften/Wegzeit/Einheiten/Beschreibung/Quelle sections
- PropertyContactForm: Verwaltung kontaktieren form

Anfragen.tsx: 481→320 lines
- anfragenKiDetection.ts: STAGE_ORDER, STAGE_LABELS, KI_RULES, detectKiStage
- AnfragenMessageBubble: chat message bubble component
- AnfragenInquiryItem: inquiry list row component

AISearch.tsx: 398→342 lines
- needSearchMapper.ts: generateSummary + buildNeedInput pure functions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 00:55:55 +02:00
parent 4d4ea6d2ff
commit ae82d0e6a0
9 changed files with 663 additions and 689 deletions
@@ -0,0 +1,83 @@
import { Box, Chip, Typography } from '@mui/material'
import { Kanban } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
new: { label: 'Neu', color: '#dc2626', bgColor: '#fef2f2' },
in_progress: { label: 'Aktiv', color: '#d97706', bgColor: '#fffbeb' },
answered: { label: 'Beantwortet', color: '#1a7a4a', bgColor: '#f0fdf4' },
archived: { label: 'Archiviert', color: '#64748b', bgColor: '#f8fafc' },
}
interface AnfragenInquiryItemProps {
inq: Inquiry
isSelected: boolean
hasPipeline: boolean
onSelect: (id: string) => void
}
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
const cfg = STATUS_CONFIG[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1]
return (
<Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
cursor: 'pointer',
bgcolor: isSelected ? '#eff6ff' : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
transition: 'background-color 0.1s ease',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: '#dc2626', flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{inq.tenantName}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{inq.unreadCount > 0 && (
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: '#dc2626', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
</Box>
)}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
</Box>
</Box>
{inq.tenantCompany && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
{inq.tenantCompany}
</Typography>
)}
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
{inq.subject}
</Typography>
{lastMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
{lastMsg.body.split('\n')[0]}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label={cfg?.label ?? inq.status} size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bgColor, color: cfg?.color, fontWeight: 600 }} />
{inq.matchScore && (
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}>
{inq.matchScore}%
</Typography>
)}
{hasPipeline && (
<Chip
icon={<Kanban size={9} />}
label="Pipeline"
size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, '& .MuiChip-icon': { color: '#1e3a5f' } }}
/>
)}
</Box>
</Box>
)
}
@@ -0,0 +1,70 @@
import { Box, Typography } from '@mui/material'
import { Bot, Building2, FileText } from 'lucide-react'
import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
const isOwnMessage = msg.senderType === 'tenant'
const isAI = msg.senderType === 'ai'
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const date = new Date(msg.createdAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
{!isOwnMessage && isAI && <Bot size={11} color="#7c3aed" />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color="#64748b" />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time}
</Typography>
</Box>
<Box
sx={{
maxWidth: { xs: '88%', md: '72%' },
px: 2, py: 1.25,
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
bgcolor: isOwnMessage ? '#1e3a5f' : isAI ? '#f5f3ff' : 'white',
border: isOwnMessage ? 'none' : isAI ? '1px solid #ddd6fe' : '1px solid #e2e8f0',
boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
}}
>
<Typography
variant="body2"
sx={{
color: isOwnMessage ? 'white' : isAI ? '#5b21b6' : '#1e293b',
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
}}
>
{msg.body}
</Typography>
{msg.attachments.length > 0 && (
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{msg.attachments.map(att => (
<Box
key={att.id}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.5, py: 0.75,
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : '#f1f5f9',
borderRadius: 1.5, cursor: 'pointer',
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : '#e2e8f0' },
}}
>
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : '#64748b'} />
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : '#475569', flex: 1 }} noWrap>
{att.fileName}
</Typography>
{att.fileSize && (
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : '#94a3b8', flexShrink: 0 }}>
{att.fileSize < 1024 * 1024
? `${Math.round(att.fileSize / 1024)} KB`
: `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`}
</Typography>
)}
</Box>
))}
</Box>
)}
</Box>
</Box>
)
}
@@ -0,0 +1,104 @@
import { useState } from 'react'
import {
Alert,
Box,
Button,
Paper,
TextField,
Typography,
} from '@mui/material'
import { Calendar, CheckCircle2, Mail } from 'lucide-react'
import type { PropertyUnit } from '../../domain/property'
interface PropertyContactFormProps {
propertyTitle: string
highlightUnitId?: string | null
units?: PropertyUnit[]
}
export function PropertyContactForm({ propertyTitle, highlightUnitId, units }: PropertyContactFormProps) {
const [inquiryName, setInquiryName] = useState('')
const [inquiryText, setInquiryText] = useState('')
const [sent, setSent] = useState(false)
function handleSendInquiry() {
if (!inquiryName.trim() || !inquiryText.trim()) return
setSent(true)
}
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Mail size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Verwaltung kontaktieren</Typography>
</Box>
{sent ? (
<Alert
icon={<CheckCircle2 size={18} />}
severity="success"
sx={{ borderRadius: 1 }}
>
Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze.
</Alert>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihr Name</Typography>
<TextField
size="small"
fullWidth
placeholder="Max Muster"
value={inquiryName}
onChange={e => setInquiryName(e.target.value)}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Bezug</Typography>
<TextField
size="small"
fullWidth
value={
highlightUnitId
? (units?.find(u => u.id === highlightUnitId)?.unitLabel ?? 'Einheit')
: propertyTitle
}
slotProps={{ input: { readOnly: true } }}
sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }}
/>
</Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihre Nachricht</Typography>
<TextField
size="small"
fullWidth
multiline
rows={4}
placeholder="Wir interessieren uns für die Fläche und möchten gerne einen Besichtigungstermin vereinbaren..."
value={inquiryText}
onChange={e => setInquiryText(e.target.value)}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={12} color="#64748b" />
<Typography variant="caption" color="text.secondary">Antwortzeit: typisch 12 Werktage</Typography>
</Box>
<Button
variant="contained"
size="small"
disabled={!inquiryName.trim() || !inquiryText.trim()}
onClick={handleSendInquiry}
startIcon={<Mail size={14} />}
sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
>
Anfrage senden
</Button>
</Box>
</Box>
)}
</Paper>
)
}
@@ -0,0 +1,287 @@
import {
Box,
Button,
Chip,
Paper,
Typography,
} from '@mui/material'
import {
Building2,
Clock,
ExternalLink,
Info,
Layers,
ShieldCheck,
Tag,
Train,
TrendingUp,
} from 'lucide-react'
import type { Property } from '../../domain/property'
import {
ASSET_LABELS,
FLOOR_LABEL,
KeyFactRow,
PASSERBY_LABELS,
RISK_LABELS,
SOURCE_LABELS,
UnitRow,
} from './MatchDetailPropertyDetails'
interface PropertyDetailPublicSectionsProps {
property: Property
highlightUnitId?: string | null
}
export function PropertyDetailPublicSections({ property, highlightUnitId }: PropertyDetailPublicSectionsProps) {
const preMarketUnits = (property.units ?? []).filter(u => u.schattenmarktRelease?.enabled)
const otherUnits = (property.units ?? []).filter(u => !u.schattenmarktRelease?.enabled)
const flexibleUnits = (property.units ?? []).filter(u => u.isFlexible && u.minLettableSqm !== undefined)
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
const minLettable = property.areaSqmMin
?? (flexibleUnits.length > 0
? Math.min(...flexibleUnits.map(u => u.minLettableSqm!))
: undefined)
const sourceLabel = property.sourceLabel
?? property.sourceMeta?.sourceLabel
?? SOURCE_LABELS[property.sourceType]
?? property.sourceType
return (
<>
{/* ── Preis ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Tag size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
</Box>
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} />
<KeyFactRow label="Pro m²/Monat" value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.`} />
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.`} />
{property.ancillaryCosts != null && (
<KeyFactRow
label="Nebenkosten"
value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`}
/>
)}
</Paper>
{/* ── Hauptangaben ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
</Box>
<KeyFactRow
label="Verfügbarkeit"
value={
property.availabilityDate
? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })
: 'Auf Anfrage'
}
/>
<KeyFactRow label="Objekttyp" value={ASSET_LABELS[property.assetType] ?? property.assetType} />
<KeyFactRow label="Nutzfläche" value={`${property.areaSqm.toLocaleString('de-CH')}`} />
{minLettable != null && (
<KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')}`} />
)}
{property.contractDurationMonths != null && (
<KeyFactRow label="Mietdauer" value={`${property.contractDurationMonths} Monate`} />
)}
{property.floorLevel != null && (
<KeyFactRow label="Stockwerk" value={FLOOR_LABEL(property.floorLevel)} />
)}
{property.currentTenant && (
<KeyFactRow label="Aktueller Mieter" value={property.currentTenant} />
)}
{property.leaseEndDate && (
<KeyFactRow
label="Mietvertragsende"
value={new Date(property.leaseEndDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
/>
)}
{property.breakoutOption && (
<KeyFactRow
label="Break-out Option"
value={
property.breakoutOptionDate
? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
: 'Ja'
}
/>
)}
{property.riskLevel && (
<KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />
)}
{property.expansionPotentialSqm != null && (
<KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />
)}
</Paper>
{/* ── Eigenschaften ── */}
{property.softFactors && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{property.softFactors.publicTransportMinutes != null && (
<Chip
size="small"
icon={<Train size={11} />}
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }}
/>
)}
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
<Chip
size="small"
label={`${property.softFactors.parkingSpots} Parkplätze`}
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }}
/>
)}
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
<Chip
size="small"
label="Prestigestandort"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
/>
)}
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
<Chip
size="small"
label="Hohe Sichtbarkeit"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
/>
)}
{property.softFactors.passerbyFrequency && (
<Chip
size="small"
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }}
/>
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip
size="small"
label="Hoher Talentzugang"
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
/>
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip
size="small"
label={`Talentindex: ${property.softFactors.talentAccess}`}
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
/>
)}
</Box>
</Paper>
)}
{/* ── Wegzeit ── */}
{property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{property.softFactors.publicTransportMinutes} Min. zu Fuss
</Typography>
<Typography variant="caption" color="text.secondary">
Nächster ÖV-Anschluss {property.location.city}
</Typography>
</Box>
</Box>
{property.softFactors.infrastructureNotes && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
{property.softFactors.infrastructureNotes}
</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Die Zeiten beziehen sich auf die Strecke zu Fuss.
</Typography>
</Paper>
)}
{/* ── Einheiten ── */}
{(property.units ?? []).length > 0 && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
{['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
))}
</Box>
{preMarketUnits.map(u => (
<UnitRow key={u.id} unit={u} highlighted={u.id === highlightUnitId || preMarketUnits.length === 1} />
))}
{otherUnits.map(u => (
<UnitRow key={u.id} unit={u} highlighted={u.id === highlightUnitId} />
))}
</Paper>
)}
{/* ── Beschreibung ── */}
{property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</Box>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
{property.description}
</Typography>
</Paper>
)}
{/* ── Quelle & Referenz ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} />
{property.propertyNumber && (
<KeyFactRow label="Objektnummer" value={property.propertyNumber} />
)}
{property.importedFrom && (
<KeyFactRow label="Importiert aus" value={property.importedFrom} />
)}
{property.dataQuality.lastVerifiedAt && (
<KeyFactRow
label="Zuletzt verifiziert"
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
/>
)}
{property.sourceUrl && (
<Box sx={{ mt: 1.25 }}>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={12} />}
href={property.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}
>
Zum Originalinserat
</Button>
</Box>
)}
</Paper>
</>
)
}
+1 -57
View File
@@ -23,63 +23,7 @@ import { needService } from '../../services/needService'
import { weightingService } from '../../services/weightingService'
import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import type { CreateNeedInput } from '../../domain/need'
// ── Helpers ───────────────────────────────────────────────────────────────────
const ASSET_LABELS_TEXT: Record<string, string> = {
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
}
function generateSummary(c: ParsedNeedCriteria): string {
const parts: string[] = []
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
if (c.areaRange && (c.areaRange.min > 0 || c.areaRange.max > 0))
parts.push(`${c.areaRange.min}${c.areaRange.max}`)
if (c.preferredLocations?.length) parts.push(`in ${c.preferredLocations.join(', ')}`)
if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`)
if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`)
if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`)
return parts.join(', ')
}
function buildNeedInput(
criteria: ParsedNeedCriteria,
weights: Record<WeightingKey, number>,
needTitle: string,
overallConfidence: number,
status: 'DRAFT' | 'ACTIVE',
): CreateNeedInput {
return {
companyName: needTitle || criteria.companyName || 'Neue Suche',
assetType: criteria.assetType ?? AssetType.UNKNOWN,
requiredArea: criteria.areaRange ?? { min: 0, max: 0 },
preferredLocations: criteria.preferredLocations ?? [],
budgetRange: criteria.budgetRange ?? { maxPerSqm: 0, currency: 'CHF' },
timing: {
earliestMoveIn: criteria.timing?.earliestMoveIn ?? '',
latestMoveIn: criteria.timing?.latestMoveIn ?? criteria.timing?.earliestMoveIn ?? '',
contractDurationMonths: criteria.timing?.contractDurationMonths,
flexibleTiming: criteria.timing?.flexibleTiming ?? true,
},
weightingProfile: weights,
confidenceInCriteria: overallConfidence,
status,
mustCriteriaText: criteria.mustHaveCriteria ?? [],
requireGroundFloor: criteria.requireGroundFloor,
requiredFitOut: criteria.requiredFitOut,
requiredParkingMin: criteria.requiredParkingMin,
requireAirConditioning: criteria.requireAirConditioning,
requireLoadingDock: criteria.requireLoadingDock,
requireBarrierFree: criteria.requireBarrierFree,
minCeilingHeightM: criteria.minCeilingHeightM,
minContractDurationMonths: criteria.minContractDurationMonths,
notes: criteria.notes,
extractedFromText: undefined,
}
}
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
// ── Action intent ─────────────────────────────────────────────────────────────
+13 -174
View File
@@ -4,12 +4,14 @@ import {
Box, Typography, TextField, Chip, Avatar, IconButton,
InputAdornment, Alert,
} from '@mui/material'
import { Search, Send, Paperclip, ArrowLeft, FileText, Bot, Building2, Kanban } from 'lucide-react'
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
import { mockInquiries } from '../../mock-data/inquiries'
import { usePipelineStore } from '../../stores/pipelineStore'
import { useToastStore } from '../../stores/toastStore'
import type { InquiryMessage } from '../../domain/inquiry'
import type { PipelineStage } from '../../domain/pipeline'
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
// ── Config ────────────────────────────────────────────────────────────────────
@@ -27,111 +29,6 @@ const FILTER_TABS = [
{ key: 'answered', label: 'Beantwortet' },
]
// Stage order for auto-advance (KI only advances, never goes back)
const STAGE_ORDER: PipelineStage[] = ['SAVED', 'DISCOVERED', 'QUALIFIED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST']
const STAGE_LABELS: Record<string, string> = {
SAVED: 'Gemerkt', DISCOVERED: 'Entdeckt', QUALIFIED: 'Qualifiziert',
VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt',
}
// KI keyword detection
const KI_RULES: { keywords: string[]; stage: PipelineStage; label: string }[] = [
{
keywords: ['vertrag unterschrieben', 'unterschrieben', 'deal abgeschlossen', 'abgeschlossen und fix', 'mietbeginn bestätigt'],
stage: 'CLOSED_WON',
label: 'Abschluss erkannt',
},
{
keywords: ['mietvertrag', 'vertragsvorlage', 'anbiet', 'konditionen verhandl', 'preisvorstellung'],
stage: 'NEGOTIATION',
label: 'Verhandlung erkannt',
},
{
keywords: ['besichtigungstermin', 'besichtigung', 'besichtigen', 'vorort termin', 'vor ort', 'terminvorschlag', 'termin bestätigt', 'termin vereinbart'],
stage: 'VISITED',
label: 'Besichtigungstermin erkannt',
},
]
function detectKiStage(text: string): { stage: PipelineStage; label: string } | null {
const lower = text.toLowerCase()
for (const rule of KI_RULES) {
if (rule.keywords.some(kw => lower.includes(kw))) {
return { stage: rule.stage, label: rule.label }
}
}
return null
}
// ── MessageBubble ─────────────────────────────────────────────────────────────
function MessageBubble({ msg }: { msg: InquiryMessage }) {
const isOwnMessage = msg.senderType === 'tenant'
const isAI = msg.senderType === 'ai'
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const date = new Date(msg.createdAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
{!isOwnMessage && isAI && <Bot size={11} color="#7c3aed" />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color="#64748b" />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time}
</Typography>
</Box>
<Box
sx={{
maxWidth: { xs: '88%', md: '72%' },
px: 2, py: 1.25,
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
bgcolor: isOwnMessage ? '#1e3a5f' : isAI ? '#f5f3ff' : 'white',
border: isOwnMessage ? 'none' : isAI ? '1px solid #ddd6fe' : '1px solid #e2e8f0',
boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
}}
>
<Typography
variant="body2"
sx={{
color: isOwnMessage ? 'white' : isAI ? '#5b21b6' : '#1e293b',
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
}}
>
{msg.body}
</Typography>
{msg.attachments.length > 0 && (
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{msg.attachments.map(att => (
<Box
key={att.id}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.5, py: 0.75,
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : '#f1f5f9',
borderRadius: 1.5, cursor: 'pointer',
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : '#e2e8f0' },
}}
>
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : '#64748b'} />
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : '#475569', flex: 1 }} noWrap>
{att.fileName}
</Typography>
{att.fileSize && (
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : '#94a3b8', flexShrink: 0 }}>
{att.fileSize < 1024 * 1024
? `${Math.round(att.fileSize / 1024)} KB`
: `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`}
</Typography>
)}
</Box>
))}
</Box>
)}
</Box>
</Box>
)
}
// ── Anfragen page ─────────────────────────────────────────────────────────────
export default function Anfragen() {
@@ -278,73 +175,15 @@ export default function Anfragen() {
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">Keine Anfragen gefunden.</Typography>
</Box>
) : filtered.map(inq => {
const cfg = STATUS_CONFIG[inq.status ?? 'new']
const isSelected = inq.id === selectedId
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1]
const hasPipeline = !!(inq.propertyId ? findByPropertyId(inq.propertyId) : findByInquiryId(inq.id))
return (
<Box key={inq.id} onClick={() => handleSelect(inq.id)} sx={{
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
cursor: 'pointer',
bgcolor: isSelected ? '#eff6ff' : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
transition: 'background-color 0.1s ease',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: '#dc2626', flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{inq.tenantName}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{inq.unreadCount > 0 && (
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: '#dc2626', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
</Box>
)}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
</Box>
</Box>
{inq.tenantCompany && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
{inq.tenantCompany}
</Typography>
)}
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
{inq.subject}
</Typography>
{lastMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
{lastMsg.body.split('\n')[0]}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label={cfg?.label ?? inq.status} size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bgColor, color: cfg?.color, fontWeight: 600 }} />
{inq.matchScore && (
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}>
{inq.matchScore}%
</Typography>
)}
{hasPipeline && (
<Chip
icon={<Kanban size={9} />}
label="Pipeline"
size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, '& .MuiChip-icon': { color: '#1e3a5f' } }}
) : filtered.map(inq => (
<AnfragenInquiryItem
key={inq.id}
inq={inq}
isSelected={inq.id === selectedId}
hasPipeline={!!(inq.propertyId ? findByPropertyId(inq.propertyId) : findByInquiryId(inq.id))}
onSelect={handleSelect}
/>
)}
</Box>
</Box>
)
})}
))}
</Box>
</Box>
@@ -446,7 +285,7 @@ export default function Anfragen() {
{/* Thread */}
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{selected.thread.map(msg => (
<MessageBubble key={msg.id} msg={msg} />
<AnfragenMessageBubble key={msg.id} msg={msg} />
))}
</Box>
+9 -456
View File
@@ -1,132 +1,17 @@
import { useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Divider,
Paper,
TextField,
Typography,
} from '@mui/material'
import {
ArrowLeft,
Building2,
Calendar,
CheckCircle2,
Clock,
ExternalLink,
Info,
Layers,
Mail,
MapPin,
ShieldCheck,
Tag,
Train,
TrendingUp,
} from 'lucide-react'
import { ArrowLeft, Building2, MapPin, ShieldCheck } from 'lucide-react'
import { usePropertyById } from '../../hooks/useProperties'
import type { PropertyUnit } from '../../domain/property'
// ── Helpers ───────────────────────────────────────────────────────────────────
const FLOOR_LABEL = (level: number) =>
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
}
const RISK_LABELS: Record<string, string> = {
LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch',
}
const SOURCE_LABELS: Record<string, string> = {
ERP_IMPORT: 'ERP-Import (intern)',
IMMOSCOUT_SCRAPE: 'ImmoScout24',
HOMEGATE_SCRAPE: 'Homegate',
MATCHOFFICE_SCRAPE: 'MatchOffice',
NEWHOME_SCRAPE: 'newhome.ch',
AI_SIGNAL: 'KI-Signal',
MANUAL: 'Manuell erfasst',
}
const PASSERBY_LABELS: Record<string, string> = {
LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch',
}
// ── Sub-components ────────────────────────────────────────────────────────────
function KeyFactRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
</Box>
)
}
function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
if (unit.schattenmarktRelease?.enabled) {
return (
<Chip
size="small"
icon={<ShieldCheck size={11} />}
label="PRE-MARKET"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }}
/>
)
}
if (unit.available) {
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
}
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
}
function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) {
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate
const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 80px 110px 120px auto',
gap: 1.5,
alignItems: 'center',
px: 2,
py: 1.5,
borderRadius: 1,
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
mb: 1,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
</Typography>
{unit.currentTenant && (
<Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>
)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
<Typography variant="body2" color="text.secondary">
{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : ''}
</Typography>
<Typography variant="body2" color="text.secondary">
{availableFrom
? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
: ''}
</Typography>
<UnitStatusChip unit={unit} />
</Box>
)
}
import { ASSET_LABELS } from '../../components/match-detail/MatchDetailPropertyDetails'
import { PropertyDetailPublicSections } from '../../components/match-detail/PropertyDetailPublicSections'
import { PropertyContactForm } from '../../components/demand/PropertyContactForm'
// ── Page ──────────────────────────────────────────────────────────────────────
@@ -138,10 +23,6 @@ export default function PropertyDetail() {
const { data: property, isLoading } = usePropertyById(propertyId ?? '')
const [inquiryName, setInquiryName] = useState('')
const [inquiryText, setInquiryText] = useState('')
const [sent, setSent] = useState(false)
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}>
@@ -159,25 +40,7 @@ export default function PropertyDetail() {
}
const preMarketUnits = (property.units ?? []).filter(u => u.schattenmarktRelease?.enabled)
const otherUnits = (property.units ?? []).filter(u => !u.schattenmarktRelease?.enabled)
const flexibleUnits = (property.units ?? []).filter(u => u.isFlexible && u.minLettableSqm !== undefined)
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
const minLettable = property.areaSqmMin
?? (flexibleUnits.length > 0
? Math.min(...flexibleUnits.map(u => u.minLettableSqm!))
: undefined)
const sourceLabel = property.sourceLabel
?? property.sourceMeta?.sourceLabel
?? SOURCE_LABELS[property.sourceType]
?? property.sourceType
function handleSendInquiry() {
if (!inquiryName.trim() || !inquiryText.trim()) return
setSent(true)
}
return (
<Box sx={{ maxWidth: 860, mx: 'auto', p: { xs: 2, md: 3 } }}>
@@ -238,323 +101,13 @@ export default function PropertyDetail() {
</Box>
</Paper>
{/* ── Preis ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Tag size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
</Box>
<KeyFactRow
label="Monatliche Miete"
value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`}
/>
<KeyFactRow
label="Pro m²/Monat"
value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.`}
/>
<KeyFactRow
label="Pro m²/Jahr"
value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.`}
/>
{property.ancillaryCosts != null && (
<KeyFactRow
label="Nebenkosten"
value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`}
/>
)}
</Paper>
<PropertyDetailPublicSections property={property} highlightUnitId={highlightUnitId} />
{/* ── Hauptangaben ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
</Box>
<KeyFactRow
label="Verfügbarkeit"
value={
property.availabilityDate
? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })
: 'Auf Anfrage'
}
<PropertyContactForm
propertyTitle={property.title}
highlightUnitId={highlightUnitId}
units={property.units}
/>
<KeyFactRow label="Objekttyp" value={ASSET_LABELS[property.assetType] ?? property.assetType} />
<KeyFactRow label="Nutzfläche" value={`${property.areaSqm.toLocaleString('de-CH')}`} />
{minLettable != null && (
<KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')}`} />
)}
{property.contractDurationMonths != null && (
<KeyFactRow label="Mietdauer" value={`${property.contractDurationMonths} Monate`} />
)}
{property.floorLevel != null && (
<KeyFactRow label="Stockwerk" value={FLOOR_LABEL(property.floorLevel)} />
)}
{property.currentTenant && (
<KeyFactRow label="Aktueller Mieter" value={property.currentTenant} />
)}
{property.leaseEndDate && (
<KeyFactRow
label="Mietvertragsende"
value={new Date(property.leaseEndDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
/>
)}
{property.breakoutOption && (
<KeyFactRow
label="Break-out Option"
value={
property.breakoutOptionDate
? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
: 'Ja'
}
/>
)}
{property.riskLevel && (
<KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />
)}
{property.expansionPotentialSqm != null && (
<KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />
)}
</Paper>
{/* ── Eigenschaften ── */}
{property.softFactors && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{property.softFactors.publicTransportMinutes != null && (
<Chip
size="small"
icon={<Train size={11} />}
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }}
/>
)}
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
<Chip
size="small"
label={`${property.softFactors.parkingSpots} Parkplätze`}
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }}
/>
)}
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
<Chip
size="small"
label="Prestigestandort"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
/>
)}
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
<Chip
size="small"
label="Hohe Sichtbarkeit"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
/>
)}
{property.softFactors.passerbyFrequency && (
<Chip
size="small"
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }}
/>
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip
size="small"
label="Hoher Talentzugang"
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
/>
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip
size="small"
label={`Talentindex: ${property.softFactors.talentAccess}`}
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
/>
)}
</Box>
</Paper>
)}
{/* ── Wegzeit ── */}
{property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{property.softFactors.publicTransportMinutes} Min. zu Fuss
</Typography>
<Typography variant="caption" color="text.secondary">
Nächster ÖV-Anschluss {property.location.city}
</Typography>
</Box>
</Box>
{property.softFactors.infrastructureNotes && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
{property.softFactors.infrastructureNotes}
</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Die Zeiten beziehen sich auf die Strecke zu Fuss.
</Typography>
</Paper>
)}
{/* ── Einheiten ── */}
{(property.units ?? []).length > 0 && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
{['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
))}
</Box>
{preMarketUnits.map(u => (
<UnitRow key={u.id} unit={u} highlighted={u.id === highlightUnitId || preMarketUnits.length === 1} />
))}
{otherUnits.map(u => (
<UnitRow key={u.id} unit={u} highlighted={u.id === highlightUnitId} />
))}
</Paper>
)}
{/* ── Beschreibung ── */}
{property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</Box>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
{property.description}
</Typography>
</Paper>
)}
{/* ── Quelle & Referenz ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} />
{property.propertyNumber && (
<KeyFactRow label="Objektnummer" value={property.propertyNumber} />
)}
{property.importedFrom && (
<KeyFactRow label="Importiert aus" value={property.importedFrom} />
)}
{property.dataQuality.lastVerifiedAt && (
<KeyFactRow
label="Zuletzt verifiziert"
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
/>
)}
{property.sourceUrl && (
<Box sx={{ mt: 1.25 }}>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={12} />}
href={property.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}
>
Zum Originalinserat
</Button>
</Box>
)}
</Paper>
{/* ── Verwaltung kontaktieren ── */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Mail size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Verwaltung kontaktieren</Typography>
</Box>
{sent ? (
<Alert
icon={<CheckCircle2 size={18} />}
severity="success"
sx={{ borderRadius: 1 }}
>
Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze.
</Alert>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihr Name</Typography>
<TextField
size="small"
fullWidth
placeholder="Max Muster"
value={inquiryName}
onChange={e => setInquiryName(e.target.value)}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Bezug</Typography>
<TextField
size="small"
fullWidth
value={
highlightUnitId
? (property.units?.find(u => u.id === highlightUnitId)?.unitLabel ?? 'Einheit')
: property.title
}
slotProps={{ input: { readOnly: true } }}
sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }}
/>
</Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihre Nachricht</Typography>
<TextField
size="small"
fullWidth
multiline
rows={4}
placeholder="Wir interessieren uns für die Fläche und möchten gerne einen Besichtigungstermin vereinbaren..."
value={inquiryText}
onChange={e => setInquiryText(e.target.value)}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={12} color="#64748b" />
<Typography variant="caption" color="text.secondary">Antwortzeit: typisch 12 Werktage</Typography>
</Box>
<Button
variant="contained"
size="small"
disabled={!inquiryName.trim() || !inquiryText.trim()}
onClick={handleSendInquiry}
startIcon={<Mail size={14} />}
sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
>
Anfrage senden
</Button>
</Box>
</Box>
)}
</Paper>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.5 }}>
+38
View File
@@ -0,0 +1,38 @@
import type { PipelineStage } from '../../domain/pipeline'
export const STAGE_ORDER: PipelineStage[] = [
'SAVED', 'DISCOVERED', 'QUALIFIED', 'VISITED', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST',
]
export const STAGE_LABELS: Record<string, string> = {
SAVED: 'Gemerkt', DISCOVERED: 'Entdeckt', QUALIFIED: 'Qualifiziert',
VISITED: 'Besichtigt', NEGOTIATION: 'Verhandlung', CLOSED_WON: 'Gewonnen', CLOSED_LOST: 'Abgelehnt',
}
export const KI_RULES: { keywords: string[]; stage: PipelineStage; label: string }[] = [
{
keywords: ['vertrag unterschrieben', 'unterschrieben', 'deal abgeschlossen', 'abgeschlossen und fix', 'mietbeginn bestätigt'],
stage: 'CLOSED_WON',
label: 'Abschluss erkannt',
},
{
keywords: ['mietvertrag', 'vertragsvorlage', 'anbiet', 'konditionen verhandl', 'preisvorstellung'],
stage: 'NEGOTIATION',
label: 'Verhandlung erkannt',
},
{
keywords: ['besichtigungstermin', 'besichtigung', 'besichtigen', 'vorort termin', 'vor ort', 'terminvorschlag', 'termin bestätigt', 'termin vereinbart'],
stage: 'VISITED',
label: 'Besichtigungstermin erkannt',
},
]
export function detectKiStage(text: string): { stage: PipelineStage; label: string } | null {
const lower = text.toLowerCase()
for (const rule of KI_RULES) {
if (rule.keywords.some(kw => lower.includes(kw))) {
return { stage: rule.stage, label: rule.label }
}
}
return null
}
+56
View File
@@ -0,0 +1,56 @@
import { AssetType } from '../../domain/enums'
import type { ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import type { CreateNeedInput } from '../../domain/need'
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',
}
export function generateSummary(c: ParsedNeedCriteria): string {
const parts: string[] = []
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
if (c.areaRange && (c.areaRange.min > 0 || c.areaRange.max > 0))
parts.push(`${c.areaRange.min}${c.areaRange.max}`)
if (c.preferredLocations?.length) parts.push(`in ${c.preferredLocations.join(', ')}`)
if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`)
if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`)
if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`)
return parts.join(', ')
}
export function buildNeedInput(
criteria: ParsedNeedCriteria,
weights: Record<WeightingKey, number>,
needTitle: string,
overallConfidence: number,
status: 'DRAFT' | 'ACTIVE',
): CreateNeedInput {
return {
companyName: needTitle || criteria.companyName || 'Neue Suche',
assetType: criteria.assetType ?? AssetType.UNKNOWN,
requiredArea: criteria.areaRange ?? { min: 0, max: 0 },
preferredLocations: criteria.preferredLocations ?? [],
budgetRange: criteria.budgetRange ?? { maxPerSqm: 0, currency: 'CHF' },
timing: {
earliestMoveIn: criteria.timing?.earliestMoveIn ?? '',
latestMoveIn: criteria.timing?.latestMoveIn ?? criteria.timing?.earliestMoveIn ?? '',
contractDurationMonths: criteria.timing?.contractDurationMonths,
flexibleTiming: criteria.timing?.flexibleTiming ?? true,
},
weightingProfile: weights,
confidenceInCriteria: overallConfidence,
status,
mustCriteriaText: criteria.mustHaveCriteria ?? [],
requireGroundFloor: criteria.requireGroundFloor,
requiredFitOut: criteria.requiredFitOut,
requiredParkingMin: criteria.requiredParkingMin,
requireAirConditioning: criteria.requireAirConditioning,
requireLoadingDock: criteria.requireLoadingDock,
requireBarrierFree: criteria.requireBarrierFree,
minCeilingHeightM: criteria.minCeilingHeightM,
minContractDurationMonths: criteria.minContractDurationMonths,
notes: criteria.notes,
extractedFromText: undefined,
}
}