feat: Anfragencenter Parts A–E — read/unread, prep wizard, offer flow, report preview

- Part A: Replace InquiryStatus badges with WhatsApp-style unread indicators
  (isRead, unreadCount, lastReadAt on Inquiry; markThreadAsRead in service + hook)
- Part B: RelatedPropertyCardPanel — reposition "Objekt ansehen", add "Weitere
  Matches" section via matchService.getAdditionalMatchesForInquiry
- Part C: PreparationWizard 4-step dialog — property selection, report generation
  progress, field editing + ReportObjectFieldSelector, PDF preview + finalize
- Part D: OfferCreationWizard 4-step dialog triggered from first tenant message —
  data capture, field editing, viewing appointments, PDF generation + attach to reply
- Part E: LatentInquiryReportPreview (2-page A4 doc), ReportObjectFieldSelector
  (5 accordion groups, 35+ optional fields), mapImageUrl on Property domain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 19:20:13 +02:00
parent 279b55d70e
commit b4d398270f
26 changed files with 1874 additions and 188 deletions
@@ -0,0 +1,260 @@
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'
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',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Objekttyp',
floor: 'Stockwerk',
parking: 'Parkplätze',
ceilingHeightM: 'Deckenhöhe',
loadingDocksCount: 'Laderampen',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
isBarrierFree: 'Barrierefrei',
fitOut: 'Ausbaustandard',
publicTransportScore: 'ÖV-Score',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
description: 'Beschreibung',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : 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 #1e3a5f', pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}>
Objektvorschlag
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
</Typography>
</Box>
{editableByField['intro'] && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: '#1e293b' }}>
{editableByField['intro']}
</Typography>
)}
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Ihre Anfrage
</Typography>
<Typography variant="body2" sx={{ color: '#374151', mb: 0.5 }}>
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<Typography variant="body2" sx={{ color: '#374151' }}>
<strong>Betreff:</strong> {inquiry.subject}
</Typography>
</Box>
{editableByField['highlights'] && (
<>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Warum diese Objekte passen
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151', 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: '#e0e7ff', color: '#1e3a5f', 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: '#94a3b8', 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: '#e2e8f0',
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="#94a3b8" />}
</Box>
{/* Property photo */}
<Box
sx={{
flex: 1,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!image && <Building2 size={28} color="#94a3b8" />}
</Box>
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 0.5, fontFamily: 'inherit' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<MapPin size={13} />
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Ruler size={13} />
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<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: '#f8fafc',
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: '#64748b', fontSize: '0.68rem', display: 'block' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', 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: '#0f172a', mb: 1 }}>
Nächste Schritte
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151' }}>
{editableByField['next_steps']}
</Typography>
</Box>
)}
</Box>
)
}