feat: F030 Anfragencenter — Aktive & Latente Anfragen + Angebot-Wizard

Neuer Menüpunkt «Anfragencenter» (ehemals «Eingehende Bedarfe»):

Aktive Anfragen:
- Split-View: Anfrageliste (Liste/Grid-Toggle) + Chat-Detailansicht
- Chat-Verlauf mit Sender-Styling (Mieter links, Supply rechts)
- Antwortformular mit Betreff, Nachricht, Anhänge, Statuswechsel
- Zugehörige Property Card neben dem Chat

Latente Anfragen:
- Drei-Spalten-Layout: Need-Liste | Need-Detail | Eigene Objekte
- Need Cards mit AI-Zusammenfassung, Must-Haves, Präferenzen
- Eigene Portfolio-Objekte sortiert nach deterministischem Match-Score
- Checkbox-Selektion für Angebotsauswahl

Angebot-Wizard (4 Schritte):
- Schritt 1: Objekte auswählen (mit Score-Vorschau)
- Schritt 2: PDF-Vorschau + editierbare Textfelder
- Schritt 3: Angebot prüfen & bestätigen
- Schritt 4: Nachricht an Suchenden mit KI-generierter Mail

Architektur:
- Domain: Inquiry, LatentNeed, OfferDraft Types
- Provider: IInquiryProvider, ILatentNeedProvider, IOfferProvider + Mockups
- Services: inquiryService, latentNeedService, offerService
- Hooks: useInquiries, useLatentNeeds, useOffers (TanStack Query)
- Store: offerWizardStore (Zustand, lokaler Wizard-State)
- 27 neue Komponenten, alle Loading/Empty/Error States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-18 18:03:05 +02:00
parent 05ac0083b2
commit c4c29ef7d3
51 changed files with 4000 additions and 1 deletions
@@ -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<string | null>(null)
const [view, setView] = useState<ViewMode>(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 (
<EmptyState
icon={<Inbox size={40} />}
title="Keine aktiven Anfragen"
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
/>
)
}
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{/* Left column: list/grid */}
<Box
sx={{
width: view === 'list' ? 380 : 720,
minWidth: view === 'list' ? 380 : 480,
flexShrink: 0,
borderRight: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.25,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Anfragen
</Typography>
<Box
sx={{
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#1e3a5f',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
{inquiries.length}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Listenansicht">
<IconButton
size="small"
onClick={() => setView('list')}
sx={{
bgcolor: view === 'list' ? '#e0e7ff' : 'transparent',
color: view === 'list' ? '#1e3a5f' : '#64748b',
'&:hover': { bgcolor: view === 'list' ? '#c7d2fe' : '#f1f5f9' },
}}
>
<ListIcon size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Kartenansicht">
<IconButton
size="small"
onClick={() => setView('grid')}
sx={{
bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent',
color: view === 'grid' ? '#1e3a5f' : '#64748b',
'&:hover': { bgcolor: view === 'grid' ? '#c7d2fe' : '#f1f5f9' },
}}
>
<LayoutGrid size={16} />
</IconButton>
</Tooltip>
</Box>
</Box>
{view === 'list' ? (
<InquiryList
inquiries={inquiries}
selectedId={selectedId}
onSelect={setSelectedId}
/>
) : (
<InquiryCardGrid
inquiries={inquiries}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
</Box>
{/* Right column: detail */}
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: '#f8fafc' }}>
{selectedId ? (
<InquiryDetailPanel inquiryId={selectedId} />
) : (
<EmptyState
icon={<Inbox size={40} />}
title="Anfrage auswählen"
description="Wählen Sie eine Anfrage aus der Liste, um Details anzuzeigen und zu antworten."
/>
)}
</Box>
</Box>
)
}
@@ -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<string | null>(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 (
<Box>
<Button
size="small"
startIcon={loading ? <CircularProgress size={14} /> : <Sparkles size={14} />}
onClick={handleClick}
disabled={loading}
sx={{
textTransform: 'none',
color: '#7c3aed',
borderColor: '#c4b5fd',
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#7c3aed' },
}}
variant="outlined"
>
KI-Mail generieren
</Button>
{error && (
<Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
)
}
@@ -0,0 +1,47 @@
import { Box, TextField, Typography } from '@mui/material'
import type { OfferEditableField } from '../../domain/offer'
interface EditableOfferFieldListProps {
fields: OfferEditableField[]
values: Record<string, string>
onChange: (id: string, value: string) => void
}
export function EditableOfferFieldList({ fields, values, onChange }: EditableOfferFieldListProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{fields.map(f => {
const value = values[f.id] !== undefined ? values[f.id] : f.value
return (
<Box key={f.id}>
<Typography
variant="caption"
sx={{
color: '#64748b',
fontWeight: 600,
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: 0.5,
mb: 0.5,
display: 'block',
}}
>
{f.label}
</Typography>
<TextField
fullWidth
size="small"
value={value}
onChange={e => onChange(f.id, e.target.value)}
multiline={f.fieldType === 'textarea'}
rows={f.fieldType === 'textarea' ? 3 : undefined}
sx={{
'& .MuiInputBase-input': { fontSize: '0.85rem' },
}}
/>
</Box>
)
})}
</Box>
)
}
@@ -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 (
<Paper
onClick={onClick}
elevation={0}
sx={{
p: 2,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
},
display: 'flex',
flexDirection: 'column',
gap: 0.75,
minHeight: 160,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<InquiryStatusBadge status={inquiry.status} />
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: '#1e293b',
fontSize: '0.85rem',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{inquiry.subject}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b' }}>
<Building2 size={13} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{propertyLabelFromId(inquiry.propertyId)}
</Typography>
</Box>
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
{formatInquiryDate(inquiry.createdAt)}
</Typography>
{inquiry.matchScore !== undefined && (
<Box
sx={{
px: 1,
py: 0.25,
borderRadius: 1,
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
Match {inquiry.matchScore}%
</Box>
)}
</Box>
</Paper>
)
}
@@ -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 (
<Box
sx={{
overflowY: 'auto',
flex: 1,
p: 1.5,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 1.5,
alignContent: 'flex-start',
}}
>
{inquiries.map(inq => (
<InquiryCard
key={inq.id}
inquiry={inq}
selected={inq.id === selectedId}
onClick={() => onSelect(inq.id)}
/>
))}
</Box>
)
}
@@ -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<HTMLDivElement | null>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [inquiry.thread.length])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, overflow: 'hidden' }}>
<Box sx={{ overflowY: 'auto', flex: 1, p: 2, bgcolor: 'white' }}>
{inquiry.thread.map(m => (
<InquiryMessageBubble key={m.id} message={m} />
))}
<div ref={endRef} />
</Box>
<InquiryReplyComposer inquiryId={inquiry.id} defaultSubject={inquiry.subject} />
</Box>
)
}
@@ -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 (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress size={24} />
</Box>
)
}
if (!inquiry) {
return (
<Box sx={{ p: 3 }}>
<Typography variant="body2" color="text.secondary">
Anfrage nicht gefunden
</Typography>
</Box>
)
}
const handleStatusChange = async (e: SelectChangeEvent<InquiryStatus>) => {
const result = await updateStatus.mutateAsync({
id: inquiry.id,
status: e.target.value as InquiryStatus,
})
if (result.error) {
showToast(`Fehler: ${result.error}`, 'error')
} else {
showToast('Status aktualisiert', 'success')
}
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box
sx={{
px: 2.5,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
alignItems: 'center',
gap: 2,
flexShrink: 0,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 600,
color: '#0f172a',
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inquiry.subject}
</Typography>
</Box>
<InquiryStatusBadge status={inquiry.status} size="medium" />
<Select
size="small"
value={inquiry.status}
onChange={handleStatusChange}
disabled={updateStatus.isPending}
sx={{ minWidth: 180, fontSize: '0.8125rem' }}
>
{STATUS_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
{o.label}
</MenuItem>
))}
</Select>
</Box>
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<InquiryChat inquiry={inquiry} />
<Box sx={{ width: 300, flexShrink: 0 }}>
<RelatedPropertyCardPanel propertyId={inquiry.propertyId} />
</Box>
</Box>
</Box>
)
}
@@ -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 (
<Box sx={{ overflowY: 'auto', flex: 1 }}>
{inquiries.map(inq => (
<InquiryListRow
key={inq.id}
inquiry={inq}
selected={inq.id === selectedId}
onClick={() => onSelect(inq.id)}
/>
))}
</Box>
)
}
@@ -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 (
<Box
onClick={onClick}
sx={{
px: 2,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid',
borderLeftColor: selected ? '#1e3a5f' : 'transparent',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'background-color 0.15s, border-color 0.15s',
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem' }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<InquiryStatusBadge status={inquiry.status} />
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: '#1e293b',
fontSize: '0.8125rem',
mb: 0.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inquiry.subject}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b', fontSize: '0.75rem' }}>
<Building2 size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{propertyLabelFromId(inquiry.propertyId)}
</Typography>
{inquiry.matchScore !== undefined && (
<Box
sx={{
ml: 'auto',
px: 0.75,
py: 0.125,
borderRadius: 1,
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
{inquiry.matchScore}%
</Box>
)}
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
{formatInquiryDate(inquiry.createdAt)}
</Typography>
</Box>
)
}
@@ -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 (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}>
<Typography
variant="caption"
sx={{
color: '#64748b',
fontStyle: 'italic',
bgcolor: '#f1f5f9',
px: 2,
py: 0.5,
borderRadius: 4,
fontSize: '0.75rem',
}}
>
{message.senderName}: {message.body}
</Typography>
</Box>
)
}
return (
<Box sx={{ display: 'flex', justifyContent: isTenant ? 'flex-start' : 'flex-end', mb: 1.5 }}>
<Box
sx={{
maxWidth: '75%',
bgcolor: isTenant ? '#f1f5f9' : '#1e3a5f',
color: isTenant ? '#0f172a' : 'white',
px: 1.75,
py: 1.25,
borderRadius: 2,
boxShadow: '0 1px 2px rgba(15,23,42,0.06)',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
<Typography
variant="caption"
sx={{
fontWeight: 600,
fontSize: '0.75rem',
color: isTenant ? '#0f172a' : 'white',
}}
>
{message.senderName}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.7rem',
color: isTenant ? '#64748b' : 'rgba(255,255,255,0.7)',
}}
>
{formatInquiryDate(message.createdAt)}
</Typography>
</Box>
{message.subject && (
<Typography
variant="caption"
sx={{
display: 'block',
fontWeight: 600,
fontSize: '0.75rem',
color: isTenant ? '#334155' : 'rgba(255,255,255,0.85)',
mb: 0.5,
}}
>
{message.subject}
</Typography>
)}
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
fontSize: '0.85rem',
lineHeight: 1.5,
color: isTenant ? '#0f172a' : 'white',
}}
>
{message.body}
</Typography>
{message.attachments.length > 0 && (
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{message.attachments.map(att => (
<Box
key={att.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
bgcolor: isTenant ? 'white' : 'rgba(255,255,255,0.12)',
border: '1px solid',
borderColor: isTenant ? '#cbd5e1' : 'rgba(255,255,255,0.25)',
px: 1,
py: 0.5,
borderRadius: 1,
fontSize: '0.75rem',
}}
>
<Paperclip size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem', fontWeight: 500 }}>
{att.fileName}
</Typography>
{att.fileSize && (
<Typography
variant="caption"
sx={{
fontSize: '0.7rem',
color: isTenant ? '#64748b' : 'rgba(255,255,255,0.65)',
ml: 'auto',
}}
>
{formatFileSize(att.fileSize)}
</Typography>
)}
</Box>
))}
</Box>
)}
{isSupply && (
<Typography
variant="caption"
sx={{
display: 'block',
fontSize: '0.65rem',
color: 'rgba(255,255,255,0.6)',
mt: 0.75,
textAlign: 'right',
}}
>
Wincasa AG
</Typography>
)}
</Box>
</Box>
)
}
@@ -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<Attachment[]>([])
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 (
<Box
sx={{
borderTop: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 1.25,
}}
>
<TextField
size="small"
label="Betreff"
value={subject}
onChange={e => setSubject(e.target.value)}
fullWidth
/>
<TextField
size="small"
label="Nachricht"
value={body}
onChange={e => setBody(e.target.value)}
multiline
rows={4}
placeholder="Antwort verfassen..."
fullWidth
/>
{attachments.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{attachments.map(a => (
<Box
key={a.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
bgcolor: 'white',
border: '1px solid #cbd5e1',
borderRadius: 1,
px: 1,
py: 0.5,
fontSize: '0.75rem',
}}
>
<Paperclip size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{a.fileName}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
{formatFileSize(a.fileSize)}
</Typography>
<IconButton size="small" onClick={() => handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
<X size={12} />
</IconButton>
</Box>
))}
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
startIcon={<Paperclip size={14} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none' }}
>
Anhang
</Button>
<FormControlLabel
control={
<Checkbox
size="small"
checked={markAnswered}
onChange={e => setMarkAnswered(e.target.checked)}
/>
}
label={
<Typography variant="caption" sx={{ fontSize: '0.8rem' }}>
Status auf "Beantwortet" setzen
</Typography>
}
/>
</Box>
<Button
variant="contained"
size="small"
startIcon={
sending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />
}
onClick={handleSend}
disabled={sending || !body.trim()}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Antwort senden
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,31 @@
import { Chip } from '@mui/material'
import type { InquiryStatus } from '../../domain/inquiry'
const STATUS_MAP: Record<InquiryStatus, { label: string; bg: string }> = {
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 (
<Chip
label={cfg.label}
size={size}
sx={{
bgcolor: cfg.bg,
color: '#ffffff',
fontWeight: 600,
fontSize: '0.7rem',
height: size === 'small' ? 22 : 26,
}}
/>
)
}
@@ -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<string | null>(null)
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
useEffect(() => {
if (!selectedNeedId && needs.length > 0) {
setSelectedNeedId(needs[0].id)
}
}, [needs, selectedNeedId])
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={setSelectedNeedId} />
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{selectedNeed ? (
<PublicNeedDetail need={selectedNeed} />
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<EmptyState
icon={<Sparkles size={40} />}
title="Bedarf auswählen"
description="Wählen Sie einen latenten Bedarf, um Details und passende Objekte zu sehen."
/>
</Box>
)}
</Box>
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
</Box>
)
}
@@ -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<string, string>
fallbackFields: Record<string, string>
}
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 (
<Box
sx={{
bgcolor: 'white',
borderRadius: 1.5,
border: '1px solid #cbd5e1',
boxShadow: '0 4px 16px rgba(15,23,42,0.08)',
p: 4,
height: '100%',
overflowY: 'auto',
fontFamily: '"Georgia", "Times New Roman", serif',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
<FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
Angebotsvorschau (PDF)
</Typography>
</Box>
<Typography variant="caption" sx={{ display: 'block', color: '#94a3b8', textAlign: 'right' }}>
Wincasa AG · Zürich
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#94a3b8', textAlign: 'right', mb: 4 }}>
{new Date().toLocaleDateString('de-CH')}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', mb: 2, fontSize: '1.05rem' }}>
Angebot: {needTitle}
</Typography>
<Typography variant="body2" sx={{ mb: 1.5, fontSize: '0.875rem' }}>
{v('recipient_salutation')},
</Typography>
<Typography variant="body2" sx={{ mb: 2, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{v('offer_intro')}
</Typography>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700, fontSize: '0.875rem' }}>
Hervorgehobene Kriterien
</Typography>
<Typography variant="body2" sx={{ mb: 2.5, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{v('highlighted_criteria')}
</Typography>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700, fontSize: '0.875rem' }}>
Vorgeschlagene Objekte
</Typography>
<Box component="ul" sx={{ pl: 2, mb: 2.5 }}>
{properties.map(p => (
<Typography
key={p.id}
component="li"
variant="body2"
sx={{ fontSize: '0.875rem', mb: 0.5, lineHeight: 1.5 }}
>
<strong>{p.title}</strong> {p.location.city}, {p.areaSqm.toLocaleString('de-CH')} m², CHF {p.rentPricePerSqm}/m²
</Typography>
))}
{properties.length === 0 && (
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic' }}>
Keine Objekte ausgewählt.
</Typography>
)}
</Box>
<Typography variant="body2" sx={{ mb: 2.5, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{v('next_steps')}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{v('closing')}
</Typography>
</Box>
)
}
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, bgcolor: '#f8fafc' }}>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Empfänger-Kontext
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#1e293b', mt: 0.5 }}>
Bedarf: <strong>{needTitle}</strong> · {selectedPropertyIds.length} Objekt
{selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot
</Typography>
</Box>
<TextField
size="small"
label="Betreff"
fullWidth
value={subject}
onChange={e => setSubject(e.target.value)}
/>
<TextField
size="small"
label="Nachricht"
fullWidth
value={body}
onChange={e => setBody(e.target.value)}
multiline
rows={8}
/>
{attachments.length > 0 && (
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, mb: 0.75, display: 'block' }}>
Anhänge
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{attachments.map(a => (
<Box
key={a.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
bgcolor: 'white',
border: '1px solid #cbd5e1',
borderRadius: 1,
px: 1,
py: 0.75,
}}
>
<FileText size={14} color="#1e3a5f" />
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
{a.fileName}
</Typography>
{a.generated && (
<Box
sx={{
px: 0.75,
py: 0.125,
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 700,
fontSize: '0.65rem',
borderRadius: 1,
}}
>
PDF
</Box>
)}
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: '#64748b', ml: 'auto' }}>
{formatFileSize(a.fileSize)}
</Typography>
<IconButton size="small" onClick={() => removeAttachment(a.id)} sx={{ p: 0.25 }}>
<X size={12} />
</IconButton>
</Box>
))}
</Box>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button
size="small"
startIcon={<Paperclip size={14} />}
onClick={handleAddAttachment}
sx={{ textTransform: 'none' }}
>
Anhang
</Button>
<AiOfferEmailButton
needTitle={needTitle}
selectedProperties={propertyTitles}
matchScores={scores}
onGenerated={handleAiGenerated}
/>
</Box>
</Box>
</Box>
<Box
sx={{
p: 2,
borderTop: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
}}
>
<Button startIcon={<ArrowLeft size={14} />} onClick={() => setStep('checked')} sx={{ textTransform: 'none' }}>
Zurück
</Button>
<Button
variant="contained"
startIcon={sendOffer.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleSend}
disabled={sendOffer.isPending || !body.trim()}
sx={{
textTransform: 'none',
bgcolor: '#16a34a',
fontWeight: 600,
'&:hover': { bgcolor: '#15803d' },
}}
>
Absenden
</Button>
</Box>
</Box>
)
}
@@ -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 (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#f8fafc',
p: 4,
textAlign: 'center',
}}
>
<Box
sx={{
width: 96,
height: 96,
borderRadius: '50%',
bgcolor: '#dcfce7',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 3,
}}
>
<CheckCircle2 size={48} color="#16a34a" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Bereit zur Prüfung
</Typography>
<Typography variant="body2" sx={{ color: '#475569', maxWidth: 480, mb: 4 }}>
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.
</Typography>
<Button
variant="contained"
size="large"
startIcon={
markChecked.isPending ? <CircularProgress size={18} sx={{ color: 'white' }} /> : <CheckCircle2 size={18} />
}
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
</Button>
</Box>
)
}
@@ -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 (
<Box
sx={{
borderTop: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
p: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 1,
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a' }}>
{selectedCount} Objekt{selectedCount === 1 ? '' : 'e'} ausgewählt
</Typography>
<Button
fullWidth
variant="contained"
size="small"
startIcon={<FileText size={14} />}
onClick={() => open(needId, needTitle)}
sx={{
textTransform: 'none',
bgcolor: '#1e3a5f',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
>
Angebot erstellen
</Button>
</Box>
)
}
@@ -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<OfferDraft | null>(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<string, string> = useMemo(() => {
const out: Record<string, string> = {}
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 (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden', bgcolor: '#f1f5f9' }}>
<Box sx={{ flex: 1.4, p: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{!pdfReady ? (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
bgcolor: 'white',
borderRadius: 1.5,
border: '1px dashed #cbd5e1',
}}
>
<CircularProgress size={24} />
<Typography variant="body2" color="text.secondary">
PDF-Vorschau wird generiert...
</Typography>
</Box>
) : (
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
)}
</Box>
<Box
sx={{
width: 360,
minWidth: 360,
flexShrink: 0,
p: 2,
bgcolor: 'white',
borderLeft: '1px solid #e2e8f0',
overflowY: 'auto',
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1.5, fontSize: '0.9rem' }}>
Inhalte bearbeiten
</Typography>
<EditableOfferFieldList
fields={fields}
values={editableFieldsStore}
onChange={handleFieldChange}
/>
</Box>
</Box>
<Box
sx={{
p: 2,
borderTop: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
}}
>
<Button
startIcon={<ArrowLeft size={14} />}
onClick={() => setStep('select_properties')}
sx={{ textTransform: 'none' }}
>
Zurück
</Button>
<Button
variant="contained"
endIcon={<ArrowRight size={14} />}
onClick={handleNext}
disabled={!pdfReady}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Angebot prüfen
</Button>
</Box>
</Box>
)
}
@@ -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 (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Need summary header */}
<Box
sx={{
p: 2.5,
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Bedarf
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.1rem', mt: 0.25 }}>
{need.title}
</Typography>
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.8rem', mt: 0.5, display: 'block' }}>
{assetTypeLabel(need.assetType)} · {need.desiredLocation} · {need.sizeRange.min}{need.sizeRange.max} m²
</Typography>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1.5 }}>
Wählen Sie passende Objekte aus Ihrem Portfolio
</Typography>
{isLoading && <CircularProgress size={20} />}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{scored.map(({ property, score, reason }) => (
<SelectablePropertyMatchCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggle(property.id)}
reason={reason}
/>
))}
</Box>
</Box>
<Box
sx={{
p: 2,
borderTop: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: '#64748b' }}>
{selectedIds.length} Objekt{selectedIds.length === 1 ? '' : 'e'} ausgewählt
</Typography>
<Button
variant="contained"
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
onClick={handleNext}
disabled={selectedIds.length === 0 || createDraft.isPending}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
</Box>
</Box>
)
}
@@ -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 (
<Dialog
open={isOpen}
onClose={close}
fullScreen={fullScreen}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
height: fullScreen ? '100vh' : '85vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
}}
>
{/* Header */}
<Box
sx={{
px: 3,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', textTransform: 'uppercase', fontWeight: 600, letterSpacing: 0.5 }}>
Angebot erstellen
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 700,
color: '#0f172a',
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{needTitle}
</Typography>
</Box>
<IconButton onClick={close} size="small">
<X size={18} />
</IconButton>
</Box>
{/* Stepper */}
<Box sx={{ px: 3, py: 1.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', flexShrink: 0 }}>
<Stepper activeStep={activeIndex} alternativeLabel>
{STEPS.map(s => (
<Step key={s.key}>
<StepLabel
slotProps={{
label: { sx: { fontSize: '0.8rem', fontWeight: 500 } },
}}
>
{s.label}
</StepLabel>
</Step>
))}
</Stepper>
</Box>
{/* Step content */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
{currentStep === 'checked' && <OfferCheckedAction />}
{currentStep === 'send' && <OfferChatComposer />}
</Box>
</Dialog>
)
}
@@ -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 (
<Box
sx={{
width: 360,
minWidth: 360,
flexShrink: 0,
borderLeft: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.25,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Target size={14} color="#1e3a5f" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Eigene Objekte
</Typography>
<Box
sx={{
ml: 'auto',
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#1e3a5f',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
{scored.length}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress size={20} />
</Box>
)}
{!isLoading && scored.length === 0 && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem', textAlign: 'center', p: 2 }}>
Keine Portfolio-Objekte vorhanden
</Typography>
)}
{scored.map(({ property, score, reason }) => (
<SelectablePropertyMatchCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggle(property.id)}
reason={reason}
/>
))}
</Box>
<OfferCreationPanel
selectedCount={selectedIds.length}
needId={need.id}
needTitle={need.title}
/>
</Box>
)
}
@@ -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 (
<Paper
onClick={onClick}
elevation={0}
sx={{
p: 1.5,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
},
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
{need.title}
</Typography>
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.65rem',
height: 20,
}}
/>
</Box>
{need.tenantCompany && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{need.tenantCompany}
</Typography>
)}
<Chip
label={assetTypeLabel(need.assetType)}
size="small"
sx={{
alignSelf: 'flex-start',
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
height: 20,
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
<MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{need.desiredLocation}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
<Ruler size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{need.sizeRange.min}{need.sizeRange.max} m²
</Typography>
</Box>
</Paper>
)
}
@@ -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 (
<Box
sx={{
flex: 1,
overflowY: 'auto',
bgcolor: '#f8fafc',
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Header */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip
label={assetTypeLabel(need.assetType)}
size="small"
sx={{
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
{need.title}
</Typography>
{need.tenantCompany && (
<Typography variant="body2" sx={{ color: '#64748b' }}>
{need.tenantCompany}
</Typography>
)}
</Box>
{/* AI Summary */}
{need.aiSummary && (
<Alert
icon={<Sparkles size={16} />}
severity="info"
sx={{
bgcolor: '#eff6ff',
borderRadius: 1.5,
border: '1px solid #bfdbfe',
'& .MuiAlert-icon': { color: '#1d4ed8' },
'& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', display: 'block', mb: 0.5 }}>
KI-Analyse
</Typography>
{need.aiSummary}
</Alert>
)}
{/* Suchprofil grid */}
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
Suchprofil
</Typography>
<Box
sx={{
mt: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 1.5,
}}
>
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
<ProfileBox
icon={<Ruler size={14} />}
label="Fläche"
value={`${need.sizeRange.min}${need.sizeRange.max}`}
/>
<ProfileBox
icon={<Wallet size={14} />}
label="Budget"
value={
need.budgetRange
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${
need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
} /m²`
: 'Flexibel'
}
/>
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
</Box>
</Box>
{/* Must-have Kriterien */}
{need.mustHaveCriteria.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
Must-have Kriterien
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustHaveCriteria.map((c, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>
{c}
</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Gewichtete Präferenzen */}
{need.weightedPreferences.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
Gewichtete Präferenzen
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
{need.weightedPreferences.map((p, idx) => {
const pct = Math.round(p.weight * 100)
return (
<Box
key={idx}
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{p.criterion}
</Typography>
<Typography
variant="caption"
sx={{
fontWeight: 700,
color: '#1e3a5f',
fontSize: '0.75rem',
bgcolor: '#e0e7ff',
px: 1,
py: 0.125,
borderRadius: 1,
}}
>
{pct}%
</Typography>
</Box>
<Box
sx={{
height: 6,
borderRadius: 3,
bgcolor: '#e2e8f0',
overflow: 'hidden',
}}
>
<Box
sx={{
width: `${pct}%`,
height: '100%',
bgcolor: '#1e3a5f',
transition: 'width 0.3s',
}}
/>
</Box>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
{p.description}
</Typography>
)}
</Box>
)
})}
</Box>
</Box>
)}
{disabled && need.status !== 'public' && (
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem' }}>
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} kein Angebot möglich.
</Alert>
)}
<Box sx={{ mt: 'auto', pt: 1 }}>
<Button
variant="contained"
size="large"
fullWidth
onClick={() => openWizard(need.id, need.title)}
disabled={disabled}
sx={{
textTransform: 'none',
bgcolor: '#1e3a5f',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
>
Angebot erstellen
</Button>
</Box>
</Box>
</Box>
)
}
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>
{value}
</Typography>
</Box>
)
}
@@ -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 (
<Box
sx={{
width: 280,
minWidth: 280,
flexShrink: 0,
borderRight: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.25,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Sparkles size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Latente Bedarfe
</Typography>
<Box
sx={{
ml: 'auto',
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#1e3a5f',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
{needs.length}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress size={20} />
</Box>
)}
{error && (
<Typography variant="body2" color="error">
Fehler beim Laden
</Typography>
)}
{!isLoading && needs.length === 0 && (
<EmptyState
title="Keine Bedarfe"
description="Aktuell sind keine öffentlichen Bedarfe verfügbar."
/>
)}
{needs.map(n => (
<PublicNeedCard
key={n.id}
need={n}
selected={n.id === selectedNeedId}
onClick={() => onSelect(n.id)}
/>
))}
</Box>
</Box>
)
}
@@ -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 (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
<CircularProgress size={20} />
</Box>
)
}
if (!property) {
return (
<Box sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
Objekt nicht gefunden
</Typography>
</Box>
)
}
const image = property.images?.[0]
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
p: 2,
borderLeft: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
height: '100%',
overflowY: 'auto',
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Bezogenes Objekt
</Typography>
<Box
sx={{
height: 140,
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: '#e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!image && <Building2 size={32} color="#94a3b8" />}
</Box>
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<MapPin size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.location.city}
{property.location.district ? `, ${property.location.district}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Ruler size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Calendar size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
</Typography>
</Box>
</Box>
{property.description && (
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.8125rem', lineHeight: 1.5 }}>
{property.description}
</Typography>
)}
<Button
variant="outlined"
size="small"
endIcon={<ArrowRight size={14} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', mt: 'auto' }}
>
Objekt ansehen
</Button>
</Box>
)
}
@@ -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 (
<Paper
elevation={0}
onClick={onToggle}
sx={{
p: 1.25,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
bgcolor: selected ? '#eff6ff' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
},
display: 'flex',
gap: 1.25,
alignItems: 'flex-start',
}}
>
<Checkbox
checked={selected}
onChange={onToggle}
onClick={e => e.stopPropagation()}
size="small"
sx={{ p: 0.5, mt: -0.5, ml: -0.5 }}
/>
<Box
sx={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 1,
bgcolor: '#e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!image && <Building2 size={18} color="#94a3b8" />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: '#0f172a',
fontSize: '0.8125rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
}}
>
{property.title}
</Typography>
<Box
sx={{
background: theme.gradient,
color: theme.text,
px: 0.75,
py: 0.125,
borderRadius: 1,
fontSize: '0.7rem',
fontWeight: 700,
flexShrink: 0,
border: `1px solid ${theme.border}`,
}}
>
{matchScore}%
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25, color: '#64748b' }}>
<MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.7rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#475569', display: 'block', mt: 0.25 }}>
{reason}
</Typography>
</Box>
</Paper>
)
}
+26
View File
@@ -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'
@@ -0,0 +1,23 @@
import { mockProperties } from '../../mock-data/properties'
const propertyMap: Record<string, string> = 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`
}
@@ -0,0 +1,35 @@
import type { AssetType } from '../../domain/enums'
export function assetTypeLabel(at: AssetType): string {
const map: Record<string, string> = {
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 6096)
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)
}