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,350 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Box, Button, CircularProgress, Dialog, DialogContent,
|
||||
IconButton, LinearProgress, Stack, Step, StepLabel, Stepper,
|
||||
TextField, Typography,
|
||||
} from '@mui/material'
|
||||
import { Download, Plus, Send, Trash2, X } from 'lucide-react'
|
||||
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
|
||||
import { offerReportService } from '../../services/offerReportService'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
|
||||
interface Props {
|
||||
inquiryId: string
|
||||
propertyId: string
|
||||
tenantName: string
|
||||
onClose: () => void
|
||||
onAttach: (label: string) => void
|
||||
}
|
||||
|
||||
const STEPS = ['Daten erfassen', 'Bericht prüfen', 'Besichtigungstermine', 'PDF erstellen']
|
||||
|
||||
export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose, onAttach }: Props) {
|
||||
const [step, setStep] = useState(0)
|
||||
const [draft, setDraft] = useState<OfferReportDraft | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [ready, setReady] = useState(false)
|
||||
const { data: property } = usePropertyById(propertyId)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
offerReportService
|
||||
.create(inquiryId, propertyId, tenantName, property?.title ?? '')
|
||||
.then(d => { setDraft(d); setLoading(false) })
|
||||
}, [inquiryId, propertyId, tenantName, property?.title])
|
||||
|
||||
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 addAppointment = () => {
|
||||
if (!draft) return
|
||||
const slot: ViewingAppointmentOption = {
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10),
|
||||
timeSlot: '10:00–11:00',
|
||||
}
|
||||
setDraft({ ...draft, viewingAppointments: [...draft.viewingAppointments, slot] })
|
||||
}
|
||||
|
||||
const removeAppointment = (id: string) => {
|
||||
if (!draft) return
|
||||
setDraft({ ...draft, viewingAppointments: draft.viewingAppointments.filter(a => a.id !== id) })
|
||||
}
|
||||
|
||||
const updateAppointment = (id: string, field: keyof ViewingAppointmentOption, value: string) => {
|
||||
if (!draft) return
|
||||
setDraft({
|
||||
...draft,
|
||||
viewingAppointments: draft.viewingAppointments.map(a =>
|
||||
a.id === id ? { ...a, [field]: value } : a,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const handleGeneratePdf = async () => {
|
||||
if (!draft) return
|
||||
await offerReportService.update(draft.id, {
|
||||
editableFields: draft.editableFields,
|
||||
viewingAppointments: draft.viewingAppointments,
|
||||
})
|
||||
setStep(3)
|
||||
setGenerating(true)
|
||||
setProgress(0)
|
||||
let p = 0
|
||||
timerRef.current = setInterval(() => {
|
||||
p += Math.random() * 18 + 8
|
||||
if (p >= 100) {
|
||||
clearInterval(timerRef.current!)
|
||||
setProgress(100)
|
||||
setGenerating(false)
|
||||
setReady(true)
|
||||
offerReportService.generatePdf(draft.id).then(setDraft)
|
||||
} else {
|
||||
setProgress(Math.min(100, p))
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
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 }}>Angebot erstellen</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 }}>
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 0: Data summary */}
|
||||
{!loading && step === 0 && property && (
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Objekt</Typography>
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 2 }}>
|
||||
{property.images?.[0] && (
|
||||
<Box sx={{ height: 120, backgroundImage: `url(${property.images[0]})`, backgroundSize: 'cover', backgroundPosition: 'center', borderRadius: 1, mb: 1.5 }} />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{property.title}</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{property.location.city} · {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Interessent</Typography>
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2 }}>
|
||||
<Typography variant="body2">{tenantName}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', lineHeight: 1.7 }}>
|
||||
Das Angebot wird auf Basis der Objekt- und Anfragedaten vorausgefüllt.
|
||||
Im nächsten Schritt können Sie alle Felder anpassen.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 1: Edit fields */}
|
||||
{!loading && step === 1 && draft && (
|
||||
<Stack spacing={2}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Step 2: Viewing appointments */}
|
||||
{!loading && step === 2 && draft && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
|
||||
Fügen Sie Besichtigungstermine hinzu, die dem Interessenten angeboten werden sollen:
|
||||
</Typography>
|
||||
<Stack spacing={1.5} sx={{ mb: 2 }}>
|
||||
{draft.viewingAppointments.map(a => (
|
||||
<Box key={a.id} sx={{ display: 'flex', gap: 1.5, alignItems: 'center', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5 }}>
|
||||
<TextField
|
||||
label="Datum"
|
||||
type="date"
|
||||
size="small"
|
||||
value={a.date}
|
||||
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 160 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Zeitfenster"
|
||||
size="small"
|
||||
value={a.timeSlot}
|
||||
onChange={e => updateAppointment(a.id, 'timeSlot', e.target.value)}
|
||||
placeholder="10:00–11:00"
|
||||
sx={{ width: 140 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Kontaktperson"
|
||||
size="small"
|
||||
value={a.contactPerson ?? ''}
|
||||
onChange={e => updateAppointment(a.id, 'contactPerson', e.target.value)}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton size="small" onClick={() => removeAppointment(a.id)} sx={{ color: '#ef4444' }}>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Button
|
||||
startIcon={<Plus size={14} />}
|
||||
onClick={addAppointment}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Termin hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 3: PDF */}
|
||||
{step === 3 && (
|
||||
<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' }}>
|
||||
PDF wird generiert…
|
||||
</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>
|
||||
</>
|
||||
) : ready ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 520,
|
||||
height: 380,
|
||||
bgcolor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ bgcolor: '#1e3a5f', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', ml: 1, fontSize: '0.7rem' }}>
|
||||
Angebot_{draft?.propertyId ?? 'Objekt'}.pdf
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, p: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 1 }}>
|
||||
Angebotsschreiben
|
||||
</Typography>
|
||||
{draft?.editableFields.slice(0, 3).map(f => (
|
||||
<Box key={f.id} sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', display: 'block' }}>{f.label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#374151', fontSize: '0.75rem', display: 'block' }}>
|
||||
{f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{(draft?.viewingAppointments.length ?? 0) > 0 && (
|
||||
<Box sx={{ mt: 1.5, p: 1, bgcolor: '#f1f5f9', borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', display: 'block', mb: 0.5 }}>
|
||||
Besichtigungstermine
|
||||
</Typography>
|
||||
{draft!.viewingAppointments.map(a => (
|
||||
<Typography key={a.id} variant="caption" sx={{ fontSize: '0.7rem', display: 'block', color: '#475569' }}>
|
||||
{new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Download size={16} />}
|
||||
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Herunterladen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Send size={16} />}
|
||||
onClick={() => {
|
||||
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
||||
onAttach(label)
|
||||
showToast('Angebot als Anhang hinzugefügt', 'success')
|
||||
}}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
An Nachricht anhängen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
{/* Footer */}
|
||||
<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 > 0 && step < 3 && (
|
||||
<Button variant="outlined" onClick={() => setStep(s => s - 1)} sx={{ textTransform: 'none' }}>
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
{step === 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
onClick={() => setStep(1)}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setStep(2)}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleGeneratePdf}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
PDF erstellen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user