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,283 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Box, Button, Checkbox, CircularProgress, Dialog, DialogContent,
|
||||
FormControlLabel, LinearProgress, Stack, Step, StepLabel, Stepper,
|
||||
TextField, Typography,
|
||||
} from '@mui/material'
|
||||
import { Download, Send, X } from 'lucide-react'
|
||||
import type { Inquiry } from '../../domain/inquiry'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||
import { inquiryReportService } from '../../services/inquiryReportService'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
|
||||
interface Props {
|
||||
inquiryId: string
|
||||
inquiry: Inquiry
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const STEPS = ['Objekte wählen', 'Bericht erstellen', 'Prüfen & bearbeiten', 'Finalisieren']
|
||||
|
||||
export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
||||
const [step, setStep] = useState(0)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [finalizing, setFinalizing] = useState(false)
|
||||
const { data: allProperties = [] } = useProperties()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
||||
|
||||
const selectedProperties = draft?.selectedPropertyIds
|
||||
.map(id => allProperties.find(p => p.id === id))
|
||||
.filter((p): p is Property => !!p) ?? []
|
||||
|
||||
const toggleProperty = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||
)
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setStep(1)
|
||||
setGenerating(true)
|
||||
setProgress(0)
|
||||
let p = 0
|
||||
timerRef.current = setInterval(() => {
|
||||
p += Math.random() * 20 + 10
|
||||
if (p >= 100) {
|
||||
clearInterval(timerRef.current!)
|
||||
setProgress(100)
|
||||
setGenerating(false)
|
||||
inquiryReportService.create(inquiryId, selectedIds).then(d => {
|
||||
setDraft(d)
|
||||
setStep(2)
|
||||
})
|
||||
} else {
|
||||
setProgress(Math.min(100, p))
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
|
||||
|
||||
const handleUpdateField = (fieldId: string, value: string) => {
|
||||
if (!draft) return
|
||||
setDraft({
|
||||
...draft,
|
||||
editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f),
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateFieldSelection = (propertyId: string, sel: ReportObjectFieldSelection) => {
|
||||
if (!draft) return
|
||||
setDraft({
|
||||
...draft,
|
||||
fieldSelections: draft.fieldSelections.map(fs => fs.propertyId === propertyId ? sel : fs),
|
||||
})
|
||||
}
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!draft) return
|
||||
setFinalizing(true)
|
||||
await inquiryReportService.update(draft.id, { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections })
|
||||
const finalized = await inquiryReportService.finalize(draft.id)
|
||||
setDraft(finalized)
|
||||
setFinalizing(false)
|
||||
setStep(3)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Stepper activeStep={step} alternativeLabel>
|
||||
{STEPS.map(label => (
|
||||
<Step key={label}><StepLabel>{label}</StepLabel></Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
|
||||
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
|
||||
|
||||
{/* Step 0: Select properties */}
|
||||
{step === 0 && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
|
||||
Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten:
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{portfolioProps.map(p => (
|
||||
<FormControlLabel
|
||||
key={p.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(p.id)}
|
||||
onChange={() => toggleProperty(p.id)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 1, m: 0, alignItems: 'flex-start', '& .MuiCheckbox-root': { pt: 0 } }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 1: Generating */}
|
||||
{step === 1 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
|
||||
{generating ? (
|
||||
<>
|
||||
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
|
||||
Bericht wird erstellt…
|
||||
</Typography>
|
||||
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
|
||||
{Math.round(progress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body1">Bericht erstellt. Weiterleitung…</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 2: Review & Edit */}
|
||||
{step === 2 && draft && (
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Berichtsfelder bearbeiten</Typography>
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{draft.editableFields.map(f => (
|
||||
<TextField
|
||||
key={f.id}
|
||||
label={f.label}
|
||||
value={f.value}
|
||||
onChange={e => handleUpdateField(f.id, e.target.value)}
|
||||
multiline={f.fieldType === 'textarea'}
|
||||
rows={f.fieldType === 'textarea' ? 3 : 1}
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1.5 }}>Felder pro Objekt</Typography>
|
||||
<Stack spacing={2}>
|
||||
{draft.fieldSelections.map(fs => {
|
||||
const prop = allProperties.find(p => p.id === fs.propertyId)
|
||||
if (!prop) return null
|
||||
return (
|
||||
<ReportObjectFieldSelector
|
||||
key={fs.propertyId}
|
||||
propertyTitle={prop.title}
|
||||
value={fs}
|
||||
onChange={sel => handleUpdateFieldSelection(fs.propertyId, sel)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: 380, flexShrink: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Vorschau</Typography>
|
||||
<Box sx={{ overflow: 'auto', maxHeight: 600 }}>
|
||||
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 3: Finalized */}
|
||||
{step === 3 && draft && (
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
|
||||
</Box>
|
||||
<Box sx={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={<Download size={16} />}
|
||||
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Herunterladen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={<Send size={16} />}
|
||||
onClick={() => {
|
||||
showToast('Bericht als Anhang hinzugefügt', 'success')
|
||||
onClose()
|
||||
}}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Als Anhang senden
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
{/* Footer navigation */}
|
||||
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{step === 2 && (
|
||||
<Button variant="outlined" onClick={() => setStep(0)} sx={{ textTransform: 'none' }}>
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
{step === 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={handleGenerate}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={finalizing}
|
||||
onClick={handleFinalize}
|
||||
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Finalisieren
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user