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
@@ -1,15 +1,29 @@
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { matchService } from '../../services/matchService'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps {
propertyId: string
inquiryId: string
}
export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPanelProps) {
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate()
const [additionalMatches, setAdditionalMatches] = useState<AdditionalPropertyMatch[]>([])
const [loadingMatches, setLoadingMatches] = useState(false)
useEffect(() => {
setLoadingMatches(true)
matchService
.getAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
.then(setAdditionalMatches)
.finally(() => setLoadingMatches(false))
}, [inquiryId, propertyId])
if (isLoading) {
return (
@@ -102,10 +116,91 @@ export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPane
size="small"
endIcon={<ArrowRight size={14} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', mt: 'auto' }}
sx={{ textTransform: 'none' }}
>
Objekt ansehen
</Button>
{/* Weitere Matches */}
<Divider sx={{ my: 0.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Trophy size={14} color="#d97706" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
Weitere passende Objekte
</Typography>
</Box>
{loadingMatches ? (
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
Keine weiteren Matches
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{additionalMatches.map(m => (
<AdditionalMatchCard key={m.propertyId} match={m} />
))}
</Box>
)}
</Box>
)
}
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: 'white',
}}
>
{match.imageUrl && (
<Box
sx={{
height: 70,
backgroundImage: `url(${match.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
<Box sx={{ p: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3 }}>
{match.title}
</Typography>
<Box
sx={{
px: 0.75,
py: 0.125,
borderRadius: 1,
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
fontWeight: 700,
fontSize: '0.65rem',
flexShrink: 0,
ml: 0.5,
}}
>
{match.matchScore}%
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block' }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
</Typography>
<Button
size="small"
endIcon={<ArrowRight size={11} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#1e3a5f' }}
>
Ansehen
</Button>
</Box>
</Box>
)
}