From b4d398270f10f554e5eb872d649f9ed99469f7b0 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Tue, 19 May 2026 19:20:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Anfragencenter=20Parts=20A=E2=80=93E=20?= =?UTF-8?q?=E2=80=94=20read/unread,=20prep=20wizard,=20offer=20flow,=20rep?= =?UTF-8?q?ort=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/anfragencenter/InquiryCard.tsx | 36 +- src/components/anfragencenter/InquiryChat.tsx | 47 ++- .../anfragencenter/InquiryDetailPanel.tsx | 221 ++++++----- .../anfragencenter/InquiryListRow.tsx | 33 +- .../anfragencenter/InquiryReplyComposer.tsx | 77 ++-- .../LatentInquiryReportPreview.tsx | 260 +++++++++++++ .../anfragencenter/OfferCreationWizard.tsx | 350 ++++++++++++++++++ .../anfragencenter/PreparationWizard.tsx | 283 ++++++++++++++ .../RelatedPropertyCardPanel.tsx | 103 +++++- .../ReportObjectFieldSelector.tsx | 150 ++++++++ src/domain/additionalMatch.ts | 9 + src/domain/inquiry.ts | 5 +- src/domain/inquiryReport.ts | 37 ++ src/domain/offerReport.ts | 25 ++ src/domain/property.ts | 1 + src/hooks/useInquiries.ts | 9 +- src/mock-data/inquiries.ts | 84 +++-- src/mock-data/inquiryReportDrafts.ts | 77 ++++ src/mock-data/offerReportDrafts.ts | 44 +++ src/mock-data/properties.ts | 20 + src/provider/IInquiryProvider.ts | 2 + src/provider/MockupInquiryProvider.ts | 15 + src/services/inquiryReportService.ts | 61 +++ src/services/inquiryService.ts | 18 +- src/services/matchService.ts | 38 ++ src/services/offerReportService.ts | 57 +++ 26 files changed, 1874 insertions(+), 188 deletions(-) create mode 100644 src/components/anfragencenter/LatentInquiryReportPreview.tsx create mode 100644 src/components/anfragencenter/OfferCreationWizard.tsx create mode 100644 src/components/anfragencenter/PreparationWizard.tsx create mode 100644 src/components/anfragencenter/ReportObjectFieldSelector.tsx create mode 100644 src/domain/additionalMatch.ts create mode 100644 src/domain/inquiryReport.ts create mode 100644 src/domain/offerReport.ts create mode 100644 src/mock-data/inquiryReportDrafts.ts create mode 100644 src/mock-data/offerReportDrafts.ts create mode 100644 src/services/inquiryReportService.ts create mode 100644 src/services/offerReportService.ts diff --git a/src/components/anfragencenter/InquiryCard.tsx b/src/components/anfragencenter/InquiryCard.tsx index aa6e8bb..8395fee 100644 --- a/src/components/anfragencenter/InquiryCard.tsx +++ b/src/components/anfragencenter/InquiryCard.tsx @@ -1,7 +1,6 @@ import { Box, Paper, Typography } from '@mui/material' import { Building2 } from 'lucide-react' import type { Inquiry } from '../../domain/inquiry' -import { InquiryStatusBadge } from './InquiryStatusBadge' import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils' interface InquiryCardProps { @@ -11,6 +10,8 @@ interface InquiryCardProps { } export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) { + const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0 + return ( - + {inquiry.tenantName} {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''} - + {hasUnread && ( + 9 ? 0.75 : 0, + }} + > + {inquiry.unreadCount} + + )} void + pendingAttachment?: Attachment | null + onPendingAttachmentConsumed?: () => void } -export function InquiryChat({ inquiry }: InquiryChatProps) { +export function InquiryChat({ + inquiry, + onCreateOffer, + pendingAttachment, + onPendingAttachmentConsumed, +}: InquiryChatProps) { const endRef = useRef(null) useEffect(() => { @@ -18,12 +27,38 @@ export function InquiryChat({ inquiry }: InquiryChatProps) { return ( - {inquiry.thread.map(m => ( - + {inquiry.thread.map((m, idx) => ( + + + {idx === 0 && m.senderType === 'tenant' && onCreateOffer && ( + + + + )} + ))}
- + ) } diff --git a/src/components/anfragencenter/InquiryDetailPanel.tsx b/src/components/anfragencenter/InquiryDetailPanel.tsx index 8157ce8..cea0dcd 100644 --- a/src/components/anfragencenter/InquiryDetailPanel.tsx +++ b/src/components/anfragencenter/InquiryDetailPanel.tsx @@ -1,33 +1,28 @@ -import { - Box, - CircularProgress, - MenuItem, - Select, - Typography, - type SelectChangeEvent, -} from '@mui/material' -import { useInquiryById, useUpdateInquiryStatus } from '../../hooks/useInquiries' -import type { InquiryStatus } from '../../domain/inquiry' +import { useEffect, useState } from 'react' +import { Box, Button, CircularProgress, Typography } from '@mui/material' +import { ClipboardList } from 'lucide-react' +import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries' import { InquiryChat } from './InquiryChat' import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel' -import { InquiryStatusBadge } from './InquiryStatusBadge' -import { useToastStore } from '../../stores/toastStore' +import type { Attachment } from '../../domain/inquiry' interface InquiryDetailPanelProps { inquiryId: string } -const STATUS_OPTIONS: { value: InquiryStatus; label: string }[] = [ - { value: 'new', label: 'Neu' }, - { value: 'in_progress', label: 'In Bearbeitung' }, - { value: 'answered', label: 'Beantwortet' }, - { value: 'archived', label: 'Archiviert' }, -] - export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { const { data: inquiry, isLoading } = useInquiryById(inquiryId) - const updateStatus = useUpdateInquiryStatus() - const showToast = useToastStore(s => s.showToast) + const markRead = useMarkThreadAsRead() + const [preparationOpen, setPreparationOpen] = useState(false) + const [offerWizardOpen, setOfferWizardOpen] = useState(false) + const [pendingAttachment, setPendingAttachment] = useState(null) + + useEffect(() => { + if (inquiry && !inquiry.isRead) { + markRead.mutate(inquiry.id) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inquiry?.id]) if (isLoading) { return ( @@ -47,74 +42,130 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { ) } - const handleStatusChange = async (e: SelectChangeEvent) => { - const result = await updateStatus.mutateAsync({ - id: inquiry.id, - status: e.target.value as InquiryStatus, - }) - if (result.error) { - showToast(`Fehler: ${result.error}`, 'error') - } else { - showToast('Status aktualisiert', 'success') - } - } + const isLatentInquiry = !!inquiry.needId return ( - - - - - {inquiry.tenantName} - {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''} - {inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''} - - - {inquiry.subject} - - - - + + + {inquiry.tenantName} + {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''} + {inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''} + + + {inquiry.subject} + + + {isLatentInquiry && ( + + )} + + + + setOfferWizardOpen(true)} + pendingAttachment={pendingAttachment} + onPendingAttachmentConsumed={() => setPendingAttachment(null)} + /> + + + + - - - - - - - + {preparationOpen && ( + setPreparationOpen(false)} + /> + )} + + {offerWizardOpen && ( + setOfferWizardOpen(false)} + onAttach={(label) => { + setPendingAttachment({ + id: crypto.randomUUID(), + fileName: label, + fileType: 'application/pdf', + generated: true, + }) + setOfferWizardOpen(false) + }} + /> + )} + + ) +} + +// Lazy-loaded wizard wrappers to avoid circular imports at module load time +import { lazy, Suspense } from 'react' +const PreparationWizardComponent = lazy(() => + import('./PreparationWizard').then(m => ({ default: m.PreparationWizard })) +) +const OfferCreationWizardComponent = lazy(() => + import('./OfferCreationWizard').then(m => ({ default: m.OfferCreationWizard })) +) + +import type { Inquiry } from '../../domain/inquiry' + +function PreparationWizardLazy(props: { inquiryId: string; inquiry: Inquiry; onClose: () => void }) { + return ( + + + + ) +} + +function OfferCreationWizardLazy(props: { + inquiryId: string + propertyId: string + tenantName: string + onClose: () => void + onAttach: (label: string) => void +}) { + return ( + + + ) } diff --git a/src/components/anfragencenter/InquiryListRow.tsx b/src/components/anfragencenter/InquiryListRow.tsx index 101b2a4..775eb53 100644 --- a/src/components/anfragencenter/InquiryListRow.tsx +++ b/src/components/anfragencenter/InquiryListRow.tsx @@ -1,7 +1,6 @@ import { Box, Typography } from '@mui/material' import { Building2 } from 'lucide-react' import type { Inquiry } from '../../domain/inquiry' -import { InquiryStatusBadge } from './InquiryStatusBadge' import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils' interface InquiryListRowProps { @@ -11,6 +10,8 @@ interface InquiryListRowProps { } export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) { + const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0 + return ( - + {inquiry.tenantName} {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''} - + {hasUnread && ( + 9 ? 0.5 : 0, + }} + > + {inquiry.unreadCount} + + )} void + pendingAttachment?: Attachment | null + onPendingAttachmentConsumed?: () => void } export function InquiryReplyComposer({ inquiryId, defaultSubject, onSent, + pendingAttachment, + onPendingAttachmentConsumed, }: InquiryReplyComposerProps) { const [subject, setSubject] = useState( defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`, ) const [body, setBody] = useState('') - const [markAnswered, setMarkAnswered] = useState(true) const [attachments, setAttachments] = useState([]) const sendReply = useSendInquiryReply() - const updateStatus = useUpdateInquiryStatus() const showToast = useToastStore(s => s.showToast) - const sending = sendReply.isPending || updateStatus.isPending + useEffect(() => { + if (pendingAttachment) { + setAttachments(prev => { + if (prev.find(a => a.id === pendingAttachment.id)) return prev + return [...prev, pendingAttachment] + }) + onPendingAttachmentConsumed?.() + } + }, [pendingAttachment, onPendingAttachmentConsumed]) + + const sending = sendReply.isPending const handleAddMockAttachment = () => { const name = `Anhang_${attachments.length + 1}.pdf` @@ -69,11 +79,6 @@ export function InquiryReplyComposer({ showToast(`Fehler: ${result.error}`, 'error') return } - if (markAnswered) { - await updateStatus.mutateAsync({ id: inquiryId, status: 'answered' }) - } else { - await updateStatus.mutateAsync({ id: inquiryId, status: 'in_progress' }) - } showToast('Antwort gesendet', 'success') setBody('') setAttachments([]) @@ -119,20 +124,24 @@ export function InquiryReplyComposer({ alignItems: 'center', gap: 0.75, bgcolor: 'white', - border: '1px solid #cbd5e1', + border: '1px solid', + borderColor: a.generated ? '#bfdbfe' : '#cbd5e1', borderRadius: 1, px: 1, py: 0.5, fontSize: '0.75rem', + bgcolor: a.generated ? '#eff6ff' : 'white', }} > - - + + {a.fileName} - - {formatFileSize(a.fileSize)} - + {a.fileSize && ( + + {formatFileSize(a.fileSize)} + + )} handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}> @@ -142,30 +151,14 @@ export function InquiryReplyComposer({ )} - - - setMarkAnswered(e.target.checked)} - /> - } - label={ - - Status auf "Beantwortet" setzen - - } - /> - + + + + + + {STEPS.map(label => ( + {label} + ))} + + + + + {loading && ( + + + + )} + + {/* Step 0: Data summary */} + {!loading && step === 0 && property && ( + + + Objekt + + {property.images?.[0] && ( + + )} + {property.title} + + {property.location.city} · {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr + + + + Interessent + + {tenantName} + + + + + Das Angebot wird auf Basis der Objekt- und Anfragedaten vorausgefüllt. + Im nächsten Schritt können Sie alle Felder anpassen. + + + + )} + + {/* Step 1: Edit fields */} + {!loading && step === 1 && draft && ( + + {draft.editableFields.map(f => ( + handleUpdateField(f.id, e.target.value)} + multiline={f.fieldType === 'textarea'} + rows={f.fieldType === 'textarea' ? 3 : 1} + size="small" + fullWidth + /> + ))} + + )} + + {/* Step 2: Viewing appointments */} + {!loading && step === 2 && draft && ( + + + Fügen Sie Besichtigungstermine hinzu, die dem Interessenten angeboten werden sollen: + + + {draft.viewingAppointments.map(a => ( + + updateAppointment(a.id, 'date', e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ width: 160 }} + /> + updateAppointment(a.id, 'timeSlot', e.target.value)} + placeholder="10:00–11:00" + sx={{ width: 140 }} + /> + updateAppointment(a.id, 'contactPerson', e.target.value)} + sx={{ flex: 1 }} + /> + removeAppointment(a.id)} sx={{ color: '#ef4444' }}> + + + + ))} + + + + )} + + {/* Step 3: PDF */} + {step === 3 && ( + + {generating ? ( + <> + + + PDF wird generiert… + + + + + {Math.round(progress)}% + + + + ) : ready ? ( + + + + + + + + Angebot_{draft?.propertyId ?? 'Objekt'}.pdf + + + + + Angebotsschreiben + + {draft?.editableFields.slice(0, 3).map(f => ( + + {f.label} + + {f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''} + + + ))} + {(draft?.viewingAppointments.length ?? 0) > 0 && ( + + + Besichtigungstermine + + {draft!.viewingAppointments.map(a => ( + + {new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot} + + ))} + + )} + + + + + + + + ) : null} + + )} + + + {/* Footer */} + + + + {step > 0 && step < 3 && ( + + )} + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + + + + ) +} diff --git a/src/components/anfragencenter/PreparationWizard.tsx b/src/components/anfragencenter/PreparationWizard.tsx new file mode 100644 index 0000000..7418906 --- /dev/null +++ b/src/components/anfragencenter/PreparationWizard.tsx @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState } from 'react' +import { + Box, Button, Checkbox, CircularProgress, Dialog, DialogContent, + FormControlLabel, LinearProgress, Stack, Step, StepLabel, Stepper, + TextField, Typography, +} from '@mui/material' +import { Download, Send, X } from 'lucide-react' +import type { Inquiry } from '../../domain/inquiry' +import type { Property } from '../../domain/property' +import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport' +import { inquiryReportService } from '../../services/inquiryReportService' +import { useToastStore } from '../../stores/toastStore' +import { ReportObjectFieldSelector } from './ReportObjectFieldSelector' +import { LatentInquiryReportPreview } from './LatentInquiryReportPreview' +import { useProperties } from '../../hooks/useProperties' +import { ResultType } from '../../domain/enums' + +interface Props { + inquiryId: string + inquiry: Inquiry + onClose: () => void +} + +const STEPS = ['Objekte wählen', 'Bericht erstellen', 'Prüfen & bearbeiten', 'Finalisieren'] + +export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) { + const [step, setStep] = useState(0) + const [selectedIds, setSelectedIds] = useState([]) + const [draft, setDraft] = useState(null) + const [generating, setGenerating] = useState(false) + const [progress, setProgress] = useState(0) + const [finalizing, setFinalizing] = useState(false) + const { data: allProperties = [] } = useProperties() + const showToast = useToastStore(s => s.showToast) + const timerRef = useRef | null>(null) + + const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO) + + const selectedProperties = draft?.selectedPropertyIds + .map(id => allProperties.find(p => p.id === id)) + .filter((p): p is Property => !!p) ?? [] + + const toggleProperty = (id: string) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], + ) + } + + const handleGenerate = async () => { + setStep(1) + setGenerating(true) + setProgress(0) + let p = 0 + timerRef.current = setInterval(() => { + p += Math.random() * 20 + 10 + if (p >= 100) { + clearInterval(timerRef.current!) + setProgress(100) + setGenerating(false) + inquiryReportService.create(inquiryId, selectedIds).then(d => { + setDraft(d) + setStep(2) + }) + } else { + setProgress(Math.min(100, p)) + } + }, 200) + } + + useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, []) + + const handleUpdateField = (fieldId: string, value: string) => { + if (!draft) return + setDraft({ + ...draft, + editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f), + }) + } + + const handleUpdateFieldSelection = (propertyId: string, sel: ReportObjectFieldSelection) => { + if (!draft) return + setDraft({ + ...draft, + fieldSelections: draft.fieldSelections.map(fs => fs.propertyId === propertyId ? sel : fs), + }) + } + + const handleFinalize = async () => { + if (!draft) return + setFinalizing(true) + await inquiryReportService.update(draft.id, { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections }) + const finalized = await inquiryReportService.finalize(draft.id) + setDraft(finalized) + setFinalizing(false) + setStep(3) + } + + return ( + + + Vorbereitung starten + + + + + + {STEPS.map(label => ( + {label} + ))} + + + + + + {/* Step 0: Select properties */} + {step === 0 && ( + + + Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten: + + + {portfolioProps.map(p => ( + toggleProperty(p.id)} + /> + } + label={ + + {p.title} + + {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr + + + } + sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 1, m: 0, alignItems: 'flex-start', '& .MuiCheckbox-root': { pt: 0 } }} + /> + ))} + + + )} + + {/* Step 1: Generating */} + {step === 1 && ( + + {generating ? ( + <> + + + Bericht wird erstellt… + + + + + {Math.round(progress)}% + + + + ) : ( + Bericht erstellt. Weiterleitung… + )} + + )} + + {/* Step 2: Review & Edit */} + {step === 2 && draft && ( + + + Berichtsfelder bearbeiten + + {draft.editableFields.map(f => ( + handleUpdateField(f.id, e.target.value)} + multiline={f.fieldType === 'textarea'} + rows={f.fieldType === 'textarea' ? 3 : 1} + size="small" + fullWidth + /> + ))} + + + Felder pro Objekt + + {draft.fieldSelections.map(fs => { + const prop = allProperties.find(p => p.id === fs.propertyId) + if (!prop) return null + return ( + handleUpdateFieldSelection(fs.propertyId, sel)} + /> + ) + })} + + + + + Vorschau + + + + + + )} + + {/* Step 3: Finalized */} + {step === 3 && draft && ( + + + + + + + + + + )} + + + {/* Footer navigation */} + + + + {step === 2 && ( + + )} + {step === 0 && ( + + )} + {step === 2 && ( + + )} + + + + ) +} diff --git a/src/components/anfragencenter/RelatedPropertyCardPanel.tsx b/src/components/anfragencenter/RelatedPropertyCardPanel.tsx index 2ad6321..341301c 100644 --- a/src/components/anfragencenter/RelatedPropertyCardPanel.tsx +++ b/src/components/anfragencenter/RelatedPropertyCardPanel.tsx @@ -1,15 +1,29 @@ -import { Box, Button, CircularProgress, Typography } from '@mui/material' -import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material' +import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react' import { useNavigate } from 'react-router' import { usePropertyById } from '../../hooks/useProperties' +import { matchService } from '../../services/matchService' +import type { AdditionalPropertyMatch } from '../../domain/additionalMatch' interface RelatedPropertyCardPanelProps { propertyId: string + inquiryId: string } -export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPanelProps) { +export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) { const { data: property, isLoading } = usePropertyById(propertyId) const navigate = useNavigate() + const [additionalMatches, setAdditionalMatches] = useState([]) + const [loadingMatches, setLoadingMatches] = useState(false) + + useEffect(() => { + setLoadingMatches(true) + matchService + .getAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId }) + .then(setAdditionalMatches) + .finally(() => setLoadingMatches(false)) + }, [inquiryId, propertyId]) if (isLoading) { return ( @@ -102,10 +116,91 @@ export function RelatedPropertyCardPanel({ propertyId }: RelatedPropertyCardPane size="small" endIcon={} onClick={() => navigate('/supply/properties')} - sx={{ textTransform: 'none', mt: 'auto' }} + sx={{ textTransform: 'none' }} > Objekt ansehen + + {/* Weitere Matches */} + + + + + Weitere passende Objekte + + + + {loadingMatches ? ( + + ) : additionalMatches.length === 0 ? ( + + Keine weiteren Matches + + ) : ( + + {additionalMatches.map(m => ( + + ))} + + )} + + ) +} + +function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) { + const navigate = useNavigate() + return ( + + {match.imageUrl && ( + + )} + + + + {match.title} + + = 90 ? '#fef3c7' : '#e0e7ff', + color: match.matchScore >= 90 ? '#92400e' : '#3730a3', + fontWeight: 700, + fontSize: '0.65rem', + flexShrink: 0, + ml: 0.5, + }} + > + {match.matchScore}% + + + + {match.location} · {match.areaSqm.toLocaleString('de-CH')} m² + + + ) } diff --git a/src/components/anfragencenter/ReportObjectFieldSelector.tsx b/src/components/anfragencenter/ReportObjectFieldSelector.tsx new file mode 100644 index 0000000..3aea18f --- /dev/null +++ b/src/components/anfragencenter/ReportObjectFieldSelector.tsx @@ -0,0 +1,150 @@ +import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material' +import { ChevronDown } from 'lucide-react' +import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport' + +interface Props { + propertyTitle: string + value: ReportObjectFieldSelection + onChange: (v: ReportObjectFieldSelection) => void +} + +type FieldGroup = { + label: string + fields: { key: ReportObjectFieldKey; label: string }[] +} + +const FIELD_GROUPS: FieldGroup[] = [ + { + label: 'Grunddaten', + fields: [ + { key: 'areaSqm', label: 'Fläche (m²)' }, + { key: 'rentPricePerSqm', label: 'Mietpreis (CHF/m²/Jahr)' }, + { key: 'availabilityDate', label: 'Verfügbar ab' }, + { key: 'leaseTerm', label: 'Mietlaufzeit' }, + { key: 'breakoutOption', label: 'Breakout-Option' }, + { key: 'currentTenant', label: 'Aktueller Mieter' }, + { key: 'propertyNumber', label: 'Objektnummer' }, + { key: 'assetType', label: 'Objekttyp' }, + ], + }, + { + label: 'Technische Merkmale', + fields: [ + { key: 'floor', label: 'Stockwerk' }, + { key: 'parking', label: 'Parkplätze' }, + { key: 'ceilingHeightM', label: 'Deckenhöhe (m)' }, + { key: 'loadingDocksCount', label: 'Laderampen' }, + { key: 'powerSupplyKva', label: 'Stromanschluss (kVA)' }, + { key: 'hasServerRoom', label: 'Serverraum' }, + { key: 'isBarrierFree', label: 'Barrierefrei' }, + { key: 'fitOut', label: 'Ausbaustandard' }, + { key: 'publicTransportScore', label: 'ÖV-Score' }, + ], + }, + { + label: 'Weiche Faktoren', + fields: [ + { key: 'prestigeScore', label: 'Prestige' }, + { key: 'visibilityScore', label: 'Sichtbarkeit' }, + { key: 'footfallScore', label: 'Passantenfrequenz' }, + { key: 'commuterAccessScore', label: 'Pendleranbindung' }, + { key: 'talentAccessScore', label: 'Talentpool' }, + { key: 'esgScore', label: 'ESG-Bewertung' }, + { key: 'flexibilityScore', label: 'Flexibilität' }, + { key: 'expansionPotentialScore', label: 'Expansionspotenzial' }, + { key: 'taxEnvironmentScore', label: 'Steuerumgebung' }, + ], + }, + { + label: 'Beschreibung & Medien', + fields: [ + { key: 'description', label: 'Beschreibung' }, + { key: 'units', label: 'Stockwerkstruktur' }, + ], + }, + { + label: 'Datenqualität', + fields: [ + { key: 'dataQuality', label: 'Datenqualität' }, + { key: 'softFactors', label: 'Weiche Faktoren (Gesamt)' }, + ], + }, +] + +const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images'] + +export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) { + const isSelected = (key: ReportObjectFieldKey) => + value.selectedOptionalFields.includes(key) + + const toggle = (key: ReportObjectFieldKey) => { + const next = isSelected(key) + ? value.selectedOptionalFields.filter(k => k !== key) + : [...value.selectedOptionalFields, key] + onChange({ ...value, selectedOptionalFields: next }) + } + + const selectAll = (keys: ReportObjectFieldKey[]) => { + const next = Array.from(new Set([...value.selectedOptionalFields, ...keys])) + onChange({ ...value, selectedOptionalFields: next }) + } + + const deselectAll = (keys: ReportObjectFieldKey[]) => { + onChange({ ...value, selectedOptionalFields: value.selectedOptionalFields.filter(k => !keys.includes(k)) }) + } + + return ( + + + Felder für: {propertyTitle} + + + Pflichtfelder (immer enthalten): Titel, Standort, Karte, Fotos + + + {FIELD_GROUPS.map(group => { + const groupKeys = group.fields.map(f => f.key) + const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k)) + return ( + + } sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}> + + {group.label} + + + ({groupKeys.filter(k => value.selectedOptionalFields.includes(k)).length}/{groupKeys.length}) + + + + + + + + + {group.fields.map(f => ( + toggle(f.key)} + sx={{ py: 0.25 }} + /> + } + label={{f.label}} + sx={{ m: 0 }} + /> + ))} + + + + ) + })} + + ) +} diff --git a/src/domain/additionalMatch.ts b/src/domain/additionalMatch.ts new file mode 100644 index 0000000..518757b --- /dev/null +++ b/src/domain/additionalMatch.ts @@ -0,0 +1,9 @@ +export interface AdditionalPropertyMatch { + propertyId: string + title: string + location: string + areaSqm: number + rentPricePerSqm: number + matchScore: number + imageUrl?: string +} diff --git a/src/domain/inquiry.ts b/src/domain/inquiry.ts index 21cb466..88d712a 100644 --- a/src/domain/inquiry.ts +++ b/src/domain/inquiry.ts @@ -30,7 +30,10 @@ export interface Inquiry { tenantEmail?: string subject: string message: string - status: InquiryStatus + status?: InquiryStatus + unreadCount: number + isRead: boolean + lastReadAt?: string matchScore?: number createdAt: string updatedAt: string diff --git a/src/domain/inquiryReport.ts b/src/domain/inquiryReport.ts new file mode 100644 index 0000000..0b691c7 --- /dev/null +++ b/src/domain/inquiryReport.ts @@ -0,0 +1,37 @@ +export type ReportObjectFieldKey = + | 'title' | 'location' | 'areaSqm' | 'rentPricePerSqm' | 'availabilityDate' + | 'leaseTerm' | 'breakoutOption' | 'currentTenant' | 'propertyNumber' + | 'assetType' | 'floor' | 'parking' | 'publicTransportScore' + | 'ceilingHeightM' | 'loadingDocksCount' | 'powerSupplyKva' + | 'hasServerRoom' | 'isBarrierFree' | 'fitOut' + | 'prestigeScore' | 'visibilityScore' | 'footfallScore' | 'commuterAccessScore' + | 'talentAccessScore' | 'esgScore' | 'flexibilityScore' + | 'expansionPotentialScore' | 'taxEnvironmentScore' + | 'description' | 'images' | 'mapImageUrl' | 'units' + | 'softFactors' | 'dataQuality' + +export interface ReportObjectFieldSelection { + propertyId: string + mandatoryFields: ReportObjectFieldKey[] + selectedOptionalFields: ReportObjectFieldKey[] +} + +export interface ReportEditableField { + id: string + label: string + value: string + fieldType: 'text' | 'textarea' +} + +export interface InquiryPreparationReportDraft { + id: string + inquiryId: string + selectedPropertyIds: string[] + fieldSelections: ReportObjectFieldSelection[] + marketSignalPropertyId?: string + editableFields: ReportEditableField[] + status: 'draft' | 'finalized' + pdfUrl?: string + createdAt: string + updatedAt: string +} diff --git a/src/domain/offerReport.ts b/src/domain/offerReport.ts new file mode 100644 index 0000000..5e02dd8 --- /dev/null +++ b/src/domain/offerReport.ts @@ -0,0 +1,25 @@ +export interface ViewingAppointmentOption { + id: string + date: string + timeSlot: string + contactPerson?: string +} + +export interface OfferEditableField { + id: string + label: string + value: string + fieldType: 'text' | 'textarea' +} + +export interface OfferReportDraft { + id: string + inquiryId: string + propertyId: string + editableFields: OfferEditableField[] + viewingAppointments: ViewingAppointmentOption[] + status: 'draft' | 'finalized' + pdfUrl?: string + createdAt: string + updatedAt: string +} diff --git a/src/domain/property.ts b/src/domain/property.ts index aef3656..fd7f4b3 100644 --- a/src/domain/property.ts +++ b/src/domain/property.ts @@ -145,6 +145,7 @@ export interface Property { propertyNumber?: string units?: PropertyUnit[] + mapImageUrl?: string leaseTerm?: string leaseStartDate?: string diff --git a/src/hooks/useInquiries.ts b/src/hooks/useInquiries.ts index dd5f6aa..0fea5f7 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 { InquiryStatus, Attachment } from '../domain/inquiry' +import type { Attachment } from '../domain/inquiry' export function useActiveInquiries(filters?: InquiryFilters) { return useQuery({ @@ -37,12 +37,11 @@ export function useSendInquiryReply() { }) } -export function useUpdateInquiryStatus() { +export function useMarkThreadAsRead() { const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, status }: { id: string; status: InquiryStatus }) => - inquiryService.updateInquiryStatus(id, status), - onSuccess: (_data, { id }) => { + mutationFn: (id: string) => inquiryService.markThreadAsRead(id), + onSuccess: (_data, id) => { qc.invalidateQueries({ queryKey: ['inquiry', id] }) qc.invalidateQueries({ queryKey: ['inquiries'] }) }, diff --git a/src/mock-data/inquiries.ts b/src/mock-data/inquiries.ts index 7c94576..11b9af9 100644 --- a/src/mock-data/inquiries.ts +++ b/src/mock-data/inquiries.ts @@ -1,11 +1,12 @@ import type { Inquiry } from '../domain/inquiry' export const mockInquiries: Inquiry[] = [ - // 1 — NEW + // 1 — UNREAD { id: 'inq-001', organizationId: 'org-wincasa', propertyId: 'prop-001', + needId: 'need-001', tenantName: 'Sandra Meier', tenantCompany: 'Innovatech AG', tenantEmail: 'sandra.meier@innovatech.ch', @@ -13,6 +14,8 @@ export const mockInquiries: Inquiry[] = [ message: 'Guten Tag\n\nWir interessieren uns für die Bürofläche an der Zollstrasse 12 in Zürich-West. Könnten wir bitte einen Besichtigungstermin in der kommenden Woche vereinbaren?\n\nFreundliche Grüsse\nSandra Meier', status: 'new', + unreadCount: 1, + isRead: false, matchScore: 91, createdAt: '2026-05-17T08:32:00Z', updatedAt: '2026-05-17T08:32:00Z', @@ -31,21 +34,24 @@ export const mockInquiries: Inquiry[] = [ ], }, - // 2 — NEW + // 2 — UNREAD { - id: 'inq-002', - organizationId: 'org-wincasa', - propertyId: 'prop-009', - tenantName: 'Markus Frei', - tenantCompany: 'Frei Logistik AG', - tenantEmail: 'm.frei@frei-logistik.ch', - subject: 'Lagerfläche Winterthur — Verfügbarkeit?', + id: ‘inq-002’, + organizationId: ‘org-wincasa’, + propertyId: ‘prop-009’, + needId: ‘need-002’, + tenantName: ‘Markus Frei’, + tenantCompany: ‘Frei Logistik AG’, + tenantEmail: ‘m.frei@frei-logistik.ch’, + subject: ‘Lagerfläche Winterthur — Verfügbarkeit?’, message: - 'Sehr geehrte Damen und Herren\n\nIst Ihre Logistikfläche in Winterthur noch verfügbar? Wir benötigen ab September ca. 3’000 m² mit Rampe und Hochregalmöglichkeit.\n\nMit freundlichen Grüssen\nMarkus Frei', - status: 'new', + ‘Sehr geehrte Damen und Herren\n\nIst Ihre Logistikfläche in Winterthur noch verfügbar? Wir benötigen ab September ca. 3’000 m² mit Rampe und Hochregalmöglichkeit.\n\nMit freundlichen Grüssen\nMarkus Frei’, + status: ‘new’, + unreadCount: 1, + isRead: false, matchScore: 85, - createdAt: '2026-05-17T11:14:00Z', - updatedAt: '2026-05-17T11:14:00Z', + createdAt: ‘2026-05-17T11:14:00Z’, + updatedAt: ‘2026-05-17T11:14:00Z’, thread: [ { id: 'msg-002-1', @@ -61,7 +67,7 @@ export const mockInquiries: Inquiry[] = [ ], }, - // 3 — NEW + // 3 — READ { id: 'inq-003', organizationId: 'org-wincasa', @@ -73,6 +79,9 @@ export const mockInquiries: Inquiry[] = [ message: 'Guten Tag\n\nKönnten Sie uns bitte Detailunterlagen sowie einen Grundriss der Retailfläche am Löwenplatz zukommen lassen? Wir planen die Eröffnung unseres neuen Flagship-Stores im Frühjahr 2026.\n\nBesten Dank\nLaura Bianchi', status: 'new', + unreadCount: 0, + isRead: true, + lastReadAt: '2026-05-16T16:10:00Z', matchScore: 88, createdAt: '2026-05-16T15:50:00Z', updatedAt: '2026-05-16T15:50:00Z', @@ -91,11 +100,12 @@ export const mockInquiries: Inquiry[] = [ ], }, - // 4 — IN_PROGRESS + // 4 — UNREAD (3 messages) { id: 'inq-004', organizationId: 'org-wincasa', propertyId: 'prop-012', + needId: 'need-004', tenantName: 'Daniel Hofer', tenantCompany: 'Hofer Treuhand AG', tenantEmail: 'd.hofer@hofer-treuhand.ch', @@ -103,6 +113,8 @@ export const mockInquiries: Inquiry[] = [ message: 'Sehr geehrte Damen und Herren\n\nDie Büroflächen in Zug entsprechen genau unserem Profil. Bitte senden Sie uns die detaillierten Mietkonditionen sowie Informationen zu Nebenkosten und Mindestmietdauer.\n\nFreundliche Grüsse\nDaniel Hofer', status: 'in_progress', + unreadCount: 3, + isRead: false, matchScore: 93, createdAt: '2026-05-14T09:20:00Z', updatedAt: '2026-05-15T14:00:00Z', @@ -134,7 +146,7 @@ export const mockInquiries: Inquiry[] = [ ], }, - // 5 — IN_PROGRESS + // 5 — READ { id: 'inq-005', organizationId: 'org-wincasa', @@ -146,6 +158,9 @@ export const mockInquiries: Inquiry[] = [ message: 'Guten Tag\n\nDie Bürofläche an der Thurgauerstrasse interessiert uns sehr. Könnten Sie uns einen aktuellen Grundriss sowie Angaben zum Ausbaustand zukommen lassen?\n\nFreundliche Grüsse\nPetra Wyss', status: 'in_progress', + unreadCount: 0, + isRead: true, + lastReadAt: '2026-05-14T17:00:00Z', matchScore: 79, createdAt: '2026-05-13T13:10:00Z', updatedAt: '2026-05-14T16:20:00Z', @@ -186,21 +201,24 @@ export const mockInquiries: Inquiry[] = [ ], }, - // 6 — ANSWERED + // 6 — READ { - id: 'inq-006', - organizationId: 'org-wincasa', - propertyId: 'prop-002', - tenantName: 'Thomas Brun', - tenantCompany: 'Schweizer Logistik GmbH', - tenantEmail: 't.brun@swisslogistik.ch', - subject: 'Lagerfläche Hardstrasse Basel', + id: ‘inq-006’, + organizationId: ‘org-wincasa’, + propertyId: ‘prop-002’, + tenantName: ‘Thomas Brun’, + tenantCompany: ‘Schweizer Logistik GmbH’, + tenantEmail: ‘t.brun@swisslogistik.ch’, + subject: ‘Lagerfläche Hardstrasse Basel’, message: - 'Guten Tag\n\nWir suchen ab Juli eine Lagerfläche in Basel mit ca. 2’500 m². Ihre Liegenschaft an der Hardstrasse entspricht unserem Profil. Können wir besichtigen?\n\nThomas Brun', - status: 'answered', + ‘Guten Tag\n\nWir suchen ab Juli eine Lagerfläche in Basel mit ca. 2’500 m². Ihre Liegenschaft an der Hardstrasse entspricht unserem Profil. Können wir besichtigen?\n\nThomas Brun’, + status: ‘answered’, + unreadCount: 0, + isRead: true, + lastReadAt: ‘2026-05-10T10:00:00Z’, matchScore: 87, - createdAt: '2026-05-08T10:00:00Z', - updatedAt: '2026-05-10T09:30:00Z', + createdAt: ‘2026-05-08T10:00:00Z’, + updatedAt: ‘2026-05-10T09:30:00Z’, thread: [ { id: 'msg-006-1', @@ -236,7 +254,7 @@ export const mockInquiries: Inquiry[] = [ ], }, - // 7 — ANSWERED + // 7 — READ { id: 'inq-007', organizationId: 'org-wincasa', @@ -248,6 +266,9 @@ export const mockInquiries: Inquiry[] = [ message: 'Guten Tag\n\nWir würden gerne einen Mietvertrag für die Bürofläche im Dreispitz aufsetzen. Bitte senden Sie uns die Vertragsvorlage.\n\nCaroline Roth', status: 'answered', + unreadCount: 0, + isRead: true, + lastReadAt: '2026-05-06T12:00:00Z', matchScore: 82, createdAt: '2026-05-05T09:00:00Z', updatedAt: '2026-05-06T11:00:00Z', @@ -279,7 +300,7 @@ export const mockInquiries: Inquiry[] = [ ], }, - // 8 — ARCHIVED + // 8 — READ (archived) { id: 'inq-008', organizationId: 'org-wincasa', @@ -291,6 +312,9 @@ export const mockInquiries: Inquiry[] = [ message: 'Guten Tag\n\nWir suchen eine Produktionsfläche im Raum Bern. Ihre Liegenschaft erscheint passend, jedoch ist die Hallenhöhe etwas niedrig. Können Sie hier mehr Details geben?\n\nJonas Keller', status: 'archived', + unreadCount: 0, + isRead: true, + lastReadAt: '2026-04-25T11:00:00Z', matchScore: 64, createdAt: '2026-04-20T14:30:00Z', updatedAt: '2026-04-25T10:00:00Z', diff --git a/src/mock-data/inquiryReportDrafts.ts b/src/mock-data/inquiryReportDrafts.ts new file mode 100644 index 0000000..99f800e --- /dev/null +++ b/src/mock-data/inquiryReportDrafts.ts @@ -0,0 +1,77 @@ +import type { InquiryPreparationReportDraft } from '../domain/inquiryReport' + +export const mockInquiryReportDrafts: InquiryPreparationReportDraft[] = [ + { + id: 'irep-001', + inquiryId: 'inq-001', + selectedPropertyIds: ['prop-001', 'prop-007'], + fieldSelections: [ + { + propertyId: 'prop-001', + mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'], + selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'leaseTerm', 'currentTenant', 'description', 'floor', 'parking'], + }, + { + propertyId: 'prop-007', + mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'], + selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'leaseTerm', 'currentTenant', 'description'], + }, + ], + marketSignalPropertyId: 'prop-001', + editableFields: [ + { id: 'intro', label: 'Einleitung', value: 'Sehr geehrte Frau Meier\n\nGerne präsentieren wir Ihnen passende Objekte aus unserem Portfolio, die Ihrem Anforderungsprofil entsprechen.', fieldType: 'textarea' }, + { id: 'highlights', label: 'Besonderheiten', value: 'Beide Objekte bieten modernste Büroinfrastruktur in attraktiver Zürich-West-Lage mit direktem ÖV-Anschluss.', fieldType: 'textarea' }, + { id: 'next_steps', label: 'Nächste Schritte', value: 'Gerne vereinbaren wir einen Besichtigungstermin. Bitte kontaktieren Sie uns für eine persönliche Beratung.', fieldType: 'textarea' }, + ], + status: 'draft', + createdAt: '2026-05-17T09:00:00Z', + updatedAt: '2026-05-17T09:00:00Z', + }, + { + id: 'irep-002', + inquiryId: 'inq-002', + selectedPropertyIds: ['prop-002', 'prop-009'], + fieldSelections: [ + { + propertyId: 'prop-002', + mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'], + selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'availabilityDate', 'loadingDocksCount', 'ceilingHeightM'], + }, + { + propertyId: 'prop-009', + mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'], + selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'availabilityDate', 'description'], + }, + ], + marketSignalPropertyId: 'prop-002', + editableFields: [ + { id: 'intro', label: 'Einleitung', value: 'Guten Tag Herr Frei\n\nWir freuen uns, Ihnen zwei geeignete Logistikflächen vorstellen zu können.', fieldType: 'textarea' }, + { id: 'highlights', label: 'Besonderheiten', value: 'Beide Standorte verfügen über Rampenanschluss und sind für Hochregallager geeignet.', fieldType: 'textarea' }, + ], + status: 'draft', + createdAt: '2026-05-17T12:00:00Z', + updatedAt: '2026-05-17T12:00:00Z', + }, + { + id: 'irep-004', + inquiryId: 'inq-004', + selectedPropertyIds: ['prop-012'], + fieldSelections: [ + { + propertyId: 'prop-012', + mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'], + selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'leaseTerm', 'breakoutOption', 'currentTenant', 'propertyNumber', 'floor', 'parking', 'publicTransportScore'], + }, + ], + marketSignalPropertyId: 'prop-012', + editableFields: [ + { id: 'intro', label: 'Einleitung', value: 'Sehr geehrter Herr Hofer\n\nAnbei finden Sie detaillierte Unterlagen zur Bürofläche Stadtturm Zug.', fieldType: 'textarea' }, + { id: 'highlights', label: 'Highlights', value: 'Repräsentative Lage im Stadtzentrum Zug, beste Steuerkonditionen, direkter ÖV-Anschluss.', fieldType: 'textarea' }, + { id: 'closing', label: 'Abschluss', value: 'Wir freuen uns auf Ihr Feedback und stehen für Fragen jederzeit zur Verfügung.', fieldType: 'textarea' }, + ], + status: 'finalized', + pdfUrl: '/mock-reports/irep-004.pdf', + createdAt: '2026-05-14T10:00:00Z', + updatedAt: '2026-05-15T09:00:00Z', + }, +] diff --git a/src/mock-data/offerReportDrafts.ts b/src/mock-data/offerReportDrafts.ts new file mode 100644 index 0000000..a457878 --- /dev/null +++ b/src/mock-data/offerReportDrafts.ts @@ -0,0 +1,44 @@ +import type { OfferReportDraft } from '../domain/offerReport' + +export const mockOfferReportDrafts: OfferReportDraft[] = [ + { + id: 'offer-rep-001', + inquiryId: 'inq-004', + propertyId: 'prop-012', + editableFields: [ + { id: 'recipient_salutation', label: 'Anrede / Einleitung', value: 'Sehr geehrter Herr Hofer', fieldType: 'text' }, + { id: 'offer_intro', label: 'Angebotsbeschreibung', value: 'Wir freuen uns, Ihnen das nachfolgende Angebot für die Bürofläche Stadtturm Zug unterbreiten zu können.', fieldType: 'textarea' }, + { id: 'highlighted_criteria', label: 'Ihre Anforderungen – unsere Stärken', value: 'Repräsentativer Standort im Zentrum Zug · Modernster Ausbaustandard · Flexible Mietdauer ab 3 Jahren', fieldType: 'textarea' }, + { id: 'viewing_intro', label: 'Besichtigung', value: 'Gerne zeigen wir Ihnen die Fläche persönlich. Bitte wählen Sie einen der folgenden Termine:', fieldType: 'textarea' }, + { id: 'next_steps', label: 'Nächste Schritte', value: 'Nach Ihrer Terminbestätigung erhalten Sie eine Detailbroschüre sowie den Mietvertragsentwurf.', fieldType: 'textarea' }, + { id: 'closing', label: 'Abschluss', value: 'Mit freundlichen Grüssen\nWincasa AG', fieldType: 'textarea' }, + ], + viewingAppointments: [ + { id: 'va-001-1', date: '2026-05-22', timeSlot: '10:00–11:00', contactPerson: 'Peter Müller' }, + { id: 'va-001-2', date: '2026-05-23', timeSlot: '14:00–15:00', contactPerson: 'Peter Müller' }, + ], + status: 'draft', + createdAt: '2026-05-15T10:00:00Z', + updatedAt: '2026-05-15T10:00:00Z', + }, + { + id: 'offer-rep-005', + inquiryId: 'inq-005', + propertyId: 'prop-007', + editableFields: [ + { id: 'recipient_salutation', label: 'Anrede / Einleitung', value: 'Sehr geehrte Frau Wyss', fieldType: 'text' }, + { id: 'offer_intro', label: 'Angebotsbeschreibung', value: 'Wir freuen uns, Ihnen die Bürofläche an der Thurgauerstrasse 40 in Zürich-Oerlikon anzubieten.', fieldType: 'textarea' }, + { id: 'highlighted_criteria', label: 'Highlights', value: 'Vollsanierte Fläche (2024) · Ausgezeichnete ÖV-Anbindung · Expansionspotenzial vorhanden', fieldType: 'textarea' }, + { id: 'viewing_intro', label: 'Besichtigung', value: 'Wir laden Sie herzlich zu einer Besichtigung ein:', fieldType: 'textarea' }, + { id: 'next_steps', label: 'Nächste Schritte', value: 'Nach Ihrer Rückmeldung senden wir Ihnen den vollständigen Grundriss sowie das Exposé zu.', fieldType: 'textarea' }, + { id: 'closing', label: 'Abschluss', value: 'Mit freundlichen Grüssen\nWincasa AG', fieldType: 'textarea' }, + ], + viewingAppointments: [ + { id: 'va-005-1', date: '2026-05-21', timeSlot: '09:00–10:00', contactPerson: 'Sandra Keller' }, + ], + status: 'finalized', + pdfUrl: '/mock-reports/offer-rep-005.pdf', + createdAt: '2026-05-14T13:00:00Z', + updatedAt: '2026-05-14T16:00:00Z', + }, +] diff --git a/src/mock-data/properties.ts b/src/mock-data/properties.ts index 781723c..f6f35f9 100644 --- a/src/mock-data/properties.ts +++ b/src/mock-data/properties.ts @@ -42,6 +42,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 5.5, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', propertyNumber: 'ZH-2024-001', units: [ { id: 'unit-001-1', floorLevel: 1, unitLabel: 'Nord', areaSqm: 280, available: false, rentPricePerSqm: 456, currentTenant: 'MediaGroup Schweiz AG', leaseTerm: '5 Jahre', leaseEndDate: '2025-08-31' }, @@ -95,6 +96,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 3.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -144,6 +146,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 5.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', propertyNumber: 'ZH-2021-007', units: [ { id: 'unit-007-1', floorLevel: 0, unitLabel: 'EG Ost', areaSqm: 180, available: false, rentPricePerSqm: 420, currentTenant: 'Consulting Partners AG', leaseTerm: '4 Jahre', leaseEndDate: '2025-09-30' }, @@ -199,6 +202,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 4.5, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -246,6 +250,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 2.8, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -293,6 +298,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 8.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -340,6 +346,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 2.5, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -388,6 +395,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 6.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', propertyNumber: 'ZG-2022-012', units: [ { id: 'unit-012-1', floorLevel: 3, unitLabel: '3.OG A', areaSqm: 180, available: false, rentPricePerSqm: 504, currentTenant: 'FinTech Zug AG', leaseTerm: '3 Jahre', leaseEndDate: '2025-09-30' }, @@ -443,6 +451,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 5.0, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', propertyNumber: 'ZH-2021-013', units: [ { id: 'unit-013-1', floorLevel: 0, unitLabel: 'EG Laden', areaSqm: 320, available: false, rentPricePerSqm: 600, currentTenant: 'Design Studio Zürich GmbH', leaseTerm: '4 Jahre', leaseEndDate: '2025-10-31' }, @@ -497,6 +506,7 @@ export const mockProperties: Property[] = [ ancillaryCosts: 2.8, riskLevel: RiskLevel.LOW, images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', importedFrom: 'SAP RE-FX', importedAt: '2025-01-15T08:00:00Z', lastUpdatedAt: '2025-05-10T10:00:00Z', @@ -546,6 +556,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-02-15T14:00:00Z', updatedAt: '2025-04-10T09:00:00Z', }, @@ -574,6 +585,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-01T10:00:00Z', updatedAt: '2025-03-20T15:00:00Z', }, @@ -608,6 +620,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-12T11:00:00Z', updatedAt: '2025-04-05T10:00:00Z', }, @@ -642,6 +655,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-02-20T09:00:00Z', updatedAt: '2025-03-28T12:00:00Z', }, @@ -677,6 +691,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-05T13:00:00Z', updatedAt: '2025-04-18T11:00:00Z', }, @@ -711,6 +726,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-02-28T10:00:00Z', updatedAt: '2025-04-02T09:00:00Z', }, @@ -740,6 +756,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1565043589221-1a6fd9ae45c7?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-10T08:00:00Z', updatedAt: '2025-03-25T14:00:00Z', }, @@ -774,6 +791,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-18T09:00:00Z', updatedAt: '2025-04-12T11:00:00Z', }, @@ -809,6 +827,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-02-10T10:00:00Z', updatedAt: '2025-04-08T09:00:00Z', }, @@ -843,6 +862,7 @@ export const mockProperties: Property[] = [ }, riskLevel: RiskLevel.MEDIUM, images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'], + mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop', createdAt: '2025-03-08T08:00:00Z', updatedAt: '2025-04-14T10:00:00Z', }, diff --git a/src/provider/IInquiryProvider.ts b/src/provider/IInquiryProvider.ts index 212e467..8dfec92 100644 --- a/src/provider/IInquiryProvider.ts +++ b/src/provider/IInquiryProvider.ts @@ -10,6 +10,8 @@ export interface IInquiryProvider { getAll(filters?: InquiryFilters): Promise getById(id: string): Promise updateStatus(id: string, status: InquiryStatus): Promise + markThreadAsRead(id: string): Promise + getUnreadCount(): Promise addMessage( inquiryId: string, msg: Omit, diff --git a/src/provider/MockupInquiryProvider.ts b/src/provider/MockupInquiryProvider.ts index 50a51b0..633ca7e 100644 --- a/src/provider/MockupInquiryProvider.ts +++ b/src/provider/MockupInquiryProvider.ts @@ -21,6 +21,21 @@ export const MockupInquiryProvider: IInquiryProvider = { store[idx] = { ...store[idx], status, updatedAt: new Date().toISOString() } return store[idx] }, + async markThreadAsRead(id: string): Promise { + const idx = store.findIndex(i => i.id === id) + if (idx === -1) throw new Error(`Inquiry ${id} not found`) + store[idx] = { + ...store[idx], + isRead: true, + unreadCount: 0, + lastReadAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + return store[idx] + }, + async getUnreadCount(): Promise { + return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0) + }, async addMessage( inquiryId: string, msg: Omit, diff --git a/src/services/inquiryReportService.ts b/src/services/inquiryReportService.ts new file mode 100644 index 0000000..061d7ff --- /dev/null +++ b/src/services/inquiryReportService.ts @@ -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 = { + mandatoryFields: ['title', 'location', 'mapImageUrl', 'images'], + selectedOptionalFields: ['areaSqm', 'rentPricePerSqm', 'availabilityDate', 'leaseTerm', 'description'], +} + +export const inquiryReportService = { + async getByInquiry(inquiryId: string): Promise { + return store.find(d => d.inquiryId === inquiryId) ?? null + }, + + async create(inquiryId: string, selectedPropertyIds: string[]): Promise { + 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): Promise { + 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 { + 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] + }, +} diff --git a/src/services/inquiryService.ts b/src/services/inquiryService.ts index 3d1ea5e..d879ae1 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, 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> { + async markThreadAsRead(id: string): Promise> { 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> { + try { + const data = await provider.getUnreadCount() return { data, error: null } } catch (e) { return { data: null, error: e instanceof Error ? e.message : 'Unbekannter Fehler' } diff --git a/src/services/matchService.ts b/src/services/matchService.ts index 1d6d37f..c23c801 100644 --- a/src/services/matchService.ts +++ b/src/services/matchService.ts @@ -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 { + 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 { const [matches, properties] = await Promise.all([ provider.getAll(), diff --git a/src/services/offerReportService.ts b/src/services/offerReportService.ts new file mode 100644 index 0000000..4bb7ab6 --- /dev/null +++ b/src/services/offerReportService.ts @@ -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 { + return store.find(d => d.inquiryId === inquiryId) ?? null + }, + + async create(inquiryId: string, propertyId: string, tenantName = '', propertyTitle = ''): Promise { + 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): Promise { + 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 { + 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] + }, +}