3 Commits

Author SHA1 Message Date
Benjamin Sutter be5523bcee fix(supply): Anfragencenter parse error — stray quote in JSX attribute string
The "Gesendet" empty-state description embedded an ASCII double-quote inside a German „…" pair, which Vite/oxc parsed as the end of the attribute string (PARSE_ERROR). Reworded without embedded quotes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:36:39 +02:00
Benjamin Sutter a8b54af0b8 feat(messages): unified conversation inbox — connect demand ↔ supply, offers reach the seeker
Phase 1 of the messaging rework: one conversation/thread model (Inquiry) is the single source of truth, read perspectivally by both sides.

- domain/inquiry: add kind (INQUIRY|OFFER), tenantOrgId (demand org), offeredPropertyIds
- provider/service: InquiryFilters gains tenantOrgId+kind; new createInquiry (demand→supply) & createOffer (supply→demand) materialize threads; addMessage bumps unread; reply carries senderType (tenant vs supply_user)
- hooks: useCreateInquiry, useCreateOffer; reply payload carries sender perspective
- demand: InquiryQuickDialog → createInquiry via provider (session-based tenant/org, no hardcode); Anfragen.tsx reads via React Query (tenantOrgId) with Gesendet/Erhalten tabs, replies via provider, shows offered properties for OFFER threads; inquiryStore reduced to dialog-only (removes Zustand server-data island)
- supply: OfferChatComposer send now materializes an OFFER conversation into the seeker's inbox (routed via latentNeed.tenantOrgId); Anfragencenter gains a "Gesendet" tab; ActiveInquiriesTab filters by perspective (org + kind)
- seed: merge demand inquiries with tenantOrgId=org-mobimo + 2 OFFER seeds; provider seeds from both sets

Closes the loop: a sent inquiry reaches the Verwaltung and a sent offer reaches the Nachfrager — both answerable in one thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:32:13 +02:00
Benjamin Sutter 6206447dae fix(demand): new search profile inherits creator's organizationId
A newly created need (from "Jetzt suchen") had no organizationId, so it fell out of the org-filtered need list — the results "Aktive Suche" banner then showed a different (first saved) profile while the result cards already reflected the new search. needService.create now injects the current user's org, so banner and results stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:57:14 +02:00
14 changed files with 354 additions and 111 deletions
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material' import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react' import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
import { useActiveInquiries } from '../../hooks/useInquiries' import { useActiveInquiries } from '../../hooks/useInquiries'
import type { InquiryFilters } from '../../provider/IInquiryProvider'
import { EmptyState } from '../ui' import { EmptyState } from '../ui'
import { InquiryList } from './InquiryList' import { InquiryList } from './InquiryList'
import { InquiryCardGrid } from './InquiryCardGrid' import { InquiryCardGrid } from './InquiryCardGrid'
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
return stored === 'grid' ? 'grid' : 'list' return stored === 'grid' ? 'grid' : 'list'
} }
export function ActiveInquiriesTab() { interface Props {
const { data: inquiries = [], isLoading } = useActiveInquiries() filters?: InquiryFilters
emptyTitle?: string
emptyDescription?: string
}
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [view, setView] = useState<ViewMode>(loadViewMode()) const [view, setView] = useState<ViewMode>(loadViewMode())
const theme = useTheme() const theme = useTheme()
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
return ( return (
<EmptyState <EmptyState
icon={<Inbox size={40} />} icon={<Inbox size={40} />}
title="Keine aktiven Anfragen" title={emptyTitle ?? 'Keine aktiven Anfragen'}
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier." description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
/> />
) )
} }
@@ -2,6 +2,9 @@ import { Box, Button, CircularProgress, IconButton, TextField, Typography } from
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react' import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSendOffer } from '../../hooks/useOffers' import { useSendOffer } from '../../hooks/useOffers'
import { useCreateOffer } from '../../hooks/useInquiries'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { AiOfferEmailButton } from './AiOfferEmailButton' import { AiOfferEmailButton } from './AiOfferEmailButton'
import { mockProperties } from '../../mock-data/properties' import { mockProperties } from '../../mock-data/properties'
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
const reset = useOfferWizardStore(s => s.reset) const reset = useOfferWizardStore(s => s.reset)
const sendOffer = useSendOffer() const sendOffer = useSendOffer()
const createOffer = useCreateOffer()
const { data: latentNeed } = useLatentNeedById(needId)
const currentUser = useSessionStore(s => s.currentUser)
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const propertyTitles = selectedPropertyIds.map(id => { const propertyTitles = selectedPropertyIds.map(id => {
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
showToast(`Fehler: ${res.error}`, 'error') showToast(`Fehler: ${res.error}`, 'error')
return return
} }
// Angebot als Konversation im Nachfrager-Postfach materialisieren
await createOffer.mutateAsync({
organizationId: currentUser?.organizationId ?? 'org-wincasa',
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
needId: needId ?? '',
propertyId: selectedPropertyIds[0] ?? '',
offeredPropertyIds: selectedPropertyIds,
subject,
message: body,
senderName: currentUser?.organizationName ?? 'Wincasa AG',
recipientName: latentNeed?.tenantCompany,
attachments,
})
showToast('Angebot erfolgreich gesendet', 'success') showToast('Angebot erfolgreich gesendet', 'success')
reset() reset()
} }
@@ -6,7 +6,9 @@ import {
import { Send } from 'lucide-react' import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore' import { useInquiryStore } from '../../stores/inquiryStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useMoveStage } from '../../hooks/usePipeline' import { useMoveStage } from '../../hooks/usePipeline'
import { useCreateInquiry } from '../../hooks/useInquiries'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string { function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
@@ -19,7 +21,8 @@ export function InquiryQuickDialog() {
const dialogOpen = useInquiryStore(s => s.dialogOpen) const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry) const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog) const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry) const currentUser = useSessionStore(s => s.currentUser)
const createInquiry = useCreateInquiry()
const { mutate: moveStage } = useMoveStage() const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@@ -32,7 +35,18 @@ export function InquiryQuickDialog() {
function handleSend() { function handleSend() {
if (!pendingInquiry) return if (!pendingInquiry) return
addInquiry(pendingInquiry, message) createInquiry.mutate({
organizationId: 'org-wincasa', // Supply-Org des Objekts (Mock: Wincasa)
tenantOrgId: currentUser?.organizationId ?? 'org-mobimo',
propertyId: pendingInquiry.propertyId ?? 'unknown',
tenantName: currentUser?.name ?? 'Demand User',
tenantCompany: currentUser?.organizationName,
tenantEmail: currentUser?.email,
propertyAddress: pendingInquiry.propertyTitle,
subject: `Anfrage: ${pendingInquiry.propertyTitle}`,
message,
matchScore: pendingInquiry.matchScore,
})
if (pendingInquiry.pipelineItemId) { if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' }) moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
} }
+10
View File
@@ -20,10 +20,20 @@ export interface InquiryMessage {
export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived' export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived'
/** Konversationstyp: Nachfrager-initiierte Anfrage vs. Verwaltung-initiiertes Angebot. */
export type InquiryKind = 'INQUIRY' | 'OFFER'
export interface Inquiry { export interface Inquiry {
id: string id: string
/** Supply-Organisation (Eigentümer/Verwaltung des Objekts). */
organizationId: string organizationId: string
/** Nachfrager-Organisation — für das Demand-Postfach. */
tenantOrgId?: string
/** INQUIRY (Demand→Supply) oder OFFER (Supply→Demand). Default INQUIRY. */
kind?: InquiryKind
propertyId: string propertyId: string
/** Bei OFFER: alle im Angebot enthaltenen Objekte. */
offeredPropertyIds?: string[]
needId?: string needId?: string
tenantName: string tenantName: string
tenantCompany?: string tenantCompany?: string
+2
View File
@@ -5,6 +5,8 @@ export interface LatentNeed {
id: string id: string
title: string title: string
tenantCompany?: string tenantCompany?: string
/** Nachfrager-Organisation — Empfänger eines Angebots (Demo-Default-Routing: org-mobimo). */
tenantOrgId?: string
assetType: AssetType assetType: AssetType
desiredLocation: string desiredLocation: string
sizeRange: { min: number; max: number } sizeRange: { min: number; max: number }
+28 -3
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { inquiryService } from '../services/inquiryService' import { inquiryService } from '../services/inquiryService'
import type { InquiryFilters } from '../provider/IInquiryProvider' import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
import type { Attachment } from '../domain/inquiry' import type { Attachment, InquiryMessage } from '../domain/inquiry'
import { useToastStore } from '../stores/toastStore' import { useToastStore } from '../stores/toastStore'
export function useActiveInquiries(filters?: InquiryFilters) { export function useActiveInquiries(filters?: InquiryFilters) {
@@ -29,7 +29,7 @@ export function useSendInquiryReply() {
payload, payload,
}: { }: {
inquiryId: string inquiryId: string
payload: { subject?: string; body: string; attachments?: Attachment[] } payload: { subject?: string; body: string; attachments?: Attachment[]; senderType?: InquiryMessage['senderType']; senderName?: string }
}) => inquiryService.sendInquiryReply(inquiryId, payload), }) => inquiryService.sendInquiryReply(inquiryId, payload),
onSuccess: (_data, { inquiryId }) => { onSuccess: (_data, { inquiryId }) => {
qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] }) qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
@@ -41,6 +41,31 @@ export function useSendInquiryReply() {
}) })
} }
/** Demand→Supply: neue Anfrage erstellen (ersetzt die alte Zustand-Insel). */
export function useCreateInquiry() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateInquiryInput) => inquiryService.createInquiry(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inquiries'] })
},
onError: () => {
useToastStore.getState().showToast('Anfrage konnte nicht gesendet werden.', 'error')
},
})
}
/** Supply→Demand: Angebot als Konversation im Nachfrager-Postfach materialisieren. */
export function useCreateOffer() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateOfferInput) => inquiryService.createOffer(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inquiries'] })
},
})
}
export function useMarkThreadAsRead() { export function useMarkThreadAsRead() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
+46
View File
@@ -5,6 +5,8 @@ export const mockDemandInquiries: Inquiry[] = [
{ {
id: 'dinq-001', id: 'dinq-001',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-technopark', propertyId: 'prop-technopark',
tenantName: 'Admin User', tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG', tenantCompany: 'Mobimo Management AG',
@@ -39,6 +41,8 @@ export const mockDemandInquiries: Inquiry[] = [
{ {
id: 'dinq-002', id: 'dinq-002',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-hardturmpark', propertyId: 'prop-hardturmpark',
tenantName: 'Admin User', tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG', tenantCompany: 'Mobimo Management AG',
@@ -73,6 +77,8 @@ export const mockDemandInquiries: Inquiry[] = [
{ {
id: 'dinq-003', id: 'dinq-003',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-sihlcity', propertyId: 'prop-sihlcity',
tenantName: 'Admin User', tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG', tenantCompany: 'Mobimo Management AG',
@@ -130,6 +136,8 @@ export const mockDemandInquiries: Inquiry[] = [
{ {
id: 'dinq-004', id: 'dinq-004',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-bahnhofzug', propertyId: 'prop-bahnhofzug',
tenantName: 'Admin User', tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG', tenantCompany: 'Mobimo Management AG',
@@ -186,6 +194,8 @@ export const mockDemandInquiries: Inquiry[] = [
{ {
id: 'dinq-005', id: 'dinq-005',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-dreispitz', propertyId: 'prop-dreispitz',
tenantName: 'Admin User', tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG', tenantCompany: 'Mobimo Management AG',
@@ -238,4 +248,40 @@ export const mockDemandInquiries: Inquiry[] = [
}, },
], ],
}, },
// 6 — OFFER (Verwaltung → Nachfrager, proaktiv aus Latente Anfragen)
{
id: 'off-seed-001',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'OFFER',
propertyId: 'prop-001',
offeredPropertyIds: ['prop-001', 'prop-093'],
tenantName: 'Mobimo Management AG',
propertyManagerCompany: 'Wincasa AG',
propertyAddress: '2 Objekte im Angebot',
subject: 'Passende Büroflächen zu Ihrem Bedarf',
message:
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
status: 'new',
unreadCount: 1,
isRead: false,
createdAt: '2026-05-24T10:00:00Z',
updatedAt: '2026-05-24T10:00:00Z',
thread: [
{
id: 'off-seed-001-1',
inquiryId: 'off-seed-001',
senderType: 'supply_user',
senderName: 'Wincasa AG',
subject: 'Passende Büroflächen zu Ihrem Bedarf',
body:
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
attachments: [
{ id: 'off-att-001', fileName: 'Angebot_Bueroflaechen_Zuerich.pdf', fileType: 'application/pdf', fileSize: 312500, generated: true },
],
createdAt: '2026-05-24T10:00:00Z',
},
],
},
] ]
+48 -34
View File
@@ -5,10 +5,9 @@ import {
InputAdornment, Alert, InputAdornment, Alert,
} from '@mui/material' } from '@mui/material'
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react' import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
import { mockDemandInquiries } from '../../mock-data/demandInquiries' import { useActiveInquiries, useSendInquiryReply, useMarkThreadAsRead } from '../../hooks/useInquiries'
import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline' import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import type { InquiryMessage } from '../../domain/inquiry' import { mockProperties } from '../../mock-data/properties'
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection' import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble' import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem' import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
@@ -19,9 +18,8 @@ import { useSessionStore } from '../../stores/sessionStore'
const FILTER_TABS = [ const FILTER_TABS = [
{ key: 'all', label: 'Alle' }, { key: 'all', label: 'Alle' },
{ key: 'new', label: 'Neu' }, { key: 'INQUIRY', label: 'Gesendet' },
{ key: 'in_progress', label: 'Aktiv' }, { key: 'OFFER', label: 'Erhalten' },
{ key: 'answered', label: 'Beantwortet' },
] ]
// ── Anfragen page ───────────────────────────────────────────────────────────── // ── Anfragen page ─────────────────────────────────────────────────────────────
@@ -36,20 +34,23 @@ export default function Anfragen() {
const preselectedId = searchParams.get('inquiry') const preselectedId = searchParams.get('inquiry')
const { currentUser } = useSessionStore() const { currentUser } = useSessionStore()
const storeInquiries = useInquiryStore(s => s.sentInquiries) const { data: allInquiries = [] } = useActiveInquiries({ tenantOrgId: currentUser?.organizationId })
const [inquiries, setInquiries] = useState(mockDemandInquiries) const sendReply = useSendInquiryReply()
const allInquiries = [...storeInquiries, ...inquiries] const markRead = useMarkThreadAsRead()
const [selectedId, setSelectedId] = useState<string | null>( const [selectedId, setSelectedId] = useState<string | null>(preselectedId ?? null)
preselectedId ?? mockDemandInquiries[0]?.id ?? null
)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all') const [kindFilter, setKindFilter] = useState('all')
const [replyText, setReplyText] = useState('') const [replyText, setReplyText] = useState('')
const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId) const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId)
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null) const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
const threadRef = useRef<HTMLDivElement>(null) const threadRef = useRef<HTMLDivElement>(null)
// Default-Auswahl: erste Konversation, sobald geladen
useEffect(() => {
if (!selectedId && allInquiries.length > 0) setSelectedId(allInquiries[0].id)
}, [allInquiries, selectedId])
const filtered = allInquiries.filter(inq => { const filtered = allInquiries.filter(inq => {
const q = search.toLowerCase() const q = search.toLowerCase()
const matchesSearch = !q || const matchesSearch = !q ||
@@ -59,12 +60,14 @@ export default function Anfragen() {
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) || (inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) || (inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false) (inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter const matchesKind = kindFilter === 'all' || (inq.kind ?? 'INQUIRY') === kindFilter
return matchesSearch && matchesStatus return matchesSearch && matchesKind
}) })
const selected = allInquiries.find(i => i.id === selectedId) ?? null const selected = allInquiries.find(i => i.id === selectedId) ?? null
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0) const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
const offeredProperties = (selected?.kind === 'OFFER' ? selected.offeredPropertyIds ?? [] : [])
.map(id => mockProperties.find(p => p.id === id)).filter(Boolean)
// Pipeline link for currently selected inquiry // Pipeline link for currently selected inquiry
const linkedPipelineItem = selected?.propertyId const linkedPipelineItem = selected?.propertyId
@@ -86,7 +89,7 @@ export default function Anfragen() {
function handleSelect(id: string) { function handleSelect(id: string) {
setSelectedId(id) setSelectedId(id)
setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i)) markRead.mutate(id)
setMobileShowChat(true) setMobileShowChat(true)
setKiAlert(null) setKiAlert(null)
} }
@@ -95,20 +98,10 @@ export default function Anfragen() {
if (!replyText.trim() || !selectedId) return if (!replyText.trim() || !selectedId) return
const text = replyText.trim() const text = replyText.trim()
const msg: InquiryMessage = { sendReply.mutate({
id: `msg-${Date.now()}`,
inquiryId: selectedId, inquiryId: selectedId,
senderType: 'tenant', payload: { body: text, senderType: 'tenant', senderName: currentUser?.name ?? 'Sie' },
senderName: 'Sie', })
body: text,
attachments: [],
createdAt: new Date().toISOString(),
}
setInquiries(prev => prev.map(i =>
i.id === selectedId
? { ...i, thread: [...i.thread, msg], status: 'in_progress', updatedAt: new Date().toISOString() }
: i
))
setReplyText('') setReplyText('')
// KI: detect stage transition from message content // KI: detect stage transition from message content
@@ -160,13 +153,13 @@ export default function Anfragen() {
/> />
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{FILTER_TABS.map(tab => ( {FILTER_TABS.map(tab => (
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)} <Chip key={tab.key} label={tab.label} size="small" onClick={() => setKindFilter(tab.key)}
sx={{ sx={{
height: 22, fontSize: '0.7rem', cursor: 'pointer', height: 22, fontSize: '0.7rem', cursor: 'pointer',
bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle, bgcolor: kindFilter === tab.key ? 'primary.main' : DS_BG.subtle,
color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary, color: kindFilter === tab.key ? 'white' : DS_TEXT.secondary,
fontWeight: statusFilter === tab.key ? 700 : 400, fontWeight: kindFilter === tab.key ? 700 : 400,
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted }, '&:hover': { bgcolor: kindFilter === tab.key ? 'primary.dark' : DS_BG.muted },
}} }}
/> />
))} ))}
@@ -291,6 +284,27 @@ export default function Anfragen() {
</Box> </Box>
)} )}
{/* Angebot: enthaltene Objekte */}
{offeredProperties.length > 0 && (
<Box sx={{ px: { xs: 2, md: 3 }, pt: 1.5, flexShrink: 0 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 0.75 }}>
Enthaltene Objekte ({offeredProperties.length})
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{offeredProperties.map(p => p && (
<Chip
key={p.id}
icon={<Building2 size={12} />}
label={`${p.title} · CHF ${p.rentPricePerSqm}/m²`}
size="small"
onClick={() => navigate(`/demand/property/${p.id}`)}
sx={{ cursor: 'pointer', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.blue.border}`, fontSize: '0.72rem', '& .MuiChip-icon': { color: DS_TEXT.signalDark } }}
/>
))}
</Box>
</Box>
)}
{/* Thread */} {/* Thread */}
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{selected.thread.map(msg => ( {selected.thread.map(msg => (
+12 -2
View File
@@ -2,11 +2,13 @@ import { useState } from 'react'
import { useLocation } from 'react-router' import { useLocation } from 'react-router'
import { Box, Tab, Tabs, Typography } from '@mui/material' import { Box, Tab, Tabs, Typography } from '@mui/material'
import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter' import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
import { useSessionStore } from '../../stores/sessionStore'
export default function Anfragencenter() { export default function Anfragencenter() {
const location = useLocation() const location = useLocation()
const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0 const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0
const [tab, setTab] = useState(initialTab) const [tab, setTab] = useState(initialTab)
const orgId = useSessionStore(s => s.currentUser?.organizationId)
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}> <Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}>
@@ -21,12 +23,20 @@ export default function Anfragencenter() {
}} }}
> >
<Tab label="Aktive Anfragen" /> <Tab label="Aktive Anfragen" />
<Tab label="Gesendet" />
<Tab label="Latente Anfragen" /> <Tab label="Latente Anfragen" />
</Tabs> </Tabs>
</Box> </Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}> <Box sx={{ flex: 1, overflow: 'hidden' }}>
{tab === 0 && <ActiveInquiriesTab />} {tab === 0 && <ActiveInquiriesTab filters={{ organizationId: orgId, kind: 'INQUIRY' }} />}
{tab === 1 && <LatentInquiriesTab />} {tab === 1 && (
<ActiveInquiriesTab
filters={{ organizationId: orgId, kind: 'OFFER' }}
emptyTitle="Keine gesendeten Angebote"
emptyDescription="Angebote aus den latenten Anfragen erscheinen hier, sobald Sie sie versenden."
/>
)}
{tab === 2 && <LatentInquiriesTab />}
</Box> </Box>
<OfferWizard /> <OfferWizard />
</Box> </Box>
+36 -3
View File
@@ -1,17 +1,50 @@
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry' import type { Inquiry, InquiryStatus, InquiryKind, InquiryMessage, Attachment } from '../domain/inquiry'
export interface InquiryFilters { export interface InquiryFilters {
status?: InquiryStatus status?: InquiryStatus
propertyId?: string propertyId?: string
organizationId?: string organizationId?: string // Supply-Perspektive
tenantOrgId?: string // Demand-Perspektive
kind?: InquiryKind
}
/** Demand→Supply: neue Anfrage zu einem Objekt. */
export interface CreateInquiryInput {
organizationId: string // Supply-Org (Objekt-Eigentümer)
tenantOrgId: string // Demand-Org (Absender)
propertyId: string
needId?: string
tenantName: string
tenantCompany?: string
tenantEmail?: string
propertyAddress?: string
subject: string
message: string
matchScore?: number
}
/** Supply→Demand: Angebot zu einem latenten Bedarf. */
export interface CreateOfferInput {
organizationId: string // Supply-Org (Absender)
tenantOrgId: string // Demand-Org (Empfänger, aus need.organizationId)
needId: string
propertyId: string // primäres Objekt (Kompatibilität)
offeredPropertyIds: string[]
subject: string
message: string
senderName: string // Verwaltungs-/Org-Name
recipientName?: string
attachments?: Attachment[]
} }
export interface IInquiryProvider { export interface IInquiryProvider {
getAll(filters?: InquiryFilters): Promise<Inquiry[]> getAll(filters?: InquiryFilters): Promise<Inquiry[]>
getById(id: string): Promise<Inquiry | null> getById(id: string): Promise<Inquiry | null>
createInquiry(input: CreateInquiryInput): Promise<Inquiry>
createOffer(input: CreateOfferInput): Promise<Inquiry>
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
markThreadAsRead(id: string): Promise<Inquiry> markThreadAsRead(id: string): Promise<Inquiry>
getUnreadCount(): Promise<number> getUnreadCount(filters?: InquiryFilters): Promise<number>
addMessage( addMessage(
inquiryId: string, inquiryId: string,
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>, msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
+92 -9
View File
@@ -1,20 +1,99 @@
import type { IInquiryProvider, InquiryFilters } from './IInquiryProvider' import type { IInquiryProvider, InquiryFilters, CreateInquiryInput, CreateOfferInput } from './IInquiryProvider'
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry' import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
import { mockInquiries } from '../mock-data/inquiries' import { mockInquiries } from '../mock-data/inquiries'
import { mockDemandInquiries } from '../mock-data/demandInquiries'
const store: Inquiry[] = mockInquiries.map(i => ({ ...i, thread: [...i.thread] })) // Eine gemeinsame Konversationsmenge: Supply-Eingang (mockInquiries) + Demand-Perspektive
// (mockDemandInquiries, inkl. erhaltener Angebote). Beide Seiten lesen perspektivisch gefiltert.
const store: Inquiry[] = [...mockInquiries, ...mockDemandInquiries].map(i => ({ ...i, thread: [...i.thread] }))
function applyFilters(results: Inquiry[], filters?: InquiryFilters): Inquiry[] {
let out = results
if (filters?.status) out = out.filter(i => i.status === filters.status)
if (filters?.propertyId) out = out.filter(i => i.propertyId === filters.propertyId)
if (filters?.organizationId) out = out.filter(i => i.organizationId === filters.organizationId)
if (filters?.tenantOrgId) out = out.filter(i => i.tenantOrgId === filters.tenantOrgId)
if (filters?.kind) out = out.filter(i => (i.kind ?? 'INQUIRY') === filters.kind)
return out
}
export const MockupInquiryProvider: IInquiryProvider = { export const MockupInquiryProvider: IInquiryProvider = {
async getAll(filters?: InquiryFilters): Promise<Inquiry[]> { async getAll(filters?: InquiryFilters): Promise<Inquiry[]> {
let results = [...store] return applyFilters([...store], filters).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
if (filters?.status) results = results.filter(i => i.status === filters.status)
if (filters?.propertyId) results = results.filter(i => i.propertyId === filters.propertyId)
if (filters?.organizationId) results = results.filter(i => i.organizationId === filters.organizationId)
return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
}, },
async getById(id: string): Promise<Inquiry | null> { async getById(id: string): Promise<Inquiry | null> {
return store.find(i => i.id === id) ?? null return store.find(i => i.id === id) ?? null
}, },
async createInquiry(input: CreateInquiryInput): Promise<Inquiry> {
const now = new Date().toISOString()
const id = `inq-${crypto.randomUUID().slice(0, 8)}`
const inquiry: Inquiry = {
id,
kind: 'INQUIRY',
organizationId: input.organizationId,
tenantOrgId: input.tenantOrgId,
propertyId: input.propertyId,
needId: input.needId,
tenantName: input.tenantName,
tenantCompany: input.tenantCompany,
tenantEmail: input.tenantEmail,
propertyAddress: input.propertyAddress,
subject: input.subject,
message: input.message,
status: 'new',
unreadCount: 1, // neu für die Verwaltung (Empfänger)
isRead: false,
matchScore: input.matchScore,
createdAt: now,
updatedAt: now,
thread: [{
id: crypto.randomUUID(),
inquiryId: id,
senderType: 'tenant',
senderName: input.tenantCompany ?? input.tenantName,
subject: input.subject,
body: input.message,
attachments: [],
createdAt: now,
}],
}
store.unshift(inquiry)
return inquiry
},
async createOffer(input: CreateOfferInput): Promise<Inquiry> {
const now = new Date().toISOString()
const id = `off-${crypto.randomUUID().slice(0, 8)}`
const inquiry: Inquiry = {
id,
kind: 'OFFER',
organizationId: input.organizationId,
tenantOrgId: input.tenantOrgId,
propertyId: input.propertyId,
offeredPropertyIds: input.offeredPropertyIds,
needId: input.needId,
tenantName: input.recipientName ?? '',
propertyManagerCompany: input.senderName,
subject: input.subject,
message: input.message,
status: 'new',
unreadCount: 1, // neu für den Nachfrager (Empfänger)
isRead: false,
createdAt: now,
updatedAt: now,
thread: [{
id: crypto.randomUUID(),
inquiryId: id,
senderType: 'supply_user',
senderName: input.senderName,
subject: input.subject,
body: input.message,
attachments: input.attachments ?? [],
createdAt: now,
}],
}
store.unshift(inquiry)
return inquiry
},
async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> { async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> {
const idx = store.findIndex(i => i.id === id) const idx = store.findIndex(i => i.id === id)
if (idx === -1) throw new Error(`Inquiry ${id} not found`) if (idx === -1) throw new Error(`Inquiry ${id} not found`)
@@ -33,8 +112,8 @@ export const MockupInquiryProvider: IInquiryProvider = {
} }
return store[idx] return store[idx]
}, },
async getUnreadCount(): Promise<number> { async getUnreadCount(filters?: InquiryFilters): Promise<number> {
return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0) return applyFilters([...store], filters).reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
}, },
async addMessage( async addMessage(
inquiryId: string, inquiryId: string,
@@ -51,6 +130,10 @@ export const MockupInquiryProvider: IInquiryProvider = {
store[idx] = { store[idx] = {
...store[idx], ...store[idx],
thread: [...store[idx].thread, message], thread: [...store[idx].thread, message],
// Neue Nachricht der Gegenseite → für den Empfänger ungelesen
unreadCount: (store[idx].unreadCount ?? 0) + 1,
isRead: false,
status: store[idx].status === 'new' ? 'in_progress' : store[idx].status,
updatedAt: message.createdAt, updatedAt: message.createdAt,
} }
return store[idx] return store[idx]
+26 -6
View File
@@ -1,6 +1,6 @@
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider' import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
import type { InquiryFilters } from '../provider/IInquiryProvider' import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
import type { Inquiry, Attachment } from '../domain/inquiry' import type { Inquiry, Attachment, InquiryMessage } from '../domain/inquiry'
import type { ListResponse, ItemResponse } from './types' import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors' import { throwServiceError } from './errors'
@@ -10,6 +10,8 @@ export interface InquiryReplyPayload {
subject?: string subject?: string
body: string body: string
attachments?: Attachment[] attachments?: Attachment[]
senderType?: InquiryMessage['senderType'] // default 'supply_user'
senderName?: string
} }
export const inquiryService = { export const inquiryService = {
@@ -31,14 +33,32 @@ export const inquiryService = {
} }
}, },
async createInquiry(input: CreateInquiryInput): Promise<ItemResponse<Inquiry>> {
try {
const data = await provider.createInquiry(input)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async createOffer(input: CreateOfferInput): Promise<ItemResponse<Inquiry>> {
try {
const data = await provider.createOffer(input)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async sendInquiryReply( async sendInquiryReply(
inquiryId: string, inquiryId: string,
payload: InquiryReplyPayload, payload: InquiryReplyPayload,
): Promise<ItemResponse<Inquiry>> { ): Promise<ItemResponse<Inquiry>> {
try { try {
const data = await provider.addMessage(inquiryId, { const data = await provider.addMessage(inquiryId, {
senderType: 'supply_user', senderType: payload.senderType ?? 'supply_user',
senderName: 'Wincasa AG', senderName: payload.senderName ?? 'Wincasa AG',
subject: payload.subject, subject: payload.subject,
body: payload.body, body: payload.body,
attachments: payload.attachments ?? [], attachments: payload.attachments ?? [],
@@ -58,9 +78,9 @@ export const inquiryService = {
} }
}, },
async getUnreadCount(): Promise<ItemResponse<number>> { async getUnreadCount(filters?: InquiryFilters): Promise<ItemResponse<number>> {
try { try {
const data = await provider.getUnreadCount() const data = await provider.getUnreadCount(filters)
return { data } return { data }
} catch (err) { } catch (err) {
throwServiceError(err) throwServiceError(err)
+5 -1
View File
@@ -3,6 +3,7 @@ import type { NeedFilters } from '../provider/INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need' import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import type { ListResponse, ItemResponse } from './types' import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors' import { throwServiceError } from './errors'
import { useSessionStore } from '../stores/sessionStore'
const provider = MockupNeedProvider const provider = MockupNeedProvider
@@ -25,7 +26,10 @@ export const needService = {
}, },
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> { async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
try { try {
const data = await provider.create(input) // Neues Suchprofil der Organisation des Erstellers zuordnen (sonst fällt es aus der
// org-gefilterten Liste — Aktive-Suche-Leiste zeigt sonst ein fremdes Profil)
const orgId = useSessionStore.getState().currentUser?.organizationId
const data = await provider.create({ ...input, organizationId: input.organizationId ?? orgId })
return { data } return { data }
} catch (err) { } catch (err) {
throwServiceError(err) throwServiceError(err)
+2 -46
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { Inquiry, InquiryMessage } from '../domain/inquiry'
// Reine UI-State: der Anfrage-Dialog. Versendete Anfragen leben jetzt im Provider
// (MockupInquiryProvider) und werden via React Query gelesen — nicht mehr hier.
export interface PendingInquiry { export interface PendingInquiry {
propertyTitle: string propertyTitle: string
location: string location: string
@@ -13,15 +14,10 @@ export interface PendingInquiry {
} }
interface InquiryStore { interface InquiryStore {
// Dialog state
dialogOpen: boolean dialogOpen: boolean
pendingInquiry: PendingInquiry | null pendingInquiry: PendingInquiry | null
openInquiryDialog: (item: PendingInquiry) => void openInquiryDialog: (item: PendingInquiry) => void
closeInquiryDialog: () => void closeInquiryDialog: () => void
// Sent inquiries (in-memory, persists for the session)
sentInquiries: Inquiry[]
addInquiry: (item: PendingInquiry, message: string) => void
} }
export const useInquiryStore = create<InquiryStore>((set) => ({ export const useInquiryStore = create<InquiryStore>((set) => ({
@@ -29,44 +25,4 @@ export const useInquiryStore = create<InquiryStore>((set) => ({
pendingInquiry: null, pendingInquiry: null,
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }), openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
closeInquiryDialog: () => set({ dialogOpen: false, pendingInquiry: null }), closeInquiryDialog: () => set({ dialogOpen: false, pendingInquiry: null }),
sentInquiries: [],
addInquiry: (item, message) => {
const now = new Date().toISOString()
const msgId = `msg-sent-${Date.now()}`
const id = `sent-${Date.now()}`
const thread: InquiryMessage = {
id: msgId,
inquiryId: id,
senderType: 'tenant',
senderName: 'Admin User',
body: message,
attachments: [],
createdAt: now,
}
const inquiry: Inquiry = {
id,
organizationId: 'org-wincasa',
propertyId: item.propertyId ?? 'unknown',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyAddress: item.propertyTitle,
propertyManagerName: 'Verwalter',
propertyManagerCompany: '',
subject: 'Anfrage: ' + item.propertyTitle,
message,
status: 'new',
unreadCount: 0,
isRead: true,
matchScore: item.matchScore,
createdAt: now,
updatedAt: now,
thread: [thread],
}
set(state => ({ sentInquiries: [inquiry, ...state.sentInquiries] }))
},
})) }))