Compare commits
3 Commits
8cb6f21581
...
be5523bcee
| Author | SHA1 | Date | |
|---|---|---|---|
| be5523bcee | |||
| a8b54af0b8 | |||
| 6206447dae |
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
|
||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||
import type { InquiryFilters } from '../../provider/IInquiryProvider'
|
||||
import { EmptyState } from '../ui'
|
||||
import { InquiryList } from './InquiryList'
|
||||
import { InquiryCardGrid } from './InquiryCardGrid'
|
||||
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
|
||||
return stored === 'grid' ? 'grid' : 'list'
|
||||
}
|
||||
|
||||
export function ActiveInquiriesTab() {
|
||||
const { data: inquiries = [], isLoading } = useActiveInquiries()
|
||||
interface Props {
|
||||
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 [view, setView] = useState<ViewMode>(loadViewMode())
|
||||
const theme = useTheme()
|
||||
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Inbox size={40} />}
|
||||
title="Keine aktiven Anfragen"
|
||||
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
|
||||
title={emptyTitle ?? 'Keine aktiven Anfragen'}
|
||||
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 { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||
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 { AiOfferEmailButton } from './AiOfferEmailButton'
|
||||
import { mockProperties } from '../../mock-data/properties'
|
||||
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
|
||||
const reset = useOfferWizardStore(s => s.reset)
|
||||
|
||||
const sendOffer = useSendOffer()
|
||||
const createOffer = useCreateOffer()
|
||||
const { data: latentNeed } = useLatentNeedById(needId)
|
||||
const currentUser = useSessionStore(s => s.currentUser)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
const propertyTitles = selectedPropertyIds.map(id => {
|
||||
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
|
||||
showToast(`Fehler: ${res.error}`, 'error')
|
||||
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')
|
||||
reset()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
import { Send } from 'lucide-react'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useMoveStage } from '../../hooks/usePipeline'
|
||||
import { useCreateInquiry } from '../../hooks/useInquiries'
|
||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||
@@ -19,7 +21,8 @@ export function InquiryQuickDialog() {
|
||||
const dialogOpen = useInquiryStore(s => s.dialogOpen)
|
||||
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||
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 [message, setMessage] = useState('')
|
||||
@@ -32,7 +35,18 @@ export function InquiryQuickDialog() {
|
||||
|
||||
function handleSend() {
|
||||
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) {
|
||||
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||
}
|
||||
|
||||
@@ -20,10 +20,20 @@ export interface InquiryMessage {
|
||||
|
||||
export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived'
|
||||
|
||||
/** Konversationstyp: Nachfrager-initiierte Anfrage vs. Verwaltung-initiiertes Angebot. */
|
||||
export type InquiryKind = 'INQUIRY' | 'OFFER'
|
||||
|
||||
export interface Inquiry {
|
||||
id: string
|
||||
/** Supply-Organisation (Eigentümer/Verwaltung des Objekts). */
|
||||
organizationId: string
|
||||
/** Nachfrager-Organisation — für das Demand-Postfach. */
|
||||
tenantOrgId?: string
|
||||
/** INQUIRY (Demand→Supply) oder OFFER (Supply→Demand). Default INQUIRY. */
|
||||
kind?: InquiryKind
|
||||
propertyId: string
|
||||
/** Bei OFFER: alle im Angebot enthaltenen Objekte. */
|
||||
offeredPropertyIds?: string[]
|
||||
needId?: string
|
||||
tenantName: string
|
||||
tenantCompany?: string
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface LatentNeed {
|
||||
id: string
|
||||
title: string
|
||||
tenantCompany?: string
|
||||
/** Nachfrager-Organisation — Empfänger eines Angebots (Demo-Default-Routing: org-mobimo). */
|
||||
tenantOrgId?: string
|
||||
assetType: AssetType
|
||||
desiredLocation: string
|
||||
sizeRange: { min: number; max: number }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { inquiryService } from '../services/inquiryService'
|
||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||
import type { Attachment } from '../domain/inquiry'
|
||||
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
|
||||
import type { Attachment, InquiryMessage } from '../domain/inquiry'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
export function useActiveInquiries(filters?: InquiryFilters) {
|
||||
@@ -29,7 +29,7 @@ export function useSendInquiryReply() {
|
||||
payload,
|
||||
}: {
|
||||
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),
|
||||
onSuccess: (_data, { 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() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -5,6 +5,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-001',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-technopark',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -39,6 +41,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-002',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-hardturmpark',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -73,6 +77,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-003',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-sihlcity',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -130,6 +136,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-004',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-bahnhofzug',
|
||||
tenantName: 'Admin User',
|
||||
tenantCompany: 'Mobimo Management AG',
|
||||
@@ -186,6 +194,8 @@ export const mockDemandInquiries: Inquiry[] = [
|
||||
{
|
||||
id: 'dinq-005',
|
||||
organizationId: 'org-wincasa',
|
||||
tenantOrgId: 'org-mobimo',
|
||||
kind: 'INQUIRY',
|
||||
propertyId: 'prop-dreispitz',
|
||||
tenantName: 'Admin User',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
InputAdornment, Alert,
|
||||
} from '@mui/material'
|
||||
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
|
||||
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { useActiveInquiries, useSendInquiryReply, useMarkThreadAsRead } from '../../hooks/useInquiries'
|
||||
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 { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
|
||||
@@ -19,9 +18,8 @@ import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'new', label: 'Neu' },
|
||||
{ key: 'in_progress', label: 'Aktiv' },
|
||||
{ key: 'answered', label: 'Beantwortet' },
|
||||
{ key: 'INQUIRY', label: 'Gesendet' },
|
||||
{ key: 'OFFER', label: 'Erhalten' },
|
||||
]
|
||||
|
||||
// ── Anfragen page ─────────────────────────────────────────────────────────────
|
||||
@@ -36,20 +34,23 @@ export default function Anfragen() {
|
||||
const preselectedId = searchParams.get('inquiry')
|
||||
|
||||
const { currentUser } = useSessionStore()
|
||||
const storeInquiries = useInquiryStore(s => s.sentInquiries)
|
||||
const [inquiries, setInquiries] = useState(mockDemandInquiries)
|
||||
const allInquiries = [...storeInquiries, ...inquiries]
|
||||
const { data: allInquiries = [] } = useActiveInquiries({ tenantOrgId: currentUser?.organizationId })
|
||||
const sendReply = useSendInquiryReply()
|
||||
const markRead = useMarkThreadAsRead()
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
preselectedId ?? mockDemandInquiries[0]?.id ?? null
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(preselectedId ?? null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [kindFilter, setKindFilter] = useState('all')
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId)
|
||||
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(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 q = search.toLowerCase()
|
||||
const matchesSearch = !q ||
|
||||
@@ -59,12 +60,14 @@ export default function Anfragen() {
|
||||
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
|
||||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
|
||||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
|
||||
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
const matchesKind = kindFilter === 'all' || (inq.kind ?? 'INQUIRY') === kindFilter
|
||||
return matchesSearch && matchesKind
|
||||
})
|
||||
|
||||
const selected = allInquiries.find(i => i.id === selectedId) ?? null
|
||||
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
|
||||
const linkedPipelineItem = selected?.propertyId
|
||||
@@ -86,7 +89,7 @@ export default function Anfragen() {
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setSelectedId(id)
|
||||
setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i))
|
||||
markRead.mutate(id)
|
||||
setMobileShowChat(true)
|
||||
setKiAlert(null)
|
||||
}
|
||||
@@ -95,20 +98,10 @@ export default function Anfragen() {
|
||||
if (!replyText.trim() || !selectedId) return
|
||||
const text = replyText.trim()
|
||||
|
||||
const msg: InquiryMessage = {
|
||||
id: `msg-${Date.now()}`,
|
||||
sendReply.mutate({
|
||||
inquiryId: selectedId,
|
||||
senderType: 'tenant',
|
||||
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
|
||||
))
|
||||
payload: { body: text, senderType: 'tenant', senderName: currentUser?.name ?? 'Sie' },
|
||||
})
|
||||
setReplyText('')
|
||||
|
||||
// KI: detect stage transition from message content
|
||||
@@ -160,13 +153,13 @@ export default function Anfragen() {
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{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={{
|
||||
height: 22, fontSize: '0.7rem', cursor: 'pointer',
|
||||
bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle,
|
||||
color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary,
|
||||
fontWeight: statusFilter === tab.key ? 700 : 400,
|
||||
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted },
|
||||
bgcolor: kindFilter === tab.key ? 'primary.main' : DS_BG.subtle,
|
||||
color: kindFilter === tab.key ? 'white' : DS_TEXT.secondary,
|
||||
fontWeight: kindFilter === tab.key ? 700 : 400,
|
||||
'&:hover': { bgcolor: kindFilter === tab.key ? 'primary.dark' : DS_BG.muted },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -291,6 +284,27 @@ export default function Anfragen() {
|
||||
</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 */}
|
||||
<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 => (
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
||||
import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
export default function Anfragencenter() {
|
||||
const location = useLocation()
|
||||
const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0
|
||||
const [tab, setTab] = useState(initialTab)
|
||||
const orgId = useSessionStore(s => s.currentUser?.organizationId)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<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="Gesendet" />
|
||||
<Tab label="Latente Anfragen" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
{tab === 0 && <ActiveInquiriesTab />}
|
||||
{tab === 1 && <LatentInquiriesTab />}
|
||||
{tab === 0 && <ActiveInquiriesTab filters={{ organizationId: orgId, kind: 'INQUIRY' }} />}
|
||||
{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>
|
||||
<OfferWizard />
|
||||
</Box>
|
||||
|
||||
@@ -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 {
|
||||
status?: InquiryStatus
|
||||
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 {
|
||||
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
|
||||
getById(id: string): Promise<Inquiry | null>
|
||||
createInquiry(input: CreateInquiryInput): Promise<Inquiry>
|
||||
createOffer(input: CreateOfferInput): Promise<Inquiry>
|
||||
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
|
||||
markThreadAsRead(id: string): Promise<Inquiry>
|
||||
getUnreadCount(): Promise<number>
|
||||
getUnreadCount(filters?: InquiryFilters): Promise<number>
|
||||
addMessage(
|
||||
inquiryId: string,
|
||||
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
||||
|
||||
@@ -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 { 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 = {
|
||||
async getAll(filters?: InquiryFilters): Promise<Inquiry[]> {
|
||||
let results = [...store]
|
||||
if (filters?.status) results = results.filter(i => i.status === filters.status)
|
||||
if (filters?.propertyId) results = results.filter(i => i.propertyId === filters.propertyId)
|
||||
if (filters?.organizationId) results = results.filter(i => i.organizationId === filters.organizationId)
|
||||
return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
return applyFilters([...store], filters).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
|
||||
},
|
||||
async getById(id: string): Promise<Inquiry | 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> {
|
||||
const idx = store.findIndex(i => i.id === id)
|
||||
if (idx === -1) throw new Error(`Inquiry ${id} not found`)
|
||||
@@ -33,8 +112,8 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
||||
}
|
||||
return store[idx]
|
||||
},
|
||||
async getUnreadCount(): Promise<number> {
|
||||
return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
||||
async getUnreadCount(filters?: InquiryFilters): Promise<number> {
|
||||
return applyFilters([...store], filters).reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
||||
},
|
||||
async addMessage(
|
||||
inquiryId: string,
|
||||
@@ -51,6 +130,10 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
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,
|
||||
}
|
||||
return store[idx]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||
import type { Inquiry, Attachment } from '../domain/inquiry'
|
||||
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
|
||||
import type { Inquiry, Attachment, InquiryMessage } from '../domain/inquiry'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { throwServiceError } from './errors'
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface InquiryReplyPayload {
|
||||
subject?: string
|
||||
body: string
|
||||
attachments?: Attachment[]
|
||||
senderType?: InquiryMessage['senderType'] // default 'supply_user'
|
||||
senderName?: string
|
||||
}
|
||||
|
||||
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(
|
||||
inquiryId: string,
|
||||
payload: InquiryReplyPayload,
|
||||
): Promise<ItemResponse<Inquiry>> {
|
||||
try {
|
||||
const data = await provider.addMessage(inquiryId, {
|
||||
senderType: 'supply_user',
|
||||
senderName: 'Wincasa AG',
|
||||
senderType: payload.senderType ?? 'supply_user',
|
||||
senderName: payload.senderName ?? 'Wincasa AG',
|
||||
subject: payload.subject,
|
||||
body: payload.body,
|
||||
attachments: payload.attachments ?? [],
|
||||
@@ -58,9 +78,9 @@ export const inquiryService = {
|
||||
}
|
||||
},
|
||||
|
||||
async getUnreadCount(): Promise<ItemResponse<number>> {
|
||||
async getUnreadCount(filters?: InquiryFilters): Promise<ItemResponse<number>> {
|
||||
try {
|
||||
const data = await provider.getUnreadCount()
|
||||
const data = await provider.getUnreadCount(filters)
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NeedFilters } from '../provider/INeedProvider'
|
||||
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { throwServiceError } from './errors'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
|
||||
const provider = MockupNeedProvider
|
||||
|
||||
@@ -25,7 +26,10 @@ export const needService = {
|
||||
},
|
||||
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
|
||||
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 }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
propertyTitle: string
|
||||
location: string
|
||||
@@ -13,15 +14,10 @@ export interface PendingInquiry {
|
||||
}
|
||||
|
||||
interface InquiryStore {
|
||||
// Dialog state
|
||||
dialogOpen: boolean
|
||||
pendingInquiry: PendingInquiry | null
|
||||
openInquiryDialog: (item: PendingInquiry) => 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) => ({
|
||||
@@ -29,44 +25,4 @@ export const useInquiryStore = create<InquiryStore>((set) => ({
|
||||
pendingInquiry: null,
|
||||
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
|
||||
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] }))
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user