refactor: extract helpers from ReminderDetailDrawer + OfferCreationWizard

- 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>
This commit is contained in:
Benjamin Sutter
2026-05-24 01:10:31 +02:00
parent ee71ecc881
commit a3fc213916
4 changed files with 206 additions and 175 deletions
@@ -1,14 +1,15 @@
import { useEffect, useRef, useState } from 'react'
import {
Box, Button, CircularProgress, Dialog, DialogContent,
IconButton, LinearProgress, Stack, Step, StepLabel, Stepper,
IconButton, Stack, Step, StepLabel, Stepper,
TextField, Typography,
} from '@mui/material'
import { Download, Plus, Send, Trash2, X } from 'lucide-react'
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
@@ -213,94 +214,19 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
{/* 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={() => {
<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')
}}
sx={{ textTransform: 'none' }}
>
An Nachricht anhängen
</Button>
</Box>
</Box>
) : null}
</Box>
/>
)}
</DialogContent>
@@ -0,0 +1,103 @@
import { Box, Button, CircularProgress, LinearProgress, Typography } from '@mui/material'
import { Download, Send } from 'lucide-react'
import type { OfferReportDraft } from '../../domain/offerReport'
import type { Property } from '../../domain/property'
interface OfferWizardPdfStepProps {
generating: boolean
progress: number
ready: boolean
draft: OfferReportDraft | null
property: Property | undefined
onDownload: () => void
onAttach: () => void
}
export function OfferWizardPdfStep({ generating, progress, ready, draft, property, onDownload, onAttach }: OfferWizardPdfStepProps) {
return (
<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_{property?.title ?? '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={onDownload}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
<Button
variant="outlined"
startIcon={<Send size={16} />}
onClick={onAttach}
sx={{ textTransform: 'none' }}
>
An Nachricht anhängen
</Button>
</Box>
</Box>
) : null}
</Box>
)
}
+2 -85
View File
@@ -18,91 +18,8 @@ import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
import { ReminderTypeBadge } from './ReminderTypeBadge'
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
import { ReminderStatus, ShadowMarketRisk } from '../../domain/reminder'
import type { ReminderActivity } from '../../domain/reminder'
const SHADOW_RISK_COLOR: Record<string, string> = {
NONE: '#64748b',
LOW: '#16a34a',
MEDIUM: '#ca8a04',
HIGH: '#dc2626',
}
const SHADOW_RISK_LABEL: Record<string, string> = {
NONE: 'Kein Risiko',
LOW: 'Niedrig',
MEDIUM: 'Mittel',
HIGH: 'Hoch',
}
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
ACTIVE: 'info',
SNOOZED: 'warning',
COMPLETED: 'success',
DISMISSED: 'default',
}
const STATUS_LABEL: Record<string, string> = {
ACTIVE: 'Aktiv',
SNOOZED: 'Schlummernd',
COMPLETED: 'Erledigt',
DISMISSED: 'Verworfen',
}
const ACTION_LABEL: Record<string, string> = {
CREATED: 'Erstellt',
SNOOZED: 'Zurückgestellt',
COMPLETED: 'Erledigt',
DISMISSED: 'Verworfen',
NOTED: 'Notiz',
}
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{icon}
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1e293b', fontSize: '0.8125rem' }}>
{children}
</Typography>
</Box>
)
}
function DateRow({ label, value }: { label: string; value?: string }) {
if (!value) return null
return (
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{new Date(value).toLocaleDateString('de-CH')}</Typography>
</Box>
)
}
function ActivityEntry({ entry }: { entry: ReminderActivity }) {
return (
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#94a3b8', mt: '4px' }} />
<Box sx={{ width: 1, flex: 1, bgcolor: '#e2e8f0', mt: 0.5 }} />
</Box>
<Box sx={{ pb: 1.5, minWidth: 0 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>
{ACTION_LABEL[entry.action]}
</Typography>
<Typography variant="caption" color="text.secondary">
{entry.by} · {new Date(entry.at).toLocaleDateString('de-CH')}
</Typography>
</Box>
{entry.note && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{entry.note}
</Typography>
)}
</Box>
</Box>
)
}
import { ReminderStatus } from '../../domain/reminder'
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, ACTION_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
export function ReminderDetailDrawer() {
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
@@ -0,0 +1,85 @@
import { Box, Typography } from '@mui/material'
import type { ReminderActivity } from '../../domain/reminder'
export const SHADOW_RISK_COLOR: Record<string, string> = {
NONE: '#64748b',
LOW: '#16a34a',
MEDIUM: '#ca8a04',
HIGH: '#dc2626',
}
export const SHADOW_RISK_LABEL: Record<string, string> = {
NONE: 'Kein Risiko',
LOW: 'Niedrig',
MEDIUM: 'Mittel',
HIGH: 'Hoch',
}
export const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
ACTIVE: 'info',
SNOOZED: 'warning',
COMPLETED: 'success',
DISMISSED: 'default',
}
export const STATUS_LABEL: Record<string, string> = {
ACTIVE: 'Aktiv',
SNOOZED: 'Schlummernd',
COMPLETED: 'Erledigt',
DISMISSED: 'Verworfen',
}
export const ACTION_LABEL: Record<string, string> = {
CREATED: 'Erstellt',
SNOOZED: 'Zurückgestellt',
COMPLETED: 'Erledigt',
DISMISSED: 'Verworfen',
NOTED: 'Notiz',
}
export function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{icon}
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1e293b', fontSize: '0.8125rem' }}>
{children}
</Typography>
</Box>
)
}
export function DateRow({ label, value }: { label: string; value?: string }) {
if (!value) return null
return (
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{new Date(value).toLocaleDateString('de-CH')}</Typography>
</Box>
)
}
export function ActivityEntry({ entry }: { entry: ReminderActivity }) {
return (
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#94a3b8', mt: '4px' }} />
<Box sx={{ width: 1, flex: 1, bgcolor: '#e2e8f0', mt: 0.5 }} />
</Box>
<Box sx={{ pb: 1.5, minWidth: 0 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>
{ACTION_LABEL[entry.action]}
</Typography>
<Typography variant="caption" color="text.secondary">
{entry.by} · {new Date(entry.at).toLocaleDateString('de-CH')}
</Typography>
</Box>
{entry.note && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{entry.note}
</Typography>
)}
</Box>
</Box>
)
}