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