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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<PreMarketUnitGrid
|
||||
units={p.units!}
|
||||
unitStates={unitStates}
|
||||
unitSaving={unitSaving}
|
||||
setUnitStates={setUnitStates}
|
||||
saveUnit={saveUnit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export const DS_TEXT = {
|
||||
warningDark: '#92400e',
|
||||
signalDark: '#4c1d95',
|
||||
infoDark: '#0c4a6e',
|
||||
brandDark: '#162d4a',
|
||||
} as const
|
||||
|
||||
// ── Background tokens ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
+26
-118
@@ -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>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Preview + Save as Profile ── */}
|
||||
{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
|
||||
criteria={editedCriteria}
|
||||
weights={weights}
|
||||
confidenceByField={parseResult.confidenceByField}
|
||||
missingFields={parseResult.missingFields}
|
||||
needTitle={needTitle}
|
||||
onNeedTitleChange={setNeedTitle}
|
||||
<AISearchActionBar
|
||||
canProceed={canProceed}
|
||||
isSearching={isProcessing && intent === 'search'}
|
||||
isSavingProfile={isProcessing && intent === 'save-profile'}
|
||||
onSearch={() => handleAction('search')}
|
||||
onSaveProfile={() => handleAction('save-profile')}
|
||||
/>
|
||||
<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 ── */}
|
||||
{/* Save preview step */}
|
||||
{isSaveStep && parseResult && editedCriteria && (
|
||||
<AISearchSavePreview
|
||||
criteria={editedCriteria}
|
||||
weights={weights}
|
||||
parseResult={parseResult}
|
||||
needTitle={needTitle}
|
||||
overallConfidence={overallConfidence}
|
||||
isSaving={step === NeedBuilderStep.SAVING}
|
||||
onNeedTitleChange={setNeedTitle}
|
||||
onBack={() => setStep(NeedBuilderStep.IDLE)}
|
||||
onSave={handleSaveProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{step === NeedBuilderStep.ERROR && (
|
||||
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+68
-387
@@ -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>
|
||||
<AiAssistCard
|
||||
text={form.aiText}
|
||||
onTextChange={form.setAiText}
|
||||
onParse={form.handleAiParse}
|
||||
parsing={form.aiParsing}
|
||||
applied={form.aiApplied}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<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="Fläche (m²)" value={areaSqm}
|
||||
onChange={e => setAreaSqm(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} 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}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||
onChange={e => setRentPerSqm(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
/>
|
||||
<SoftFactorsSection softLevels={form.softLevels} onChange={form.setSoftLevel} />
|
||||
|
||||
<TextField
|
||||
label="Verfügbar ab" value={availableFrom}
|
||||
onChange={e => setAvailableFrom(e.target.value)}
|
||||
size="small" type="date"
|
||||
slotProps={{ inputLabel: { shrink: true } }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Beschreibung (optional)" value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<ImageUrlSection
|
||||
images={form.images}
|
||||
imageInput={form.imageInput}
|
||||
onImageInputChange={form.setImageInput}
|
||||
onAdd={form.addImage}
|
||||
onRemove={form.removeImage}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<ContactSection
|
||||
name={form.contactName} onNameChange={form.setContactName}
|
||||
email={form.contactEmail} onEmailChange={form.setContactEmail}
|
||||
phone={form.contactPhone} onPhoneChange={form.setContactPhone}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
<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://…"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -51,3 +51,5 @@ export interface LocationState {
|
||||
propertyId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Prefill = NonNullable<LocationState['prefill']>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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):
|
||||
{
|
||||
|
||||
@@ -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"],
|
||||
|
||||
+126
-84
@@ -4,6 +4,10 @@
|
||||
* Every OpenRouter response is validated against its schema before reaching
|
||||
* the UI. Validation failures trigger an explicit fallback to MockAIService —
|
||||
* no invalid data ever passes through silently.
|
||||
*
|
||||
* All schemas use .strict() — any unknown key from the AI response triggers
|
||||
* immediate validation failure and fallback, preventing hallucinated fields
|
||||
* from reaching the UI.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -20,33 +24,44 @@ export const ScoreSchema = z.number().min(0).max(100)
|
||||
|
||||
// ── 1. Need Parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
export const NeedParsingResponseSchema = z.object({
|
||||
assetType: z
|
||||
.enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'GASTRO', 'MIXED', 'UNKNOWN'])
|
||||
.optional()
|
||||
.nullable(),
|
||||
areaRange: z
|
||||
.object({ min: z.number().min(0), max: z.number().min(0) })
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }),
|
||||
preferredLocations: z.array(z.string().min(1)).optional(),
|
||||
budgetRange: z
|
||||
.object({ maxPerSqm: z.number().positive(), currency: z.string().min(1) })
|
||||
.optional()
|
||||
.nullable(),
|
||||
timing: z
|
||||
.object({
|
||||
earliestMoveIn: z.string().optional(),
|
||||
latestMoveIn: z.string().optional(),
|
||||
flexibleTiming: z.boolean().optional(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
mustHaveCriteria: z.array(z.string()).optional(),
|
||||
missingFields: z.array(z.string()).optional(),
|
||||
assumptions: z.array(z.string()).optional(),
|
||||
})
|
||||
export const NeedParsingResponseSchema = z
|
||||
.object({
|
||||
assetType: z
|
||||
.enum(['OFFICE', 'RETAIL', 'LOGISTICS', 'PRODUCTION', 'LIGHT_INDUSTRIAL', 'GASTRO', 'MIXED', 'UNKNOWN'])
|
||||
.optional()
|
||||
.nullable(),
|
||||
areaRange: z
|
||||
.object({
|
||||
min: z.number().min(1, 'minimum area must be ≥ 1 m²').max(100_000),
|
||||
max: z.number().min(0).max(100_000),
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(r => r == null || r.max >= r.min, { message: 'areaRange.max must be >= min' }),
|
||||
preferredLocations: z.array(z.string().min(1).max(100)).max(20).optional(),
|
||||
budgetRange: z
|
||||
.object({
|
||||
maxPerSqm: z.number().positive().max(100_000, 'budget > 100k CHF/m²/a is implausible'),
|
||||
currency: z.string().min(1).max(10),
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
.nullable(),
|
||||
timing: z
|
||||
.object({
|
||||
earliestMoveIn: z.string().optional(),
|
||||
latestMoveIn: z.string().optional(),
|
||||
flexibleTiming: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
.nullable(),
|
||||
mustHaveCriteria: z.array(z.string().min(1).max(200)).max(30).optional(),
|
||||
missingFields: z.array(z.string().min(1)).max(20).optional(),
|
||||
assumptions: z.array(z.string().min(1)).max(20).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type NeedParsingResponseRaw = z.infer<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),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
concern: z.string().min(1),
|
||||
severity: SeveritySchema,
|
||||
mitigation: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.max(5),
|
||||
overallRisk: SeveritySchema,
|
||||
})
|
||||
export const TradeOffSummaryResponseSchema = z
|
||||
.object({
|
||||
headline: z.string().min(5).max(300),
|
||||
items: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
concern: z.string().min(5).max(300),
|
||||
severity: SeveritySchema,
|
||||
mitigation: z.string().max(300).optional(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(3),
|
||||
overallRisk: SeveritySchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
// ── 4. Comparison Summary ─────────────────────────────────────────────────────
|
||||
|
||||
export const CompareSummaryResponseSchema = z.object({
|
||||
overallAssessment: z.string().min(1),
|
||||
recommendation: z.string().optional(),
|
||||
strongestOption: z.string().optional(),
|
||||
})
|
||||
export const CompareSummaryResponseSchema = z
|
||||
.object({
|
||||
overallAssessment: z.string().min(10).max(1000),
|
||||
recommendation: z.string().min(5).max(500).optional(),
|
||||
strongestOption: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
// ── 5. Decision Brief ────────────────────────────────────────────────────────
|
||||
|
||||
export const DecisionBriefResponseSchema = z.object({
|
||||
summary: z.string().min(1),
|
||||
sections: z
|
||||
.array(z.object({ title: z.string().min(1), body: z.string().min(1) }))
|
||||
.min(1)
|
||||
.max(6),
|
||||
})
|
||||
export const DecisionBriefResponseSchema = z
|
||||
.object({
|
||||
summary: z.string().min(10).max(500),
|
||||
sections: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
title: z.string().min(1).max(100),
|
||||
body: z.string().min(10).max(1000),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1)
|
||||
.max(6),
|
||||
})
|
||||
.strict()
|
||||
|
||||
// ── 6. Data Quality Summary ───────────────────────────────────────────────────
|
||||
|
||||
export const DataQualitySummaryResponseSchema = z.object({
|
||||
overallAssessment: z.string().min(1),
|
||||
missingCriticalFields: z.array(z.string()).optional(),
|
||||
recommendation: z.string().min(1),
|
||||
confidence: z.number().min(0).max(1),
|
||||
})
|
||||
export const DataQualitySummaryResponseSchema = z
|
||||
.object({
|
||||
overallAssessment: z.string().min(10).max(600),
|
||||
missingCriticalFields: z.array(z.string().min(1).max(100)).max(30).optional(),
|
||||
recommendation: z.string().min(5).max(500),
|
||||
confidence: z.number().min(0).max(1),
|
||||
})
|
||||
.strict()
|
||||
|
||||
// ── 7. Market Signal Classification ──────────────────────────────────────────
|
||||
|
||||
export const MarketSignalClassificationResponseSchema = z.object({
|
||||
signalType: z.enum([
|
||||
'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING',
|
||||
'EXPANSION', 'RELOCATION', 'UNKNOWN',
|
||||
]),
|
||||
probability: ProbabilitySchema,
|
||||
timeHorizonMonths: z.number().positive().int().optional().nullable(),
|
||||
areaSqmEstimate: z.number().positive().optional().nullable(),
|
||||
credibility: CredibilitySchema,
|
||||
reasoning: z.string().min(1),
|
||||
})
|
||||
export const MarketSignalClassificationResponseSchema = z
|
||||
.object({
|
||||
signalType: z.enum([
|
||||
'VACANCY', 'CONSTRUCTION', 'RESTRUCTURING',
|
||||
'EXPANSION', 'RELOCATION', 'UNKNOWN',
|
||||
]),
|
||||
probability: ProbabilitySchema,
|
||||
timeHorizonMonths: z.number().int().min(1).max(240).optional().nullable(),
|
||||
areaSqmEstimate: z.number().positive().max(1_000_000).optional().nullable(),
|
||||
credibility: CredibilitySchema,
|
||||
reasoning: z.string().min(10).max(1000),
|
||||
})
|
||||
.strict()
|
||||
|
||||
// ── 8. Offer Email ────────────────────────────────────────────────────────────
|
||||
|
||||
export const OfferEmailResponseSchema = z.object({
|
||||
subject: z.string().min(1),
|
||||
body: z.string().min(10),
|
||||
})
|
||||
export const OfferEmailResponseSchema = z
|
||||
.object({
|
||||
subject: z.string().min(5).max(200),
|
||||
body: z.string().min(50).max(5000),
|
||||
})
|
||||
.strict()
|
||||
|
||||
// ── Validation helper ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -144,6 +180,12 @@ export function validateAIResponse<T>(
|
||||
): 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
|
||||
}
|
||||
|
||||
@@ -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 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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user