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:
@@ -23,6 +23,7 @@ const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
|
|||||||
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
|
||||||
const Properties = lazy(() => import('./pages/supply/Properties'))
|
const Properties = lazy(() => import('./pages/supply/Properties'))
|
||||||
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
|
||||||
|
const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
|
||||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ function App() {
|
|||||||
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
|
||||||
<Route path="/supply/properties" element={<Properties />} />
|
<Route path="/supply/properties" element={<Properties />} />
|
||||||
<Route path="/supply/match-center" element={<MatchCenter />} />
|
<Route path="/supply/match-center" element={<MatchCenter />} />
|
||||||
|
<Route path="/supply/anfragen" element={<Anfragencenter />} />
|
||||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -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} m²`}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 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)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
Radar,
|
Radar,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
@@ -76,7 +77,7 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
navItems: [
|
navItems: [
|
||||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
{ 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/future-availability', label: 'Marktchancen', icon: TrendingUp },
|
||||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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 (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0 }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, pt: 2.5, pb: 1 }}>
|
||||||
|
Anfragencenter
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(_, v) => setTab(v)}
|
||||||
|
sx={{
|
||||||
|
'& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.95rem' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab label="Aktive Anfragen" />
|
||||||
|
<Tab label="Latente Anfragen" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
{tab === 0 && <ActiveInquiriesTab />}
|
||||||
|
{tab === 1 && <LatentInquiriesTab />}
|
||||||
|
</Box>
|
||||||
|
<OfferWizard />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Inquiry[]>
|
||||||
|
getById(id: string): Promise<Inquiry | null>
|
||||||
|
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
|
||||||
|
addMessage(
|
||||||
|
inquiryId: string,
|
||||||
|
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
||||||
|
): Promise<Inquiry>
|
||||||
|
}
|
||||||
@@ -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<LatentNeed[]>
|
||||||
|
getById(id: string): Promise<LatentNeed | null>
|
||||||
|
}
|
||||||
@@ -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<OfferDraft>
|
||||||
|
getById(id: string): Promise<OfferDraft | null>
|
||||||
|
updateField(id: string, fieldId: string, value: string): Promise<OfferDraft>
|
||||||
|
updateStatus(id: string, status: OfferStatus): Promise<OfferDraft>
|
||||||
|
setPdfUrl(id: string, pdfUrl: string): Promise<OfferDraft>
|
||||||
|
}
|
||||||
@@ -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<Inquiry[]> {
|
||||||
|
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<Inquiry | null> {
|
||||||
|
return store.find(i => i.id === id) ?? null
|
||||||
|
},
|
||||||
|
async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> {
|
||||||
|
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<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
||||||
|
): Promise<Inquiry> {
|
||||||
|
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]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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<LatentNeed[]> {
|
||||||
|
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<LatentNeed | null> {
|
||||||
|
return store.find(n => n.id === id) ?? null
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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<OfferDraft> {
|
||||||
|
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<OfferDraft | null> {
|
||||||
|
return store.find(d => d.id === id) ?? null
|
||||||
|
},
|
||||||
|
async updateField(id: string, fieldId: string, value: string): Promise<OfferDraft> {
|
||||||
|
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<OfferDraft> {
|
||||||
|
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<OfferDraft> {
|
||||||
|
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]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -421,4 +421,22 @@ export const aiService = {
|
|||||||
await new Promise(r => setTimeout(r, 1800))
|
await new Promise(r => setTimeout(r, 1800))
|
||||||
return { data: buildMockDecisionBrief(shortlistId) }
|
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,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T> = { data: T; error: null } | { data: null; error: string }
|
||||||
|
|
||||||
|
export const inquiryService = {
|
||||||
|
async getActiveInquiries(filters?: InquiryFilters): Promise<ServiceResult<Inquiry[]>> {
|
||||||
|
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<ServiceResult<Inquiry | null>> {
|
||||||
|
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<ServiceResult<Inquiry>> {
|
||||||
|
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<ServiceResult<Inquiry>> {
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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<T> = { data: T; error: null } | { data: null; error: string }
|
||||||
|
|
||||||
|
export const latentNeedService = {
|
||||||
|
async getPublicNeeds(filters?: LatentNeedFilters): Promise<ServiceResult<LatentNeed[]>> {
|
||||||
|
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<ServiceResult<LatentNeed | null>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data, error: null }
|
||||||
|
} catch (e) {
|
||||||
|
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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<T> = { data: T; error: null } | { data: null; error: string }
|
||||||
|
|
||||||
|
function assetTypeLabel(at: AssetType): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
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<ServiceResult<OfferDraft>> {
|
||||||
|
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<ServiceResult<OfferDraft>> {
|
||||||
|
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<ServiceResult<OfferDraft>> {
|
||||||
|
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<ServiceResult<{ previewReady: true; pdfUrl: string }>> {
|
||||||
|
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<ServiceResult<OfferDraft>> {
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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<string, string>
|
||||||
|
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<OfferWizardState>((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: [],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user