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