da50f3b5ea
- Design tokens (ds.ts, theme.ts, scoreTheme.ts): new warm palette (#152642 navy, #f9f8f6 warm white, #e8e7e4 borders, #b8975a gold accent), flat score tier badges replacing CSS gradients, Inter + DM Serif Display typography - Card components: white-background cards, DM Serif score numbers, max-2 badge chips with +N overflow tooltip, editorial score badge positioning - Layout shell: gold left-accent nav active state, 64px top bar, outlined workspace chip, DM Serif page titles - Shared atoms: GenericBadge (outlined/solid variants), ResultFilterBar (simplified chip styles), DecisionContextPanel (dot metrics, no left accent) - Global replacement (118 files): #1e3a5f→#152642, #e2e8f0→#e8e7e4, #f4f6f9→#f9f8f6 — all handled via Node.js for proper UTF-8 safety Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
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 { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
|
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
|
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 { data: allProperties = [] } = useProperties()
|
|
const showToast = useToastStore(s => s.showToast)
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
const createInquiryReport = useCreateInquiryReport()
|
|
const updateInquiryReport = useUpdateInquiryReport()
|
|
const finalizeInquiryReport = useFinalizeInquiryReport()
|
|
const finalizing = finalizeInquiryReport.isPending
|
|
|
|
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
|
|
|
const toggleProperty = (id: string) => {
|
|
setSelectedIds(prev =>
|
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
|
)
|
|
}
|
|
|
|
const handleGenerate = () => {
|
|
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)
|
|
createInquiryReport.mutate(
|
|
{ inquiryId, selectedPropertyIds: selectedIds },
|
|
{
|
|
onSuccess: (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 = () => {
|
|
if (!draft) return
|
|
updateInquiryReport.mutate(
|
|
{ draftId: draft.id, data: { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections } },
|
|
{
|
|
onSuccess: () => {
|
|
finalizeInquiryReport.mutate(draft.id, {
|
|
onSuccess: (finalized) => {
|
|
setDraft(finalized)
|
|
setStep(3)
|
|
},
|
|
})
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { 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: '#152642' }} />
|
|
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
|
|
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: '#152642', '&: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: '#152642', '&: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: '#152642', '&:hover': { bgcolor: '#16304d' } }}
|
|
>
|
|
Finalisieren
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
)
|
|
}
|