refactor: architecture compliance pass — DS tokens, hook boundary, god component split, AI hardening

- DS token migration: Anfragen.tsx + child components (AnfragenInquiryItem, AnfragenMessageBubble)
  fully migrated; DS_TEXT.brandDark added; scoreTheme.ts moved to src/lib/ with re-export proxy
- Hook boundary: Results.tsx no longer calls needService directly — routes through useNeeds()
  with optional refetchOnMount/gcTime overrides
- NewListing.tsx (440L) split into useNewListingForm hook + 8 section components under
  src/components/new-listing/; page shell reduced to 121 lines
- AI hardening: Zod .strict() on all schemas, AIProvenance extended with schemaVersion/
  fallbackReason/traceId/latencyMs, AITraceStore stats with p50/p90/p99 + failure breakdowns,
  MockAIService buildFollowUpQuestions with priority ordering + area-ambiguity detection,
  prompt templates updated (LIGHT_INDUSTRIAL, budget unit, ambiguity detection, decimal precision)
- Tests: all 154 passing; fixed test regression caused by OfferEmailResponseSchema body min(50)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 16:10:39 +02:00
parent e36c5bc979
commit e1f4beb898
44 changed files with 1610 additions and 1058 deletions
@@ -3,6 +3,7 @@ import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport' import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import type { Inquiry } from '../../domain/inquiry' import type { Inquiry } from '../../domain/inquiry'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
interface Props { interface Props {
draft: InquiryPreparationReportDraft draft: InquiryPreparationReportDraft
@@ -100,39 +101,39 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
mb: 2, mb: 2,
}} }}
> >
<Box sx={{ borderBottom: '3px solid #1e3a5f', pb: 2, mb: 3 }}> <Box sx={{ borderBottom: `3px solid ${DS_TEXT.brand}`, pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}> <Typography variant="h5" sx={{ fontWeight: 700, color: DS_TEXT.brand, fontFamily: 'inherit' }}>
Objektvorschlag Objektvorschlag
</Typography> </Typography>
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}> <Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 0.5 }}>
Wincasa AG · {new Date().toLocaleDateString('de-CH')} Wincasa AG · {new Date().toLocaleDateString('de-CH')}
</Typography> </Typography>
</Box> </Box>
{editableByField['intro'] && ( {editableByField['intro'] && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: '#1e293b' }}> <Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: DS_TEXT.primary }}>
{editableByField['intro']} {editableByField['intro']}
</Typography> </Typography>
)} )}
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}> <Box sx={{ bgcolor: DS_BG.page, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}> <Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Ihre Anfrage Ihre Anfrage
</Typography> </Typography>
<Typography variant="body2" sx={{ color: '#374151', mb: 0.5 }}> <Typography variant="body2" sx={{ color: DS_TEXT.primary, mb: 0.5 }}>
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''} <strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: '#374151' }}> <Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
<strong>Betreff:</strong> {inquiry.subject} <strong>Betreff:</strong> {inquiry.subject}
</Typography> </Typography>
</Box> </Box>
{editableByField['highlights'] && ( {editableByField['highlights'] && (
<> <>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}> <Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Warum diese Objekte passen Warum diese Objekte passen
</Typography> </Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151', mb: 3 }}> <Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary, mb: 3 }}>
{editableByField['highlights']} {editableByField['highlights']}
</Typography> </Typography>
</> </>
@@ -145,7 +146,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
label={p.title} label={p.title}
size="small" size="small"
icon={<Building2 size={12} />} icon={<Building2 size={12} />}
sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }} sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }}
/> />
))} ))}
</Box> </Box>
@@ -161,7 +162,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
<Box key={property.id}> <Box key={property.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Divider sx={{ flex: 1 }} /> <Divider sx={{ flex: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}> <Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
Objekt {idx + 1} von {selectedProps.length} Objekt {idx + 1} von {selectedProps.length}
</Typography> </Typography>
<Divider sx={{ flex: 1 }} /> <Divider sx={{ flex: 1 }} />
@@ -184,7 +185,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
height: 120, height: 120,
borderRadius: 1, borderRadius: 1,
overflow: 'hidden', overflow: 'hidden',
bgcolor: '#e2e8f0', bgcolor: DS_BORDER.default,
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none', backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
@@ -194,7 +195,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
{!property.mapImageUrl && <MapPin size={28} color="#94a3b8" />} {!property.mapImageUrl && <MapPin size={28} color={DS_TEXT.disabled} />}
</Box> </Box>
{/* Property photo */} {/* Property photo */}
<Box <Box
@@ -203,7 +204,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
height: 120, height: 120,
borderRadius: 1, borderRadius: 1,
overflow: 'hidden', overflow: 'hidden',
bgcolor: '#e2e8f0', bgcolor: DS_BORDER.default,
backgroundImage: image ? `url(${image})` : 'none', backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
@@ -212,23 +213,23 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
{!image && <Building2 size={28} color="#94a3b8" />} {!image && <Building2 size={28} color={DS_TEXT.disabled} />}
</Box> </Box>
</Box> </Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 0.5, fontFamily: 'inherit' }}> <Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.brand, mb: 0.5, fontFamily: 'inherit' }}>
{property.title} {property.title}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<MapPin size={13} /> <MapPin size={13} />
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography> <Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<Ruler size={13} /> <Ruler size={13} />
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography> <Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<Calendar size={13} /> <Calendar size={13} />
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography> <Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
</Box> </Box>
@@ -240,7 +241,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr 1fr', gridTemplateColumns: '1fr 1fr',
gap: 0.75, gap: 0.75,
bgcolor: '#f8fafc', bgcolor: DS_BG.page,
borderRadius: 1, borderRadius: 1,
p: 1.5, p: 1.5,
}} }}
@@ -250,10 +251,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
if (!val) return null if (!val) return null
return ( return (
<Box key={key}> <Box key={key}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block' }}> <Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', display: 'block' }}>
{FIELD_LABELS[key] ?? key} {FIELD_LABELS[key] ?? key}
</Typography> </Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.78rem' }}> <Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary, fontSize: '0.78rem' }}>
{val} {val}
</Typography> </Typography>
</Box> </Box>
@@ -268,10 +269,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
{editableByField['next_steps'] && ( {editableByField['next_steps'] && (
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}> <Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}> <Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Nächste Schritte Nächste Schritte
</Typography> </Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151' }}> <Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary }}>
{editableByField['next_steps']} {editableByField['next_steps']}
</Typography> </Typography>
</Box> </Box>
@@ -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 (
<>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
variant="contained"
size="large"
disabled={!canProceed || isProcessing}
onClick={onSearch}
endIcon={isSearching ? <CircularProgress size={18} color="inherit" /> : <Search size={18} />}
sx={{
flex: 1,
py: 1.5,
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
textTransform: 'none',
}}
>
{isSearching ? 'Sucht…' : 'Jetzt suchen'}
</Button>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Button
variant="outlined"
size="large"
disabled={!canProceed || isProcessing}
onClick={onSaveProfile}
startIcon={<Bookmark size={16} />}
endIcon={isSavingProfile ? <CircularProgress size={18} color="inherit" /> : <ArrowRight size={18} />}
sx={{
py: 1.5,
fontSize: 15,
fontWeight: 500,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{isSavingProfile ? 'Analysiert…' : 'Als Suchprofil speichern'}
</Button>
</Box>
<Alert severity="info" sx={{ mt: -1 }}>
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird auch in Zukunft.
</Alert>
</>
)
}
@@ -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<WeightingKey, number>
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 (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
<Alert severity="success" sx={{ mb: 3 }}>
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
</Alert>
<NeedCardPreview
criteria={criteria}
weights={weights}
confidenceByField={parseResult.confidenceByField}
missingFields={parseResult.missingFields}
needTitle={needTitle}
onNeedTitleChange={onNeedTitleChange}
/>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
Zurück
</Button>
<Button
variant="contained"
onClick={onSave}
disabled={isSaving}
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
{isSaving
? 'Wird gespeichert…'
: overallConfidence < 0.6
? 'Als Entwurf speichern'
: 'Suchprofil speichern & Matching starten'}
</Button>
</Box>
</Box>
)
}
@@ -1,6 +1,6 @@
import { Box, Chip, Typography } from '@mui/material' import { Box, Chip, Typography } from '@mui/material'
import { Kanban } from 'lucide-react' 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' import type { Inquiry } from '../../domain/inquiry'
interface AnfragenInquiryItemProps { interface AnfragenInquiryItemProps {
@@ -14,28 +14,29 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new'] const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1] const lastMsg = inq.thread[inq.thread.length - 1]
const unreadColor = cfg?.fg ?? DS_TEXT.danger
return ( return (
<Box onClick={() => onSelect(inq.id)} sx={{ <Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5, px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9', borderBottom: `1px solid ${DS_BORDER.muted}`,
cursor: 'pointer', cursor: 'pointer',
bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent', bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
borderLeft: isSelected ? '3px solid' : '3px solid transparent', borderLeft: isSelected ? '3px solid' : '3px solid transparent',
borderLeftColor: isSelected ? 'primary.main' : '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', transition: 'background-color 0.1s ease',
}}> }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: cfg?.fg ?? '#dc2626', flexShrink: 0 }} />} {!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap> <Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{inq.tenantName} {inq.tenantName}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{inq.unreadCount > 0 && ( {inq.unreadCount > 0 && (
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: cfg?.fg ?? '#dc2626', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: unreadColor, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography> <Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
</Box> </Box>
)} )}
@@ -47,7 +48,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
{inq.tenantCompany} {inq.tenantCompany}
</Typography> </Typography>
)} )}
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap> <Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
{inq.subject} {inq.subject}
</Typography> </Typography>
{lastMsg && ( {lastMsg && (
@@ -60,7 +61,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
<Chip label={cfg?.label ?? inq.status} size="small" <Chip label={cfg?.label ?? inq.status} size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bg, color: cfg?.fg, fontWeight: 600 }} /> sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bg, color: cfg?.fg, fontWeight: 600 }} />
{inq.matchScore && ( {inq.matchScore && (
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}> <Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning, fontWeight: 700, fontSize: '0.7rem' }}>
{inq.matchScore}% {inq.matchScore}%
</Typography> </Typography>
)} )}
+10 -10
View File
@@ -1,6 +1,6 @@
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { Bot, Building2, FileText } from 'lucide-react' 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' import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) { export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
@@ -12,8 +12,8 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
{!isOwnMessage && isAI && <Bot size={11} color="#7c3aed" />} {!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color="#64748b" />} {!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}> <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time} {msg.senderName} · {date} {time}
</Typography> </Typography>
@@ -24,14 +24,14 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
px: 2, py: 1.25, px: 2, py: 1.25,
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px', borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
bgcolor: isOwnMessage ? 'primary.main' : isAI ? DS_COLORS.futureCard.controlled.headerBg : 'white', 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)', boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
}} }}
> >
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
color: isOwnMessage ? 'white' : isAI ? DS_COLORS.futureCard.controlled.badgeText : '#1e293b', color: isOwnMessage ? 'white' : isAI ? DS_COLORS.futureCard.controlled.badgeText : DS_TEXT.primary,
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
}} }}
> >
@@ -45,17 +45,17 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
sx={{ sx={{
display: 'flex', alignItems: 'center', gap: 1, display: 'flex', alignItems: 'center', gap: 1,
px: 1.5, py: 0.75, 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', 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 },
}} }}
> >
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : '#64748b'} /> <FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : DS_TEXT.muted} />
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : '#475569', flex: 1 }} noWrap> <Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : DS_TEXT.secondary, flex: 1 }} noWrap>
{att.fileName} {att.fileName}
</Typography> </Typography>
{att.fileSize && ( {att.fileSize && (
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : '#94a3b8', flexShrink: 0 }}> <Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : DS_TEXT.disabled, flexShrink: 0 }}>
{att.fileSize < 1024 * 1024 {att.fileSize < 1024 * 1024
? `${Math.round(att.fileSize / 1024)} KB` ? `${Math.round(att.fileSize / 1024)} KB`
: `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`} : `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`}
@@ -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 (
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
<TextField
size="small" type="number" fullWidth placeholder="0"
value={c.requiredParkingMin ?? ''}
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 100 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
<TextField
select size="small" fullWidth
value={c.requiredFitOut ?? ''}
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
>
<MenuItem value="">Kein Mindeststandard</MenuItem>
<MenuItem value="BASIC">Basisausbau</MenuItem>
<MenuItem value="FULL">Vollausbau</MenuItem>
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
</TextField>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 6"
value={c.minCeilingHeightM ?? ''}
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 36"
value={c.minContractDurationMonths ?? ''}
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
</Box>
</AccordionDetails>
</Accordion>
)
}
+3 -73
View File
@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { Accordion, AccordionDetails, AccordionSummary, Box, Card, Checkbox, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material' import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder' import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums' import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
interface Props { interface Props {
criteria: ParsedNeedCriteria criteria: ParsedNeedCriteria
@@ -220,77 +220,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack> </Stack>
)} )}
{/* Extended requirements */} <NeedExtendedRequirements criteria={c} onChange={set} />
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
{/* Boolean requirement checkboxes — 2×2 grid */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
/>
</Box>
{/* Numeric / select fields — 2×2 grid */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
<TextField
size="small" type="number" fullWidth placeholder="0"
value={c.requiredParkingMin ?? ''}
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 100 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
<TextField
select size="small" fullWidth
value={c.requiredFitOut ?? ''}
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
>
<MenuItem value="">Kein Mindeststandard</MenuItem>
<MenuItem value="BASIC">Basisausbau</MenuItem>
<MenuItem value="FULL">Vollausbau</MenuItem>
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
</TextField>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 6"
value={c.minCeilingHeightM ?? ''}
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 36"
value={c.minContractDurationMonths ?? ''}
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
</Box>
</AccordionDetails>
</Accordion>
</Card> </Card>
) )
} }
@@ -14,6 +14,7 @@ import type { FutureSignal } from '../../domain/futureSignal'
import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants' import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants'
import { CREDIBILITY_META, SENSITIVITY_META, RISK_META, ageLabel } from './futureAvailabilityContextUtils' import { CREDIBILITY_META, SENSITIVITY_META, RISK_META, ageLabel } from './futureAvailabilityContextUtils'
import { SignalSourcesSection } from './SignalSourcesSection' import { SignalSourcesSection } from './SignalSourcesSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_PRE_MARKET, DS_MARKET_SIGNAL, DS_BORDER } from '../../lib/ds'
interface Props { interface Props {
match: Match match: Match
@@ -35,81 +36,85 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
...(signal.evidence?.sourceUrls ?? []), ...(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 ( return (
<Paper sx={{ p: 2.5, mb: 2 }}> <Paper sx={{ p: 2.5, mb: 2 }}>
{/* ── Header ──────────────────────────────────────────────────────────── */} {/* ── Header ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75, flexWrap: 'wrap' }}>
<Zap size={16} color="#7c3aed" /> <Zap size={16} color={DS_PRE_MARKET.accent} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
{signal.isVerified && ( {signal.isVerified && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#f0fdf4', border: '1px solid #86efac', borderRadius: 1, px: 0.75, py: 0.2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 0.75, py: 0.2 }}>
<ShieldCheck size={11} color="#1a7a4a" /> <ShieldCheck size={11} color={DS_TEXT.success} />
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography> <Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.success, fontWeight: 700 }}>Verifiziert</Typography>
</Box> </Box>
)} )}
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: '#94a3b8' }}> <Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.disabled }}>
<Clock size={12} /> <Clock size={12} />
<Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography> <Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography>
</Box> </Box>
</Box> </Box>
{signal.title && ( {signal.title && (
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: '#1e293b', mb: 2, lineHeight: 1.4 }}> <Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: DS_TEXT.primary, mb: 2, lineHeight: 1.4 }}>
{signal.title} {signal.title}
</Typography> </Typography>
)} )}
{/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */} {/* ── 1. KI-Zusammenfassung ── */}
{signal.aiSummary && ( {signal.aiSummary && (
<Box sx={{ mb: 2.5 }}> <Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
<Sparkles size={14} color="#7c3aed" /> <Sparkles size={14} color={DS_PRE_MARKET.accent} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95', fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.signalDark, fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
</Box> </Box>
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1.5, p: 1.75 }}> <Box sx={{ bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1.5, p: 1.75 }}>
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.7 }}> <Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.7 }}>
{signal.aiSummary} {signal.aiSummary}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
)} )}
{/* ── 2. Strategische Einschätzung ─────────────────────────────────── */} {/* ── 2. Strategische Einschätzung ── */}
{signal.strategicInterpretation && ( {signal.strategicInterpretation && (
<Box sx={{ mb: 2.5 }}> <Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
<TrendingUp size={14} color="#0369a1" /> <TrendingUp size={14} color={DS_TEXT.info} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Strategische Einschätzung</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Strategische Einschätzung</Typography>
</Box> </Box>
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1.5, p: 1.75 }}> <Box sx={{ bgcolor: DS_SURFACE.info.bg, border: `1px solid ${DS_SURFACE.info.border}`, borderRadius: 1.5, p: 1.75 }}>
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.7 }}> <Typography variant="body2" sx={{ color: DS_TEXT.infoDark, lineHeight: 1.7 }}>
{signal.strategicInterpretation} {signal.strategicInterpretation}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
)} )}
{/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */} {/* ── 3. Handlungsempfehlung ── */}
{action && ( {action && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: action.urgency === 'high' ? '#fff7ed' : '#f8fafc', border: `1px solid ${action.urgency === 'high' ? '#fed7aa' : '#e2e8f0'}`, borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5 }}> <Box sx={{
<Zap size={14} color={action.urgency === 'high' ? '#c2410c' : '#64748b'} style={{ flexShrink: 0, marginTop: 2 }} /> display: 'flex', alignItems: 'flex-start', gap: 1,
bgcolor: action.urgency === 'high' ? DS_SURFACE.orange.bg : DS_BG.page,
border: `1px solid ${action.urgency === 'high' ? DS_SURFACE.orange.border : DS_BORDER.default}`,
borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5,
}}>
<Zap size={14} color={action.urgency === 'high' ? DS_TEXT.error : DS_TEXT.muted} style={{ flexShrink: 0, marginTop: 2 }} />
<Box> <Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? '#9a3412' : '#475569', mb: 0.25 }}>Empfohlene Aktion</Typography> <Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? DS_TEXT.warningDark : DS_TEXT.secondary, mb: 0.25 }}>Empfohlene Aktion</Typography>
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? '#7c2d12' : '#334155', lineHeight: 1.5 }}>{action.label}</Typography> <Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? DS_TEXT.error : DS_TEXT.primary, lineHeight: 1.5 }}>{action.label}</Typography>
</Box> </Box>
</Box> </Box>
)} )}
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
{/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */} {/* ── 4. Signal-Kenndaten ── */}
<Box sx={{ mb: 2.5 }}> <Box sx={{ mb: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography>
{/* Probability bar */}
<Box sx={{ mb: 1.5 }}> <Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography> <Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography>
@@ -119,7 +124,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
variant="determinate" variant="determinate"
value={probPct} value={probPct}
sx={{ sx={{
height: 6, borderRadius: 3, bgcolor: '#f1f5f9', height: 6, borderRadius: 3, bgcolor: DS_BG.subtle,
'& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 }, '& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 },
}} }}
/> />
@@ -129,13 +134,13 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
{signal.signalType && ( {signal.signalType && (
<Box> <Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} /> <Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: DS_PRE_MARKET.badgeBg, color: DS_PRE_MARKET.accentHover, fontWeight: 600 }} />
</Box> </Box>
)} )}
<Box> <Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={13} color="#64748b" /> <Calendar size={13} color={DS_TEXT.muted} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography> <Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
</Box> </Box>
</Box> </Box>
@@ -156,22 +161,22 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
{/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */} {/* ── 5. Erkannte Marktindikatoren ── */}
{signal.marketIndicators && signal.marketIndicators.length > 0 && ( {signal.marketIndicators && signal.marketIndicators.length > 0 && (
<Box sx={{ mb: 2.5 }}> <Box sx={{ mb: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{signal.marketIndicators.map((indicator, i) => ( {signal.marketIndicators.map((indicator, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.875 }}> <Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.875 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '6px', flexShrink: 0 }} /> <Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: DS_MARKET_SIGNAL.accent, mt: '6px', flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: '#334155', lineHeight: 1.55 }}>{indicator}</Typography> <Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>{indicator}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
</Box> </Box>
)} )}
{/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */} {/* ── 6. Transparenz ── */}
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) || {((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && ( (signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
<Box sx={{ mb: 2.5 }}> <Box sx={{ mb: 2.5 }}>
@@ -179,14 +184,14 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{(signal.confirmedFacts ?? []).map((fact, i) => ( {(signal.confirmedFacts ?? []).map((fact, i) => (
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}> <Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={14} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 1 }} /> <CheckCircle2 size={14} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography> <Typography variant="body2" sx={{ color: DS_TEXT.success, fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
</Box> </Box>
))} ))}
{(signal.unconfirmedFacts ?? []).map((fact, i) => ( {(signal.unconfirmedFacts ?? []).map((fact, i) => (
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}> <Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<AlertTriangle size={14} color="#d97706" style={{ flexShrink: 0, marginTop: 1 }} /> <AlertTriangle size={14} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: '#92400e', lineHeight: 1.45 }}>{fact}</Typography> <Typography variant="body2" sx={{ color: DS_TEXT.warningDark, lineHeight: 1.45 }}>{fact}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -195,7 +200,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
{/* ── 7. Quellen & Belege ───────────────────────────────────────────── */} {/* ── 7. Quellen & Belege ── */}
<SignalSourcesSection <SignalSourcesSection
sourceMeta={sourceMeta} sourceMeta={sourceMeta}
credMeta={credMeta} credMeta={credMeta}
@@ -204,7 +209,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
signal={signal} signal={signal}
/> />
{/* ── Footer ──────────────────────────────────────────────────────────── */} {/* ── Footer ── */}
<Divider sx={{ mb: 1.5 }} /> <Divider sx={{ mb: 1.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25, flexWrap: 'wrap' }}>
@@ -213,12 +218,12 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
{signal.verifiedBy && ( {signal.verifiedBy && (
<> <>
<Typography variant="caption" color="text.secondary">Verifiziert von:</Typography> <Typography variant="caption" color="text.secondary">Verifiziert von:</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>{signal.verifiedBy}</Typography> <Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success }}>{signal.verifiedBy}</Typography>
</> </>
)} )}
</Box> </Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: '#f8fafc', borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: DS_BG.page, borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
{signal.disclaimer} {signal.disclaimer}
</Typography> </Typography>
</Paper> </Paper>
@@ -3,6 +3,7 @@ import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp }
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails' 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<ReturnType<typeof useMatchDetail>['data']> type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -30,7 +31,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Preis */} {/* Preis */}
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Tag size={15} color="#374151" /> <Tag size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
</Box> </Box>
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} /> <KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} />
@@ -44,7 +45,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Hauptangaben */} {/* Hauptangaben */}
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" /> <Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
</Box> </Box>
<KeyFactRow label="Verfügbarkeit" value={property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Auf Anfrage'} /> <KeyFactRow label="Verfügbarkeit" value={property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Auf Anfrage'} />
@@ -66,27 +67,27 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.softFactors && ( {property.softFactors && (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" /> <TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{property.softFactors.publicTransportMinutes != null && ( {property.softFactors.publicTransportMinutes != null && (
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} /> <Chip size="small" icon={<Train size={11} />} 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.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }} /> <Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: DS_BG.page, color: DS_TEXT.primary, border: `1px solid ${DS_BORDER.default}`, fontWeight: 500 }} />
)} )}
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && ( {property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} /> <Chip size="small" label="Prestigestandort" sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }} />
)} )}
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && ( {property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} /> <Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }} />
)} )}
{property.softFactors.passerbyFrequency && ( {property.softFactors.passerbyFrequency && (
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }} /> <Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.success, border: `1px solid ${DS_SURFACE.success.border}`, fontWeight: 500 }} />
)} )}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} /> <Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }} />
)} )}
</Box> </Box>
</Paper> </Paper>
@@ -96,12 +97,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.softFactors?.publicTransportMinutes != null && ( {property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" /> <Clock size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: DS_SURFACE.blue.bg, border: `1px solid ${DS_SURFACE.blue.border}`, flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" /> <Train size={18} color={DS_MARKET_SIGNAL.accent} />
</Box> </Box>
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography> <Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography>
@@ -118,7 +119,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{units.length > 0 && ( {units.length > 0 && (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" /> <Layers size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box> </Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}> <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
@@ -135,10 +136,10 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.description && ( {property.description && (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" /> <Building2 size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</Box> </Box>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}> <Typography variant="body2" sx={{ lineHeight: 1.75, color: DS_TEXT.primary, whiteSpace: 'pre-wrap' }}>
{property.description} {property.description}
</Typography> </Typography>
</Paper> </Paper>
@@ -147,7 +148,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Quelle & Referenz */} {/* Quelle & Referenz */}
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" /> <Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box> </Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} /> <KeyFactRow label="Datenquelle" value={sourceLabel} />
@@ -158,7 +159,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
)} )}
{property.sourceUrl && ( {property.sourceUrl && (
<Box sx={{ mt: 1.25 }}> <Box sx={{ mt: 1.25 }}>
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}> <Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
Zum Originalinserat Zum Originalinserat
</Button> </Button>
</Box> </Box>
@@ -26,6 +26,7 @@ import {
SOURCE_LABELS, SOURCE_LABELS,
UnitRow, UnitRow,
} from './MatchDetailPropertyDetails' } from './MatchDetailPropertyDetails'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
interface PropertyDetailPublicSectionsProps { interface PropertyDetailPublicSectionsProps {
property: Property property: Property
@@ -57,7 +58,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Preis ── */} {/* ── Preis ── */}
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Tag size={15} color="#374151" /> <Tag size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
</Box> </Box>
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} /> <KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} />
@@ -74,7 +75,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Hauptangaben ── */} {/* ── Hauptangaben ── */}
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" /> <Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
</Box> </Box>
<KeyFactRow <KeyFactRow
@@ -127,7 +128,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{property.softFactors && ( {property.softFactors && (
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" /> <TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
@@ -136,49 +137,49 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
size="small" size="small"
icon={<Train size={11} />} icon={<Train size={11} />}
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} 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.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
<Chip <Chip
size="small" size="small"
label={`${property.softFactors.parkingSpots} Parkplätze`} label={`${property.softFactors.parkingSpots} Parkplätze`}
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }} sx={{ bgcolor: DS_BG.page, color: DS_TEXT.primary, border: `1px solid ${DS_BORDER.default}`, fontWeight: 500 }}
/> />
)} )}
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && ( {property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
<Chip <Chip
size="small" size="small"
label="Prestigestandort" label="Prestigestandort"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }}
/> />
)} )}
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && ( {property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
<Chip <Chip
size="small" size="small"
label="Hohe Sichtbarkeit" label="Hohe Sichtbarkeit"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }}
/> />
)} )}
{property.softFactors.passerbyFrequency && ( {property.softFactors.passerbyFrequency && (
<Chip <Chip
size="small" size="small"
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }} sx={{ bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.success, border: `1px solid ${DS_SURFACE.success.border}`, fontWeight: 500 }}
/> />
)} )}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip <Chip
size="small" size="small"
label="Hoher Talentzugang" label="Hoher Talentzugang"
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }}
/> />
)} )}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip <Chip
size="small" size="small"
label={`Talentindex: ${property.softFactors.talentAccess}`} label={`Talentindex: ${property.softFactors.talentAccess}`}
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }}
/> />
)} )}
</Box> </Box>
@@ -189,12 +190,12 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{property.softFactors?.publicTransportMinutes != null && ( {property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" /> <Clock size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: DS_SURFACE.blue.bg, border: `1px solid ${DS_SURFACE.blue.border}`, flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" /> <Train size={18} color={DS_MARKET_SIGNAL.accent} />
</Box> </Box>
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}> <Typography variant="body2" sx={{ fontWeight: 600 }}>
@@ -220,7 +221,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{(property.units ?? []).length > 0 && ( {(property.units ?? []).length > 0 && (
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" /> <Layers size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box> </Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}> <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
@@ -241,10 +242,10 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{property.description && ( {property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" /> <Building2 size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</Box> </Box>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}> <Typography variant="body2" sx={{ lineHeight: 1.75, color: DS_TEXT.primary, whiteSpace: 'pre-wrap' }}>
{property.description} {property.description}
</Typography> </Typography>
</Paper> </Paper>
@@ -253,7 +254,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Quelle & Referenz ── */} {/* ── Quelle & Referenz ── */}
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" /> <Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box> </Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} /> <KeyFactRow label="Datenquelle" value={sourceLabel} />
@@ -278,7 +279,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
href={property.sourceUrl} href={property.sourceUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" 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 Zum Originalinserat
</Button> </Button>
@@ -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 (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Adresse</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 2, mb: 2 }}>
<TextField
label="Strasse" value={street}
onChange={e => onStreetChange(e.target.value)} size="small" fullWidth
/>
<TextField
label="Nr." value={houseNumber}
onChange={e => onHouseNumberChange(e.target.value)} size="small" fullWidth
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<TextField
label="PLZ" value={postalCode}
onChange={e => onPostalCodeChange(e.target.value)} size="small" fullWidth
/>
<TextField
label="Ort" value={city}
onChange={e => onCityChange(e.target.value)} size="small" fullWidth
/>
</Box>
</Card>
)
}
@@ -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 (
<Card sx={{ p: 3, mb: 3, border: `1px solid ${DS_SURFACE.indigo.border}`, bgcolor: DS_SURFACE.indigo.bg }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Sparkles size={16} color={DS_TEXT.brand} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.brand }}>
KI-Hilfe Formular automatisch ausfüllen
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
Beschreiben Sie die Fläche in eigenen Worten die KI füllt die Felder automatisch aus.
</Typography>
<TextField
multiline
rows={3}
fullWidth
size="small"
placeholder="z.B.: Bürofläche 450 m² im Zentrum Zürich, 3. OG, CHF 280/m²/Jahr, sehr gute ÖV-Anbindung, 4 Parkplätze, Vollausbau, verfügbar ab Juli 2025"
value={text}
onChange={e => onTextChange(e.target.value)}
sx={{ mb: 1.5, bgcolor: 'white' }}
/>
{applied && (
<Alert severity="success" sx={{ mb: 1.5, py: 0.5 }}>
Felder wurden automatisch ausgefüllt bitte überprüfen und ergänzen.
</Alert>
)}
<Button
variant="contained"
size="small"
startIcon={parsing ? <CircularProgress size={13} color="inherit" /> : <Sparkles size={13} />}
onClick={onParse}
disabled={!text.trim() || parsing}
sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_BORDER.strong }, textTransform: 'none' }}
>
{parsing ? 'Analysiert…' : 'KI analysieren'}
</Button>
</Card>
)
}
@@ -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 (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Flächendetails</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
select label="Flächentyp" value={assetType}
onChange={e => onAssetTypeChange(e.target.value)} size="small" fullWidth
>
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
<MenuItem key={v} value={v}>{l}</MenuItem>
))}
</TextField>
<TextField
label="Fläche (m²)" value={areaSqm}
onChange={e => onAreaSqmChange(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth
/>
<TextField
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
onChange={e => onRentPerSqmChange(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth
/>
<TextField
label="Verfügbar ab" value={availableFrom}
onChange={e => onAvailableFromChange(e.target.value)}
size="small" type="date"
slotProps={{ inputLabel: { shrink: true } }} fullWidth
/>
</Box>
<TextField
label="Beschreibung (optional)" value={description}
onChange={e => onDescriptionChange(e.target.value)}
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
/>
</Card>
)
}
@@ -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 (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Kontakt (optional)</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
label="Name" value={name}
onChange={e => onNameChange(e.target.value)} size="small" fullWidth
/>
<TextField
label="Telefon" value={phone}
onChange={e => onPhoneChange(e.target.value)} size="small" fullWidth
/>
<TextField
label="E-Mail" value={email}
onChange={e => onEmailChange(e.target.value)}
size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
/>
</Box>
</Card>
)
}
@@ -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 (
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
<CheckCircle size={48} color={DS_TEXT.success} style={{ marginBottom: 16 }} />
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>Inserat erstellt</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar.
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button variant="outlined" onClick={onViewListings}>
Meine Inserate
</Button>
<Button variant="contained" sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_TEXT.brandDark } }} onClick={onCreateAnother}>
Weiteres Inserat
</Button>
</Box>
</Box>
)
}
@@ -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 (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Bilder (optional)</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
<TextField
label="Bild-URL eingeben"
value={imageInput}
onChange={e => onImageInputChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); onAdd() } }}
size="small"
fullWidth
placeholder="https://…"
/>
<Button
variant="outlined"
size="small"
startIcon={<ImagePlus size={15} />}
onClick={onAdd}
disabled={!imageInput.trim()}
sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
>
Hinzufügen
</Button>
</Box>
{images.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{images.map((url, i) => (
<Chip
key={i}
label={url.length > 40 ? url.slice(0, 40) + '…' : url}
size="small"
onDelete={() => onRemove(i)}
sx={{ maxWidth: 300 }}
/>
))}
</Box>
)}
</Card>
)
}
@@ -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<string, string>
onChange: (key: string, value: string) => void
}
export function SoftFactorsSection({ softLevels, onChange }: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Lage & Ausstrahlung</Typography>
<Typography variant="caption" color="text.secondary">
Diese Angaben verbessern die Match-Qualität erheblich.
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
{SOFT_FACTORS.map(({ key, label }) => (
<TextField
key={key}
select
label={label}
value={softLevels[key] ?? ''}
onChange={e => onChange(key, e.target.value)}
size="small"
fullWidth
>
{LEVEL_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
))}
</Box>
</Card>
)
}
@@ -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 (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 2 }}>
<TextField
label="Stockwerk" value={floor}
onChange={e => onFloorChange(e.target.value)}
size="small" type="number" fullWidth placeholder="0 = EG"
/>
<TextField
select label="Ausbaustandard" value={fitOut}
onChange={e => onFitOutChange(e.target.value)} size="small" fullWidth
>
{FIT_OUT_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)}
size="small" type="number" inputProps={{ min: 0 }} fullWidth
/>
<TextField
label="Deckenhöhe (m)" value={ceilingHeight}
onChange={e => onCeilingHeightChange(e.target.value)}
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
/>
</Box>
</Card>
)
}
+8
View File
@@ -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'
+2 -37
View File
@@ -1,37 +1,2 @@
export type ScoreTier = 'gold' | 'silver' | 'bronze' export { getScoreTier, SCORE_THEME } from '../../lib/scoreTheme'
export type { ScoreTier } from '../../lib/scoreTheme'
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
+17 -16
View File
@@ -1,6 +1,7 @@
import { Box, Card, Chip, Stack, Typography } from '@mui/material' import { Box, Card, Chip, Stack, Typography } from '@mui/material'
import { MapPin, Ruler, Wallet, Calendar, CheckCircle } from 'lucide-react' import { MapPin, Ruler, Wallet, Calendar, CheckCircle } from 'lucide-react'
import type { PropertyNeedMatch } from '../../domain/match' import type { PropertyNeedMatch } from '../../domain/match'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
interface Props { interface Props {
match: PropertyNeedMatch match: PropertyNeedMatch
@@ -9,9 +10,9 @@ interface Props {
function ScoreBadge({ score }: { score: number }) { function ScoreBadge({ score }: { score: number }) {
const isGold = score >= 90 const isGold = score >= 90
const bg = isGold ? '#fef3c7' : '#f1f5f9' const bg = isGold ? DS_SURFACE.warning.bg : DS_BG.subtle
const color = isGold ? '#d97706' : '#64748b' const color = isGold ? DS_TEXT.warning : DS_TEXT.muted
const borderColor = isGold ? '#fde68a' : '#e2e8f0' const borderColor = isGold ? DS_SURFACE.warning.border : DS_BORDER.default
return ( return (
<Box <Box
@@ -37,9 +38,9 @@ function ScoreBadge({ score }: { score: number }) {
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ color: '#94a3b8', display: 'flex', alignItems: 'center' }}>{icon}</Box> <Box sx={{ color: DS_TEXT.disabled, display: 'flex', alignItems: 'center' }}>{icon}</Box>
<Typography variant="caption" sx={{ color: '#64748b', minWidth: 70 }}>{label}</Typography> <Typography variant="caption" sx={{ color: DS_TEXT.muted, minWidth: 70 }}>{label}</Typography>
<Typography variant="caption" sx={{ color: '#0f172a', fontWeight: 500 }}>{value}</Typography> <Typography variant="caption" sx={{ color: DS_TEXT.primary, fontWeight: 500 }}>{value}</Typography>
</Box> </Box>
) )
} }
@@ -52,10 +53,10 @@ export function NeedMatchCard({ match, onClick }: Props) {
sx={{ sx={{
p: 2, p: 2,
mb: 1.5, mb: 1.5,
border: '1px solid #e2e8f0', border: `1px solid ${DS_BORDER.default}`,
borderRadius: 1.5, borderRadius: 1.5,
cursor: onClick ? 'pointer' : 'default', 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', transition: 'border-color 0.15s, background-color 0.15s, box-shadow 0.15s',
}} }}
> >
@@ -64,7 +65,7 @@ export function NeedMatchCard({ match, onClick }: Props) {
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}> <Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
{match.company} {match.company}
</Typography> </Typography>
<Chip <Chip
@@ -73,10 +74,10 @@ export function NeedMatchCard({ match, onClick }: Props) {
sx={{ sx={{
fontSize: '0.65rem', fontSize: '0.65rem',
height: 18, height: 18,
bgcolor: match.score >= 90 ? '#fef3c7' : '#f1f5f9', bgcolor: match.score >= 90 ? DS_SURFACE.warning.bg : DS_BG.subtle,
color: match.score >= 90 ? '#92400e' : '#475569', color: match.score >= 90 ? DS_TEXT.warningDark : DS_TEXT.secondary,
border: '1px solid', border: '1px solid',
borderColor: match.score >= 90 ? '#fde68a' : '#e2e8f0', borderColor: match.score >= 90 ? DS_SURFACE.warning.border : DS_BORDER.default,
}} }}
/> />
</Box> </Box>
@@ -96,8 +97,8 @@ export function NeedMatchCard({ match, onClick }: Props) {
<Box sx={{ mb: 1 }}> <Box sx={{ mb: 1 }}>
{match.matchHighlights.slice(0, 2).map((h, i) => ( {match.matchHighlights.slice(0, 2).map((h, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}> <Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
<CheckCircle size={11} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} /> <CheckCircle size={11} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.4 }}>{h}</Typography> <Typography variant="caption" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{h}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -110,11 +111,11 @@ export function NeedMatchCard({ match, onClick }: Props) {
key={mh} key={mh}
label={mh} label={mh}
size="small" 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 && (
<Typography variant="caption" sx={{ color: '#94a3b8', alignSelf: 'center' }}> <Typography variant="caption" sx={{ color: DS_TEXT.disabled, alignSelf: 'center' }}>
+{match.mustHaves.length - 3} +{match.mustHaves.length - 3}
</Typography> </Typography>
)} )}
@@ -5,8 +5,7 @@ import { useNeeds } from '../../hooks/useNeeds'
import { getCityIntelligence, getMarketRent } from '../../lib/locationIntelligence' import { getCityIntelligence, getMarketRent } from '../../lib/locationIntelligence'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { generateSellingArguments, generateWeaknesses } from './negotiationInsightsUtils' import { generateSellingArguments, generateWeaknesses } from './negotiationInsightsUtils'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
// ── Main component ────────────────────────────────────────────────────────────
interface Props { interface Props {
property: Property property: Property
@@ -20,7 +19,6 @@ export function NegotiationInsightsPanel({ property }: Props) {
const marketRent = getMarketRent(property.location.city, property.assetType) const marketRent = getMarketRent(property.location.city, property.assetType)
const priceDiff = marketRent ? ((property.rentPricePerSqm - marketRent) / marketRent) * 100 : null const priceDiff = marketRent ? ((property.rentPricePerSqm - marketRent) / marketRent) * 100 : null
// Comparable properties for price positioning
const comparables = allProperties const comparables = allProperties
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === property.location.city) .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 ? comparables.reduce((s, p) => s + p.rentPricePerSqm, 0) / comparables.length
: null : null
// Active needs matching this property type/location
const matchingNeeds = needs.filter(n => const matchingNeeds = needs.filter(n =>
n.assetType === property.assetType && n.assetType === property.assetType &&
(n.preferredLocations?.some(loc => loc.toLowerCase().includes(property.location.city.toLowerCase())) ?? false) (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 strongArgs = sellingArgs.filter(a => a.strength === 'strong')
const mediumArgs = sellingArgs.filter(a => a.strength === 'medium') 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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -47,24 +48,24 @@ export function NegotiationInsightsPanel({ property }: Props) {
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Preispositionierung</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Preispositionierung</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5 }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5 }}>
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}> <Box sx={{ flex: 1, p: 1.5, bgcolor: DS_BG.page, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Ihr Preis</Typography> <Typography variant="caption" color="text.secondary">Ihr Preis</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f1923' }}> <Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
CHF {property.rentPricePerSqm}/m²/Jahr CHF {property.rentPricePerSqm}/m²/Jahr
</Typography> </Typography>
</Box> </Box>
{marketRent && ( {marketRent && (
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}> <Box sx={{ flex: 1, p: 1.5, bgcolor: DS_BG.page, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Marktmedian {property.location.city}</Typography> <Typography variant="caption" color="text.secondary">Marktmedian {property.location.city}</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}> <Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.secondary }}>
CHF {marketRent}/m² CHF {marketRent}/m²
</Typography> </Typography>
</Box> </Box>
)} )}
{avgComparableRent && ( {avgComparableRent && (
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 120 }}> <Box sx={{ flex: 1, p: 1.5, bgcolor: DS_BG.page, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 120 }}>
<Typography variant="caption" color="text.secondary">Vergleichsangebote Ø</Typography> <Typography variant="caption" color="text.secondary">Vergleichsangebote Ø</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#475569' }}> <Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.secondary }}>
CHF {Math.round(avgComparableRent)}/m² CHF {Math.round(avgComparableRent)}/m²
</Typography> </Typography>
</Box> </Box>
@@ -72,9 +73,9 @@ export function NegotiationInsightsPanel({ property }: Props) {
</Box> </Box>
{priceDiff !== null && ( {priceDiff !== null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: Math.abs(priceDiff) < 10 ? '#f0fdf4' : priceDiff > 0 ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: priceDiffBg, borderRadius: 1.5 }}>
{priceDiff > 0 ? <TrendingUp size={15} color="#d97706" /> : <TrendingDown size={15} color="#1a7a4a" />} {priceDiff > 0 ? <TrendingUp size={15} color={DS_TEXT.warning} /> : <TrendingDown size={15} color={DS_TEXT.success} />}
<Typography variant="body2" sx={{ color: priceDiff > 15 ? '#92400e' : priceDiff > 0 ? '#d97706' : '#1a7a4a', fontWeight: 500 }}> <Typography variant="body2" sx={{ color: priceDiff > 15 ? DS_TEXT.warningDark : priceDiff > 0 ? DS_TEXT.warning : DS_TEXT.success, fontWeight: 500 }}>
{priceDiff > 15 {priceDiff > 15
? `Ihr Preis liegt ${Math.round(priceDiff)}% über dem Marktmedian — starke USPs nötig zur Rechtfertigung.` ? `Ihr Preis liegt ${Math.round(priceDiff)}% über dem Marktmedian — starke USPs nötig zur Rechtfertigung.`
: priceDiff > 5 : priceDiff > 5
@@ -86,7 +87,6 @@ export function NegotiationInsightsPanel({ property }: Props) {
</Box> </Box>
)} )}
{/* Price bar vs market */}
{marketRent && ( {marketRent && (
<Box sx={{ mt: 1.5 }}> <Box sx={{ mt: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
@@ -95,7 +95,7 @@ export function NegotiationInsightsPanel({ property }: Props) {
CHF {Math.round(marketRent * 0.7)}{Math.round(marketRent * 1.4)}/m² CHF {Math.round(marketRent * 0.7)}{Math.round(marketRent * 1.4)}/m²
</Typography> </Typography>
</Box> </Box>
<Box sx={{ position: 'relative', height: 8, bgcolor: '#e2e8f0', borderRadius: 4 }}> <Box sx={{ position: 'relative', height: 8, bgcolor: DS_BORDER.default, borderRadius: 4 }}>
<Box sx={{ <Box sx={{
position: 'absolute', position: 'absolute',
left: `${Math.min(90, Math.max(5, ((property.rentPricePerSqm - marketRent * 0.7) / (marketRent * 0.7)) * 100))}%`, left: `${Math.min(90, Math.max(5, ((property.rentPricePerSqm - marketRent * 0.7) / (marketRent * 0.7)) * 100))}%`,
@@ -103,7 +103,7 @@ export function NegotiationInsightsPanel({ property }: Props) {
width: 12, width: 12,
height: 12, height: 12,
borderRadius: '50%', borderRadius: '50%',
bgcolor: '#1e3a5f', bgcolor: DS_TEXT.brand,
border: '2px solid white', border: '2px solid white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} /> }} />
@@ -121,11 +121,11 @@ export function NegotiationInsightsPanel({ property }: Props) {
{matchingNeeds.length > 0 ? ( {matchingNeeds.length > 0 ? (
<> <>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{matchingNeeds.length}</Typography> <Typography variant="h4" sx={{ fontWeight: 800, color: DS_TEXT.brand }}>{matchingNeeds.length}</Typography>
<Typography variant="body2" color="text.secondary">aktive Suchprofile für diesen Typ & Standort</Typography> <Typography variant="body2" color="text.secondary">aktive Suchprofile für diesen Typ & Standort</Typography>
</Box> </Box>
{matchingNeeds.slice(0, 4).map(n => ( {matchingNeeds.slice(0, 4).map(n => (
<Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid #f1f5f9' }}> <Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: `1px solid ${DS_BORDER.muted}` }}>
<Box> <Box>
<Typography variant="caption" sx={{ fontWeight: 500 }}>{n.companyName}</Typography> <Typography variant="caption" sx={{ fontWeight: 500 }}>{n.companyName}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
@@ -137,8 +137,8 @@ export function NegotiationInsightsPanel({ property }: Props) {
label={n.status === 'ACTIVE' ? 'Aktiv' : n.status === 'DRAFT' ? 'Entwurf' : n.status} label={n.status === 'ACTIVE' ? 'Aktiv' : n.status === 'DRAFT' ? 'Entwurf' : n.status}
size="small" size="small"
sx={{ sx={{
bgcolor: n.status === 'ACTIVE' ? '#f0fdf4' : '#f1f5f9', bgcolor: n.status === 'ACTIVE' ? DS_SURFACE.success.bg : DS_BG.subtle,
color: n.status === 'ACTIVE' ? '#1a7a4a' : '#64748b', color: n.status === 'ACTIVE' ? DS_TEXT.success : DS_TEXT.muted,
fontSize: 10, height: 18, fontSize: 10, height: 18,
}} }}
/> />
@@ -156,10 +156,10 @@ export function NegotiationInsightsPanel({ property }: Props) {
</Typography> </Typography>
)} )}
{intel && ( {intel && (
<Box sx={{ mt: 1.5, p: 1.25, bgcolor: '#f8fafc', borderRadius: 1.5 }}> <Box sx={{ mt: 1.5, p: 1.25, bgcolor: DS_BG.page, borderRadius: 1.5 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Ø Vermietungsdauer vergleichbarer Objekte in {property.location.city}:{' '} Ø Vermietungsdauer vergleichbarer Objekte in {property.location.city}:{' '}
<strong style={{ color: '#1e3a5f' }}>{intel.avgDaysOnMarket} Tage</strong> <strong style={{ color: DS_TEXT.brand }}>{intel.avgDaysOnMarket} Tage</strong>
</Typography> </Typography>
</Box> </Box>
)} )}
@@ -175,12 +175,12 @@ export function NegotiationInsightsPanel({ property }: Props) {
{strongArgs.length > 0 && ( {strongArgs.length > 0 && (
<Box sx={{ mb: 1.5 }}> <Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a', display: 'block', mb: 0.75 }}> <Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success, display: 'block', mb: 0.75 }}>
Starke Argumente Starke Argumente
</Typography> </Typography>
{strongArgs.map((arg, i) => ( {strongArgs.map((arg, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}> <Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}>
<CheckCircle size={15} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} /> <CheckCircle size={15} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{arg.title}</Typography> <Typography variant="body2" sx={{ fontWeight: 600 }}>{arg.title}</Typography>
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography> <Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
@@ -192,12 +192,12 @@ export function NegotiationInsightsPanel({ property }: Props) {
{mediumArgs.length > 0 && ( {mediumArgs.length > 0 && (
<Box> <Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706', display: 'block', mb: 0.75 }}> <Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.warning, display: 'block', mb: 0.75 }}>
Weitere Vorteile Weitere Vorteile
</Typography> </Typography>
{mediumArgs.map((arg, i) => ( {mediumArgs.map((arg, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75 }}> <Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75 }}>
<CheckCircle size={14} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} /> <CheckCircle size={14} color={DS_TEXT.warning} style={{ marginTop: 2, flexShrink: 0 }} />
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{arg.title}</Typography> <Typography variant="body2" sx={{ fontWeight: 500 }}>{arg.title}</Typography>
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography> <Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
@@ -218,10 +218,10 @@ export function NegotiationInsightsPanel({ property }: Props) {
</Typography> </Typography>
{weaknesses.map((w, i) => ( {weaknesses.map((w, i) => (
<Box key={i} sx={{ mb: 1.25 }}> <Box key={i} sx={{ mb: 1.25 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#c0392b', mb: 0.25 }}> <Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.error, mb: 0.25 }}>
{w.issue} {w.issue}
</Typography> </Typography>
<Box sx={{ pl: 1.5, borderLeft: '2px solid #e2e8f0' }}> <Box sx={{ pl: 1.5, borderLeft: `2px solid ${DS_BORDER.default}` }}>
<Typography variant="caption" color="text.secondary"> {w.mitigation}</Typography> <Typography variant="caption" color="text.secondary"> {w.mitigation}</Typography>
</Box> </Box>
{i < weaknesses.length - 1 && <Divider sx={{ mt: 1.25 }} />} {i < weaknesses.length - 1 && <Divider sx={{ mt: 1.25 }} />}
@@ -240,14 +240,14 @@ export function NegotiationInsightsPanel({ property }: Props) {
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{intel.dominantIndustryClusters.map(c => ( {intel.dominantIndustryClusters.map(c => (
<Chip key={c} label={c} size="small" <Chip key={c} label={c} size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 24, fontWeight: 500 }} sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.brand, fontSize: 11, height: 24, fontWeight: 500 }}
/> />
))} ))}
</Box> </Box>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={intel.demandStrength === 'VERY_HIGH' ? 95 : intel.demandStrength === 'HIGH' ? 75 : intel.demandStrength === 'MEDIUM' ? 50 : 25} value={intel.demandStrength === 'VERY_HIGH' ? 95 : intel.demandStrength === 'HIGH' ? 75 : intel.demandStrength === 'MEDIUM' ? 50 : 25}
sx={{ mt: 1.5, height: 6, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }} sx={{ mt: 1.5, height: 6, borderRadius: 3, bgcolor: DS_BG.subtle, '& .MuiLinearProgress-bar': { bgcolor: DS_TEXT.brand } }}
/> />
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Nachfragestärke: {' '} Nachfragestärke: {' '}
@@ -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 (
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
Matching Demand Intelligence
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Users size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
<strong>{demandProfiles} aktive Suchprofile</strong> im System erkannt
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Target size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
<strong>{highQualityLeads} hochwertige Suchanfragen</strong> mit passendem Flächenbedarf
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<TrendingUp size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
Frühzeitige Matchgelegenheit vor offizieller Vermarktung exklusiv verfügbar
</Typography>
</Box>
</Box>
</Box>
)
}
+18 -89
View File
@@ -1,12 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material' import { useQueryClient } from '@tanstack/react-query'
import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react' import { Box, Chip, CircularProgress, Divider, Switch, Typography } from '@mui/material'
import { Clock, ShieldCheck, Zap } from 'lucide-react'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { MockupUnitProvider } from '../../provider/MockupUnitProvider' import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
import { useUpdateProperty } from '../../hooks/useProperties' import { useUpdateProperty } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' 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') export const MOCK_TODAY = new Date('2026-05-20')
@@ -24,6 +27,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
return init return init
}) })
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({}) const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
const queryClient = useQueryClient()
const updateProperty = useUpdateProperty() const updateProperty = useUpdateProperty()
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const saving = updateProperty.isPending 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))) ? Math.max(0, Math.round((targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30)))
: null : null
// Mock demand intelligence (derived deterministically from property characteristics)
const demandProfiles = Math.min(14, (p.areaSqm >= 1000 ? 5 : p.areaSqm >= 500 ? 8 : 4) + 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)) (['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1))
const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38)) const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38))
@@ -133,7 +136,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
</Box> </Box>
</Box> </Box>
{/* Active: lead time + status + demand intelligence */} {/* Active: lead time + status + unit grid + demand intelligence */}
{enabled && ( {enabled && (
<Box sx={{ mt: 1.5 }}> <Box sx={{ mt: 1.5 }}>
{/* Lead time selector */} {/* Lead time selector */}
@@ -182,94 +185,20 @@ export function PreMarketPanel({ p }: { p: Property }) {
</Typography> </Typography>
</Box> </Box>
{/* Unit-level release controls */}
{(p.units?.length ?? 0) > 0 && ( {(p.units?.length ?? 0) > 0 && (
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}> <PreMarketUnitGrid
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}> units={p.units!}
Einheiten freigeben unitStates={unitStates}
</Typography> unitSaving={unitSaving}
{p.units!.map(u => { setUnitStates={setUnitStates}
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' } saveUnit={saveUnit}
return (
<Box
key={u.id}
sx={{
display: 'grid', gridTemplateColumns: '1fr 140px auto',
gap: 1, alignItems: 'center', py: 0.75,
borderBottom: '1px solid #f3e8ff',
'&:last-child': { borderBottom: 'none' },
}}
>
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
{u.areaSqm.toLocaleString('de-CH')} m²
{u.currentTenant ? ` · ${u.currentTenant}` : ''}
</Typography>
</Box>
<TextField
type="date"
size="small"
value={us.availableFrom}
disabled={!us.enabled}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
onChange={e => {
const next = { ...us, availableFrom: e.target.value }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
if (us.enabled) saveUnit(u.id, true, e.target.value)
}}
/> />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
<Switch
size="small"
checked={us.enabled}
onChange={(_, checked) => {
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' },
}}
/>
</Box>
</Box>
)
})}
</Box>
)} )}
{/* Demand Intelligence */} <PreMarketDemandIntelligence
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}> demandProfiles={demandProfiles}
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}> highQualityLeads={highQualityLeads}
Matching Demand Intelligence />
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Users size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
<strong>{demandProfiles} aktive Suchprofile</strong> im System erkannt
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Target size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
<strong>{highQualityLeads} hochwertige Suchanfragen</strong> mit passendem Flächenbedarf
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<TrendingUp size={11} color={DS_PRE_MARKET.accent} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
Frühzeitige Matchgelegenheit vor offizieller Vermarktung exklusiv verfügbar
</Typography>
</Box>
</Box>
</Box>
</Box> </Box>
)} )}
@@ -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<Property['units']>[number]
interface Props {
units: PropertyUnit[]
unitStates: Record<string, { enabled: boolean; availableFrom: string }>
unitSaving: Record<string, boolean>
setUnitStates: React.Dispatch<React.SetStateAction<Record<string, { enabled: boolean; availableFrom: string }>>>
saveUnit: (unitId: string, enabled: boolean, availableFrom: string) => Promise<void>
}
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
return (
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
Einheiten freigeben
</Typography>
{units.map(u => {
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' }
return (
<Box
key={u.id}
sx={{
display: 'grid', gridTemplateColumns: '1fr 140px auto',
gap: 1, alignItems: 'center', py: 0.75,
borderBottom: '1px solid #f3e8ff',
'&:last-child': { borderBottom: 'none' },
}}
>
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
{u.areaSqm.toLocaleString('de-CH')} m²
{u.currentTenant ? ` · ${u.currentTenant}` : ''}
</Typography>
</Box>
<TextField
type="date"
size="small"
value={us.availableFrom}
disabled={!us.enabled}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
onChange={e => {
const next = { ...us, availableFrom: e.target.value }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
if (us.enabled) saveUnit(u.id, true, e.target.value)
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
<Switch
size="small"
checked={us.enabled}
onChange={(_, checked) => {
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' },
}}
/>
</Box>
</Box>
)
})}
</Box>
)
}
+7 -1
View File
@@ -2,11 +2,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { needService } from '../services/needService' import { needService } from '../services/needService'
import type { CreateNeedInput } from '../domain/need' import type { CreateNeedInput } from '../domain/need'
export function useNeeds() { interface UseNeedsOptions {
refetchOnMount?: boolean | 'always'
gcTime?: number
}
export function useNeeds(options?: UseNeedsOptions) {
return useQuery({ return useQuery({
queryKey: ['needs'], queryKey: ['needs'],
queryFn: () => needService.getAll(), queryFn: () => needService.getAll(),
select: (res) => res.data ?? [], select: (res) => res.data ?? [],
...options,
}) })
} }
+200
View File
@@ -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<string, string> {
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<string, string>
// 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<Record<string, string>>(emptySoftLevels)
const [floor, setFloor] = useState('')
const [fitOut, setFitOut] = useState('')
const [parking, setParking] = useState('')
const [ceilingHeight, setCeilingHeight]= useState('')
const [images, setImages] = useState<string[]>([])
const [imageInput, setImageInput] = useState('')
const [aiText, setAiText] = useState('')
const [aiApplied, setAiApplied] = useState(false)
const [error, setError] = useState<string | null>(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,
}
}
+1
View File
@@ -88,6 +88,7 @@ export const DS_TEXT = {
warningDark: '#92400e', warningDark: '#92400e',
signalDark: '#4c1d95', signalDark: '#4c1d95',
infoDark: '#0c4a6e', infoDark: '#0c4a6e',
brandDark: '#162d4a',
} as const } as const
// ── Background tokens ───────────────────────────────────────────────────────── // ── Background tokens ─────────────────────────────────────────────────────────
+37
View File
@@ -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
+19 -111
View File
@@ -1,13 +1,5 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { import { Box, Typography } from '@mui/material'
Alert,
Box,
Button,
CircularProgress,
Divider,
Typography,
} from '@mui/material'
import { ArrowRight, Bookmark, Save, Search } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { import {
@@ -15,9 +7,10 @@ import {
NeedInput, NeedInput,
VoiceNeedInput, VoiceNeedInput,
WeightingEditor, WeightingEditor,
NeedCardPreview,
NeedBuilderErrorState, NeedBuilderErrorState,
} from '../../components/demand' } from '../../components/demand'
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
import { useParseNeed } from '../../hooks/useAI' import { useParseNeed } from '../../hooks/useAI'
import { useCreateNeed } from '../../hooks/useNeeds' import { useCreateNeed } from '../../hooks/useNeeds'
import { useDefaultWeights } from '../../hooks/useWeighting' import { useDefaultWeights } from '../../hooks/useWeighting'
@@ -25,12 +18,8 @@ import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper' import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
// ── Action intent ─────────────────────────────────────────────────────────────
type ActionIntent = 'search' | 'save-profile' type ActionIntent = 'search' | 'save-profile'
// ── Page ──────────────────────────────────────────────────────────────────────
export default function AISearch() { export default function AISearch() {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() 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) { function handleAction(chosenIntent: ActionIntent) {
setIntent(chosenIntent) setIntent(chosenIntent)
setError(null) setError(null)
@@ -130,7 +118,6 @@ export default function AISearch() {
resolvedResult: ParseNeedResult | null, resolvedResult: ParseNeedResult | null,
) { ) {
if (chosenIntent === 'search') { if (chosenIntent === 'search') {
// Save as DRAFT and navigate immediately
setStep(NeedBuilderStep.SAVING) setStep(NeedBuilderStep.SAVING)
const conf = resolvedResult const conf = resolvedResult
? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) / ? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) /
@@ -150,7 +137,6 @@ export default function AISearch() {
return return
} }
// save-profile: show preview step
const confidenceByField: Record<string, number> = resolvedResult?.confidenceByField ?? {} const confidenceByField: Record<string, number> = resolvedResult?.confidenceByField ?? {}
if (!resolvedResult) { if (!resolvedResult) {
if (resolved.assetType) confidenceByField.assetType = 1.0 if (resolved.assetType) confidenceByField.assetType = 1.0
@@ -229,10 +215,9 @@ export default function AISearch() {
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}> <Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* ── IDLE: full form ── */} {/* IDLE: full form */}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && ( {(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
<VoiceNeedInput <VoiceNeedInput
text={inputText} text={inputText}
onTextChange={handleTextChange} onTextChange={handleTextChange}
@@ -240,7 +225,6 @@ export default function AISearch() {
onAiSubmit={handleAiAutofill} onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING} isAnalyzing={step === NeedBuilderStep.PARSING}
/> />
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}> <Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} /> <NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor <WeightingEditor
@@ -250,108 +234,32 @@ export default function AISearch() {
assetType={criteria.assetType} assetType={criteria.assetType}
/> />
</Box> </Box>
<AISearchActionBar
{/* Action bar */} canProceed={canProceed}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> isSearching={isProcessing && intent === 'search'}
<Button isSavingProfile={isProcessing && intent === 'save-profile'}
variant="contained" onSearch={() => handleAction('search')}
size="large" onSaveProfile={() => handleAction('save-profile')}
disabled={!canProceed || isProcessing} />
onClick={() => handleAction('search')}
endIcon={
isProcessing && intent === 'search'
? <CircularProgress size={18} color="inherit" />
: <Search size={18} />
}
sx={{
flex: 1,
py: 1.5,
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
textTransform: 'none',
}}
>
{isProcessing && intent === 'search' ? 'Sucht…' : 'Jetzt suchen'}
</Button>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Button
variant="outlined"
size="large"
disabled={!canProceed || isProcessing}
onClick={() => handleAction('save-profile')}
startIcon={<Bookmark size={16} />}
endIcon={
isProcessing && intent === 'save-profile'
? <CircularProgress size={18} color="inherit" />
: <ArrowRight size={18} />
}
sx={{
py: 1.5,
fontSize: 15,
fontWeight: 500,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{isProcessing && intent === 'save-profile' ? 'Analysiert…' : 'Als Suchprofil speichern'}
</Button>
</Box>
<Alert severity="info" sx={{ mt: -1 }}>
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird auch in Zukunft.
</Alert>
</Box> </Box>
)} )}
{/* ── Preview + Save as Profile ── */} {/* Save preview step */}
{isSaveStep && parseResult && editedCriteria && ( {isSaveStep && parseResult && editedCriteria && (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}> <AISearchSavePreview
<Alert severity="success" sx={{ mb: 3 }}>
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
</Alert>
<NeedCardPreview
criteria={editedCriteria} criteria={editedCriteria}
weights={weights} weights={weights}
confidenceByField={parseResult.confidenceByField} parseResult={parseResult}
missingFields={parseResult.missingFields}
needTitle={needTitle} needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
onNeedTitleChange={setNeedTitle} onNeedTitleChange={setNeedTitle}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/> />
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
variant="outlined"
onClick={() => setStep(NeedBuilderStep.IDLE)}
disabled={step === NeedBuilderStep.SAVING}
>
Zurück
</Button>
<Button
variant="contained"
onClick={handleSaveProfile}
disabled={step === NeedBuilderStep.SAVING}
endIcon={
step === NeedBuilderStep.SAVING
? <CircularProgress size={16} color="inherit" />
: <Save size={16} />
}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
{step === NeedBuilderStep.SAVING
? 'Wird gespeichert…'
: overallConfidence < 0.6
? 'Als Entwurf speichern'
: 'Suchprofil speichern & Matching starten'}
</Button>
</Box>
</Box>
)} )}
{/* ── Error ── */} {/* Error */}
{step === NeedBuilderStep.ERROR && ( {step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} /> <NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)} )}
+16 -16
View File
@@ -12,7 +12,7 @@ import type { InquiryMessage } from '../../domain/inquiry'
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection' import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble' import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem' 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 ──────────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────────
@@ -130,13 +130,13 @@ export default function Anfragen() {
minWidth: { md: 320 }, minWidth: { md: 320 },
flexShrink: 0, flexShrink: 0,
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
borderRight: '1px solid #e2e8f0', borderRight: `1px solid ${DS_BORDER.default}`,
overflow: 'hidden', overflow: 'hidden',
bgcolor: 'white', bgcolor: 'white',
transition: 'width 0.2s ease', transition: 'width 0.2s ease',
}} }}
> >
<Box sx={{ px: 2, py: 2, borderBottom: '1px solid #e2e8f0' }}> <Box sx={{ px: 2, py: 2, borderBottom: `1px solid ${DS_BORDER.default}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1rem' }}>Anfragen</Typography> <Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1rem' }}>Anfragen</Typography>
{totalUnread > 0 && ( {totalUnread > 0 && (
@@ -147,7 +147,7 @@ export default function Anfragen() {
<TextField <TextField
size="small" placeholder="Suchen..." fullWidth size="small" placeholder="Suchen..." fullWidth
value={search} onChange={e => setSearch(e.target.value)} value={search} onChange={e => setSearch(e.target.value)}
InputProps={{ startAdornment: <InputAdornment position="start"><Search size={14} color="#94a3b8" /></InputAdornment> }} InputProps={{ startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> }}
sx={{ mb: 1.25 }} sx={{ mb: 1.25 }}
/> />
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
@@ -155,10 +155,10 @@ export default function Anfragen() {
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)} <Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)}
sx={{ sx={{
height: 22, fontSize: '0.7rem', cursor: 'pointer', height: 22, fontSize: '0.7rem', cursor: 'pointer',
bgcolor: statusFilter === tab.key ? 'primary.main' : '#f1f5f9', bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle,
color: statusFilter === tab.key ? 'white' : '#475569', color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary,
fontWeight: statusFilter === tab.key ? 700 : 400, 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' }, display: { xs: mobileShowChat ? 'flex' : 'none', md: 'flex' },
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
bgcolor: '#f8fafc', bgcolor: DS_BG.page,
minWidth: 0, minWidth: 0,
}}> }}>
{!selected ? ( {!selected ? (
@@ -199,7 +199,7 @@ export default function Anfragen() {
) : ( ) : (
<> <>
{/* Chat header */} {/* Chat header */}
<Box sx={{ px: 3, py: 1.75, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}> <Box sx={{ px: 3, py: 1.75, bgcolor: 'white', borderBottom: `1px solid ${DS_BORDER.default}`, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<IconButton size="small" sx={{ display: { md: 'none' }, mr: -0.5 }} onClick={() => setMobileShowChat(false)}> <IconButton size="small" sx={{ display: { md: 'none' }, mr: -0.5 }} onClick={() => setMobileShowChat(false)}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
@@ -219,10 +219,10 @@ export default function Anfragen() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{selected.matchScore && ( {selected.matchScore && (
<Chip label={`${selected.matchScore}%`} size="small" sx={{ <Chip label={`${selected.matchScore}%`} size="small" sx={{
bgcolor: selected.matchScore >= 80 ? '#f0fdf4' : '#fffbeb', bgcolor: selected.matchScore >= 80 ? DS_SURFACE.success.bg : DS_SURFACE.warning.bg,
color: selected.matchScore >= 80 ? '#1a7a4a' : '#d97706', color: selected.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning,
fontWeight: 700, height: 22, fontSize: '0.75rem', 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}`,
}} /> }} />
)} )}
<Chip <Chip
@@ -253,7 +253,7 @@ export default function Anfragen() {
{/* Property reference */} {/* Property reference */}
{(linkedPipelineItem?.propertyAddress ?? selected.subject) && ( {(linkedPipelineItem?.propertyAddress ?? selected.subject) && (
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Building2 size={12} color="#64748b" /> <Building2 size={12} color={DS_TEXT.muted} />
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{linkedPipelineItem?.propertyAddress ?? selected.subject} {linkedPipelineItem?.propertyAddress ?? selected.subject}
</Typography> </Typography>
@@ -286,7 +286,7 @@ export default function Anfragen() {
</Box> </Box>
{/* Composer */} {/* Composer */}
<Box sx={{ px: { xs: 2, md: 3 }, py: 2, bgcolor: 'white', borderTop: '1px solid #e2e8f0', flexShrink: 0 }}> <Box sx={{ px: { xs: 2, md: 3 }, py: 2, bgcolor: 'white', borderTop: `1px solid ${DS_BORDER.default}`, flexShrink: 0 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
<TextField <TextField
multiline minRows={2} maxRows={6} fullWidth size="small" multiline minRows={2} maxRows={6} fullWidth size="small"
@@ -297,9 +297,9 @@ export default function Anfragen() {
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/> />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<IconButton size="small" sx={{ color: '#94a3b8' }}><Paperclip size={16} /></IconButton> <IconButton size="small" sx={{ color: DS_TEXT.disabled }}><Paperclip size={16} /></IconButton>
<IconButton size="small" onClick={handleSend} disabled={!replyText.trim()} <IconButton size="small" onClick={handleSend} disabled={!replyText.trim()}
sx={{ bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark' }, '&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' } }}> sx={{ bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark' }, '&:disabled': { bgcolor: DS_BG.muted, color: DS_TEXT.disabled } }}>
<Send size={16} /> <Send size={16} />
</IconButton> </IconButton>
</Box> </Box>
+3 -7
View File
@@ -1,9 +1,9 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Typography } from '@mui/material' import { Box, Button, Card, Typography } from '@mui/material'
import { useNavigate, useLocation } from 'react-router' 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 { useUnifiedResults } from '../../hooks/useUnifiedResults'
import { needService } from '../../services/needService' import { useNeeds } from '../../hooks/useNeeds'
import { DecisionContextPanel } from '../../components/ui' import { DecisionContextPanel } from '../../components/ui'
import { import {
FeedEmptyState, FeedEmptyState,
@@ -47,15 +47,11 @@ export default function Results() {
// When coming from NeedBuilder, invalidate so the freshly created need is included // When coming from NeedBuilder, invalidate so the freshly created need is included
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
const { data: needResp } = useQuery({ const { data: allNeeds = [] } = useNeeds({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
refetchOnMount: activeNeedIdFromNav ? 'always' : true, refetchOnMount: activeNeedIdFromNav ? 'always' : true,
gcTime: 0, gcTime: 0,
}) })
const allNeeds = needResp?.data ?? []
// Nav ID (from NeedBuilder) takes priority; otherwise first named need // Nav ID (from NeedBuilder) takes priority; otherwise first named need
const effectiveNeedId = activeNeedIdFromNav ?? allNeeds.find(n => n.companyName !== 'Neue Suche')?.id const effectiveNeedId = activeNeedIdFromNav ?? allNeeds.find(n => n.companyName !== 'Neue Suche')?.id
+26 -25
View File
@@ -20,6 +20,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useMarketLeads } from '../../hooks/useMarketLeads' 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 { DecisionContextPanel } from '../../components/ui'
import type { MarketLead } from '../../hooks/useMarketLeads' import type { MarketLead } from '../../hooks/useMarketLeads'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
@@ -34,9 +35,9 @@ const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
} }
const QUALITY_META: Record<string, { label: string; color: string }> = { const QUALITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Signalqualität', color: '#1a7a4a' }, HIGH: { label: 'Hohe Signalqualität', color: DS_TEXT.success },
MEDIUM: { label: 'Mittlere Signalqualität', color: '#d97706' }, MEDIUM: { label: 'Mittlere Signalqualität', color: DS_TEXT.warning },
LOW: { label: 'Niedrige Signalqualität', color: '#c0392b' }, LOW: { label: 'Niedrige Signalqualität', color: DS_TEXT.error },
} }
function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' { function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' {
@@ -53,15 +54,15 @@ function PropertyMatchRow({ p }: { p: Property }) {
sx={{ sx={{
display: 'flex', alignItems: 'center', gap: 1, display: 'flex', alignItems: 'center', gap: 1,
px: 1.25, py: 0.75, borderRadius: 1, px: 1.25, py: 0.75, borderRadius: 1,
bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`,
cursor: 'pointer', '&:hover': { bgcolor: '#dcfce7' }, cursor: 'pointer', '&:hover': { bgcolor: DS_SURFACE.success.bg },
}} }}
> >
<Building2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} /> <Building2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.title} {p.title}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#1a7a4a', flexShrink: 0 }}> <Typography variant="caption" sx={{ color: DS_TEXT.success, flexShrink: 0 }}>
{p.areaSqm.toLocaleString('de-CH')} m² {p.areaSqm.toLocaleString('de-CH')} m²
</Typography> </Typography>
</Box> </Box>
@@ -81,7 +82,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
<Paper <Paper
sx={{ sx={{
p: 2.5, mb: 2, p: 2.5, mb: 2,
borderLeft: '3px solid #1d4ed8', borderLeft: `3px solid ${DS_MARKET_SIGNAL.accent}`,
position: 'relative', position: 'relative',
}} }}
> >
@@ -89,7 +90,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ mt: 0.25, flexShrink: 0 }}> <Box sx={{ mt: 0.25, flexShrink: 0 }}>
<Target size={18} color="#1d4ed8" /> <Target size={18} color={DS_MARKET_SIGNAL.accent} />
</Box> </Box>
<Box> <Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.25, mb: 0.25 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.25, mb: 0.25 }}>
@@ -103,7 +104,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
<Chip <Chip
label="Market Signal" label="Market Signal"
size="small" size="small"
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 700, fontSize: '0.68rem', flexShrink: 0 }} sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700, fontSize: '0.68rem', flexShrink: 0 }}
/> />
</Box> </Box>
@@ -143,14 +144,14 @@ function LeadCard({ lead }: { lead: MarketLead }) {
{/* Market indicators */} {/* Market indicators */}
{signal.marketIndicators && signal.marketIndicators.length > 0 && ( {signal.marketIndicators && signal.marketIndicators.length > 0 && (
<Box sx={{ mb: 1.75 }}> <Box sx={{ mb: 1.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}> <Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
Erkannte Nachfragesignale Erkannte Nachfragesignale
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{signal.marketIndicators.map((ind, i) => ( {signal.marketIndicators.map((ind, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}> <Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<TrendingUp size={11} color="#1d4ed8" style={{ marginTop: 3, flexShrink: 0 }} /> <TrendingUp size={11} color={DS_MARKET_SIGNAL.accent} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: '#374151', lineHeight: 1.4 }}>{ind}</Typography> <Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{ind}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -163,14 +164,14 @@ function LeadCard({ lead }: { lead: MarketLead }) {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{(signal.confirmedFacts ?? []).map((f, i) => ( {(signal.confirmedFacts ?? []).map((f, i) => (
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}> <Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} /> <CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 500 }}>{f}</Typography> <Typography variant="caption" sx={{ color: DS_TEXT.success, fontWeight: 500 }}>{f}</Typography>
</Box> </Box>
))} ))}
{(signal.unconfirmedFacts ?? []).map((f, i) => ( {(signal.unconfirmedFacts ?? []).map((f, i) => (
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}> <Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<AlertCircle size={12} color="#d97706" style={{ flexShrink: 0 }} /> <AlertCircle size={12} color={DS_TEXT.warning} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#d97706', fontWeight: 500 }}>{f}</Typography> <Typography variant="caption" sx={{ color: DS_TEXT.warning, fontWeight: 500 }}>{f}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -181,7 +182,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
{/* Portfolio matches */} {/* Portfolio matches */}
<Box> <Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}> <Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
Passende Objekte im Portfolio ({matchingProperties.length}) Passende Objekte im Portfolio ({matchingProperties.length})
</Typography> </Typography>
{matchingProperties.length === 0 ? ( {matchingProperties.length === 0 ? (
@@ -203,7 +204,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
</Box> </Box>
{/* Disclaimer */} {/* Disclaimer */}
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1.5, pt: 1.25, borderTop: '1px solid #f1f5f9', lineHeight: 1.4 }}> <Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BG.subtle}`, lineHeight: 1.4 }}>
{signal.disclaimer} {signal.disclaimer}
</Typography> </Typography>
</Paper> </Paper>
@@ -214,7 +215,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
function LeadSkeleton() { function LeadSkeleton() {
return ( return (
<Paper sx={{ p: 2.5, mb: 2, borderLeft: '3px solid #e2e8f0' }}> <Paper sx={{ p: 2.5, mb: 2, borderLeft: `3px solid ${DS_BORDER.default}` }}>
<Skeleton variant="text" width="60%" height={24} sx={{ mb: 0.5 }} /> <Skeleton variant="text" width="60%" height={24} sx={{ mb: 0.5 }} />
<Skeleton variant="text" width="80%" height={18} sx={{ mb: 2 }} /> <Skeleton variant="text" width="80%" height={18} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1, mb: 2 }} /> <Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1, mb: 2 }} />
@@ -234,9 +235,9 @@ export default function MarketLeads() {
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */} {/* Header */}
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}> <Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: `1px solid ${DS_BORDER.default}`, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Users size={20} color="#1d4ed8" /> <Users size={20} color={DS_MARKET_SIGNAL.accent} />
<Box> <Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Markt-Leads</Typography> <Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Markt-Leads</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -247,7 +248,7 @@ export default function MarketLeads() {
<Chip <Chip
label={`${leads.length} aktive Leads`} label={`${leads.length} aktive Leads`}
size="small" size="small"
sx={{ ml: 'auto', bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 700 }} sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700 }}
/> />
)} )}
</Box> </Box>
@@ -282,7 +283,7 @@ export default function MarketLeads() {
</> </>
) : leads.length === 0 ? ( ) : leads.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}> <Paper sx={{ p: 4, textAlign: 'center' }}>
<Target size={32} color="#94a3b8" style={{ marginBottom: 12 }} /> <Target size={32} color={DS_TEXT.disabled} style={{ marginBottom: 12 }} />
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>Keine aktiven Markt-Leads gefunden.</Typography> <Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>Keine aktiven Markt-Leads gefunden.</Typography>
<Typography variant="caption" color="text.disabled">Signale werden laufend aus öffentlichen Quellen erkannt.</Typography> <Typography variant="caption" color="text.disabled">Signale werden laufend aus öffentlichen Quellen erkannt.</Typography>
</Paper> </Paper>
+63 -382
View File
@@ -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 { useLocation, useNavigate } from 'react-router'
import { useNewListingForm } from '../../hooks/useNewListingForm'
import { import {
Alert, AiAssistCard,
Box, AreaDetailsSection,
Button, AddressSection,
Card, SoftFactorsSection,
Chip, TechnicalDetailsSection,
CircularProgress, ImageUrlSection,
Divider, ContactSection,
IconButton, CreatedScreen,
MenuItem, } from '../../components/new-listing'
TextField, import { DS_TEXT } from '../../lib/ds'
Tooltip, import type { LocationState } from './newListingConstants'
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'
export default function NewListing() { export default function NewListing() {
const navigate = useNavigate() const navigate = useNavigate()
const { state } = useLocation() as { state: LocationState | null } const { state } = useLocation() as { state: LocationState | null }
const pre = state?.prefill ?? {} const pre = state?.prefill ?? {}
const createProperty = useCreateProperty() const form = useNewListingForm(pre)
const parseListingMutation = useParseListingText()
// Core fields if (form.created) {
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<Record<string, string>>(
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<string[]>([])
const [imageInput, setImageInput] = useState('')
// AI
const [aiText, setAiText] = useState('')
const [aiApplied, setAiApplied] = useState(false)
// Submit
const [error, setError] = useState<string | null>(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) {
return ( return (
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}> <CreatedScreen
<CheckCircle size={48} color="#16a34a" style={{ marginBottom: 16 }} /> onViewListings={() => navigate('/supply/my-listings')}
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>Inserat erstellt</Typography> onCreateAnother={form.resetForm}
<Typography color="text.secondary" sx={{ mb: 3 }}> />
Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar.
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button variant="outlined" onClick={() => navigate('/supply/my-listings')}>
Meine Inserate
</Button>
<Button variant="contained" sx={{ bgcolor: '#1e3a5f' }} onClick={resetForm}>
Weiteres Inserat
</Button>
</Box>
</Box>
) )
} }
return ( return (
<Box sx={{ maxWidth: 760, mx: 'auto', px: 3, py: 4 }}> <Box sx={{ maxWidth: 760, mx: 'auto', px: 3, py: 4 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<Button <Button
variant="text" variant="text"
size="small" size="small"
startIcon={<ArrowLeft size={16} />} startIcon={<ArrowLeft size={16} />}
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
sx={{ color: '#64748b', textTransform: 'none', px: 0 }} sx={{ color: DS_TEXT.muted, textTransform: 'none', px: 0 }}
> >
Zurück Zurück
</Button> </Button>
@@ -193,246 +47,73 @@ export default function NewListing() {
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Neues Inserat erstellen</Typography> <Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Neues Inserat erstellen</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{isPrefilled {form.isPrefilled
? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.` ? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.`
: 'Fläche direkt inserieren — ohne vollständiges Objekt im Portfolio.'} : 'Fläche direkt inserieren — ohne vollständiges Objekt im Portfolio.'}
</Typography> </Typography>
{/* AI Hilfe */} <AiAssistCard
<Card sx={{ p: 3, mb: 3, border: '1px solid #e0e7ff', bgcolor: '#f5f3ff' }}> text={form.aiText}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> onTextChange={form.setAiText}
<Sparkles size={16} color="#7c3aed" /> onParse={form.handleAiParse}
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed' }}> parsing={form.aiParsing}
KI-Hilfe Formular automatisch ausfüllen applied={form.aiApplied}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
Beschreiben Sie die Fläche in eigenen Worten die KI füllt die Felder automatisch aus.
</Typography>
<TextField
multiline
rows={3}
fullWidth
size="small"
placeholder="z.B.: Bürofläche 450 m² im Zentrum Zürich, 3. OG, CHF 280/m²/Jahr, sehr gute ÖV-Anbindung, 4 Parkplätze, Vollausbau, verfügbar ab Juli 2025"
value={aiText}
onChange={e => setAiText(e.target.value)}
sx={{ mb: 1.5, bgcolor: '#fff' }}
/>
{aiApplied && (
<Alert severity="success" sx={{ mb: 1.5, py: 0.5 }}>
Felder wurden automatisch ausgefüllt bitte überprüfen und ergänzen.
</Alert>
)}
<Button
variant="contained"
size="small"
startIcon={aiParsing ? <CircularProgress size={13} color="inherit" /> : <Sparkles size={13} />}
onClick={handleAiParse}
disabled={!aiText.trim() || aiParsing}
sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
>
{aiParsing ? 'Analysiert…' : 'KI analysieren'}
</Button>
</Card>
{/* Flächendetails */}
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Flächendetails</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
select label="Flächentyp" value={assetType}
onChange={e => setAssetType(e.target.value)} size="small" fullWidth
>
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
<MenuItem key={v} value={v}>{l}</MenuItem>
))}
</TextField>
<TextField
label="Fläche (m²)" value={areaSqm}
onChange={e => setAreaSqm(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth
/> />
<TextField <AreaDetailsSection
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm} assetType={form.assetType} onAssetTypeChange={form.setAssetType}
onChange={e => setRentPerSqm(e.target.value)} areaSqm={form.areaSqm} onAreaSqmChange={form.setAreaSqm}
size="small" type="number" inputProps={{ min: 1 }} fullWidth rentPerSqm={form.rentPerSqm} onRentPerSqmChange={form.setRentPerSqm}
availableFrom={form.availableFrom} onAvailableFromChange={form.setAvailableFrom}
description={form.description} onDescriptionChange={form.setDescription}
/> />
<TextField <AddressSection
label="Verfügbar ab" value={availableFrom} street={form.street} onStreetChange={form.setStreet}
onChange={e => setAvailableFrom(e.target.value)} houseNumber={form.houseNumber} onHouseNumberChange={form.setHouseNumber}
size="small" type="date" postalCode={form.postalCode} onPostalCodeChange={form.setPostalCode}
slotProps={{ inputLabel: { shrink: true } }} fullWidth city={form.city} onCityChange={form.setCity}
/> />
</Box>
<TextField
label="Beschreibung (optional)" value={description}
onChange={e => setDescription(e.target.value)}
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
/>
</Card>
{/* Adresse */} <SoftFactorsSection softLevels={form.softLevels} onChange={form.setSoftLevel} />
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Adresse</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 2, mb: 2 }}>
<TextField
label="Strasse" value={street}
onChange={e => setStreet(e.target.value)} size="small" fullWidth
/>
<TextField
label="Nr." value={houseNumber}
onChange={e => setHouseNumber(e.target.value)} size="small" fullWidth
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<TextField
label="PLZ" value={postalCode}
onChange={e => setPostalCode(e.target.value)} size="small" fullWidth
/>
<TextField
label="Ort" value={city}
onChange={e => setCity(e.target.value)} size="small" fullWidth
/>
</Box>
</Card>
{/* Lage & Ausstrahlung */} <TechnicalDetailsSection
<Card sx={{ p: 3, mb: 3 }}> floor={form.floor} onFloorChange={form.setFloor}
<Box sx={{ mb: 2 }}> fitOut={form.fitOut} onFitOutChange={form.setFitOut}
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Lage & Ausstrahlung</Typography> parking={form.parking} onParkingChange={form.setParking}
<Typography variant="caption" color="text.secondary"> ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
Diese Angaben verbessern die Match-Qualität erheblich. />
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
{SOFT_FACTORS.map(({ key, label }) => (
<TextField
key={key}
select
label={label}
value={softLevels[key] ?? ''}
onChange={e => setSoftLevels(prev => ({ ...prev, [key]: e.target.value }))}
size="small"
fullWidth
>
{LEVEL_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
))}
</Box>
</Card>
{/* Technische Details */} <ImageUrlSection
<Card sx={{ p: 3, mb: 3 }}> images={form.images}
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography> imageInput={form.imageInput}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 2 }}> onImageInputChange={form.setImageInput}
<TextField onAdd={form.addImage}
label="Stockwerk" value={floor} onRemove={form.removeImage}
onChange={e => setFloor(e.target.value)}
size="small" type="number" fullWidth
placeholder="0 = EG"
/> />
<TextField
select label="Ausbaustandard" value={fitOut}
onChange={e => setFitOut(e.target.value)} size="small" fullWidth
>
{FIT_OUT_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Parkplätze" value={parking}
onChange={e => setParking(e.target.value)}
size="small" type="number" inputProps={{ min: 0 }} fullWidth
/>
<TextField
label="Deckenhöhe (m)" value={ceilingHeight}
onChange={e => setCeilingHeight(e.target.value)}
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
/>
</Box>
</Card>
{/* Bilder */} <ContactSection
<Card sx={{ p: 3, mb: 3 }}> name={form.contactName} onNameChange={form.setContactName}
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Bilder (optional)</Typography> email={form.contactEmail} onEmailChange={form.setContactEmail}
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}> phone={form.contactPhone} onPhoneChange={form.setContactPhone}
<TextField
label="Bild-URL eingeben"
value={imageInput}
onChange={e => setImageInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addImage() } }}
size="small"
fullWidth
placeholder="https://…"
/> />
<Button
variant="outlined"
size="small"
startIcon={<ImagePlus size={15} />}
onClick={addImage}
disabled={!imageInput.trim()}
sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
>
Hinzufügen
</Button>
</Box>
{images.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{images.map((url, i) => (
<Chip
key={i}
label={url.length > 40 ? url.slice(0, 40) + '…' : url}
size="small"
onDelete={() => setImages(prev => prev.filter((_, j) => j !== i))}
sx={{ maxWidth: 300 }}
/>
))}
</Box>
)}
</Card>
{/* Kontakt */} {form.error && <Alert severity="error" sx={{ mb: 2 }}>{form.error}</Alert>}
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Kontakt (optional)</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
label="Name" value={contactName}
onChange={e => setContactName(e.target.value)} size="small" fullWidth
/>
<TextField
label="Telefon" value={contactPhone}
onChange={e => setContactPhone(e.target.value)} size="small" fullWidth
/>
<TextField
label="E-Mail" value={contactEmail}
onChange={e => setContactEmail(e.target.value)}
size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
/>
</Box>
</Card>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" onClick={() => navigate(-1)} disabled={submitting}> <Button variant="outlined" onClick={() => navigate(-1)} disabled={form.submitting}>
Abbrechen Abbrechen
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_TEXT.brandDark } }}
onClick={handleSubmit} onClick={form.handleSubmit}
disabled={submitting} disabled={form.submitting}
startIcon={submitting ? <CircularProgress size={14} color="inherit" /> : null} startIcon={form.submitting ? <CircularProgress size={14} color="inherit" /> : null}
> >
{submitting ? 'Wird erstellt…' : 'Inserat veröffentlichen'} {form.submitting ? 'Wird erstellt…' : 'Inserat veröffentlichen'}
</Button> </Button>
</Box> </Box>
</Box> </Box>
+2
View File
@@ -51,3 +51,5 @@ export interface LocationState {
propertyId?: string propertyId?: string
} }
} }
export type Prefill = NonNullable<LocationState['prefill']>
+10
View File
@@ -14,12 +14,20 @@ export interface AIProvenance {
generatedAt: string generatedAt: string
/** Prompt version string used to generate this response */ /** Prompt version string used to generate this response */
promptVersion: string promptVersion: string
/** Zod schema version used for validation */
schemaVersion: string
/** Whether the response is AI-only, mock-only, or a hybrid merge */ /** Whether the response is AI-only, mock-only, or a hybrid merge */
source: 'ai' | 'mock' | 'hybrid' source: 'ai' | 'mock' | 'hybrid'
/** True when the original AI call failed and mock was substituted */ /** True when the original AI call failed and mock was substituted */
fallbackUsed: boolean fallbackUsed: boolean
/** Human-readable reason why a fallback occurred — undefined when no fallback */
fallbackReason?: string
/** True when the AI response passed Zod schema validation */ /** True when the AI response passed Zod schema validation */
validationPassed: boolean 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>): AIProvenance
model: 'mock', model: 'mock',
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
promptVersion: 'mock', promptVersion: 'mock',
schemaVersion: 'mock',
source: 'mock', source: 'mock',
fallbackUsed: false, fallbackUsed: false,
validationPassed: true, validationPassed: true,
traceId: crypto.randomUUID(),
...overrides, ...overrides,
} }
} }
+3 -3
View File
@@ -301,7 +301,7 @@ describe('OfferEmailResponseSchema', () => {
expect(result.success).toBe(false) 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.' }) const result = OfferEmailResponseSchema.safeParse({ subject: 'Angebot', body: 'Kurz.' })
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
@@ -313,11 +313,11 @@ describe('validateAIResponse helper', () => {
it('returns parsed data when schema passes', () => { it('returns parsed data when schema passes', () => {
const result = validateAIResponse( const result = validateAIResponse(
OfferEmailResponseSchema, 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', 'test',
) )
expect(result).not.toBeNull() 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)', () => { it('returns null when schema fails (does not throw)', () => {
+55 -18
View File
@@ -59,7 +59,7 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
assetType: { assetType: {
questionText: 'Welchen Nutzungstyp suchen Sie?', questionText: 'Welchen Nutzungstyp suchen Sie?',
reason: 'Nutzungstyp ist zwingend für die Matchsuche', reason: 'Nutzungstyp ist zwingend für die Matchsuche',
suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Gastro'], suggestedAnswerOptions: ['Büro', 'Retail', 'Logistik', 'Produktion', 'Leichtindustrie', 'Gastro'],
importance: 'required', importance: 'required',
}, },
areaRange: { areaRange: {
@@ -74,7 +74,7 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
importance: 'required', importance: 'required',
}, },
budgetRange: { budgetRange: {
questionText: 'Was ist Ihr maximales Budget pro m² und Monat (CHF)?', questionText: 'Was ist Ihr maximales Budget pro m² und Jahr (CHF)?',
reason: 'Budget ist wichtig für die Filterung unpassender Objekte', reason: 'Budget ist wichtig für die Filterung unpassender Objekte',
importance: 'recommended', importance: 'recommended',
}, },
@@ -90,32 +90,69 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
}, },
} }
const AREA_AMBIGUITY_RATIO_THRESHOLD = 8
const AREA_AMBIGUITY_QUESTION: QuestionTemplate = {
questionText: 'Ihre Flächenangabe ist sehr weit gefasst — können Sie den Bereich präzisieren (z.B. min 300 m², max 600 m²)?',
reason: 'Zu grosse Spanne reduziert die Matchgenauigkeit erheblich',
importance: 'required',
}
function isAreaAmbiguous(areaRange: NonNullable<ParsedNeedCriteria['areaRange']>): 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<keyof ParsedNeedCriteria> = [
'assetType',
'areaRange',
'preferredLocations',
'budgetRange',
'timing',
'mustHaveCriteria',
]
function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[] { function buildFollowUpQuestions(criteria: ParsedNeedCriteria): FollowUpQuestion[] {
const missing: Array<keyof ParsedNeedCriteria> = [] const questions: FollowUpQuestion[] = []
let idx = 0
if (!criteria.assetType) missing.push('assetType') for (const field of FIELD_PRIORITY) {
if (!criteria.areaRange) missing.push('areaRange') if (questions.length >= 3) break
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')
return missing if (field === 'areaRange') {
.slice(0, 3) if (!criteria.areaRange) {
.map((field, i) => { 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] const tpl = FOLLOW_UP_TEMPLATES[field]
if (!tpl) return null if (!tpl) continue
const q: FollowUpQuestion = { questions.push({
id: `fq-mock-${i}`, id: `fq-mock-${idx++}`,
questionText: tpl.questionText, questionText: tpl.questionText,
targetField: field, targetField: field,
reason: tpl.reason, reason: tpl.reason,
importance: tpl.importance, importance: tpl.importance,
suggestedAnswerOptions: tpl.suggestedAnswerOptions, suggestedAnswerOptions: tpl.suggestedAnswerOptions,
}
return q
}) })
.filter((q): q is FollowUpQuestion => q !== null) }
}
return questions
} }
// ── Service ─────────────────────────────────────────────────────────────────── // ── Service ───────────────────────────────────────────────────────────────────
@@ -87,15 +87,19 @@ function makeProvenance(
source: AIProvenance['source'], source: AIProvenance['source'],
fallbackUsed: boolean, fallbackUsed: boolean,
validationPassed: boolean, validationPassed: boolean,
extras: { fallbackReason?: string } = {},
): AIProvenance { ): AIProvenance {
return { return {
provider: 'openrouter', provider: 'openrouter',
model: config.model, model: config.model,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
promptVersion: PROMPT_VERSION, promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
source, source,
fallbackUsed, fallbackUsed,
validationPassed, validationPassed,
traceId: crypto.randomUUID(),
fallbackReason: extras.fallbackReason,
} }
} }
@@ -186,34 +190,51 @@ async function withFallback<T>(
): Promise<AIResponse<T>> { ): Promise<AIResponse<T>> {
const config = getConfig() const config = getConfig()
const startMs = Date.now() const startMs = Date.now()
const callId = crypto.randomUUID()
if (!config) { if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`) console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
const result = await fallback() 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({ aiTraceStore.add({
id: crypto.randomUUID(), id: callId,
method: label, method: label,
provider: 'openrouter', provider: 'openrouter',
model: DEFAULT_MODEL, model: DEFAULT_MODEL,
promptVersion: PROMPT_VERSION, promptVersion: PROMPT_VERSION,
latencyMs: Date.now() - startMs, latencyMs,
fallbackUsed: true, fallbackUsed: true,
validationPassed: false, validationPassed: false,
responseValidationStatus: 'fallback', responseValidationStatus: 'fallback',
errorType: 'no_api_key', errorType: 'no_api_key',
fallbackReason: 'no_api_key',
source: 'mock', source: 'mock',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
inputSizeChars, inputSizeChars,
}) })
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } } return { ...result, provenance }
} }
try { try {
const result = await fn(config) const result = await fn(config)
const latencyMs = Date.now() - startMs const latencyMs = Date.now() - startMs
const prov = result.provenance const prov = result.provenance
const provenance: AIProvenance = {
...prov,
traceId: callId,
latencyMs,
schemaVersion: SCHEMA_VERSION,
}
aiTraceStore.add({ aiTraceStore.add({
id: crypto.randomUUID(), id: callId,
method: label, method: label,
provider: prov.provider, provider: prov.provider,
model: prov.model, model: prov.model,
@@ -221,12 +242,13 @@ async function withFallback<T>(
latencyMs, latencyMs,
fallbackUsed: prov.fallbackUsed, fallbackUsed: prov.fallbackUsed,
validationPassed: prov.validationPassed, validationPassed: prov.validationPassed,
responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source), responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason),
fallbackReason: prov.fallbackReason,
source: prov.source, source: prov.source,
createdAt: prov.generatedAt, createdAt: prov.generatedAt,
inputSizeChars, inputSizeChars,
}) })
return result return { ...result, provenance }
} catch (err) { } catch (err) {
console.error(`[OpenRouterAIService] ${label} failed:`, err) console.error(`[OpenRouterAIService] ${label} failed:`, err)
const result = await fallback() const result = await fallback()
@@ -241,8 +263,17 @@ async function withFallback<T>(
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error' ? 'api_error'
: 'network_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({ aiTraceStore.add({
id: crypto.randomUUID(), id: callId,
method: label, method: label,
provider: 'openrouter', provider: 'openrouter',
model: config.model, model: config.model,
@@ -252,11 +283,12 @@ async function withFallback<T>(
validationPassed: false, validationPassed: false,
responseValidationStatus, responseValidationStatus,
errorType, errorType,
fallbackReason,
source: 'mock', source: 'mock',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
inputSizeChars, inputSizeChars,
}) })
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } } return { ...result, provenance }
} }
} }
@@ -275,7 +307,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback') console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback')
const fb = await MockAIService.parseNeed(input) 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 = { const extractedCriteria: ParsedNeedCriteria = {
@@ -321,9 +353,14 @@ export const OpenRouterAIService: IAIService = {
// ── generateFollowUpQuestions ─────────────────────────────────────────────── // ── generateFollowUpQuestions ───────────────────────────────────────────────
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> { generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
return withFallback('generateFollowUpQuestions', async (config) => { return withFallback('generateFollowUpQuestions', async (config) => {
const missingFields = Object.entries(criteria) const missingFields = [
.filter(([, v]) => v == null) ...(!criteria.assetType ? ['assetType'] : []),
.map(([k]) => k) ...(!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 { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
const raw = await chat(config, system, user) const raw = await chat(config, system, user)
const json = extractJSON<unknown[]>(raw) const json = extractJSON<unknown[]>(raw)
@@ -332,7 +369,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai?.length) { if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback') console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUpQuestions(criteria) 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 { return {
data: ai.map((q, i) => ({ data: ai.map((q, i) => ({
@@ -359,7 +396,7 @@ export const OpenRouterAIService: IAIService = {
if (!summary) { if (!summary) {
console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback') console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback')
const fb = await MockAIService.generateMatchExplanation(input) 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' const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
return { return {
@@ -387,7 +424,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] summarizeTradeOffs: invalid response — using mock fallback') console.warn('[OpenRouterAIService] summarizeTradeOffs: invalid response — using mock fallback')
const fb = await MockAIService.summarizeTradeOffs(tradeoffs) 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 { return {
data: { data: {
@@ -430,7 +467,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback') console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback')
const fb = await MockAIService.summarizeComparison(items) 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) const mock = await MockAIService.summarizeComparison(items)
return { return {
@@ -456,7 +493,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback') console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback')
const fb = await MockAIService.generateDecisionBrief(shortlistId) 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) const mock = await MockAIService.generateDecisionBrief(shortlistId)
return { return {
@@ -481,7 +518,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback') console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback')
const fb = await MockAIService.generateDataQualitySummary(propertyId, quality) 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 { return {
data: { data: {
@@ -508,7 +545,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] classifyMarketSignal: invalid response — using mock fallback') console.warn('[OpenRouterAIService] classifyMarketSignal: invalid response — using mock fallback')
const fb = await MockAIService.classifyMarketSignal(signalText) 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 { return {
data: { data: {
@@ -539,7 +576,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback') console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback')
const fb = await MockAIService.generateOfferEmail(payload) 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 { return {
data: { subject: ai.subject, body: ai.body }, data: { subject: ai.subject, body: ai.body },
@@ -559,7 +596,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) { if (!ai) {
console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback') console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback')
const fb = await MockAIService.extractCriteria(input) 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 { return {
data: { data: {
@@ -602,7 +639,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai?.length) { if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback') console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUp(partialNeed) 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 { return {
data: ai.map(q => q.questionText).filter(Boolean), data: ai.map(q => q.questionText).filter(Boolean),
@@ -24,8 +24,12 @@ PRIORITÄTSREIHENFOLGE:
5. timing (recommended) wichtig für Verfügbarkeitsabgleich 5. timing (recommended) wichtig für Verfügbarkeitsabgleich
6. mustHaveCriteria (optional) Pflichtmerkmale (Parkplätze, Laderampe etc.) 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: VERBOTE NIEMALS:
- Fragen zu bereits bekannten Kriterien stellen - Fragen zu bereits bekannten, eindeutigen Kriterien stellen
- Mehr als 3 Fragen ausgeben - Mehr als 3 Fragen ausgeben
- Fragen erfinden, die nicht einem der 6 definierten Zielfelder entsprechen - Fragen erfinden, die nicht einem der 6 definierten Zielfelder entsprechen
- Doppelfragen stellen - Doppelfragen stellen
@@ -54,7 +58,7 @@ Ausgabe:
"importance": "required" "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", "targetField": "budgetRange",
"reason": "Budget ist wichtig für die Filterung unpassender Objekte", "reason": "Budget ist wichtig für die Filterung unpassender Objekte",
"suggestedAnswerOptions": [], "suggestedAnswerOptions": [],
@@ -34,6 +34,7 @@ WEITERE VERBOTE:
- Fläche in m² schätzen, wenn kein konkreter Hinweis im Text steht ( null setzen) - 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) - 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 > 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): AUSGABEFORMAT antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{ {
+11 -5
View File
@@ -20,10 +20,10 @@ VERBOTE — NIEMALS:
AUSGABEFORMAT antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen): AUSGABEFORMAT antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{ {
"assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "GASTRO" | "MIXED" | "UNKNOWN" | null, "assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "LIGHT_INDUSTRIAL" | "GASTRO" | "MIXED" | "UNKNOWN" | null,
"areaRange": { "min": number, "max": number } | null, "areaRange": { "min": number (1), "max": number (1, min) } | null,
"preferredLocations": string[], "preferredLocations": string[],
"budgetRange": { "maxPerSqm": number, "currency": "CHF" } | null, "budgetRange": { "maxPerSqm": number (CHF/m²/Jahr), "currency": "CHF" } | null,
"timing": { "timing": {
"earliestMoveIn": "YYYY-MM-DD" | null, "earliestMoveIn": "YYYY-MM-DD" | null,
"latestMoveIn": "YYYY-MM-DD" | null, "latestMoveIn": "YYYY-MM-DD" | null,
@@ -34,14 +34,20 @@ AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block
"assumptions": string[] "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: 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: Ausgabe:
{ {
"assetType": "OFFICE", "assetType": "OFFICE",
"areaRange": { "min": 200, "max": 400 }, "areaRange": { "min": 200, "max": 400 },
"preferredLocations": ["Zürich"], "preferredLocations": ["Zürich"],
"budgetRange": { "maxPerSqm": 50, "currency": "CHF" }, "budgetRange": { "maxPerSqm": 600, "currency": "CHF" },
"timing": { "earliestMoveIn": "2026-03-01", "latestMoveIn": null, "flexibleTiming": false }, "timing": { "earliestMoveIn": "2026-03-01", "latestMoveIn": null, "flexibleTiming": false },
"mustHaveCriteria": [], "mustHaveCriteria": [],
"missingFields": ["timing.latestMoveIn", "mustHaveCriteria"], "missingFields": ["timing.latestMoveIn", "mustHaveCriteria"],
+86 -44
View File
@@ -4,6 +4,10 @@
* Every OpenRouter response is validated against its schema before reaching * Every OpenRouter response is validated against its schema before reaching
* the UI. Validation failures trigger an explicit fallback to MockAIService * the UI. Validation failures trigger an explicit fallback to MockAIService
* no invalid data ever passes through silently. * 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' import { z } from 'zod'
@@ -20,19 +24,28 @@ export const ScoreSchema = z.number().min(0).max(100)
// ── 1. Need Parsing ─────────────────────────────────────────────────────────── // ── 1. Need Parsing ───────────────────────────────────────────────────────────
export const NeedParsingResponseSchema = z.object({ export const NeedParsingResponseSchema = z
.object({
assetType: z assetType: z
.enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'GASTRO', 'MIXED', 'UNKNOWN']) .enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'LIGHT_INDUSTRIAL', 'GASTRO', 'MIXED', 'UNKNOWN'])
.optional() .optional()
.nullable(), .nullable(),
areaRange: z areaRange: z
.object({ min: z.number().min(0), max: z.number().min(0) }) .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() .optional()
.nullable() .nullable()
.refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }), .refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }),
preferredLocations: z.array(z.string().min(1)).optional(), preferredLocations: z.array(z.string().min(1).max(100)).max(20).optional(),
budgetRange: z budgetRange: z
.object({ maxPerSqm: z.number().positive(), currency: z.string().min(1) }) .object({
maxPerSqm: z.number().positive().max(100_000, 'budget > 100k CHF/m²/a is implausible'),
currency: z.string().min(1).max(10),
})
.strict()
.optional() .optional()
.nullable(), .nullable(),
timing: z timing: z
@@ -41,12 +54,14 @@ export const NeedParsingResponseSchema = z.object({
latestMoveIn: z.string().optional(), latestMoveIn: z.string().optional(),
flexibleTiming: z.boolean().optional(), flexibleTiming: z.boolean().optional(),
}) })
.strict()
.optional() .optional()
.nullable(), .nullable(),
mustHaveCriteria: z.array(z.string()).optional(), mustHaveCriteria: z.array(z.string().min(1).max(200)).max(30).optional(),
missingFields: z.array(z.string()).optional(), missingFields: z.array(z.string().min(1)).max(20).optional(),
assumptions: z.array(z.string()).optional(), assumptions: z.array(z.string().min(1)).max(20).optional(),
}) })
.strict()
export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema> export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema>
@@ -54,81 +69,102 @@ export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema>
export const FollowUpQuestionsResponseSchema = z export const FollowUpQuestionsResponseSchema = z
.array( .array(
z.object({ z
questionText: z.string().min(1), .object({
targetField: z.string().min(1), questionText: z.string().min(5).max(500),
reason: z.string().optional(), targetField: z.string().min(1).max(100),
suggestedAnswerOptions: z.array(z.string()).optional(), reason: z.string().min(3).max(300).optional(),
importance: z suggestedAnswerOptions: z.array(z.string().min(1).max(200)).max(10).optional(),
.enum(['required', 'recommended', 'optional']) importance: z.enum(['required', 'recommended', 'optional']),
.optional(), })
}), .strict(),
) )
.max(5) .max(3)
// ── 3. Trade-Off Summary ────────────────────────────────────────────────────── // ── 3. Trade-Off Summary ──────────────────────────────────────────────────────
export const TradeOffSummaryResponseSchema = z.object({ export const TradeOffSummaryResponseSchema = z
headline: z.string().min(1), .object({
headline: z.string().min(5).max(300),
items: z items: z
.array( .array(
z.object({ z
concern: z.string().min(1), .object({
concern: z.string().min(5).max(300),
severity: SeveritySchema, severity: SeveritySchema,
mitigation: z.string().optional(), mitigation: z.string().max(300).optional(),
}), })
.strict(),
) )
.max(5), .max(3),
overallRisk: SeveritySchema, overallRisk: SeveritySchema,
}) })
.strict()
// ── 4. Comparison Summary ───────────────────────────────────────────────────── // ── 4. Comparison Summary ─────────────────────────────────────────────────────
export const CompareSummaryResponseSchema = z.object({ export const CompareSummaryResponseSchema = z
overallAssessment: z.string().min(1), .object({
recommendation: z.string().optional(), overallAssessment: z.string().min(10).max(1000),
strongestOption: z.string().optional(), recommendation: z.string().min(5).max(500).optional(),
strongestOption: z.string().min(1).max(200).optional(),
}) })
.strict()
// ── 5. Decision Brief ──────────────────────────────────────────────────────── // ── 5. Decision Brief ────────────────────────────────────────────────────────
export const DecisionBriefResponseSchema = z.object({ export const DecisionBriefResponseSchema = z
summary: z.string().min(1), .object({
summary: z.string().min(10).max(500),
sections: z sections: z
.array(z.object({ title: z.string().min(1), body: z.string().min(1) })) .array(
z
.object({
title: z.string().min(1).max(100),
body: z.string().min(10).max(1000),
})
.strict(),
)
.min(1) .min(1)
.max(6), .max(6),
}) })
.strict()
// ── 6. Data Quality Summary ─────────────────────────────────────────────────── // ── 6. Data Quality Summary ───────────────────────────────────────────────────
export const DataQualitySummaryResponseSchema = z.object({ export const DataQualitySummaryResponseSchema = z
overallAssessment: z.string().min(1), .object({
missingCriticalFields: z.array(z.string()).optional(), overallAssessment: z.string().min(10).max(600),
recommendation: z.string().min(1), 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), confidence: z.number().min(0).max(1),
}) })
.strict()
// ── 7. Market Signal Classification ────────────────────────────────────────── // ── 7. Market Signal Classification ──────────────────────────────────────────
export const MarketSignalClassificationResponseSchema = z.object({ export const MarketSignalClassificationResponseSchema = z
.object({
signalType: z.enum([ signalType: z.enum([
'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING', 'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING',
'EXPANSION', 'RELOCATION', 'UNKNOWN', 'EXPANSION', 'RELOCATION', 'UNKNOWN',
]), ]),
probability: ProbabilitySchema, probability: ProbabilitySchema,
timeHorizonMonths: z.number().positive().int().optional().nullable(), timeHorizonMonths: z.number().int().min(1).max(240).optional().nullable(),
areaSqmEstimate: z.number().positive().optional().nullable(), areaSqmEstimate: z.number().positive().max(1_000_000).optional().nullable(),
credibility: CredibilitySchema, credibility: CredibilitySchema,
reasoning: z.string().min(1), reasoning: z.string().min(10).max(1000),
}) })
.strict()
// ── 8. Offer Email ──────────────────────────────────────────────────────────── // ── 8. Offer Email ────────────────────────────────────────────────────────────
export const OfferEmailResponseSchema = z.object({ export const OfferEmailResponseSchema = z
subject: z.string().min(1), .object({
body: z.string().min(10), subject: z.string().min(5).max(200),
body: z.string().min(50).max(5000),
}) })
.strict()
// ── Validation helper ───────────────────────────────────────────────────────── // ── Validation helper ─────────────────────────────────────────────────────────
@@ -144,6 +180,12 @@ export function validateAIResponse<T>(
): T | null { ): T | null {
const result = schema.safeParse(raw) const result = schema.safeParse(raw)
if (result.success) return result.data 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 return null
} }
+65 -5
View File
@@ -56,6 +56,8 @@ export interface AITrace {
responseValidationStatus: AITraceValidationStatus responseValidationStatus: AITraceValidationStatus
/** Only present when responseValidationStatus indicates a failure */ /** Only present when responseValidationStatus indicates a failure */
errorType?: AITraceErrorType errorType?: AITraceErrorType
/** Human-readable reason for the fallback — mirrors AIProvenance.fallbackReason */
fallbackReason?: string
source: AIProvenance['source'] source: AIProvenance['source']
/** ISO-8601 timestamp of when the call completed */ /** ISO-8601 timestamp of when the call completed */
createdAt: string createdAt: string
@@ -65,6 +67,11 @@ export interface AITrace {
// ── Store ───────────────────────────────────────────────────────────────────── // ── 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 MAX_ENTRIES = 100
const STORAGE_KEY = 'pm_ai_traces' const STORAGE_KEY = 'pm_ai_traces'
@@ -103,19 +110,67 @@ class AITraceStore {
fallbacks: number fallbacks: number
schemaFailures: number schemaFailures: number
avgLatencyMs: number avgLatencyMs: number
latencyPercentiles: { p50: number; p90: number; p99: number }
byMethod: Record<string, number> byMethod: Record<string, number>
failureCountByError: Record<string, number>
validationFailuresByMethod: Record<string, number>
fallbackReasonDistribution: Record<string, number>
promptVersionUsage: Record<string, number>
} { } {
const total = this.entries.length const total = this.entries.length
const fallbacks = this.entries.filter(t => t.fallbackUsed).length const fallbacks = this.entries.filter(t => t.fallbackUsed).length
const schemaFailures = this.entries.filter(t => t.responseValidationStatus === 'invalid_schema').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<Record<string, number>>((acc, t) => { const byMethod = this.entries.reduce<Record<string, number>>((acc, t) => {
acc[t.method] = (acc[t.method] ?? 0) + 1 acc[t.method] = (acc[t.method] ?? 0) + 1
return acc return acc
}, {}) }, {})
return { total, fallbacks, schemaFailures, avgLatencyMs, byMethod }
const failureCountByError = this.entries
.filter(t => t.errorType)
.reduce<Record<string, number>>((acc, t) => {
acc[t.errorType!] = (acc[t.errorType!] ?? 0) + 1
return acc
}, {})
const validationFailuresByMethod = this.entries
.filter(t => t.responseValidationStatus === 'invalid_schema')
.reduce<Record<string, number>>((acc, t) => {
acc[t.method] = (acc[t.method] ?? 0) + 1
return acc
}, {})
const fallbackReasonDistribution = this.entries
.filter(t => t.fallbackReason)
.reduce<Record<string, number>>((acc, t) => {
acc[t.fallbackReason!] = (acc[t.fallbackReason!] ?? 0) + 1
return acc
}, {})
const promptVersionUsage = this.entries.reduce<Record<string, number>>((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). */ /** Load the persisted trace list from localStorage (dev only). */
@@ -140,7 +195,8 @@ class AITraceStore {
console.debug( console.debug(
`[AITrace] ${icon} ${trace.method}${fallback}${validation}` + `[AITrace] ${icon} ${trace.method}${fallback}${validation}` +
`${trace.provider}/${trace.model}` + `${trace.provider}/${trace.model}` +
` | ${trace.latencyMs}ms | source:${trace.source}`, ` | ${trace.latencyMs}ms | source:${trace.source}` +
(trace.fallbackReason ? ` | reason:${trace.fallbackReason}` : ''),
trace, trace,
) )
} }
@@ -175,8 +231,12 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
export function provenanceToStatus( export function provenanceToStatus(
fallbackUsed: boolean, fallbackUsed: boolean,
source: AIProvenance['source'], source: AIProvenance['source'],
fallbackReason?: string,
): AITraceValidationStatus { ): AITraceValidationStatus {
if (!fallbackUsed) return 'valid' 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' if (source === 'mock') return 'invalid_schema'
return 'valid' return 'valid'
} }