feat: F030 Anfragencenter — Aktive & Latente Anfragen + Angebot-Wizard

Neuer Menüpunkt «Anfragencenter» (ehemals «Eingehende Bedarfe»):

Aktive Anfragen:
- Split-View: Anfrageliste (Liste/Grid-Toggle) + Chat-Detailansicht
- Chat-Verlauf mit Sender-Styling (Mieter links, Supply rechts)
- Antwortformular mit Betreff, Nachricht, Anhänge, Statuswechsel
- Zugehörige Property Card neben dem Chat

Latente Anfragen:
- Drei-Spalten-Layout: Need-Liste | Need-Detail | Eigene Objekte
- Need Cards mit AI-Zusammenfassung, Must-Haves, Präferenzen
- Eigene Portfolio-Objekte sortiert nach deterministischem Match-Score
- Checkbox-Selektion für Angebotsauswahl

Angebot-Wizard (4 Schritte):
- Schritt 1: Objekte auswählen (mit Score-Vorschau)
- Schritt 2: PDF-Vorschau + editierbare Textfelder
- Schritt 3: Angebot prüfen & bestätigen
- Schritt 4: Nachricht an Suchenden mit KI-generierter Mail

Architektur:
- Domain: Inquiry, LatentNeed, OfferDraft Types
- Provider: IInquiryProvider, ILatentNeedProvider, IOfferProvider + Mockups
- Services: inquiryService, latentNeedService, offerService
- Hooks: useInquiries, useLatentNeeds, useOffers (TanStack Query)
- Store: offerWizardStore (Zustand, lokaler Wizard-State)
- 27 neue Komponenten, alle Loading/Empty/Error States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-18 18:03:05 +02:00
parent 05ac0083b2
commit c4c29ef7d3
51 changed files with 4000 additions and 1 deletions
+18
View File
@@ -421,4 +421,22 @@ export const aiService = {
await new Promise(r => setTimeout(r, 1800))
return { data: buildMockDecisionBrief(shortlistId) }
},
async generateOfferEmail(payload: {
needTitle: string
properties: string[]
matchScores: number[]
}): Promise<{ data: { subject: string; body: string }; error: null }> {
await new Promise(r => setTimeout(r, 1200))
return {
data: {
subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${payload.needTitle}`,
body:
`Sehr geehrte Damen und Herren,\n\nvielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgende passende Gewerbeobjekte aus unserem Portfolio:\n\n` +
payload.properties.map((p, i) => `${p} (Match-Score: ${payload.matchScores[i]}%)`).join('\n') +
`\n\nGerne arrangieren wir Besichtigungstermine für die genannten Objekte und stehen für alle weiteren Fragen zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
},
error: null,
}
},
}
+63
View File
@@ -0,0 +1,63 @@
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
import type { InquiryFilters } from '../provider/IInquiryProvider'
import type { Inquiry, InquiryStatus, Attachment } from '../domain/inquiry'
const provider = MockupInquiryProvider
export interface InquiryReplyPayload {
subject?: string
body: string
attachments?: Attachment[]
}
type ServiceResult<T> = { data: T; error: null } | { data: null; error: string }
export const inquiryService = {
async getActiveInquiries(filters?: InquiryFilters): Promise<ServiceResult<Inquiry[]>> {
try {
const data = await provider.getAll(filters)
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
async getInquiryById(id: string): Promise<ServiceResult<Inquiry | null>> {
try {
const data = await provider.getById(id)
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
async sendInquiryReply(
inquiryId: string,
payload: InquiryReplyPayload,
): Promise<ServiceResult<Inquiry>> {
try {
const data = await provider.addMessage(inquiryId, {
senderType: 'supply_user',
senderName: 'Wincasa AG',
subject: payload.subject,
body: payload.body,
attachments: payload.attachments ?? [],
})
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
async updateInquiryStatus(
id: string,
status: InquiryStatus,
): Promise<ServiceResult<Inquiry>> {
try {
const data = await provider.updateStatus(id, status)
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
}
+27
View File
@@ -0,0 +1,27 @@
import { MockupLatentNeedProvider } from '../provider/MockupLatentNeedProvider'
import type { LatentNeedFilters } from '../provider/ILatentNeedProvider'
import type { LatentNeed } from '../domain/latentNeed'
const provider = MockupLatentNeedProvider
type ServiceResult<T> = { data: T; error: null } | { data: null; error: string }
export const latentNeedService = {
async getPublicNeeds(filters?: LatentNeedFilters): Promise<ServiceResult<LatentNeed[]>> {
try {
const data = await provider.getAll(filters)
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
async getNeedById(id: string): Promise<ServiceResult<LatentNeed | null>> {
try {
const data = await provider.getById(id)
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
}
+135
View File
@@ -0,0 +1,135 @@
import { MockupOfferProvider } from '../provider/MockupOfferProvider'
import type { OfferDraft, OfferEditableField } from '../domain/offer'
import type { AssetType } from '../domain/enums'
const provider = MockupOfferProvider
type ServiceResult<T> = { data: T; error: null } | { data: null; error: string }
function assetTypeLabel(at: AssetType): string {
const map: Record<string, string> = {
OFFICE: 'Bürofläche',
RETAIL: 'Retailfläche',
LOGISTICS: 'Logistikfläche',
LIGHT_INDUSTRIAL: 'Gewerbefläche',
PRODUCTION: 'Produktionsfläche',
GASTRO: 'Gastronomiefläche',
MIXED: 'Mischnutzungsfläche',
UNKNOWN: 'Fläche',
}
return map[at] ?? 'Fläche'
}
function buildEditableFields(
needTitle: string,
location: string,
assetType: AssetType,
sizeRange: { min: number; max: number },
): OfferEditableField[] {
return [
{
id: 'recipient_salutation',
label: 'Anrede',
value: 'Sehr geehrte Damen und Herren',
fieldType: 'text',
},
{
id: 'offer_intro',
label: 'Einleitungstext',
value:
`vielen Dank für Ihr Interesse an einer ${assetTypeLabel(assetType)} im Raum ${location}. ` +
`Basierend auf Ihrem Profil "${needTitle}" haben wir Ihnen passende Objekte zusammengestellt.`,
fieldType: 'textarea',
},
{
id: 'highlighted_criteria',
label: 'Hervorgehobene Kriterien',
value: `Flächenbedarf ${sizeRange.min}${sizeRange.max} m², Standortwunsch ${location}.`,
fieldType: 'textarea',
},
{
id: 'next_steps',
label: 'Nächste Schritte',
value:
'Gerne arrangieren wir Besichtigungstermine für die aufgeführten Objekte und stehen für Detailfragen zur Verfügung.',
fieldType: 'textarea',
},
{
id: 'closing',
label: 'Schlussformel',
value: 'Freundliche Grüsse\nWincasa AG',
fieldType: 'textarea',
},
]
}
export const offerService = {
async createOfferDraft(
needId: string,
selectedPropertyIds: string[],
needTitle: string,
location: string,
assetType: AssetType,
sizeRange: { min: number; max: number },
): Promise<ServiceResult<OfferDraft>> {
try {
const editableFields = buildEditableFields(needTitle, location, assetType, sizeRange)
const data = await provider.create({
needId,
selectedPropertyIds,
subject: `Passende Gewerbeflächen zu Ihrer Anfrage: ${needTitle}`,
message:
'Sehr geehrte Damen und Herren,\n\nbitte finden Sie anbei unser Angebot mit den passenden Objekten.\n\nFreundliche Grüsse\nWincasa AG',
editableFields,
})
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
async updateOfferField(
offerDraftId: string,
fieldId: string,
value: string,
): Promise<ServiceResult<OfferDraft>> {
try {
const data = await provider.updateField(offerDraftId, fieldId, value)
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
async markOfferChecked(offerDraftId: string): Promise<ServiceResult<OfferDraft>> {
try {
const data = await provider.updateStatus(offerDraftId, 'checked')
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
async generatePdfPreview(
offerDraftId: string,
): Promise<ServiceResult<{ previewReady: true; pdfUrl: string }>> {
try {
await new Promise(r => setTimeout(r, 900))
const pdfUrl = `mock://offers/${offerDraftId}.pdf`
await provider.setPdfUrl(offerDraftId, pdfUrl)
return { data: { previewReady: true, pdfUrl }, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
async sendOffer(offerDraftId: string): Promise<ServiceResult<OfferDraft>> {
try {
await new Promise(r => setTimeout(r, 700))
const data = await provider.updateStatus(offerDraftId, 'sent')
return { data, error: null }
} catch (e) {
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
}
},
}