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:
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user