diff --git a/src/components/anfragencenter/ActiveInquiriesTab.tsx b/src/components/anfragencenter/ActiveInquiriesTab.tsx index 7d39749..66114ad 100644 --- a/src/components/anfragencenter/ActiveInquiriesTab.tsx +++ b/src/components/anfragencenter/ActiveInquiriesTab.tsx @@ -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(null) const [view, setView] = useState(loadViewMode()) const theme = useTheme() @@ -41,8 +48,8 @@ export function ActiveInquiriesTab() { return ( } - 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.'} /> ) } diff --git a/src/components/anfragencenter/OfferChatComposer.tsx b/src/components/anfragencenter/OfferChatComposer.tsx index 828f2f9..c1a9849 100644 --- a/src/components/anfragencenter/OfferChatComposer.tsx +++ b/src/components/anfragencenter/OfferChatComposer.tsx @@ -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() } diff --git a/src/components/match-detail/InquiryQuickDialog.tsx b/src/components/match-detail/InquiryQuickDialog.tsx index a9df729..b584021 100644 --- a/src/components/match-detail/InquiryQuickDialog.tsx +++ b/src/components/match-detail/InquiryQuickDialog.tsx @@ -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' }) } diff --git a/src/domain/inquiry.ts b/src/domain/inquiry.ts index dcfe4dd..ecbdf9d 100644 --- a/src/domain/inquiry.ts +++ b/src/domain/inquiry.ts @@ -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 diff --git a/src/domain/latentNeed.ts b/src/domain/latentNeed.ts index 9f84f33..7498a4c 100644 --- a/src/domain/latentNeed.ts +++ b/src/domain/latentNeed.ts @@ -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 } diff --git a/src/hooks/useInquiries.ts b/src/hooks/useInquiries.ts index ff8d5c3..fdc75a7 100644 --- a/src/hooks/useInquiries.ts +++ b/src/hooks/useInquiries.ts @@ -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({ diff --git a/src/mock-data/demandInquiries.ts b/src/mock-data/demandInquiries.ts index 92f669a..582bbad 100644 --- a/src/mock-data/demandInquiries.ts +++ b/src/mock-data/demandInquiries.ts @@ -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', + }, + ], + }, ] diff --git a/src/pages/demand/Anfragen.tsx b/src/pages/demand/Anfragen.tsx index 3555570..c1e2306 100644 --- a/src/pages/demand/Anfragen.tsx +++ b/src/pages/demand/Anfragen.tsx @@ -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' @@ -18,10 +17,9 @@ import { useSessionStore } from '../../stores/sessionStore' // ── Config ──────────────────────────────────────────────────────────────────── const FILTER_TABS = [ - { key: 'all', label: 'Alle' }, - { key: 'new', label: 'Neu' }, - { key: 'in_progress', label: 'Aktiv' }, - { key: 'answered', label: 'Beantwortet' }, + { key: 'all', label: 'Alle' }, + { 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( - preselectedId ?? mockDemandInquiries[0]?.id ?? null - ) + const [selectedId, setSelectedId] = useState(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(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() { /> {FILTER_TABS.map(tab => ( - setStatusFilter(tab.key)} + 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() { )} + {/* Angebot: enthaltene Objekte */} + {offeredProperties.length > 0 && ( + + + Enthaltene Objekte ({offeredProperties.length}) + + + {offeredProperties.map(p => p && ( + } + 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 } }} + /> + ))} + + + )} + {/* Thread */} {selected.thread.map(msg => ( diff --git a/src/pages/supply/Anfragencenter.tsx b/src/pages/supply/Anfragencenter.tsx index 5081de4..6fa8491 100644 --- a/src/pages/supply/Anfragencenter.tsx +++ b/src/pages/supply/Anfragencenter.tsx @@ -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 ( @@ -21,12 +23,20 @@ export default function Anfragencenter() { }} > + - {tab === 0 && } - {tab === 1 && } + {tab === 0 && } + {tab === 1 && ( + + )} + {tab === 2 && } diff --git a/src/provider/IInquiryProvider.ts b/src/provider/IInquiryProvider.ts index 8dfec92..bb375bb 100644 --- a/src/provider/IInquiryProvider.ts +++ b/src/provider/IInquiryProvider.ts @@ -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 getById(id: string): Promise + createInquiry(input: CreateInquiryInput): Promise + createOffer(input: CreateOfferInput): Promise updateStatus(id: string, status: InquiryStatus): Promise markThreadAsRead(id: string): Promise - getUnreadCount(): Promise + getUnreadCount(filters?: InquiryFilters): Promise addMessage( inquiryId: string, msg: Omit, diff --git a/src/provider/MockupInquiryProvider.ts b/src/provider/MockupInquiryProvider.ts index 633ca7e..7e71ac9 100644 --- a/src/provider/MockupInquiryProvider.ts +++ b/src/provider/MockupInquiryProvider.ts @@ -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 { - 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 { return store.find(i => i.id === id) ?? null }, + async createInquiry(input: CreateInquiryInput): Promise { + 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 { + 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 { 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 { - return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0) + async getUnreadCount(filters?: InquiryFilters): Promise { + 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] diff --git a/src/services/inquiryService.ts b/src/services/inquiryService.ts index f2dc1c4..d6b1b6c 100644 --- a/src/services/inquiryService.ts +++ b/src/services/inquiryService.ts @@ -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> { + try { + const data = await provider.createInquiry(input) + return { data } + } catch (err) { + throwServiceError(err) + } + }, + + async createOffer(input: CreateOfferInput): Promise> { + try { + const data = await provider.createOffer(input) + return { data } + } catch (err) { + throwServiceError(err) + } + }, + async sendInquiryReply( inquiryId: string, payload: InquiryReplyPayload, ): Promise> { 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> { + async getUnreadCount(filters?: InquiryFilters): Promise> { try { - const data = await provider.getUnreadCount() + const data = await provider.getUnreadCount(filters) return { data } } catch (err) { throwServiceError(err) diff --git a/src/stores/inquiryStore.ts b/src/stores/inquiryStore.ts index 82740be..f52b35a 100644 --- a/src/stores/inquiryStore.ts +++ b/src/stores/inquiryStore.ts @@ -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((set) => ({ @@ -29,44 +25,4 @@ export const useInquiryStore = create((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] })) - }, }))