feat: Anfragencenter Parts A–E — read/unread, prep wizard, offer flow, report preview
- Part A: Replace InquiryStatus badges with WhatsApp-style unread indicators (isRead, unreadCount, lastReadAt on Inquiry; markThreadAsRead in service + hook) - Part B: RelatedPropertyCardPanel — reposition "Objekt ansehen", add "Weitere Matches" section via matchService.getAdditionalMatchesForInquiry - Part C: PreparationWizard 4-step dialog — property selection, report generation progress, field editing + ReportObjectFieldSelector, PDF preview + finalize - Part D: OfferCreationWizard 4-step dialog triggered from first tenant message — data capture, field editing, viewing appointments, PDF generation + attach to reply - Part E: LatentInquiryReportPreview (2-page A4 doc), ReportObjectFieldSelector (5 accordion groups, 35+ optional fields), mapImageUrl on Property domain Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection, ReportEditableField } from '../domain/inquiryReport'
|
||||
import { mockInquiryReportDrafts } from '../mock-data/inquiryReportDrafts'
|
||||
|
||||
const store: InquiryPreparationReportDraft[] = mockInquiryReportDrafts.map(d => ({ ...d }))
|
||||
|
||||
const DEFAULT_EDITABLE_FIELDS: ReportEditableField[] = [
|
||||
{ id: 'intro', label: 'Einleitung', value: 'Sehr geehrte Damen und Herren\n\nGerne präsentieren wir Ihnen passende Objekte aus unserem Portfolio.', fieldType: 'textarea' },
|
||||
{ id: 'highlights', label: 'Besonderheiten', value: 'Die ausgewählten Objekte entsprechen Ihrem Anforderungsprofil.', fieldType: 'textarea' },
|
||||
{ id: 'next_steps', label: 'Nächste Schritte', value: 'Für einen Besichtigungstermin stehen wir gerne zur Verfügung.', fieldType: 'textarea' },
|
||||
]
|
||||
|
||||
const DEFAULT_FIELD_SELECTION: Omit<ReportObjectFieldSelection, 'propertyId'> = {
|
||||
mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'],
|
||||
selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'availabilityDate', 'leaseTerm', 'description'],
|
||||
}
|
||||
|
||||
export const inquiryReportService = {
|
||||
async getByInquiry(inquiryId: string): Promise<InquiryPreparationReportDraft | null> {
|
||||
return store.find(d => d.inquiryId === inquiryId) ?? null
|
||||
},
|
||||
|
||||
async create(inquiryId: string, selectedPropertyIds: string[]): Promise<InquiryPreparationReportDraft> {
|
||||
const existing = store.find(d => d.inquiryId === inquiryId)
|
||||
if (existing) return existing
|
||||
const draft: InquiryPreparationReportDraft = {
|
||||
id: crypto.randomUUID(),
|
||||
inquiryId,
|
||||
selectedPropertyIds,
|
||||
fieldSelections: selectedPropertyIds.map(propertyId => ({
|
||||
propertyId,
|
||||
...DEFAULT_FIELD_SELECTION,
|
||||
})),
|
||||
marketSignalPropertyId: selectedPropertyIds[0],
|
||||
editableFields: DEFAULT_EDITABLE_FIELDS.map(f => ({ ...f })),
|
||||
status: 'draft',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
store.push(draft)
|
||||
return draft
|
||||
},
|
||||
|
||||
async update(draftId: string, data: Partial<InquiryPreparationReportDraft>): Promise<InquiryPreparationReportDraft> {
|
||||
const idx = store.findIndex(d => d.id === draftId)
|
||||
if (idx === -1) throw new Error(`Draft ${draftId} not found`)
|
||||
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||
return store[idx]
|
||||
},
|
||||
|
||||
async finalize(draftId: string): Promise<InquiryPreparationReportDraft> {
|
||||
const idx = store.findIndex(d => d.id === draftId)
|
||||
if (idx === -1) throw new Error(`Draft ${draftId} not found`)
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
status: 'finalized',
|
||||
pdfUrl: `/mock-reports/${draftId}.pdf`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
return store[idx]
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
||||
import type { InquiryFilters } from '../provider/IInquiryProvider'
|
||||
import type { Inquiry, InquiryStatus, Attachment } from '../domain/inquiry'
|
||||
import type { Inquiry, Attachment } from '../domain/inquiry'
|
||||
|
||||
const provider = MockupInquiryProvider
|
||||
|
||||
@@ -49,12 +49,18 @@ export const inquiryService = {
|
||||
}
|
||||
},
|
||||
|
||||
async updateInquiryStatus(
|
||||
id: string,
|
||||
status: InquiryStatus,
|
||||
): Promise<ServiceResult<Inquiry>> {
|
||||
async markThreadAsRead(id: string): Promise<ServiceResult<Inquiry>> {
|
||||
try {
|
||||
const data = await provider.updateStatus(id, status)
|
||||
const data = await provider.markThreadAsRead(id)
|
||||
return { data, error: null }
|
||||
} catch (e) {
|
||||
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
||||
}
|
||||
},
|
||||
|
||||
async getUnreadCount(): Promise<ServiceResult<number>> {
|
||||
try {
|
||||
const data = await provider.getUnreadCount()
|
||||
return { data, error: null }
|
||||
} catch (e) {
|
||||
return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
|
||||
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
||||
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
|
||||
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
|
||||
import type { MatchFilters } from '../provider/IMatchProvider'
|
||||
import type { Match, PropertyNeedMatch } from '../domain/match'
|
||||
import type { Need } from '../domain/need'
|
||||
import type { Property } from '../domain/property'
|
||||
import type { StrongMatchItem } from '../domain/dashboard'
|
||||
import type { ScoreBreakdown } from '../domain/match'
|
||||
import type { AdditionalPropertyMatch } from '../domain/additionalMatch'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
|
||||
import { ResultType } from '../domain/enums'
|
||||
|
||||
const provider = MockupMatchProvider
|
||||
|
||||
@@ -98,6 +101,41 @@ export const matchService = {
|
||||
.filter((x): x is PropertyNeedMatch => x !== null)
|
||||
},
|
||||
|
||||
async getAdditionalMatchesForInquiry(
|
||||
inquiryId: string,
|
||||
opts?: { minScore?: number; excludePropertyId?: string },
|
||||
): Promise<AdditionalPropertyMatch[]> {
|
||||
const minScore = opts?.minScore ?? 80
|
||||
const inquiry = await MockupInquiryProvider.getById(inquiryId)
|
||||
if (!inquiry) return []
|
||||
const excludeId = opts?.excludePropertyId ?? inquiry.propertyId
|
||||
const [allProperties, allMatches] = await Promise.all([
|
||||
MockupPropertyProvider.getAll(),
|
||||
provider.getAll(),
|
||||
])
|
||||
const portfolioProperties = allProperties.filter(
|
||||
p => p.resultType === ResultType.VERIFIED_PORTFOLIO && p.id !== excludeId,
|
||||
)
|
||||
return portfolioProperties
|
||||
.map(p => {
|
||||
const match = allMatches.find(m => m.propertyId === p.id)
|
||||
const score = match?.matchScore ?? 0
|
||||
return { property: p, score }
|
||||
})
|
||||
.filter(({ score }) => score >= minScore)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5)
|
||||
.map(({ property: p, score }) => ({
|
||||
propertyId: p.id,
|
||||
title: p.title,
|
||||
location: `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`,
|
||||
areaSqm: p.areaSqm,
|
||||
rentPricePerSqm: p.rentPricePerSqm,
|
||||
matchScore: score,
|
||||
imageUrl: p.images?.[0],
|
||||
}))
|
||||
},
|
||||
|
||||
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
|
||||
const [matches, properties] = await Promise.all([
|
||||
provider.getAll(),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { OfferReportDraft, OfferEditableField } from '../domain/offerReport'
|
||||
import { mockOfferReportDrafts } from '../mock-data/offerReportDrafts'
|
||||
|
||||
const store: OfferReportDraft[] = mockOfferReportDrafts.map(d => ({ ...d }))
|
||||
|
||||
function buildDefaultFields(tenantName: string, propertyTitle: string): OfferEditableField[] {
|
||||
return [
|
||||
{ id: 'recipient_salutation', label: 'Anrede / Einleitung', value: `Sehr geehrte(r) ${tenantName}`, fieldType: 'text' },
|
||||
{ id: 'offer_intro', label: 'Angebotsbeschreibung', value: `Wir freuen uns, Ihnen folgendes Angebot für die Liegenschaft «${propertyTitle}» zu unterbreiten.`, fieldType: 'textarea' },
|
||||
{ id: 'highlighted_criteria', label: 'Highlights der Liegenschaft', value: 'Exzellente Lage · Modernster Ausbaustandard · Flexible Mietkonditionen', fieldType: 'textarea' },
|
||||
{ id: 'viewing_intro', label: 'Besichtigungstermine', value: 'Gerne laden wir Sie zu einer persönlichen Besichtigung ein. Folgende Termine sind verfügbar:', fieldType: 'textarea' },
|
||||
{ id: 'next_steps', label: 'Nächste Schritte', value: 'Nach Ihrer Terminbestätigung erhalten Sie alle weiteren Unterlagen.', fieldType: 'textarea' },
|
||||
{ id: 'closing', label: 'Abschluss', value: 'Mit freundlichen Grüssen\nWincasa AG', fieldType: 'textarea' },
|
||||
]
|
||||
}
|
||||
|
||||
export const offerReportService = {
|
||||
async getByInquiry(inquiryId: string): Promise<OfferReportDraft | null> {
|
||||
return store.find(d => d.inquiryId === inquiryId) ?? null
|
||||
},
|
||||
|
||||
async create(inquiryId: string, propertyId: string, tenantName = '', propertyTitle = ''): Promise<OfferReportDraft> {
|
||||
const existing = store.find(d => d.inquiryId === inquiryId && d.propertyId === propertyId)
|
||||
if (existing) return existing
|
||||
const draft: OfferReportDraft = {
|
||||
id: crypto.randomUUID(),
|
||||
inquiryId,
|
||||
propertyId,
|
||||
editableFields: buildDefaultFields(tenantName, propertyTitle),
|
||||
viewingAppointments: [],
|
||||
status: 'draft',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
store.push(draft)
|
||||
return draft
|
||||
},
|
||||
|
||||
async update(draftId: string, data: Partial<OfferReportDraft>): Promise<OfferReportDraft> {
|
||||
const idx = store.findIndex(d => d.id === draftId)
|
||||
if (idx === -1) throw new Error(`Offer draft ${draftId} not found`)
|
||||
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
|
||||
return store[idx]
|
||||
},
|
||||
|
||||
async generatePdf(draftId: string): Promise<OfferReportDraft> {
|
||||
const idx = store.findIndex(d => d.id === draftId)
|
||||
if (idx === -1) throw new Error(`Offer draft ${draftId} not found`)
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
status: 'finalized',
|
||||
pdfUrl: `/mock-reports/${draftId}.pdf`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
return store[idx]
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user