feat: Anfragencenter Parts A–E — read/unread, prep wizard, offer flow, report preview

- Part A: Replace InquiryStatus badges with WhatsApp-style unread indicators
  (isRead, unreadCount, lastReadAt on Inquiry; markThreadAsRead in service + hook)
- Part B: RelatedPropertyCardPanel — reposition "Objekt ansehen", add "Weitere
  Matches" section via matchService.getAdditionalMatchesForInquiry
- Part C: PreparationWizard 4-step dialog — property selection, report generation
  progress, field editing + ReportObjectFieldSelector, PDF preview + finalize
- Part D: OfferCreationWizard 4-step dialog triggered from first tenant message —
  data capture, field editing, viewing appointments, PDF generation + attach to reply
- Part E: LatentInquiryReportPreview (2-page A4 doc), ReportObjectFieldSelector
  (5 accordion groups, 35+ optional fields), mapImageUrl on Property domain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 19:20:13 +02:00
parent 279b55d70e
commit b4d398270f
26 changed files with 1874 additions and 188 deletions
+32 -4
View File
@@ -1,7 +1,6 @@
import { Box, Paper, Typography } from '@mui/material' import { 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',
+41 -6
View File
@@ -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')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Plätze` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props) {
const editableByField = Object.fromEntries(draft.editableFields.map(f => [f.id, f.value]))
const selectedProps = draft.selectedPropertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is Property => !!p)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0, fontFamily: 'Georgia, serif' }}>
{/* Page 1 — Need Summary */}
<Box
sx={{
bgcolor: 'white',
p: 4,
minHeight: 600,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
borderRadius: 1,
mb: 2,
}}
>
<Box sx={{ borderBottom: '3px solid #1e3a5f', pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}>
Objektvorschlag
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
</Typography>
</Box>
{editableByField['intro'] && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: '#1e293b' }}>
{editableByField['intro']}
</Typography>
)}
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Ihre Anfrage
</Typography>
<Typography variant="body2" sx={{ color: '#374151', mb: 0.5 }}>
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<Typography variant="body2" sx={{ color: '#374151' }}>
<strong>Betreff:</strong> {inquiry.subject}
</Typography>
</Box>
{editableByField['highlights'] && (
<>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Warum diese Objekte passen
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151', mb: 3 }}>
{editableByField['highlights']}
</Typography>
</>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{selectedProps.map(p => (
<Chip
key={p.id}
label={p.title}
size="small"
icon={<Building2 size={12} />}
sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }}
/>
))}
</Box>
</Box>
{/* Page 2+ — One section per property */}
{selectedProps.map((property, idx) => {
const selection = draft.fieldSelections.find(fs => fs.propertyId === property.id)
const optionalFields = selection?.selectedOptionalFields ?? []
const image = property.images?.[0]
return (
<Box key={property.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Divider sx={{ flex: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
Objekt {idx + 1} von {selectedProps.length}
</Typography>
<Divider sx={{ flex: 1 }} />
</Box>
<Box
sx={{
bgcolor: 'white',
p: 3,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
borderRadius: 1,
mb: 2,
}}
>
<Box sx={{ display: 'flex', gap: 2, mb: 2.5 }}>
{/* Map placeholder */}
<Box
sx={{
width: 160,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!property.mapImageUrl && <MapPin size={28} color="#94a3b8" />}
</Box>
{/* Property photo */}
<Box
sx={{
flex: 1,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!image && <Building2 size={28} color="#94a3b8" />}
</Box>
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 0.5, fontFamily: 'inherit' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<MapPin size={13} />
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Ruler size={13} />
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Calendar size={13} />
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
</Box>
</Box>
{optionalFields.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 0.75,
bgcolor: '#f8fafc',
borderRadius: 1,
p: 1.5,
}}
>
{optionalFields.map(key => {
const val = getFieldValue(property, key)
if (!val) return null
return (
<Box key={key}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.78rem' }}>
{val}
</Typography>
</Box>
)
})}
</Box>
)}
</Box>
</Box>
)
})}
{editableByField['next_steps'] && (
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Nächste Schritte
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151' }}>
{editableByField['next_steps']}
</Typography>
</Box>
)}
</Box>
)
}
@@ -0,0 +1,350 @@
import { useEffect, useRef, useState } from 'react'
import {
Box, Button, CircularProgress, Dialog, DialogContent,
IconButton, LinearProgress, Stack, Step, StepLabel, Stepper,
TextField, Typography,
} from '@mui/material'
import { Download, Plus, Send, Trash2, X } from 'lucide-react'
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
import { offerReportService } from '../../services/offerReportService'
import { usePropertyById } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore'
interface Props {
inquiryId: string
propertyId: string
tenantName: string
onClose: () => void
onAttach: (label: string) => void
}
const STEPS = ['Daten erfassen', 'Bericht prüfen', 'Besichtigungstermine', 'PDF erstellen']
export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose, onAttach }: Props) {
const [step, setStep] = useState(0)
const [draft, setDraft] = useState<OfferReportDraft | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [ready, setReady] = useState(false)
const { data: property } = usePropertyById(propertyId)
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
offerReportService
.create(inquiryId, propertyId, tenantName, property?.title ?? '')
.then(d => { setDraft(d); setLoading(false) })
}, [inquiryId, propertyId, tenantName, property?.title])
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
const handleUpdateField = (fieldId: string, value: string) => {
if (!draft) return
setDraft({ ...draft, editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f) })
}
const addAppointment = () => {
if (!draft) return
const slot: ViewingAppointmentOption = {
id: crypto.randomUUID(),
date: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10),
timeSlot: '10:0011:00',
}
setDraft({ ...draft, viewingAppointments: [...draft.viewingAppointments, slot] })
}
const removeAppointment = (id: string) => {
if (!draft) return
setDraft({ ...draft, viewingAppointments: draft.viewingAppointments.filter(a => a.id !== id) })
}
const updateAppointment = (id: string, field: keyof ViewingAppointmentOption, value: string) => {
if (!draft) return
setDraft({
...draft,
viewingAppointments: draft.viewingAppointments.map(a =>
a.id === id ? { ...a, [field]: value } : a,
),
})
}
const handleGeneratePdf = async () => {
if (!draft) return
await offerReportService.update(draft.id, {
editableFields: draft.editableFields,
viewingAppointments: draft.viewingAppointments,
})
setStep(3)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 18 + 8
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
setReady(true)
offerReportService.generatePdf(draft.id).then(setDraft)
} else {
setProgress(Math.min(100, p))
}
}, 200)
}
return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
</Box>
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
<Stepper activeStep={step} alternativeLabel>
{STEPS.map(label => (
<Step key={label}><StepLabel>{label}</StepLabel></Step>
))}
</Stepper>
</Box>
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<CircularProgress />
</Box>
)}
{/* Step 0: Data summary */}
{!loading && step === 0 && property && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Objekt</Typography>
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 2 }}>
{property.images?.[0] && (
<Box sx={{ height: 120, backgroundImage: `url(${property.images[0]})`, backgroundSize: 'cover', backgroundPosition: 'center', borderRadius: 1, mb: 1.5 }} />
)}
<Typography variant="body2" sx={{ fontWeight: 700 }}>{property.title}</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{property.location.city} · {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Interessent</Typography>
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2 }}>
<Typography variant="body2">{tenantName}</Typography>
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: '#64748b', lineHeight: 1.7 }}>
Das Angebot wird auf Basis der Objekt- und Anfragedaten vorausgefüllt.
Im nächsten Schritt können Sie alle Felder anpassen.
</Typography>
</Box>
</Box>
)}
{/* Step 1: Edit fields */}
{!loading && step === 1 && draft && (
<Stack spacing={2}>
{draft.editableFields.map(f => (
<TextField
key={f.id}
label={f.label}
value={f.value}
onChange={e => handleUpdateField(f.id, e.target.value)}
multiline={f.fieldType === 'textarea'}
rows={f.fieldType === 'textarea' ? 3 : 1}
size="small"
fullWidth
/>
))}
</Stack>
)}
{/* Step 2: Viewing appointments */}
{!loading && step === 2 && draft && (
<Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
Fügen Sie Besichtigungstermine hinzu, die dem Interessenten angeboten werden sollen:
</Typography>
<Stack spacing={1.5} sx={{ mb: 2 }}>
{draft.viewingAppointments.map(a => (
<Box key={a.id} sx={{ display: 'flex', gap: 1.5, alignItems: 'center', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5 }}>
<TextField
label="Datum"
type="date"
size="small"
value={a.date}
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 160 }}
/>
<TextField
label="Zeitfenster"
size="small"
value={a.timeSlot}
onChange={e => updateAppointment(a.id, 'timeSlot', e.target.value)}
placeholder="10:0011:00"
sx={{ width: 140 }}
/>
<TextField
label="Kontaktperson"
size="small"
value={a.contactPerson ?? ''}
onChange={e => updateAppointment(a.id, 'contactPerson', e.target.value)}
sx={{ flex: 1 }}
/>
<IconButton size="small" onClick={() => removeAppointment(a.id)} sx={{ color: '#ef4444' }}>
<Trash2 size={16} />
</IconButton>
</Box>
))}
</Stack>
<Button
startIcon={<Plus size={14} />}
onClick={addAppointment}
sx={{ textTransform: 'none' }}
>
Termin hinzufügen
</Button>
</Box>
)}
{/* Step 3: PDF */}
{step === 3 && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
PDF wird generiert
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
{Math.round(progress)}%
</Typography>
</Box>
</>
) : ready ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, width: '100%' }}>
<Box
sx={{
width: '100%',
maxWidth: 520,
height: 380,
bgcolor: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box sx={{ bgcolor: '#1e3a5f', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', ml: 1, fontSize: '0.7rem' }}>
Angebot_{draft?.propertyId ?? 'Objekt'}.pdf
</Typography>
</Box>
<Box sx={{ flex: 1, p: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 1 }}>
Angebotsschreiben
</Typography>
{draft?.editableFields.slice(0, 3).map(f => (
<Box key={f.id} sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', display: 'block' }}>{f.label}</Typography>
<Typography variant="caption" sx={{ color: '#374151', fontSize: '0.75rem', display: 'block' }}>
{f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''}
</Typography>
</Box>
))}
{(draft?.viewingAppointments.length ?? 0) > 0 && (
<Box sx={{ mt: 1.5, p: 1, bgcolor: '#f1f5f9', borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', display: 'block', mb: 0.5 }}>
Besichtigungstermine
</Typography>
{draft!.viewingAppointments.map(a => (
<Typography key={a.id} variant="caption" sx={{ fontSize: '0.7rem', display: 'block', color: '#475569' }}>
{new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot}
</Typography>
))}
</Box>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
variant="contained"
startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
<Button
variant="outlined"
startIcon={<Send size={16} />}
onClick={() => {
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
onAttach(label)
showToast('Angebot als Anhang hinzugefügt', 'success')
}}
sx={{ textTransform: 'none' }}
>
An Nachricht anhängen
</Button>
</Box>
</Box>
) : null}
</Box>
)}
</DialogContent>
{/* Footer */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
Abbrechen
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
{step > 0 && step < 3 && (
<Button variant="outlined" onClick={() => setStep(s => s - 1)} sx={{ textTransform: 'none' }}>
Zurück
</Button>
)}
{step === 0 && (
<Button
variant="contained"
disabled={loading}
onClick={() => setStep(1)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 1 && (
<Button
variant="contained"
onClick={() => setStep(2)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 2 && (
<Button
variant="contained"
onClick={handleGeneratePdf}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
PDF erstellen
</Button>
)}
</Box>
</Box>
</Dialog>
)
}
@@ -0,0 +1,283 @@
import { useEffect, useRef, useState } from 'react'
import {
Box, Button, Checkbox, CircularProgress, Dialog, DialogContent,
FormControlLabel, LinearProgress, Stack, Step, StepLabel, Stepper,
TextField, Typography,
} from '@mui/material'
import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import type { Property } from '../../domain/property'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { inquiryReportService } from '../../services/inquiryReportService'
import { useToastStore } from '../../stores/toastStore'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
import { useProperties } from '../../hooks/useProperties'
import { ResultType } from '../../domain/enums'
interface Props {
inquiryId: string
inquiry: Inquiry
onClose: () => void
}
const STEPS = ['Objekte wählen', 'Bericht erstellen', 'Prüfen & bearbeiten', 'Finalisieren']
export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
const [step, setStep] = useState(0)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [finalizing, setFinalizing] = useState(false)
const { data: allProperties = [] } = useProperties()
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const selectedProperties = draft?.selectedPropertyIds
.map(id => allProperties.find(p => p.id === id))
.filter((p): p is Property => !!p) ?? []
const toggleProperty = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
)
}
const handleGenerate = async () => {
setStep(1)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 20 + 10
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
inquiryReportService.create(inquiryId, selectedIds).then(d => {
setDraft(d)
setStep(2)
})
} else {
setProgress(Math.min(100, p))
}
}, 200)
}
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
const handleUpdateField = (fieldId: string, value: string) => {
if (!draft) return
setDraft({
...draft,
editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f),
})
}
const handleUpdateFieldSelection = (propertyId: string, sel: ReportObjectFieldSelection) => {
if (!draft) return
setDraft({
...draft,
fieldSelections: draft.fieldSelections.map(fs => fs.propertyId === propertyId ? sel : fs),
})
}
const handleFinalize = async () => {
if (!draft) return
setFinalizing(true)
await inquiryReportService.update(draft.id, { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections })
const finalized = await inquiryReportService.finalize(draft.id)
setDraft(finalized)
setFinalizing(false)
setStep(3)
}
return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
</Box>
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
<Stepper activeStep={step} alternativeLabel>
{STEPS.map(label => (
<Step key={label}><StepLabel>{label}</StepLabel></Step>
))}
</Stepper>
</Box>
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
{/* Step 0: Select properties */}
{step === 0 && (
<Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten:
</Typography>
<Stack spacing={1}>
{portfolioProps.map(p => (
<FormControlLabel
key={p.id}
control={
<Checkbox
checked={selectedIds.includes(p.id)}
onChange={() => toggleProperty(p.id)}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
}
sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 1, m: 0, alignItems: 'flex-start', '& .MuiCheckbox-root': { pt: 0 } }}
/>
))}
</Stack>
</Box>
)}
{/* Step 1: Generating */}
{step === 1 && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
Bericht wird erstellt
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
{Math.round(progress)}%
</Typography>
</Box>
</>
) : (
<Typography variant="body1">Bericht erstellt. Weiterleitung</Typography>
)}
</Box>
)}
{/* Step 2: Review & Edit */}
{step === 2 && draft && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Berichtsfelder bearbeiten</Typography>
<Stack spacing={2} sx={{ mb: 3 }}>
{draft.editableFields.map(f => (
<TextField
key={f.id}
label={f.label}
value={f.value}
onChange={e => handleUpdateField(f.id, e.target.value)}
multiline={f.fieldType === 'textarea'}
rows={f.fieldType === 'textarea' ? 3 : 1}
size="small"
fullWidth
/>
))}
</Stack>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1.5 }}>Felder pro Objekt</Typography>
<Stack spacing={2}>
{draft.fieldSelections.map(fs => {
const prop = allProperties.find(p => p.id === fs.propertyId)
if (!prop) return null
return (
<ReportObjectFieldSelector
key={fs.propertyId}
propertyTitle={prop.title}
value={fs}
onChange={sel => handleUpdateFieldSelection(fs.propertyId, sel)}
/>
)
})}
</Stack>
</Box>
<Box sx={{ width: 380, flexShrink: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Vorschau</Typography>
<Box sx={{ overflow: 'auto', maxHeight: 600 }}>
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
</Box>
</Box>
</Box>
)}
{/* Step 3: Finalized */}
{step === 3 && draft && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1, overflow: 'auto' }}>
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
</Box>
<Box sx={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1 }}>
<Button
variant="contained"
fullWidth
startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
<Button
variant="outlined"
fullWidth
startIcon={<Send size={16} />}
onClick={() => {
showToast('Bericht als Anhang hinzugefügt', 'success')
onClose()
}}
sx={{ textTransform: 'none' }}
>
Als Anhang senden
</Button>
</Box>
</Box>
)}
</DialogContent>
{/* Footer navigation */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
Abbrechen
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
{step === 2 && (
<Button variant="outlined" onClick={() => setStep(0)} sx={{ textTransform: 'none' }}>
Zurück
</Button>
)}
{step === 0 && (
<Button
variant="contained"
disabled={selectedIds.length === 0}
onClick={handleGenerate}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 2 && (
<Button
variant="contained"
disabled={finalizing}
onClick={handleFinalize}
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Finalisieren
</Button>
)}
</Box>
</Box>
</Dialog>
)
}
@@ -1,15 +1,29 @@
import { Box, Button, CircularProgress, Typography } from '@mui/material' import { 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>
)
}
+9
View File
@@ -0,0 +1,9 @@
export interface AdditionalPropertyMatch {
propertyId: string
title: string
location: string
areaSqm: number
rentPricePerSqm: number
matchScore: number
imageUrl?: string
}
+4 -1
View File
@@ -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
+37
View File
@@ -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
}
+25
View File
@@ -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
}
+1
View File
@@ -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
+4 -5
View File
@@ -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
View File
@@ -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. 3000 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. 3000 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. 2500 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. 2500 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',
+77
View File
@@ -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',
},
]
+44
View File
@@ -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:0011:00', contactPerson: 'Peter Müller' },
{ id: 'va-001-2', date: '2026-05-23', timeSlot: '14:0015: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:0010: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',
},
]
+20
View File
@@ -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',
}, },
+2
View File
@@ -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'>,
+15
View File
@@ -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'>,
+61
View File
@@ -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]
},
}
+12 -6
View File
@@ -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' }
+38
View File
@@ -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(),
+57
View File
@@ -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]
},
}