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:
Benjamin Sutter
2026-05-19 19:20:13 +02:00
parent 279b55d70e
commit b4d398270f
26 changed files with 1874 additions and 188 deletions
+32 -4
View File
@@ -1,7 +1,6 @@
import { Box, Paper, Typography } from '@mui/material'
import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import { InquiryStatusBadge } from './InquiryStatusBadge'
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
interface InquiryCardProps {
@@ -11,6 +10,8 @@ interface InquiryCardProps {
}
export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return (
<Paper
onClick={onClick}
@@ -34,17 +35,44 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
<Typography
variant="body2"
sx={{
fontWeight: hasUnread ? 700 : 600,
color: '#0f172a',
fontSize: '0.85rem',
lineHeight: 1.3,
}}
>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<InquiryStatusBadge status={inquiry.status} />
{hasUnread && (
<Box
sx={{
minWidth: 20,
height: 20,
borderRadius: '50%',
bgcolor: '#2563eb',
color: 'white',
fontSize: '0.65rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
px: inquiry.unreadCount > 9 ? 0.75 : 0,
}}
>
{inquiry.unreadCount}
</Box>
)}
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
fontWeight: hasUnread ? 600 : 500,
color: '#1e293b',
fontSize: '0.85rem',
display: '-webkit-box',
+41 -6
View File
@@ -1,14 +1,23 @@
import { useEffect, useRef } from 'react'
import { Box } from '@mui/material'
import type { Inquiry } from '../../domain/inquiry'
import { Box, Button } from '@mui/material'
import { FileText } from 'lucide-react'
import type { Inquiry, Attachment } from '../../domain/inquiry'
import { InquiryMessageBubble } from './InquiryMessageBubble'
import { InquiryReplyComposer } from './InquiryReplyComposer'
interface InquiryChatProps {
inquiry: Inquiry
onCreateOffer?: () => void
pendingAttachment?: Attachment | null
onPendingAttachmentConsumed?: () => void
}
export function InquiryChat({ inquiry }: InquiryChatProps) {
export function InquiryChat({
inquiry,
onCreateOffer,
pendingAttachment,
onPendingAttachmentConsumed,
}: InquiryChatProps) {
const endRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
@@ -18,12 +27,38 @@ export function InquiryChat({ inquiry }: InquiryChatProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, overflow: 'hidden' }}>
<Box sx={{ overflowY: 'auto', flex: 1, p: 2, bgcolor: 'white' }}>
{inquiry.thread.map(m => (
<InquiryMessageBubble key={m.id} message={m} />
{inquiry.thread.map((m, idx) => (
<Box key={m.id}>
<InquiryMessageBubble message={m} />
{idx === 0 && m.senderType === 'tenant' && onCreateOffer && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', mb: 1.5, ml: 0.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<FileText size={13} />}
onClick={onCreateOffer}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
borderColor: '#cbd5e1',
color: '#475569',
'&:hover': { borderColor: '#1e3a5f', color: '#1e3a5f', bgcolor: '#f0f4f8' },
}}
>
Angebot erstellen
</Button>
</Box>
)}
</Box>
))}
<div ref={endRef} />
</Box>
<InquiryReplyComposer inquiryId={inquiry.id} defaultSubject={inquiry.subject} />
<InquiryReplyComposer
inquiryId={inquiry.id}
defaultSubject={inquiry.subject}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
/>
</Box>
)
}
@@ -1,33 +1,28 @@
import {
Box,
CircularProgress,
MenuItem,
Select,
Typography,
type SelectChangeEvent,
} from '@mui/material'
import { useInquiryById, useUpdateInquiryStatus } from '../../hooks/useInquiries'
import type { InquiryStatus } from '../../domain/inquiry'
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ClipboardList } from 'lucide-react'
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
import { InquiryChat } from './InquiryChat'
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
import { InquiryStatusBadge } from './InquiryStatusBadge'
import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry'
interface InquiryDetailPanelProps {
inquiryId: string
}
const STATUS_OPTIONS: { value: InquiryStatus; label: string }[] = [
{ value: 'new', label: 'Neu' },
{ value: 'in_progress', label: 'In Bearbeitung' },
{ value: 'answered', label: 'Beantwortet' },
{ value: 'archived', label: 'Archiviert' },
]
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
const updateStatus = useUpdateInquiryStatus()
const showToast = useToastStore(s => s.showToast)
const markRead = useMarkThreadAsRead()
const [preparationOpen, setPreparationOpen] = useState(false)
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
useEffect(() => {
if (inquiry && !inquiry.isRead) {
markRead.mutate(inquiry.id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inquiry?.id])
if (isLoading) {
return (
@@ -47,74 +42,130 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
)
}
const handleStatusChange = async (e: SelectChangeEvent<InquiryStatus>) => {
const result = await updateStatus.mutateAsync({
id: inquiry.id,
status: e.target.value as InquiryStatus,
})
if (result.error) {
showToast(`Fehler: ${result.error}`, 'error')
} else {
showToast('Status aktualisiert', 'success')
}
}
const isLatentInquiry = !!inquiry.needId
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box
sx={{
px: 2.5,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
alignItems: 'center',
gap: 2,
flexShrink: 0,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 600,
color: '#0f172a',
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inquiry.subject}
</Typography>
</Box>
<InquiryStatusBadge status={inquiry.status} size="medium" />
<Select
size="small"
value={inquiry.status}
onChange={handleStatusChange}
disabled={updateStatus.isPending}
sx={{ minWidth: 180, fontSize: '0.8125rem' }}
<>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box
sx={{
px: 2.5,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
alignItems: 'center',
gap: 2,
flexShrink: 0,
}}
>
{STATUS_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
{o.label}
</MenuItem>
))}
</Select>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 600,
color: '#0f172a',
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inquiry.subject}
</Typography>
</Box>
{isLatentInquiry && (
<Button
size="small"
variant="outlined"
startIcon={<ClipboardList size={14} />}
onClick={() => setPreparationOpen(true)}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#1e3a5f', color: '#1e3a5f', '&:hover': { bgcolor: '#f0f4f8' } }}
>
Vorbereitung starten
</Button>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
<InquiryChat
inquiry={inquiry}
onCreateOffer={() => setOfferWizardOpen(true)}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
/>
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
<RelatedPropertyCardPanel
propertyId={inquiry.propertyId}
inquiryId={inquiry.id}
/>
</Box>
</Box>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
<InquiryChat inquiry={inquiry} />
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
<RelatedPropertyCardPanel propertyId={inquiry.propertyId} />
</Box>
</Box>
</Box>
{preparationOpen && (
<PreparationWizardLazy
inquiryId={inquiry.id}
inquiry={inquiry}
onClose={() => setPreparationOpen(false)}
/>
)}
{offerWizardOpen && (
<OfferCreationWizardLazy
inquiryId={inquiry.id}
propertyId={inquiry.propertyId}
tenantName={inquiry.tenantName}
onClose={() => setOfferWizardOpen(false)}
onAttach={(label) => {
setPendingAttachment({
id: crypto.randomUUID(),
fileName: label,
fileType: 'application/pdf',
generated: true,
})
setOfferWizardOpen(false)
}}
/>
)}
</>
)
}
// Lazy-loaded wizard wrappers to avoid circular imports at module load time
import { lazy, Suspense } from 'react'
const PreparationWizardComponent = lazy(() =>
import('./PreparationWizard').then(m => ({ default: m.PreparationWizard }))
)
const OfferCreationWizardComponent = lazy(() =>
import('./OfferCreationWizard').then(m => ({ default: m.OfferCreationWizard }))
)
import type { Inquiry } from '../../domain/inquiry'
function PreparationWizardLazy(props: { inquiryId: string; inquiry: Inquiry; onClose: () => void }) {
return (
<Suspense fallback={null}>
<PreparationWizardComponent {...props} />
</Suspense>
)
}
function OfferCreationWizardLazy(props: {
inquiryId: string
propertyId: string
tenantName: string
onClose: () => void
onAttach: (label: string) => void
}) {
return (
<Suspense fallback={null}>
<OfferCreationWizardComponent {...props} />
</Suspense>
)
}
@@ -1,7 +1,6 @@
import { Box, Typography } from '@mui/material'
import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import { InquiryStatusBadge } from './InquiryStatusBadge'
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
interface InquiryListRowProps {
@@ -11,6 +10,8 @@ interface InquiryListRowProps {
}
export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return (
<Box
onClick={onClick}
@@ -19,7 +20,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
py: 1.5,
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid',
borderLeftColor: selected ? '#1e3a5f' : 'transparent',
borderLeftColor: selected ? '#1e3a5f' : hasUnread ? '#2563eb' : 'transparent',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'background-color 0.15s, border-color 0.15s',
@@ -27,16 +28,38 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem' }}>
<Typography
variant="body2"
sx={{ fontWeight: hasUnread ? 700 : 600, color: '#0f172a', fontSize: '0.85rem' }}
>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<InquiryStatusBadge status={inquiry.status} />
{hasUnread && (
<Box
sx={{
minWidth: 18,
height: 18,
borderRadius: '50%',
bgcolor: '#2563eb',
color: 'white',
fontSize: '0.6rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
px: inquiry.unreadCount > 9 ? 0.5 : 0,
}}
>
{inquiry.unreadCount}
</Box>
)}
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
fontWeight: hasUnread ? 600 : 500,
color: '#1e293b',
fontSize: '0.8125rem',
mb: 0.5,
@@ -1,16 +1,14 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
Box,
Button,
Checkbox,
CircularProgress,
FormControlLabel,
IconButton,
TextField,
Typography,
} from '@mui/material'
import { Send, Paperclip, X } from 'lucide-react'
import { useSendInquiryReply, useUpdateInquiryStatus } from '../../hooks/useInquiries'
import { useSendInquiryReply } from '../../hooks/useInquiries'
import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry'
import { formatFileSize } from './inquiryUtils'
@@ -19,25 +17,37 @@ interface InquiryReplyComposerProps {
inquiryId: string
defaultSubject: string
onSent?: () => void
pendingAttachment?: Attachment | null
onPendingAttachmentConsumed?: () => void
}
export function InquiryReplyComposer({
inquiryId,
defaultSubject,
onSent,
pendingAttachment,
onPendingAttachmentConsumed,
}: InquiryReplyComposerProps) {
const [subject, setSubject] = useState(
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
)
const [body, setBody] = useState('')
const [markAnswered, setMarkAnswered] = useState(true)
const [attachments, setAttachments] = useState<Attachment[]>([])
const sendReply = useSendInquiryReply()
const updateStatus = useUpdateInquiryStatus()
const showToast = useToastStore(s => s.showToast)
const sending = sendReply.isPending || updateStatus.isPending
useEffect(() => {
if (pendingAttachment) {
setAttachments(prev => {
if (prev.find(a => a.id === pendingAttachment.id)) return prev
return [...prev, pendingAttachment]
})
onPendingAttachmentConsumed?.()
}
}, [pendingAttachment, onPendingAttachmentConsumed])
const sending = sendReply.isPending
const handleAddMockAttachment = () => {
const name = `Anhang_${attachments.length + 1}.pdf`
@@ -69,11 +79,6 @@ export function InquiryReplyComposer({
showToast(`Fehler: ${result.error}`, 'error')
return
}
if (markAnswered) {
await updateStatus.mutateAsync({ id: inquiryId, status: 'answered' })
} else {
await updateStatus.mutateAsync({ id: inquiryId, status: 'in_progress' })
}
showToast('Antwort gesendet', 'success')
setBody('')
setAttachments([])
@@ -119,20 +124,24 @@ export function InquiryReplyComposer({
alignItems: 'center',
gap: 0.75,
bgcolor: 'white',
border: '1px solid #cbd5e1',
border: '1px solid',
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
borderRadius: 1,
px: 1,
py: 0.5,
fontSize: '0.75rem',
bgcolor: a.generated ? '#eff6ff' : 'white',
}}
>
<Paperclip size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
<Paperclip size={12} color={a.generated ? '#2563eb' : undefined} />
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: a.generated ? '#1d4ed8' : undefined }}>
{a.fileName}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
{formatFileSize(a.fileSize)}
</Typography>
{a.fileSize && (
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
{formatFileSize(a.fileSize)}
</Typography>
)}
<IconButton size="small" onClick={() => handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
<X size={12} />
</IconButton>
@@ -142,30 +151,14 @@ export function InquiryReplyComposer({
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
startIcon={<Paperclip size={14} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none' }}
>
Anhang
</Button>
<FormControlLabel
control={
<Checkbox
size="small"
checked={markAnswered}
onChange={e => setMarkAnswered(e.target.checked)}
/>
}
label={
<Typography variant="caption" sx={{ fontSize: '0.8rem' }}>
Status auf "Beantwortet" setzen
</Typography>
}
/>
</Box>
<Button
size="small"
startIcon={<Paperclip size={14} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none' }}
>
Anhang
</Button>
<Button
variant="contained"
size="small"
@@ -0,0 +1,260 @@
import { Box, Chip, Divider, Typography } from '@mui/material'
import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
import type { Inquiry } from '../../domain/inquiry'
interface Props {
draft: InquiryPreparationReportDraft
inquiry: Inquiry
properties: Property[]
}
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
areaSqm: 'Fläche',
rentPricePerSqm: 'Mietpreis',
availabilityDate: 'Verfügbar ab',
leaseTerm: 'Mietlaufzeit',
breakoutOption: 'Breakout-Option',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Objekttyp',
floor: 'Stockwerk',
parking: 'Parkplätze',
ceilingHeightM: 'Deckenhöhe',
loadingDocksCount: 'Laderampen',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
isBarrierFree: 'Barrierefrei',
fitOut: 'Ausbaustandard',
publicTransportScore: 'ÖV-Score',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
description: 'Beschreibung',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Plätze` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props) {
const editableByField = Object.fromEntries(draft.editableFields.map(f => [f.id, f.value]))
const selectedProps = draft.selectedPropertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is Property => !!p)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0, fontFamily: 'Georgia, serif' }}>
{/* Page 1 — Need Summary */}
<Box
sx={{
bgcolor: 'white',
p: 4,
minHeight: 600,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
borderRadius: 1,
mb: 2,
}}
>
<Box sx={{ borderBottom: '3px solid #1e3a5f', pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}>
Objektvorschlag
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
</Typography>
</Box>
{editableByField['intro'] && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: '#1e293b' }}>
{editableByField['intro']}
</Typography>
)}
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Ihre Anfrage
</Typography>
<Typography variant="body2" sx={{ color: '#374151', mb: 0.5 }}>
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<Typography variant="body2" sx={{ color: '#374151' }}>
<strong>Betreff:</strong> {inquiry.subject}
</Typography>
</Box>
{editableByField['highlights'] && (
<>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Warum diese Objekte passen
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151', mb: 3 }}>
{editableByField['highlights']}
</Typography>
</>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{selectedProps.map(p => (
<Chip
key={p.id}
label={p.title}
size="small"
icon={<Building2 size={12} />}
sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }}
/>
))}
</Box>
</Box>
{/* Page 2+ — One section per property */}
{selectedProps.map((property, idx) => {
const selection = draft.fieldSelections.find(fs => fs.propertyId === property.id)
const optionalFields = selection?.selectedOptionalFields ?? []
const image = property.images?.[0]
return (
<Box key={property.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Divider sx={{ flex: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
Objekt {idx + 1} von {selectedProps.length}
</Typography>
<Divider sx={{ flex: 1 }} />
</Box>
<Box
sx={{
bgcolor: 'white',
p: 3,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
borderRadius: 1,
mb: 2,
}}
>
<Box sx={{ display: 'flex', gap: 2, mb: 2.5 }}>
{/* Map placeholder */}
<Box
sx={{
width: 160,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!property.mapImageUrl && <MapPin size={28} color="#94a3b8" />}
</Box>
{/* Property photo */}
<Box
sx={{
flex: 1,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!image && <Building2 size={28} color="#94a3b8" />}
</Box>
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 0.5, fontFamily: 'inherit' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<MapPin size={13} />
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Ruler size={13} />
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Calendar size={13} />
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
</Box>
</Box>
{optionalFields.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 0.75,
bgcolor: '#f8fafc',
borderRadius: 1,
p: 1.5,
}}
>
{optionalFields.map(key => {
const val = getFieldValue(property, key)
if (!val) return null
return (
<Box key={key}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.78rem' }}>
{val}
</Typography>
</Box>
)
})}
</Box>
)}
</Box>
</Box>
)
})}
{editableByField['next_steps'] && (
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Nächste Schritte
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151' }}>
{editableByField['next_steps']}
</Typography>
</Box>
)}
</Box>
)
}
@@ -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:0011: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:0011: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>
)
}
@@ -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>
)
}
@@ -1,15 +1,29 @@
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { matchService } from '../../services/matchService'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps {
propertyId: string
inquiryId: string
}
export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPanelProps) {
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate()
const [additionalMatches, setAdditionalMatches] = useState<AdditionalPropertyMatch[]>([])
const [loadingMatches, setLoadingMatches] = useState(false)
useEffect(() => {
setLoadingMatches(true)
matchService
.getAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
.then(setAdditionalMatches)
.finally(() => setLoadingMatches(false))
}, [inquiryId, propertyId])
if (isLoading) {
return (
@@ -102,10 +116,91 @@ export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPane
size="small"
endIcon={<ArrowRight size={14} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', mt: 'auto' }}
sx={{ textTransform: 'none' }}
>
Objekt ansehen
</Button>
{/* Weitere Matches */}
<Divider sx={{ my: 0.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Trophy size={14} color="#d97706" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
Weitere passende Objekte
</Typography>
</Box>
{loadingMatches ? (
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
Keine weiteren Matches
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{additionalMatches.map(m => (
<AdditionalMatchCard key={m.propertyId} match={m} />
))}
</Box>
)}
</Box>
)
}
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: 'white',
}}
>
{match.imageUrl && (
<Box
sx={{
height: 70,
backgroundImage: `url(${match.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
<Box sx={{ p: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3 }}>
{match.title}
</Typography>
<Box
sx={{
px: 0.75,
py: 0.125,
borderRadius: 1,
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
fontWeight: 700,
fontSize: '0.65rem',
flexShrink: 0,
ml: 0.5,
}}
>
{match.matchScore}%
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block' }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
</Typography>
<Button
size="small"
endIcon={<ArrowRight size={11} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#1e3a5f' }}
>
Ansehen
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,150 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
interface Props {
propertyTitle: string
value: ReportObjectFieldSelection
onChange: (v: ReportObjectFieldSelection) => void
}
type FieldGroup = {
label: string
fields: { key: ReportObjectFieldKey; label: string }[]
}
const FIELD_GROUPS: FieldGroup[] = [
{
label: 'Grunddaten',
fields: [
{ key: 'areaSqm', label: 'Fläche (m²)' },
{ key: 'rentPricePerSqm', label: 'Mietpreis (CHF/m²/Jahr)' },
{ key: 'availabilityDate', label: 'Verfügbar ab' },
{ key: 'leaseTerm', label: 'Mietlaufzeit' },
{ key: 'breakoutOption', label: 'Breakout-Option' },
{ key: 'currentTenant', label: 'Aktueller Mieter' },
{ key: 'propertyNumber', label: 'Objektnummer' },
{ key: 'assetType', label: 'Objekttyp' },
],
},
{
label: 'Technische Merkmale',
fields: [
{ key: 'floor', label: 'Stockwerk' },
{ key: 'parking', label: 'Parkplätze' },
{ key: 'ceilingHeightM', label: 'Deckenhöhe (m)' },
{ key: 'loadingDocksCount', label: 'Laderampen' },
{ key: 'powerSupplyKva', label: 'Stromanschluss (kVA)' },
{ key: 'hasServerRoom', label: 'Serverraum' },
{ key: 'isBarrierFree', label: 'Barrierefrei' },
{ key: 'fitOut', label: 'Ausbaustandard' },
{ key: 'publicTransportScore', label: 'ÖV-Score' },
],
},
{
label: 'Weiche Faktoren',
fields: [
{ key: 'prestigeScore', label: 'Prestige' },
{ key: 'visibilityScore', label: 'Sichtbarkeit' },
{ key: 'footfallScore', label: 'Passantenfrequenz' },
{ key: 'commuterAccessScore', label: 'Pendleranbindung' },
{ key: 'talentAccessScore', label: 'Talentpool' },
{ key: 'esgScore', label: 'ESG-Bewertung' },
{ key: 'flexibilityScore', label: 'Flexibilität' },
{ key: 'expansionPotentialScore', label: 'Expansionspotenzial' },
{ key: 'taxEnvironmentScore', label: 'Steuerumgebung' },
],
},
{
label: 'Beschreibung & Medien',
fields: [
{ key: 'description', label: 'Beschreibung' },
{ key: 'units', label: 'Stockwerkstruktur' },
],
},
{
label: 'Datenqualität',
fields: [
{ key: 'dataQuality', label: 'Datenqualität' },
{ key: 'softFactors', label: 'Weiche Faktoren (Gesamt)' },
],
},
]
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
const isSelected = (key: ReportObjectFieldKey) =>
value.selectedOptionalFields.includes(key)
const toggle = (key: ReportObjectFieldKey) => {
const next = isSelected(key)
? value.selectedOptionalFields.filter(k => k !== key)
: [...value.selectedOptionalFields, key]
onChange({ ...value, selectedOptionalFields: next })
}
const selectAll = (keys: ReportObjectFieldKey[]) => {
const next = Array.from(new Set([...value.selectedOptionalFields, ...keys]))
onChange({ ...value, selectedOptionalFields: next })
}
const deselectAll = (keys: ReportObjectFieldKey[]) => {
onChange({ ...value, selectedOptionalFields: value.selectedOptionalFields.filter(k => !keys.includes(k)) })
}
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1 }}>
Felder für: {propertyTitle}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 1.5 }}>
Pflichtfelder (immer enthalten): Titel, Standort, Karte, Fotos
</Typography>
{FIELD_GROUPS.map(group => {
const groupKeys = group.fields.map(f => f.key)
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
return (
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>
{group.label}
</Typography>
<Typography variant="caption" sx={{ ml: 1, color: '#64748b', alignSelf: 'center' }}>
({groupKeys.filter(k => value.selectedOptionalFields.includes(k)).length}/{groupKeys.length})
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0, pb: 1 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
<Button size="small" onClick={() => selectAll(groupKeys)} sx={{ textTransform: 'none', fontSize: '0.7rem', p: '2px 8px', minWidth: 0 }}>
Alle
</Button>
<Button size="small" onClick={() => deselectAll(groupKeys)} sx={{ textTransform: 'none', fontSize: '0.7rem', p: '2px 8px', minWidth: 0 }}>
Keine
</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0 }}>
{group.fields.map(f => (
<FormControlLabel
key={f.key}
control={
<Checkbox
size="small"
checked={isSelected(f.key)}
onChange={() => toggle(f.key)}
sx={{ py: 0.25 }}
/>
}
label={<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{f.label}</Typography>}
sx={{ m: 0 }}
/>
))}
</Box>
</AccordionDetails>
</Accordion>
)
})}
</Box>
)
}