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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 19:20:13 +02:00
parent 279b55d70e
commit b4d398270f
26 changed files with 1874 additions and 188 deletions
+32 -4
View File
@@ -1,7 +1,6 @@
import { Box, Paper, Typography } from '@mui/material'
import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import { InquiryStatusBadge } from './InquiryStatusBadge'
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
interface InquiryCardProps {
@@ -11,6 +10,8 @@ interface InquiryCardProps {
}
export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return (
<Paper
onClick={onClick}
@@ -34,17 +35,44 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
<Typography
variant="body2"
sx={{
fontWeight: hasUnread ? 700 : 600,
color: '#0f172a',
fontSize: '0.85rem',
lineHeight: 1.3,
}}
>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<InquiryStatusBadge status={inquiry.status} />
{hasUnread && (
<Box
sx={{
minWidth: 20,
height: 20,
borderRadius: '50%',
bgcolor: '#2563eb',
color: 'white',
fontSize: '0.65rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
px: inquiry.unreadCount > 9 ? 0.75 : 0,
}}
>
{inquiry.unreadCount}
</Box>
)}
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
fontWeight: hasUnread ? 600 : 500,
color: '#1e293b',
fontSize: '0.85rem',
display: '-webkit-box',
+41 -6
View File
@@ -1,14 +1,23 @@
import { useEffect, useRef } from 'react'
import { Box } from '@mui/material'
import type { Inquiry } from '../../domain/inquiry'
import { Box, Button } from '@mui/material'
import { FileText } from 'lucide-react'
import type { Inquiry, Attachment } from '../../domain/inquiry'
import { InquiryMessageBubble } from './InquiryMessageBubble'
import { InquiryReplyComposer } from './InquiryReplyComposer'
interface InquiryChatProps {
inquiry: Inquiry
onCreateOffer?: () => void
pendingAttachment?: Attachment | null
onPendingAttachmentConsumed?: () => void
}
export function InquiryChat({ inquiry }: InquiryChatProps) {
export function InquiryChat({
inquiry,
onCreateOffer,
pendingAttachment,
onPendingAttachmentConsumed,
}: InquiryChatProps) {
const endRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
@@ -18,12 +27,38 @@ export function InquiryChat({ inquiry }: InquiryChatProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, overflow: 'hidden' }}>
<Box sx={{ overflowY: 'auto', flex: 1, p: 2, bgcolor: 'white' }}>
{inquiry.thread.map(m => (
<InquiryMessageBubble key={m.id} message={m} />
{inquiry.thread.map((m, idx) => (
<Box key={m.id}>
<InquiryMessageBubble message={m} />
{idx === 0 && m.senderType === 'tenant' && onCreateOffer && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', mb: 1.5, ml: 0.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<FileText size={13} />}
onClick={onCreateOffer}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
borderColor: '#cbd5e1',
color: '#475569',
'&:hover': { borderColor: '#1e3a5f', color: '#1e3a5f', bgcolor: '#f0f4f8' },
}}
>
Angebot erstellen
</Button>
</Box>
)}
</Box>
))}
<div ref={endRef} />
</Box>
<InquiryReplyComposer inquiryId={inquiry.id} defaultSubject={inquiry.subject} />
<InquiryReplyComposer
inquiryId={inquiry.id}
defaultSubject={inquiry.subject}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
/>
</Box>
)
}
@@ -1,33 +1,28 @@
import {
Box,
CircularProgress,
MenuItem,
Select,
Typography,
type SelectChangeEvent,
} from '@mui/material'
import { useInquiryById, useUpdateInquiryStatus } from '../../hooks/useInquiries'
import type { InquiryStatus } from '../../domain/inquiry'
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ClipboardList } from 'lucide-react'
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
import { InquiryChat } from './InquiryChat'
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
import { InquiryStatusBadge } from './InquiryStatusBadge'
import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry'
interface InquiryDetailPanelProps {
inquiryId: string
}
const STATUS_OPTIONS: { value: InquiryStatus; label: string }[] = [
{ value: 'new', label: 'Neu' },
{ value: 'in_progress', label: 'In Bearbeitung' },
{ value: 'answered', label: 'Beantwortet' },
{ value: 'archived', label: 'Archiviert' },
]
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
const updateStatus = useUpdateInquiryStatus()
const showToast = useToastStore(s => s.showToast)
const markRead = useMarkThreadAsRead()
const [preparationOpen, setPreparationOpen] = useState(false)
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
useEffect(() => {
if (inquiry && !inquiry.isRead) {
markRead.mutate(inquiry.id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inquiry?.id])
if (isLoading) {
return (
@@ -47,74 +42,130 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
)
}
const handleStatusChange = async (e: SelectChangeEvent<InquiryStatus>) => {
const result = await updateStatus.mutateAsync({
id: inquiry.id,
status: e.target.value as InquiryStatus,
})
if (result.error) {
showToast(`Fehler: ${result.error}`, 'error')
} else {
showToast('Status aktualisiert', 'success')
}
}
const isLatentInquiry = !!inquiry.needId
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box
sx={{
px: 2.5,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
alignItems: 'center',
gap: 2,
flexShrink: 0,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 600,
color: '#0f172a',
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inquiry.subject}
</Typography>
</Box>
<InquiryStatusBadge status={inquiry.status} size="medium" />
<Select
size="small"
value={inquiry.status}
onChange={handleStatusChange}
disabled={updateStatus.isPending}
sx={{ minWidth: 180, fontSize: '0.8125rem' }}
<>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box
sx={{
px: 2.5,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
alignItems: 'center',
gap: 2,
flexShrink: 0,
}}
>
{STATUS_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
{o.label}
</MenuItem>
))}
</Select>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 600,
color: '#0f172a',
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inquiry.subject}
</Typography>
</Box>
{isLatentInquiry && (
<Button
size="small"
variant="outlined"
startIcon={<ClipboardList size={14} />}
onClick={() => setPreparationOpen(true)}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#1e3a5f', color: '#1e3a5f', '&:hover': { bgcolor: '#f0f4f8' } }}
>
Vorbereitung starten
</Button>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
<InquiryChat
inquiry={inquiry}
onCreateOffer={() => setOfferWizardOpen(true)}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
/>
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
<RelatedPropertyCardPanel
propertyId={inquiry.propertyId}
inquiryId={inquiry.id}
/>
</Box>
</Box>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
<InquiryChat inquiry={inquiry} />
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
<RelatedPropertyCardPanel propertyId={inquiry.propertyId} />
</Box>
</Box>
</Box>
{preparationOpen && (
<PreparationWizardLazy
inquiryId={inquiry.id}
inquiry={inquiry}
onClose={() => setPreparationOpen(false)}
/>
)}
{offerWizardOpen && (
<OfferCreationWizardLazy
inquiryId={inquiry.id}
propertyId={inquiry.propertyId}
tenantName={inquiry.tenantName}
onClose={() => setOfferWizardOpen(false)}
onAttach={(label) => {
setPendingAttachment({
id: crypto.randomUUID(),
fileName: label,
fileType: 'application/pdf',
generated: true,
})
setOfferWizardOpen(false)
}}
/>
)}
</>
)
}
// Lazy-loaded wizard wrappers to avoid circular imports at module load time
import { lazy, Suspense } from 'react'
const PreparationWizardComponent = lazy(() =>
import('./PreparationWizard').then(m => ({ default: m.PreparationWizard }))
)
const OfferCreationWizardComponent = lazy(() =>
import('./OfferCreationWizard').then(m => ({ default: m.OfferCreationWizard }))
)
import type { Inquiry } from '../../domain/inquiry'
function PreparationWizardLazy(props: { inquiryId: string; inquiry: Inquiry; onClose: () => void }) {
return (
<Suspense fallback={null}>
<PreparationWizardComponent {...props} />
</Suspense>
)
}
function OfferCreationWizardLazy(props: {
inquiryId: string
propertyId: string
tenantName: string
onClose: () => void
onAttach: (label: string) => void
}) {
return (
<Suspense fallback={null}>
<OfferCreationWizardComponent {...props} />
</Suspense>
)
}
@@ -1,7 +1,6 @@
import { Box, Typography } from '@mui/material'
import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import { InquiryStatusBadge } from './InquiryStatusBadge'
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
interface InquiryListRowProps {
@@ -11,6 +10,8 @@ interface InquiryListRowProps {
}
export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return (
<Box
onClick={onClick}
@@ -19,7 +20,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
py: 1.5,
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid',
borderLeftColor: selected ? '#1e3a5f' : 'transparent',
borderLeftColor: selected ? '#1e3a5f' : hasUnread ? '#2563eb' : 'transparent',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'background-color 0.15s, border-color 0.15s',
@@ -27,16 +28,38 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem' }}>
<Typography
variant="body2"
sx={{ fontWeight: hasUnread ? 700 : 600, color: '#0f172a', fontSize: '0.85rem' }}
>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<InquiryStatusBadge status={inquiry.status} />
{hasUnread && (
<Box
sx={{
minWidth: 18,
height: 18,
borderRadius: '50%',
bgcolor: '#2563eb',
color: 'white',
fontSize: '0.6rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
px: inquiry.unreadCount > 9 ? 0.5 : 0,
}}
>
{inquiry.unreadCount}
</Box>
)}
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
fontWeight: hasUnread ? 600 : 500,
color: '#1e293b',
fontSize: '0.8125rem',
mb: 0.5,
@@ -1,16 +1,14 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
Box,
Button,
Checkbox,
CircularProgress,
FormControlLabel,
IconButton,
TextField,
Typography,
} from '@mui/material'
import { Send, Paperclip, X } from 'lucide-react'
import { useSendInquiryReply, useUpdateInquiryStatus } from '../../hooks/useInquiries'
import { useSendInquiryReply } from '../../hooks/useInquiries'
import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry'
import { formatFileSize } from './inquiryUtils'
@@ -19,25 +17,37 @@ interface InquiryReplyComposerProps {
inquiryId: string
defaultSubject: string
onSent?: () => void
pendingAttachment?: Attachment | null
onPendingAttachmentConsumed?: () => void
}
export function InquiryReplyComposer({
inquiryId,
defaultSubject,
onSent,
pendingAttachment,
onPendingAttachmentConsumed,
}: InquiryReplyComposerProps) {
const [subject, setSubject] = useState(
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
)
const [body, setBody] = useState('')
const [markAnswered, setMarkAnswered] = useState(true)
const [attachments, setAttachments] = useState<Attachment[]>([])
const sendReply = useSendInquiryReply()
const updateStatus = useUpdateInquiryStatus()
const showToast = useToastStore(s => s.showToast)
const sending = sendReply.isPending || updateStatus.isPending
useEffect(() => {
if (pendingAttachment) {
setAttachments(prev => {
if (prev.find(a => a.id === pendingAttachment.id)) return prev
return [...prev, pendingAttachment]
})
onPendingAttachmentConsumed?.()
}
}, [pendingAttachment, onPendingAttachmentConsumed])
const sending = sendReply.isPending
const handleAddMockAttachment = () => {
const name = `Anhang_${attachments.length + 1}.pdf`
@@ -69,11 +79,6 @@ export function InquiryReplyComposer({
showToast(`Fehler: ${result.error}`, 'error')
return
}
if (markAnswered) {
await updateStatus.mutateAsync({ id: inquiryId, status: 'answered' })
} else {
await updateStatus.mutateAsync({ id: inquiryId, status: 'in_progress' })
}
showToast('Antwort gesendet', 'success')
setBody('')
setAttachments([])
@@ -119,20 +124,24 @@ export function InquiryReplyComposer({
alignItems: 'center',
gap: 0.75,
bgcolor: 'white',
border: '1px solid #cbd5e1',
border: '1px solid',
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
borderRadius: 1,
px: 1,
py: 0.5,
fontSize: '0.75rem',
bgcolor: a.generated ? '#eff6ff' : 'white',
}}
>
<Paperclip size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
<Paperclip size={12} color={a.generated ? '#2563eb' : undefined} />
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: a.generated ? '#1d4ed8' : undefined }}>
{a.fileName}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
{formatFileSize(a.fileSize)}
</Typography>
{a.fileSize && (
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
{formatFileSize(a.fileSize)}
</Typography>
)}
<IconButton size="small" onClick={() => handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
<X size={12} />
</IconButton>
@@ -142,30 +151,14 @@ export function InquiryReplyComposer({
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
startIcon={<Paperclip size={14} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none' }}
>
Anhang
</Button>
<FormControlLabel
control={
<Checkbox
size="small"
checked={markAnswered}
onChange={e => setMarkAnswered(e.target.checked)}
/>
}
label={
<Typography variant="caption" sx={{ fontSize: '0.8rem' }}>
Status auf "Beantwortet" setzen
</Typography>
}
/>
</Box>
<Button
size="small"
startIcon={<Paperclip size={14} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none' }}
>
Anhang
</Button>
<Button
variant="contained"
size="small"
@@ -0,0 +1,260 @@
import { Box, Chip, Divider, Typography } from '@mui/material'
import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
import type { Inquiry } from '../../domain/inquiry'
interface Props {
draft: InquiryPreparationReportDraft
inquiry: Inquiry
properties: Property[]
}
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
areaSqm: 'Fläche',
rentPricePerSqm: 'Mietpreis',
availabilityDate: 'Verfügbar ab',
leaseTerm: 'Mietlaufzeit',
breakoutOption: 'Breakout-Option',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Objekttyp',
floor: 'Stockwerk',
parking: 'Parkplätze',
ceilingHeightM: 'Deckenhöhe',
loadingDocksCount: 'Laderampen',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
isBarrierFree: 'Barrierefrei',
fitOut: 'Ausbaustandard',
publicTransportScore: 'ÖV-Score',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
description: 'Beschreibung',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Plätze` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props) {
const editableByField = Object.fromEntries(draft.editableFields.map(f => [f.id, f.value]))
const selectedProps = draft.selectedPropertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is Property => !!p)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0, fontFamily: 'Georgia, serif' }}>
{/* Page 1 — Need Summary */}
<Box
sx={{
bgcolor: 'white',
p: 4,
minHeight: 600,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
borderRadius: 1,
mb: 2,
}}
>
<Box sx={{ borderBottom: '3px solid #1e3a5f', pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}>
Objektvorschlag
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
</Typography>
</Box>
{editableByField['intro'] && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: '#1e293b' }}>
{editableByField['intro']}
</Typography>
)}
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Ihre Anfrage
</Typography>
<Typography variant="body2" sx={{ color: '#374151', mb: 0.5 }}>
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<Typography variant="body2" sx={{ color: '#374151' }}>
<strong>Betreff:</strong> {inquiry.subject}
</Typography>
</Box>
{editableByField['highlights'] && (
<>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Warum diese Objekte passen
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151', mb: 3 }}>
{editableByField['highlights']}
</Typography>
</>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{selectedProps.map(p => (
<Chip
key={p.id}
label={p.title}
size="small"
icon={<Building2 size={12} />}
sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }}
/>
))}
</Box>
</Box>
{/* Page 2+ — One section per property */}
{selectedProps.map((property, idx) => {
const selection = draft.fieldSelections.find(fs => fs.propertyId === property.id)
const optionalFields = selection?.selectedOptionalFields ?? []
const image = property.images?.[0]
return (
<Box key={property.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Divider sx={{ flex: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
Objekt {idx + 1} von {selectedProps.length}
</Typography>
<Divider sx={{ flex: 1 }} />
</Box>
<Box
sx={{
bgcolor: 'white',
p: 3,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
borderRadius: 1,
mb: 2,
}}
>
<Box sx={{ display: 'flex', gap: 2, mb: 2.5 }}>
{/* Map placeholder */}
<Box
sx={{
width: 160,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!property.mapImageUrl && <MapPin size={28} color="#94a3b8" />}
</Box>
{/* Property photo */}
<Box
sx={{
flex: 1,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!image && <Building2 size={28} color="#94a3b8" />}
</Box>
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 0.5, fontFamily: 'inherit' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<MapPin size={13} />
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Ruler size={13} />
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Calendar size={13} />
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
</Box>
</Box>
{optionalFields.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 0.75,
bgcolor: '#f8fafc',
borderRadius: 1,
p: 1.5,
}}
>
{optionalFields.map(key => {
const val = getFieldValue(property, key)
if (!val) return null
return (
<Box key={key}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.78rem' }}>
{val}
</Typography>
</Box>
)
})}
</Box>
)}
</Box>
</Box>
)
})}
{editableByField['next_steps'] && (
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Nächste Schritte
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151' }}>
{editableByField['next_steps']}
</Typography>
</Box>
)}
</Box>
)
}
@@ -0,0 +1,350 @@
import { useEffect, useRef, useState } from 'react'
import {
Box, Button, CircularProgress, Dialog, DialogContent,
IconButton, LinearProgress, Stack, Step, StepLabel, Stepper,
TextField, Typography,
} from '@mui/material'
import { Download, Plus, Send, Trash2, X } from 'lucide-react'
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
import { offerReportService } from '../../services/offerReportService'
import { usePropertyById } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore'
interface Props {
inquiryId: string
propertyId: string
tenantName: string
onClose: () => void
onAttach: (label: string) => void
}
const STEPS = ['Daten erfassen', 'Bericht prüfen', 'Besichtigungstermine', 'PDF erstellen']
export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose, onAttach }: Props) {
const [step, setStep] = useState(0)
const [draft, setDraft] = useState<OfferReportDraft | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [ready, setReady] = useState(false)
const { data: property } = usePropertyById(propertyId)
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
offerReportService
.create(inquiryId, propertyId, tenantName, property?.title ?? '')
.then(d => { setDraft(d); setLoading(false) })
}, [inquiryId, propertyId, tenantName, property?.title])
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
const handleUpdateField = (fieldId: string, value: string) => {
if (!draft) return
setDraft({ ...draft, editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f) })
}
const addAppointment = () => {
if (!draft) return
const slot: ViewingAppointmentOption = {
id: crypto.randomUUID(),
date: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10),
timeSlot: '10:0011:00',
}
setDraft({ ...draft, viewingAppointments: [...draft.viewingAppointments, slot] })
}
const removeAppointment = (id: string) => {
if (!draft) return
setDraft({ ...draft, viewingAppointments: draft.viewingAppointments.filter(a => a.id !== id) })
}
const updateAppointment = (id: string, field: keyof ViewingAppointmentOption, value: string) => {
if (!draft) return
setDraft({
...draft,
viewingAppointments: draft.viewingAppointments.map(a =>
a.id === id ? { ...a, [field]: value } : a,
),
})
}
const handleGeneratePdf = async () => {
if (!draft) return
await offerReportService.update(draft.id, {
editableFields: draft.editableFields,
viewingAppointments: draft.viewingAppointments,
})
setStep(3)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 18 + 8
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
setReady(true)
offerReportService.generatePdf(draft.id).then(setDraft)
} else {
setProgress(Math.min(100, p))
}
}, 200)
}
return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
</Box>
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
<Stepper activeStep={step} alternativeLabel>
{STEPS.map(label => (
<Step key={label}><StepLabel>{label}</StepLabel></Step>
))}
</Stepper>
</Box>
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<CircularProgress />
</Box>
)}
{/* Step 0: Data summary */}
{!loading && step === 0 && property && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Objekt</Typography>
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 2 }}>
{property.images?.[0] && (
<Box sx={{ height: 120, backgroundImage: `url(${property.images[0]})`, backgroundSize: 'cover', backgroundPosition: 'center', borderRadius: 1, mb: 1.5 }} />
)}
<Typography variant="body2" sx={{ fontWeight: 700 }}>{property.title}</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{property.location.city} · {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Interessent</Typography>
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2 }}>
<Typography variant="body2">{tenantName}</Typography>
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: '#64748b', lineHeight: 1.7 }}>
Das Angebot wird auf Basis der Objekt- und Anfragedaten vorausgefüllt.
Im nächsten Schritt können Sie alle Felder anpassen.
</Typography>
</Box>
</Box>
)}
{/* Step 1: Edit fields */}
{!loading && step === 1 && draft && (
<Stack spacing={2}>
{draft.editableFields.map(f => (
<TextField
key={f.id}
label={f.label}
value={f.value}
onChange={e => handleUpdateField(f.id, e.target.value)}
multiline={f.fieldType === 'textarea'}
rows={f.fieldType === 'textarea' ? 3 : 1}
size="small"
fullWidth
/>
))}
</Stack>
)}
{/* Step 2: Viewing appointments */}
{!loading && step === 2 && draft && (
<Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
Fügen Sie Besichtigungstermine hinzu, die dem Interessenten angeboten werden sollen:
</Typography>
<Stack spacing={1.5} sx={{ mb: 2 }}>
{draft.viewingAppointments.map(a => (
<Box key={a.id} sx={{ display: 'flex', gap: 1.5, alignItems: 'center', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5 }}>
<TextField
label="Datum"
type="date"
size="small"
value={a.date}
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 160 }}
/>
<TextField
label="Zeitfenster"
size="small"
value={a.timeSlot}
onChange={e => updateAppointment(a.id, 'timeSlot', e.target.value)}
placeholder="10:0011:00"
sx={{ width: 140 }}
/>
<TextField
label="Kontaktperson"
size="small"
value={a.contactPerson ?? ''}
onChange={e => updateAppointment(a.id, 'contactPerson', e.target.value)}
sx={{ flex: 1 }}
/>
<IconButton size="small" onClick={() => removeAppointment(a.id)} sx={{ color: '#ef4444' }}>
<Trash2 size={16} />
</IconButton>
</Box>
))}
</Stack>
<Button
startIcon={<Plus size={14} />}
onClick={addAppointment}
sx={{ textTransform: 'none' }}
>
Termin hinzufügen
</Button>
</Box>
)}
{/* Step 3: PDF */}
{step === 3 && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
PDF wird generiert
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
{Math.round(progress)}%
</Typography>
</Box>
</>
) : ready ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, width: '100%' }}>
<Box
sx={{
width: '100%',
maxWidth: 520,
height: 380,
bgcolor: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box sx={{ bgcolor: '#1e3a5f', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', ml: 1, fontSize: '0.7rem' }}>
Angebot_{draft?.propertyId ?? 'Objekt'}.pdf
</Typography>
</Box>
<Box sx={{ flex: 1, p: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 1 }}>
Angebotsschreiben
</Typography>
{draft?.editableFields.slice(0, 3).map(f => (
<Box key={f.id} sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', display: 'block' }}>{f.label}</Typography>
<Typography variant="caption" sx={{ color: '#374151', fontSize: '0.75rem', display: 'block' }}>
{f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''}
</Typography>
</Box>
))}
{(draft?.viewingAppointments.length ?? 0) > 0 && (
<Box sx={{ mt: 1.5, p: 1, bgcolor: '#f1f5f9', borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', display: 'block', mb: 0.5 }}>
Besichtigungstermine
</Typography>
{draft!.viewingAppointments.map(a => (
<Typography key={a.id} variant="caption" sx={{ fontSize: '0.7rem', display: 'block', color: '#475569' }}>
{new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot}
</Typography>
))}
</Box>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
variant="contained"
startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
<Button
variant="outlined"
startIcon={<Send size={16} />}
onClick={() => {
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
onAttach(label)
showToast('Angebot als Anhang hinzugefügt', 'success')
}}
sx={{ textTransform: 'none' }}
>
An Nachricht anhängen
</Button>
</Box>
</Box>
) : null}
</Box>
)}
</DialogContent>
{/* Footer */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
Abbrechen
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
{step > 0 && step < 3 && (
<Button variant="outlined" onClick={() => setStep(s => s - 1)} sx={{ textTransform: 'none' }}>
Zurück
</Button>
)}
{step === 0 && (
<Button
variant="contained"
disabled={loading}
onClick={() => setStep(1)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 1 && (
<Button
variant="contained"
onClick={() => setStep(2)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 2 && (
<Button
variant="contained"
onClick={handleGeneratePdf}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
PDF erstellen
</Button>
)}
</Box>
</Box>
</Dialog>
)
}
@@ -0,0 +1,283 @@
import { useEffect, useRef, useState } from 'react'
import {
Box, Button, Checkbox, CircularProgress, Dialog, DialogContent,
FormControlLabel, LinearProgress, Stack, Step, StepLabel, Stepper,
TextField, Typography,
} from '@mui/material'
import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import type { Property } from '../../domain/property'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { inquiryReportService } from '../../services/inquiryReportService'
import { useToastStore } from '../../stores/toastStore'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
import { useProperties } from '../../hooks/useProperties'
import { ResultType } from '../../domain/enums'
interface Props {
inquiryId: string
inquiry: Inquiry
onClose: () => void
}
const STEPS = ['Objekte wählen', 'Bericht erstellen', 'Prüfen & bearbeiten', 'Finalisieren']
export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
const [step, setStep] = useState(0)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [finalizing, setFinalizing] = useState(false)
const { data: allProperties = [] } = useProperties()
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const selectedProperties = draft?.selectedPropertyIds
.map(id => allProperties.find(p => p.id === id))
.filter((p): p is Property => !!p) ?? []
const toggleProperty = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
)
}
const handleGenerate = async () => {
setStep(1)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 20 + 10
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
inquiryReportService.create(inquiryId, selectedIds).then(d => {
setDraft(d)
setStep(2)
})
} else {
setProgress(Math.min(100, p))
}
}, 200)
}
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
const handleUpdateField = (fieldId: string, value: string) => {
if (!draft) return
setDraft({
...draft,
editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f),
})
}
const handleUpdateFieldSelection = (propertyId: string, sel: ReportObjectFieldSelection) => {
if (!draft) return
setDraft({
...draft,
fieldSelections: draft.fieldSelections.map(fs => fs.propertyId === propertyId ? sel : fs),
})
}
const handleFinalize = async () => {
if (!draft) return
setFinalizing(true)
await inquiryReportService.update(draft.id, { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections })
const finalized = await inquiryReportService.finalize(draft.id)
setDraft(finalized)
setFinalizing(false)
setStep(3)
}
return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
</Box>
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
<Stepper activeStep={step} alternativeLabel>
{STEPS.map(label => (
<Step key={label}><StepLabel>{label}</StepLabel></Step>
))}
</Stepper>
</Box>
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
{/* Step 0: Select properties */}
{step === 0 && (
<Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten:
</Typography>
<Stack spacing={1}>
{portfolioProps.map(p => (
<FormControlLabel
key={p.id}
control={
<Checkbox
checked={selectedIds.includes(p.id)}
onChange={() => toggleProperty(p.id)}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
}
sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 1, m: 0, alignItems: 'flex-start', '& .MuiCheckbox-root': { pt: 0 } }}
/>
))}
</Stack>
</Box>
)}
{/* Step 1: Generating */}
{step === 1 && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
Bericht wird erstellt
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
{Math.round(progress)}%
</Typography>
</Box>
</>
) : (
<Typography variant="body1">Bericht erstellt. Weiterleitung</Typography>
)}
</Box>
)}
{/* Step 2: Review & Edit */}
{step === 2 && draft && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Berichtsfelder bearbeiten</Typography>
<Stack spacing={2} sx={{ mb: 3 }}>
{draft.editableFields.map(f => (
<TextField
key={f.id}
label={f.label}
value={f.value}
onChange={e => handleUpdateField(f.id, e.target.value)}
multiline={f.fieldType === 'textarea'}
rows={f.fieldType === 'textarea' ? 3 : 1}
size="small"
fullWidth
/>
))}
</Stack>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1.5 }}>Felder pro Objekt</Typography>
<Stack spacing={2}>
{draft.fieldSelections.map(fs => {
const prop = allProperties.find(p => p.id === fs.propertyId)
if (!prop) return null
return (
<ReportObjectFieldSelector
key={fs.propertyId}
propertyTitle={prop.title}
value={fs}
onChange={sel => handleUpdateFieldSelection(fs.propertyId, sel)}
/>
)
})}
</Stack>
</Box>
<Box sx={{ width: 380, flexShrink: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Vorschau</Typography>
<Box sx={{ overflow: 'auto', maxHeight: 600 }}>
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
</Box>
</Box>
</Box>
)}
{/* Step 3: Finalized */}
{step === 3 && draft && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1, overflow: 'auto' }}>
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
</Box>
<Box sx={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1 }}>
<Button
variant="contained"
fullWidth
startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
<Button
variant="outlined"
fullWidth
startIcon={<Send size={16} />}
onClick={() => {
showToast('Bericht als Anhang hinzugefügt', 'success')
onClose()
}}
sx={{ textTransform: 'none' }}
>
Als Anhang senden
</Button>
</Box>
</Box>
)}
</DialogContent>
{/* Footer navigation */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
Abbrechen
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
{step === 2 && (
<Button variant="outlined" onClick={() => setStep(0)} sx={{ textTransform: 'none' }}>
Zurück
</Button>
)}
{step === 0 && (
<Button
variant="contained"
disabled={selectedIds.length === 0}
onClick={handleGenerate}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 2 && (
<Button
variant="contained"
disabled={finalizing}
onClick={handleFinalize}
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Finalisieren
</Button>
)}
</Box>
</Box>
</Dialog>
)
}
@@ -1,15 +1,29 @@
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { matchService } from '../../services/matchService'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps {
propertyId: string
inquiryId: string
}
export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPanelProps) {
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate()
const [additionalMatches, setAdditionalMatches] = useState<AdditionalPropertyMatch[]>([])
const [loadingMatches, setLoadingMatches] = useState(false)
useEffect(() => {
setLoadingMatches(true)
matchService
.getAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
.then(setAdditionalMatches)
.finally(() => setLoadingMatches(false))
}, [inquiryId, propertyId])
if (isLoading) {
return (
@@ -102,10 +116,91 @@ export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPane
size="small"
endIcon={<ArrowRight size={14} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', mt: 'auto' }}
sx={{ textTransform: 'none' }}
>
Objekt ansehen
</Button>
{/* Weitere Matches */}
<Divider sx={{ my: 0.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Trophy size={14} color="#d97706" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
Weitere passende Objekte
</Typography>
</Box>
{loadingMatches ? (
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
Keine weiteren Matches
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{additionalMatches.map(m => (
<AdditionalMatchCard key={m.propertyId} match={m} />
))}
</Box>
)}
</Box>
)
}
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: 'white',
}}
>
{match.imageUrl && (
<Box
sx={{
height: 70,
backgroundImage: `url(${match.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
<Box sx={{ p: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3 }}>
{match.title}
</Typography>
<Box
sx={{
px: 0.75,
py: 0.125,
borderRadius: 1,
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
fontWeight: 700,
fontSize: '0.65rem',
flexShrink: 0,
ml: 0.5,
}}
>
{match.matchScore}%
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block' }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
</Typography>
<Button
size="small"
endIcon={<ArrowRight size={11} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#1e3a5f' }}
>
Ansehen
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,150 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
interface Props {
propertyTitle: string
value: ReportObjectFieldSelection
onChange: (v: ReportObjectFieldSelection) => void
}
type FieldGroup = {
label: string
fields: { key: ReportObjectFieldKey; label: string }[]
}
const FIELD_GROUPS: FieldGroup[] = [
{
label: 'Grunddaten',
fields: [
{ key: 'areaSqm', label: 'Fläche (m²)' },
{ key: 'rentPricePerSqm', label: 'Mietpreis (CHF/m²/Jahr)' },
{ key: 'availabilityDate', label: 'Verfügbar ab' },
{ key: 'leaseTerm', label: 'Mietlaufzeit' },
{ key: 'breakoutOption', label: 'Breakout-Option' },
{ key: 'currentTenant', label: 'Aktueller Mieter' },
{ key: 'propertyNumber', label: 'Objektnummer' },
{ key: 'assetType', label: 'Objekttyp' },
],
},
{
label: 'Technische Merkmale',
fields: [
{ key: 'floor', label: 'Stockwerk' },
{ key: 'parking', label: 'Parkplätze' },
{ key: 'ceilingHeightM', label: 'Deckenhöhe (m)' },
{ key: 'loadingDocksCount', label: 'Laderampen' },
{ key: 'powerSupplyKva', label: 'Stromanschluss (kVA)' },
{ key: 'hasServerRoom', label: 'Serverraum' },
{ key: 'isBarrierFree', label: 'Barrierefrei' },
{ key: 'fitOut', label: 'Ausbaustandard' },
{ key: 'publicTransportScore', label: 'ÖV-Score' },
],
},
{
label: 'Weiche Faktoren',
fields: [
{ key: 'prestigeScore', label: 'Prestige' },
{ key: 'visibilityScore', label: 'Sichtbarkeit' },
{ key: 'footfallScore', label: 'Passantenfrequenz' },
{ key: 'commuterAccessScore', label: 'Pendleranbindung' },
{ key: 'talentAccessScore', label: 'Talentpool' },
{ key: 'esgScore', label: 'ESG-Bewertung' },
{ key: 'flexibilityScore', label: 'Flexibilität' },
{ key: 'expansionPotentialScore', label: 'Expansionspotenzial' },
{ key: 'taxEnvironmentScore', label: 'Steuerumgebung' },
],
},
{
label: 'Beschreibung & Medien',
fields: [
{ key: 'description', label: 'Beschreibung' },
{ key: 'units', label: 'Stockwerkstruktur' },
],
},
{
label: 'Datenqualität',
fields: [
{ key: 'dataQuality', label: 'Datenqualität' },
{ key: 'softFactors', label: 'Weiche Faktoren (Gesamt)' },
],
},
]
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
const isSelected = (key: ReportObjectFieldKey) =>
value.selectedOptionalFields.includes(key)
const toggle = (key: ReportObjectFieldKey) => {
const next = isSelected(key)
? value.selectedOptionalFields.filter(k => k !== key)
: [...value.selectedOptionalFields, key]
onChange({ ...value, selectedOptionalFields: next })
}
const selectAll = (keys: ReportObjectFieldKey[]) => {
const next = Array.from(new Set([...value.selectedOptionalFields, ...keys]))
onChange({ ...value, selectedOptionalFields: next })
}
const deselectAll = (keys: ReportObjectFieldKey[]) => {
onChange({ ...value, selectedOptionalFields: value.selectedOptionalFields.filter(k => !keys.includes(k)) })
}
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1 }}>
Felder für: {propertyTitle}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 1.5 }}>
Pflichtfelder (immer enthalten): Titel, Standort, Karte, Fotos
</Typography>
{FIELD_GROUPS.map(group => {
const groupKeys = group.fields.map(f => f.key)
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
return (
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>
{group.label}
</Typography>
<Typography variant="caption" sx={{ ml: 1, color: '#64748b', alignSelf: 'center' }}>
({groupKeys.filter(k => value.selectedOptionalFields.includes(k)).length}/{groupKeys.length})
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0, pb: 1 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
<Button size="small" onClick={() => selectAll(groupKeys)} sx={{ textTransform: 'none', fontSize: '0.7rem', p: '2px 8px', minWidth: 0 }}>
Alle
</Button>
<Button size="small" onClick={() => deselectAll(groupKeys)} sx={{ textTransform: 'none', fontSize: '0.7rem', p: '2px 8px', minWidth: 0 }}>
Keine
</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0 }}>
{group.fields.map(f => (
<FormControlLabel
key={f.key}
control={
<Checkbox
size="small"
checked={isSelected(f.key)}
onChange={() => toggle(f.key)}
sx={{ py: 0.25 }}
/>
}
label={<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{f.label}</Typography>}
sx={{ m: 0 }}
/>
))}
</Box>
</AccordionDetails>
</Accordion>
)
})}
</Box>
)
}
+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
subject: string
message: string
status: InquiryStatus
status?: InquiryStatus
unreadCount: number
isRead: boolean
lastReadAt?: string
matchScore?: number
createdAt: 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
units?: PropertyUnit[]
mapImageUrl?: string
leaseTerm?: string
leaseStartDate?: string
+4 -5
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { inquiryService } from '../services/inquiryService'
import type { InquiryFilters } from '../provider/IInquiryProvider'
import type { InquiryStatus, Attachment } from '../domain/inquiry'
import type { Attachment } from '../domain/inquiry'
export function useActiveInquiries(filters?: InquiryFilters) {
return useQuery({
@@ -37,12 +37,11 @@ export function useSendInquiryReply() {
})
}
export function useUpdateInquiryStatus() {
export function useMarkThreadAsRead() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, status }: { id: string; status: InquiryStatus }) =>
inquiryService.updateInquiryStatus(id, status),
onSuccess: (_data, { id }) => {
mutationFn: (id: string) => inquiryService.markThreadAsRead(id),
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: ['inquiry', id] })
qc.invalidateQueries({ queryKey: ['inquiries'] })
},
+54 -30
View File
@@ -1,11 +1,12 @@
import type { Inquiry } from '../domain/inquiry'
export const mockInquiries: Inquiry[] = [
// 1 — NEW
// 1 — UNREAD
{
id: 'inq-001',
organizationId: 'org-wincasa',
propertyId: 'prop-001',
needId: 'need-001',
tenantName: 'Sandra Meier',
tenantCompany: 'Innovatech AG',
tenantEmail: 'sandra.meier@innovatech.ch',
@@ -13,6 +14,8 @@ export const mockInquiries: Inquiry[] = [
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',
status: 'new',
unreadCount: 1,
isRead: false,
matchScore: 91,
createdAt: '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',
organizationId: 'org-wincasa',
propertyId: 'prop-009',
tenantName: 'Markus Frei',
tenantCompany: 'Frei Logistik AG',
tenantEmail: 'm.frei@frei-logistik.ch',
subject: 'Lagerfläche Winterthur — Verfügbarkeit?',
id: inq-002,
organizationId: org-wincasa,
propertyId: prop-009,
needId: need-002,
tenantName: Markus Frei,
tenantCompany: Frei Logistik AG,
tenantEmail: m.frei@frei-logistik.ch,
subject: Lagerfläche Winterthur Verfügbarkeit?,
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',
status: 'new',
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,
unreadCount: 1,
isRead: false,
matchScore: 85,
createdAt: '2026-05-17T11:14:00Z',
updatedAt: '2026-05-17T11:14:00Z',
createdAt: 2026-05-17T11:14:00Z,
updatedAt: 2026-05-17T11:14:00Z,
thread: [
{
id: 'msg-002-1',
@@ -61,7 +67,7 @@ export const mockInquiries: Inquiry[] = [
],
},
// 3 — NEW
// 3 — READ
{
id: 'inq-003',
organizationId: 'org-wincasa',
@@ -73,6 +79,9 @@ export const mockInquiries: Inquiry[] = [
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',
status: 'new',
unreadCount: 0,
isRead: true,
lastReadAt: '2026-05-16T16:10:00Z',
matchScore: 88,
createdAt: '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',
organizationId: 'org-wincasa',
propertyId: 'prop-012',
needId: 'need-004',
tenantName: 'Daniel Hofer',
tenantCompany: 'Hofer Treuhand AG',
tenantEmail: 'd.hofer@hofer-treuhand.ch',
@@ -103,6 +113,8 @@ export const mockInquiries: Inquiry[] = [
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',
status: 'in_progress',
unreadCount: 3,
isRead: false,
matchScore: 93,
createdAt: '2026-05-14T09:20:00Z',
updatedAt: '2026-05-15T14:00:00Z',
@@ -134,7 +146,7 @@ export const mockInquiries: Inquiry[] = [
],
},
// 5 — IN_PROGRESS
// 5 — READ
{
id: 'inq-005',
organizationId: 'org-wincasa',
@@ -146,6 +158,9 @@ export const mockInquiries: Inquiry[] = [
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',
status: 'in_progress',
unreadCount: 0,
isRead: true,
lastReadAt: '2026-05-14T17:00:00Z',
matchScore: 79,
createdAt: '2026-05-13T13:10:00Z',
updatedAt: '2026-05-14T16:20:00Z',
@@ -186,21 +201,24 @@ export const mockInquiries: Inquiry[] = [
],
},
// 6 — ANSWERED
// 6 — READ
{
id: 'inq-006',
organizationId: 'org-wincasa',
propertyId: 'prop-002',
tenantName: 'Thomas Brun',
tenantCompany: 'Schweizer Logistik GmbH',
tenantEmail: 't.brun@swisslogistik.ch',
subject: 'Lagerfläche Hardstrasse Basel',
id: inq-006,
organizationId: org-wincasa,
propertyId: prop-002,
tenantName: Thomas Brun,
tenantCompany: Schweizer Logistik GmbH,
tenantEmail: t.brun@swisslogistik.ch,
subject: Lagerfläche Hardstrasse Basel,
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',
status: 'answered',
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,
unreadCount: 0,
isRead: true,
lastReadAt: 2026-05-10T10:00:00Z,
matchScore: 87,
createdAt: '2026-05-08T10:00:00Z',
updatedAt: '2026-05-10T09:30:00Z',
createdAt: 2026-05-08T10:00:00Z,
updatedAt: 2026-05-10T09:30:00Z,
thread: [
{
id: 'msg-006-1',
@@ -236,7 +254,7 @@ export const mockInquiries: Inquiry[] = [
],
},
// 7 — ANSWERED
// 7 — READ
{
id: 'inq-007',
organizationId: 'org-wincasa',
@@ -248,6 +266,9 @@ export const mockInquiries: Inquiry[] = [
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',
status: 'answered',
unreadCount: 0,
isRead: true,
lastReadAt: '2026-05-06T12:00:00Z',
matchScore: 82,
createdAt: '2026-05-05T09:00:00Z',
updatedAt: '2026-05-06T11:00:00Z',
@@ -279,7 +300,7 @@ export const mockInquiries: Inquiry[] = [
],
},
// 8 — ARCHIVED
// 8 — READ (archived)
{
id: 'inq-008',
organizationId: 'org-wincasa',
@@ -291,6 +312,9 @@ export const mockInquiries: Inquiry[] = [
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',
status: 'archived',
unreadCount: 0,
isRead: true,
lastReadAt: '2026-04-25T11:00:00Z',
matchScore: 64,
createdAt: '2026-04-20T14:30: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,
riskLevel: RiskLevel.LOW,
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',
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' },
@@ -95,6 +96,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 3.0,
riskLevel: RiskLevel.LOW,
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',
importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -144,6 +146,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 5.0,
riskLevel: RiskLevel.LOW,
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',
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' },
@@ -199,6 +202,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 4.5,
riskLevel: RiskLevel.LOW,
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',
importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -246,6 +250,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 2.8,
riskLevel: RiskLevel.LOW,
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',
importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -293,6 +298,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 8.0,
riskLevel: RiskLevel.LOW,
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',
importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -340,6 +346,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 2.5,
riskLevel: RiskLevel.LOW,
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',
importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -388,6 +395,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 6.0,
riskLevel: RiskLevel.LOW,
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',
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' },
@@ -443,6 +451,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 5.0,
riskLevel: RiskLevel.LOW,
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',
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' },
@@ -497,6 +506,7 @@ export const mockProperties: Property[] = [
ancillaryCosts: 2.8,
riskLevel: RiskLevel.LOW,
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',
importedAt: '2025-01-15T08:00:00Z',
lastUpdatedAt: '2025-05-10T10:00:00Z',
@@ -546,6 +556,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-04-10T09:00:00Z',
},
@@ -574,6 +585,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-03-20T15:00:00Z',
},
@@ -608,6 +620,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-04-05T10:00:00Z',
},
@@ -642,6 +655,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-03-28T12:00:00Z',
},
@@ -677,6 +691,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-04-18T11:00:00Z',
},
@@ -711,6 +726,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-04-02T09:00:00Z',
},
@@ -740,6 +756,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-03-25T14:00:00Z',
},
@@ -774,6 +791,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-04-12T11:00:00Z',
},
@@ -809,6 +827,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-04-08T09:00:00Z',
},
@@ -843,6 +862,7 @@ export const mockProperties: Property[] = [
},
riskLevel: RiskLevel.MEDIUM,
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',
updatedAt: '2025-04-14T10:00:00Z',
},
+2
View File
@@ -10,6 +10,8 @@ export interface IInquiryProvider {
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
getById(id: string): Promise<Inquiry | null>
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
markThreadAsRead(id: string): Promise<Inquiry>
getUnreadCount(): Promise<number>
addMessage(
inquiryId: string,
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() }
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(
inquiryId: string,
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 type { InquiryFilters } from '../provider/IInquiryProvider'
import type { Inquiry, InquiryStatus, Attachment } from '../domain/inquiry'
import type { Inquiry, Attachment } from '../domain/inquiry'
const provider = MockupInquiryProvider
@@ -49,12 +49,18 @@ export const inquiryService = {
}
},
async updateInquiryStatus(
id: string,
status: InquiryStatus,
): Promise<ServiceResult<Inquiry>> {
async markThreadAsRead(id: string): Promise<ServiceResult<Inquiry>> {
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 }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+38
View File
@@ -1,14 +1,17 @@
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
import type { MatchFilters } from '../provider/IMatchProvider'
import type { Match, PropertyNeedMatch } from '../domain/match'
import type { Need } from '../domain/need'
import type { Property } from '../domain/property'
import type { StrongMatchItem } from '../domain/dashboard'
import type { ScoreBreakdown } from '../domain/match'
import type { AdditionalPropertyMatch } from '../domain/additionalMatch'
import type { ListResponse, ItemResponse } from './types'
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
import { ResultType } from '../domain/enums'
const provider = MockupMatchProvider
@@ -98,6 +101,41 @@ export const matchService = {
.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[]> {
const [matches, properties] = await Promise.all([
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]
},
}