e1f4beb898
- 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>
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
import { Box, Chip, Divider, Typography } from '@mui/material'
|
|
import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
|
|
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
|
|
import type { Property } from '../../domain/property'
|
|
import type { Inquiry } from '../../domain/inquiry'
|
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
|
|
|
interface Props {
|
|
draft: InquiryPreparationReportDraft
|
|
inquiry: Inquiry
|
|
properties: Property[]
|
|
}
|
|
|
|
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
|
|
areaSqm: 'Fläche',
|
|
rentPricePerSqm: 'Mietpreis',
|
|
availabilityDate: 'Verfügbar ab',
|
|
leaseTerm: 'Mietlaufzeit',
|
|
breakoutOption: 'Breakout-Option',
|
|
breakoutOptionDate: 'Breakout Zeitpunkt',
|
|
currentTenant: 'Aktueller Mieter',
|
|
propertyNumber: 'Objektnummer',
|
|
assetType: 'Asset Type',
|
|
floor: 'Stockwerk',
|
|
parking: 'Parkplätze',
|
|
fitOut: 'Ausbaugrad',
|
|
isBarrierFree: 'Barrierefrei',
|
|
ceilingHeightM: 'Raumhöhe',
|
|
floorLoad: 'Bodenlast',
|
|
loadingDocksCount: 'Anlieferung',
|
|
goodsLift: 'Warenaufzug',
|
|
passengerLift: 'Personenaufzug',
|
|
powerSupplyKva: 'Stromanschluss',
|
|
hasServerRoom: 'Serverraum',
|
|
internet: 'Internet',
|
|
deliveryAccess: 'Zufahrt',
|
|
publicTransportScore: 'ÖV-Anbindung',
|
|
prestigeScore: 'Prestige',
|
|
visibilityScore: 'Sichtbarkeit',
|
|
footfallScore: 'Passantenfrequenz',
|
|
commuterAccessScore: 'Pendlererreichbarkeit',
|
|
talentAccessScore: 'Talent Access',
|
|
esgScore: 'ESG',
|
|
flexibilityScore: 'Flexibilität',
|
|
expansionPotentialScore: 'Expansionspotenzial',
|
|
taxEnvironmentScore: 'Steuerumfeld',
|
|
microLocation: 'Mikrostandort',
|
|
competitionEnvironment: 'Konkurrenzumfeld',
|
|
infrastructure: 'Infrastruktur',
|
|
marketSignals: 'Marktsignale',
|
|
negotiationHints: 'Verhandlungshinweise',
|
|
missingData: 'Datenlücken',
|
|
dataQuality: 'Datenqualität',
|
|
description: 'Beschreibung',
|
|
units: 'Stockwerkstruktur',
|
|
}
|
|
|
|
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
|
|
switch (key) {
|
|
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : null
|
|
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : null
|
|
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
|
|
case 'leaseTerm': return property.leaseTerm ?? null
|
|
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
|
|
case 'currentTenant': return property.currentTenant ?? null
|
|
case 'propertyNumber': return property.propertyNumber ?? null
|
|
case 'assetType': return property.assetType ?? null
|
|
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
|
|
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Plätze` : null
|
|
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
|
|
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
|
|
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
|
|
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
|
|
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
|
|
case 'fitOut': return property.hardFacts?.fitOut ?? null
|
|
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
|
|
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
|
|
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
|
|
case 'description': return property.description ?? null
|
|
default: return null
|
|
}
|
|
}
|
|
|
|
export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props) {
|
|
const editableByField = Object.fromEntries(draft.editableFields.map(f => [f.id, f.value]))
|
|
const selectedProps = draft.selectedPropertyIds
|
|
.map(id => properties.find(p => p.id === id))
|
|
.filter((p): p is Property => !!p)
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0, fontFamily: 'Georgia, serif' }}>
|
|
|
|
{/* Page 1 — Need Summary */}
|
|
<Box
|
|
sx={{
|
|
bgcolor: 'white',
|
|
p: 4,
|
|
minHeight: 600,
|
|
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
|
|
borderRadius: 1,
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Box sx={{ borderBottom: `3px solid ${DS_TEXT.brand}`, pb: 2, mb: 3 }}>
|
|
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_TEXT.brand, fontFamily: 'inherit' }}>
|
|
Objektvorschlag
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 0.5 }}>
|
|
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{editableByField['intro'] && (
|
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: DS_TEXT.primary }}>
|
|
{editableByField['intro']}
|
|
</Typography>
|
|
)}
|
|
|
|
<Box sx={{ bgcolor: DS_BG.page, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, p: 2, mb: 3 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
|
Ihre Anfrage
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary, mb: 0.5 }}>
|
|
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
|
|
<strong>Betreff:</strong> {inquiry.subject}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{editableByField['highlights'] && (
|
|
<>
|
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
|
Warum diese Objekte passen
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary, mb: 3 }}>
|
|
{editableByField['highlights']}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
|
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
{selectedProps.map(p => (
|
|
<Chip
|
|
key={p.id}
|
|
label={p.title}
|
|
size="small"
|
|
icon={<Building2 size={12} />}
|
|
sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Page 2+ — One section per property */}
|
|
{selectedProps.map((property, idx) => {
|
|
const selection = draft.fieldSelections.find(fs => fs.propertyId === property.id)
|
|
const optionalFields = selection?.selectedOptionalFields ?? []
|
|
const image = property.images?.[0]
|
|
|
|
return (
|
|
<Box key={property.id}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
<Divider sx={{ flex: 1 }} />
|
|
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
|
Objekt {idx + 1} von {selectedProps.length}
|
|
</Typography>
|
|
<Divider sx={{ flex: 1 }} />
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
bgcolor: 'white',
|
|
p: 3,
|
|
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
|
|
borderRadius: 1,
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 2.5 }}>
|
|
{/* Map placeholder */}
|
|
<Box
|
|
sx={{
|
|
width: 160,
|
|
height: 120,
|
|
borderRadius: 1,
|
|
overflow: 'hidden',
|
|
bgcolor: DS_BORDER.default,
|
|
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
flexShrink: 0,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
{!property.mapImageUrl && <MapPin size={28} color={DS_TEXT.disabled} />}
|
|
</Box>
|
|
{/* Property photo */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
height: 120,
|
|
borderRadius: 1,
|
|
overflow: 'hidden',
|
|
bgcolor: DS_BORDER.default,
|
|
backgroundImage: image ? `url(${image})` : 'none',
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
{!image && <Building2 size={28} color={DS_TEXT.disabled} />}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.brand, mb: 0.5, fontFamily: 'inherit' }}>
|
|
{property.title}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
|
<MapPin size={13} />
|
|
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
|
<Ruler size={13} />
|
|
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
|
|
<Calendar size={13} />
|
|
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
{optionalFields.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '1fr 1fr',
|
|
gap: 0.75,
|
|
bgcolor: DS_BG.page,
|
|
borderRadius: 1,
|
|
p: 1.5,
|
|
}}
|
|
>
|
|
{optionalFields.map(key => {
|
|
const val = getFieldValue(property, key)
|
|
if (!val) return null
|
|
return (
|
|
<Box key={key}>
|
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', display: 'block' }}>
|
|
{FIELD_LABELS[key] ?? key}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary, fontSize: '0.78rem' }}>
|
|
{val}
|
|
</Typography>
|
|
</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
})}
|
|
|
|
{editableByField['next_steps'] && (
|
|
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
|
|
Nächste Schritte
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary }}>
|
|
{editableByField['next_steps']}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|