a3fc213916
- ReminderDetailDrawer (340→257 lines): constants + SectionTitle/DateRow/ActivityEntry → reminderDetailHelpers.tsx - OfferCreationWizard (350→276 lines): PDF step JSX → OfferWizardPdfStep.tsx Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import {
|
||
Box, Button, CircularProgress, Dialog, DialogContent,
|
||
IconButton, Stack, Step, StepLabel, Stepper,
|
||
TextField, Typography,
|
||
} from '@mui/material'
|
||
import { Plus, 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'
|
||
import { OfferWizardPdfStep } from './OfferWizardPdfStep'
|
||
|
||
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 && (
|
||
<OfferWizardPdfStep
|
||
generating={generating}
|
||
progress={progress}
|
||
ready={ready}
|
||
draft={draft}
|
||
property={property}
|
||
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||
onAttach={() => {
|
||
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
||
onAttach(label)
|
||
showToast('Angebot als Anhang hinzugefügt', 'success')
|
||
}}
|
||
/>
|
||
)}
|
||
</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>
|
||
)
|
||
}
|