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 { Property } from '../../domain/property'
import type { Inquiry } from '../../domain/inquiry'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
interface Props {
draft: InquiryPreparationReportDraft
@@ -100,39 +101,39 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
mb: 2,
}}
>
<Box sx={{ borderBottom: '3px solid #1e3a5f', pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}>
<Box sx={{ borderBottom: `3px solid ${DS_TEXT.brand}`, pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_TEXT.brand, fontFamily: 'inherit' }}>
Objektvorschlag
</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')}
</Typography>
</Box>
{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']}
</Typography>
)}
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
<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: DS_TEXT.primary, mb: 1 }}>
Ihre Anfrage
</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}` : ''}
</Typography>
<Typography variant="body2" sx={{ color: '#374151' }}>
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
<strong>Betreff:</strong> {inquiry.subject}
</Typography>
</Box>
{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
</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']}
</Typography>
</>
@@ -145,7 +146,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
label={p.title}
size="small"
icon={<Building2 size={12} />}
sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }}
sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }}
/>
))}
</Box>
@@ -161,7 +162,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
<Box key={property.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 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}
</Typography>
<Divider sx={{ flex: 1 }} />
@@ -184,7 +185,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
bgcolor: DS_BORDER.default,
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
@@ -194,7 +195,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
justifyContent: 'center',
}}
>
{!property.mapImageUrl && <MapPin size={28} color="#94a3b8" />}
{!property.mapImageUrl && <MapPin size={28} color={DS_TEXT.disabled} />}
</Box>
{/* Property photo */}
<Box
@@ -203,7 +204,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
bgcolor: DS_BORDER.default,
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
@@ -212,23 +213,23 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
justifyContent: 'center',
}}
>
{!image && <Building2 size={28} color="#94a3b8" />}
{!image && <Building2 size={28} color={DS_TEXT.disabled} />}
</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}
</Typography>
<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} />
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
</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} />
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</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} />
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
</Box>
@@ -240,7 +241,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 0.75,
bgcolor: '#f8fafc',
bgcolor: DS_BG.page,
borderRadius: 1,
p: 1.5,
}}
@@ -250,10 +251,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
if (!val) return null
return (
<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}
</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}
</Typography>
</Box>
@@ -268,10 +269,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
{editableByField['next_steps'] && (
<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
</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']}
</Typography>
</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 { Kanban } from 'lucide-react'
import { INQUIRY_STATUS_META, DS_COLORS } from '../../lib/ds'
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { Inquiry } from '../../domain/inquiry'
interface AnfragenInquiryItemProps {
@@ -14,28 +14,29 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1]
const unreadColor = cfg?.fg ?? DS_TEXT.danger
return (
<Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
borderBottom: `1px solid ${DS_BORDER.muted}`,
cursor: 'pointer',
bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
borderLeft: isSelected ? '3px solid' : '3px solid transparent',
borderLeftColor: isSelected ? 'primary.main' : 'transparent',
'&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : '#f8fafc' },
'&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : DS_BG.page },
transition: 'background-color 0.1s ease',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: 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>
{inq.tenantName}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{inq.unreadCount > 0 && (
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: 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>
</Box>
)}
@@ -47,7 +48,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
{inq.tenantCompany}
</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}
</Typography>
{lastMsg && (
@@ -60,7 +61,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
<Chip label={cfg?.label ?? inq.status} size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bg, color: cfg?.fg, fontWeight: 600 }} />
{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}%
</Typography>
)}
+10 -10
View File
@@ -1,6 +1,6 @@
import { Box, Typography } from '@mui/material'
import { Bot, Building2, FileText } from 'lucide-react'
import { DS_COLORS } from '../../lib/ds'
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
@@ -12,8 +12,8 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
{!isOwnMessage && isAI && <Bot size={11} color="#7c3aed" />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color="#64748b" />}
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time}
</Typography>
@@ -24,14 +24,14 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
px: 2, py: 1.25,
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
bgcolor: isOwnMessage ? 'primary.main' : isAI ? DS_COLORS.futureCard.controlled.headerBg : 'white',
border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : '1px solid #e2e8f0',
border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : `1px solid ${DS_BORDER.default}`,
boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
}}
>
<Typography
variant="body2"
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',
}}
>
@@ -45,17 +45,17 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.5, py: 0.75,
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : '#f1f5f9',
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : DS_BG.subtle,
borderRadius: 1.5, cursor: 'pointer',
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : '#e2e8f0' },
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : DS_BG.muted },
}}
>
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : '#64748b'} />
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : '#475569', flex: 1 }} noWrap>
<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)' : DS_TEXT.secondary, flex: 1 }} noWrap>
{att.fileName}
</Typography>
{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
? `${Math.round(att.fileSize / 1024)} KB`
: `${(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 { Accordion, AccordionDetails, AccordionSummary, Box, Card, Checkbox, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
interface Props {
criteria: ParsedNeedCriteria
@@ -220,77 +220,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack>
)}
{/* Extended requirements */}
<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>
<NeedExtendedRequirements criteria={c} onChange={set} />
</Card>
)
}
@@ -14,6 +14,7 @@ import type { FutureSignal } from '../../domain/futureSignal'
import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants'
import { CREDIBILITY_META, SENSITIVITY_META, RISK_META, ageLabel } from './futureAvailabilityContextUtils'
import { SignalSourcesSection } from './SignalSourcesSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_PRE_MARKET, DS_MARKET_SIGNAL, DS_BORDER } from '../../lib/ds'
interface Props {
match: Match
@@ -35,81 +36,85 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
...(signal.evidence?.sourceUrls ?? []),
]
const probBarColor = probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b'
const probBarColor = probPct >= 70 ? DS_TEXT.success : probPct >= 50 ? DS_TEXT.warning : DS_TEXT.error
return (
<Paper sx={{ p: 2.5, mb: 2 }}>
{/* ── Header ──────────────────────────────────────────────────────────── */}
{/* ── Header ── */}
<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>
{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 }}>
<ShieldCheck size={11} color="#1a7a4a" />
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
<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={DS_TEXT.success} />
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.success, fontWeight: 700 }}>Verifiziert</Typography>
</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} />
<Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography>
</Box>
</Box>
{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}
</Typography>
)}
{/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */}
{/* ── 1. KI-Zusammenfassung ── */}
{signal.aiSummary && (
<Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
<Sparkles size={14} color="#7c3aed" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95', fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
<Sparkles size={14} color={DS_PRE_MARKET.accent} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.signalDark, fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
</Box>
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1.5, p: 1.75 }}>
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.7 }}>
<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: DS_TEXT.primary, lineHeight: 1.7 }}>
{signal.aiSummary}
</Typography>
</Box>
</Box>
)}
{/* ── 2. Strategische Einschätzung ─────────────────────────────────── */}
{/* ── 2. Strategische Einschätzung ── */}
{signal.strategicInterpretation && (
<Box sx={{ mb: 2.5 }}>
<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>
</Box>
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1.5, p: 1.75 }}>
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.7 }}>
<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: DS_TEXT.infoDark, lineHeight: 1.7 }}>
{signal.strategicInterpretation}
</Typography>
</Box>
</Box>
)}
{/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */}
{/* ── 3. Handlungsempfehlung ── */}
{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 }}>
<Zap size={14} color={action.urgency === 'high' ? '#c2410c' : '#64748b'} style={{ flexShrink: 0, marginTop: 2 }} />
<Box sx={{
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>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? '#9a3412' : '#475569', 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.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' ? DS_TEXT.error : DS_TEXT.primary, lineHeight: 1.5 }}>{action.label}</Typography>
</Box>
</Box>
)}
<Divider sx={{ mb: 2 }} />
{/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */}
{/* ── 4. Signal-Kenndaten ── */}
<Box sx={{ mb: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography>
{/* Probability bar */}
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography>
@@ -119,7 +124,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
variant="determinate"
value={probPct}
sx={{
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
height: 6, borderRadius: 3, bgcolor: DS_BG.subtle,
'& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 },
}}
/>
@@ -129,13 +134,13 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
{signal.signalType && (
<Box>
<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>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
<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>
</Box>
</Box>
@@ -156,22 +161,22 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Divider sx={{ mb: 2 }} />
{/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */}
{/* ── 5. Erkannte Marktindikatoren ── */}
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
<Box sx={{ mb: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{signal.marketIndicators.map((indicator, i) => (
<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 }} />
<Typography variant="body2" sx={{ color: '#334155', lineHeight: 1.55 }}>{indicator}</Typography>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: DS_MARKET_SIGNAL.accent, mt: '6px', flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>{indicator}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */}
{/* ── 6. Transparenz ── */}
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
<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 }}>
{(signal.confirmedFacts ?? []).map((fact, i) => (
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={14} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
<CheckCircle2 size={14} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.success, fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
</Box>
))}
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<AlertTriangle size={14} color="#d97706" style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: '#92400e', lineHeight: 1.45 }}>{fact}</Typography>
<AlertTriangle size={14} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark, lineHeight: 1.45 }}>{fact}</Typography>
</Box>
))}
</Box>
@@ -195,7 +200,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Divider sx={{ mb: 2 }} />
{/* ── 7. Quellen & Belege ───────────────────────────────────────────── */}
{/* ── 7. Quellen & Belege ── */}
<SignalSourcesSection
sourceMeta={sourceMeta}
credMeta={credMeta}
@@ -204,7 +209,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
signal={signal}
/>
{/* ── Footer ──────────────────────────────────────────────────────────── */}
{/* ── Footer ── */}
<Divider sx={{ mb: 1.5 }} />
<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 && (
<>
<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>
<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}
</Typography>
</Paper>
@@ -3,6 +3,7 @@ import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp }
import { useMatchDetail } from '../../hooks/useMatches'
import type { Property } from '../../domain/property'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -30,7 +31,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Preis */}
<Paper sx={{ p: 2.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>
</Box>
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} />
@@ -44,7 +45,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Hauptangaben */}
<Paper sx={{ p: 2.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>
</Box>
<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 && (
<Paper sx={{ p: 2.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>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{property.softFactors.publicTransportMinutes != null && (
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} />
<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 && (
<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 && (
<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 && (
<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 && (
<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 && (
<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>
</Paper>
@@ -96,12 +97,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ p: 2.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>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" />
<Box 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={DS_MARKET_SIGNAL.accent} />
</Box>
<Box>
<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 && (
<Paper sx={{ p: 2.5 }}>
<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>
</Box>
<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 && (
<Paper sx={{ p: 2.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>
</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}
</Typography>
</Paper>
@@ -147,7 +148,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Quelle & Referenz */}
<Paper sx={{ p: 2.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>
</Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} />
@@ -158,7 +159,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
)}
{property.sourceUrl && (
<Box sx={{ mt: 1.25 }}>
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}>
<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
</Button>
</Box>
@@ -26,6 +26,7 @@ import {
SOURCE_LABELS,
UnitRow,
} from './MatchDetailPropertyDetails'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
interface PropertyDetailPublicSectionsProps {
property: Property
@@ -57,7 +58,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Preis ── */}
<Paper sx={{ mb: 2, p: 2.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>
</Box>
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} />
@@ -74,7 +75,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Hauptangaben ── */}
<Paper sx={{ mb: 2, p: 2.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>
</Box>
<KeyFactRow
@@ -127,7 +128,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{property.softFactors && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" />
<TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
@@ -136,49 +137,49 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
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 }}
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 && (
<Chip
size="small"
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 && (
<Chip
size="small"
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 && (
<Chip
size="small"
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 && (
<Chip
size="small"
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 && (
<Chip
size="small"
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 && (
<Chip
size="small"
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>
@@ -189,12 +190,12 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" />
<Clock size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" />
<Box 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={DS_MARKET_SIGNAL.accent} />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
@@ -220,7 +221,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{(property.units ?? []).length > 0 && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" />
<Layers size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
@@ -241,10 +242,10 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" />
<Building2 size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</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}
</Typography>
</Paper>
@@ -253,7 +254,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Quelle & Referenz ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} />
@@ -278,7 +279,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
href={property.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}
>
Zum Originalinserat
</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 function getScoreTier(score: number): ScoreTier {
if (score >= 90) return 'gold'
if (score >= 80) return 'silver'
return 'bronze'
}
export const SCORE_THEME = {
gold: {
gradient: 'linear-gradient(135deg,#f8e642 0%,#d4920e 100%)',
border: '#c9900c',
glow: 'rgba(212,146,14,0.35)',
text: '#7a4f00',
label: 'Top Match',
cardBorder: '#fbbf24',
cardBg: 'linear-gradient(160deg,#fffbeb,#fef3c7)',
},
silver: {
gradient: 'linear-gradient(135deg,#f1f5f9 0%,#cbd5e1 100%)',
border: '#94a3b8',
glow: 'rgba(148,163,184,0.30)',
text: '#334155',
label: 'Starkes Match',
cardBorder: '#cbd5e1',
cardBg: 'linear-gradient(160deg,#f8fafc,#f1f5f9)',
},
bronze: {
gradient: 'linear-gradient(135deg,#fde8c8 0%,#d4956a 100%)',
border: '#c07a46',
glow: 'rgba(192,122,70,0.25)',
text: '#7c3d0c',
label: 'Gutes Match',
cardBorder: '#f5d0a9',
cardBg: 'linear-gradient(160deg,#fdf6f0,#fef3e8)',
},
} as const
export { getScoreTier, SCORE_THEME } from '../../lib/scoreTheme'
export type { ScoreTier } from '../../lib/scoreTheme'
+17 -16
View File
@@ -1,6 +1,7 @@
import { Box, Card, Chip, Stack, Typography } from '@mui/material'
import { MapPin, Ruler, Wallet, Calendar, CheckCircle } from 'lucide-react'
import type { PropertyNeedMatch } from '../../domain/match'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
interface Props {
match: PropertyNeedMatch
@@ -9,9 +10,9 @@ interface Props {
function ScoreBadge({ score }: { score: number }) {
const isGold = score >= 90
const bg = isGold ? '#fef3c7' : '#f1f5f9'
const color = isGold ? '#d97706' : '#64748b'
const borderColor = isGold ? '#fde68a' : '#e2e8f0'
const bg = isGold ? DS_SURFACE.warning.bg : DS_BG.subtle
const color = isGold ? DS_TEXT.warning : DS_TEXT.muted
const borderColor = isGold ? DS_SURFACE.warning.border : DS_BORDER.default
return (
<Box
@@ -37,9 +38,9 @@ function ScoreBadge({ score }: { score: number }) {
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ color: '#94a3b8', display: 'flex', alignItems: 'center' }}>{icon}</Box>
<Typography variant="caption" sx={{ color: '#64748b', minWidth: 70 }}>{label}</Typography>
<Typography variant="caption" sx={{ color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
<Box sx={{ color: DS_TEXT.disabled, display: 'flex', alignItems: 'center' }}>{icon}</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, minWidth: 70 }}>{label}</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.primary, fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
@@ -52,10 +53,10 @@ export function NeedMatchCard({ match, onClick }: Props) {
sx={{
p: 2,
mb: 1.5,
border: '1px solid #e2e8f0',
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 1.5,
cursor: onClick ? 'pointer' : 'default',
'&:hover': onClick ? { borderColor: '#1e3a5f', bgcolor: '#f8fafc', boxShadow: '0 2px 8px rgba(30,58,95,0.08)' } : { borderColor: '#cbd5e1', bgcolor: '#fafafa' },
'&:hover': onClick ? { borderColor: DS_TEXT.brand, bgcolor: DS_BG.page, boxShadow: '0 2px 8px rgba(30,58,95,0.08)' } : { borderColor: DS_BORDER.strong, bgcolor: DS_BG.page },
transition: 'border-color 0.15s, background-color 0.15s, box-shadow 0.15s',
}}
>
@@ -64,7 +65,7 @@ export function NeedMatchCard({ match, onClick }: Props) {
<Box sx={{ flex: 1, minWidth: 0 }}>
<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}
</Typography>
<Chip
@@ -73,10 +74,10 @@ export function NeedMatchCard({ match, onClick }: Props) {
sx={{
fontSize: '0.65rem',
height: 18,
bgcolor: match.score >= 90 ? '#fef3c7' : '#f1f5f9',
color: match.score >= 90 ? '#92400e' : '#475569',
bgcolor: match.score >= 90 ? DS_SURFACE.warning.bg : DS_BG.subtle,
color: match.score >= 90 ? DS_TEXT.warningDark : DS_TEXT.secondary,
border: '1px solid',
borderColor: match.score >= 90 ? '#fde68a' : '#e2e8f0',
borderColor: match.score >= 90 ? DS_SURFACE.warning.border : DS_BORDER.default,
}}
/>
</Box>
@@ -96,8 +97,8 @@ export function NeedMatchCard({ match, onClick }: Props) {
<Box sx={{ mb: 1 }}>
{match.matchHighlights.slice(0, 2).map((h, i) => (
<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 }} />
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.4 }}>{h}</Typography>
<CheckCircle size={11} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{h}</Typography>
</Box>
))}
</Box>
@@ -110,11 +111,11 @@ export function NeedMatchCard({ match, onClick }: Props) {
key={mh}
label={mh}
size="small"
sx={{ fontSize: '0.62rem', height: 18, bgcolor: '#f0fdf4', color: '#166534', border: '1px solid #bbf7d0' }}
sx={{ fontSize: '0.62rem', height: 18, bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.successDark, border: `1px solid ${DS_SURFACE.success.border}` }}
/>
))}
{match.mustHaves.length > 3 && (
<Typography variant="caption" sx={{ color: '#94a3b8', alignSelf: 'center' }}>
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, alignSelf: 'center' }}>
+{match.mustHaves.length - 3}
</Typography>
)}
@@ -5,8 +5,7 @@ import { useNeeds } from '../../hooks/useNeeds'
import { getCityIntelligence, getMarketRent } from '../../lib/locationIntelligence'
import type { Property } from '../../domain/property'
import { generateSellingArguments, generateWeaknesses } from './negotiationInsightsUtils'
// ── Main component ────────────────────────────────────────────────────────────
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
interface Props {
property: Property
@@ -20,7 +19,6 @@ export function NegotiationInsightsPanel({ property }: Props) {
const marketRent = getMarketRent(property.location.city, property.assetType)
const priceDiff = marketRent ? ((property.rentPricePerSqm - marketRent) / marketRent) * 100 : null
// Comparable properties for price positioning
const comparables = allProperties
.filter(p => p.id !== property.id && p.assetType === property.assetType && p.location.city === property.location.city)
@@ -28,7 +26,6 @@ export function NegotiationInsightsPanel({ property }: Props) {
? comparables.reduce((s, p) => s + p.rentPricePerSqm, 0) / comparables.length
: null
// Active needs matching this property type/location
const matchingNeeds = needs.filter(n =>
n.assetType === property.assetType &&
(n.preferredLocations?.some(loc => loc.toLowerCase().includes(property.location.city.toLowerCase())) ?? false)
@@ -39,6 +36,10 @@ export function NegotiationInsightsPanel({ property }: Props) {
const strongArgs = sellingArgs.filter(a => a.strength === 'strong')
const mediumArgs = sellingArgs.filter(a => a.strength === 'medium')
const priceDiffBg = priceDiff !== null
? (Math.abs(priceDiff) < 10 ? DS_SURFACE.success.bg : priceDiff > 0 ? DS_SURFACE.orange.bg : DS_SURFACE.success.bg)
: DS_BG.page
return (
<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>
<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="h6" sx={{ fontWeight: 700, color: '#0f1923' }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
{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="h6" sx={{ fontWeight: 700, color: '#475569' }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.secondary }}>
CHF {marketRent}/m²
</Typography>
</Box>
)}
{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="h6" sx={{ fontWeight: 700, color: '#475569' }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.secondary }}>
CHF {Math.round(avgComparableRent)}/m²
</Typography>
</Box>
@@ -72,9 +73,9 @@ export function NegotiationInsightsPanel({ property }: Props) {
</Box>
{priceDiff !== null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: Math.abs(priceDiff) < 10 ? '#f0fdf4' : priceDiff > 0 ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5 }}>
{priceDiff > 0 ? <TrendingUp size={15} color="#d97706" /> : <TrendingDown size={15} color="#1a7a4a" />}
<Typography variant="body2" sx={{ color: priceDiff > 15 ? '#92400e' : priceDiff > 0 ? '#d97706' : '#1a7a4a', fontWeight: 500 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, bgcolor: priceDiffBg, borderRadius: 1.5 }}>
{priceDiff > 0 ? <TrendingUp size={15} color={DS_TEXT.warning} /> : <TrendingDown size={15} color={DS_TEXT.success} />}
<Typography variant="body2" sx={{ color: priceDiff > 15 ? DS_TEXT.warningDark : priceDiff > 0 ? DS_TEXT.warning : DS_TEXT.success, fontWeight: 500 }}>
{priceDiff > 15
? `Ihr Preis liegt ${Math.round(priceDiff)}% über dem Marktmedian — starke USPs nötig zur Rechtfertigung.`
: priceDiff > 5
@@ -86,7 +87,6 @@ export function NegotiationInsightsPanel({ property }: Props) {
</Box>
)}
{/* Price bar vs market */}
{marketRent && (
<Box sx={{ mt: 1.5 }}>
<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²
</Typography>
</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={{
position: 'absolute',
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,
height: 12,
borderRadius: '50%',
bgcolor: '#1e3a5f',
bgcolor: DS_TEXT.brand,
border: '2px solid white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
@@ -121,11 +121,11 @@ export function NegotiationInsightsPanel({ property }: Props) {
{matchingNeeds.length > 0 ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{matchingNeeds.length}</Typography>
<Typography variant="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>
</Box>
{matchingNeeds.slice(0, 4).map(n => (
<Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid #f1f5f9' }}>
<Box key={n.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: `1px solid ${DS_BORDER.muted}` }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 500 }}>{n.companyName}</Typography>
<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}
size="small"
sx={{
bgcolor: n.status === 'ACTIVE' ? '#f0fdf4' : '#f1f5f9',
color: n.status === 'ACTIVE' ? '#1a7a4a' : '#64748b',
bgcolor: n.status === 'ACTIVE' ? DS_SURFACE.success.bg : DS_BG.subtle,
color: n.status === 'ACTIVE' ? DS_TEXT.success : DS_TEXT.muted,
fontSize: 10, height: 18,
}}
/>
@@ -156,10 +156,10 @@ export function NegotiationInsightsPanel({ property }: Props) {
</Typography>
)}
{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">
Ø 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>
</Box>
)}
@@ -175,12 +175,12 @@ export function NegotiationInsightsPanel({ property }: Props) {
{strongArgs.length > 0 && (
<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
</Typography>
{strongArgs.map((arg, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}>
<CheckCircle size={15} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<CheckCircle size={15} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{arg.title}</Typography>
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
@@ -192,12 +192,12 @@ export function NegotiationInsightsPanel({ property }: Props) {
{mediumArgs.length > 0 && (
<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
</Typography>
{mediumArgs.map((arg, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 0.75 }}>
<CheckCircle size={14} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<CheckCircle size={14} color={DS_TEXT.warning} style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{arg.title}</Typography>
<Typography variant="caption" color="text.secondary">{arg.detail}</Typography>
@@ -218,10 +218,10 @@ export function NegotiationInsightsPanel({ property }: Props) {
</Typography>
{weaknesses.map((w, i) => (
<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}
</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>
</Box>
{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 }}>
{intel.dominantIndustryClusters.map(c => (
<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>
<LinearProgress
variant="determinate"
value={intel.demandStrength === 'VERY_HIGH' ? 95 : intel.demandStrength === 'HIGH' ? 75 : intel.demandStrength === 'MEDIUM' ? 50 : 25}
sx={{ mt: 1.5, height: 6, borderRadius: 3, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
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' }}>
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 { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material'
import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { Box, Chip, CircularProgress, Divider, Switch, Typography } from '@mui/material'
import { Clock, ShieldCheck, Zap } from 'lucide-react'
import type { Property } from '../../domain/property'
import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
import { useUpdateProperty } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { floorLabel, SectionTitle } from './PropertyDetailHelpers'
import { SectionTitle } from './PropertyDetailHelpers'
import { PreMarketDemandIntelligence } from './PreMarketDemandIntelligence'
import { PreMarketUnitGrid } from './PreMarketUnitGrid'
export const MOCK_TODAY = new Date('2026-05-20')
@@ -24,6 +27,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
return init
})
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
const queryClient = useQueryClient()
const updateProperty = useUpdateProperty()
const showToast = useToastStore(s => s.showToast)
const saving = updateProperty.isPending
@@ -48,7 +52,6 @@ export function PreMarketPanel({ p }: { p: Property }) {
? Math.max(0, Math.round((targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30)))
: null
// Mock demand intelligence (derived deterministically from property characteristics)
const demandProfiles = Math.min(14, (p.areaSqm >= 1000 ? 5 : p.areaSqm >= 500 ? 8 : 4) +
(['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1))
const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38))
@@ -133,7 +136,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
</Box>
</Box>
{/* Active: lead time + status + demand intelligence */}
{/* Active: lead time + status + unit grid + demand intelligence */}
{enabled && (
<Box sx={{ mt: 1.5 }}>
{/* Lead time selector */}
@@ -182,94 +185,20 @@ export function PreMarketPanel({ p }: { p: Property }) {
</Typography>
</Box>
{/* Unit-level release controls */}
{(p.units?.length ?? 0) > 0 && (
<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>
{p.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)
}}
<PreMarketUnitGrid
units={p.units!}
unitStates={unitStates}
unitSaving={unitSaving}
setUnitStates={setUnitStates}
saveUnit={saveUnit}
/>
<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 */}
<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>
<PreMarketDemandIntelligence
demandProfiles={demandProfiles}
highQualityLeads={highQualityLeads}
/>
</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 type { CreateNeedInput } from '../domain/need'
export function useNeeds() {
interface UseNeedsOptions {
refetchOnMount?: boolean | 'always'
gcTime?: number
}
export function useNeeds(options?: UseNeedsOptions) {
return useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
select: (res) => res.data ?? [],
...options,
})
}
+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',
signalDark: '#4c1d95',
infoDark: '#0c4a6e',
brandDark: '#162d4a',
} as const
// ── 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 {
Alert,
Box,
Button,
CircularProgress,
Divider,
Typography,
} from '@mui/material'
import { ArrowRight, Bookmark, Save, Search } from 'lucide-react'
import { Box, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import {
@@ -15,9 +7,10 @@ import {
NeedInput,
VoiceNeedInput,
WeightingEditor,
NeedCardPreview,
NeedBuilderErrorState,
} from '../../components/demand'
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
import { useParseNeed } from '../../hooks/useAI'
import { useCreateNeed } from '../../hooks/useNeeds'
import { useDefaultWeights } from '../../hooks/useWeighting'
@@ -25,12 +18,8 @@ import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
// ── Action intent ─────────────────────────────────────────────────────────────
type ActionIntent = 'search' | 'save-profile'
// ── Page ──────────────────────────────────────────────────────────────────────
export default function AISearch() {
const navigate = useNavigate()
const queryClient = useQueryClient()
@@ -90,7 +79,6 @@ export default function AISearch() {
})
}
// Resolve criteria (parse text if needed), then either search or show save preview
function handleAction(chosenIntent: ActionIntent) {
setIntent(chosenIntent)
setError(null)
@@ -130,7 +118,6 @@ export default function AISearch() {
resolvedResult: ParseNeedResult | null,
) {
if (chosenIntent === 'search') {
// Save as DRAFT and navigate immediately
setStep(NeedBuilderStep.SAVING)
const conf = resolvedResult
? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) /
@@ -150,7 +137,6 @@ export default function AISearch() {
return
}
// save-profile: show preview step
const confidenceByField: Record<string, number> = resolvedResult?.confidenceByField ?? {}
if (!resolvedResult) {
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 }}>
{/* ── IDLE: full form ── */}
{/* IDLE: full form */}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
@@ -240,7 +225,6 @@ export default function AISearch() {
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
@@ -250,108 +234,32 @@ export default function AISearch() {
assetType={criteria.assetType}
/>
</Box>
{/* Action bar */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
variant="contained"
size="large"
disabled={!canProceed || isProcessing}
onClick={() => handleAction('search')}
endIcon={
isProcessing && intent === 'search'
? <CircularProgress size={18} color="inherit" />
: <Search size={18} />
}
sx={{
flex: 1,
py: 1.5,
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
textTransform: 'none',
}}
>
{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>
<AISearchActionBar
canProceed={canProceed}
isSearching={isProcessing && intent === 'search'}
isSavingProfile={isProcessing && intent === 'save-profile'}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
</Box>
)}
{/* ── Preview + Save as Profile ── */}
{/* Save preview step */}
{isSaveStep && parseResult && editedCriteria && (
<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
<AISearchSavePreview
criteria={editedCriteria}
weights={weights}
confidenceByField={parseResult.confidenceByField}
missingFields={parseResult.missingFields}
parseResult={parseResult}
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
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 && (
<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 { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
import { INQUIRY_STATUS_META, DS_COLORS } from '../../lib/ds'
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
// ── Config ────────────────────────────────────────────────────────────────────
@@ -130,13 +130,13 @@ export default function Anfragen() {
minWidth: { md: 320 },
flexShrink: 0,
display: 'flex', flexDirection: 'column',
borderRight: '1px solid #e2e8f0',
borderRight: `1px solid ${DS_BORDER.default}`,
overflow: 'hidden',
bgcolor: 'white',
transition: 'width 0.2s ease',
}}
>
<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 }}>
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1rem' }}>Anfragen</Typography>
{totalUnread > 0 && (
@@ -147,7 +147,7 @@ export default function Anfragen() {
<TextField
size="small" placeholder="Suchen..." fullWidth
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 }}
/>
<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)}
sx={{
height: 22, fontSize: '0.7rem', cursor: 'pointer',
bgcolor: statusFilter === tab.key ? 'primary.main' : '#f1f5f9',
color: statusFilter === tab.key ? 'white' : '#475569',
bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle,
color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary,
fontWeight: statusFilter === tab.key ? 700 : 400,
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : '#e2e8f0' },
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted },
}}
/>
))}
@@ -188,7 +188,7 @@ export default function Anfragen() {
display: { xs: mobileShowChat ? 'flex' : 'none', md: 'flex' },
flexDirection: 'column',
overflow: 'hidden',
bgcolor: '#f8fafc',
bgcolor: DS_BG.page,
minWidth: 0,
}}>
{!selected ? (
@@ -199,7 +199,7 @@ export default function Anfragen() {
) : (
<>
{/* Chat header */}
<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 }}>
<IconButton size="small" sx={{ display: { md: 'none' }, mr: -0.5 }} onClick={() => setMobileShowChat(false)}>
<ArrowLeft size={16} />
@@ -219,10 +219,10 @@ export default function Anfragen() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{selected.matchScore && (
<Chip label={`${selected.matchScore}%`} size="small" sx={{
bgcolor: selected.matchScore >= 80 ? '#f0fdf4' : '#fffbeb',
color: selected.matchScore >= 80 ? '#1a7a4a' : '#d97706',
bgcolor: selected.matchScore >= 80 ? DS_SURFACE.success.bg : DS_SURFACE.warning.bg,
color: selected.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning,
fontWeight: 700, height: 22, fontSize: '0.75rem',
border: `1px solid ${selected.matchScore >= 80 ? '#86efac' : '#fde68a'}`,
border: `1px solid ${selected.matchScore >= 80 ? DS_SURFACE.success.border : DS_SURFACE.warning.border}`,
}} />
)}
<Chip
@@ -253,7 +253,7 @@ export default function Anfragen() {
{/* Property reference */}
{(linkedPipelineItem?.propertyAddress ?? selected.subject) && (
<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">
{linkedPipelineItem?.propertyAddress ?? selected.subject}
</Typography>
@@ -286,7 +286,7 @@ export default function Anfragen() {
</Box>
{/* 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' }}>
<TextField
multiline minRows={2} maxRows={6} fullWidth size="small"
@@ -297,9 +297,9 @@ export default function Anfragen() {
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
<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()}
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} />
</IconButton>
</Box>
+3 -7
View File
@@ -1,9 +1,9 @@
import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Typography } from '@mui/material'
import { useNavigate, useLocation } from 'react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQueryClient } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
import { needService } from '../../services/needService'
import { useNeeds } from '../../hooks/useNeeds'
import { DecisionContextPanel } from '../../components/ui'
import {
FeedEmptyState,
@@ -47,15 +47,11 @@ export default function Results() {
// When coming from NeedBuilder, invalidate so the freshly created need is included
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
const { data: needResp } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
const { data: allNeeds = [] } = useNeeds({
refetchOnMount: activeNeedIdFromNav ? 'always' : true,
gcTime: 0,
})
const allNeeds = needResp?.data ?? []
// Nav ID (from NeedBuilder) takes priority; otherwise first named need
const effectiveNeedId = activeNeedIdFromNav ?? allNeeds.find(n => n.companyName !== 'Neue Suche')?.id
+26 -25
View File
@@ -20,6 +20,7 @@ import {
} from 'lucide-react'
import { useNavigate } from 'react-router'
import { useMarketLeads } from '../../hooks/useMarketLeads'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
import { DecisionContextPanel } from '../../components/ui'
import type { MarketLead } from '../../hooks/useMarketLeads'
import type { Property } from '../../domain/property'
@@ -34,9 +35,9 @@ const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
}
const QUALITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Signalqualität', color: '#1a7a4a' },
MEDIUM: { label: 'Mittlere Signalqualität', color: '#d97706' },
LOW: { label: 'Niedrige Signalqualität', color: '#c0392b' },
HIGH: { label: 'Hohe Signalqualität', color: DS_TEXT.success },
MEDIUM: { label: 'Mittlere Signalqualität', color: DS_TEXT.warning },
LOW: { label: 'Niedrige Signalqualität', color: DS_TEXT.error },
}
function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' {
@@ -53,15 +54,15 @@ function PropertyMatchRow({ p }: { p: Property }) {
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.25, py: 0.75, borderRadius: 1,
bgcolor: '#f0fdf4', border: '1px solid #bbf7d0',
cursor: 'pointer', '&:hover': { bgcolor: '#dcfce7' },
bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`,
cursor: 'pointer', '&:hover': { bgcolor: DS_SURFACE.success.bg },
}}
>
<Building2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Building2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.title}
</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²
</Typography>
</Box>
@@ -81,7 +82,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
<Paper
sx={{
p: 2.5, mb: 2,
borderLeft: '3px solid #1d4ed8',
borderLeft: `3px solid ${DS_MARKET_SIGNAL.accent}`,
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', gap: 1.25 }}>
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
<Target size={18} color="#1d4ed8" />
<Target size={18} color={DS_MARKET_SIGNAL.accent} />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.25, mb: 0.25 }}>
@@ -103,7 +104,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
<Chip
label="Market Signal"
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>
@@ -143,14 +144,14 @@ function LeadCard({ lead }: { lead: MarketLead }) {
{/* Market indicators */}
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
<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
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{signal.marketIndicators.map((ind, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<TrendingUp size={11} color="#1d4ed8" style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: '#374151', lineHeight: 1.4 }}>{ind}</Typography>
<TrendingUp size={11} color={DS_MARKET_SIGNAL.accent} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{ind}</Typography>
</Box>
))}
</Box>
@@ -163,14 +164,14 @@ function LeadCard({ lead }: { lead: MarketLead }) {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{(signal.confirmedFacts ?? []).map((f, i) => (
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 500 }}>{f}</Typography>
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.success, fontWeight: 500 }}>{f}</Typography>
</Box>
))}
{(signal.unconfirmedFacts ?? []).map((f, i) => (
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<AlertCircle size={12} color="#d97706" style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#d97706', fontWeight: 500 }}>{f}</Typography>
<AlertCircle size={12} color={DS_TEXT.warning} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.warning, fontWeight: 500 }}>{f}</Typography>
</Box>
))}
</Box>
@@ -181,7 +182,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
{/* Portfolio matches */}
<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})
</Typography>
{matchingProperties.length === 0 ? (
@@ -203,7 +204,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
</Box>
{/* 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}
</Typography>
</Paper>
@@ -214,7 +215,7 @@ function LeadCard({ lead }: { lead: MarketLead }) {
function LeadSkeleton() {
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="80%" height={18} sx={{ 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' }}>
{/* 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 }}>
<Users size={20} color="#1d4ed8" />
<Users size={20} color={DS_MARKET_SIGNAL.accent} />
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Markt-Leads</Typography>
<Typography variant="caption" color="text.secondary">
@@ -247,7 +248,7 @@ export default function MarketLeads() {
<Chip
label={`${leads.length} aktive Leads`}
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>
@@ -282,7 +283,7 @@ export default function MarketLeads() {
</>
) : leads.length === 0 ? (
<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="caption" color="text.disabled">Signale werden laufend aus öffentlichen Quellen erkannt.</Typography>
</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 { useNewListingForm } from '../../hooks/useNewListingForm'
import {
Alert,
Box,
Button,
Card,
Chip,
CircularProgress,
Divider,
IconButton,
MenuItem,
TextField,
Tooltip,
Typography,
} from '@mui/material'
import { ArrowLeft, CheckCircle, ImagePlus, Sparkles, X } from 'lucide-react'
import { useCreateProperty } from '../../hooks/useProperties'
import { useParseListingText } from '../../hooks/useAI'
import {
ASSET_TYPE_LABELS,
SOFT_FACTORS,
LEVEL_OPTIONS,
FIT_OUT_OPTIONS,
type LocationState,
} from './newListingConstants'
import { buildCreatePropertyInput } from './newListingMapper'
AiAssistCard,
AreaDetailsSection,
AddressSection,
SoftFactorsSection,
TechnicalDetailsSection,
ImageUrlSection,
ContactSection,
CreatedScreen,
} from '../../components/new-listing'
import { DS_TEXT } from '../../lib/ds'
import type { LocationState } from './newListingConstants'
export default function NewListing() {
const navigate = useNavigate()
const { state } = useLocation() as { state: LocationState | null }
const pre = state?.prefill ?? {}
const createProperty = useCreateProperty()
const parseListingMutation = useParseListingText()
const form = useNewListingForm(pre)
// Core fields
const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE')
const [street, setStreet] = useState(pre.street ?? '')
const [houseNumber, setHouseNumber] = useState(pre.houseNumber ?? '')
const [postalCode, setPostalCode] = useState(pre.postalCode ?? '')
const [city, setCity] = useState(pre.city ?? '')
const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '')
const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '')
const [availableFrom, setAvailableFrom] = useState('')
const [description, setDescription] = useState('')
const [contactName, setContactName] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
// Soft factors
const [softLevels, setSoftLevels] = useState<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) {
if (form.created) {
return (
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
<CheckCircle size={48} color="#16a34a" 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={() => navigate('/supply/my-listings')}>
Meine Inserate
</Button>
<Button variant="contained" sx={{ bgcolor: '#1e3a5f' }} onClick={resetForm}>
Weiteres Inserat
</Button>
</Box>
</Box>
<CreatedScreen
onViewListings={() => navigate('/supply/my-listings')}
onCreateAnother={form.resetForm}
/>
)
}
return (
<Box sx={{ maxWidth: 760, mx: 'auto', px: 3, py: 4 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<Button
variant="text"
size="small"
startIcon={<ArrowLeft size={16} />}
onClick={() => navigate(-1)}
sx={{ color: '#64748b', textTransform: 'none', px: 0 }}
sx={{ color: DS_TEXT.muted, textTransform: 'none', px: 0 }}
>
Zurück
</Button>
@@ -193,246 +47,73 @@ export default function NewListing() {
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Neues Inserat erstellen</Typography>
<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.`
: 'Fläche direkt inserieren — ohne vollständiges Objekt im Portfolio.'}
</Typography>
{/* AI Hilfe */}
<Card sx={{ p: 3, mb: 3, border: '1px solid #e0e7ff', bgcolor: '#f5f3ff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Sparkles size={16} color="#7c3aed" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed' }}>
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={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
<AiAssistCard
text={form.aiText}
onTextChange={form.setAiText}
onParse={form.handleAiParse}
parsing={form.aiParsing}
applied={form.aiApplied}
/>
<TextField
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
onChange={e => setRentPerSqm(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth
<AreaDetailsSection
assetType={form.assetType} onAssetTypeChange={form.setAssetType}
areaSqm={form.areaSqm} onAreaSqmChange={form.setAreaSqm}
rentPerSqm={form.rentPerSqm} onRentPerSqmChange={form.setRentPerSqm}
availableFrom={form.availableFrom} onAvailableFromChange={form.setAvailableFrom}
description={form.description} onDescriptionChange={form.setDescription}
/>
<TextField
label="Verfügbar ab" value={availableFrom}
onChange={e => setAvailableFrom(e.target.value)}
size="small" type="date"
slotProps={{ inputLabel: { shrink: true } }} fullWidth
<AddressSection
street={form.street} onStreetChange={form.setStreet}
houseNumber={form.houseNumber} onHouseNumberChange={form.setHouseNumber}
postalCode={form.postalCode} onPostalCodeChange={form.setPostalCode}
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 */}
<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>
<SoftFactorsSection softLevels={form.softLevels} onChange={form.setSoftLevel} />
{/* Lage & Ausstrahlung */}
<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 => 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>
<TechnicalDetailsSection
floor={form.floor} onFloorChange={form.setFloor}
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
/>
{/* Technische Details */}
<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 => setFloor(e.target.value)}
size="small" type="number" fullWidth
placeholder="0 = EG"
<ImageUrlSection
images={form.images}
imageInput={form.imageInput}
onImageInputChange={form.setImageInput}
onAdd={form.addImage}
onRemove={form.removeImage}
/>
<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 */}
<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 => setImageInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addImage() } }}
size="small"
fullWidth
placeholder="https://…"
<ContactSection
name={form.contactName} onNameChange={form.setContactName}
email={form.contactEmail} onEmailChange={form.setContactEmail}
phone={form.contactPhone} onPhoneChange={form.setContactPhone}
/>
<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 */}
<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>}
{form.error && <Alert severity="error" sx={{ mb: 2 }}>{form.error}</Alert>}
<Divider sx={{ mb: 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
</Button>
<Button
variant="contained"
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
onClick={handleSubmit}
disabled={submitting}
startIcon={submitting ? <CircularProgress size={14} color="inherit" /> : null}
sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_TEXT.brandDark } }}
onClick={form.handleSubmit}
disabled={form.submitting}
startIcon={form.submitting ? <CircularProgress size={14} color="inherit" /> : null}
>
{submitting ? 'Wird erstellt…' : 'Inserat veröffentlichen'}
{form.submitting ? 'Wird erstellt…' : 'Inserat veröffentlichen'}
</Button>
</Box>
</Box>
+2
View File
@@ -51,3 +51,5 @@ export interface LocationState {
propertyId?: string
}
}
export type Prefill = NonNullable<LocationState['prefill']>
+10
View File
@@ -14,12 +14,20 @@ export interface AIProvenance {
generatedAt: string
/** Prompt version string used to generate this response */
promptVersion: string
/** Zod schema version used for validation */
schemaVersion: string
/** Whether the response is AI-only, mock-only, or a hybrid merge */
source: 'ai' | 'mock' | 'hybrid'
/** True when the original AI call failed and mock was substituted */
fallbackUsed: boolean
/** Human-readable reason why a fallback occurred — undefined when no fallback */
fallbackReason?: string
/** True when the AI response passed Zod schema validation */
validationPassed: boolean
/** Unique request ID — correlates AIResponse with AITrace.id */
traceId: string
/** Wall-clock latency for this call in milliseconds */
latencyMs?: number
}
/**
@@ -40,9 +48,11 @@ export function mockProvenance(overrides?: Partial<AIProvenance>): AIProvenance
model: 'mock',
generatedAt: new Date().toISOString(),
promptVersion: 'mock',
schemaVersion: 'mock',
source: 'mock',
fallbackUsed: false,
validationPassed: true,
traceId: crypto.randomUUID(),
...overrides,
}
}
+3 -3
View File
@@ -301,7 +301,7 @@ describe('OfferEmailResponseSchema', () => {
expect(result.success).toBe(false)
})
it('rejects body shorter than 10 characters', () => {
it('rejects body shorter than 50 characters', () => {
const result = OfferEmailResponseSchema.safeParse({ subject: 'Angebot', body: 'Kurz.' })
expect(result.success).toBe(false)
})
@@ -313,11 +313,11 @@ describe('validateAIResponse helper', () => {
it('returns parsed data when schema passes', () => {
const result = validateAIResponse(
OfferEmailResponseSchema,
{ subject: 'Test', body: 'Long enough body text here.' },
{ subject: 'Angebot Büroflächen', body: 'This body is definitely long enough to pass the fifty character minimum threshold.' },
'test',
)
expect(result).not.toBeNull()
expect(result?.subject).toBe('Test')
expect(result?.subject).toBe('Angebot Büroflächen')
})
it('returns null when schema fails (does not throw)', () => {
+55 -18
View File
@@ -59,7 +59,7 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
assetType: {
questionText: 'Welchen Nutzungstyp suchen Sie?',
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',
},
areaRange: {
@@ -74,7 +74,7 @@ const FOLLOW_UP_TEMPLATES: Partial<Record<keyof ParsedNeedCriteria, QuestionTemp
importance: 'required',
},
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',
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[] {
const missing: Array<keyof ParsedNeedCriteria> = []
const questions: FollowUpQuestion[] = []
let idx = 0
if (!criteria.assetType) missing.push('assetType')
if (!criteria.areaRange) missing.push('areaRange')
if (!criteria.preferredLocations?.length) missing.push('preferredLocations')
if (!criteria.budgetRange) missing.push('budgetRange')
if (!criteria.timing) missing.push('timing')
if (!criteria.mustHaveCriteria?.length) missing.push('mustHaveCriteria')
for (const field of FIELD_PRIORITY) {
if (questions.length >= 3) break
return missing
.slice(0, 3)
.map((field, i) => {
if (field === 'areaRange') {
if (!criteria.areaRange) {
const tpl = FOLLOW_UP_TEMPLATES['areaRange']!
questions.push({ id: `fq-mock-${idx++}`, questionText: tpl.questionText, targetField: 'areaRange', reason: tpl.reason, importance: tpl.importance })
} else if (isAreaAmbiguous(criteria.areaRange)) {
questions.push({ id: `fq-mock-${idx++}`, questionText: AREA_AMBIGUITY_QUESTION.questionText, targetField: 'areaRange', reason: AREA_AMBIGUITY_QUESTION.reason, importance: AREA_AMBIGUITY_QUESTION.importance })
}
continue
}
const isMissing =
field === 'preferredLocations' ? !criteria.preferredLocations?.length
: field === 'mustHaveCriteria' ? !criteria.mustHaveCriteria?.length
: !criteria[field]
if (isMissing) {
const tpl = FOLLOW_UP_TEMPLATES[field]
if (!tpl) return null
const q: FollowUpQuestion = {
id: `fq-mock-${i}`,
if (!tpl) continue
questions.push({
id: `fq-mock-${idx++}`,
questionText: tpl.questionText,
targetField: field,
reason: tpl.reason,
importance: tpl.importance,
suggestedAnswerOptions: tpl.suggestedAnswerOptions,
}
return q
})
.filter((q): q is FollowUpQuestion => q !== null)
}
}
return questions
}
// ── Service ───────────────────────────────────────────────────────────────────
@@ -87,15 +87,19 @@ function makeProvenance(
source: AIProvenance['source'],
fallbackUsed: boolean,
validationPassed: boolean,
extras: { fallbackReason?: string } = {},
): AIProvenance {
return {
provider: 'openrouter',
model: config.model,
generatedAt: new Date().toISOString(),
promptVersion: PROMPT_VERSION,
schemaVersion: SCHEMA_VERSION,
source,
fallbackUsed,
validationPassed,
traceId: crypto.randomUUID(),
fallbackReason: extras.fallbackReason,
}
}
@@ -186,34 +190,51 @@ async function withFallback<T>(
): Promise<AIResponse<T>> {
const config = getConfig()
const startMs = Date.now()
const callId = crypto.randomUUID()
if (!config) {
console.warn(`[OpenRouterAIService] ${label}: no API key — using MockAIService`)
const result = await fallback()
const latencyMs = Date.now() - startMs
const provenance: AIProvenance = {
...result.provenance,
fallbackUsed: true,
traceId: callId,
fallbackReason: 'no_api_key',
schemaVersion: SCHEMA_VERSION,
latencyMs,
}
aiTraceStore.add({
id: crypto.randomUUID(),
id: callId,
method: label,
provider: 'openrouter',
model: DEFAULT_MODEL,
promptVersion: PROMPT_VERSION,
latencyMs: Date.now() - startMs,
latencyMs,
fallbackUsed: true,
validationPassed: false,
responseValidationStatus: 'fallback',
errorType: 'no_api_key',
fallbackReason: 'no_api_key',
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
return { ...result, provenance }
}
try {
const result = await fn(config)
const latencyMs = Date.now() - startMs
const prov = result.provenance
const provenance: AIProvenance = {
...prov,
traceId: callId,
latencyMs,
schemaVersion: SCHEMA_VERSION,
}
aiTraceStore.add({
id: crypto.randomUUID(),
id: callId,
method: label,
provider: prov.provider,
model: prov.model,
@@ -221,12 +242,13 @@ async function withFallback<T>(
latencyMs,
fallbackUsed: prov.fallbackUsed,
validationPassed: prov.validationPassed,
responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source),
responseValidationStatus: provenanceToStatus(prov.fallbackUsed, prov.source, prov.fallbackReason),
fallbackReason: prov.fallbackReason,
source: prov.source,
createdAt: prov.generatedAt,
inputSizeChars,
})
return result
return { ...result, provenance }
} catch (err) {
console.error(`[OpenRouterAIService] ${label} failed:`, err)
const result = await fallback()
@@ -241,8 +263,17 @@ async function withFallback<T>(
err instanceof AppError && err.code === ServiceErrorCode.AI_GENERATION_FAILED
? 'api_error'
: 'network_error'
const fallbackReason = `${errorType}: ${err instanceof Error ? err.message.slice(0, 100) : 'unknown error'}`
const provenance: AIProvenance = {
...result.provenance,
fallbackUsed: true,
traceId: callId,
fallbackReason,
schemaVersion: SCHEMA_VERSION,
latencyMs,
}
aiTraceStore.add({
id: crypto.randomUUID(),
id: callId,
method: label,
provider: 'openrouter',
model: config.model,
@@ -252,11 +283,12 @@ async function withFallback<T>(
validationPassed: false,
responseValidationStatus,
errorType,
fallbackReason,
source: 'mock',
createdAt: new Date().toISOString(),
inputSizeChars,
})
return { ...result, provenance: { ...result.provenance, fallbackUsed: true } }
return { ...result, provenance }
}
}
@@ -275,7 +307,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] parseNeed: invalid response — using mock fallback')
const fb = await MockAIService.parseNeed(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const extractedCriteria: ParsedNeedCriteria = {
@@ -321,9 +353,14 @@ export const OpenRouterAIService: IAIService = {
// ── generateFollowUpQuestions ───────────────────────────────────────────────
generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<AIResponse<FollowUpQuestion[]>> {
return withFallback('generateFollowUpQuestions', async (config) => {
const missingFields = Object.entries(criteria)
.filter(([, v]) => v == null)
.map(([k]) => k)
const missingFields = [
...(!criteria.assetType ? ['assetType'] : []),
...(!criteria.areaRange || (criteria.areaRange.min <= 0 && criteria.areaRange.max <= 0) ? ['areaRange'] : []),
...(!criteria.preferredLocations?.length ? ['preferredLocations'] : []),
...(!criteria.budgetRange ? ['budgetRange'] : []),
...(!criteria.timing ? ['timing'] : []),
...(!criteria.mustHaveCriteria?.length ? ['mustHaveCriteria'] : []),
]
const { system, user } = buildFollowUpQuestionsPrompt({ criteria, missingFields })
const raw = await chat(config, system, user)
const json = extractJSON<unknown[]>(raw)
@@ -332,7 +369,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUpQuestions: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUpQuestions(criteria)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map((q, i) => ({
@@ -359,7 +396,7 @@ export const OpenRouterAIService: IAIService = {
if (!summary) {
console.warn('[OpenRouterAIService] generateMatchExplanation: empty response — using mock fallback')
const fb = await MockAIService.generateMatchExplanation(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: 'empty_response' }) }
}
const scoreLabel = input.matchScore >= 78 ? 'Starkes' : input.matchScore >= 52 ? 'Gutes' : 'Schwaches'
return {
@@ -387,7 +424,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] summarizeTradeOffs: invalid response — using mock fallback')
const fb = await MockAIService.summarizeTradeOffs(tradeoffs)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
@@ -430,7 +467,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] summarizeComparison: invalid response — using mock fallback')
const fb = await MockAIService.summarizeComparison(items)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const mock = await MockAIService.summarizeComparison(items)
return {
@@ -456,7 +493,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] generateDecisionBrief: invalid response — using mock fallback')
const fb = await MockAIService.generateDecisionBrief(shortlistId)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
const mock = await MockAIService.generateDecisionBrief(shortlistId)
return {
@@ -481,7 +518,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] generateDataQualitySummary: invalid response — using mock fallback')
const fb = await MockAIService.generateDataQualitySummary(propertyId, quality)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
@@ -508,7 +545,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] classifyMarketSignal: invalid response — using mock fallback')
const fb = await MockAIService.classifyMarketSignal(signalText)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
@@ -539,7 +576,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] generateOfferEmail: invalid response — using mock fallback')
const fb = await MockAIService.generateOfferEmail(payload)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: { subject: ai.subject, body: ai.body },
@@ -559,7 +596,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai) {
console.warn('[OpenRouterAIService] extractCriteria: invalid response — using mock fallback')
const fb = await MockAIService.extractCriteria(input)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: {
@@ -602,7 +639,7 @@ export const OpenRouterAIService: IAIService = {
if (!ai?.length) {
console.warn('[OpenRouterAIService] generateFollowUp: invalid response — using mock fallback')
const fb = await MockAIService.generateFollowUp(partialNeed)
return { ...fb, provenance: makeProvenance(config, 'mock', true, false) }
return { ...fb, provenance: makeProvenance(config, 'mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return {
data: ai.map(q => q.questionText).filter(Boolean),
@@ -24,8 +24,12 @@ PRIORITÄTSREIHENFOLGE:
5. timing (recommended) wichtig für Verfügbarkeitsabgleich
6. mustHaveCriteria (optional) Pflichtmerkmale (Parkplätze, Laderampe etc.)
MEHRDEUTIGKEITSERKENNUNG prüfe auch bekannte Felder auf Ambiguität:
- areaRange vorhanden, aber max/min-Verhältnis > 5: generiere eine Präzisierungsfrage (targetField: "areaRange", importance: "required") statt sie als vollständig zu behandeln
- areaRange vorhanden, aber min = 0 oder max = 0: generiere dieselbe Präzisierungsfrage
VERBOTE NIEMALS:
- Fragen zu bereits bekannten Kriterien stellen
- Fragen zu bereits bekannten, eindeutigen Kriterien stellen
- Mehr als 3 Fragen ausgeben
- Fragen erfinden, die nicht einem der 6 definierten Zielfelder entsprechen
- Doppelfragen stellen
@@ -54,7 +58,7 @@ Ausgabe:
"importance": "required"
},
{
"questionText": "Was ist Ihr maximales Budget pro m² und Monat (CHF)?",
"questionText": "Was ist Ihr maximales Budget pro m² und Jahr (CHF)?",
"targetField": "budgetRange",
"reason": "Budget ist wichtig für die Filterung unpassender Objekte",
"suggestedAnswerOptions": [],
@@ -34,6 +34,7 @@ WEITERE VERBOTE:
- Fläche in m² schätzen, wenn kein konkreter Hinweis im Text steht ( null setzen)
- Zeithorizont nennen, wenn er nicht aus dem Text ableitbar ist ( null setzen)
- probability > 0.85 setzen ohne mehrere unabhängige, verlässliche Bestätigungen
- probability mit mehr als 2 Dezimalstellen angeben (z.B. 0.724 0.72, 0.6666 0.67)
AUSGABEFORMAT antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{
+11 -5
View File
@@ -20,10 +20,10 @@ VERBOTE — NIEMALS:
AUSGABEFORMAT antworte ausschliesslich als valides JSON (kein Markdown-Block, keine Erklärungen):
{
"assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "GASTRO" | "MIXED" | "UNKNOWN" | null,
"areaRange": { "min": number, "max": number } | null,
"assetType": "OFFICE" | "RETAIL" | "LOGISTICS" | "PRODUCTION" | "LIGHT_INDUSTRIAL" | "GASTRO" | "MIXED" | "UNKNOWN" | null,
"areaRange": { "min": number (1), "max": number (1, min) } | null,
"preferredLocations": string[],
"budgetRange": { "maxPerSqm": number, "currency": "CHF" } | null,
"budgetRange": { "maxPerSqm": number (CHF/m²/Jahr), "currency": "CHF" } | null,
"timing": {
"earliestMoveIn": "YYYY-MM-DD" | null,
"latestMoveIn": "YYYY-MM-DD" | null,
@@ -34,14 +34,20 @@ AUSGABEFORMAT — antworte ausschliesslich als valides JSON (kein Markdown-Block
"assumptions": string[]
}
FELDREGELN:
- areaRange.min und areaRange.max müssen beide 1 sein nie 0 setzen
- budgetRange.maxPerSqm ist CHF pro m² pro Jahr (Jahresmiete) nicht Monatsmiete
- LIGHT_INDUSTRIAL: Leichtindustrielle Nutzung (Werkstatt, Atelier, kleine Produktion), klar abgegrenzt von LOGISTICS
- Maximale Einträge: preferredLocations max. 20, mustHaveCriteria max. 10
BEISPIEL:
Eingabe: "Wir suchen ein Büro für ca. 20 Personen in Zürich, Budget rund 50 CHF/m², Einzug ab März 2026"
Eingabe: "Wir suchen ein Büro für ca. 20 Personen in Zürich, Budget rund 600 CHF/m²/Jahr, Einzug ab März 2026"
Ausgabe:
{
"assetType": "OFFICE",
"areaRange": { "min": 200, "max": 400 },
"preferredLocations": ["Zürich"],
"budgetRange": { "maxPerSqm": 50, "currency": "CHF" },
"budgetRange": { "maxPerSqm": 600, "currency": "CHF" },
"timing": { "earliestMoveIn": "2026-03-01", "latestMoveIn": null, "flexibleTiming": false },
"mustHaveCriteria": [],
"missingFields": ["timing.latestMoveIn", "mustHaveCriteria"],
+93 -51
View File
@@ -4,6 +4,10 @@
* Every OpenRouter response is validated against its schema before reaching
* the UI. Validation failures trigger an explicit fallback to MockAIService
* no invalid data ever passes through silently.
*
* All schemas use .strict() any unknown key from the AI response triggers
* immediate validation failure and fallback, preventing hallucinated fields
* from reaching the UI.
*/
import { z } from 'zod'
@@ -20,19 +24,28 @@ export const ScoreSchema = z.number().min(0).max(100)
// ── 1. Need Parsing ───────────────────────────────────────────────────────────
export const NeedParsingResponseSchema = z.object({
export const NeedParsingResponseSchema = z
.object({
assetType: z
.enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'GASTRO', 'MIXED', 'UNKNOWN'])
.enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'LIGHT_INDUSTRIAL', 'GASTRO', 'MIXED', 'UNKNOWN'])
.optional()
.nullable(),
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()
.nullable()
.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
.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()
.nullable(),
timing: z
@@ -41,12 +54,14 @@ export const NeedParsingResponseSchema = z.object({
latestMoveIn: z.string().optional(),
flexibleTiming: z.boolean().optional(),
})
.strict()
.optional()
.nullable(),
mustHaveCriteria: z.array(z.string()).optional(),
missingFields: z.array(z.string()).optional(),
assumptions: z.array(z.string()).optional(),
})
mustHaveCriteria: z.array(z.string().min(1).max(200)).max(30).optional(),
missingFields: z.array(z.string().min(1)).max(20).optional(),
assumptions: z.array(z.string().min(1)).max(20).optional(),
})
.strict()
export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema>
@@ -54,81 +69,102 @@ export type NeedParsingResponseRaw = z.infer<typeof NeedParsingResponseSchema>
export const FollowUpQuestionsResponseSchema = z
.array(
z.object({
questionText: z.string().min(1),
targetField: z.string().min(1),
reason: z.string().optional(),
suggestedAnswerOptions: z.array(z.string()).optional(),
importance: z
.enum(['required', 'recommended', 'optional'])
.optional(),
}),
z
.object({
questionText: z.string().min(5).max(500),
targetField: z.string().min(1).max(100),
reason: z.string().min(3).max(300).optional(),
suggestedAnswerOptions: z.array(z.string().min(1).max(200)).max(10).optional(),
importance: z.enum(['required', 'recommended', 'optional']),
})
.strict(),
)
.max(5)
.max(3)
// ── 3. Trade-Off Summary ──────────────────────────────────────────────────────
export const TradeOffSummaryResponseSchema = z.object({
headline: z.string().min(1),
export const TradeOffSummaryResponseSchema = z
.object({
headline: z.string().min(5).max(300),
items: z
.array(
z.object({
concern: z.string().min(1),
z
.object({
concern: z.string().min(5).max(300),
severity: SeveritySchema,
mitigation: z.string().optional(),
}),
mitigation: z.string().max(300).optional(),
})
.strict(),
)
.max(5),
.max(3),
overallRisk: SeveritySchema,
})
})
.strict()
// ── 4. Comparison Summary ─────────────────────────────────────────────────────
export const CompareSummaryResponseSchema = z.object({
overallAssessment: z.string().min(1),
recommendation: z.string().optional(),
strongestOption: z.string().optional(),
})
export const CompareSummaryResponseSchema = z
.object({
overallAssessment: z.string().min(10).max(1000),
recommendation: z.string().min(5).max(500).optional(),
strongestOption: z.string().min(1).max(200).optional(),
})
.strict()
// ── 5. Decision Brief ────────────────────────────────────────────────────────
export const DecisionBriefResponseSchema = z.object({
summary: z.string().min(1),
export const DecisionBriefResponseSchema = z
.object({
summary: z.string().min(10).max(500),
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)
.max(6),
})
})
.strict()
// ── 6. Data Quality Summary ───────────────────────────────────────────────────
export const DataQualitySummaryResponseSchema = z.object({
overallAssessment: z.string().min(1),
missingCriticalFields: z.array(z.string()).optional(),
recommendation: z.string().min(1),
export const DataQualitySummaryResponseSchema = z
.object({
overallAssessment: z.string().min(10).max(600),
missingCriticalFields: z.array(z.string().min(1).max(100)).max(30).optional(),
recommendation: z.string().min(5).max(500),
confidence: z.number().min(0).max(1),
})
})
.strict()
// ── 7. Market Signal Classification ──────────────────────────────────────────
export const MarketSignalClassificationResponseSchema = z.object({
export const MarketSignalClassificationResponseSchema = z
.object({
signalType: z.enum([
'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING',
'EXPANSION', 'RELOCATION', 'UNKNOWN',
]),
probability: ProbabilitySchema,
timeHorizonMonths: z.number().positive().int().optional().nullable(),
areaSqmEstimate: z.number().positive().optional().nullable(),
timeHorizonMonths: z.number().int().min(1).max(240).optional().nullable(),
areaSqmEstimate: z.number().positive().max(1_000_000).optional().nullable(),
credibility: CredibilitySchema,
reasoning: z.string().min(1),
})
reasoning: z.string().min(10).max(1000),
})
.strict()
// ── 8. Offer Email ────────────────────────────────────────────────────────────
export const OfferEmailResponseSchema = z.object({
subject: z.string().min(1),
body: z.string().min(10),
})
export const OfferEmailResponseSchema = z
.object({
subject: z.string().min(5).max(200),
body: z.string().min(50).max(5000),
})
.strict()
// ── Validation helper ─────────────────────────────────────────────────────────
@@ -144,6 +180,12 @@ export function validateAIResponse<T>(
): T | null {
const result = schema.safeParse(raw)
if (result.success) return result.data
console.warn(`[AISchema] ${label} validation failed:`, result.error.flatten())
const errors = result.error.flatten()
console.warn(`[AISchema] ${label} validation failed`, {
fieldErrors: errors.fieldErrors,
formErrors: errors.formErrors,
receivedKeys: typeof raw === 'object' && raw !== null ? Object.keys(raw as object) : [],
snippet: JSON.stringify(raw).slice(0, 300),
})
return null
}
+65 -5
View File
@@ -56,6 +56,8 @@ export interface AITrace {
responseValidationStatus: AITraceValidationStatus
/** Only present when responseValidationStatus indicates a failure */
errorType?: AITraceErrorType
/** Human-readable reason for the fallback — mirrors AIProvenance.fallbackReason */
fallbackReason?: string
source: AIProvenance['source']
/** ISO-8601 timestamp of when the call completed */
createdAt: string
@@ -65,6 +67,11 @@ export interface AITrace {
// ── Store ─────────────────────────────────────────────────────────────────────
function percentile(sortedArr: number[], p: number): number {
if (sortedArr.length === 0) return 0
return sortedArr[Math.max(0, Math.ceil(p * sortedArr.length) - 1)]
}
const MAX_ENTRIES = 100
const STORAGE_KEY = 'pm_ai_traces'
@@ -103,19 +110,67 @@ class AITraceStore {
fallbacks: number
schemaFailures: number
avgLatencyMs: number
latencyPercentiles: { p50: number; p90: number; p99: number }
byMethod: Record<string, number>
failureCountByError: Record<string, number>
validationFailuresByMethod: Record<string, number>
fallbackReasonDistribution: Record<string, number>
promptVersionUsage: Record<string, number>
} {
const total = this.entries.length
const fallbacks = this.entries.filter(t => t.fallbackUsed).length
const schemaFailures = this.entries.filter(t => t.responseValidationStatus === 'invalid_schema').length
const avgLatencyMs = total === 0 ? 0 : Math.round(
this.entries.reduce((s, t) => s + t.latencyMs, 0) / total
)
const sortedLatencies = [...this.entries.map(t => t.latencyMs)].sort((a, b) => a - b)
const avgLatencyMs = total === 0 ? 0 : Math.round(sortedLatencies.reduce((s, l) => s + l, 0) / total)
const byMethod = this.entries.reduce<Record<string, number>>((acc, t) => {
acc[t.method] = (acc[t.method] ?? 0) + 1
return acc
}, {})
return { total, fallbacks, schemaFailures, avgLatencyMs, byMethod }
const failureCountByError = this.entries
.filter(t => t.errorType)
.reduce<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). */
@@ -140,7 +195,8 @@ class AITraceStore {
console.debug(
`[AITrace] ${icon} ${trace.method}${fallback}${validation}` +
`${trace.provider}/${trace.model}` +
` | ${trace.latencyMs}ms | source:${trace.source}`,
` | ${trace.latencyMs}ms | source:${trace.source}` +
(trace.fallbackReason ? ` | reason:${trace.fallbackReason}` : ''),
trace,
)
}
@@ -175,8 +231,12 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
export function provenanceToStatus(
fallbackUsed: boolean,
source: AIProvenance['source'],
fallbackReason?: string,
): AITraceValidationStatus {
if (!fallbackUsed) return 'valid'
if (fallbackReason === 'no_api_key') return 'fallback'
if (fallbackReason?.startsWith('api_error')) return 'api_error'
if (fallbackReason?.startsWith('network')) return 'network_error'
if (source === 'mock') return 'invalid_schema'
return 'valid'
}