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 { Box, Paper, Typography } from '@mui/material'
|
||||||
import { Building2 } from 'lucide-react'
|
import { Building2 } from 'lucide-react'
|
||||||
import type { Inquiry } from '../../domain/inquiry'
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
import { InquiryStatusBadge } from './InquiryStatusBadge'
|
|
||||||
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
|
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
|
||||||
|
|
||||||
interface InquiryCardProps {
|
interface InquiryCardProps {
|
||||||
@@ -11,6 +10,8 @@ interface InquiryCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
|
export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
|
||||||
|
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
onClick={onClick}
|
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 }}>
|
<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.tenantName}
|
||||||
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 500,
|
fontWeight: hasUnread ? 600 : 500,
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { Box } from '@mui/material'
|
import { Box, Button } from '@mui/material'
|
||||||
import type { Inquiry } from '../../domain/inquiry'
|
import { FileText } from 'lucide-react'
|
||||||
|
import type { Inquiry, Attachment } from '../../domain/inquiry'
|
||||||
import { InquiryMessageBubble } from './InquiryMessageBubble'
|
import { InquiryMessageBubble } from './InquiryMessageBubble'
|
||||||
import { InquiryReplyComposer } from './InquiryReplyComposer'
|
import { InquiryReplyComposer } from './InquiryReplyComposer'
|
||||||
|
|
||||||
interface InquiryChatProps {
|
interface InquiryChatProps {
|
||||||
inquiry: Inquiry
|
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)
|
const endRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -18,12 +27,38 @@ export function InquiryChat({ inquiry }: InquiryChatProps) {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, overflow: 'hidden' }}>
|
||||||
<Box sx={{ overflowY: 'auto', flex: 1, p: 2, bgcolor: 'white' }}>
|
<Box sx={{ overflowY: 'auto', flex: 1, p: 2, bgcolor: 'white' }}>
|
||||||
{inquiry.thread.map(m => (
|
{inquiry.thread.map((m, idx) => (
|
||||||
<InquiryMessageBubble key={m.id} message={m} />
|
<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} />
|
<div ref={endRef} />
|
||||||
</Box>
|
</Box>
|
||||||
<InquiryReplyComposer inquiryId={inquiry.id} defaultSubject={inquiry.subject} />
|
<InquiryReplyComposer
|
||||||
|
inquiryId={inquiry.id}
|
||||||
|
defaultSubject={inquiry.subject}
|
||||||
|
pendingAttachment={pendingAttachment}
|
||||||
|
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
import {
|
import { useEffect, useState } from 'react'
|
||||||
Box,
|
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||||
CircularProgress,
|
import { ClipboardList } from 'lucide-react'
|
||||||
MenuItem,
|
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||||
Select,
|
|
||||||
Typography,
|
|
||||||
type SelectChangeEvent,
|
|
||||||
} from '@mui/material'
|
|
||||||
import { useInquiryById, useUpdateInquiryStatus } from '../../hooks/useInquiries'
|
|
||||||
import type { InquiryStatus } from '../../domain/inquiry'
|
|
||||||
import { InquiryChat } from './InquiryChat'
|
import { InquiryChat } from './InquiryChat'
|
||||||
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
|
||||||
import { InquiryStatusBadge } from './InquiryStatusBadge'
|
import type { Attachment } from '../../domain/inquiry'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
|
||||||
|
|
||||||
interface InquiryDetailPanelProps {
|
interface InquiryDetailPanelProps {
|
||||||
inquiryId: string
|
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) {
|
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
||||||
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
|
||||||
const updateStatus = useUpdateInquiryStatus()
|
const markRead = useMarkThreadAsRead()
|
||||||
const showToast = useToastStore(s => s.showToast)
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -47,74 +42,130 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStatusChange = async (e: SelectChangeEvent<InquiryStatus>) => {
|
const isLatentInquiry = !!inquiry.needId
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<>
|
||||||
<Box
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
sx={{
|
<Box
|
||||||
px: 2.5,
|
sx={{
|
||||||
py: 1.5,
|
px: 2.5,
|
||||||
borderBottom: '1px solid #e2e8f0',
|
py: 1.5,
|
||||||
bgcolor: 'white',
|
borderBottom: '1px solid #e2e8f0',
|
||||||
display: 'flex',
|
bgcolor: 'white',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
gap: 2,
|
alignItems: 'center',
|
||||||
flexShrink: 0,
|
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' }}
|
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map(o => (
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||||
{o.label}
|
{inquiry.tenantName}
|
||||||
</MenuItem>
|
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
))}
|
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
|
||||||
</Select>
|
</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>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
|
{preparationOpen && (
|
||||||
<InquiryChat inquiry={inquiry} />
|
<PreparationWizardLazy
|
||||||
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
|
inquiryId={inquiry.id}
|
||||||
<RelatedPropertyCardPanel propertyId={inquiry.propertyId} />
|
inquiry={inquiry}
|
||||||
</Box>
|
onClose={() => setPreparationOpen(false)}
|
||||||
</Box>
|
/>
|
||||||
</Box>
|
)}
|
||||||
|
|
||||||
|
{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 { Box, Typography } from '@mui/material'
|
||||||
import { Building2 } from 'lucide-react'
|
import { Building2 } from 'lucide-react'
|
||||||
import type { Inquiry } from '../../domain/inquiry'
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
import { InquiryStatusBadge } from './InquiryStatusBadge'
|
|
||||||
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
|
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
|
||||||
|
|
||||||
interface InquiryListRowProps {
|
interface InquiryListRowProps {
|
||||||
@@ -11,6 +10,8 @@ interface InquiryListRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
|
export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
|
||||||
|
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -19,7 +20,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
|
|||||||
py: 1.5,
|
py: 1.5,
|
||||||
borderBottom: '1px solid #e2e8f0',
|
borderBottom: '1px solid #e2e8f0',
|
||||||
borderLeft: '3px solid',
|
borderLeft: '3px solid',
|
||||||
borderLeftColor: selected ? '#1e3a5f' : 'transparent',
|
borderLeftColor: selected ? '#1e3a5f' : hasUnread ? '#2563eb' : 'transparent',
|
||||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'background-color 0.15s, border-color 0.15s',
|
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 }}>
|
<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.tenantName}
|
||||||
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 500,
|
fontWeight: hasUnread ? 600 : 500,
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
fontSize: '0.8125rem',
|
fontSize: '0.8125rem',
|
||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControlLabel,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Send, Paperclip, X } from 'lucide-react'
|
import { Send, Paperclip, X } from 'lucide-react'
|
||||||
import { useSendInquiryReply, useUpdateInquiryStatus } from '../../hooks/useInquiries'
|
import { useSendInquiryReply } from '../../hooks/useInquiries'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import type { Attachment } from '../../domain/inquiry'
|
import type { Attachment } from '../../domain/inquiry'
|
||||||
import { formatFileSize } from './inquiryUtils'
|
import { formatFileSize } from './inquiryUtils'
|
||||||
@@ -19,25 +17,37 @@ interface InquiryReplyComposerProps {
|
|||||||
inquiryId: string
|
inquiryId: string
|
||||||
defaultSubject: string
|
defaultSubject: string
|
||||||
onSent?: () => void
|
onSent?: () => void
|
||||||
|
pendingAttachment?: Attachment | null
|
||||||
|
onPendingAttachmentConsumed?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InquiryReplyComposer({
|
export function InquiryReplyComposer({
|
||||||
inquiryId,
|
inquiryId,
|
||||||
defaultSubject,
|
defaultSubject,
|
||||||
onSent,
|
onSent,
|
||||||
|
pendingAttachment,
|
||||||
|
onPendingAttachmentConsumed,
|
||||||
}: InquiryReplyComposerProps) {
|
}: InquiryReplyComposerProps) {
|
||||||
const [subject, setSubject] = useState(
|
const [subject, setSubject] = useState(
|
||||||
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
|
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
|
||||||
)
|
)
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [markAnswered, setMarkAnswered] = useState(true)
|
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
|
|
||||||
const sendReply = useSendInquiryReply()
|
const sendReply = useSendInquiryReply()
|
||||||
const updateStatus = useUpdateInquiryStatus()
|
|
||||||
const showToast = useToastStore(s => s.showToast)
|
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 handleAddMockAttachment = () => {
|
||||||
const name = `Anhang_${attachments.length + 1}.pdf`
|
const name = `Anhang_${attachments.length + 1}.pdf`
|
||||||
@@ -69,11 +79,6 @@ export function InquiryReplyComposer({
|
|||||||
showToast(`Fehler: ${result.error}`, 'error')
|
showToast(`Fehler: ${result.error}`, 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (markAnswered) {
|
|
||||||
await updateStatus.mutateAsync({ id: inquiryId, status: 'answered' })
|
|
||||||
} else {
|
|
||||||
await updateStatus.mutateAsync({ id: inquiryId, status: 'in_progress' })
|
|
||||||
}
|
|
||||||
showToast('Antwort gesendet', 'success')
|
showToast('Antwort gesendet', 'success')
|
||||||
setBody('')
|
setBody('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
@@ -119,20 +124,24 @@ export function InquiryReplyComposer({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.75,
|
gap: 0.75,
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
border: '1px solid #cbd5e1',
|
border: '1px solid',
|
||||||
|
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
px: 1,
|
px: 1,
|
||||||
py: 0.5,
|
py: 0.5,
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: a.generated ? '#eff6ff' : 'white',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paperclip size={12} />
|
<Paperclip size={12} color={a.generated ? '#2563eb' : undefined} />
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: a.generated ? '#1d4ed8' : undefined }}>
|
||||||
{a.fileName}
|
{a.fileName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
|
{a.fileSize && (
|
||||||
{formatFileSize(a.fileSize)}
|
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
|
||||||
</Typography>
|
{formatFileSize(a.fileSize)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
<IconButton size="small" onClick={() => handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
|
<IconButton size="small" onClick={() => handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</IconButton>
|
</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.5, justifyContent: 'space-between' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Button
|
||||||
<Button
|
size="small"
|
||||||
size="small"
|
startIcon={<Paperclip size={14} />}
|
||||||
startIcon={<Paperclip size={14} />}
|
onClick={handleAddMockAttachment}
|
||||||
onClick={handleAddMockAttachment}
|
sx={{ textTransform: 'none' }}
|
||||||
sx={{ textTransform: 'none' }}
|
>
|
||||||
>
|
Anhang
|
||||||
Anhang
|
</Button>
|
||||||
</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
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
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 { useEffect, useState } from 'react'
|
||||||
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-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 { useNavigate } from 'react-router'
|
||||||
import { usePropertyById } from '../../hooks/useProperties'
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
|
import { matchService } from '../../services/matchService'
|
||||||
|
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
|
||||||
|
|
||||||
interface RelatedPropertyCardPanelProps {
|
interface RelatedPropertyCardPanelProps {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
|
inquiryId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPanelProps) {
|
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
|
||||||
const { data: property, isLoading } = usePropertyById(propertyId)
|
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||||
const navigate = useNavigate()
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -102,10 +116,91 @@ export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPane
|
|||||||
size="small"
|
size="small"
|
||||||
endIcon={<ArrowRight size={14} />}
|
endIcon={<ArrowRight size={14} />}
|
||||||
onClick={() => navigate('/supply/properties')}
|
onClick={() => navigate('/supply/properties')}
|
||||||
sx={{ textTransform: 'none', mt: 'auto' }}
|
sx={{ textTransform: 'none' }}
|
||||||
>
|
>
|
||||||
Objekt ansehen
|
Objekt ansehen
|
||||||
</Button>
|
</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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface AdditionalPropertyMatch {
|
||||||
|
propertyId: string
|
||||||
|
title: string
|
||||||
|
location: string
|
||||||
|
areaSqm: number
|
||||||
|
rentPricePerSqm: number
|
||||||
|
matchScore: number
|
||||||
|
imageUrl?: string
|
||||||
|
}
|
||||||
@@ -30,7 +30,10 @@ export interface Inquiry {
|
|||||||
tenantEmail?: string
|
tenantEmail?: string
|
||||||
subject: string
|
subject: string
|
||||||
message: string
|
message: string
|
||||||
status: InquiryStatus
|
status?: InquiryStatus
|
||||||
|
unreadCount: number
|
||||||
|
isRead: boolean
|
||||||
|
lastReadAt?: string
|
||||||
matchScore?: number
|
matchScore?: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export type ReportObjectFieldKey =
|
||||||
|
| 'title' | 'location' | 'areaSqm' | 'rentPricePerSqm' | 'availabilityDate'
|
||||||
|
| 'leaseTerm' | 'breakoutOption' | 'currentTenant' | 'propertyNumber'
|
||||||
|
| 'assetType' | 'floor' | 'parking' | 'publicTransportScore'
|
||||||
|
| 'ceilingHeightM' | 'loadingDocksCount' | 'powerSupplyKva'
|
||||||
|
| 'hasServerRoom' | 'isBarrierFree' | 'fitOut'
|
||||||
|
| 'prestigeScore' | 'visibilityScore' | 'footfallScore' | 'commuterAccessScore'
|
||||||
|
| 'talentAccessScore' | 'esgScore' | 'flexibilityScore'
|
||||||
|
| 'expansionPotentialScore' | 'taxEnvironmentScore'
|
||||||
|
| 'description' | 'images' | 'mapImageUrl' | 'units'
|
||||||
|
| 'softFactors' | 'dataQuality'
|
||||||
|
|
||||||
|
export interface ReportObjectFieldSelection {
|
||||||
|
propertyId: string
|
||||||
|
mandatoryFields: ReportObjectFieldKey[]
|
||||||
|
selectedOptionalFields: ReportObjectFieldKey[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportEditableField {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
fieldType: 'text' | 'textarea'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InquiryPreparationReportDraft {
|
||||||
|
id: string
|
||||||
|
inquiryId: string
|
||||||
|
selectedPropertyIds: string[]
|
||||||
|
fieldSelections: ReportObjectFieldSelection[]
|
||||||
|
marketSignalPropertyId?: string
|
||||||
|
editableFields: ReportEditableField[]
|
||||||
|
status: 'draft' | 'finalized'
|
||||||
|
pdfUrl?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export interface ViewingAppointmentOption {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
timeSlot: string
|
||||||
|
contactPerson?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferEditableField {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
fieldType: 'text' | 'textarea'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferReportDraft {
|
||||||
|
id: string
|
||||||
|
inquiryId: string
|
||||||
|
propertyId: string
|
||||||
|
editableFields: OfferEditableField[]
|
||||||
|
viewingAppointments: ViewingAppointmentOption[]
|
||||||
|
status: 'draft' | 'finalized'
|
||||||
|
pdfUrl?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -145,6 +145,7 @@ export interface Property {
|
|||||||
|
|
||||||
propertyNumber?: string
|
propertyNumber?: string
|
||||||
units?: PropertyUnit[]
|
units?: PropertyUnit[]
|
||||||
|
mapImageUrl?: string
|
||||||
|
|
||||||
leaseTerm?: string
|
leaseTerm?: string
|
||||||
leaseStartDate?: string
|
leaseStartDate?: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { inquiryService } from '../services/inquiryService'
|
import { inquiryService } from '../services/inquiryService'
|
||||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||||
import type { InquiryStatus, Attachment } from '../domain/inquiry'
|
import type { Attachment } from '../domain/inquiry'
|
||||||
|
|
||||||
export function useActiveInquiries(filters?: InquiryFilters) {
|
export function useActiveInquiries(filters?: InquiryFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -37,12 +37,11 @@ export function useSendInquiryReply() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateInquiryStatus() {
|
export function useMarkThreadAsRead() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: InquiryStatus }) =>
|
mutationFn: (id: string) => inquiryService.markThreadAsRead(id),
|
||||||
inquiryService.updateInquiryStatus(id, status),
|
onSuccess: (_data, id) => {
|
||||||
onSuccess: (_data, { id }) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['inquiry', id] })
|
qc.invalidateQueries({ queryKey: ['inquiry', id] })
|
||||||
qc.invalidateQueries({ queryKey: ['inquiries'] })
|
qc.invalidateQueries({ queryKey: ['inquiries'] })
|
||||||
},
|
},
|
||||||
|
|||||||
+54
-30
@@ -1,11 +1,12 @@
|
|||||||
import type { Inquiry } from '../domain/inquiry'
|
import type { Inquiry } from '../domain/inquiry'
|
||||||
|
|
||||||
export const mockInquiries: Inquiry[] = [
|
export const mockInquiries: Inquiry[] = [
|
||||||
// 1 — NEW
|
// 1 — UNREAD
|
||||||
{
|
{
|
||||||
id: 'inq-001',
|
id: 'inq-001',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
propertyId: 'prop-001',
|
propertyId: 'prop-001',
|
||||||
|
needId: 'need-001',
|
||||||
tenantName: 'Sandra Meier',
|
tenantName: 'Sandra Meier',
|
||||||
tenantCompany: 'Innovatech AG',
|
tenantCompany: 'Innovatech AG',
|
||||||
tenantEmail: 'sandra.meier@innovatech.ch',
|
tenantEmail: 'sandra.meier@innovatech.ch',
|
||||||
@@ -13,6 +14,8 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
message:
|
message:
|
||||||
'Guten Tag\n\nWir interessieren uns für die Bürofläche an der Zollstrasse 12 in Zürich-West. Könnten wir bitte einen Besichtigungstermin in der kommenden Woche vereinbaren?\n\nFreundliche Grüsse\nSandra Meier',
|
'Guten Tag\n\nWir interessieren uns für die Bürofläche an der Zollstrasse 12 in Zürich-West. Könnten wir bitte einen Besichtigungstermin in der kommenden Woche vereinbaren?\n\nFreundliche Grüsse\nSandra Meier',
|
||||||
status: 'new',
|
status: 'new',
|
||||||
|
unreadCount: 1,
|
||||||
|
isRead: false,
|
||||||
matchScore: 91,
|
matchScore: 91,
|
||||||
createdAt: '2026-05-17T08:32:00Z',
|
createdAt: '2026-05-17T08:32:00Z',
|
||||||
updatedAt: '2026-05-17T08:32:00Z',
|
updatedAt: '2026-05-17T08:32:00Z',
|
||||||
@@ -31,21 +34,24 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 2 — NEW
|
// 2 — UNREAD
|
||||||
{
|
{
|
||||||
id: 'inq-002',
|
id: ‘inq-002’,
|
||||||
organizationId: 'org-wincasa',
|
organizationId: ‘org-wincasa’,
|
||||||
propertyId: 'prop-009',
|
propertyId: ‘prop-009’,
|
||||||
tenantName: 'Markus Frei',
|
needId: ‘need-002’,
|
||||||
tenantCompany: 'Frei Logistik AG',
|
tenantName: ‘Markus Frei’,
|
||||||
tenantEmail: 'm.frei@frei-logistik.ch',
|
tenantCompany: ‘Frei Logistik AG’,
|
||||||
subject: 'Lagerfläche Winterthur — Verfügbarkeit?',
|
tenantEmail: ‘m.frei@frei-logistik.ch’,
|
||||||
|
subject: ‘Lagerfläche Winterthur — Verfügbarkeit?’,
|
||||||
message:
|
message:
|
||||||
'Sehr geehrte Damen und Herren\n\nIst Ihre Logistikfläche in Winterthur noch verfügbar? Wir benötigen ab September ca. 3’000 m² mit Rampe und Hochregalmöglichkeit.\n\nMit freundlichen Grüssen\nMarkus Frei',
|
‘Sehr geehrte Damen und Herren\n\nIst Ihre Logistikfläche in Winterthur noch verfügbar? Wir benötigen ab September ca. 3’000 m² mit Rampe und Hochregalmöglichkeit.\n\nMit freundlichen Grüssen\nMarkus Frei’,
|
||||||
status: 'new',
|
status: ‘new’,
|
||||||
|
unreadCount: 1,
|
||||||
|
isRead: false,
|
||||||
matchScore: 85,
|
matchScore: 85,
|
||||||
createdAt: '2026-05-17T11:14:00Z',
|
createdAt: ‘2026-05-17T11:14:00Z’,
|
||||||
updatedAt: '2026-05-17T11:14:00Z',
|
updatedAt: ‘2026-05-17T11:14:00Z’,
|
||||||
thread: [
|
thread: [
|
||||||
{
|
{
|
||||||
id: 'msg-002-1',
|
id: 'msg-002-1',
|
||||||
@@ -61,7 +67,7 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 3 — NEW
|
// 3 — READ
|
||||||
{
|
{
|
||||||
id: 'inq-003',
|
id: 'inq-003',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
@@ -73,6 +79,9 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
message:
|
message:
|
||||||
'Guten Tag\n\nKönnten Sie uns bitte Detailunterlagen sowie einen Grundriss der Retailfläche am Löwenplatz zukommen lassen? Wir planen die Eröffnung unseres neuen Flagship-Stores im Frühjahr 2026.\n\nBesten Dank\nLaura Bianchi',
|
'Guten Tag\n\nKönnten Sie uns bitte Detailunterlagen sowie einen Grundriss der Retailfläche am Löwenplatz zukommen lassen? Wir planen die Eröffnung unseres neuen Flagship-Stores im Frühjahr 2026.\n\nBesten Dank\nLaura Bianchi',
|
||||||
status: 'new',
|
status: 'new',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: '2026-05-16T16:10:00Z',
|
||||||
matchScore: 88,
|
matchScore: 88,
|
||||||
createdAt: '2026-05-16T15:50:00Z',
|
createdAt: '2026-05-16T15:50:00Z',
|
||||||
updatedAt: '2026-05-16T15:50:00Z',
|
updatedAt: '2026-05-16T15:50:00Z',
|
||||||
@@ -91,11 +100,12 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 4 — IN_PROGRESS
|
// 4 — UNREAD (3 messages)
|
||||||
{
|
{
|
||||||
id: 'inq-004',
|
id: 'inq-004',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
propertyId: 'prop-012',
|
propertyId: 'prop-012',
|
||||||
|
needId: 'need-004',
|
||||||
tenantName: 'Daniel Hofer',
|
tenantName: 'Daniel Hofer',
|
||||||
tenantCompany: 'Hofer Treuhand AG',
|
tenantCompany: 'Hofer Treuhand AG',
|
||||||
tenantEmail: 'd.hofer@hofer-treuhand.ch',
|
tenantEmail: 'd.hofer@hofer-treuhand.ch',
|
||||||
@@ -103,6 +113,8 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
message:
|
message:
|
||||||
'Sehr geehrte Damen und Herren\n\nDie Büroflächen in Zug entsprechen genau unserem Profil. Bitte senden Sie uns die detaillierten Mietkonditionen sowie Informationen zu Nebenkosten und Mindestmietdauer.\n\nFreundliche Grüsse\nDaniel Hofer',
|
'Sehr geehrte Damen und Herren\n\nDie Büroflächen in Zug entsprechen genau unserem Profil. Bitte senden Sie uns die detaillierten Mietkonditionen sowie Informationen zu Nebenkosten und Mindestmietdauer.\n\nFreundliche Grüsse\nDaniel Hofer',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
|
unreadCount: 3,
|
||||||
|
isRead: false,
|
||||||
matchScore: 93,
|
matchScore: 93,
|
||||||
createdAt: '2026-05-14T09:20:00Z',
|
createdAt: '2026-05-14T09:20:00Z',
|
||||||
updatedAt: '2026-05-15T14:00:00Z',
|
updatedAt: '2026-05-15T14:00:00Z',
|
||||||
@@ -134,7 +146,7 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 5 — IN_PROGRESS
|
// 5 — READ
|
||||||
{
|
{
|
||||||
id: 'inq-005',
|
id: 'inq-005',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
@@ -146,6 +158,9 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
message:
|
message:
|
||||||
'Guten Tag\n\nDie Bürofläche an der Thurgauerstrasse interessiert uns sehr. Könnten Sie uns einen aktuellen Grundriss sowie Angaben zum Ausbaustand zukommen lassen?\n\nFreundliche Grüsse\nPetra Wyss',
|
'Guten Tag\n\nDie Bürofläche an der Thurgauerstrasse interessiert uns sehr. Könnten Sie uns einen aktuellen Grundriss sowie Angaben zum Ausbaustand zukommen lassen?\n\nFreundliche Grüsse\nPetra Wyss',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: '2026-05-14T17:00:00Z',
|
||||||
matchScore: 79,
|
matchScore: 79,
|
||||||
createdAt: '2026-05-13T13:10:00Z',
|
createdAt: '2026-05-13T13:10:00Z',
|
||||||
updatedAt: '2026-05-14T16:20:00Z',
|
updatedAt: '2026-05-14T16:20:00Z',
|
||||||
@@ -186,21 +201,24 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 6 — ANSWERED
|
// 6 — READ
|
||||||
{
|
{
|
||||||
id: 'inq-006',
|
id: ‘inq-006’,
|
||||||
organizationId: 'org-wincasa',
|
organizationId: ‘org-wincasa’,
|
||||||
propertyId: 'prop-002',
|
propertyId: ‘prop-002’,
|
||||||
tenantName: 'Thomas Brun',
|
tenantName: ‘Thomas Brun’,
|
||||||
tenantCompany: 'Schweizer Logistik GmbH',
|
tenantCompany: ‘Schweizer Logistik GmbH’,
|
||||||
tenantEmail: 't.brun@swisslogistik.ch',
|
tenantEmail: ‘t.brun@swisslogistik.ch’,
|
||||||
subject: 'Lagerfläche Hardstrasse Basel',
|
subject: ‘Lagerfläche Hardstrasse Basel’,
|
||||||
message:
|
message:
|
||||||
'Guten Tag\n\nWir suchen ab Juli eine Lagerfläche in Basel mit ca. 2’500 m². Ihre Liegenschaft an der Hardstrasse entspricht unserem Profil. Können wir besichtigen?\n\nThomas Brun',
|
‘Guten Tag\n\nWir suchen ab Juli eine Lagerfläche in Basel mit ca. 2’500 m². Ihre Liegenschaft an der Hardstrasse entspricht unserem Profil. Können wir besichtigen?\n\nThomas Brun’,
|
||||||
status: 'answered',
|
status: ‘answered’,
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: ‘2026-05-10T10:00:00Z’,
|
||||||
matchScore: 87,
|
matchScore: 87,
|
||||||
createdAt: '2026-05-08T10:00:00Z',
|
createdAt: ‘2026-05-08T10:00:00Z’,
|
||||||
updatedAt: '2026-05-10T09:30:00Z',
|
updatedAt: ‘2026-05-10T09:30:00Z’,
|
||||||
thread: [
|
thread: [
|
||||||
{
|
{
|
||||||
id: 'msg-006-1',
|
id: 'msg-006-1',
|
||||||
@@ -236,7 +254,7 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 7 — ANSWERED
|
// 7 — READ
|
||||||
{
|
{
|
||||||
id: 'inq-007',
|
id: 'inq-007',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
@@ -248,6 +266,9 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
message:
|
message:
|
||||||
'Guten Tag\n\nWir würden gerne einen Mietvertrag für die Bürofläche im Dreispitz aufsetzen. Bitte senden Sie uns die Vertragsvorlage.\n\nCaroline Roth',
|
'Guten Tag\n\nWir würden gerne einen Mietvertrag für die Bürofläche im Dreispitz aufsetzen. Bitte senden Sie uns die Vertragsvorlage.\n\nCaroline Roth',
|
||||||
status: 'answered',
|
status: 'answered',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: '2026-05-06T12:00:00Z',
|
||||||
matchScore: 82,
|
matchScore: 82,
|
||||||
createdAt: '2026-05-05T09:00:00Z',
|
createdAt: '2026-05-05T09:00:00Z',
|
||||||
updatedAt: '2026-05-06T11:00:00Z',
|
updatedAt: '2026-05-06T11:00:00Z',
|
||||||
@@ -279,7 +300,7 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 8 — ARCHIVED
|
// 8 — READ (archived)
|
||||||
{
|
{
|
||||||
id: 'inq-008',
|
id: 'inq-008',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
@@ -291,6 +312,9 @@ export const mockInquiries: Inquiry[] = [
|
|||||||
message:
|
message:
|
||||||
'Guten Tag\n\nWir suchen eine Produktionsfläche im Raum Bern. Ihre Liegenschaft erscheint passend, jedoch ist die Hallenhöhe etwas niedrig. Können Sie hier mehr Details geben?\n\nJonas Keller',
|
'Guten Tag\n\nWir suchen eine Produktionsfläche im Raum Bern. Ihre Liegenschaft erscheint passend, jedoch ist die Hallenhöhe etwas niedrig. Können Sie hier mehr Details geben?\n\nJonas Keller',
|
||||||
status: 'archived',
|
status: 'archived',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: '2026-04-25T11:00:00Z',
|
||||||
matchScore: 64,
|
matchScore: 64,
|
||||||
createdAt: '2026-04-20T14:30:00Z',
|
createdAt: '2026-04-20T14:30:00Z',
|
||||||
updatedAt: '2026-04-25T10:00:00Z',
|
updatedAt: '2026-04-25T10:00:00Z',
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { InquiryPreparationReportDraft } from '../domain/inquiryReport'
|
||||||
|
|
||||||
|
export const mockInquiryReportDrafts: InquiryPreparationReportDraft[] = [
|
||||||
|
{
|
||||||
|
id: 'irep-001',
|
||||||
|
inquiryId: 'inq-001',
|
||||||
|
selectedPropertyIds: ['prop-001', 'prop-007'],
|
||||||
|
fieldSelections: [
|
||||||
|
{
|
||||||
|
propertyId: 'prop-001',
|
||||||
|
mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'],
|
||||||
|
selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'leaseTerm', 'currentTenant', 'description', 'floor', 'parking'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
propertyId: 'prop-007',
|
||||||
|
mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'],
|
||||||
|
selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'leaseTerm', 'currentTenant', 'description'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
marketSignalPropertyId: 'prop-001',
|
||||||
|
editableFields: [
|
||||||
|
{ id: 'intro', label: 'Einleitung', value: 'Sehr geehrte Frau Meier\n\nGerne präsentieren wir Ihnen passende Objekte aus unserem Portfolio, die Ihrem Anforderungsprofil entsprechen.', fieldType: 'textarea' },
|
||||||
|
{ id: 'highlights', label: 'Besonderheiten', value: 'Beide Objekte bieten modernste Büroinfrastruktur in attraktiver Zürich-West-Lage mit direktem ÖV-Anschluss.', fieldType: 'textarea' },
|
||||||
|
{ id: 'next_steps', label: 'Nächste Schritte', value: 'Gerne vereinbaren wir einen Besichtigungstermin. Bitte kontaktieren Sie uns für eine persönliche Beratung.', fieldType: 'textarea' },
|
||||||
|
],
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: '2026-05-17T09:00:00Z',
|
||||||
|
updatedAt: '2026-05-17T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'irep-002',
|
||||||
|
inquiryId: 'inq-002',
|
||||||
|
selectedPropertyIds: ['prop-002', 'prop-009'],
|
||||||
|
fieldSelections: [
|
||||||
|
{
|
||||||
|
propertyId: 'prop-002',
|
||||||
|
mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'],
|
||||||
|
selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'availabilityDate', 'loadingDocksCount', 'ceilingHeightM'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
propertyId: 'prop-009',
|
||||||
|
mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'],
|
||||||
|
selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'availabilityDate', 'description'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
marketSignalPropertyId: 'prop-002',
|
||||||
|
editableFields: [
|
||||||
|
{ id: 'intro', label: 'Einleitung', value: 'Guten Tag Herr Frei\n\nWir freuen uns, Ihnen zwei geeignete Logistikflächen vorstellen zu können.', fieldType: 'textarea' },
|
||||||
|
{ id: 'highlights', label: 'Besonderheiten', value: 'Beide Standorte verfügen über Rampenanschluss und sind für Hochregallager geeignet.', fieldType: 'textarea' },
|
||||||
|
],
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: '2026-05-17T12:00:00Z',
|
||||||
|
updatedAt: '2026-05-17T12:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'irep-004',
|
||||||
|
inquiryId: 'inq-004',
|
||||||
|
selectedPropertyIds: ['prop-012'],
|
||||||
|
fieldSelections: [
|
||||||
|
{
|
||||||
|
propertyId: 'prop-012',
|
||||||
|
mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'],
|
||||||
|
selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'leaseTerm', 'breakoutOption', 'currentTenant', 'propertyNumber', 'floor', 'parking', 'publicTransportScore'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
marketSignalPropertyId: 'prop-012',
|
||||||
|
editableFields: [
|
||||||
|
{ id: 'intro', label: 'Einleitung', value: 'Sehr geehrter Herr Hofer\n\nAnbei finden Sie detaillierte Unterlagen zur Bürofläche Stadtturm Zug.', fieldType: 'textarea' },
|
||||||
|
{ id: 'highlights', label: 'Highlights', value: 'Repräsentative Lage im Stadtzentrum Zug, beste Steuerkonditionen, direkter ÖV-Anschluss.', fieldType: 'textarea' },
|
||||||
|
{ id: 'closing', label: 'Abschluss', value: 'Wir freuen uns auf Ihr Feedback und stehen für Fragen jederzeit zur Verfügung.', fieldType: 'textarea' },
|
||||||
|
],
|
||||||
|
status: 'finalized',
|
||||||
|
pdfUrl: '/mock-reports/irep-004.pdf',
|
||||||
|
createdAt: '2026-05-14T10:00:00Z',
|
||||||
|
updatedAt: '2026-05-15T09:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { OfferReportDraft } from '../domain/offerReport'
|
||||||
|
|
||||||
|
export const mockOfferReportDrafts: OfferReportDraft[] = [
|
||||||
|
{
|
||||||
|
id: 'offer-rep-001',
|
||||||
|
inquiryId: 'inq-004',
|
||||||
|
propertyId: 'prop-012',
|
||||||
|
editableFields: [
|
||||||
|
{ id: 'recipient_salutation', label: 'Anrede / Einleitung', value: 'Sehr geehrter Herr Hofer', fieldType: 'text' },
|
||||||
|
{ id: 'offer_intro', label: 'Angebotsbeschreibung', value: 'Wir freuen uns, Ihnen das nachfolgende Angebot für die Bürofläche Stadtturm Zug unterbreiten zu können.', fieldType: 'textarea' },
|
||||||
|
{ id: 'highlighted_criteria', label: 'Ihre Anforderungen – unsere Stärken', value: 'Repräsentativer Standort im Zentrum Zug · Modernster Ausbaustandard · Flexible Mietdauer ab 3 Jahren', fieldType: 'textarea' },
|
||||||
|
{ id: 'viewing_intro', label: 'Besichtigung', value: 'Gerne zeigen wir Ihnen die Fläche persönlich. Bitte wählen Sie einen der folgenden Termine:', fieldType: 'textarea' },
|
||||||
|
{ id: 'next_steps', label: 'Nächste Schritte', value: 'Nach Ihrer Terminbestätigung erhalten Sie eine Detailbroschüre sowie den Mietvertragsentwurf.', fieldType: 'textarea' },
|
||||||
|
{ id: 'closing', label: 'Abschluss', value: 'Mit freundlichen Grüssen\nWincasa AG', fieldType: 'textarea' },
|
||||||
|
],
|
||||||
|
viewingAppointments: [
|
||||||
|
{ id: 'va-001-1', date: '2026-05-22', timeSlot: '10:00–11:00', contactPerson: 'Peter Müller' },
|
||||||
|
{ id: 'va-001-2', date: '2026-05-23', timeSlot: '14:00–15:00', contactPerson: 'Peter Müller' },
|
||||||
|
],
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: '2026-05-15T10:00:00Z',
|
||||||
|
updatedAt: '2026-05-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'offer-rep-005',
|
||||||
|
inquiryId: 'inq-005',
|
||||||
|
propertyId: 'prop-007',
|
||||||
|
editableFields: [
|
||||||
|
{ id: 'recipient_salutation', label: 'Anrede / Einleitung', value: 'Sehr geehrte Frau Wyss', fieldType: 'text' },
|
||||||
|
{ id: 'offer_intro', label: 'Angebotsbeschreibung', value: 'Wir freuen uns, Ihnen die Bürofläche an der Thurgauerstrasse 40 in Zürich-Oerlikon anzubieten.', fieldType: 'textarea' },
|
||||||
|
{ id: 'highlighted_criteria', label: 'Highlights', value: 'Vollsanierte Fläche (2024) · Ausgezeichnete ÖV-Anbindung · Expansionspotenzial vorhanden', fieldType: 'textarea' },
|
||||||
|
{ id: 'viewing_intro', label: 'Besichtigung', value: 'Wir laden Sie herzlich zu einer Besichtigung ein:', fieldType: 'textarea' },
|
||||||
|
{ id: 'next_steps', label: 'Nächste Schritte', value: 'Nach Ihrer Rückmeldung senden wir Ihnen den vollständigen Grundriss sowie das Exposé zu.', fieldType: 'textarea' },
|
||||||
|
{ id: 'closing', label: 'Abschluss', value: 'Mit freundlichen Grüssen\nWincasa AG', fieldType: 'textarea' },
|
||||||
|
],
|
||||||
|
viewingAppointments: [
|
||||||
|
{ id: 'va-005-1', date: '2026-05-21', timeSlot: '09:00–10:00', contactPerson: 'Sandra Keller' },
|
||||||
|
],
|
||||||
|
status: 'finalized',
|
||||||
|
pdfUrl: '/mock-reports/offer-rep-005.pdf',
|
||||||
|
createdAt: '2026-05-14T13:00:00Z',
|
||||||
|
updatedAt: '2026-05-14T16:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -42,6 +42,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 5.5,
|
ancillaryCosts: 5.5,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
propertyNumber: 'ZH-2024-001',
|
propertyNumber: 'ZH-2024-001',
|
||||||
units: [
|
units: [
|
||||||
{ id: 'unit-001-1', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' },
|
{ id: 'unit-001-1', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' },
|
||||||
@@ -95,6 +96,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 3.0,
|
ancillaryCosts: 3.0,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
importedFrom: 'SAP RE-FX',
|
importedFrom: 'SAP RE-FX',
|
||||||
importedAt: '2025-01-15T08:00:00Z',
|
importedAt: '2025-01-15T08:00:00Z',
|
||||||
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
||||||
@@ -144,6 +146,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 5.0,
|
ancillaryCosts: 5.0,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
propertyNumber: 'ZH-2021-007',
|
propertyNumber: 'ZH-2021-007',
|
||||||
units: [
|
units: [
|
||||||
{ id: 'unit-007-1', floorLevel: 0, unitLabel: 'EG Ost', areaSqm: 180, available: false, rentPricePerSqm: 420, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2025-09-30' },
|
{ id: 'unit-007-1', floorLevel: 0, unitLabel: 'EG Ost', areaSqm: 180, available: false, rentPricePerSqm: 420, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2025-09-30' },
|
||||||
@@ -199,6 +202,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 4.5,
|
ancillaryCosts: 4.5,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
importedFrom: 'SAP RE-FX',
|
importedFrom: 'SAP RE-FX',
|
||||||
importedAt: '2025-01-15T08:00:00Z',
|
importedAt: '2025-01-15T08:00:00Z',
|
||||||
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
||||||
@@ -246,6 +250,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 2.8,
|
ancillaryCosts: 2.8,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
importedFrom: 'SAP RE-FX',
|
importedFrom: 'SAP RE-FX',
|
||||||
importedAt: '2025-01-15T08:00:00Z',
|
importedAt: '2025-01-15T08:00:00Z',
|
||||||
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
||||||
@@ -293,6 +298,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 8.0,
|
ancillaryCosts: 8.0,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
importedFrom: 'SAP RE-FX',
|
importedFrom: 'SAP RE-FX',
|
||||||
importedAt: '2025-01-15T08:00:00Z',
|
importedAt: '2025-01-15T08:00:00Z',
|
||||||
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
||||||
@@ -340,6 +346,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 2.5,
|
ancillaryCosts: 2.5,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
importedFrom: 'SAP RE-FX',
|
importedFrom: 'SAP RE-FX',
|
||||||
importedAt: '2025-01-15T08:00:00Z',
|
importedAt: '2025-01-15T08:00:00Z',
|
||||||
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
||||||
@@ -388,6 +395,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 6.0,
|
ancillaryCosts: 6.0,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
propertyNumber: 'ZG-2022-012',
|
propertyNumber: 'ZG-2022-012',
|
||||||
units: [
|
units: [
|
||||||
{ id: 'unit-012-1', floorLevel: 3, unitLabel: '3.OG A', areaSqm: 180, available: false, rentPricePerSqm: 504, currentTenant: 'FinTech Zug AG', leaseTerm: '3 Jahre', leaseEndDate: '2025-09-30' },
|
{ id: 'unit-012-1', floorLevel: 3, unitLabel: '3.OG A', areaSqm: 180, available: false, rentPricePerSqm: 504, currentTenant: 'FinTech Zug AG', leaseTerm: '3 Jahre', leaseEndDate: '2025-09-30' },
|
||||||
@@ -443,6 +451,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 5.0,
|
ancillaryCosts: 5.0,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
propertyNumber: 'ZH-2021-013',
|
propertyNumber: 'ZH-2021-013',
|
||||||
units: [
|
units: [
|
||||||
{ id: 'unit-013-1', floorLevel: 0, unitLabel: 'EG Laden', areaSqm: 320, available: false, rentPricePerSqm: 600, currentTenant: 'Design Studio Zürich GmbH', leaseTerm: '4 Jahre', leaseEndDate: '2025-10-31' },
|
{ id: 'unit-013-1', floorLevel: 0, unitLabel: 'EG Laden', areaSqm: 320, available: false, rentPricePerSqm: 600, currentTenant: 'Design Studio Zürich GmbH', leaseTerm: '4 Jahre', leaseEndDate: '2025-10-31' },
|
||||||
@@ -497,6 +506,7 @@ export const mockProperties: Property[] = [
|
|||||||
ancillaryCosts: 2.8,
|
ancillaryCosts: 2.8,
|
||||||
riskLevel: RiskLevel.LOW,
|
riskLevel: RiskLevel.LOW,
|
||||||
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
importedFrom: 'SAP RE-FX',
|
importedFrom: 'SAP RE-FX',
|
||||||
importedAt: '2025-01-15T08:00:00Z',
|
importedAt: '2025-01-15T08:00:00Z',
|
||||||
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
lastUpdatedAt: '2025-05-10T10:00:00Z',
|
||||||
@@ -546,6 +556,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-02-15T14:00:00Z',
|
createdAt: '2025-02-15T14:00:00Z',
|
||||||
updatedAt: '2025-04-10T09:00:00Z',
|
updatedAt: '2025-04-10T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -574,6 +585,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-03-01T10:00:00Z',
|
createdAt: '2025-03-01T10:00:00Z',
|
||||||
updatedAt: '2025-03-20T15:00:00Z',
|
updatedAt: '2025-03-20T15:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -608,6 +620,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-03-12T11:00:00Z',
|
createdAt: '2025-03-12T11:00:00Z',
|
||||||
updatedAt: '2025-04-05T10:00:00Z',
|
updatedAt: '2025-04-05T10:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -642,6 +655,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-02-20T09:00:00Z',
|
createdAt: '2025-02-20T09:00:00Z',
|
||||||
updatedAt: '2025-03-28T12:00:00Z',
|
updatedAt: '2025-03-28T12:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -677,6 +691,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-03-05T13:00:00Z',
|
createdAt: '2025-03-05T13:00:00Z',
|
||||||
updatedAt: '2025-04-18T11:00:00Z',
|
updatedAt: '2025-04-18T11:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -711,6 +726,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-02-28T10:00:00Z',
|
createdAt: '2025-02-28T10:00:00Z',
|
||||||
updatedAt: '2025-04-02T09:00:00Z',
|
updatedAt: '2025-04-02T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -740,6 +756,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-03-10T08:00:00Z',
|
createdAt: '2025-03-10T08:00:00Z',
|
||||||
updatedAt: '2025-03-25T14:00:00Z',
|
updatedAt: '2025-03-25T14:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -774,6 +791,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-03-18T09:00:00Z',
|
createdAt: '2025-03-18T09:00:00Z',
|
||||||
updatedAt: '2025-04-12T11:00:00Z',
|
updatedAt: '2025-04-12T11:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -809,6 +827,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-02-10T10:00:00Z',
|
createdAt: '2025-02-10T10:00:00Z',
|
||||||
updatedAt: '2025-04-08T09:00:00Z',
|
updatedAt: '2025-04-08T09:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -843,6 +862,7 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||||
createdAt: '2025-03-08T08:00:00Z',
|
createdAt: '2025-03-08T08:00:00Z',
|
||||||
updatedAt: '2025-04-14T10:00:00Z',
|
updatedAt: '2025-04-14T10:00:00Z',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface IInquiryProvider {
|
|||||||
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
|
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
|
||||||
getById(id: string): Promise<Inquiry | null>
|
getById(id: string): Promise<Inquiry | null>
|
||||||
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
|
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
|
||||||
|
markThreadAsRead(id: string): Promise<Inquiry>
|
||||||
|
getUnreadCount(): Promise<number>
|
||||||
addMessage(
|
addMessage(
|
||||||
inquiryId: string,
|
inquiryId: string,
|
||||||
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
|||||||
store[idx] = { ...store[idx], status, updatedAt: new Date().toISOString() }
|
store[idx] = { ...store[idx], status, updatedAt: new Date().toISOString() }
|
||||||
return store[idx]
|
return store[idx]
|
||||||
},
|
},
|
||||||
|
async markThreadAsRead(id: string): Promise<Inquiry> {
|
||||||
|
const idx = store.findIndex(i => i.id === id)
|
||||||
|
if (idx === -1) throw new Error(`Inquiry ${id} not found`)
|
||||||
|
store[idx] = {
|
||||||
|
...store[idx],
|
||||||
|
isRead: true,
|
||||||
|
unreadCount: 0,
|
||||||
|
lastReadAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
async getUnreadCount(): Promise<number> {
|
||||||
|
return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
||||||
|
},
|
||||||
async addMessage(
|
async addMessage(
|
||||||
inquiryId: string,
|
inquiryId: string,
|
||||||
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection, ReportEditableField } from '../domain/inquiryReport'
|
||||||
|
import { mockInquiryReportDrafts } from '../mock-data/inquiryReportDrafts'
|
||||||
|
|
||||||
|
const store: InquiryPreparationReportDraft[] = mockInquiryReportDrafts.map(d => ({ ...d }))
|
||||||
|
|
||||||
|
const DEFAULT_EDITABLE_FIELDS: ReportEditableField[] = [
|
||||||
|
{ id: 'intro', label: 'Einleitung', value: 'Sehr geehrte Damen und Herren\n\nGerne präsentieren wir Ihnen passende Objekte aus unserem Portfolio.', fieldType: 'textarea' },
|
||||||
|
{ id: 'highlights', label: 'Besonderheiten', value: 'Die ausgewählten Objekte entsprechen Ihrem Anforderungsprofil.', fieldType: 'textarea' },
|
||||||
|
{ id: 'next_steps', label: 'Nächste Schritte', value: 'Für einen Besichtigungstermin stehen wir gerne zur Verfügung.', fieldType: 'textarea' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_FIELD_SELECTION: Omit<ReportObjectFieldSelection, 'propertyId'> = {
|
||||||
|
mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'],
|
||||||
|
selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'availabilityDate', 'leaseTerm', 'description'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inquiryReportService = {
|
||||||
|
async getByInquiry(inquiryId: string): Promise<InquiryPreparationReportDraft | null> {
|
||||||
|
return store.find(d => d.inquiryId === inquiryId) ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(inquiryId: string, selectedPropertyIds: string[]): Promise<InquiryPreparationReportDraft> {
|
||||||
|
const existing = store.find(d => d.inquiryId === inquiryId)
|
||||||
|
if (existing) return existing
|
||||||
|
const draft: InquiryPreparationReportDraft = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
inquiryId,
|
||||||
|
selectedPropertyIds,
|
||||||
|
fieldSelections: selectedPropertyIds.map(propertyId => ({
|
||||||
|
propertyId,
|
||||||
|
...DEFAULT_FIELD_SELECTION,
|
||||||
|
})),
|
||||||
|
marketSignalPropertyId: selectedPropertyIds[0],
|
||||||
|
editableFields: DEFAULT_EDITABLE_FIELDS.map(f => ({ ...f })),
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
store.push(draft)
|
||||||
|
return draft
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(draftId: string, data: Partial<InquiryPreparationReportDraft>): Promise<InquiryPreparationReportDraft> {
|
||||||
|
const idx = store.findIndex(d => d.id === draftId)
|
||||||
|
if (idx === -1) throw new Error(`Draft ${draftId} not found`)
|
||||||
|
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
|
||||||
|
async finalize(draftId: string): Promise<InquiryPreparationReportDraft> {
|
||||||
|
const idx = store.findIndex(d => d.id === draftId)
|
||||||
|
if (idx === -1) throw new Error(`Draft ${draftId} not found`)
|
||||||
|
store[idx] = {
|
||||||
|
...store[idx],
|
||||||
|
status: 'finalized',
|
||||||
|
pdfUrl: `/mock-reports/${draftId}.pdf`,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
||||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||||
import type { Inquiry, InquiryStatus, Attachment } from '../domain/inquiry'
|
import type { Inquiry, Attachment } from '../domain/inquiry'
|
||||||
|
|
||||||
const provider = MockupInquiryProvider
|
const provider = MockupInquiryProvider
|
||||||
|
|
||||||
@@ -49,12 +49,18 @@ export const inquiryService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateInquiryStatus(
|
async markThreadAsRead(id: string): Promise<ServiceResult<Inquiry>> {
|
||||||
id: string,
|
|
||||||
status: InquiryStatus,
|
|
||||||
): Promise<ServiceResult<Inquiry>> {
|
|
||||||
try {
|
try {
|
||||||
const data = await provider.updateStatus(id, status)
|
const data = await provider.markThreadAsRead(id)
|
||||||
|
return { data, error: null }
|
||||||
|
} catch (e) {
|
||||||
|
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUnreadCount(): Promise<ServiceResult<number>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getUnreadCount()
|
||||||
return { data, error: null }
|
return { data, error: null }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
|
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
|
||||||
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
||||||
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
|
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
|
||||||
|
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
||||||
import type { MatchFilters } from '../provider/IMatchProvider'
|
import type { MatchFilters } from '../provider/IMatchProvider'
|
||||||
import type { Match, PropertyNeedMatch } from '../domain/match'
|
import type { Match, PropertyNeedMatch } from '../domain/match'
|
||||||
import type { Need } from '../domain/need'
|
import type { Need } from '../domain/need'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
import type { StrongMatchItem } from '../domain/dashboard'
|
import type { StrongMatchItem } from '../domain/dashboard'
|
||||||
import type { ScoreBreakdown } from '../domain/match'
|
import type { ScoreBreakdown } from '../domain/match'
|
||||||
|
import type { AdditionalPropertyMatch } from '../domain/additionalMatch'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
|
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
|
||||||
|
import { ResultType } from '../domain/enums'
|
||||||
|
|
||||||
const provider = MockupMatchProvider
|
const provider = MockupMatchProvider
|
||||||
|
|
||||||
@@ -98,6 +101,41 @@ export const matchService = {
|
|||||||
.filter((x): x is PropertyNeedMatch => x !== null)
|
.filter((x): x is PropertyNeedMatch => x !== null)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getAdditionalMatchesForInquiry(
|
||||||
|
inquiryId: string,
|
||||||
|
opts?: { minScore?: number; excludePropertyId?: string },
|
||||||
|
): Promise<AdditionalPropertyMatch[]> {
|
||||||
|
const minScore = opts?.minScore ?? 80
|
||||||
|
const inquiry = await MockupInquiryProvider.getById(inquiryId)
|
||||||
|
if (!inquiry) return []
|
||||||
|
const excludeId = opts?.excludePropertyId ?? inquiry.propertyId
|
||||||
|
const [allProperties, allMatches] = await Promise.all([
|
||||||
|
MockupPropertyProvider.getAll(),
|
||||||
|
provider.getAll(),
|
||||||
|
])
|
||||||
|
const portfolioProperties = allProperties.filter(
|
||||||
|
p => p.resultType === ResultType.VERIFIED_PORTFOLIO && p.id !== excludeId,
|
||||||
|
)
|
||||||
|
return portfolioProperties
|
||||||
|
.map(p => {
|
||||||
|
const match = allMatches.find(m => m.propertyId === p.id)
|
||||||
|
const score = match?.matchScore ?? 0
|
||||||
|
return { property: p, score }
|
||||||
|
})
|
||||||
|
.filter(({ score }) => score >= minScore)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(({ property: p, score }) => ({
|
||||||
|
propertyId: p.id,
|
||||||
|
title: p.title,
|
||||||
|
location: `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`,
|
||||||
|
areaSqm: p.areaSqm,
|
||||||
|
rentPricePerSqm: p.rentPricePerSqm,
|
||||||
|
matchScore: score,
|
||||||
|
imageUrl: p.images?.[0],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
|
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
|
||||||
const [matches, properties] = await Promise.all([
|
const [matches, properties] = await Promise.all([
|
||||||
provider.getAll(),
|
provider.getAll(),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { OfferReportDraft, OfferEditableField } from '../domain/offerReport'
|
||||||
|
import { mockOfferReportDrafts } from '../mock-data/offerReportDrafts'
|
||||||
|
|
||||||
|
const store: OfferReportDraft[] = mockOfferReportDrafts.map(d => ({ ...d }))
|
||||||
|
|
||||||
|
function buildDefaultFields(tenantName: string, propertyTitle: string): OfferEditableField[] {
|
||||||
|
return [
|
||||||
|
{ id: 'recipient_salutation', label: 'Anrede / Einleitung', value: `Sehr geehrte(r) ${tenantName}`, fieldType: 'text' },
|
||||||
|
{ id: 'offer_intro', label: 'Angebotsbeschreibung', value: `Wir freuen uns, Ihnen folgendes Angebot für die Liegenschaft «${propertyTitle}» zu unterbreiten.`, fieldType: 'textarea' },
|
||||||
|
{ id: 'highlighted_criteria', label: 'Highlights der Liegenschaft', value: 'Exzellente Lage · Modernster Ausbaustandard · Flexible Mietkonditionen', fieldType: 'textarea' },
|
||||||
|
{ id: 'viewing_intro', label: 'Besichtigungstermine', value: 'Gerne laden wir Sie zu einer persönlichen Besichtigung ein. Folgende Termine sind verfügbar:', fieldType: 'textarea' },
|
||||||
|
{ id: 'next_steps', label: 'Nächste Schritte', value: 'Nach Ihrer Terminbestätigung erhalten Sie alle weiteren Unterlagen.', fieldType: 'textarea' },
|
||||||
|
{ id: 'closing', label: 'Abschluss', value: 'Mit freundlichen Grüssen\nWincasa AG', fieldType: 'textarea' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const offerReportService = {
|
||||||
|
async getByInquiry(inquiryId: string): Promise<OfferReportDraft | null> {
|
||||||
|
return store.find(d => d.inquiryId === inquiryId) ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(inquiryId: string, propertyId: string, tenantName = '', propertyTitle = ''): Promise<OfferReportDraft> {
|
||||||
|
const existing = store.find(d => d.inquiryId === inquiryId && d.propertyId === propertyId)
|
||||||
|
if (existing) return existing
|
||||||
|
const draft: OfferReportDraft = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
inquiryId,
|
||||||
|
propertyId,
|
||||||
|
editableFields: buildDefaultFields(tenantName, propertyTitle),
|
||||||
|
viewingAppointments: [],
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
store.push(draft)
|
||||||
|
return draft
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(draftId: string, data: Partial<OfferReportDraft>): Promise<OfferReportDraft> {
|
||||||
|
const idx = store.findIndex(d => d.id === draftId)
|
||||||
|
if (idx === -1) throw new Error(`Offer draft ${draftId} not found`)
|
||||||
|
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
|
||||||
|
async generatePdf(draftId: string): Promise<OfferReportDraft> {
|
||||||
|
const idx = store.findIndex(d => d.id === draftId)
|
||||||
|
if (idx === -1) throw new Error(`Offer draft ${draftId} not found`)
|
||||||
|
store[idx] = {
|
||||||
|
...store[idx],
|
||||||
|
status: 'finalized',
|
||||||
|
pdfUrl: `/mock-reports/${draftId}.pdf`,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
return store[idx]
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user