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:
@@ -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',
|
||||
|
||||
@@ -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')} m²` : 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: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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user