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:
@@ -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')} 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 #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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user