From ae82d0e6a0a4b3f9134785d10e9e886d0d2d13bc Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 00:55:55 +0200 Subject: [PATCH] refactor: split PropertyDetail, Anfragen, AISearch god components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/demand/AnfragenInquiryItem.tsx | 83 ++++ .../demand/AnfragenMessageBubble.tsx | 70 +++ src/components/demand/PropertyContactForm.tsx | 104 ++++ .../PropertyDetailPublicSections.tsx | 287 +++++++++++ src/pages/demand/AISearch.tsx | 58 +-- src/pages/demand/Anfragen.tsx | 189 +------ src/pages/demand/PropertyDetail.tsx | 467 +----------------- src/pages/demand/anfragenKiDetection.ts | 38 ++ src/services/aiSearch/needSearchMapper.ts | 56 +++ 9 files changed, 663 insertions(+), 689 deletions(-) create mode 100644 src/components/demand/AnfragenInquiryItem.tsx create mode 100644 src/components/demand/AnfragenMessageBubble.tsx create mode 100644 src/components/demand/PropertyContactForm.tsx create mode 100644 src/components/match-detail/PropertyDetailPublicSections.tsx create mode 100644 src/pages/demand/anfragenKiDetection.ts create mode 100644 src/services/aiSearch/needSearchMapper.ts diff --git a/src/components/demand/AnfragenInquiryItem.tsx b/src/components/demand/AnfragenInquiryItem.tsx new file mode 100644 index 0000000..2a4bdd7 --- /dev/null +++ b/src/components/demand/AnfragenInquiryItem.tsx @@ -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 = { + 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 ( + 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', + }}> + + + {!inq.isRead && } + + {inq.tenantName} + + + + {inq.unreadCount > 0 && ( + + {inq.unreadCount} + + )} + {displayDate} + + + {inq.tenantCompany && ( + + {inq.tenantCompany} + + )} + + {inq.subject} + + {lastMsg && ( + + {lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `} + {lastMsg.body.split('\n')[0]} + + )} + + + {inq.matchScore && ( + = 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}> + {inq.matchScore}% + + )} + {hasPipeline && ( + } + label="Pipeline" + size="small" + sx={{ height: 16, fontSize: '0.6rem', bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, '& .MuiChip-icon': { color: '#1e3a5f' } }} + /> + )} + + + ) +} diff --git a/src/components/demand/AnfragenMessageBubble.tsx b/src/components/demand/AnfragenMessageBubble.tsx new file mode 100644 index 0000000..a70d84a --- /dev/null +++ b/src/components/demand/AnfragenMessageBubble.tsx @@ -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 ( + + + {!isOwnMessage && isAI && } + {!isOwnMessage && msg.senderType === 'supply_user' && } + + {msg.senderName} · {date} {time} + + + + + {msg.body} + + {msg.attachments.length > 0 && ( + + {msg.attachments.map(att => ( + + + + {att.fileName} + + {att.fileSize && ( + + {att.fileSize < 1024 * 1024 + ? `${Math.round(att.fileSize / 1024)} KB` + : `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`} + + )} + + ))} + + )} + + + ) +} diff --git a/src/components/demand/PropertyContactForm.tsx b/src/components/demand/PropertyContactForm.tsx new file mode 100644 index 0000000..79b126a --- /dev/null +++ b/src/components/demand/PropertyContactForm.tsx @@ -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 ( + + + + Verwaltung kontaktieren + + + {sent ? ( + } + severity="success" + sx={{ borderRadius: 1 }} + > + Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze. + + ) : ( + + + + Ihr Name + setInquiryName(e.target.value)} + /> + + + Bezug + u.id === highlightUnitId)?.unitLabel ?? 'Einheit') + : propertyTitle + } + slotProps={{ input: { readOnly: true } }} + sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }} + /> + + + + Ihre Nachricht + setInquiryText(e.target.value)} + /> + + + + + Antwortzeit: typisch 1–2 Werktage + + + + + )} + + ) +} diff --git a/src/components/match-detail/PropertyDetailPublicSections.tsx b/src/components/match-detail/PropertyDetailPublicSections.tsx new file mode 100644 index 0000000..66980c2 --- /dev/null +++ b/src/components/match-detail/PropertyDetailPublicSections.tsx @@ -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 ── */} + + + + Preis + + + + + {property.ancillaryCosts != null && ( + + )} + + + {/* ── Hauptangaben ── */} + + + + Hauptangaben + + + + + {minLettable != null && ( + + )} + {property.contractDurationMonths != null && ( + + )} + {property.floorLevel != null && ( + + )} + {property.currentTenant && ( + + )} + {property.leaseEndDate && ( + + )} + {property.breakoutOption && ( + + )} + {property.riskLevel && ( + + )} + {property.expansionPotentialSqm != null && ( + + )} + + + {/* ── Eigenschaften ── */} + {property.softFactors && ( + + + + Eigenschaften + + + {property.softFactors.publicTransportMinutes != null && ( + } + 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 && ( + + )} + {property.softFactors.prestige != null && property.softFactors.prestige >= 80 && ( + + )} + {property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && ( + + )} + {property.softFactors.passerbyFrequency && ( + + )} + {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( + + )} + {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( + + )} + + + )} + + {/* ── Wegzeit ── */} + {property.softFactors?.publicTransportMinutes != null && ( + + + + Wegzeit + + + + + + + + {property.softFactors.publicTransportMinutes} Min. zu Fuss + + + Nächster ÖV-Anschluss — {property.location.city} + + + + {property.softFactors.infrastructureNotes && ( + + {property.softFactors.infrastructureNotes} + + )} + + Die Zeiten beziehen sich auf die Strecke zu Fuss. + + + )} + + {/* ── Einheiten ── */} + {(property.units ?? []).length > 0 && ( + + + + Einheiten + + + {['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => ( + {h} + ))} + + {preMarketUnits.map(u => ( + + ))} + {otherUnits.map(u => ( + + ))} + + )} + + {/* ── Beschreibung ── */} + {property.description && ( + + + + Beschreibung + + + {property.description} + + + )} + + {/* ── Quelle & Referenz ── */} + + + + Quelle & Referenz + + + {property.propertyNumber && ( + + )} + {property.importedFrom && ( + + )} + {property.dataQuality.lastVerifiedAt && ( + + )} + {property.sourceUrl && ( + + + + )} + + + ) +} diff --git a/src/pages/demand/AISearch.tsx b/src/pages/demand/AISearch.tsx index d3e18d9..83ae48d 100644 --- a/src/pages/demand/AISearch.tsx +++ b/src/pages/demand/AISearch.tsx @@ -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 = { - OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche', - PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche', -} - -function generateSummary(c: ParsedNeedCriteria): string { - const parts: string[] = [] - if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`) - if (c.areaRange && (c.areaRange.min > 0 || c.areaRange.max > 0)) - parts.push(`${c.areaRange.min}–${c.areaRange.max} m²`) - if (c.preferredLocations?.length) parts.push(`in ${c.preferredLocations.join(', ')}`) - if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`) - if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`) - if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`) - return parts.join(', ') -} - -function buildNeedInput( - criteria: ParsedNeedCriteria, - weights: Record, - needTitle: string, - overallConfidence: number, - status: 'DRAFT' | 'ACTIVE', -): CreateNeedInput { - return { - companyName: needTitle || criteria.companyName || '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 ───────────────────────────────────────────────────────────── diff --git a/src/pages/demand/Anfragen.tsx b/src/pages/demand/Anfragen.tsx index cccce81..9dcb556 100644 --- a/src/pages/demand/Anfragen.tsx +++ b/src/pages/demand/Anfragen.tsx @@ -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 = { - 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 ( - - - {!isOwnMessage && isAI && } - {!isOwnMessage && msg.senderType === 'supply_user' && } - - {msg.senderName} · {date} {time} - - - - - {msg.body} - - {msg.attachments.length > 0 && ( - - {msg.attachments.map(att => ( - - - - {att.fileName} - - {att.fileSize && ( - - {att.fileSize < 1024 * 1024 - ? `${Math.round(att.fileSize / 1024)} KB` - : `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`} - - )} - - ))} - - )} - - - ) -} - // ── Anfragen page ───────────────────────────────────────────────────────────── export default function Anfragen() { @@ -278,73 +175,15 @@ export default function Anfragen() { Keine Anfragen gefunden. - ) : 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 ( - 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', - }}> - - - {!inq.isRead && } - - {inq.tenantName} - - - - {inq.unreadCount > 0 && ( - - {inq.unreadCount} - - )} - {displayDate} - - - {inq.tenantCompany && ( - - {inq.tenantCompany} - - )} - - {inq.subject} - - {lastMsg && ( - - {lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `} - {lastMsg.body.split('\n')[0]} - - )} - - - {inq.matchScore && ( - = 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}> - {inq.matchScore}% - - )} - {hasPipeline && ( - } - label="Pipeline" - size="small" - sx={{ height: 16, fontSize: '0.6rem', bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, '& .MuiChip-icon': { color: '#1e3a5f' } }} - /> - )} - - - ) - })} + ) : filtered.map(inq => ( + + ))} @@ -446,7 +285,7 @@ export default function Anfragen() { {/* Thread */} {selected.thread.map(msg => ( - + ))} diff --git a/src/pages/demand/PropertyDetail.tsx b/src/pages/demand/PropertyDetail.tsx index d197387..142420d 100644 --- a/src/pages/demand/PropertyDetail.tsx +++ b/src/pages/demand/PropertyDetail.tsx @@ -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 = { - OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden', - PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)', -} - -const RISK_LABELS: Record = { - LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', -} - -const SOURCE_LABELS: Record = { - 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 = { - 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 ( - - {label} - {value} - - ) -} - -function UnitStatusChip({ unit }: { unit: PropertyUnit }) { - if (unit.schattenmarktRelease?.enabled) { - return ( - } - label="PRE-MARKET" - sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} - /> - ) - } - if (unit.available) { - return - } - return -} - -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 ( - - - - {FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''} - - {unit.currentTenant && ( - {unit.currentTenant} - )} - - {unit.areaSqm.toLocaleString('de-CH')} m² - - {monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : '–'} - - - {availableFrom - ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) - : '–'} - - - - ) -} +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 ( @@ -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 ( @@ -238,323 +101,13 @@ export default function PropertyDetail() { - {/* ── Preis ── */} - - - - Preis - - - - - {property.ancillaryCosts != null && ( - - )} - + - {/* ── Hauptangaben ── */} - - - - Hauptangaben - - - - - {minLettable != null && ( - - )} - {property.contractDurationMonths != null && ( - - )} - {property.floorLevel != null && ( - - )} - {property.currentTenant && ( - - )} - {property.leaseEndDate && ( - - )} - {property.breakoutOption && ( - - )} - {property.riskLevel && ( - - )} - {property.expansionPotentialSqm != null && ( - - )} - - - {/* ── Eigenschaften ── */} - {property.softFactors && ( - - - - Eigenschaften - - - {property.softFactors.publicTransportMinutes != null && ( - } - 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 && ( - - )} - {property.softFactors.prestige != null && property.softFactors.prestige >= 80 && ( - - )} - {property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && ( - - )} - {property.softFactors.passerbyFrequency && ( - - )} - {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( - - )} - {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( - - )} - - - )} - - {/* ── Wegzeit ── */} - {property.softFactors?.publicTransportMinutes != null && ( - - - - Wegzeit - - - - - - - - {property.softFactors.publicTransportMinutes} Min. zu Fuss - - - Nächster ÖV-Anschluss — {property.location.city} - - - - {property.softFactors.infrastructureNotes && ( - - {property.softFactors.infrastructureNotes} - - )} - - Die Zeiten beziehen sich auf die Strecke zu Fuss. - - - )} - - {/* ── Einheiten ── */} - {(property.units ?? []).length > 0 && ( - - - - Einheiten - - - - {['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => ( - {h} - ))} - - - {preMarketUnits.map(u => ( - - ))} - {otherUnits.map(u => ( - - ))} - - )} - - {/* ── Beschreibung ── */} - {property.description && ( - - - - Beschreibung - - - {property.description} - - - )} - - {/* ── Quelle & Referenz ── */} - - - - Quelle & Referenz - - - {property.propertyNumber && ( - - )} - {property.importedFrom && ( - - )} - {property.dataQuality.lastVerifiedAt && ( - - )} - {property.sourceUrl && ( - - - - )} - - - {/* ── Verwaltung kontaktieren ── */} - - - - Verwaltung kontaktieren - - - {sent ? ( - } - severity="success" - sx={{ borderRadius: 1 }} - > - Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze. - - ) : ( - - - - Ihr Name - setInquiryName(e.target.value)} - /> - - - Bezug - u.id === highlightUnitId)?.unitLabel ?? 'Einheit') - : property.title - } - slotProps={{ input: { readOnly: true } }} - sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }} - /> - - - - Ihre Nachricht - setInquiryText(e.target.value)} - /> - - - - - Antwortzeit: typisch 1–2 Werktage - - - - - )} - + diff --git a/src/pages/demand/anfragenKiDetection.ts b/src/pages/demand/anfragenKiDetection.ts new file mode 100644 index 0000000..d3ca764 --- /dev/null +++ b/src/pages/demand/anfragenKiDetection.ts @@ -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 = { + 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 +} diff --git a/src/services/aiSearch/needSearchMapper.ts b/src/services/aiSearch/needSearchMapper.ts new file mode 100644 index 0000000..6f92cf4 --- /dev/null +++ b/src/services/aiSearch/needSearchMapper.ts @@ -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 = { + 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} 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(', ') +} + +export function buildNeedInput( + criteria: ParsedNeedCriteria, + weights: Record, + 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, + } +}