diff --git a/src/App.tsx b/src/App.tsx
index 908bc7e..fe53ef2 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -23,6 +23,7 @@ const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
const Properties = lazy(() => import('./pages/supply/Properties'))
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
+const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
@@ -58,6 +59,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/components/anfragencenter/ActiveInquiriesTab.tsx b/src/components/anfragencenter/ActiveInquiriesTab.tsx
new file mode 100644
index 0000000..7f6e9fd
--- /dev/null
+++ b/src/components/anfragencenter/ActiveInquiriesTab.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useState } from 'react'
+import { Box, IconButton, Tooltip, Typography } from '@mui/material'
+import { LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
+import { useActiveInquiries } from '../../hooks/useInquiries'
+import { EmptyState } from '../ui'
+import { InquiryList } from './InquiryList'
+import { InquiryCardGrid } from './InquiryCardGrid'
+import { InquiryDetailPanel } from './InquiryDetailPanel'
+
+type ViewMode = 'list' | 'grid'
+
+const VIEW_STORAGE_KEY = 'view-inquiries'
+
+function loadViewMode(): ViewMode {
+ if (typeof window === 'undefined') return 'list'
+ const stored = window.localStorage.getItem(VIEW_STORAGE_KEY)
+ return stored === 'grid' ? 'grid' : 'list'
+}
+
+export function ActiveInquiriesTab() {
+ const { data: inquiries = [], isLoading } = useActiveInquiries()
+ const [selectedId, setSelectedId] = useState(null)
+ const [view, setView] = useState(loadViewMode())
+
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(VIEW_STORAGE_KEY, view)
+ }
+ }, [view])
+
+ // Auto-select first inquiry when data loads
+ useEffect(() => {
+ if (!selectedId && inquiries.length > 0) {
+ setSelectedId(inquiries[0].id)
+ }
+ }, [inquiries, selectedId])
+
+ if (!isLoading && inquiries.length === 0) {
+ return (
+ }
+ title="Keine aktiven Anfragen"
+ description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
+ />
+ )
+ }
+
+ return (
+
+ {/* Left column: list/grid */}
+
+
+
+
+ Anfragen
+
+
+ {inquiries.length}
+
+
+
+
+ setView('list')}
+ sx={{
+ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent',
+ color: view === 'list' ? '#1e3a5f' : '#64748b',
+ '&:hover': { bgcolor: view === 'list' ? '#c7d2fe' : '#f1f5f9' },
+ }}
+ >
+
+
+
+
+ setView('grid')}
+ sx={{
+ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent',
+ color: view === 'grid' ? '#1e3a5f' : '#64748b',
+ '&:hover': { bgcolor: view === 'grid' ? '#c7d2fe' : '#f1f5f9' },
+ }}
+ >
+
+
+
+
+
+
+ {view === 'list' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Right column: detail */}
+
+ {selectedId ? (
+
+ ) : (
+ }
+ title="Anfrage auswählen"
+ description="Wählen Sie eine Anfrage aus der Liste, um Details anzuzeigen und zu antworten."
+ />
+ )}
+
+
+ )
+}
diff --git a/src/components/anfragencenter/AiOfferEmailButton.tsx b/src/components/anfragencenter/AiOfferEmailButton.tsx
new file mode 100644
index 0000000..a25d3a5
--- /dev/null
+++ b/src/components/anfragencenter/AiOfferEmailButton.tsx
@@ -0,0 +1,63 @@
+import { useState } from 'react'
+import { Box, Button, CircularProgress, Typography } from '@mui/material'
+import { Sparkles } from 'lucide-react'
+import { aiService } from '../../services/aiService'
+
+interface AiOfferEmailButtonProps {
+ needTitle: string
+ selectedProperties: string[]
+ matchScores: number[]
+ onGenerated: (subject: string, body: string) => void
+}
+
+export function AiOfferEmailButton({
+ needTitle,
+ selectedProperties,
+ matchScores,
+ onGenerated,
+}: AiOfferEmailButtonProps) {
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleClick = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await aiService.generateOfferEmail({
+ needTitle,
+ properties: selectedProperties,
+ matchScores,
+ })
+ onGenerated(res.data.subject, res.data.body)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'KI-Generierung fehlgeschlagen')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ : }
+ onClick={handleClick}
+ disabled={loading}
+ sx={{
+ textTransform: 'none',
+ color: '#7c3aed',
+ borderColor: '#c4b5fd',
+ '&:hover': { bgcolor: '#f5f3ff', borderColor: '#7c3aed' },
+ }}
+ variant="outlined"
+ >
+ KI-Mail generieren
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/src/components/anfragencenter/EditableOfferFieldList.tsx b/src/components/anfragencenter/EditableOfferFieldList.tsx
new file mode 100644
index 0000000..5929339
--- /dev/null
+++ b/src/components/anfragencenter/EditableOfferFieldList.tsx
@@ -0,0 +1,47 @@
+import { Box, TextField, Typography } from '@mui/material'
+import type { OfferEditableField } from '../../domain/offer'
+
+interface EditableOfferFieldListProps {
+ fields: OfferEditableField[]
+ values: Record
+ onChange: (id: string, value: string) => void
+}
+
+export function EditableOfferFieldList({ fields, values, onChange }: EditableOfferFieldListProps) {
+ return (
+
+ {fields.map(f => {
+ const value = values[f.id] !== undefined ? values[f.id] : f.value
+ return (
+
+
+ {f.label}
+
+ onChange(f.id, e.target.value)}
+ multiline={f.fieldType === 'textarea'}
+ rows={f.fieldType === 'textarea' ? 3 : undefined}
+ sx={{
+ '& .MuiInputBase-input': { fontSize: '0.85rem' },
+ }}
+ />
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryCard.tsx b/src/components/anfragencenter/InquiryCard.tsx
new file mode 100644
index 0000000..aa6e8bb
--- /dev/null
+++ b/src/components/anfragencenter/InquiryCard.tsx
@@ -0,0 +1,88 @@
+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 {
+ inquiry: Inquiry
+ selected: boolean
+ onClick: () => void
+}
+
+export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
+ return (
+
+
+
+ {inquiry.tenantName}
+ {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
+
+
+
+
+
+ {inquiry.subject}
+
+
+
+
+
+ {propertyLabelFromId(inquiry.propertyId)}
+
+
+
+
+
+ {formatInquiryDate(inquiry.createdAt)}
+
+ {inquiry.matchScore !== undefined && (
+
+ Match {inquiry.matchScore}%
+
+ )}
+
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryCardGrid.tsx b/src/components/anfragencenter/InquiryCardGrid.tsx
new file mode 100644
index 0000000..8f22f03
--- /dev/null
+++ b/src/components/anfragencenter/InquiryCardGrid.tsx
@@ -0,0 +1,34 @@
+import { Box } from '@mui/material'
+import type { Inquiry } from '../../domain/inquiry'
+import { InquiryCard } from './InquiryCard'
+
+interface InquiryCardGridProps {
+ inquiries: Inquiry[]
+ selectedId: string | null
+ onSelect: (id: string) => void
+}
+
+export function InquiryCardGrid({ inquiries, selectedId, onSelect }: InquiryCardGridProps) {
+ return (
+
+ {inquiries.map(inq => (
+ onSelect(inq.id)}
+ />
+ ))}
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryChat.tsx b/src/components/anfragencenter/InquiryChat.tsx
new file mode 100644
index 0000000..d68704a
--- /dev/null
+++ b/src/components/anfragencenter/InquiryChat.tsx
@@ -0,0 +1,29 @@
+import { useEffect, useRef } from 'react'
+import { Box } from '@mui/material'
+import type { Inquiry } from '../../domain/inquiry'
+import { InquiryMessageBubble } from './InquiryMessageBubble'
+import { InquiryReplyComposer } from './InquiryReplyComposer'
+
+interface InquiryChatProps {
+ inquiry: Inquiry
+}
+
+export function InquiryChat({ inquiry }: InquiryChatProps) {
+ const endRef = useRef(null)
+
+ useEffect(() => {
+ endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
+ }, [inquiry.thread.length])
+
+ return (
+
+
+ {inquiry.thread.map(m => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryDetailPanel.tsx b/src/components/anfragencenter/InquiryDetailPanel.tsx
new file mode 100644
index 0000000..8d70cc1
--- /dev/null
+++ b/src/components/anfragencenter/InquiryDetailPanel.tsx
@@ -0,0 +1,120 @@
+import {
+ Box,
+ CircularProgress,
+ MenuItem,
+ Select,
+ Typography,
+ type SelectChangeEvent,
+} from '@mui/material'
+import { useInquiryById, useUpdateInquiryStatus } from '../../hooks/useInquiries'
+import type { InquiryStatus } from '../../domain/inquiry'
+import { InquiryChat } from './InquiryChat'
+import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
+import { InquiryStatusBadge } from './InquiryStatusBadge'
+import { useToastStore } from '../../stores/toastStore'
+
+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)
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!inquiry) {
+ return (
+
+
+ Anfrage nicht gefunden
+
+
+ )
+ }
+
+ const handleStatusChange = async (e: SelectChangeEvent) => {
+ 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 (
+
+
+
+
+ {inquiry.tenantName}
+ {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
+ {inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
+
+
+ {inquiry.subject}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryList.tsx b/src/components/anfragencenter/InquiryList.tsx
new file mode 100644
index 0000000..edfce3a
--- /dev/null
+++ b/src/components/anfragencenter/InquiryList.tsx
@@ -0,0 +1,24 @@
+import { Box } from '@mui/material'
+import type { Inquiry } from '../../domain/inquiry'
+import { InquiryListRow } from './InquiryListRow'
+
+interface InquiryListProps {
+ inquiries: Inquiry[]
+ selectedId: string | null
+ onSelect: (id: string) => void
+}
+
+export function InquiryList({ inquiries, selectedId, onSelect }: InquiryListProps) {
+ return (
+
+ {inquiries.map(inq => (
+ onSelect(inq.id)}
+ />
+ ))}
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryListRow.tsx b/src/components/anfragencenter/InquiryListRow.tsx
new file mode 100644
index 0000000..101b2a4
--- /dev/null
+++ b/src/components/anfragencenter/InquiryListRow.tsx
@@ -0,0 +1,77 @@
+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 {
+ inquiry: Inquiry
+ selected: boolean
+ onClick: () => void
+}
+
+export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
+ return (
+
+
+
+ {inquiry.tenantName}
+ {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
+
+
+
+
+ {inquiry.subject}
+
+
+
+
+ {propertyLabelFromId(inquiry.propertyId)}
+
+ {inquiry.matchScore !== undefined && (
+
+ {inquiry.matchScore}%
+
+ )}
+
+
+ {formatInquiryDate(inquiry.createdAt)}
+
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryMessageBubble.tsx b/src/components/anfragencenter/InquiryMessageBubble.tsx
new file mode 100644
index 0000000..7398050
--- /dev/null
+++ b/src/components/anfragencenter/InquiryMessageBubble.tsx
@@ -0,0 +1,152 @@
+import { Box, Typography } from '@mui/material'
+import { Paperclip } from 'lucide-react'
+import type { InquiryMessage } from '../../domain/inquiry'
+import { formatInquiryDate, formatFileSize } from './inquiryUtils'
+
+interface InquiryMessageBubbleProps {
+ message: InquiryMessage
+}
+
+export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
+ const isTenant = message.senderType === 'tenant'
+ const isSupply = message.senderType === 'supply_user'
+ const isSystemOrAi = message.senderType === 'system' || message.senderType === 'ai'
+
+ if (isSystemOrAi) {
+ return (
+
+
+ {message.senderName}: {message.body}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {message.senderName}
+
+
+ {formatInquiryDate(message.createdAt)}
+
+
+ {message.subject && (
+
+ {message.subject}
+
+ )}
+
+ {message.body}
+
+
+ {message.attachments.length > 0 && (
+
+ {message.attachments.map(att => (
+
+
+
+ {att.fileName}
+
+ {att.fileSize && (
+
+ {formatFileSize(att.fileSize)}
+
+ )}
+
+ ))}
+
+ )}
+
+ {isSupply && (
+
+ Wincasa AG
+
+ )}
+
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryReplyComposer.tsx b/src/components/anfragencenter/InquiryReplyComposer.tsx
new file mode 100644
index 0000000..fc3eb97
--- /dev/null
+++ b/src/components/anfragencenter/InquiryReplyComposer.tsx
@@ -0,0 +1,184 @@
+import { 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 { useToastStore } from '../../stores/toastStore'
+import type { Attachment } from '../../domain/inquiry'
+import { formatFileSize } from './inquiryUtils'
+
+interface InquiryReplyComposerProps {
+ inquiryId: string
+ defaultSubject: string
+ onSent?: () => void
+}
+
+export function InquiryReplyComposer({
+ inquiryId,
+ defaultSubject,
+ onSent,
+}: InquiryReplyComposerProps) {
+ const [subject, setSubject] = useState(
+ defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
+ )
+ const [body, setBody] = useState('')
+ const [markAnswered, setMarkAnswered] = useState(true)
+ const [attachments, setAttachments] = useState([])
+
+ const sendReply = useSendInquiryReply()
+ const updateStatus = useUpdateInquiryStatus()
+ const showToast = useToastStore(s => s.showToast)
+
+ const sending = sendReply.isPending || updateStatus.isPending
+
+ const handleAddMockAttachment = () => {
+ const name = `Anhang_${attachments.length + 1}.pdf`
+ setAttachments(prev => [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ fileName: name,
+ fileType: 'application/pdf',
+ fileSize: 240_000 + Math.floor(Math.random() * 800_000),
+ },
+ ])
+ }
+
+ const handleRemoveAttachment = (id: string) => {
+ setAttachments(prev => prev.filter(a => a.id !== id))
+ }
+
+ const handleSend = async () => {
+ if (!body.trim()) {
+ showToast('Bitte geben Sie eine Nachricht ein', 'warning')
+ return
+ }
+ const result = await sendReply.mutateAsync({
+ inquiryId,
+ payload: { subject, body, attachments },
+ })
+ if (result.error) {
+ 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([])
+ onSent?.()
+ }
+
+ return (
+
+ setSubject(e.target.value)}
+ fullWidth
+ />
+ setBody(e.target.value)}
+ multiline
+ rows={4}
+ placeholder="Antwort verfassen..."
+ fullWidth
+ />
+
+ {attachments.length > 0 && (
+
+ {attachments.map(a => (
+
+
+
+ {a.fileName}
+
+
+ {formatFileSize(a.fileSize)}
+
+ handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
+
+
+
+ ))}
+
+ )}
+
+
+
+ }
+ onClick={handleAddMockAttachment}
+ sx={{ textTransform: 'none' }}
+ >
+ Anhang
+
+ setMarkAnswered(e.target.checked)}
+ />
+ }
+ label={
+
+ Status auf "Beantwortet" setzen
+
+ }
+ />
+
+ :
+ }
+ onClick={handleSend}
+ disabled={sending || !body.trim()}
+ sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
+ >
+ Antwort senden
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/InquiryStatusBadge.tsx b/src/components/anfragencenter/InquiryStatusBadge.tsx
new file mode 100644
index 0000000..ec20599
--- /dev/null
+++ b/src/components/anfragencenter/InquiryStatusBadge.tsx
@@ -0,0 +1,31 @@
+import { Chip } from '@mui/material'
+import type { InquiryStatus } from '../../domain/inquiry'
+
+const STATUS_MAP: Record = {
+ new: { label: 'Neu', bg: '#1e40af' },
+ in_progress: { label: 'In Bearbeitung', bg: '#d97706' },
+ answered: { label: 'Beantwortet', bg: '#1a7a4a' },
+ archived: { label: 'Archiviert', bg: '#64748b' },
+}
+
+interface InquiryStatusBadgeProps {
+ status: InquiryStatus
+ size?: 'small' | 'medium'
+}
+
+export function InquiryStatusBadge({ status, size = 'small' }: InquiryStatusBadgeProps) {
+ const cfg = STATUS_MAP[status]
+ return (
+
+ )
+}
diff --git a/src/components/anfragencenter/LatentInquiriesTab.tsx b/src/components/anfragencenter/LatentInquiriesTab.tsx
new file mode 100644
index 0000000..0120305
--- /dev/null
+++ b/src/components/anfragencenter/LatentInquiriesTab.tsx
@@ -0,0 +1,42 @@
+import { useEffect, useState } from 'react'
+import { Box } from '@mui/material'
+import { Sparkles } from 'lucide-react'
+import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
+import { EmptyState } from '../ui'
+import { PublicNeedList } from './PublicNeedList'
+import { PublicNeedDetail } from './PublicNeedDetail'
+import { OwnPropertyMatchList } from './OwnPropertyMatchList'
+
+export function LatentInquiriesTab() {
+ const { data: needs = [] } = usePublicNeeds()
+ const [selectedNeedId, setSelectedNeedId] = useState(null)
+ const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
+
+ useEffect(() => {
+ if (!selectedNeedId && needs.length > 0) {
+ setSelectedNeedId(needs[0].id)
+ }
+ }, [needs, selectedNeedId])
+
+ return (
+
+
+
+
+ {selectedNeed ? (
+
+ ) : (
+
+ }
+ title="Bedarf auswählen"
+ description="Wählen Sie einen latenten Bedarf, um Details und passende Objekte zu sehen."
+ />
+
+ )}
+
+
+ {selectedNeed && }
+
+ )
+}
diff --git a/src/components/anfragencenter/MockPdfPreview.tsx b/src/components/anfragencenter/MockPdfPreview.tsx
new file mode 100644
index 0000000..16990f1
--- /dev/null
+++ b/src/components/anfragencenter/MockPdfPreview.tsx
@@ -0,0 +1,95 @@
+import { Box, Typography } from '@mui/material'
+import { FileText } from 'lucide-react'
+import { useOfferWizardStore } from '../../stores/offerWizardStore'
+import { mockProperties } from '../../mock-data/properties'
+
+interface MockPdfPreviewProps {
+ fields: Record
+ fallbackFields: Record
+}
+
+export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
+ const needTitle = useOfferWizardStore(s => s.needTitle)
+ const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
+
+ const properties = mockProperties.filter(p => propertyIds.includes(p.id))
+
+ const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
+
+ return (
+
+
+
+
+ Angebotsvorschau (PDF)
+
+
+
+
+ Wincasa AG · Zürich
+
+
+ {new Date().toLocaleDateString('de-CH')}
+
+
+
+ Angebot: {needTitle}
+
+
+
+ {v('recipient_salutation')},
+
+
+
+ {v('offer_intro')}
+
+
+
+ Hervorgehobene Kriterien
+
+
+ {v('highlighted_criteria')}
+
+
+
+ Vorgeschlagene Objekte
+
+
+ {properties.map(p => (
+
+ {p.title} — {p.location.city}, {p.areaSqm.toLocaleString('de-CH')} m², CHF {p.rentPricePerSqm}/m²
+
+ ))}
+ {properties.length === 0 && (
+
+ Keine Objekte ausgewählt.
+
+ )}
+
+
+
+ {v('next_steps')}
+
+
+
+ {v('closing')}
+
+
+ )
+}
diff --git a/src/components/anfragencenter/OfferChatComposer.tsx b/src/components/anfragencenter/OfferChatComposer.tsx
new file mode 100644
index 0000000..96d158f
--- /dev/null
+++ b/src/components/anfragencenter/OfferChatComposer.tsx
@@ -0,0 +1,200 @@
+import { Box, Button, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
+import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
+import { useOfferWizardStore } from '../../stores/offerWizardStore'
+import { useSendOffer } from '../../hooks/useOffers'
+import { useToastStore } from '../../stores/toastStore'
+import { AiOfferEmailButton } from './AiOfferEmailButton'
+import { mockProperties } from '../../mock-data/properties'
+import { deterministicMatchScore } from './latentNeedUtils'
+import { formatFileSize } from './inquiryUtils'
+
+export function OfferChatComposer() {
+ const subject = useOfferWizardStore(s => s.messageSubject)
+ const body = useOfferWizardStore(s => s.messageDraft)
+ const setSubject = useOfferWizardStore(s => s.setMessageSubject)
+ const setBody = useOfferWizardStore(s => s.setMessageDraft)
+ const attachments = useOfferWizardStore(s => s.attachments)
+ const addAttachment = useOfferWizardStore(s => s.addAttachment)
+ const removeAttachment = useOfferWizardStore(s => s.removeAttachment)
+ const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
+ const selectedPropertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
+ const needId = useOfferWizardStore(s => s.selectedNeedId)
+ const needTitle = useOfferWizardStore(s => s.needTitle)
+ const setStep = useOfferWizardStore(s => s.setStep)
+ const reset = useOfferWizardStore(s => s.reset)
+
+ const sendOffer = useSendOffer()
+ const showToast = useToastStore(s => s.showToast)
+
+ const propertyTitles = selectedPropertyIds.map(id => {
+ const p = mockProperties.find(pp => pp.id === id)
+ return p?.title ?? id
+ })
+ const scores = needId
+ ? selectedPropertyIds.map(id => deterministicMatchScore(id, needId))
+ : []
+
+ const handleAiGenerated = (newSubject: string, newBody: string) => {
+ setSubject(newSubject)
+ setBody(newBody)
+ showToast('KI-Vorschlag eingefügt', 'success')
+ }
+
+ const handleAddAttachment = () => {
+ addAttachment({
+ id: crypto.randomUUID(),
+ fileName: `Anhang_${attachments.length + 1}.pdf`,
+ fileType: 'application/pdf',
+ fileSize: 200_000 + Math.floor(Math.random() * 600_000),
+ })
+ }
+
+ const handleSend = async () => {
+ if (!offerDraftId) return
+ if (!body.trim()) {
+ showToast('Bitte Nachricht eingeben', 'warning')
+ return
+ }
+ const res = await sendOffer.mutateAsync(offerDraftId)
+ if (res.error) {
+ showToast(`Fehler: ${res.error}`, 'error')
+ return
+ }
+ showToast('Angebot erfolgreich gesendet', 'success')
+ reset()
+ }
+
+ return (
+
+
+
+
+
+ Empfänger-Kontext
+
+
+ Bedarf: {needTitle} · {selectedPropertyIds.length} Objekt
+ {selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot
+
+
+
+ setSubject(e.target.value)}
+ />
+
+ setBody(e.target.value)}
+ multiline
+ rows={8}
+ />
+
+ {attachments.length > 0 && (
+
+
+ Anhänge
+
+
+ {attachments.map(a => (
+
+
+
+ {a.fileName}
+
+ {a.generated && (
+
+ PDF
+
+ )}
+
+ {formatFileSize(a.fileSize)}
+
+ removeAttachment(a.id)} sx={{ p: 0.25 }}>
+
+
+
+ ))}
+
+
+ )}
+
+
+ }
+ onClick={handleAddAttachment}
+ sx={{ textTransform: 'none' }}
+ >
+ Anhang
+
+
+
+
+
+
+
+ } onClick={() => setStep('checked')} sx={{ textTransform: 'none' }}>
+ Zurück
+
+ : }
+ onClick={handleSend}
+ disabled={sendOffer.isPending || !body.trim()}
+ sx={{
+ textTransform: 'none',
+ bgcolor: '#16a34a',
+ fontWeight: 600,
+ '&:hover': { bgcolor: '#15803d' },
+ }}
+ >
+ Absenden
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/OfferCheckedAction.tsx b/src/components/anfragencenter/OfferCheckedAction.tsx
new file mode 100644
index 0000000..f442410
--- /dev/null
+++ b/src/components/anfragencenter/OfferCheckedAction.tsx
@@ -0,0 +1,96 @@
+import { Box, Button, CircularProgress, Typography } from '@mui/material'
+import { CheckCircle2 } from 'lucide-react'
+import { useOfferWizardStore } from '../../stores/offerWizardStore'
+import { useMarkOfferChecked } from '../../hooks/useOffers'
+import { useToastStore } from '../../stores/toastStore'
+
+export function OfferCheckedAction() {
+ const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
+ const needTitle = useOfferWizardStore(s => s.needTitle)
+ const setStep = useOfferWizardStore(s => s.setStep)
+ const addAttachment = useOfferWizardStore(s => s.addAttachment)
+ const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
+ const setMessageSubject = useOfferWizardStore(s => s.setMessageSubject)
+
+ const markChecked = useMarkOfferChecked()
+ const showToast = useToastStore(s => s.showToast)
+
+ const handleConfirm = async () => {
+ if (!offerDraftId) return
+ const res = await markChecked.mutateAsync(offerDraftId)
+ if (res.error) {
+ showToast(`Fehler: ${res.error}`, 'error')
+ return
+ }
+ // Add generated PDF as attachment to message
+ addAttachment({
+ id: crypto.randomUUID(),
+ fileName: `Angebot_${needTitle.replace(/\s+/g, '_')}.pdf`,
+ fileType: 'application/pdf',
+ fileSize: 320_000,
+ generated: true,
+ })
+ setMessageSubject(`Passende Gewerbeflächen zu Ihrer Anfrage: ${needTitle}`)
+ setMessageDraft(
+ `Sehr geehrte Damen und Herren,\n\nbitte finden Sie anbei unser Angebot zu Ihrem Bedarf "${needTitle}". Gerne stehen wir für Rückfragen und Besichtigungstermine zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
+ )
+ setStep('send')
+ }
+
+ return (
+
+
+
+
+
+ Bereit zur Prüfung
+
+
+ Bestätigen Sie das geprüfte Angebot. Das PDF wird automatisch als Anhang für den Chat
+ vorbereitet, damit Sie es direkt versenden können.
+
+ :
+ }
+ onClick={handleConfirm}
+ disabled={markChecked.isPending}
+ sx={{
+ textTransform: 'none',
+ bgcolor: '#16a34a',
+ fontSize: '1rem',
+ fontWeight: 700,
+ px: 4,
+ py: 1.5,
+ '&:hover': { bgcolor: '#15803d' },
+ }}
+ >
+ ANGEBOT GEPRÜFT
+
+
+ )
+}
diff --git a/src/components/anfragencenter/OfferCreationPanel.tsx b/src/components/anfragencenter/OfferCreationPanel.tsx
new file mode 100644
index 0000000..6277340
--- /dev/null
+++ b/src/components/anfragencenter/OfferCreationPanel.tsx
@@ -0,0 +1,47 @@
+import { Box, Button, Typography } from '@mui/material'
+import { FileText } from 'lucide-react'
+import { useOfferWizardStore } from '../../stores/offerWizardStore'
+
+interface OfferCreationPanelProps {
+ selectedCount: number
+ needId: string
+ needTitle: string
+}
+
+export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCreationPanelProps) {
+ const open = useOfferWizardStore(s => s.open)
+ if (selectedCount === 0) return null
+
+ return (
+
+
+ {selectedCount} Objekt{selectedCount === 1 ? '' : 'e'} ausgewählt
+
+ }
+ onClick={() => open(needId, needTitle)}
+ sx={{
+ textTransform: 'none',
+ bgcolor: '#1e3a5f',
+ fontWeight: 600,
+ '&:hover': { bgcolor: '#16304d' },
+ }}
+ >
+ Angebot erstellen
+
+
+ )
+}
diff --git a/src/components/anfragencenter/OfferPdfReviewStep.tsx b/src/components/anfragencenter/OfferPdfReviewStep.tsx
new file mode 100644
index 0000000..1a45c84
--- /dev/null
+++ b/src/components/anfragencenter/OfferPdfReviewStep.tsx
@@ -0,0 +1,166 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Box, Button, CircularProgress, Typography } from '@mui/material'
+import { ArrowLeft, ArrowRight } from 'lucide-react'
+import { useOfferWizardStore } from '../../stores/offerWizardStore'
+import { offerService } from '../../services/offerService'
+import { useUpdateOfferField, useGeneratePdfPreview } from '../../hooks/useOffers'
+import { useToastStore } from '../../stores/toastStore'
+import { MockPdfPreview } from './MockPdfPreview'
+import { EditableOfferFieldList } from './EditableOfferFieldList'
+import type { OfferDraft, OfferEditableField } from '../../domain/offer'
+
+export function OfferPdfReviewStep() {
+ const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
+ const editableFieldsStore = useOfferWizardStore(s => s.editableFields)
+ const updateField = useOfferWizardStore(s => s.updateField)
+ const setStep = useOfferWizardStore(s => s.setStep)
+ const setPdfReady = useOfferWizardStore(s => s.setPdfReady)
+ const pdfReady = useOfferWizardStore(s => s.pdfPreviewReady)
+
+ const [draft, setDraft] = useState(null)
+ const [loadingDraft, setLoadingDraft] = useState(true)
+
+ const updateFieldMut = useUpdateOfferField()
+ const genPreview = useGeneratePdfPreview()
+ const showToast = useToastStore(s => s.showToast)
+
+ useEffect(() => {
+ let cancelled = false
+ async function load() {
+ if (!offerDraftId) return
+ setLoadingDraft(true)
+ const draftRes = await import('../../provider/MockupOfferProvider').then(m =>
+ m.MockupOfferProvider.getById(offerDraftId),
+ )
+ if (cancelled) return
+ setDraft(draftRes)
+ setLoadingDraft(false)
+
+ // Trigger PDF generation once
+ if (draftRes && !pdfReady) {
+ const res = await genPreview.mutateAsync(offerDraftId)
+ if (!cancelled && res.data) setPdfReady()
+ }
+ }
+ void load()
+ return () => {
+ cancelled = true
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [offerDraftId])
+
+ const fallback: Record = useMemo(() => {
+ const out: Record = {}
+ if (draft) {
+ for (const f of draft.editableFields) out[f.id] = f.value
+ }
+ return out
+ }, [draft])
+
+ const fields: OfferEditableField[] = draft?.editableFields ?? []
+
+ const handleFieldChange = async (id: string, value: string) => {
+ updateField(id, value)
+ if (offerDraftId) {
+ await updateFieldMut.mutateAsync({ offerDraftId, fieldId: id, value })
+ }
+ }
+
+ const handleNext = () => {
+ if (!offerDraftId) return
+ if (!pdfReady) {
+ showToast('PDF-Vorschau wird noch generiert...', 'info')
+ return
+ }
+ setStep('checked')
+ }
+
+ if (loadingDraft || !draft) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {!pdfReady ? (
+
+
+
+ PDF-Vorschau wird generiert...
+
+
+ ) : (
+
+ )}
+
+
+
+
+ Inhalte bearbeiten
+
+
+
+
+
+
+ }
+ onClick={() => setStep('select_properties')}
+ sx={{ textTransform: 'none' }}
+ >
+ Zurück
+
+ }
+ onClick={handleNext}
+ disabled={!pdfReady}
+ sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
+ >
+ Angebot prüfen
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/OfferPropertySelectionStep.tsx b/src/components/anfragencenter/OfferPropertySelectionStep.tsx
new file mode 100644
index 0000000..d59928f
--- /dev/null
+++ b/src/components/anfragencenter/OfferPropertySelectionStep.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useMemo } from 'react'
+import { Box, Button, CircularProgress, Typography } from '@mui/material'
+import { ArrowRight } from 'lucide-react'
+import { useProperties } from '../../hooks/useProperties'
+import { ResultType } from '../../domain/enums'
+import { useOfferWizardStore } from '../../stores/offerWizardStore'
+import { useLatentNeedById } from '../../hooks/useLatentNeeds'
+import { useCreateOfferDraft } from '../../hooks/useOffers'
+import { useToastStore } from '../../stores/toastStore'
+import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
+import { deterministicMatchScore, assetTypeLabel } from './latentNeedUtils'
+
+function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
+ if (propertyAssetType === needAssetType) {
+ const cityMatch = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
+ if (cityMatch) return 'Nutzungstyp und Standort passen sehr gut'
+ return 'Passender Nutzungstyp, alternative Lage'
+ }
+ return 'Alternatives Profil — Detailprüfung empfohlen'
+}
+
+export function OfferPropertySelectionStep() {
+ const needId = useOfferWizardStore(s => s.selectedNeedId)
+ const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
+ const toggle = useOfferWizardStore(s => s.toggleProperty)
+ const setSelected = useOfferWizardStore(s => s.setSelectedProperties)
+ const setOfferDraftId = useOfferWizardStore(s => s.setOfferDraftId)
+ const setStep = useOfferWizardStore(s => s.setStep)
+
+ const { data: need } = useLatentNeedById(needId)
+ const { data: properties = [], isLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
+
+ const createDraft = useCreateOfferDraft()
+ const showToast = useToastStore(s => s.showToast)
+
+ const scored = useMemo(() => {
+ if (!need) return []
+ return properties
+ .map(p => ({
+ property: p,
+ score: deterministicMatchScore(p.id, need.id),
+ reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
+ }))
+ .sort((a, b) => b.score - a.score)
+ }, [properties, need])
+
+ // Pre-select top 2 if nothing selected
+ useEffect(() => {
+ if (scored.length > 0 && selectedIds.length === 0) {
+ setSelected(scored.slice(0, 2).map(s => s.property.id))
+ }
+ }, [scored, selectedIds.length, setSelected])
+
+ const handleNext = async () => {
+ if (!need) return
+ if (selectedIds.length === 0) {
+ showToast('Bitte mindestens ein Objekt auswählen', 'warning')
+ return
+ }
+ const res = await createDraft.mutateAsync({
+ needId: need.id,
+ selectedPropertyIds: selectedIds,
+ needTitle: need.title,
+ location: need.desiredLocation,
+ assetType: need.assetType,
+ sizeRange: need.sizeRange,
+ })
+ if (res.error || !res.data) {
+ showToast(`Fehler: ${res.error}`, 'error')
+ return
+ }
+ setOfferDraftId(res.data.id)
+ setStep('pdf_review')
+ }
+
+ if (!need) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Need summary header */}
+
+
+ Bedarf
+
+
+ {need.title}
+
+
+ {assetTypeLabel(need.assetType)} · {need.desiredLocation} · {need.sizeRange.min}–{need.sizeRange.max} m²
+
+
+
+
+
+ Wählen Sie passende Objekte aus Ihrem Portfolio
+
+ {isLoading && }
+
+ {scored.map(({ property, score, reason }) => (
+ toggle(property.id)}
+ reason={reason}
+ />
+ ))}
+
+
+
+
+
+ {selectedIds.length} Objekt{selectedIds.length === 1 ? '' : 'e'} ausgewählt
+
+ : }
+ onClick={handleNext}
+ disabled={selectedIds.length === 0 || createDraft.isPending}
+ sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
+ >
+ Weiter
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/OfferWizard.tsx b/src/components/anfragencenter/OfferWizard.tsx
new file mode 100644
index 0000000..93abe37
--- /dev/null
+++ b/src/components/anfragencenter/OfferWizard.tsx
@@ -0,0 +1,114 @@
+import {
+ Box,
+ Dialog,
+ IconButton,
+ Step,
+ StepLabel,
+ Stepper,
+ Typography,
+ useMediaQuery,
+ useTheme,
+} from '@mui/material'
+import { X } from 'lucide-react'
+import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
+import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
+import { OfferPdfReviewStep } from './OfferPdfReviewStep'
+import { OfferCheckedAction } from './OfferCheckedAction'
+import { OfferChatComposer } from './OfferChatComposer'
+
+const STEPS: { key: OfferStep; label: string }[] = [
+ { key: 'select_properties', label: 'Objekte wählen' },
+ { key: 'pdf_review', label: 'PDF-Vorschau' },
+ { key: 'checked', label: 'Prüfen' },
+ { key: 'send', label: 'Senden' },
+]
+
+export function OfferWizard() {
+ const theme = useTheme()
+ const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
+
+ const isOpen = useOfferWizardStore(s => s.isOpen)
+ const close = useOfferWizardStore(s => s.close)
+ const currentStep = useOfferWizardStore(s => s.currentStep)
+ const needTitle = useOfferWizardStore(s => s.needTitle)
+
+ const activeIndex = STEPS.findIndex(s => s.key === currentStep)
+
+ return (
+
+ )
+}
diff --git a/src/components/anfragencenter/OwnPropertyMatchList.tsx b/src/components/anfragencenter/OwnPropertyMatchList.tsx
new file mode 100644
index 0000000..5beb133
--- /dev/null
+++ b/src/components/anfragencenter/OwnPropertyMatchList.tsx
@@ -0,0 +1,115 @@
+import { useMemo } from 'react'
+import { Box, CircularProgress, Typography } from '@mui/material'
+import { Target } from 'lucide-react'
+import { useProperties } from '../../hooks/useProperties'
+import { ResultType } from '../../domain/enums'
+import { useOfferWizardStore } from '../../stores/offerWizardStore'
+import { deterministicMatchScore } from './latentNeedUtils'
+import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
+import { OfferCreationPanel } from './OfferCreationPanel'
+import type { LatentNeed } from '../../domain/latentNeed'
+
+interface OwnPropertyMatchListProps {
+ need: LatentNeed
+}
+
+function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
+ if (propertyAssetType === needAssetType) {
+ const cityMatch = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
+ if (cityMatch) return 'Nutzungstyp und Standort passen sehr gut'
+ return 'Passender Nutzungstyp, alternative Lage'
+ }
+ return 'Alternatives Profil — Detailprüfung empfohlen'
+}
+
+export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
+ const { data: properties = [], isLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
+
+ const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
+ const toggle = useOfferWizardStore(s => s.toggleProperty)
+
+ const scored = useMemo(() => {
+ return properties
+ .map(p => ({
+ property: p,
+ score: deterministicMatchScore(p.id, need.id),
+ reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
+ }))
+ .sort((a, b) => b.score - a.score)
+ }, [properties, need])
+
+ return (
+
+
+
+
+ Eigene Objekte
+
+
+ {scored.length}
+
+
+
+
+ {isLoading && (
+
+
+
+ )}
+ {!isLoading && scored.length === 0 && (
+
+ Keine Portfolio-Objekte vorhanden
+
+ )}
+ {scored.map(({ property, score, reason }) => (
+ toggle(property.id)}
+ reason={reason}
+ />
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/PublicNeedCard.tsx b/src/components/anfragencenter/PublicNeedCard.tsx
new file mode 100644
index 0000000..8998a37
--- /dev/null
+++ b/src/components/anfragencenter/PublicNeedCard.tsx
@@ -0,0 +1,85 @@
+import { Box, Chip, Paper, Typography } from '@mui/material'
+import { MapPin, Ruler } from 'lucide-react'
+import type { LatentNeed } from '../../domain/latentNeed'
+import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
+
+interface PublicNeedCardProps {
+ need: LatentNeed
+ selected: boolean
+ onClick: () => void
+}
+
+export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
+ const statusCfg = latentStatusBadge(need.status)
+ return (
+
+
+
+ {need.title}
+
+
+
+
+ {need.tenantCompany && (
+
+ {need.tenantCompany}
+
+ )}
+
+
+
+
+
+
+ {need.desiredLocation}
+
+
+
+
+
+ {need.sizeRange.min}–{need.sizeRange.max} m²
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/PublicNeedDetail.tsx b/src/components/anfragencenter/PublicNeedDetail.tsx
new file mode 100644
index 0000000..e7a7219
--- /dev/null
+++ b/src/components/anfragencenter/PublicNeedDetail.tsx
@@ -0,0 +1,267 @@
+import { Alert, Box, Button, Chip, Typography } from '@mui/material'
+import { CheckCircle2, Calendar, MapPin, Wallet, Info, Ruler, Sparkles } from 'lucide-react'
+import type { LatentNeed } from '../../domain/latentNeed'
+import { useOfferWizardStore } from '../../stores/offerWizardStore'
+import { useSessionStore } from '../../stores/sessionStore'
+import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
+import { UserRole } from '../../domain/enums'
+
+interface PublicNeedDetailProps {
+ need: LatentNeed
+}
+
+export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
+ const openWizard = useOfferWizardStore(s => s.open)
+ const currentUser = useSessionStore(s => s.currentUser)
+ const statusCfg = latentStatusBadge(need.status)
+
+ const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+ {need.title}
+
+ {need.tenantCompany && (
+
+ {need.tenantCompany}
+
+ )}
+
+
+ {/* AI Summary */}
+ {need.aiSummary && (
+ }
+ severity="info"
+ sx={{
+ bgcolor: '#eff6ff',
+ borderRadius: 1.5,
+ border: '1px solid #bfdbfe',
+ '& .MuiAlert-icon': { color: '#1d4ed8' },
+ '& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
+ }}
+ >
+
+ KI-Analyse
+
+ {need.aiSummary}
+
+ )}
+
+ {/* Suchprofil grid */}
+
+
+ Suchprofil
+
+
+ } label="Standort" value={need.desiredLocation} />
+ }
+ label="Fläche"
+ value={`${need.sizeRange.min}–${need.sizeRange.max} m²`}
+ />
+ }
+ label="Budget"
+ value={
+ need.budgetRange
+ ? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}–${
+ need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
+ } /m²`
+ : 'Flexibel'
+ }
+ />
+ } label="Timing" value={need.timing} />
+
+
+
+ {/* Must-have Kriterien */}
+ {need.mustHaveCriteria.length > 0 && (
+
+
+ Must-have Kriterien
+
+
+ {need.mustHaveCriteria.map((c, idx) => (
+
+
+
+ {c}
+
+
+ ))}
+
+
+ )}
+
+ {/* Gewichtete Präferenzen */}
+ {need.weightedPreferences.length > 0 && (
+
+
+ Gewichtete Präferenzen
+
+
+ {need.weightedPreferences.map((p, idx) => {
+ const pct = Math.round(p.weight * 100)
+ return (
+
+
+
+ {p.criterion}
+
+
+ {pct}%
+
+
+
+
+
+ {p.description && (
+
+ {p.description}
+
+ )}
+
+ )
+ })}
+
+
+ )}
+
+ {disabled && need.status !== 'public' && (
+ } sx={{ fontSize: '0.8125rem' }}>
+ Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} — kein Angebot möglich.
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
+ return (
+
+
+ {icon}
+
+ {label}
+
+
+
+ {value}
+
+
+ )
+}
diff --git a/src/components/anfragencenter/PublicNeedList.tsx b/src/components/anfragencenter/PublicNeedList.tsx
new file mode 100644
index 0000000..0d1dfc7
--- /dev/null
+++ b/src/components/anfragencenter/PublicNeedList.tsx
@@ -0,0 +1,87 @@
+import { Box, CircularProgress, Typography } from '@mui/material'
+import { Sparkles } from 'lucide-react'
+import { usePublicNeeds } from '../../hooks/useLatentNeeds'
+import { PublicNeedCard } from './PublicNeedCard'
+import { EmptyState } from '../ui'
+
+interface PublicNeedListProps {
+ selectedNeedId: string | null
+ onSelect: (id: string) => void
+}
+
+export function PublicNeedList({ selectedNeedId, onSelect }: PublicNeedListProps) {
+ const { data: needs = [], isLoading, error } = usePublicNeeds()
+
+ return (
+
+
+
+
+ Latente Bedarfe
+
+
+ {needs.length}
+
+
+
+
+ {isLoading && (
+
+
+
+ )}
+ {error && (
+
+ Fehler beim Laden
+
+ )}
+ {!isLoading && needs.length === 0 && (
+
+ )}
+ {needs.map(n => (
+ onSelect(n.id)}
+ />
+ ))}
+
+
+ )
+}
diff --git a/src/components/anfragencenter/RelatedPropertyCardPanel.tsx b/src/components/anfragencenter/RelatedPropertyCardPanel.tsx
new file mode 100644
index 0000000..2ad6321
--- /dev/null
+++ b/src/components/anfragencenter/RelatedPropertyCardPanel.tsx
@@ -0,0 +1,111 @@
+import { Box, Button, CircularProgress, Typography } from '@mui/material'
+import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
+import { useNavigate } from 'react-router'
+import { usePropertyById } from '../../hooks/useProperties'
+
+interface RelatedPropertyCardPanelProps {
+ propertyId: string
+}
+
+export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPanelProps) {
+ const { data: property, isLoading } = usePropertyById(propertyId)
+ const navigate = useNavigate()
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!property) {
+ return (
+
+
+ Objekt nicht gefunden
+
+
+ )
+ }
+
+ const image = property.images?.[0]
+
+ return (
+
+
+ Bezogenes Objekt
+
+
+
+ {!image && }
+
+
+
+ {property.title}
+
+
+
+
+
+
+ {property.location.city}
+ {property.location.district ? `, ${property.location.district}` : ''}
+
+
+
+
+
+ {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²
+
+
+
+
+
+ Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
+
+
+
+
+ {property.description && (
+
+ {property.description}
+
+ )}
+
+ }
+ onClick={() => navigate('/supply/properties')}
+ sx={{ textTransform: 'none', mt: 'auto' }}
+ >
+ Objekt ansehen
+
+
+ )
+}
diff --git a/src/components/anfragencenter/SelectablePropertyMatchCard.tsx b/src/components/anfragencenter/SelectablePropertyMatchCard.tsx
new file mode 100644
index 0000000..20d02b0
--- /dev/null
+++ b/src/components/anfragencenter/SelectablePropertyMatchCard.tsx
@@ -0,0 +1,118 @@
+import { Box, Checkbox, Paper, Typography } from '@mui/material'
+import { Building2, MapPin } from 'lucide-react'
+import type { Property } from '../../domain/property'
+import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
+import { assetTypeLabel } from './latentNeedUtils'
+
+interface SelectablePropertyMatchCardProps {
+ property: Property
+ matchScore: number
+ selected: boolean
+ onToggle: () => void
+ reason: string
+}
+
+export function SelectablePropertyMatchCard({
+ property,
+ matchScore,
+ selected,
+ onToggle,
+ reason,
+}: SelectablePropertyMatchCardProps) {
+ const tier = getScoreTier(matchScore)
+ const theme = SCORE_THEME[tier]
+ const image = property.images?.[0]
+
+ return (
+
+ e.stopPropagation()}
+ size="small"
+ sx={{ p: 0.5, mt: -0.5, ml: -0.5 }}
+ />
+
+
+ {!image && }
+
+
+
+
+
+ {property.title}
+
+
+ {matchScore}%
+
+
+
+
+
+ {property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
+
+
+
+ {reason}
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/index.ts b/src/components/anfragencenter/index.ts
new file mode 100644
index 0000000..da22619
--- /dev/null
+++ b/src/components/anfragencenter/index.ts
@@ -0,0 +1,26 @@
+export { ActiveInquiriesTab } from './ActiveInquiriesTab'
+export { LatentInquiriesTab } from './LatentInquiriesTab'
+export { OfferWizard } from './OfferWizard'
+export { InquiryList } from './InquiryList'
+export { InquiryListRow } from './InquiryListRow'
+export { InquiryCard } from './InquiryCard'
+export { InquiryCardGrid } from './InquiryCardGrid'
+export { InquiryChat } from './InquiryChat'
+export { InquiryMessageBubble } from './InquiryMessageBubble'
+export { InquiryReplyComposer } from './InquiryReplyComposer'
+export { InquiryDetailPanel } from './InquiryDetailPanel'
+export { InquiryStatusBadge } from './InquiryStatusBadge'
+export { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
+export { PublicNeedList } from './PublicNeedList'
+export { PublicNeedCard } from './PublicNeedCard'
+export { PublicNeedDetail } from './PublicNeedDetail'
+export { OwnPropertyMatchList } from './OwnPropertyMatchList'
+export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
+export { OfferCreationPanel } from './OfferCreationPanel'
+export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
+export { OfferPdfReviewStep } from './OfferPdfReviewStep'
+export { OfferCheckedAction } from './OfferCheckedAction'
+export { OfferChatComposer } from './OfferChatComposer'
+export { AiOfferEmailButton } from './AiOfferEmailButton'
+export { EditableOfferFieldList } from './EditableOfferFieldList'
+export { MockPdfPreview } from './MockPdfPreview'
diff --git a/src/components/anfragencenter/inquiryUtils.ts b/src/components/anfragencenter/inquiryUtils.ts
new file mode 100644
index 0000000..32ace8d
--- /dev/null
+++ b/src/components/anfragencenter/inquiryUtils.ts
@@ -0,0 +1,23 @@
+import { mockProperties } from '../../mock-data/properties'
+
+const propertyMap: Record = Object.fromEntries(
+ mockProperties.map(p => [p.id, p.title]),
+)
+
+export function propertyLabelFromId(id: string): string {
+ return propertyMap[id] ?? id
+}
+
+export function formatInquiryDate(iso: string): string {
+ const d = new Date(iso)
+ const date = d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ const time = d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
+ return `${date} · ${time}`
+}
+
+export function formatFileSize(bytes?: number): string {
+ if (!bytes) return ''
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
diff --git a/src/components/anfragencenter/latentNeedUtils.ts b/src/components/anfragencenter/latentNeedUtils.ts
new file mode 100644
index 0000000..7cc1a6c
--- /dev/null
+++ b/src/components/anfragencenter/latentNeedUtils.ts
@@ -0,0 +1,35 @@
+import type { AssetType } from '../../domain/enums'
+
+export function assetTypeLabel(at: AssetType): string {
+ const map: Record = {
+ OFFICE: 'Büro',
+ RETAIL: 'Retail',
+ LOGISTICS: 'Logistik',
+ LIGHT_INDUSTRIAL: 'Gewerbe',
+ PRODUCTION: 'Produktion',
+ GASTRO: 'Gastro',
+ MIXED: 'Mischnutzung',
+ UNKNOWN: 'Unbekannt',
+ }
+ return map[at] ?? 'Fläche'
+}
+
+export function latentStatusBadge(status: 'public' | 'paused' | 'expired'): {
+ label: string
+ bg: string
+ fg: string
+} {
+ if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' }
+ if (status === 'paused') return { label: 'Pausiert', bg: '#fed7aa', fg: '#9a3412' }
+ return { label: 'Abgelaufen', bg: '#e2e8f0', fg: '#475569' }
+}
+
+// Deterministic pseudo-random match score from string IDs (range 60–96)
+export function deterministicMatchScore(propertyId: string, needId: string): number {
+ const seed = `${propertyId}::${needId}`
+ let h = 0
+ for (let i = 0; i < seed.length; i++) {
+ h = (h * 31 + seed.charCodeAt(i)) >>> 0
+ }
+ return 60 + (h % 37)
+}
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
index d27e3a6..739c875 100644
--- a/src/components/layout/AppShell.tsx
+++ b/src/components/layout/AppShell.tsx
@@ -32,6 +32,7 @@ import {
Radar,
ServerCog,
GitBranch,
+ MessageSquare,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { OrganizationContextBadge } from './OrganizationContextBadge'
@@ -76,7 +77,7 @@ const WORKSPACE_CONFIG: Record = {
navItems: [
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
- { path: '/supply/match-center', label: 'Eingehende Bedarfe', icon: Target },
+ { path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
{ path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
],
diff --git a/src/domain/inquiry.ts b/src/domain/inquiry.ts
new file mode 100644
index 0000000..21cb466
--- /dev/null
+++ b/src/domain/inquiry.ts
@@ -0,0 +1,38 @@
+export interface Attachment {
+ id: string
+ fileName: string
+ fileType: string
+ fileSize?: number
+ url?: string
+ generated?: boolean
+}
+
+export interface InquiryMessage {
+ id: string
+ inquiryId: string
+ senderType: 'tenant' | 'supply_user' | 'system' | 'ai'
+ senderName: string
+ subject?: string
+ body: string
+ attachments: Attachment[]
+ createdAt: string
+}
+
+export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived'
+
+export interface Inquiry {
+ id: string
+ organizationId: string
+ propertyId: string
+ needId?: string
+ tenantName: string
+ tenantCompany?: string
+ tenantEmail?: string
+ subject: string
+ message: string
+ status: InquiryStatus
+ matchScore?: number
+ createdAt: string
+ updatedAt: string
+ thread: InquiryMessage[]
+}
diff --git a/src/domain/latentNeed.ts b/src/domain/latentNeed.ts
new file mode 100644
index 0000000..9f84f33
--- /dev/null
+++ b/src/domain/latentNeed.ts
@@ -0,0 +1,19 @@
+import type { AssetType } from './enums'
+import type { WeightedPreference } from './need'
+
+export interface LatentNeed {
+ id: string
+ title: string
+ tenantCompany?: string
+ assetType: AssetType
+ desiredLocation: string
+ sizeRange: { min: number; max: number }
+ budgetRange?: { min?: number; max?: number }
+ timing: string
+ mustHaveCriteria: string[]
+ weightedPreferences: WeightedPreference[]
+ aiSummary?: string
+ publicVisibility: boolean
+ status: 'public' | 'paused' | 'expired'
+ createdAt: string
+}
diff --git a/src/domain/offer.ts b/src/domain/offer.ts
new file mode 100644
index 0000000..3b388d0
--- /dev/null
+++ b/src/domain/offer.ts
@@ -0,0 +1,21 @@
+export type OfferStatus = 'selecting_properties' | 'draft' | 'pdf_review' | 'checked' | 'sent'
+
+export interface OfferEditableField {
+ id: string
+ label: string
+ value: string
+ fieldType: 'text' | 'textarea'
+}
+
+export interface OfferDraft {
+ id: string
+ needId: string
+ selectedPropertyIds: string[]
+ subject: string
+ message: string
+ pdfUrl?: string
+ status: OfferStatus
+ editableFields: OfferEditableField[]
+ createdAt: string
+ updatedAt: string
+}
diff --git a/src/hooks/useInquiries.ts b/src/hooks/useInquiries.ts
new file mode 100644
index 0000000..dd5f6aa
--- /dev/null
+++ b/src/hooks/useInquiries.ts
@@ -0,0 +1,50 @@
+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'
+
+export function useActiveInquiries(filters?: InquiryFilters) {
+ return useQuery({
+ queryKey: ['inquiries', 'active', filters ?? {}],
+ queryFn: () => inquiryService.getActiveInquiries(filters),
+ select: (res) => res.data ?? [],
+ })
+}
+
+export function useInquiryById(id: string) {
+ return useQuery({
+ queryKey: ['inquiry', id],
+ queryFn: () => inquiryService.getInquiryById(id),
+ enabled: !!id,
+ select: (res) => res.data ?? null,
+ })
+}
+
+export function useSendInquiryReply() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({
+ inquiryId,
+ payload,
+ }: {
+ inquiryId: string
+ payload: { subject?: string; body: string; attachments?: Attachment[] }
+ }) => inquiryService.sendInquiryReply(inquiryId, payload),
+ onSuccess: (_data, { inquiryId }) => {
+ qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
+ qc.invalidateQueries({ queryKey: ['inquiries'] })
+ },
+ })
+}
+
+export function useUpdateInquiryStatus() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ id, status }: { id: string; status: InquiryStatus }) =>
+ inquiryService.updateInquiryStatus(id, status),
+ onSuccess: (_data, { id }) => {
+ qc.invalidateQueries({ queryKey: ['inquiry', id] })
+ qc.invalidateQueries({ queryKey: ['inquiries'] })
+ },
+ })
+}
diff --git a/src/hooks/useLatentNeeds.ts b/src/hooks/useLatentNeeds.ts
new file mode 100644
index 0000000..9f25a9f
--- /dev/null
+++ b/src/hooks/useLatentNeeds.ts
@@ -0,0 +1,20 @@
+import { useQuery } from '@tanstack/react-query'
+import { latentNeedService } from '../services/latentNeedService'
+import type { LatentNeedFilters } from '../provider/ILatentNeedProvider'
+
+export function usePublicNeeds(filters?: LatentNeedFilters) {
+ return useQuery({
+ queryKey: ['latent-needs', filters ?? {}],
+ queryFn: () => latentNeedService.getPublicNeeds(filters),
+ select: (res) => res.data ?? [],
+ })
+}
+
+export function useLatentNeedById(id: string | null) {
+ return useQuery({
+ queryKey: ['latent-need', id],
+ queryFn: () => latentNeedService.getNeedById(id!),
+ enabled: !!id,
+ select: (res) => res.data ?? null,
+ })
+}
diff --git a/src/hooks/useOffers.ts b/src/hooks/useOffers.ts
new file mode 100644
index 0000000..9aac4ec
--- /dev/null
+++ b/src/hooks/useOffers.ts
@@ -0,0 +1,49 @@
+import { useMutation } from '@tanstack/react-query'
+import { offerService } from '../services/offerService'
+import type { AssetType } from '../domain/enums'
+
+export function useCreateOfferDraft() {
+ return useMutation({
+ mutationFn: (input: {
+ needId: string
+ selectedPropertyIds: string[]
+ needTitle: string
+ location: string
+ assetType: AssetType
+ sizeRange: { min: number; max: number }
+ }) =>
+ offerService.createOfferDraft(
+ input.needId,
+ input.selectedPropertyIds,
+ input.needTitle,
+ input.location,
+ input.assetType,
+ input.sizeRange,
+ ),
+ })
+}
+
+export function useUpdateOfferField() {
+ return useMutation({
+ mutationFn: (input: { offerDraftId: string; fieldId: string; value: string }) =>
+ offerService.updateOfferField(input.offerDraftId, input.fieldId, input.value),
+ })
+}
+
+export function useMarkOfferChecked() {
+ return useMutation({
+ mutationFn: (offerDraftId: string) => offerService.markOfferChecked(offerDraftId),
+ })
+}
+
+export function useGeneratePdfPreview() {
+ return useMutation({
+ mutationFn: (offerDraftId: string) => offerService.generatePdfPreview(offerDraftId),
+ })
+}
+
+export function useSendOffer() {
+ return useMutation({
+ mutationFn: (offerDraftId: string) => offerService.sendOffer(offerDraftId),
+ })
+}
diff --git a/src/mock-data/inquiries.ts b/src/mock-data/inquiries.ts
new file mode 100644
index 0000000..7c94576
--- /dev/null
+++ b/src/mock-data/inquiries.ts
@@ -0,0 +1,331 @@
+import type { Inquiry } from '../domain/inquiry'
+
+export const mockInquiries: Inquiry[] = [
+ // 1 — NEW
+ {
+ id: 'inq-001',
+ organizationId: 'org-wincasa',
+ propertyId: 'prop-001',
+ tenantName: 'Sandra Meier',
+ tenantCompany: 'Innovatech AG',
+ tenantEmail: 'sandra.meier@innovatech.ch',
+ subject: 'Anfrage zu Bürofläche Zollstrasse 12',
+ 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',
+ matchScore: 91,
+ createdAt: '2026-05-17T08:32:00Z',
+ updatedAt: '2026-05-17T08:32:00Z',
+ thread: [
+ {
+ id: 'msg-001-1',
+ inquiryId: 'inq-001',
+ senderType: 'tenant',
+ senderName: 'Sandra Meier',
+ subject: 'Anfrage zu Bürofläche Zollstrasse 12',
+ body:
+ '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',
+ attachments: [],
+ createdAt: '2026-05-17T08:32:00Z',
+ },
+ ],
+ },
+
+ // 2 — NEW
+ {
+ 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?',
+ message:
+ 'Sehr geehrte Damen und Herren\n\nIst Ihre Logistikfläche in Winterthur noch verfügbar? Wir benötigen ab September ca. 3’000 m² mit Rampe und Hochregalmöglichkeit.\n\nMit freundlichen Grüssen\nMarkus Frei',
+ status: 'new',
+ matchScore: 85,
+ createdAt: '2026-05-17T11:14:00Z',
+ updatedAt: '2026-05-17T11:14:00Z',
+ thread: [
+ {
+ id: 'msg-002-1',
+ inquiryId: 'inq-002',
+ senderType: 'tenant',
+ senderName: 'Markus Frei',
+ subject: 'Lagerfläche Winterthur — Verfügbarkeit?',
+ body:
+ 'Sehr geehrte Damen und Herren\n\nIst Ihre Logistikfläche in Winterthur noch verfügbar? Wir benötigen ab September ca. 3’000 m² mit Rampe und Hochregalmöglichkeit.\n\nMit freundlichen Grüssen\nMarkus Frei',
+ attachments: [],
+ createdAt: '2026-05-17T11:14:00Z',
+ },
+ ],
+ },
+
+ // 3 — NEW
+ {
+ id: 'inq-003',
+ organizationId: 'org-wincasa',
+ propertyId: 'prop-010',
+ tenantName: 'Laura Bianchi',
+ tenantCompany: 'Bianchi Mode GmbH',
+ tenantEmail: 'laura@bianchi-mode.ch',
+ subject: 'Retailfläche Löwenplatz — Detailunterlagen',
+ 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',
+ matchScore: 88,
+ createdAt: '2026-05-16T15:50:00Z',
+ updatedAt: '2026-05-16T15:50:00Z',
+ thread: [
+ {
+ id: 'msg-003-1',
+ inquiryId: 'inq-003',
+ senderType: 'tenant',
+ senderName: 'Laura Bianchi',
+ subject: 'Retailfläche Löwenplatz — Detailunterlagen',
+ body:
+ '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',
+ attachments: [],
+ createdAt: '2026-05-16T15:50:00Z',
+ },
+ ],
+ },
+
+ // 4 — IN_PROGRESS
+ {
+ id: 'inq-004',
+ organizationId: 'org-wincasa',
+ propertyId: 'prop-012',
+ tenantName: 'Daniel Hofer',
+ tenantCompany: 'Hofer Treuhand AG',
+ tenantEmail: 'd.hofer@hofer-treuhand.ch',
+ subject: 'Bürofläche Zug — Konditionen',
+ 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',
+ matchScore: 93,
+ createdAt: '2026-05-14T09:20:00Z',
+ updatedAt: '2026-05-15T14:00:00Z',
+ thread: [
+ {
+ id: 'msg-004-1',
+ inquiryId: 'inq-004',
+ senderType: 'tenant',
+ senderName: 'Daniel Hofer',
+ subject: 'Bürofläche Zug — Konditionen',
+ body:
+ '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',
+ attachments: [],
+ createdAt: '2026-05-14T09:20:00Z',
+ },
+ {
+ id: 'msg-004-2',
+ inquiryId: 'inq-004',
+ senderType: 'supply_user',
+ senderName: 'Wincasa AG',
+ subject: 'Re: Bürofläche Zug — Konditionen',
+ body:
+ 'Guten Tag Herr Hofer\n\nVielen Dank für Ihr Interesse. Anbei finden Sie unser Exposé sowie die Konditionsübersicht. Gerne stehen wir für eine Besichtigung zur Verfügung — passt Ihnen Donnerstag oder Freitag dieser Woche?\n\nFreundliche Grüsse\nWincasa AG',
+ attachments: [
+ { id: 'att-004-1', fileName: 'Expose_Zug.pdf', fileType: 'application/pdf', fileSize: 1240000 },
+ ],
+ createdAt: '2026-05-15T14:00:00Z',
+ },
+ ],
+ },
+
+ // 5 — IN_PROGRESS
+ {
+ id: 'inq-005',
+ organizationId: 'org-wincasa',
+ propertyId: 'prop-007',
+ tenantName: 'Petra Wyss',
+ tenantCompany: 'NorthStar Consulting',
+ tenantEmail: 'petra.wyss@northstar.ch',
+ subject: 'Büro Oerlikon — Grundriss & Ausbaustand',
+ 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',
+ matchScore: 79,
+ createdAt: '2026-05-13T13:10:00Z',
+ updatedAt: '2026-05-14T16:20:00Z',
+ thread: [
+ {
+ id: 'msg-005-1',
+ inquiryId: 'inq-005',
+ senderType: 'tenant',
+ senderName: 'Petra Wyss',
+ subject: 'Büro Oerlikon — Grundriss & Ausbaustand',
+ body:
+ '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',
+ attachments: [],
+ createdAt: '2026-05-13T13:10:00Z',
+ },
+ {
+ id: 'msg-005-2',
+ inquiryId: 'inq-005',
+ senderType: 'supply_user',
+ senderName: 'Wincasa AG',
+ body:
+ 'Guten Tag Frau Wyss\n\nAnbei der angefragte Grundriss. Die Fläche wurde 2024 vollständig saniert (Ausbau Standard plus). Wir freuen uns auf Ihre Rückmeldung.\n\nWincasa AG',
+ attachments: [
+ { id: 'att-005-1', fileName: 'Grundriss_Thurgauerstr40.pdf', fileType: 'application/pdf', fileSize: 980000 },
+ ],
+ createdAt: '2026-05-14T11:45:00Z',
+ },
+ {
+ id: 'msg-005-3',
+ inquiryId: 'inq-005',
+ senderType: 'tenant',
+ senderName: 'Petra Wyss',
+ body:
+ 'Vielen Dank für die schnelle Rückmeldung. Wir würden gerne nächste Woche besichtigen — wie sieht es bei Ihnen aus?',
+ attachments: [],
+ createdAt: '2026-05-14T16:20:00Z',
+ },
+ ],
+ },
+
+ // 6 — ANSWERED
+ {
+ 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. 2’500 m². Ihre Liegenschaft an der Hardstrasse entspricht unserem Profil. Können wir besichtigen?\n\nThomas Brun',
+ status: 'answered',
+ matchScore: 87,
+ createdAt: '2026-05-08T10:00:00Z',
+ updatedAt: '2026-05-10T09:30:00Z',
+ thread: [
+ {
+ id: 'msg-006-1',
+ inquiryId: 'inq-006',
+ senderType: 'tenant',
+ senderName: 'Thomas Brun',
+ subject: 'Lagerfläche Hardstrasse Basel',
+ body:
+ 'Guten Tag\n\nWir suchen ab Juli eine Lagerfläche in Basel mit ca. 2’500 m². Ihre Liegenschaft an der Hardstrasse entspricht unserem Profil. Können wir besichtigen?\n\nThomas Brun',
+ attachments: [],
+ createdAt: '2026-05-08T10:00:00Z',
+ },
+ {
+ id: 'msg-006-2',
+ inquiryId: 'inq-006',
+ senderType: 'supply_user',
+ senderName: 'Wincasa AG',
+ body:
+ 'Sehr geehrter Herr Brun\n\nGerne. Wir haben einen Termin am 12. Mai um 10:00 Uhr vor Ort reserviert. Bitte bestätigen Sie kurz.\n\nWincasa AG',
+ attachments: [],
+ createdAt: '2026-05-09T08:15:00Z',
+ },
+ {
+ id: 'msg-006-3',
+ inquiryId: 'inq-006',
+ senderType: 'tenant',
+ senderName: 'Thomas Brun',
+ body:
+ 'Bestätigt — wir sind dabei. Vielen Dank!',
+ attachments: [],
+ createdAt: '2026-05-10T09:30:00Z',
+ },
+ ],
+ },
+
+ // 7 — ANSWERED
+ {
+ id: 'inq-007',
+ organizationId: 'org-wincasa',
+ propertyId: 'prop-008',
+ tenantName: 'Caroline Roth',
+ tenantCompany: 'Roth & Partner',
+ tenantEmail: 'c.roth@roth-partner.ch',
+ subject: 'Büro Dreispitz — Mietvertrag',
+ 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',
+ matchScore: 82,
+ createdAt: '2026-05-05T09:00:00Z',
+ updatedAt: '2026-05-06T11:00:00Z',
+ thread: [
+ {
+ id: 'msg-007-1',
+ inquiryId: 'inq-007',
+ senderType: 'tenant',
+ senderName: 'Caroline Roth',
+ subject: 'Büro Dreispitz — Mietvertrag',
+ body:
+ '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',
+ attachments: [],
+ createdAt: '2026-05-05T09:00:00Z',
+ },
+ {
+ id: 'msg-007-2',
+ inquiryId: 'inq-007',
+ senderType: 'supply_user',
+ senderName: 'Wincasa AG',
+ body:
+ 'Sehr geehrte Frau Roth\n\nVielen Dank für Ihre Anfrage. Anbei die Vertragsvorlage sowie die Anhangsdokumente. Wir freuen uns auf Ihre Rückmeldung.\n\nWincasa AG',
+ attachments: [
+ { id: 'att-007-1', fileName: 'Mietvertrag_Dreispitz.pdf', fileType: 'application/pdf', fileSize: 540000 },
+ { id: 'att-007-2', fileName: 'AGB.pdf', fileType: 'application/pdf', fileSize: 220000 },
+ ],
+ createdAt: '2026-05-06T11:00:00Z',
+ },
+ ],
+ },
+
+ // 8 — ARCHIVED
+ {
+ id: 'inq-008',
+ organizationId: 'org-wincasa',
+ propertyId: 'prop-011',
+ tenantName: 'Jonas Keller',
+ tenantCompany: 'Keller Maschinen AG',
+ tenantEmail: 'j.keller@keller-maschinen.ch',
+ subject: 'Produktionsfläche Bern',
+ 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',
+ matchScore: 64,
+ createdAt: '2026-04-20T14:30:00Z',
+ updatedAt: '2026-04-25T10:00:00Z',
+ thread: [
+ {
+ id: 'msg-008-1',
+ inquiryId: 'inq-008',
+ senderType: 'tenant',
+ senderName: 'Jonas Keller',
+ subject: 'Produktionsfläche Bern',
+ body:
+ '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',
+ attachments: [],
+ createdAt: '2026-04-20T14:30:00Z',
+ },
+ {
+ id: 'msg-008-2',
+ inquiryId: 'inq-008',
+ senderType: 'supply_user',
+ senderName: 'Wincasa AG',
+ body:
+ 'Sehr geehrter Herr Keller\n\nDie Hallenhöhe beträgt 5.2 m. Leider entspricht das nicht Ihren Anforderungen. Wir informieren Sie, sobald passendere Flächen verfügbar werden.\n\nWincasa AG',
+ attachments: [],
+ createdAt: '2026-04-22T09:00:00Z',
+ },
+ {
+ id: 'msg-008-3',
+ inquiryId: 'inq-008',
+ senderType: 'tenant',
+ senderName: 'Jonas Keller',
+ body:
+ 'Danke für die Information — wir suchen weiter und melden uns wieder.',
+ attachments: [],
+ createdAt: '2026-04-25T10:00:00Z',
+ },
+ ],
+ },
+]
diff --git a/src/mock-data/latentNeeds.ts b/src/mock-data/latentNeeds.ts
new file mode 100644
index 0000000..63ec9fb
--- /dev/null
+++ b/src/mock-data/latentNeeds.ts
@@ -0,0 +1,174 @@
+import { AssetType } from '../domain/enums'
+import type { LatentNeed } from '../domain/latentNeed'
+
+export const mockLatentNeeds: LatentNeed[] = [
+ {
+ id: 'lneed-001',
+ title: 'Modernes Büro für wachsendes Tech-Team',
+ tenantCompany: 'Helvetia Tech Labs AG',
+ assetType: AssetType.OFFICE,
+ desiredLocation: 'Zürich-West / Kreis 5',
+ sizeRange: { min: 700, max: 1100 },
+ budgetRange: { min: 32, max: 48 },
+ timing: 'Bezug ab Q4 2026, flexibel bis Q1 2027',
+ mustHaveCriteria: [
+ 'ÖV-Anbindung unter 5 Min zu Fuss',
+ 'Glasfaseranschluss',
+ 'Klimaanlage in allen Räumen',
+ 'Mindestens 2 Sitzungszimmer',
+ ],
+ weightedPreferences: [
+ { criterion: 'Standortprestige', weight: 0.25, description: 'Repräsentative Adresse für Kundenmeetings' },
+ { criterion: 'Flexibilität Grundriss', weight: 0.20, description: 'Open Space mit modularen Wänden' },
+ { criterion: 'ESG-Zertifizierung', weight: 0.15, description: 'Minergie oder vergleichbar' },
+ { criterion: 'Erweiterungspotenzial', weight: 0.15, description: 'Wachstum auf bis zu 1’500 m² möglich' },
+ { criterion: 'Nähe zu Restaurants', weight: 0.10 },
+ ],
+ aiSummary:
+ 'Schnell wachsendes Software-Unternehmen sucht eine repräsentative Bürofläche in Zürich-West. Hoher Wert auf Flexibilität, ESG und Skalierbarkeit. Hohe Abschlusswahrscheinlichkeit bei passendem Objekt.',
+ publicVisibility: true,
+ status: 'public',
+ createdAt: '2026-05-02T10:00:00Z',
+ },
+
+ {
+ id: 'lneed-002',
+ title: 'Logistik-Hub Mittelland',
+ tenantCompany: 'NordWest Distribution GmbH',
+ assetType: AssetType.LOGISTICS,
+ desiredLocation: 'Region Basel / Pratteln / Muttenz',
+ sizeRange: { min: 2500, max: 5000 },
+ budgetRange: { min: 12, max: 18 },
+ timing: 'Bezug ab Januar 2027',
+ mustHaveCriteria: [
+ 'Mindestens 2 Rampen',
+ 'Hallenhöhe ≥ 8 m',
+ 'Autobahnanschluss unter 5 Min',
+ 'LKW-Wendekreis 18 m',
+ ],
+ weightedPreferences: [
+ { criterion: 'Andienungslogistik', weight: 0.30, description: 'Effiziente Be- und Entladung' },
+ { criterion: '24/7-Betrieb möglich', weight: 0.25 },
+ { criterion: 'Kühlmöglichkeit', weight: 0.20, description: 'Teilflächen kühlbar' },
+ { criterion: 'Photovoltaik-Dach', weight: 0.15 },
+ { criterion: 'Erweiterungspotenzial', weight: 0.10 },
+ ],
+ aiSummary:
+ 'Distributor mit Fokus auf Frischwaren sucht modernen Logistik-Hub in der Region Basel. Anforderungen sind klar definiert, Budget realistisch. Sehr hohe Match-Wahrscheinlichkeit mit verfügbaren Liegenschaften.',
+ publicVisibility: true,
+ status: 'public',
+ createdAt: '2026-04-28T09:30:00Z',
+ },
+
+ {
+ id: 'lneed-003',
+ title: 'Retail-Flagship in Top-Innenstadtlage',
+ tenantCompany: 'Aurum Concept Stores AG',
+ assetType: AssetType.RETAIL,
+ desiredLocation: 'Zürich Bahnhofstrasse / Löwenplatz / Innenstadt',
+ sizeRange: { min: 200, max: 450 },
+ budgetRange: { min: 80, max: 180 },
+ timing: 'Eröffnung Frühjahr 2027, frühester Bezug Q4 2026',
+ mustHaveCriteria: [
+ 'Schaufenster mindestens 8 m breit',
+ 'Hochwertige Passantenfrequenz',
+ 'Stromanschluss ≥ 30 kW',
+ 'Klimaanlage und Lüftung',
+ ],
+ weightedPreferences: [
+ { criterion: 'Lagequalität', weight: 0.40, description: 'Top-Frequenzlage mit Markenumfeld' },
+ { criterion: 'Sichtbarkeit', weight: 0.25, description: 'Eckfläche oder freie Sichtachse' },
+ { criterion: 'Deckenhöhe', weight: 0.15, description: 'Mindestens 3.5 m' },
+ { criterion: 'Lagerfläche im UG', weight: 0.10 },
+ { criterion: 'Architektur / Charme', weight: 0.10 },
+ ],
+ aiSummary:
+ 'Premium-Lifestyle-Marke sucht Flagship-Store an Top-Adresse. Budget grosszügig, Anforderungen anspruchsvoll. Sehr starkes Signal für Premium-Retailobjekte in Zürich.',
+ publicVisibility: true,
+ status: 'public',
+ createdAt: '2026-05-08T13:45:00Z',
+ },
+
+ {
+ id: 'lneed-004',
+ title: 'Backoffice-Standort Kanton Zug',
+ tenantCompany: 'Lakeside Capital Partners',
+ assetType: AssetType.OFFICE,
+ desiredLocation: 'Zug / Baar / Cham',
+ sizeRange: { min: 400, max: 700 },
+ budgetRange: { min: 30, max: 42 },
+ timing: 'Bezug spätestens Q3 2026',
+ mustHaveCriteria: [
+ 'ÖV-Anbindung unter 10 Min',
+ 'Mindestens 4 Parkplätze',
+ 'Diskretes, repräsentatives Umfeld',
+ ],
+ weightedPreferences: [
+ { criterion: 'Standortprestige', weight: 0.30 },
+ { criterion: 'Sicherheit / Zutrittskonzept', weight: 0.25 },
+ { criterion: 'Steuerumfeld', weight: 0.20, description: 'Bevorzugt steuerattraktive Gemeinde' },
+ { criterion: 'Architektonische Qualität', weight: 0.15 },
+ { criterion: 'Erweiterungspotenzial', weight: 0.10 },
+ ],
+ aiSummary:
+ 'Vermögensverwalter sucht diskreten Backoffice-Standort im Kanton Zug. Klassisches Profil mit Fokus auf Diskretion und Steuerumfeld. Solides Match-Potenzial mit zentralen Zuger Liegenschaften.',
+ publicVisibility: true,
+ status: 'public',
+ createdAt: '2026-05-04T11:20:00Z',
+ },
+
+ {
+ id: 'lneed-005',
+ title: 'Stadtnahe Logistikfläche Raum Winterthur',
+ tenantCompany: 'PaketExpress Schweiz AG',
+ assetType: AssetType.LOGISTICS,
+ desiredLocation: 'Winterthur / Töss / Oberwinterthur',
+ sizeRange: { min: 1500, max: 3500 },
+ budgetRange: { min: 10, max: 16 },
+ timing: 'Bezug Q3 2026, hohe Dringlichkeit',
+ mustHaveCriteria: [
+ 'Mindestens 4 Rampen',
+ 'LKW-tauglicher Innenhof',
+ '24/7 Anlieferung erlaubt',
+ 'Strom für E-Flottenladung skalierbar',
+ ],
+ weightedPreferences: [
+ { criterion: 'Stadtnähe', weight: 0.30, description: 'Letzte Meile zur Innenstadt' },
+ { criterion: 'Anbindung an A1 / A7', weight: 0.25 },
+ { criterion: 'Lademöglichkeiten E-Flotte', weight: 0.20 },
+ { criterion: 'Flexibilität Vertrag', weight: 0.15 },
+ { criterion: 'Erweiterungspotenzial', weight: 0.10 },
+ ],
+ aiSummary:
+ 'KEP-Dienstleister mit hoher Dringlichkeit sucht stadtnahe Logistikbasis. Sehr starkes Profil für Last-Mile-Hub. Verfügbare Winterthurer Lagerflächen sind hochrelevant.',
+ publicVisibility: true,
+ status: 'public',
+ createdAt: '2026-05-09T08:15:00Z',
+ },
+
+ {
+ id: 'lneed-006',
+ title: 'Pop-up-Retailfläche temporär 6 Monate',
+ tenantCompany: 'Nordic Living Concept',
+ assetType: AssetType.RETAIL,
+ desiredLocation: 'Zürich / Basel — zentrale Lage',
+ sizeRange: { min: 80, max: 200 },
+ timing: 'Bezug August 2026 — Vertragsdauer 6 Monate',
+ mustHaveCriteria: [
+ 'Möblierbarkeit kurzfristig möglich',
+ 'Schaufenster zur Strasse',
+ 'Funktionierende Klimaanlage',
+ ],
+ weightedPreferences: [
+ { criterion: 'Lage / Frequenz', weight: 0.35 },
+ { criterion: 'Vertragsflexibilität', weight: 0.30, description: 'Kurzfristige Verträge möglich' },
+ { criterion: 'Renovationsstand', weight: 0.20, description: 'Sofort einzugsbereit' },
+ { criterion: 'Sichtbarkeit', weight: 0.15 },
+ ],
+ aiSummary:
+ 'Skandinavische Lifestyle-Marke sucht Pop-up-Fläche für 6 Monate. Niedrige Bindung, aber hohe Lageansprüche. Geeignet für Zwischenvermietung von Top-Retailflächen.',
+ publicVisibility: true,
+ status: 'paused',
+ createdAt: '2026-04-15T15:00:00Z',
+ },
+]
diff --git a/src/pages/supply/Anfragencenter.tsx b/src/pages/supply/Anfragencenter.tsx
new file mode 100644
index 0000000..a5d7ae0
--- /dev/null
+++ b/src/pages/supply/Anfragencenter.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react'
+import { Box, Tab, Tabs, Typography } from '@mui/material'
+import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
+
+export default function Anfragencenter() {
+ const [tab, setTab] = useState(0)
+ return (
+
+
+
+ Anfragencenter
+
+ setTab(v)}
+ sx={{
+ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.95rem' },
+ }}
+ >
+
+
+
+
+
+ {tab === 0 && }
+ {tab === 1 && }
+
+
+
+ )
+}
diff --git a/src/provider/IInquiryProvider.ts b/src/provider/IInquiryProvider.ts
new file mode 100644
index 0000000..212e467
--- /dev/null
+++ b/src/provider/IInquiryProvider.ts
@@ -0,0 +1,17 @@
+import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
+
+export interface InquiryFilters {
+ status?: InquiryStatus
+ propertyId?: string
+ organizationId?: string
+}
+
+export interface IInquiryProvider {
+ getAll(filters?: InquiryFilters): Promise
+ getById(id: string): Promise
+ updateStatus(id: string, status: InquiryStatus): Promise
+ addMessage(
+ inquiryId: string,
+ msg: Omit,
+ ): Promise
+}
diff --git a/src/provider/ILatentNeedProvider.ts b/src/provider/ILatentNeedProvider.ts
new file mode 100644
index 0000000..fd0c086
--- /dev/null
+++ b/src/provider/ILatentNeedProvider.ts
@@ -0,0 +1,12 @@
+import type { LatentNeed } from '../domain/latentNeed'
+import type { AssetType } from '../domain/enums'
+
+export interface LatentNeedFilters {
+ assetType?: AssetType
+ status?: LatentNeed['status']
+}
+
+export interface ILatentNeedProvider {
+ getAll(filters?: LatentNeedFilters): Promise
+ getById(id: string): Promise
+}
diff --git a/src/provider/IOfferProvider.ts b/src/provider/IOfferProvider.ts
new file mode 100644
index 0000000..8d59bef
--- /dev/null
+++ b/src/provider/IOfferProvider.ts
@@ -0,0 +1,17 @@
+import type { OfferDraft, OfferEditableField, OfferStatus } from '../domain/offer'
+
+export interface CreateOfferDraftInput {
+ needId: string
+ selectedPropertyIds: string[]
+ subject: string
+ message: string
+ editableFields: OfferEditableField[]
+}
+
+export interface IOfferProvider {
+ create(input: CreateOfferDraftInput): Promise
+ getById(id: string): Promise
+ updateField(id: string, fieldId: string, value: string): Promise
+ updateStatus(id: string, status: OfferStatus): Promise
+ setPdfUrl(id: string, pdfUrl: string): Promise
+}
diff --git a/src/provider/MockupInquiryProvider.ts b/src/provider/MockupInquiryProvider.ts
new file mode 100644
index 0000000..50a51b0
--- /dev/null
+++ b/src/provider/MockupInquiryProvider.ts
@@ -0,0 +1,43 @@
+import type { IInquiryProvider, InquiryFilters } from './IInquiryProvider'
+import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
+import { mockInquiries } from '../mock-data/inquiries'
+
+const store: Inquiry[] = mockInquiries.map(i => ({ ...i, thread: [...i.thread] }))
+
+export const MockupInquiryProvider: IInquiryProvider = {
+ async getAll(filters?: InquiryFilters): Promise {
+ let results = [...store]
+ if (filters?.status) results = results.filter(i => i.status === filters.status)
+ if (filters?.propertyId) results = results.filter(i => i.propertyId === filters.propertyId)
+ if (filters?.organizationId) results = results.filter(i => i.organizationId === filters.organizationId)
+ return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
+ },
+ async getById(id: string): Promise {
+ return store.find(i => i.id === id) ?? null
+ },
+ async updateStatus(id: string, status: InquiryStatus): Promise {
+ const idx = store.findIndex(i => i.id === id)
+ if (idx === -1) throw new Error(`Inquiry ${id} not found`)
+ store[idx] = { ...store[idx], status, updatedAt: new Date().toISOString() }
+ return store[idx]
+ },
+ async addMessage(
+ inquiryId: string,
+ msg: Omit,
+ ): Promise {
+ const idx = store.findIndex(i => i.id === inquiryId)
+ if (idx === -1) throw new Error(`Inquiry ${inquiryId} not found`)
+ const message: InquiryMessage = {
+ id: crypto.randomUUID(),
+ inquiryId,
+ createdAt: new Date().toISOString(),
+ ...msg,
+ }
+ store[idx] = {
+ ...store[idx],
+ thread: [...store[idx].thread, message],
+ updatedAt: message.createdAt,
+ }
+ return store[idx]
+ },
+}
diff --git a/src/provider/MockupLatentNeedProvider.ts b/src/provider/MockupLatentNeedProvider.ts
new file mode 100644
index 0000000..4cfad4b
--- /dev/null
+++ b/src/provider/MockupLatentNeedProvider.ts
@@ -0,0 +1,17 @@
+import type { ILatentNeedProvider, LatentNeedFilters } from './ILatentNeedProvider'
+import type { LatentNeed } from '../domain/latentNeed'
+import { mockLatentNeeds } from '../mock-data/latentNeeds'
+
+const store: LatentNeed[] = [...mockLatentNeeds]
+
+export const MockupLatentNeedProvider: ILatentNeedProvider = {
+ async getAll(filters?: LatentNeedFilters): Promise {
+ let results = [...store]
+ if (filters?.assetType) results = results.filter(n => n.assetType === filters.assetType)
+ if (filters?.status) results = results.filter(n => n.status === filters.status)
+ return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
+ },
+ async getById(id: string): Promise {
+ return store.find(n => n.id === id) ?? null
+ },
+}
diff --git a/src/provider/MockupOfferProvider.ts b/src/provider/MockupOfferProvider.ts
new file mode 100644
index 0000000..37f2551
--- /dev/null
+++ b/src/provider/MockupOfferProvider.ts
@@ -0,0 +1,45 @@
+import type { IOfferProvider, CreateOfferDraftInput } from './IOfferProvider'
+import type { OfferDraft, OfferStatus } from '../domain/offer'
+
+const store: OfferDraft[] = []
+
+export const MockupOfferProvider: IOfferProvider = {
+ async create(input: CreateOfferDraftInput): Promise {
+ const now = new Date().toISOString()
+ const draft: OfferDraft = {
+ id: crypto.randomUUID(),
+ needId: input.needId,
+ selectedPropertyIds: input.selectedPropertyIds,
+ subject: input.subject,
+ message: input.message,
+ status: 'draft',
+ editableFields: input.editableFields,
+ createdAt: now,
+ updatedAt: now,
+ }
+ store.push(draft)
+ return draft
+ },
+ async getById(id: string): Promise {
+ return store.find(d => d.id === id) ?? null
+ },
+ async updateField(id: string, fieldId: string, value: string): Promise {
+ const idx = store.findIndex(d => d.id === id)
+ if (idx === -1) throw new Error(`OfferDraft ${id} not found`)
+ const fields = store[idx].editableFields.map(f => (f.id === fieldId ? { ...f, value } : f))
+ store[idx] = { ...store[idx], editableFields: fields, updatedAt: new Date().toISOString() }
+ return store[idx]
+ },
+ async updateStatus(id: string, status: OfferStatus): Promise {
+ const idx = store.findIndex(d => d.id === id)
+ if (idx === -1) throw new Error(`OfferDraft ${id} not found`)
+ store[idx] = { ...store[idx], status, updatedAt: new Date().toISOString() }
+ return store[idx]
+ },
+ async setPdfUrl(id: string, pdfUrl: string): Promise {
+ const idx = store.findIndex(d => d.id === id)
+ if (idx === -1) throw new Error(`OfferDraft ${id} not found`)
+ store[idx] = { ...store[idx], pdfUrl, updatedAt: new Date().toISOString() }
+ return store[idx]
+ },
+}
diff --git a/src/services/aiService.ts b/src/services/aiService.ts
index 8fd04a5..139f94e 100644
--- a/src/services/aiService.ts
+++ b/src/services/aiService.ts
@@ -421,4 +421,22 @@ export const aiService = {
await new Promise(r => setTimeout(r, 1800))
return { data: buildMockDecisionBrief(shortlistId) }
},
+
+ async generateOfferEmail(payload: {
+ needTitle: string
+ properties: string[]
+ matchScores: number[]
+ }): Promise<{ data: { subject: string; body: string }; error: null }> {
+ await new Promise(r => setTimeout(r, 1200))
+ return {
+ data: {
+ subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`,
+ body:
+ `Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\n` +
+ payload.properties.map((p, i) => `• ${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') +
+ `\n\nGerne arrangieren wir Besichtigungstermine für die genannten Objekte und stehen für alle weiteren Fragen zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
+ },
+ error: null,
+ }
+ },
}
diff --git a/src/services/inquiryService.ts b/src/services/inquiryService.ts
new file mode 100644
index 0000000..3d1ea5e
--- /dev/null
+++ b/src/services/inquiryService.ts
@@ -0,0 +1,63 @@
+import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
+import type { InquiryFilters } from '../provider/IInquiryProvider'
+import type { Inquiry, InquiryStatus, Attachment } from '../domain/inquiry'
+
+const provider = MockupInquiryProvider
+
+export interface InquiryReplyPayload {
+ subject?: string
+ body: string
+ attachments?: Attachment[]
+}
+
+type ServiceResult = { data: T; error: null } | { data: null; error: string }
+
+export const inquiryService = {
+ async getActiveInquiries(filters?: InquiryFilters): Promise> {
+ try {
+ const data = await provider.getAll(filters)
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+
+ async getInquiryById(id: string): Promise> {
+ try {
+ const data = await provider.getById(id)
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+
+ async sendInquiryReply(
+ inquiryId: string,
+ payload: InquiryReplyPayload,
+ ): Promise> {
+ try {
+ const data = await provider.addMessage(inquiryId, {
+ senderType: 'supply_user',
+ senderName: 'Wincasa AG',
+ subject: payload.subject,
+ body: payload.body,
+ attachments: payload.attachments ?? [],
+ })
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+
+ async updateInquiryStatus(
+ id: string,
+ status: InquiryStatus,
+ ): Promise> {
+ try {
+ const data = await provider.updateStatus(id, status)
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+}
diff --git a/src/services/latentNeedService.ts b/src/services/latentNeedService.ts
new file mode 100644
index 0000000..039b160
--- /dev/null
+++ b/src/services/latentNeedService.ts
@@ -0,0 +1,27 @@
+import { MockupLatentNeedProvider } from '../provider/MockupLatentNeedProvider'
+import type { LatentNeedFilters } from '../provider/ILatentNeedProvider'
+import type { LatentNeed } from '../domain/latentNeed'
+
+const provider = MockupLatentNeedProvider
+
+type ServiceResult = { data: T; error: null } | { data: null; error: string }
+
+export const latentNeedService = {
+ async getPublicNeeds(filters?: LatentNeedFilters): Promise> {
+ try {
+ const data = await provider.getAll(filters)
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+
+ async getNeedById(id: string): Promise> {
+ try {
+ const data = await provider.getById(id)
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+}
diff --git a/src/services/offerService.ts b/src/services/offerService.ts
new file mode 100644
index 0000000..bf9343c
--- /dev/null
+++ b/src/services/offerService.ts
@@ -0,0 +1,135 @@
+import { MockupOfferProvider } from '../provider/MockupOfferProvider'
+import type { OfferDraft, OfferEditableField } from '../domain/offer'
+import type { AssetType } from '../domain/enums'
+
+const provider = MockupOfferProvider
+
+type ServiceResult = { data: T; error: null } | { data: null; error: string }
+
+function assetTypeLabel(at: AssetType): string {
+ const map: Record = {
+ OFFICE: 'Bürofläche',
+ RETAIL: 'Retailfläche',
+ LOGISTICS: 'Logistikfläche',
+ LIGHT_INDUSTRIAL: 'Gewerbefläche',
+ PRODUCTION: 'Produktionsfläche',
+ GASTRO: 'Gastronomiefläche',
+ MIXED: 'Mischnutzungsfläche',
+ UNKNOWN: 'Fläche',
+ }
+ return map[at] ?? 'Fläche'
+}
+
+function buildEditableFields(
+ needTitle: string,
+ location: string,
+ assetType: AssetType,
+ sizeRange: { min: number; max: number },
+): OfferEditableField[] {
+ return [
+ {
+ id: 'recipient_salutation',
+ label: 'Anrede',
+ value: 'Sehr geehrte Damen und Herren',
+ fieldType: 'text',
+ },
+ {
+ id: 'offer_intro',
+ label: 'Einleitungstext',
+ value:
+ `vielen Dank für Ihr Interesse an einer ${assetTypeLabel(assetType)} im Raum ${location}. ` +
+ `Basierend auf Ihrem Profil "${needTitle}" haben wir Ihnen passende Objekte zusammengestellt.`,
+ fieldType: 'textarea',
+ },
+ {
+ id: 'highlighted_criteria',
+ label: 'Hervorgehobene Kriterien',
+ value: `Flächenbedarf ${sizeRange.min}–${sizeRange.max} m², Standortwunsch ${location}.`,
+ fieldType: 'textarea',
+ },
+ {
+ id: 'next_steps',
+ label: 'Nächste Schritte',
+ value:
+ 'Gerne arrangieren wir Besichtigungstermine für die aufgeführten Objekte und stehen für Detailfragen zur Verfügung.',
+ fieldType: 'textarea',
+ },
+ {
+ id: 'closing',
+ label: 'Schlussformel',
+ value: 'Freundliche Grüsse\nWincasa AG',
+ fieldType: 'textarea',
+ },
+ ]
+}
+
+export const offerService = {
+ async createOfferDraft(
+ needId: string,
+ selectedPropertyIds: string[],
+ needTitle: string,
+ location: string,
+ assetType: AssetType,
+ sizeRange: { min: number; max: number },
+ ): Promise> {
+ try {
+ const editableFields = buildEditableFields(needTitle, location, assetType, sizeRange)
+ const data = await provider.create({
+ needId,
+ selectedPropertyIds,
+ subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${needTitle}`,
+ message:
+ 'Sehr geehrte Damen und Herren,\n\nbitte finden Sie anbei unser Angebot mit den passenden Objekten.\n\nFreundliche Grüsse\nWincasa AG',
+ editableFields,
+ })
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+
+ async updateOfferField(
+ offerDraftId: string,
+ fieldId: string,
+ value: string,
+ ): Promise> {
+ try {
+ const data = await provider.updateField(offerDraftId, fieldId, value)
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+
+ async markOfferChecked(offerDraftId: string): Promise> {
+ try {
+ const data = await provider.updateStatus(offerDraftId, 'checked')
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+
+ async generatePdfPreview(
+ offerDraftId: string,
+ ): Promise> {
+ try {
+ await new Promise(r => setTimeout(r, 900))
+ const pdfUrl = `mock://offers/${offerDraftId}.pdf`
+ await provider.setPdfUrl(offerDraftId, pdfUrl)
+ return { data: { previewReady: true, pdfUrl }, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+
+ async sendOffer(offerDraftId: string): Promise> {
+ try {
+ await new Promise(r => setTimeout(r, 700))
+ const data = await provider.updateStatus(offerDraftId, 'sent')
+ return { data, error: null }
+ } catch (e) {
+ return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
+ }
+ },
+}
diff --git a/src/stores/offerWizardStore.ts b/src/stores/offerWizardStore.ts
new file mode 100644
index 0000000..ec2437c
--- /dev/null
+++ b/src/stores/offerWizardStore.ts
@@ -0,0 +1,91 @@
+import { create } from 'zustand'
+import type { Attachment } from '../domain/inquiry'
+
+export type OfferStep = 'select_properties' | 'pdf_review' | 'checked' | 'send'
+
+interface OfferWizardState {
+ isOpen: boolean
+ selectedNeedId: string | null
+ needTitle: string
+ selectedPropertyIds: string[]
+ currentStep: OfferStep
+ offerDraftId: string | null
+ editableFields: Record
+ pdfPreviewReady: boolean
+ messageDraft: string
+ messageSubject: string
+ attachments: Attachment[]
+ open(needId: string, needTitle: string): void
+ close(): void
+ toggleProperty(id: string): void
+ setSelectedProperties(ids: string[]): void
+ setStep(step: OfferStep): void
+ setOfferDraftId(id: string): void
+ updateField(fieldId: string, value: string): void
+ setPdfReady(): void
+ setMessageDraft(text: string): void
+ setMessageSubject(s: string): void
+ addAttachment(a: Attachment): void
+ removeAttachment(id: string): void
+ reset(): void
+}
+
+export const useOfferWizardStore = create((set) => ({
+ isOpen: false,
+ selectedNeedId: null,
+ needTitle: '',
+ selectedPropertyIds: [],
+ currentStep: 'select_properties',
+ offerDraftId: null,
+ editableFields: {},
+ pdfPreviewReady: false,
+ messageDraft: '',
+ messageSubject: '',
+ attachments: [],
+ open: (needId, needTitle) =>
+ set({
+ isOpen: true,
+ selectedNeedId: needId,
+ needTitle,
+ selectedPropertyIds: [],
+ currentStep: 'select_properties',
+ offerDraftId: null,
+ editableFields: {},
+ pdfPreviewReady: false,
+ messageDraft: '',
+ messageSubject: '',
+ attachments: [],
+ }),
+ close: () => set({ isOpen: false }),
+ toggleProperty: (id) =>
+ set((s) => ({
+ selectedPropertyIds: s.selectedPropertyIds.includes(id)
+ ? s.selectedPropertyIds.filter((p) => p !== id)
+ : [...s.selectedPropertyIds, id],
+ })),
+ setSelectedProperties: (ids) => set({ selectedPropertyIds: ids }),
+ setStep: (currentStep) => set({ currentStep }),
+ setOfferDraftId: (id) => set({ offerDraftId: id }),
+ updateField: (fieldId, value) =>
+ set((s) => ({ editableFields: { ...s.editableFields, [fieldId]: value } })),
+ setPdfReady: () => set({ pdfPreviewReady: true }),
+ setMessageDraft: (messageDraft) => set({ messageDraft }),
+ setMessageSubject: (messageSubject) => set({ messageSubject }),
+ addAttachment: (a) => set((s) => ({ attachments: [...s.attachments, a] })),
+ removeAttachment: (id) =>
+ set((s) => ({ attachments: s.attachments.filter((a) => a.id !== id) })),
+ reset: () =>
+ set({
+ isOpen: false,
+ selectedNeedId: null,
+ needTitle: '',
+ selectedPropertyIds: [],
+ currentStep: 'select_properties',
+ offerDraftId: null,
+ editableFields: {},
+ pdfPreviewReady: false,
+ messageDraft: '',
+ messageSubject: '',
+ attachments: [],
+ }),
+}))