diff --git a/src/components/anfragencenter/LatentInquiryReportPreview.tsx b/src/components/anfragencenter/LatentInquiryReportPreview.tsx index 3543d15..bda734e 100644 --- a/src/components/anfragencenter/LatentInquiryReportPreview.tsx +++ b/src/components/anfragencenter/LatentInquiryReportPreview.tsx @@ -3,6 +3,7 @@ import { Building2, MapPin, Ruler, Calendar } from 'lucide-react' import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport' import type { Property } from '../../domain/property' import type { Inquiry } from '../../domain/inquiry' +import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds' interface Props { draft: InquiryPreparationReportDraft @@ -100,39 +101,39 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props mb: 2, }} > - - + + Objektvorschlag - + Wincasa AG · {new Date().toLocaleDateString('de-CH')} {editableByField['intro'] && ( - + {editableByField['intro']} )} - - + + Ihre Anfrage - + Kontakt: {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''} - + Betreff: {inquiry.subject} {editableByField['highlights'] && ( <> - + Warum diese Objekte passen - + {editableByField['highlights']} @@ -145,7 +146,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props label={p.title} size="small" icon={} - sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }} + sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }} /> ))} @@ -161,7 +162,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props - + Objekt {idx + 1} von {selectedProps.length} @@ -184,7 +185,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props height: 120, borderRadius: 1, overflow: 'hidden', - bgcolor: '#e2e8f0', + bgcolor: DS_BORDER.default, backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none', backgroundSize: 'cover', backgroundPosition: 'center', @@ -194,7 +195,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props justifyContent: 'center', }} > - {!property.mapImageUrl && } + {!property.mapImageUrl && } {/* Property photo */} - {!image && } + {!image && } - + {property.title} - + {property.location.city}{property.location.district ? `, ${property.location.district}` : ''} - + {property.areaSqm.toLocaleString('de-CH')} m² - + {new Date(property.availabilityDate).toLocaleDateString('de-CH')} @@ -240,7 +241,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.75, - bgcolor: '#f8fafc', + bgcolor: DS_BG.page, borderRadius: 1, p: 1.5, }} @@ -250,10 +251,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props if (!val) return null return ( - + {FIELD_LABELS[key] ?? key} - + {val} @@ -268,10 +269,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props {editableByField['next_steps'] && ( - + Nächste Schritte - + {editableByField['next_steps']} diff --git a/src/components/demand/AISearchActionBar.tsx b/src/components/demand/AISearchActionBar.tsx new file mode 100644 index 0000000..704a8e8 --- /dev/null +++ b/src/components/demand/AISearchActionBar.tsx @@ -0,0 +1,63 @@ +import { Alert, Box, Button, CircularProgress, Divider } from '@mui/material' +import { ArrowRight, Bookmark, Search } from 'lucide-react' + +interface Props { + canProceed: boolean + isSearching: boolean + isSavingProfile: boolean + onSearch: () => void + onSaveProfile: () => void +} + +export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, onSearch, onSaveProfile }: Props) { + const isProcessing = isSearching || isSavingProfile + return ( + <> + + + + + + + + + + Jetzt suchen liefert sofortige Ergebnisse.{' '} + Als Suchprofil speichern legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird — auch in Zukunft. + + + ) +} diff --git a/src/components/demand/AISearchSavePreview.tsx b/src/components/demand/AISearchSavePreview.tsx new file mode 100644 index 0000000..f923770 --- /dev/null +++ b/src/components/demand/AISearchSavePreview.tsx @@ -0,0 +1,62 @@ +import { Alert, Box, Button, CircularProgress } from '@mui/material' +import { Save } from 'lucide-react' +import { NeedCardPreview } from './NeedCardPreview' +import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' + +interface Props { + criteria: ParsedNeedCriteria + weights: Record + parseResult: ParseNeedResult + needTitle: string + overallConfidence: number + isSaving: boolean + onNeedTitleChange: (t: string) => void + onBack: () => void + onSave: () => void +} + +export function AISearchSavePreview({ + criteria, + weights, + parseResult, + needTitle, + overallConfidence, + isSaving, + onNeedTitleChange, + onBack, + onSave, +}: Props) { + return ( + + + Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung. + + + + + + + + ) +} diff --git a/src/components/demand/AnfragenInquiryItem.tsx b/src/components/demand/AnfragenInquiryItem.tsx index cf26218..28e897b 100644 --- a/src/components/demand/AnfragenInquiryItem.tsx +++ b/src/components/demand/AnfragenInquiryItem.tsx @@ -1,6 +1,6 @@ import { Box, Chip, Typography } from '@mui/material' import { Kanban } from 'lucide-react' -import { INQUIRY_STATUS_META, DS_COLORS } from '../../lib/ds' +import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds' import type { Inquiry } from '../../domain/inquiry' interface AnfragenInquiryItemProps { @@ -14,28 +14,29 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: const cfg = INQUIRY_STATUS_META[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] + const unreadColor = cfg?.fg ?? DS_TEXT.danger return ( onSelect(inq.id)} sx={{ px: 2, py: 1.5, - borderBottom: '1px solid #f1f5f9', + borderBottom: `1px solid ${DS_BORDER.muted}`, cursor: 'pointer', bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent', borderLeft: isSelected ? '3px solid' : '3px solid transparent', borderLeftColor: isSelected ? 'primary.main' : 'transparent', - '&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : '#f8fafc' }, + '&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : DS_BG.page }, transition: 'background-color 0.1s ease', }}> - {!inq.isRead && } + {!inq.isRead && } {inq.tenantName} {inq.unreadCount > 0 && ( - + {inq.unreadCount} )} @@ -47,7 +48,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: {inq.tenantCompany} )} - + {inq.subject} {lastMsg && ( @@ -60,7 +61,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: {inq.matchScore && ( - = 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}> + = 80 ? DS_TEXT.success : DS_TEXT.warning, fontWeight: 700, fontSize: '0.7rem' }}> {inq.matchScore}% )} diff --git a/src/components/demand/AnfragenMessageBubble.tsx b/src/components/demand/AnfragenMessageBubble.tsx index 8a4cd92..1cb24f0 100644 --- a/src/components/demand/AnfragenMessageBubble.tsx +++ b/src/components/demand/AnfragenMessageBubble.tsx @@ -1,6 +1,6 @@ import { Box, Typography } from '@mui/material' import { Bot, Building2, FileText } from 'lucide-react' -import { DS_COLORS } from '../../lib/ds' +import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds' import type { InquiryMessage } from '../../domain/inquiry' export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) { @@ -12,8 +12,8 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) { return ( - {!isOwnMessage && isAI && } - {!isOwnMessage && msg.senderType === 'supply_user' && } + {!isOwnMessage && isAI && } + {!isOwnMessage && msg.senderType === 'supply_user' && } {msg.senderName} · {date} {time} @@ -24,14 +24,14 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) { px: 2, py: 1.25, borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px', bgcolor: isOwnMessage ? 'primary.main' : isAI ? DS_COLORS.futureCard.controlled.headerBg : 'white', - border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : '1px solid #e2e8f0', + border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : `1px solid ${DS_BORDER.default}`, boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)', }} > @@ -45,17 +45,17 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) { sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.75, - bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : '#f1f5f9', + bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : DS_BG.subtle, borderRadius: 1.5, cursor: 'pointer', - '&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : '#e2e8f0' }, + '&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : DS_BG.muted }, }} > - - + + {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/NeedExtendedRequirements.tsx b/src/components/demand/NeedExtendedRequirements.tsx new file mode 100644 index 0000000..dd6ec4b --- /dev/null +++ b/src/components/demand/NeedExtendedRequirements.tsx @@ -0,0 +1,80 @@ +import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material' +import { ChevronDown } from 'lucide-react' +import type { ParsedNeedCriteria } from '../../domain/needBuilder' + +interface Props { + criteria: ParsedNeedCriteria + onChange: (c: ParsedNeedCriteria) => void +} + +export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props) { + return ( + + } sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}> + Erweiterte Anforderungen + + + + set({ ...c, requireGroundFloor: e.target.checked || undefined })} />} + label={Erdgeschoss erforderlich} + /> + set({ ...c, requireAirConditioning: e.target.checked || undefined })} />} + label={Klimaanlage erforderlich} + /> + set({ ...c, requireLoadingDock: e.target.checked || undefined })} />} + label={Laderampe erforderlich} + /> + set({ ...c, requireBarrierFree: e.target.checked || undefined })} />} + label={Barrierefrei erforderlich} + /> + + + + Mindest-Parkplätze + set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })} + slotProps={{ htmlInput: { min: 0, max: 100 } }} + /> + + + Mindest-Ausbaustandard + set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })} + > + Kein Mindeststandard + Basisausbau + Vollausbau + Premiumausbau + + + + Mindest-Deckenhöhe (m) + set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })} + slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }} + /> + + + Mindest-Vertragslaufzeit (Monate) + set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })} + slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }} + /> + + + + + ) +} diff --git a/src/components/demand/NeedInput.tsx b/src/components/demand/NeedInput.tsx index 655d58c..9d060c6 100644 --- a/src/components/demand/NeedInput.tsx +++ b/src/components/demand/NeedInput.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' -import { Accordion, AccordionDetails, AccordionSummary, Box, Card, Checkbox, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material' -import { ChevronDown } from 'lucide-react' +import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material' import type { ParsedNeedCriteria } from '../../domain/needBuilder' import { AssetType } from '../../domain/enums' +import { NeedExtendedRequirements } from './NeedExtendedRequirements' interface Props { criteria: ParsedNeedCriteria @@ -220,77 +220,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { )} - {/* Extended requirements */} - - } sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}> - Erweiterte Anforderungen - - - {/* Boolean requirement checkboxes — 2×2 grid */} - - set({ ...c, requireGroundFloor: e.target.checked || undefined })} />} - label={Erdgeschoss erforderlich} - /> - set({ ...c, requireAirConditioning: e.target.checked || undefined })} />} - label={Klimaanlage erforderlich} - /> - set({ ...c, requireLoadingDock: e.target.checked || undefined })} />} - label={Laderampe erforderlich} - /> - set({ ...c, requireBarrierFree: e.target.checked || undefined })} />} - label={Barrierefrei erforderlich} - /> - - - {/* Numeric / select fields — 2×2 grid */} - - - Mindest-Parkplätze - set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })} - slotProps={{ htmlInput: { min: 0, max: 100 } }} - /> - - - Mindest-Ausbaustandard - set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })} - > - Kein Mindeststandard - Basisausbau - Vollausbau - Premiumausbau - - - - Mindest-Deckenhöhe (m) - set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })} - slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }} - /> - - - Mindest-Vertragslaufzeit (Monate) - set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })} - slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }} - /> - - - - + ) } diff --git a/src/components/match-detail/FutureAvailabilityContextPanel.tsx b/src/components/match-detail/FutureAvailabilityContextPanel.tsx index 7e529c8..80c53b8 100644 --- a/src/components/match-detail/FutureAvailabilityContextPanel.tsx +++ b/src/components/match-detail/FutureAvailabilityContextPanel.tsx @@ -14,6 +14,7 @@ import type { FutureSignal } from '../../domain/futureSignal' import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants' import { CREDIBILITY_META, SENSITIVITY_META, RISK_META, ageLabel } from './futureAvailabilityContextUtils' import { SignalSourcesSection } from './SignalSourcesSection' +import { DS_TEXT, DS_BG, DS_SURFACE, DS_PRE_MARKET, DS_MARKET_SIGNAL, DS_BORDER } from '../../lib/ds' interface Props { match: Match @@ -35,81 +36,85 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) ...(signal.evidence?.sourceUrls ?? []), ] - const probBarColor = probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' + const probBarColor = probPct >= 70 ? DS_TEXT.success : probPct >= 50 ? DS_TEXT.warning : DS_TEXT.error return ( - {/* ── Header ──────────────────────────────────────────────────────────── */} + {/* ── Header ── */} - + Future Availability Signal {signal.isVerified && ( - - - Verifiziert + + + Verifiziert )} - + {ageLabel(signal.createdAt)} {signal.title && ( - + {signal.title} )} - {/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */} + {/* ── 1. KI-Zusammenfassung ── */} {signal.aiSummary && ( - - KI-Zusammenfassung + + KI-Zusammenfassung - - + + {signal.aiSummary} )} - {/* ── 2. Strategische Einschätzung ─────────────────────────────────── */} + {/* ── 2. Strategische Einschätzung ── */} {signal.strategicInterpretation && ( - + Strategische Einschätzung - - + + {signal.strategicInterpretation} )} - {/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */} + {/* ── 3. Handlungsempfehlung ── */} {action && ( - - + + - Empfohlene Aktion - {action.label} + Empfohlene Aktion + {action.label} )} - {/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */} + {/* ── 4. Signal-Kenndaten ── */} Signal-Kenndaten - {/* Probability bar */} Eintretenswahrscheinlichkeit @@ -119,7 +124,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) variant="determinate" value={probPct} sx={{ - height: 6, borderRadius: 3, bgcolor: '#f1f5f9', + height: 6, borderRadius: 3, bgcolor: DS_BG.subtle, '& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 }, }} /> @@ -129,13 +134,13 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {signal.signalType && ( Signaltyp - + )} Zeithorizont - + ~{signal.timeHorizonMonths} Monate @@ -156,22 +161,22 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) - {/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */} + {/* ── 5. Erkannte Marktindikatoren ── */} {signal.marketIndicators && signal.marketIndicators.length > 0 && ( Erkannte Marktindikatoren {signal.marketIndicators.map((indicator, i) => ( - - {indicator} + + {indicator} ))} )} - {/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */} + {/* ── 6. Transparenz ── */} {((signal.confirmedFacts && signal.confirmedFacts.length > 0) || (signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && ( @@ -179,14 +184,14 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {(signal.confirmedFacts ?? []).map((fact, i) => ( - - {fact} + + {fact} ))} {(signal.unconfirmedFacts ?? []).map((fact, i) => ( - - {fact} + + {fact} ))} @@ -195,7 +200,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) - {/* ── 7. Quellen & Belege ───────────────────────────────────────────── */} + {/* ── 7. Quellen & Belege ── */} - {/* ── Footer ──────────────────────────────────────────────────────────── */} + {/* ── Footer ── */} @@ -213,12 +218,12 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {signal.verifiedBy && ( <> Verifiziert von: - {signal.verifiedBy} + {signal.verifiedBy} )} - + {signal.disclaimer} diff --git a/src/components/match-detail/MatchDetailPropertySections.tsx b/src/components/match-detail/MatchDetailPropertySections.tsx index 1d80d0b..65c03ed 100644 --- a/src/components/match-detail/MatchDetailPropertySections.tsx +++ b/src/components/match-detail/MatchDetailPropertySections.tsx @@ -3,6 +3,7 @@ import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } import { useMatchDetail } from '../../hooks/useMatches' import type { Property } from '../../domain/property' import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails' +import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds' type Match = NonNullable['data']> @@ -30,7 +31,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp {/* Preis */} - + Preis @@ -44,7 +45,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp {/* Hauptangaben */} - + Hauptangaben @@ -66,27 +67,27 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp {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 }} /> + } label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, 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 && ( - + )} @@ -96,12 +97,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp {property.softFactors?.publicTransportMinutes != null && ( - + Wegzeit - - + + {property.softFactors.publicTransportMinutes} Min. zu Fuss @@ -118,7 +119,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp {units.length > 0 && ( - + Einheiten @@ -135,10 +136,10 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp {property.description && ( - + Beschreibung - + {property.description} @@ -147,7 +148,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp {/* Quelle & Referenz */} - + Quelle & Referenz @@ -158,7 +159,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp )} {property.sourceUrl && ( - diff --git a/src/components/match-detail/PropertyDetailPublicSections.tsx b/src/components/match-detail/PropertyDetailPublicSections.tsx index 285ee32..362e460 100644 --- a/src/components/match-detail/PropertyDetailPublicSections.tsx +++ b/src/components/match-detail/PropertyDetailPublicSections.tsx @@ -26,6 +26,7 @@ import { SOURCE_LABELS, UnitRow, } from './MatchDetailPropertyDetails' +import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds' interface PropertyDetailPublicSectionsProps { property: Property @@ -57,7 +58,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop {/* ── Preis ── */} - + Preis @@ -74,7 +75,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop {/* ── Hauptangaben ── */} - + Hauptangaben - + Eigenschaften @@ -136,49 +137,49 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop size="small" icon={} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} - sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} + sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, 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 && ( )} @@ -189,12 +190,12 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop {property.softFactors?.publicTransportMinutes != null && ( - + Wegzeit - - + + @@ -220,7 +221,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop {(property.units ?? []).length > 0 && ( - + Einheiten @@ -241,10 +242,10 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop {property.description && ( - + Beschreibung - + {property.description} @@ -253,7 +254,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop {/* ── Quelle & Referenz ── */} - + Quelle & Referenz @@ -278,7 +279,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop href={property.sourceUrl} target="_blank" rel="noopener noreferrer" - sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }} + sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }} > Zum Originalinserat diff --git a/src/components/new-listing/AddressSection.tsx b/src/components/new-listing/AddressSection.tsx new file mode 100644 index 0000000..c287200 --- /dev/null +++ b/src/components/new-listing/AddressSection.tsx @@ -0,0 +1,45 @@ +import { Box, Card, TextField, Typography } from '@mui/material' + +interface Props { + street: string + onStreetChange: (v: string) => void + houseNumber: string + onHouseNumberChange: (v: string) => void + postalCode: string + onPostalCodeChange: (v: string) => void + city: string + onCityChange: (v: string) => void +} + +export function AddressSection({ + street, onStreetChange, + houseNumber, onHouseNumberChange, + postalCode, onPostalCodeChange, + city, onCityChange, +}: Props) { + return ( + + Adresse + + onStreetChange(e.target.value)} size="small" fullWidth + /> + onHouseNumberChange(e.target.value)} size="small" fullWidth + /> + + + onPostalCodeChange(e.target.value)} size="small" fullWidth + /> + onCityChange(e.target.value)} size="small" fullWidth + /> + + + ) +} diff --git a/src/components/new-listing/AiAssistCard.tsx b/src/components/new-listing/AiAssistCard.tsx new file mode 100644 index 0000000..a676191 --- /dev/null +++ b/src/components/new-listing/AiAssistCard.tsx @@ -0,0 +1,52 @@ +import { Alert, Box, Button, Card, CircularProgress, TextField, Typography } from '@mui/material' +import { Sparkles } from 'lucide-react' +import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds' + +interface Props { + text: string + onTextChange: (v: string) => void + onParse: () => void + parsing: boolean + applied: boolean +} + +export function AiAssistCard({ text, onTextChange, onParse, parsing, applied }: Props) { + return ( + + + + + KI-Hilfe — Formular automatisch ausfüllen + + + + Beschreiben Sie die Fläche in eigenen Worten — die KI füllt die Felder automatisch aus. + + onTextChange(e.target.value)} + sx={{ mb: 1.5, bgcolor: 'white' }} + /> + {applied && ( + + Felder wurden automatisch ausgefüllt — bitte überprüfen und ergänzen. + + )} + + + ) +} diff --git a/src/components/new-listing/AreaDetailsSection.tsx b/src/components/new-listing/AreaDetailsSection.tsx new file mode 100644 index 0000000..7c8ef55 --- /dev/null +++ b/src/components/new-listing/AreaDetailsSection.tsx @@ -0,0 +1,63 @@ +import { Box, Card, MenuItem, TextField, Typography } from '@mui/material' +import { ASSET_TYPE_LABELS } from '../../pages/supply/newListingConstants' + +interface Props { + assetType: string + onAssetTypeChange: (v: string) => void + areaSqm: string + onAreaSqmChange: (v: string) => void + rentPerSqm: string + onRentPerSqmChange: (v: string) => void + availableFrom: string + onAvailableFromChange: (v: string) => void + description: string + onDescriptionChange: (v: string) => void +} + +export function AreaDetailsSection({ + assetType, onAssetTypeChange, + areaSqm, onAreaSqmChange, + rentPerSqm, onRentPerSqmChange, + availableFrom, onAvailableFromChange, + description, onDescriptionChange, +}: Props) { + return ( + + Flächendetails + + onAssetTypeChange(e.target.value)} size="small" fullWidth + > + {Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => ( + {l} + ))} + + + onAreaSqmChange(e.target.value)} + size="small" type="number" inputProps={{ min: 1 }} fullWidth + /> + + onRentPerSqmChange(e.target.value)} + size="small" type="number" inputProps={{ min: 1 }} fullWidth + /> + + onAvailableFromChange(e.target.value)} + size="small" type="date" + slotProps={{ inputLabel: { shrink: true } }} fullWidth + /> + + onDescriptionChange(e.target.value)} + size="small" multiline rows={3} fullWidth sx={{ mt: 2 }} + /> + + ) +} diff --git a/src/components/new-listing/ContactSection.tsx b/src/components/new-listing/ContactSection.tsx new file mode 100644 index 0000000..b1863fe --- /dev/null +++ b/src/components/new-listing/ContactSection.tsx @@ -0,0 +1,33 @@ +import { Box, Card, TextField, Typography } from '@mui/material' + +interface Props { + name: string + onNameChange: (v: string) => void + email: string + onEmailChange: (v: string) => void + phone: string + onPhoneChange: (v: string) => void +} + +export function ContactSection({ name, onNameChange, email, onEmailChange, phone, onPhoneChange }: Props) { + return ( + + Kontakt (optional) + + onNameChange(e.target.value)} size="small" fullWidth + /> + onPhoneChange(e.target.value)} size="small" fullWidth + /> + onEmailChange(e.target.value)} + size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }} + /> + + + ) +} diff --git a/src/components/new-listing/CreatedScreen.tsx b/src/components/new-listing/CreatedScreen.tsx new file mode 100644 index 0000000..6cddc0d --- /dev/null +++ b/src/components/new-listing/CreatedScreen.tsx @@ -0,0 +1,28 @@ +import { Box, Button, Typography } from '@mui/material' +import { CheckCircle } from 'lucide-react' +import { DS_TEXT } from '../../lib/ds' + +interface Props { + onViewListings: () => void + onCreateAnother: () => void +} + +export function CreatedScreen({ onViewListings, onCreateAnother }: Props) { + return ( + + + Inserat erstellt + + Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar. + + + + + + + ) +} diff --git a/src/components/new-listing/ImageUrlSection.tsx b/src/components/new-listing/ImageUrlSection.tsx new file mode 100644 index 0000000..934547d --- /dev/null +++ b/src/components/new-listing/ImageUrlSection.tsx @@ -0,0 +1,52 @@ +import { Box, Button, Card, Chip, TextField, Typography } from '@mui/material' +import { ImagePlus } from 'lucide-react' + +interface Props { + images: string[] + imageInput: string + onImageInputChange: (v: string) => void + onAdd: () => void + onRemove: (index: number) => void +} + +export function ImageUrlSection({ images, imageInput, onImageInputChange, onAdd, onRemove }: Props) { + return ( + + Bilder (optional) + + onImageInputChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); onAdd() } }} + size="small" + fullWidth + placeholder="https://…" + /> + + + {images.length > 0 && ( + + {images.map((url, i) => ( + 40 ? url.slice(0, 40) + '…' : url} + size="small" + onDelete={() => onRemove(i)} + sx={{ maxWidth: 300 }} + /> + ))} + + )} + + ) +} diff --git a/src/components/new-listing/SoftFactorsSection.tsx b/src/components/new-listing/SoftFactorsSection.tsx new file mode 100644 index 0000000..ff0503a --- /dev/null +++ b/src/components/new-listing/SoftFactorsSection.tsx @@ -0,0 +1,37 @@ +import { Box, Card, MenuItem, TextField, Typography } from '@mui/material' +import { SOFT_FACTORS, LEVEL_OPTIONS } from '../../pages/supply/newListingConstants' + +interface Props { + softLevels: Record + onChange: (key: string, value: string) => void +} + +export function SoftFactorsSection({ softLevels, onChange }: Props) { + return ( + + + Lage & Ausstrahlung + + Diese Angaben verbessern die Match-Qualität erheblich. + + + + {SOFT_FACTORS.map(({ key, label }) => ( + onChange(key, e.target.value)} + size="small" + fullWidth + > + {LEVEL_OPTIONS.map(o => ( + {o.label} + ))} + + ))} + + + ) +} diff --git a/src/components/new-listing/TechnicalDetailsSection.tsx b/src/components/new-listing/TechnicalDetailsSection.tsx new file mode 100644 index 0000000..4c42f88 --- /dev/null +++ b/src/components/new-listing/TechnicalDetailsSection.tsx @@ -0,0 +1,51 @@ +import { Box, Card, MenuItem, TextField, Typography } from '@mui/material' +import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants' + +interface Props { + floor: string + onFloorChange: (v: string) => void + fitOut: string + onFitOutChange: (v: string) => void + parking: string + onParkingChange: (v: string) => void + ceilingHeight: string + onCeilingHeightChange: (v: string) => void +} + +export function TechnicalDetailsSection({ + floor, onFloorChange, + fitOut, onFitOutChange, + parking, onParkingChange, + ceilingHeight, onCeilingHeightChange, +}: Props) { + return ( + + Technische Details (optional) + + onFloorChange(e.target.value)} + size="small" type="number" fullWidth placeholder="0 = EG" + /> + onFitOutChange(e.target.value)} size="small" fullWidth + > + {FIT_OUT_OPTIONS.map(o => ( + {o.label} + ))} + + onParkingChange(e.target.value)} + size="small" type="number" inputProps={{ min: 0 }} fullWidth + /> + onCeilingHeightChange(e.target.value)} + size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth + /> + + + ) +} diff --git a/src/components/new-listing/index.ts b/src/components/new-listing/index.ts new file mode 100644 index 0000000..f8b31fe --- /dev/null +++ b/src/components/new-listing/index.ts @@ -0,0 +1,8 @@ +export { AiAssistCard } from './AiAssistCard' +export { AreaDetailsSection } from './AreaDetailsSection' +export { AddressSection } from './AddressSection' +export { SoftFactorsSection } from './SoftFactorsSection' +export { TechnicalDetailsSection } from './TechnicalDetailsSection' +export { ImageUrlSection } from './ImageUrlSection' +export { ContactSection } from './ContactSection' +export { CreatedScreen } from './CreatedScreen' diff --git a/src/components/shared/scoreTheme.ts b/src/components/shared/scoreTheme.ts index 35c7886..8f9afbd 100644 --- a/src/components/shared/scoreTheme.ts +++ b/src/components/shared/scoreTheme.ts @@ -1,37 +1,2 @@ -export type ScoreTier = 'gold' | 'silver' | 'bronze' - -export function getScoreTier(score: number): ScoreTier { - if (score >= 90) return 'gold' - if (score >= 80) return 'silver' - return 'bronze' -} - -export const SCORE_THEME = { - gold: { - gradient: 'linear-gradient(135deg,#f8e642 0%,#d4920e 100%)', - border: '#c9900c', - glow: 'rgba(212,146,14,0.35)', - text: '#7a4f00', - label: 'Top Match', - cardBorder: '#fbbf24', - cardBg: 'linear-gradient(160deg,#fffbeb,#fef3c7)', - }, - silver: { - gradient: 'linear-gradient(135deg,#f1f5f9 0%,#cbd5e1 100%)', - border: '#94a3b8', - glow: 'rgba(148,163,184,0.30)', - text: '#334155', - label: 'Starkes Match', - cardBorder: '#cbd5e1', - cardBg: 'linear-gradient(160deg,#f8fafc,#f1f5f9)', - }, - bronze: { - gradient: 'linear-gradient(135deg,#fde8c8 0%,#d4956a 100%)', - border: '#c07a46', - glow: 'rgba(192,122,70,0.25)', - text: '#7c3d0c', - label: 'Gutes Match', - cardBorder: '#f5d0a9', - cardBg: 'linear-gradient(160deg,#fdf6f0,#fef3e8)', - }, -} as const +export { getScoreTier, SCORE_THEME } from '../../lib/scoreTheme' +export type { ScoreTier } from '../../lib/scoreTheme' diff --git a/src/components/supply/NeedMatchCard.tsx b/src/components/supply/NeedMatchCard.tsx index 7758df8..e81706a 100644 --- a/src/components/supply/NeedMatchCard.tsx +++ b/src/components/supply/NeedMatchCard.tsx @@ -1,6 +1,7 @@ import { Box, Card, Chip, Stack, Typography } from '@mui/material' import { MapPin, Ruler, Wallet, Calendar, CheckCircle } from 'lucide-react' import type { PropertyNeedMatch } from '../../domain/match' +import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds' interface Props { match: PropertyNeedMatch @@ -9,9 +10,9 @@ interface Props { function ScoreBadge({ score }: { score: number }) { const isGold = score >= 90 - const bg = isGold ? '#fef3c7' : '#f1f5f9' - const color = isGold ? '#d97706' : '#64748b' - const borderColor = isGold ? '#fde68a' : '#e2e8f0' + const bg = isGold ? DS_SURFACE.warning.bg : DS_BG.subtle + const color = isGold ? DS_TEXT.warning : DS_TEXT.muted + const borderColor = isGold ? DS_SURFACE.warning.border : DS_BORDER.default return ( - {icon} - {label} - {value} + {icon} + {label} + {value} ) } @@ -52,10 +53,10 @@ export function NeedMatchCard({ match, onClick }: Props) { sx={{ p: 2, mb: 1.5, - border: '1px solid #e2e8f0', + border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, cursor: onClick ? 'pointer' : 'default', - '&:hover': onClick ? { borderColor: '#1e3a5f', bgcolor: '#f8fafc', boxShadow: '0 2px 8px rgba(30,58,95,0.08)' } : { borderColor: '#cbd5e1', bgcolor: '#fafafa' }, + '&:hover': onClick ? { borderColor: DS_TEXT.brand, bgcolor: DS_BG.page, boxShadow: '0 2px 8px rgba(30,58,95,0.08)' } : { borderColor: DS_BORDER.strong, bgcolor: DS_BG.page }, transition: 'border-color 0.15s, background-color 0.15s, box-shadow 0.15s', }} > @@ -64,7 +65,7 @@ export function NeedMatchCard({ match, onClick }: Props) { - + {match.company} = 90 ? '#fef3c7' : '#f1f5f9', - color: match.score >= 90 ? '#92400e' : '#475569', + bgcolor: match.score >= 90 ? DS_SURFACE.warning.bg : DS_BG.subtle, + color: match.score >= 90 ? DS_TEXT.warningDark : DS_TEXT.secondary, border: '1px solid', - borderColor: match.score >= 90 ? '#fde68a' : '#e2e8f0', + borderColor: match.score >= 90 ? DS_SURFACE.warning.border : DS_BORDER.default, }} /> @@ -96,8 +97,8 @@ export function NeedMatchCard({ match, onClick }: Props) { {match.matchHighlights.slice(0, 2).map((h, i) => ( - - {h} + + {h} ))} @@ -110,11 +111,11 @@ export function NeedMatchCard({ match, onClick }: Props) { key={mh} label={mh} size="small" - sx={{ fontSize: '0.62rem', height: 18, bgcolor: '#f0fdf4', color: '#166534', border: '1px solid #bbf7d0' }} + sx={{ fontSize: '0.62rem', height: 18, bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.successDark, border: `1px solid ${DS_SURFACE.success.border}` }} /> ))} {match.mustHaves.length > 3 && ( - + +{match.mustHaves.length - 3} )} diff --git a/src/components/supply/NegotiationInsightsPanel.tsx b/src/components/supply/NegotiationInsightsPanel.tsx index 66f6567..0edbc02 100644 --- a/src/components/supply/NegotiationInsightsPanel.tsx +++ b/src/components/supply/NegotiationInsightsPanel.tsx @@ -5,8 +5,7 @@ import { useNeeds } from '../../hooks/useNeeds' import { getCityIntelligence, getMarketRent } from '../../lib/locationIntelligence' import type { Property } from '../../domain/property' import { generateSellingArguments, generateWeaknesses } from './negotiationInsightsUtils' - -// ── Main component ──────────────────────────────────────────────────────────── +import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds' interface Props { property: Property @@ -20,7 +19,6 @@ export function NegotiationInsightsPanel({ property }: Props) { 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) @@ -28,7 +26,6 @@ export function NegotiationInsightsPanel({ property }: Props) { ? 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) @@ -39,6 +36,10 @@ export function NegotiationInsightsPanel({ property }: Props) { const strongArgs = sellingArgs.filter(a => a.strength === 'strong') const mediumArgs = sellingArgs.filter(a => a.strength === 'medium') + const priceDiffBg = priceDiff !== null + ? (Math.abs(priceDiff) < 10 ? DS_SURFACE.success.bg : priceDiff > 0 ? DS_SURFACE.orange.bg : DS_SURFACE.success.bg) + : DS_BG.page + return ( @@ -47,24 +48,24 @@ export function NegotiationInsightsPanel({ property }: Props) { Preispositionierung - + Ihr Preis - + CHF {property.rentPricePerSqm}/m²/Jahr {marketRent && ( - + Marktmedian {property.location.city} - + CHF {marketRent}/m² )} {avgComparableRent && ( - + Vergleichsangebote Ø - + CHF {Math.round(avgComparableRent)}/m² @@ -72,9 +73,9 @@ export function NegotiationInsightsPanel({ property }: Props) { {priceDiff !== null && ( - 0 ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5 }}> - {priceDiff > 0 ? : } - 15 ? '#92400e' : priceDiff > 0 ? '#d97706' : '#1a7a4a', fontWeight: 500 }}> + + {priceDiff > 0 ? : } + 15 ? DS_TEXT.warningDark : priceDiff > 0 ? DS_TEXT.warning : DS_TEXT.success, fontWeight: 500 }}> {priceDiff > 15 ? `Ihr Preis liegt ${Math.round(priceDiff)}% über dem Marktmedian — starke USPs nötig zur Rechtfertigung.` : priceDiff > 5 @@ -86,7 +87,6 @@ export function NegotiationInsightsPanel({ property }: Props) { )} - {/* Price bar vs market */} {marketRent && ( @@ -95,7 +95,7 @@ export function NegotiationInsightsPanel({ property }: Props) { CHF {Math.round(marketRent * 0.7)}–{Math.round(marketRent * 1.4)}/m² - + @@ -121,11 +121,11 @@ export function NegotiationInsightsPanel({ property }: Props) { {matchingNeeds.length > 0 ? ( <> - {matchingNeeds.length} + {matchingNeeds.length} aktive Suchprofile für diesen Typ & Standort {matchingNeeds.slice(0, 4).map(n => ( - + {n.companyName} @@ -137,8 +137,8 @@ export function NegotiationInsightsPanel({ property }: Props) { 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', + bgcolor: n.status === 'ACTIVE' ? DS_SURFACE.success.bg : DS_BG.subtle, + color: n.status === 'ACTIVE' ? DS_TEXT.success : DS_TEXT.muted, fontSize: 10, height: 18, }} /> @@ -156,10 +156,10 @@ export function NegotiationInsightsPanel({ property }: Props) { )} {intel && ( - + Ø Vermietungsdauer vergleichbarer Objekte in {property.location.city}:{' '} - {intel.avgDaysOnMarket} Tage + {intel.avgDaysOnMarket} Tage )} @@ -175,12 +175,12 @@ export function NegotiationInsightsPanel({ property }: Props) { {strongArgs.length > 0 && ( - + Starke Argumente {strongArgs.map((arg, i) => ( - + {arg.title} {arg.detail} @@ -192,12 +192,12 @@ export function NegotiationInsightsPanel({ property }: Props) { {mediumArgs.length > 0 && ( - + Weitere Vorteile {mediumArgs.map((arg, i) => ( - + {arg.title} {arg.detail} @@ -218,10 +218,10 @@ export function NegotiationInsightsPanel({ property }: Props) { {weaknesses.map((w, i) => ( - + ⚠ {w.issue} - + → {w.mitigation} {i < weaknesses.length - 1 && } @@ -240,14 +240,14 @@ export function NegotiationInsightsPanel({ property }: Props) { {intel.dominantIndustryClusters.map(c => ( ))} Nachfragestärke: {' '} diff --git a/src/components/supply/PreMarketDemandIntelligence.tsx b/src/components/supply/PreMarketDemandIntelligence.tsx new file mode 100644 index 0000000..ebf0b51 --- /dev/null +++ b/src/components/supply/PreMarketDemandIntelligence.tsx @@ -0,0 +1,38 @@ +import { Box, Typography } from '@mui/material' +import { Target, TrendingUp, Users } from 'lucide-react' +import { DS_PRE_MARKET } from '../../lib/ds' + +interface Props { + demandProfiles: number + highQualityLeads: number +} + +export function PreMarketDemandIntelligence({ demandProfiles, highQualityLeads }: Props) { + return ( + + + Matching Demand Intelligence + + + + + + {demandProfiles} aktive Suchprofile im System erkannt + + + + + + {highQualityLeads} hochwertige Suchanfragen mit passendem Flächenbedarf + + + + + + Frühzeitige Matchgelegenheit — vor offizieller Vermarktung exklusiv verfügbar + + + + + ) +} diff --git a/src/components/supply/PreMarketPanel.tsx b/src/components/supply/PreMarketPanel.tsx index 4055555..28fde35 100644 --- a/src/components/supply/PreMarketPanel.tsx +++ b/src/components/supply/PreMarketPanel.tsx @@ -1,12 +1,15 @@ import { useState } from 'react' -import { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material' -import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' +import { Box, Chip, CircularProgress, Divider, Switch, Typography } from '@mui/material' +import { Clock, ShieldCheck, Zap } from 'lucide-react' import type { Property } from '../../domain/property' import { MockupUnitProvider } from '../../provider/MockupUnitProvider' import { useUpdateProperty } from '../../hooks/useProperties' import { useToastStore } from '../../stores/toastStore' import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' -import { floorLabel, SectionTitle } from './PropertyDetailHelpers' +import { SectionTitle } from './PropertyDetailHelpers' +import { PreMarketDemandIntelligence } from './PreMarketDemandIntelligence' +import { PreMarketUnitGrid } from './PreMarketUnitGrid' export const MOCK_TODAY = new Date('2026-05-20') @@ -24,6 +27,7 @@ export function PreMarketPanel({ p }: { p: Property }) { return init }) const [unitSaving, setUnitSaving] = useState>({}) + const queryClient = useQueryClient() const updateProperty = useUpdateProperty() const showToast = useToastStore(s => s.showToast) const saving = updateProperty.isPending @@ -48,7 +52,6 @@ export function PreMarketPanel({ p }: { p: Property }) { ? Math.max(0, Math.round((targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30))) : null - // Mock demand intelligence (derived deterministically from property characteristics) const demandProfiles = Math.min(14, (p.areaSqm >= 1000 ? 5 : p.areaSqm >= 500 ? 8 : 4) + (['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1)) const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38)) @@ -133,7 +136,7 @@ export function PreMarketPanel({ p }: { p: Property }) { - {/* Active: lead time + status + demand intelligence */} + {/* Active: lead time + status + unit grid + demand intelligence */} {enabled && ( {/* Lead time selector */} @@ -182,94 +185,20 @@ export function PreMarketPanel({ p }: { p: Property }) { - {/* Unit-level release controls */} {(p.units?.length ?? 0) > 0 && ( - - - Einheiten freigeben - - {p.units!.map(u => { - const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' } - return ( - - - - {floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''} - - - {u.areaSqm.toLocaleString('de-CH')} m² - {u.currentTenant ? ` · ${u.currentTenant}` : ''} - - - { - const next = { ...us, availableFrom: e.target.value } - setUnitStates(prev => ({ ...prev, [u.id]: next })) - if (us.enabled) saveUnit(u.id, true, e.target.value) - }} - /> - - {unitSaving[u.id] && } - { - const next = { ...us, enabled: checked } - setUnitStates(prev => ({ ...prev, [u.id]: next })) - saveUnit(u.id, checked, us.availableFrom) - }} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' }, - }} - /> - - - ) - })} - + )} - {/* Demand Intelligence */} - - - Matching Demand Intelligence - - - - - - {demandProfiles} aktive Suchprofile im System erkannt - - - - - - {highQualityLeads} hochwertige Suchanfragen mit passendem Flächenbedarf - - - - - - Frühzeitige Matchgelegenheit — vor offizieller Vermarktung exklusiv verfügbar - - - - + )} diff --git a/src/components/supply/PreMarketUnitGrid.tsx b/src/components/supply/PreMarketUnitGrid.tsx new file mode 100644 index 0000000..bfcaad5 --- /dev/null +++ b/src/components/supply/PreMarketUnitGrid.tsx @@ -0,0 +1,77 @@ +import { Box, CircularProgress, Switch, TextField, Typography } from '@mui/material' +import type { Property } from '../../domain/property' +import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds' +import { floorLabel } from './PropertyDetailHelpers' + +type PropertyUnit = NonNullable[number] + +interface Props { + units: PropertyUnit[] + unitStates: Record + unitSaving: Record + setUnitStates: React.Dispatch>> + saveUnit: (unitId: string, enabled: boolean, availableFrom: string) => Promise +} + +export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) { + return ( + + + Einheiten freigeben + + {units.map(u => { + const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' } + return ( + + + + {floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''} + + + {u.areaSqm.toLocaleString('de-CH')} m² + {u.currentTenant ? ` · ${u.currentTenant}` : ''} + + + { + const next = { ...us, availableFrom: e.target.value } + setUnitStates(prev => ({ ...prev, [u.id]: next })) + if (us.enabled) saveUnit(u.id, true, e.target.value) + }} + /> + + {unitSaving[u.id] && } + { + const next = { ...us, enabled: checked } + setUnitStates(prev => ({ ...prev, [u.id]: next })) + saveUnit(u.id, checked, us.availableFrom) + }} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' }, + }} + /> + + + ) + })} + + ) +} diff --git a/src/hooks/useNeeds.ts b/src/hooks/useNeeds.ts index 97b96e7..affae18 100644 --- a/src/hooks/useNeeds.ts +++ b/src/hooks/useNeeds.ts @@ -2,11 +2,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { needService } from '../services/needService' import type { CreateNeedInput } from '../domain/need' -export function useNeeds() { +interface UseNeedsOptions { + refetchOnMount?: boolean | 'always' + gcTime?: number +} + +export function useNeeds(options?: UseNeedsOptions) { return useQuery({ queryKey: ['needs'], queryFn: () => needService.getAll(), select: (res) => res.data ?? [], + ...options, }) } diff --git a/src/hooks/useNewListingForm.ts b/src/hooks/useNewListingForm.ts new file mode 100644 index 0000000..7dd1b09 --- /dev/null +++ b/src/hooks/useNewListingForm.ts @@ -0,0 +1,200 @@ +import { useState } from 'react' +import { useCreateProperty } from './useProperties' +import { useParseListingText } from './useAI' +import { SOFT_FACTORS } from '../pages/supply/newListingConstants' +import { buildCreatePropertyInput } from '../pages/supply/newListingMapper' +import type { Prefill } from '../pages/supply/newListingConstants' + +function emptySoftLevels(): Record { + return Object.fromEntries(SOFT_FACTORS.map(f => [f.key, ''])) +} + +function validate(fields: { + street: string + postalCode: string + city: string + areaSqm: string + rentPerSqm: string +}): string | null { + if (!fields.street.trim()) return 'Strasse erforderlich' + if (!fields.postalCode.trim()) return 'PLZ erforderlich' + if (!fields.city.trim()) return 'Ort erforderlich' + if (!fields.areaSqm || isNaN(Number(fields.areaSqm)) + || Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben' + if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm)) + || Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben' + return null +} + +export interface NewListingFormState { + // Core + assetType: string + areaSqm: string + rentPerSqm: string + availableFrom: string + description: string + // Address + street: string + houseNumber: string + postalCode: string + city: string + // Soft factors + softLevels: Record + // Technical + floor: string + fitOut: string + parking: string + ceilingHeight: string + // Contact + contactName: string + contactEmail: string + contactPhone: string + // Images + images: string[] + imageInput: string + // AI + aiText: string + aiApplied: boolean + // Status + error: string | null + created: boolean + aiParsing: boolean + submitting: boolean + isPrefilled: boolean +} + +export interface NewListingFormHandlers { + setAssetType: (v: string) => void + setAreaSqm: (v: string) => void + setRentPerSqm: (v: string) => void + setAvailableFrom: (v: string) => void + setDescription: (v: string) => void + setStreet: (v: string) => void + setHouseNumber: (v: string) => void + setPostalCode: (v: string) => void + setCity: (v: string) => void + setSoftLevel: (key: string, value: string) => void + setFloor: (v: string) => void + setFitOut: (v: string) => void + setParking: (v: string) => void + setCeilingHeight: (v: string) => void + setContactName: (v: string) => void + setContactEmail: (v: string) => void + setContactPhone: (v: string) => void + setImageInput: (v: string) => void + setAiText: (v: string) => void + addImage: () => void + removeImage: (index: number) => void + handleAiParse: () => void + handleSubmit: () => void + resetForm: () => void +} + +export function useNewListingForm(pre: Prefill): NewListingFormState & NewListingFormHandlers { + const createProperty = useCreateProperty() + const parseListingMutation = useParseListingText() + + const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE') + const [street, setStreet] = useState(pre.street ?? '') + const [houseNumber, setHouseNumber] = useState(pre.houseNumber ?? '') + const [postalCode, setPostalCode] = useState(pre.postalCode ?? '') + const [city, setCity] = useState(pre.city ?? '') + const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '') + const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '') + const [availableFrom, setAvailableFrom] = useState('') + const [description, setDescription] = useState('') + const [contactName, setContactName] = useState('') + const [contactEmail, setContactEmail] = useState('') + const [contactPhone, setContactPhone] = useState('') + const [softLevels, setSoftLevels] = useState>(emptySoftLevels) + const [floor, setFloor] = useState('') + const [fitOut, setFitOut] = useState('') + const [parking, setParking] = useState('') + const [ceilingHeight, setCeilingHeight]= useState('') + const [images, setImages] = useState([]) + const [imageInput, setImageInput] = useState('') + const [aiText, setAiText] = useState('') + const [aiApplied, setAiApplied] = useState(false) + const [error, setError] = useState(null) + const [created, setCreated] = useState(false) + + function setSoftLevel(key: string, value: string) { + setSoftLevels(prev => ({ ...prev, [key]: value })) + } + + function addImage() { + const url = imageInput.trim() + if (url && !images.includes(url)) setImages(prev => [...prev, url]) + setImageInput('') + } + + function removeImage(index: number) { + setImages(prev => prev.filter((_, i) => i !== index)) + } + + function handleAiParse() { + if (!aiText.trim()) return + parseListingMutation.mutate(aiText, { + onSuccess: (parsed) => { + if (parsed.assetType) setAssetType(parsed.assetType) + if (parsed.areaSqm) setAreaSqm(String(parsed.areaSqm)) + if (parsed.rentPerSqm) setRentPerSqm(String(parsed.rentPerSqm)) + if (parsed.city && !city) setCity(parsed.city) + if (parsed.fitOut) setFitOut(parsed.fitOut) + if (parsed.parking) setParking(String(parsed.parking)) + if (parsed.softLevels) setSoftLevels(prev => ({ ...prev, ...parsed.softLevels })) + setAiApplied(true) + }, + }) + } + + function handleSubmit() { + const err = validate({ street, postalCode, city, areaSqm, rentPerSqm }) + if (err) { setError(err); return } + setError(null) + createProperty.mutate( + buildCreatePropertyInput({ + assetType, street, houseNumber, postalCode, city, + areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm), + availableFrom, description, softLevels, + floor, fitOut, parking, ceilingHeight, images, + }), + { + onSuccess: () => setCreated(true), + onError: () => setError('Fehler beim Erstellen des Inserats. Bitte erneut versuchen.'), + }, + ) + } + + function resetForm() { + setAssetType('OFFICE') + setStreet(''); setHouseNumber(''); setPostalCode(''); setCity('') + setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('') + setContactName(''); setContactEmail(''); setContactPhone('') + setSoftLevels(emptySoftLevels()) + setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('') + setImages([]); setImageInput('') + setAiText(''); setAiApplied(false) + setCreated(false); setError(null) + } + + return { + assetType, areaSqm, rentPerSqm, availableFrom, description, + street, houseNumber, postalCode, city, + softLevels, floor, fitOut, parking, ceilingHeight, + contactName, contactEmail, contactPhone, + images, imageInput, aiText, aiApplied, + error, created, + aiParsing: parseListingMutation.isPending, + submitting: createProperty.isPending, + isPrefilled: !!pre.propertyId, + setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription, + setStreet, setHouseNumber, setPostalCode, setCity, + setSoftLevel, + setFloor, setFitOut, setParking, setCeilingHeight, + setContactName, setContactEmail, setContactPhone, + setImageInput, setAiText, + addImage, removeImage, + handleAiParse, handleSubmit, resetForm, + } +} diff --git a/src/lib/ds.ts b/src/lib/ds.ts index e717fd4..c512245 100644 --- a/src/lib/ds.ts +++ b/src/lib/ds.ts @@ -88,6 +88,7 @@ export const DS_TEXT = { warningDark: '#92400e', signalDark: '#4c1d95', infoDark: '#0c4a6e', + brandDark: '#162d4a', } as const // ── Background tokens ───────────────────────────────────────────────────────── diff --git a/src/lib/scoreTheme.ts b/src/lib/scoreTheme.ts new file mode 100644 index 0000000..35c7886 --- /dev/null +++ b/src/lib/scoreTheme.ts @@ -0,0 +1,37 @@ +export type ScoreTier = 'gold' | 'silver' | 'bronze' + +export function getScoreTier(score: number): ScoreTier { + if (score >= 90) return 'gold' + if (score >= 80) return 'silver' + return 'bronze' +} + +export const SCORE_THEME = { + gold: { + gradient: 'linear-gradient(135deg,#f8e642 0%,#d4920e 100%)', + border: '#c9900c', + glow: 'rgba(212,146,14,0.35)', + text: '#7a4f00', + label: 'Top Match', + cardBorder: '#fbbf24', + cardBg: 'linear-gradient(160deg,#fffbeb,#fef3c7)', + }, + silver: { + gradient: 'linear-gradient(135deg,#f1f5f9 0%,#cbd5e1 100%)', + border: '#94a3b8', + glow: 'rgba(148,163,184,0.30)', + text: '#334155', + label: 'Starkes Match', + cardBorder: '#cbd5e1', + cardBg: 'linear-gradient(160deg,#f8fafc,#f1f5f9)', + }, + bronze: { + gradient: 'linear-gradient(135deg,#fde8c8 0%,#d4956a 100%)', + border: '#c07a46', + glow: 'rgba(192,122,70,0.25)', + text: '#7c3d0c', + label: 'Gutes Match', + cardBorder: '#f5d0a9', + cardBg: 'linear-gradient(160deg,#fdf6f0,#fef3e8)', + }, +} as const diff --git a/src/pages/demand/AISearch.tsx b/src/pages/demand/AISearch.tsx index 6ff09fe..83da95b 100644 --- a/src/pages/demand/AISearch.tsx +++ b/src/pages/demand/AISearch.tsx @@ -1,13 +1,5 @@ import { useRef, useState } from 'react' -import { - Alert, - Box, - Button, - CircularProgress, - Divider, - Typography, -} from '@mui/material' -import { ArrowRight, Bookmark, Save, Search } from 'lucide-react' +import { Box, Typography } from '@mui/material' import { useNavigate } from 'react-router' import { useQueryClient } from '@tanstack/react-query' import { @@ -15,9 +7,10 @@ import { NeedInput, VoiceNeedInput, WeightingEditor, - NeedCardPreview, NeedBuilderErrorState, } from '../../components/demand' +import { AISearchActionBar } from '../../components/demand/AISearchActionBar' +import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview' import { useParseNeed } from '../../hooks/useAI' import { useCreateNeed } from '../../hooks/useNeeds' import { useDefaultWeights } from '../../hooks/useWeighting' @@ -25,12 +18,8 @@ import { NeedBuilderStep } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper' -// ── Action intent ───────────────────────────────────────────────────────────── - type ActionIntent = 'search' | 'save-profile' -// ── Page ────────────────────────────────────────────────────────────────────── - export default function AISearch() { const navigate = useNavigate() const queryClient = useQueryClient() @@ -90,7 +79,6 @@ export default function AISearch() { }) } - // Resolve criteria (parse text if needed), then either search or show save preview function handleAction(chosenIntent: ActionIntent) { setIntent(chosenIntent) setError(null) @@ -130,7 +118,6 @@ export default function AISearch() { resolvedResult: ParseNeedResult | null, ) { if (chosenIntent === 'search') { - // Save as DRAFT and navigate immediately setStep(NeedBuilderStep.SAVING) const conf = resolvedResult ? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) / @@ -150,7 +137,6 @@ export default function AISearch() { return } - // save-profile: show preview step const confidenceByField: Record = resolvedResult?.confidenceByField ?? {} if (!resolvedResult) { if (resolved.assetType) confidenceByField.assetType = 1.0 @@ -229,10 +215,9 @@ export default function AISearch() { - {/* ── IDLE: full form ── */} + {/* IDLE: full form */} {(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && ( - - - - {/* Action bar */} - - - - - - - - - - Jetzt suchen liefert sofortige Ergebnisse.{' '} - Als Suchprofil speichern legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird — auch in Zukunft. - - - )} - - {/* ── Preview + Save as Profile ── */} - {isSaveStep && parseResult && editedCriteria && ( - - - Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung. - - handleAction('search')} + onSaveProfile={() => handleAction('save-profile')} /> - - - - )} - {/* ── Error ── */} + {/* Save preview step */} + {isSaveStep && parseResult && editedCriteria && ( + setStep(NeedBuilderStep.IDLE)} + onSave={handleSaveProfile} + /> + )} + + {/* Error */} {step === NeedBuilderStep.ERROR && ( )} diff --git a/src/pages/demand/Anfragen.tsx b/src/pages/demand/Anfragen.tsx index 05fdab2..54bd81e 100644 --- a/src/pages/demand/Anfragen.tsx +++ b/src/pages/demand/Anfragen.tsx @@ -12,7 +12,7 @@ import type { InquiryMessage } from '../../domain/inquiry' import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection' import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble' import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem' -import { INQUIRY_STATUS_META, DS_COLORS } from '../../lib/ds' +import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds' // ── Config ──────────────────────────────────────────────────────────────────── @@ -130,13 +130,13 @@ export default function Anfragen() { minWidth: { md: 320 }, flexShrink: 0, display: 'flex', flexDirection: 'column', - borderRight: '1px solid #e2e8f0', + borderRight: `1px solid ${DS_BORDER.default}`, overflow: 'hidden', bgcolor: 'white', transition: 'width 0.2s ease', }} > - + Anfragen {totalUnread > 0 && ( @@ -147,7 +147,7 @@ export default function Anfragen() { setSearch(e.target.value)} - InputProps={{ startAdornment: }} + InputProps={{ startAdornment: }} sx={{ mb: 1.25 }} /> @@ -155,10 +155,10 @@ export default function Anfragen() { setStatusFilter(tab.key)} sx={{ height: 22, fontSize: '0.7rem', cursor: 'pointer', - bgcolor: statusFilter === tab.key ? 'primary.main' : '#f1f5f9', - color: statusFilter === tab.key ? 'white' : '#475569', + bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle, + color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary, fontWeight: statusFilter === tab.key ? 700 : 400, - '&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : '#e2e8f0' }, + '&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted }, }} /> ))} @@ -188,7 +188,7 @@ export default function Anfragen() { display: { xs: mobileShowChat ? 'flex' : 'none', md: 'flex' }, flexDirection: 'column', overflow: 'hidden', - bgcolor: '#f8fafc', + bgcolor: DS_BG.page, minWidth: 0, }}> {!selected ? ( @@ -199,7 +199,7 @@ export default function Anfragen() { ) : ( <> {/* Chat header */} - + setMobileShowChat(false)}> @@ -219,10 +219,10 @@ export default function Anfragen() { {selected.matchScore && ( = 80 ? '#f0fdf4' : '#fffbeb', - color: selected.matchScore >= 80 ? '#1a7a4a' : '#d97706', + bgcolor: selected.matchScore >= 80 ? DS_SURFACE.success.bg : DS_SURFACE.warning.bg, + color: selected.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning, fontWeight: 700, height: 22, fontSize: '0.75rem', - border: `1px solid ${selected.matchScore >= 80 ? '#86efac' : '#fde68a'}`, + border: `1px solid ${selected.matchScore >= 80 ? DS_SURFACE.success.border : DS_SURFACE.warning.border}`, }} /> )} - + {linkedPipelineItem?.propertyAddress ?? selected.subject} @@ -286,7 +286,7 @@ export default function Anfragen() { {/* Composer */} - + - + + sx={{ bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark' }, '&:disabled': { bgcolor: DS_BG.muted, color: DS_TEXT.disabled } }}> diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index d3a306f..71dd824 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -1,9 +1,9 @@ import { useState, useMemo, useEffect } from 'react' import { Box, Button, Card, Typography } from '@mui/material' import { useNavigate, useLocation } from 'react-router' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useQueryClient } from '@tanstack/react-query' import { useUnifiedResults } from '../../hooks/useUnifiedResults' -import { needService } from '../../services/needService' +import { useNeeds } from '../../hooks/useNeeds' import { DecisionContextPanel } from '../../components/ui' import { FeedEmptyState, @@ -47,15 +47,11 @@ export default function Results() { // When coming from NeedBuilder, invalidate so the freshly created need is included const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId - const { data: needResp } = useQuery({ - queryKey: ['needs'], - queryFn: () => needService.getAll(), + const { data: allNeeds = [] } = useNeeds({ refetchOnMount: activeNeedIdFromNav ? 'always' : true, gcTime: 0, }) - const allNeeds = needResp?.data ?? [] - // Nav ID (from NeedBuilder) takes priority; otherwise first named need const effectiveNeedId = activeNeedIdFromNav ?? allNeeds.find(n => n.companyName !== 'Neue Suche')?.id diff --git a/src/pages/supply/MarketLeads.tsx b/src/pages/supply/MarketLeads.tsx index 3929d96..f1690f8 100644 --- a/src/pages/supply/MarketLeads.tsx +++ b/src/pages/supply/MarketLeads.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react' import { useNavigate } from 'react-router' import { useMarketLeads } from '../../hooks/useMarketLeads' +import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds' import { DecisionContextPanel } from '../../components/ui' import type { MarketLead } from '../../hooks/useMarketLeads' import type { Property } from '../../domain/property' @@ -34,9 +35,9 @@ const SOURCE_META: Record = { } const QUALITY_META: Record = { - HIGH: { label: 'Hohe Signalqualität', color: '#1a7a4a' }, - MEDIUM: { label: 'Mittlere Signalqualität', color: '#d97706' }, - LOW: { label: 'Niedrige Signalqualität', color: '#c0392b' }, + HIGH: { label: 'Hohe Signalqualität', color: DS_TEXT.success }, + MEDIUM: { label: 'Mittlere Signalqualität', color: DS_TEXT.warning }, + LOW: { label: 'Niedrige Signalqualität', color: DS_TEXT.error }, } function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' { @@ -53,15 +54,15 @@ function PropertyMatchRow({ p }: { p: Property }) { sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.25, py: 0.75, borderRadius: 1, - bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', - cursor: 'pointer', '&:hover': { bgcolor: '#dcfce7' }, + bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, + cursor: 'pointer', '&:hover': { bgcolor: DS_SURFACE.success.bg }, }} > - - + + {p.title} - + {p.areaSqm.toLocaleString('de-CH')} m² @@ -81,7 +82,7 @@ function LeadCard({ lead }: { lead: MarketLead }) { @@ -89,7 +90,7 @@ function LeadCard({ lead }: { lead: MarketLead }) { - + @@ -103,7 +104,7 @@ function LeadCard({ lead }: { lead: MarketLead }) { @@ -143,14 +144,14 @@ function LeadCard({ lead }: { lead: MarketLead }) { {/* Market indicators */} {signal.marketIndicators && signal.marketIndicators.length > 0 && ( - + Erkannte Nachfragesignale {signal.marketIndicators.map((ind, i) => ( - - {ind} + + {ind} ))} @@ -163,14 +164,14 @@ function LeadCard({ lead }: { lead: MarketLead }) { {(signal.confirmedFacts ?? []).map((f, i) => ( - - {f} + + {f} ))} {(signal.unconfirmedFacts ?? []).map((f, i) => ( - - {f} + + {f} ))} @@ -181,7 +182,7 @@ function LeadCard({ lead }: { lead: MarketLead }) { {/* Portfolio matches */} - + Passende Objekte im Portfolio ({matchingProperties.length}) {matchingProperties.length === 0 ? ( @@ -203,7 +204,7 @@ function LeadCard({ lead }: { lead: MarketLead }) { {/* Disclaimer */} - + {signal.disclaimer} @@ -214,7 +215,7 @@ function LeadCard({ lead }: { lead: MarketLead }) { function LeadSkeleton() { return ( - + @@ -234,9 +235,9 @@ export default function MarketLeads() { {/* Header */} - + - + Markt-Leads @@ -247,7 +248,7 @@ export default function MarketLeads() { )} @@ -282,7 +283,7 @@ export default function MarketLeads() { ) : leads.length === 0 ? ( - + Keine aktiven Markt-Leads gefunden. Signale werden laufend aus öffentlichen Quellen erkannt. diff --git a/src/pages/supply/NewListing.tsx b/src/pages/supply/NewListing.tsx index 3388b36..65b1a0f 100644 --- a/src/pages/supply/NewListing.tsx +++ b/src/pages/supply/NewListing.tsx @@ -1,191 +1,45 @@ -import { useState } from 'react' +import { Alert, Box, Button, CircularProgress, Divider, Typography } from '@mui/material' +import { ArrowLeft } from 'lucide-react' import { useLocation, useNavigate } from 'react-router' +import { useNewListingForm } from '../../hooks/useNewListingForm' import { - Alert, - Box, - Button, - Card, - Chip, - CircularProgress, - Divider, - IconButton, - MenuItem, - TextField, - Tooltip, - Typography, -} from '@mui/material' -import { ArrowLeft, CheckCircle, ImagePlus, Sparkles, X } from 'lucide-react' -import { useCreateProperty } from '../../hooks/useProperties' -import { useParseListingText } from '../../hooks/useAI' -import { - ASSET_TYPE_LABELS, - SOFT_FACTORS, - LEVEL_OPTIONS, - FIT_OUT_OPTIONS, - type LocationState, -} from './newListingConstants' -import { buildCreatePropertyInput } from './newListingMapper' + AiAssistCard, + AreaDetailsSection, + AddressSection, + SoftFactorsSection, + TechnicalDetailsSection, + ImageUrlSection, + ContactSection, + CreatedScreen, +} from '../../components/new-listing' +import { DS_TEXT } from '../../lib/ds' +import type { LocationState } from './newListingConstants' export default function NewListing() { const navigate = useNavigate() const { state } = useLocation() as { state: LocationState | null } const pre = state?.prefill ?? {} - const createProperty = useCreateProperty() - const parseListingMutation = useParseListingText() + const form = useNewListingForm(pre) - // Core fields - const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE') - const [street, setStreet] = useState(pre.street ?? '') - const [houseNumber, setHouseNumber] = useState(pre.houseNumber ?? '') - const [postalCode, setPostalCode] = useState(pre.postalCode ?? '') - const [city, setCity] = useState(pre.city ?? '') - const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '') - const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '') - const [availableFrom, setAvailableFrom] = useState('') - const [description, setDescription] = useState('') - const [contactName, setContactName] = useState('') - const [contactEmail, setContactEmail] = useState('') - const [contactPhone, setContactPhone] = useState('') - - // Soft factors - const [softLevels, setSoftLevels] = useState>( - Object.fromEntries(SOFT_FACTORS.map(f => [f.key, ''])) - ) - - // Hard facts - const [floor, setFloor] = useState('') - const [fitOut, setFitOut] = useState('') - const [parking, setParking] = useState('') - const [ceilingHeight, setCeilingHeight] = useState('') - - // Images - const [images, setImages] = useState([]) - const [imageInput, setImageInput] = useState('') - - // AI - const [aiText, setAiText] = useState('') - const [aiApplied, setAiApplied] = useState(false) - - // Submit - const [error, setError] = useState(null) - const [created, setCreated] = useState(false) - - const aiParsing = parseListingMutation.isPending - const submitting = createProperty.isPending - - const isPrefilled = !!pre.propertyId - - function handleAiParse() { - if (!aiText.trim()) return - parseListingMutation.mutate(aiText, { - onSuccess: (parsed) => { - if (parsed.assetType) setAssetType(parsed.assetType) - if (parsed.areaSqm) setAreaSqm(String(parsed.areaSqm)) - if (parsed.rentPerSqm) setRentPerSqm(String(parsed.rentPerSqm)) - if (parsed.city && !city) setCity(parsed.city) - if (parsed.fitOut) setFitOut(parsed.fitOut) - if (parsed.parking) setParking(String(parsed.parking)) - if (parsed.softLevels) { - setSoftLevels(prev => ({ ...prev, ...parsed.softLevels })) - } - setAiApplied(true) - }, - }) - } - - function addImage() { - const url = imageInput.trim() - if (url && !images.includes(url)) { - setImages(prev => [...prev, url]) - } - setImageInput('') - } - - function validate(): string | null { - if (!street.trim()) return 'Strasse erforderlich' - if (!postalCode.trim()) return 'PLZ erforderlich' - if (!city.trim()) return 'Ort erforderlich' - if (!areaSqm || isNaN(Number(areaSqm)) || Number(areaSqm) <= 0) return 'Gültige Fläche eingeben' - if (!rentPerSqm || isNaN(Number(rentPerSqm)) || Number(rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben' - return null - } - - function handleSubmit() { - const err = validate() - if (err) { setError(err); return } - setError(null) - - const input = buildCreatePropertyInput({ - assetType, - street, - houseNumber, - postalCode, - city, - areaSqm: Number(areaSqm), - rentPerSqm: Number(rentPerSqm), - availableFrom, - description, - softLevels, - floor, - fitOut, - parking, - ceilingHeight, - images, - }) - - createProperty.mutate(input, { - onSuccess: () => { - setCreated(true) - }, - onError: () => { - setError('Fehler beim Erstellen des Inserats. Bitte erneut versuchen.') - }, - }) - } - - function resetForm() { - setAssetType('OFFICE') - setStreet(''); setHouseNumber(''); setPostalCode(''); setCity('') - setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('') - setContactName(''); setContactEmail(''); setContactPhone('') - setSoftLevels(Object.fromEntries(SOFT_FACTORS.map(f => [f.key, '']))) - setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('') - setImages([]); setImageInput('') - setAiText(''); setAiApplied(false) - setCreated(false) - } - - if (created) { + if (form.created) { return ( - - - Inserat erstellt - - Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar. - - - - - - + navigate('/supply/my-listings')} + onCreateAnother={form.resetForm} + /> ) } return ( - {/* Header */} @@ -193,246 +47,73 @@ export default function NewListing() { Neues Inserat erstellen - {isPrefilled + {form.isPrefilled ? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.` : 'Fläche direkt inserieren — ohne vollständiges Objekt im Portfolio.'} - {/* AI Hilfe */} - - - - - KI-Hilfe — Formular automatisch ausfüllen - - - - Beschreiben Sie die Fläche in eigenen Worten — die KI füllt die Felder automatisch aus. - - setAiText(e.target.value)} - sx={{ mb: 1.5, bgcolor: '#fff' }} - /> - {aiApplied && ( - - Felder wurden automatisch ausgefüllt — bitte überprüfen und ergänzen. - - )} - - + - {/* Flächendetails */} - - Flächendetails - - setAssetType(e.target.value)} size="small" fullWidth - > - {Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => ( - {l} - ))} - + - setAreaSqm(e.target.value)} - size="small" type="number" inputProps={{ min: 1 }} fullWidth - /> + - setRentPerSqm(e.target.value)} - size="small" type="number" inputProps={{ min: 1 }} fullWidth - /> + - setAvailableFrom(e.target.value)} - size="small" type="date" - slotProps={{ inputLabel: { shrink: true } }} fullWidth - /> - - setDescription(e.target.value)} - size="small" multiline rows={3} fullWidth sx={{ mt: 2 }} - /> - + - {/* Adresse */} - - Adresse - - setStreet(e.target.value)} size="small" fullWidth - /> - setHouseNumber(e.target.value)} size="small" fullWidth - /> - - - setPostalCode(e.target.value)} size="small" fullWidth - /> - setCity(e.target.value)} size="small" fullWidth - /> - - + - {/* Lage & Ausstrahlung */} - - - Lage & Ausstrahlung - - Diese Angaben verbessern die Match-Qualität erheblich. - - - - {SOFT_FACTORS.map(({ key, label }) => ( - setSoftLevels(prev => ({ ...prev, [key]: e.target.value }))} - size="small" - fullWidth - > - {LEVEL_OPTIONS.map(o => ( - {o.label} - ))} - - ))} - - + - {/* Technische Details */} - - Technische Details (optional) - - setFloor(e.target.value)} - size="small" type="number" fullWidth - placeholder="0 = EG" - /> - setFitOut(e.target.value)} size="small" fullWidth - > - {FIT_OUT_OPTIONS.map(o => ( - {o.label} - ))} - - setParking(e.target.value)} - size="small" type="number" inputProps={{ min: 0 }} fullWidth - /> - setCeilingHeight(e.target.value)} - size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth - /> - - - - {/* Bilder */} - - Bilder (optional) - - setImageInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addImage() } }} - size="small" - fullWidth - placeholder="https://…" - /> - - - {images.length > 0 && ( - - {images.map((url, i) => ( - 40 ? url.slice(0, 40) + '…' : url} - size="small" - onDelete={() => setImages(prev => prev.filter((_, j) => j !== i))} - sx={{ maxWidth: 300 }} - /> - ))} - - )} - - - {/* Kontakt */} - - Kontakt (optional) - - setContactName(e.target.value)} size="small" fullWidth - /> - setContactPhone(e.target.value)} size="small" fullWidth - /> - setContactEmail(e.target.value)} - size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }} - /> - - - - {error && {error}} + {form.error && {form.error}} - diff --git a/src/pages/supply/newListingConstants.ts b/src/pages/supply/newListingConstants.ts index 738e359..7109e80 100644 --- a/src/pages/supply/newListingConstants.ts +++ b/src/pages/supply/newListingConstants.ts @@ -51,3 +51,5 @@ export interface LocationState { propertyId?: string } } + +export type Prefill = NonNullable diff --git a/src/services/ai/IAIService.ts b/src/services/ai/IAIService.ts index a82cc7d..11e0c66 100644 --- a/src/services/ai/IAIService.ts +++ b/src/services/ai/IAIService.ts @@ -14,12 +14,20 @@ export interface AIProvenance { generatedAt: string /** Prompt version string used to generate this response */ promptVersion: string + /** Zod schema version used for validation */ + schemaVersion: string /** Whether the response is AI-only, mock-only, or a hybrid merge */ source: 'ai' | 'mock' | 'hybrid' /** True when the original AI call failed and mock was substituted */ fallbackUsed: boolean + /** Human-readable reason why a fallback occurred — undefined when no fallback */ + fallbackReason?: string /** True when the AI response passed Zod schema validation */ validationPassed: boolean + /** Unique request ID — correlates AIResponse with AITrace.id */ + traceId: string + /** Wall-clock latency for this call in milliseconds */ + latencyMs?: number } /** @@ -40,9 +48,11 @@ export function mockProvenance(overrides?: Partial): AIProvenance model: 'mock', generatedAt: new Date().toISOString(), promptVersion: 'mock', + schemaVersion: 'mock', source: 'mock', fallbackUsed: false, validationPassed: true, + traceId: crypto.randomUUID(), ...overrides, } } diff --git a/src/services/ai/__tests__/aiSchemas.test.ts b/src/services/ai/__tests__/aiSchemas.test.ts index 00b6b01..98da30e 100644 --- a/src/services/ai/__tests__/aiSchemas.test.ts +++ b/src/services/ai/__tests__/aiSchemas.test.ts @@ -301,7 +301,7 @@ describe('OfferEmailResponseSchema', () => { expect(result.success).toBe(false) }) - it('rejects body shorter than 10 characters', () => { + it('rejects body shorter than 50 characters', () => { const result = OfferEmailResponseSchema.safeParse({ subject: 'Angebot', body: 'Kurz.' }) expect(result.success).toBe(false) }) @@ -313,11 +313,11 @@ describe('validateAIResponse helper', () => { it('returns parsed data when schema passes', () => { const result = validateAIResponse( OfferEmailResponseSchema, - { subject: 'Test', body: 'Long enough body text here.' }, + { subject: 'Angebot Büroflächen', body: 'This body is definitely long enough to pass the fifty character minimum threshold.' }, 'test', ) expect(result).not.toBeNull() - expect(result?.subject).toBe('Test') + expect(result?.subject).toBe('Angebot Büroflächen') }) it('returns null when schema fails (does not throw)', () => { diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts index 3191e06..cb0ec7e 100644 --- a/src/services/ai/mock/MockAIService.ts +++ b/src/services/ai/mock/MockAIService.ts @@ -59,7 +59,7 @@ const FOLLOW_UP_TEMPLATES: Partial): boolean { + const { min, max } = areaRange + if (min <= 0 || max <= 0) return true + return max / min > AREA_AMBIGUITY_RATIO_THRESHOLD +} + +// Priority order: required fields first, recommended next, optional last. +// Max 3 questions returned. Area range ambiguity is detected and raised as a +// required clarification even when areaRange is nominally present. +const FIELD_PRIORITY: Array = [ + 'assetType', + 'areaRange', + 'preferredLocations', + 'budgetRange', + 'timing', + 'mustHaveCriteria', +] + function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[] { - const missing: Array = [] + const questions: FollowUpQuestion[] = [] + let idx = 0 - if (!criteria.assetType) missing.push('assetType') - if (!criteria.areaRange) missing.push('areaRange') - if (!criteria.preferredLocations?.length) missing.push('preferredLocations') - if (!criteria.budgetRange) missing.push('budgetRange') - if (!criteria.timing) missing.push('timing') - if (!criteria.mustHaveCriteria?.length) missing.push('mustHaveCriteria') + for (const field of FIELD_PRIORITY) { + if (questions.length >= 3) break - return missing - .slice(0, 3) - .map((field, i) => { + if (field === 'areaRange') { + if (!criteria.areaRange) { + const tpl = FOLLOW_UP_TEMPLATES['areaRange']! + questions.push({ id: `fq-mock-${idx++}`, questionText: tpl.questionText, targetField: 'areaRange', reason: tpl.reason, importance: tpl.importance }) + } else if (isAreaAmbiguous(criteria.areaRange)) { + questions.push({ id: `fq-mock-${idx++}`, questionText: AREA_AMBIGUITY_QUESTION.questionText, targetField: 'areaRange', reason: AREA_AMBIGUITY_QUESTION.reason, importance: AREA_AMBIGUITY_QUESTION.importance }) + } + continue + } + + const isMissing = + field === 'preferredLocations' ? !criteria.preferredLocations?.length + : field === 'mustHaveCriteria' ? !criteria.mustHaveCriteria?.length + : !criteria[field] + + if (isMissing) { const tpl = FOLLOW_UP_TEMPLATES[field] - if (!tpl) return null - const q: FollowUpQuestion = { - id: `fq-mock-${i}`, + if (!tpl) continue + questions.push({ + id: `fq-mock-${idx++}`, questionText: tpl.questionText, targetField: field, reason: tpl.reason, importance: tpl.importance, suggestedAnswerOptions: tpl.suggestedAnswerOptions, - } - return q - }) - .filter((q): q is FollowUpQuestion => q !== null) + }) + } + } + + return questions } // ── Service ─────────────────────────────────────────────────────────────────── diff --git a/src/services/ai/openrouter/OpenRouterAIService.ts b/src/services/ai/openrouter/OpenRouterAIService.ts index d0fe364..c7c09b3 100644 --- a/src/services/ai/openrouter/OpenRouterAIService.ts +++ b/src/services/ai/openrouter/OpenRouterAIService.ts @@ -87,15 +87,19 @@ function makeProvenance( source: AIProvenance['source'], fallbackUsed: boolean, validationPassed: boolean, + extras: { fallbackReason?: string } = {}, ): AIProvenance { return { provider: 'openrouter', model: config.model, generatedAt: new Date().toISOString(), promptVersion: PROMPT_VERSION, + schemaVersion: SCHEMA_VERSION, source, fallbackUsed, validationPassed, + traceId: crypto.randomUUID(), + fallbackReason: extras.fallbackReason, } } @@ -186,34 +190,51 @@ async function withFallback( ): Promise> { const config = getConfig() const startMs = Date.now() + const callId = crypto.randomUUID() if (!config) { console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`) const result = await fallback() + const latencyMs = Date.now() - startMs + const provenance: AIProvenance = { + ...result.provenance, + fallbackUsed: true, + traceId: callId, + fallbackReason: 'no_api_key', + schemaVersion: SCHEMA_VERSION, + latencyMs, + } aiTraceStore.add({ - id: crypto.randomUUID(), + id: callId, method: label, provider: 'openrouter', model: DEFAULT_MODEL, promptVersion: PROMPT_VERSION, - latencyMs: Date.now() - startMs, + latencyMs, fallbackUsed: true, validationPassed: false, responseValidationStatus: 'fallback', errorType: 'no_api_key', + fallbackReason: 'no_api_key', source: 'mock', createdAt: new Date().toISOString(), inputSizeChars, }) - return { ...result, provenance: { ...result.provenance, fallbackUsed: true } } + return { ...result, provenance } } try { const result = await fn(config) const latencyMs = Date.now() - startMs const prov = result.provenance + const provenance: AIProvenance = { + ...prov, + traceId: callId, + latencyMs, + schemaVersion: SCHEMA_VERSION, + } aiTraceStore.add({ - id: crypto.randomUUID(), + id: callId, method: label, provider: prov.provider, model: prov.model, @@ -221,12 +242,13 @@ async function withFallback( latencyMs, fallbackUsed: prov.fallbackUsed, validationPassed: prov.validationPassed, - responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source), + responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason), + fallbackReason: prov.fallbackReason, source: prov.source, createdAt: prov.generatedAt, inputSizeChars, }) - return result + return { ...result, provenance } } catch (err) { console.error(`[OpenRouterAIService] ${label} failed:`, err) const result = await fallback() @@ -241,8 +263,17 @@ async function withFallback( err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED ? 'api_error' : 'network_error' + const fallbackReason = `${errorType}: ${err instanceof Error ? err.message.slice(0, 100) : 'unknown error'}` + const provenance: AIProvenance = { + ...result.provenance, + fallbackUsed: true, + traceId: callId, + fallbackReason, + schemaVersion: SCHEMA_VERSION, + latencyMs, + } aiTraceStore.add({ - id: crypto.randomUUID(), + id: callId, method: label, provider: 'openrouter', model: config.model, @@ -252,11 +283,12 @@ async function withFallback( validationPassed: false, responseValidationStatus, errorType, + fallbackReason, source: 'mock', createdAt: new Date().toISOString(), inputSizeChars, }) - return { ...result, provenance: { ...result.provenance, fallbackUsed: true } } + return { ...result, provenance } } } @@ -275,7 +307,7 @@ export const OpenRouterAIService: IAIService = { if (!ai) { console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback') const fb = await MockAIService.parseNeed(input) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } const extractedCriteria: ParsedNeedCriteria = { @@ -321,9 +353,14 @@ export const OpenRouterAIService: IAIService = { // ── generateFollowUpQuestions ─────────────────────────────────────────────── generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise> { return withFallback('generateFollowUpQuestions', async (config) => { - const missingFields = Object.entries(criteria) - .filter(([, v]) => v == null) - .map(([k]) => k) + const missingFields = [ + ...(!criteria.assetType ? ['assetType'] : []), + ...(!criteria.areaRange || (criteria.areaRange.min <= 0 && criteria.areaRange.max <= 0) ? ['areaRange'] : []), + ...(!criteria.preferredLocations?.length ? ['preferredLocations'] : []), + ...(!criteria.budgetRange ? ['budgetRange'] : []), + ...(!criteria.timing ? ['timing'] : []), + ...(!criteria.mustHaveCriteria?.length ? ['mustHaveCriteria'] : []), + ] const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields }) const raw = await chat(config, system, user) const json = extractJSON(raw) @@ -332,7 +369,7 @@ export const OpenRouterAIService: IAIService = { if (!ai?.length) { console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback') const fb = await MockAIService.generateFollowUpQuestions(criteria) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: ai.map((q, i) => ({ @@ -359,7 +396,7 @@ export const OpenRouterAIService: IAIService = { if (!summary) { console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback') const fb = await MockAIService.generateMatchExplanation(input) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: 'empty_response' }) } } const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches' return { @@ -387,7 +424,7 @@ export const OpenRouterAIService: IAIService = { if (!ai) { console.warn('[OpenRouterAIService] summarizeTradeOffs: invalid response — using mock fallback') const fb = await MockAIService.summarizeTradeOffs(tradeoffs) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { @@ -430,7 +467,7 @@ export const OpenRouterAIService: IAIService = { if (!ai) { console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback') const fb = await MockAIService.summarizeComparison(items) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } const mock = await MockAIService.summarizeComparison(items) return { @@ -456,7 +493,7 @@ export const OpenRouterAIService: IAIService = { if (!ai) { console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback') const fb = await MockAIService.generateDecisionBrief(shortlistId) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } const mock = await MockAIService.generateDecisionBrief(shortlistId) return { @@ -481,7 +518,7 @@ export const OpenRouterAIService: IAIService = { if (!ai) { console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback') const fb = await MockAIService.generateDataQualitySummary(propertyId, quality) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { @@ -508,7 +545,7 @@ export const OpenRouterAIService: IAIService = { if (!ai) { console.warn('[OpenRouterAIService] classifyMarketSignal: invalid response — using mock fallback') const fb = await MockAIService.classifyMarketSignal(signalText) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { @@ -539,7 +576,7 @@ export const OpenRouterAIService: IAIService = { if (!ai) { console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback') const fb = await MockAIService.generateOfferEmail(payload) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { subject: ai.subject, body: ai.body }, @@ -559,7 +596,7 @@ export const OpenRouterAIService: IAIService = { if (!ai) { console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback') const fb = await MockAIService.extractCriteria(input) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: { @@ -602,7 +639,7 @@ export const OpenRouterAIService: IAIService = { if (!ai?.length) { console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback') const fb = await MockAIService.generateFollowUp(partialNeed) - return { ...fb, provenance: makeProvenance(config, 'mock', true, false) } + return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) } } return { data: ai.map(q => q.questionText).filter(Boolean), diff --git a/src/services/ai/prompts/followUpQuestionsPrompt.ts b/src/services/ai/prompts/followUpQuestionsPrompt.ts index fb8291b..a70f686 100644 --- a/src/services/ai/prompts/followUpQuestionsPrompt.ts +++ b/src/services/ai/prompts/followUpQuestionsPrompt.ts @@ -24,8 +24,12 @@ PRIORITÄTSREIHENFOLGE: 5. timing (recommended) — wichtig für Verfügbarkeitsabgleich 6. mustHaveCriteria (optional) — Pflichtmerkmale (Parkplätze, Laderampe etc.) +MEHRDEUTIGKEITSERKENNUNG — prüfe auch bekannte Felder auf Ambiguität: +- areaRange vorhanden, aber max/min-Verhältnis > 5: generiere eine Präzisierungsfrage (targetField: "areaRange", importance: "required") statt sie als vollständig zu behandeln +- areaRange vorhanden, aber min = 0 oder max = 0: generiere dieselbe Präzisierungsfrage + VERBOTE — NIEMALS: -- Fragen zu bereits bekannten Kriterien stellen +- Fragen zu bereits bekannten, eindeutigen Kriterien stellen - Mehr als 3 Fragen ausgeben - Fragen erfinden, die nicht einem der 6 definierten Zielfelder entsprechen - Doppelfragen stellen @@ -54,7 +58,7 @@ Ausgabe: "importance": "required" }, { - "questionText": "Was ist Ihr maximales Budget pro m² und Monat (CHF)?", + "questionText": "Was ist Ihr maximales Budget pro m² und Jahr (CHF)?", "targetField": "budgetRange", "reason": "Budget ist wichtig für die Filterung unpassender Objekte", "suggestedAnswerOptions": [], diff --git a/src/services/ai/prompts/marketSignalPrompt.ts b/src/services/ai/prompts/marketSignalPrompt.ts index 623b469..ad1e935 100644 --- a/src/services/ai/prompts/marketSignalPrompt.ts +++ b/src/services/ai/prompts/marketSignalPrompt.ts @@ -34,6 +34,7 @@ WEITERE VERBOTE: - Fläche in m² schätzen, wenn kein konkreter Hinweis im Text steht (→ null setzen) - Zeithorizont nennen, wenn er nicht aus dem Text ableitbar ist (→ null setzen) - probability > 0.85 setzen ohne mehrere unabhängige, verlässliche Bestätigungen +- probability mit mehr als 2 Dezimalstellen angeben (z.B. 0.724 → 0.72, 0.6666 → 0.67) AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen): { diff --git a/src/services/ai/prompts/needParsingPrompt.ts b/src/services/ai/prompts/needParsingPrompt.ts index 8af10ef..4d78956 100644 --- a/src/services/ai/prompts/needParsingPrompt.ts +++ b/src/services/ai/prompts/needParsingPrompt.ts @@ -20,10 +20,10 @@ VERBOTE — NIEMALS: AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen): { - "assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "GASTRO" | "MIXED" | "UNKNOWN" | null, - "areaRange": { "min": number, "max": number } | null, + "assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "LIGHT_INDUSTRIAL" | "GASTRO" | "MIXED" | "UNKNOWN" | null, + "areaRange": { "min": number (≥1), "max": number (≥1, ≥ min) } | null, "preferredLocations": string[], - "budgetRange": { "maxPerSqm": number, "currency": "CHF" } | null, + "budgetRange": { "maxPerSqm": number (CHF/m²/Jahr), "currency": "CHF" } | null, "timing": { "earliestMoveIn": "YYYY-MM-DD" | null, "latestMoveIn": "YYYY-MM-DD" | null, @@ -34,14 +34,20 @@ AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block "assumptions": string[] } +FELDREGELN: +- areaRange.min und areaRange.max müssen beide ≥ 1 sein — nie 0 setzen +- budgetRange.maxPerSqm ist CHF pro m² pro Jahr (Jahresmiete) — nicht Monatsmiete +- LIGHT_INDUSTRIAL: Leichtindustrielle Nutzung (Werkstatt, Atelier, kleine Produktion), klar abgegrenzt von LOGISTICS +- Maximale Einträge: preferredLocations max. 20, mustHaveCriteria max. 10 + BEISPIEL: -Eingabe: "Wir suchen ein Büro für ca. 20 Personen in Zürich, Budget rund 50 CHF/m², Einzug ab März 2026" +Eingabe: "Wir suchen ein Büro für ca. 20 Personen in Zürich, Budget rund 600 CHF/m²/Jahr, Einzug ab März 2026" Ausgabe: { "assetType": "OFFICE", "areaRange": { "min": 200, "max": 400 }, "preferredLocations": ["Zürich"], - "budgetRange": { "maxPerSqm": 50, "currency": "CHF" }, + "budgetRange": { "maxPerSqm": 600, "currency": "CHF" }, "timing": { "earliestMoveIn": "2026-03-01", "latestMoveIn": null, "flexibleTiming": false }, "mustHaveCriteria": [], "missingFields": ["timing.latestMoveIn", "mustHaveCriteria"], diff --git a/src/services/ai/schemas.ts b/src/services/ai/schemas.ts index 4f281d9..a213183 100644 --- a/src/services/ai/schemas.ts +++ b/src/services/ai/schemas.ts @@ -4,6 +4,10 @@ * Every OpenRouter response is validated against its schema before reaching * the UI. Validation failures trigger an explicit fallback to MockAIService — * no invalid data ever passes through silently. + * + * All schemas use .strict() — any unknown key from the AI response triggers + * immediate validation failure and fallback, preventing hallucinated fields + * from reaching the UI. */ import { z } from 'zod' @@ -20,33 +24,44 @@ export const ScoreSchema = z.number().min(0).max(100) // ── 1. Need Parsing ─────────────────────────────────────────────────────────── -export const NeedParsingResponseSchema = z.object({ - assetType: z - .enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'GASTRO', 'MIXED', 'UNKNOWN']) - .optional() - .nullable(), - areaRange: z - .object({ min: z.number().min(0), max: z.number().min(0) }) - .optional() - .nullable() - .refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }), - preferredLocations: z.array(z.string().min(1)).optional(), - budgetRange: z - .object({ maxPerSqm: z.number().positive(), currency: z.string().min(1) }) - .optional() - .nullable(), - timing: z - .object({ - earliestMoveIn: z.string().optional(), - latestMoveIn: z.string().optional(), - flexibleTiming: z.boolean().optional(), - }) - .optional() - .nullable(), - mustHaveCriteria: z.array(z.string()).optional(), - missingFields: z.array(z.string()).optional(), - assumptions: z.array(z.string()).optional(), -}) +export const NeedParsingResponseSchema = z + .object({ + assetType: z + .enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'LIGHT_INDUSTRIAL', 'GASTRO', 'MIXED', 'UNKNOWN']) + .optional() + .nullable(), + areaRange: z + .object({ + min: z.number().min(1, 'minimum area must be ≥ 1 m²').max(100_000), + max: z.number().min(0).max(100_000), + }) + .strict() + .optional() + .nullable() + .refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }), + preferredLocations: z.array(z.string().min(1).max(100)).max(20).optional(), + budgetRange: z + .object({ + maxPerSqm: z.number().positive().max(100_000, 'budget > 100k CHF/m²/a is implausible'), + currency: z.string().min(1).max(10), + }) + .strict() + .optional() + .nullable(), + timing: z + .object({ + earliestMoveIn: z.string().optional(), + latestMoveIn: z.string().optional(), + flexibleTiming: z.boolean().optional(), + }) + .strict() + .optional() + .nullable(), + mustHaveCriteria: z.array(z.string().min(1).max(200)).max(30).optional(), + missingFields: z.array(z.string().min(1)).max(20).optional(), + assumptions: z.array(z.string().min(1)).max(20).optional(), + }) + .strict() export type NeedParsingResponseRaw = z.infer @@ -54,81 +69,102 @@ export type NeedParsingResponseRaw = z.infer export const FollowUpQuestionsResponseSchema = z .array( - z.object({ - questionText: z.string().min(1), - targetField: z.string().min(1), - reason: z.string().optional(), - suggestedAnswerOptions: z.array(z.string()).optional(), - importance: z - .enum(['required', 'recommended', 'optional']) - .optional(), - }), + z + .object({ + questionText: z.string().min(5).max(500), + targetField: z.string().min(1).max(100), + reason: z.string().min(3).max(300).optional(), + suggestedAnswerOptions: z.array(z.string().min(1).max(200)).max(10).optional(), + importance: z.enum(['required', 'recommended', 'optional']), + }) + .strict(), ) - .max(5) + .max(3) // ── 3. Trade-Off Summary ────────────────────────────────────────────────────── -export const TradeOffSummaryResponseSchema = z.object({ - headline: z.string().min(1), - items: z - .array( - z.object({ - concern: z.string().min(1), - severity: SeveritySchema, - mitigation: z.string().optional(), - }), - ) - .max(5), - overallRisk: SeveritySchema, -}) +export const TradeOffSummaryResponseSchema = z + .object({ + headline: z.string().min(5).max(300), + items: z + .array( + z + .object({ + concern: z.string().min(5).max(300), + severity: SeveritySchema, + mitigation: z.string().max(300).optional(), + }) + .strict(), + ) + .max(3), + overallRisk: SeveritySchema, + }) + .strict() // ── 4. Comparison Summary ───────────────────────────────────────────────────── -export const CompareSummaryResponseSchema = z.object({ - overallAssessment: z.string().min(1), - recommendation: z.string().optional(), - strongestOption: z.string().optional(), -}) +export const CompareSummaryResponseSchema = z + .object({ + overallAssessment: z.string().min(10).max(1000), + recommendation: z.string().min(5).max(500).optional(), + strongestOption: z.string().min(1).max(200).optional(), + }) + .strict() // ── 5. Decision Brief ──────────────────────────────────────────────────────── -export const DecisionBriefResponseSchema = z.object({ - summary: z.string().min(1), - sections: z - .array(z.object({ title: z.string().min(1), body: z.string().min(1) })) - .min(1) - .max(6), -}) +export const DecisionBriefResponseSchema = z + .object({ + summary: z.string().min(10).max(500), + sections: z + .array( + z + .object({ + title: z.string().min(1).max(100), + body: z.string().min(10).max(1000), + }) + .strict(), + ) + .min(1) + .max(6), + }) + .strict() // ── 6. Data Quality Summary ─────────────────────────────────────────────────── -export const DataQualitySummaryResponseSchema = z.object({ - overallAssessment: z.string().min(1), - missingCriticalFields: z.array(z.string()).optional(), - recommendation: z.string().min(1), - confidence: z.number().min(0).max(1), -}) +export const DataQualitySummaryResponseSchema = z + .object({ + overallAssessment: z.string().min(10).max(600), + missingCriticalFields: z.array(z.string().min(1).max(100)).max(30).optional(), + recommendation: z.string().min(5).max(500), + confidence: z.number().min(0).max(1), + }) + .strict() // ── 7. Market Signal Classification ────────────────────────────────────────── -export const MarketSignalClassificationResponseSchema = z.object({ - signalType: z.enum([ - 'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING', - 'EXPANSION', 'RELOCATION', 'UNKNOWN', - ]), - probability: ProbabilitySchema, - timeHorizonMonths: z.number().positive().int().optional().nullable(), - areaSqmEstimate: z.number().positive().optional().nullable(), - credibility: CredibilitySchema, - reasoning: z.string().min(1), -}) +export const MarketSignalClassificationResponseSchema = z + .object({ + signalType: z.enum([ + 'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING', + 'EXPANSION', 'RELOCATION', 'UNKNOWN', + ]), + probability: ProbabilitySchema, + timeHorizonMonths: z.number().int().min(1).max(240).optional().nullable(), + areaSqmEstimate: z.number().positive().max(1_000_000).optional().nullable(), + credibility: CredibilitySchema, + reasoning: z.string().min(10).max(1000), + }) + .strict() // ── 8. Offer Email ──────────────────────────────────────────────────────────── -export const OfferEmailResponseSchema = z.object({ - subject: z.string().min(1), - body: z.string().min(10), -}) +export const OfferEmailResponseSchema = z + .object({ + subject: z.string().min(5).max(200), + body: z.string().min(50).max(5000), + }) + .strict() // ── Validation helper ───────────────────────────────────────────────────────── @@ -144,6 +180,12 @@ export function validateAIResponse( ): T | null { const result = schema.safeParse(raw) if (result.success) return result.data - console.warn(`[AISchema] ${label} validation failed:`, result.error.flatten()) + const errors = result.error.flatten() + console.warn(`[AISchema] ${label} validation failed`, { + fieldErrors: errors.fieldErrors, + formErrors: errors.formErrors, + receivedKeys: typeof raw === 'object' && raw !== null ? Object.keys(raw as object) : [], + snippet: JSON.stringify(raw).slice(0, 300), + }) return null } diff --git a/src/services/ai/tracing.ts b/src/services/ai/tracing.ts index 0ebb2a4..a418385 100644 --- a/src/services/ai/tracing.ts +++ b/src/services/ai/tracing.ts @@ -56,6 +56,8 @@ export interface AITrace { responseValidationStatus: AITraceValidationStatus /** Only present when responseValidationStatus indicates a failure */ errorType?: AITraceErrorType + /** Human-readable reason for the fallback — mirrors AIProvenance.fallbackReason */ + fallbackReason?: string source: AIProvenance['source'] /** ISO-8601 timestamp of when the call completed */ createdAt: string @@ -65,6 +67,11 @@ export interface AITrace { // ── Store ───────────────────────────────────────────────────────────────────── +function percentile(sortedArr: number[], p: number): number { + if (sortedArr.length === 0) return 0 + return sortedArr[Math.max(0, Math.ceil(p * sortedArr.length) - 1)] +} + const MAX_ENTRIES = 100 const STORAGE_KEY = 'pm_ai_traces' @@ -103,19 +110,67 @@ class AITraceStore { fallbacks: number schemaFailures: number avgLatencyMs: number + latencyPercentiles: { p50: number; p90: number; p99: number } byMethod: Record + failureCountByError: Record + validationFailuresByMethod: Record + fallbackReasonDistribution: Record + promptVersionUsage: Record } { - const total = this.entries.length - const fallbacks = this.entries.filter(t => t.fallbackUsed).length + const total = this.entries.length + const fallbacks = this.entries.filter(t => t.fallbackUsed).length const schemaFailures = this.entries.filter(t => t.responseValidationStatus === 'invalid_schema').length - const avgLatencyMs = total === 0 ? 0 : Math.round( - this.entries.reduce((s, t) => s + t.latencyMs, 0) / total - ) + + const sortedLatencies = [...this.entries.map(t => t.latencyMs)].sort((a, b) => a - b) + const avgLatencyMs = total === 0 ? 0 : Math.round(sortedLatencies.reduce((s, l) => s + l, 0) / total) + const byMethod = this.entries.reduce>((acc, t) => { acc[t.method] = (acc[t.method] ?? 0) + 1 return acc }, {}) - return { total, fallbacks, schemaFailures, avgLatencyMs, byMethod } + + const failureCountByError = this.entries + .filter(t => t.errorType) + .reduce>((acc, t) => { + acc[t.errorType!] = (acc[t.errorType!] ?? 0) + 1 + return acc + }, {}) + + const validationFailuresByMethod = this.entries + .filter(t => t.responseValidationStatus === 'invalid_schema') + .reduce>((acc, t) => { + acc[t.method] = (acc[t.method] ?? 0) + 1 + return acc + }, {}) + + const fallbackReasonDistribution = this.entries + .filter(t => t.fallbackReason) + .reduce>((acc, t) => { + acc[t.fallbackReason!] = (acc[t.fallbackReason!] ?? 0) + 1 + return acc + }, {}) + + const promptVersionUsage = this.entries.reduce>((acc, t) => { + acc[t.promptVersion] = (acc[t.promptVersion] ?? 0) + 1 + return acc + }, {}) + + return { + total, + fallbacks, + schemaFailures, + avgLatencyMs, + latencyPercentiles: { + p50: percentile(sortedLatencies, 0.50), + p90: percentile(sortedLatencies, 0.90), + p99: percentile(sortedLatencies, 0.99), + }, + byMethod, + failureCountByError, + validationFailuresByMethod, + fallbackReasonDistribution, + promptVersionUsage, + } } /** Load the persisted trace list from localStorage (dev only). */ @@ -140,7 +195,8 @@ class AITraceStore { console.debug( `[AITrace] ${icon} ${trace.method}${fallback}${validation}` + ` — ${trace.provider}/${trace.model}` + - ` | ${trace.latencyMs}ms | source:${trace.source}`, + ` | ${trace.latencyMs}ms | source:${trace.source}` + + (trace.fallbackReason ? ` | reason:${trace.fallbackReason}` : ''), trace, ) } @@ -175,8 +231,12 @@ if (import.meta.env.DEV && typeof window !== 'undefined') { export function provenanceToStatus( fallbackUsed: boolean, source: AIProvenance['source'], + fallbackReason?: string, ): AITraceValidationStatus { if (!fallbackUsed) return 'valid' + if (fallbackReason === 'no_api_key') return 'fallback' + if (fallbackReason?.startsWith('api_error')) return 'api_error' + if (fallbackReason?.startsWith('network')) return 'network_error' if (source === 'mock') return 'invalid_schema' return 'valid' }