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:
@@ -0,0 +1,200 @@
|
||||
import { Box, Button, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
|
||||
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
|
||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||
import { useSendOffer } from '../../hooks/useOffers'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { AiOfferEmailButton } from './AiOfferEmailButton'
|
||||
import { mockProperties } from '../../mock-data/properties'
|
||||
import { deterministicMatchScore } from './latentNeedUtils'
|
||||
import { formatFileSize } from './inquiryUtils'
|
||||
|
||||
export function OfferChatComposer() {
|
||||
const subject = useOfferWizardStore(s => s.messageSubject)
|
||||
const body = useOfferWizardStore(s => s.messageDraft)
|
||||
const setSubject = useOfferWizardStore(s => s.setMessageSubject)
|
||||
const setBody = useOfferWizardStore(s => s.setMessageDraft)
|
||||
const attachments = useOfferWizardStore(s => s.attachments)
|
||||
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||
const removeAttachment = useOfferWizardStore(s => s.removeAttachment)
|
||||
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
|
||||
const selectedPropertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||
const needId = useOfferWizardStore(s => s.selectedNeedId)
|
||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||
const setStep = useOfferWizardStore(s => s.setStep)
|
||||
const reset = useOfferWizardStore(s => s.reset)
|
||||
|
||||
const sendOffer = useSendOffer()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
const propertyTitles = selectedPropertyIds.map(id => {
|
||||
const p = mockProperties.find(pp => pp.id === id)
|
||||
return p?.title ?? id
|
||||
})
|
||||
const scores = needId
|
||||
? selectedPropertyIds.map(id => deterministicMatchScore(id, needId))
|
||||
: []
|
||||
|
||||
const handleAiGenerated = (newSubject: string, newBody: string) => {
|
||||
setSubject(newSubject)
|
||||
setBody(newBody)
|
||||
showToast('KI-Vorschlag eingefügt', 'success')
|
||||
}
|
||||
|
||||
const handleAddAttachment = () => {
|
||||
addAttachment({
|
||||
id: crypto.randomUUID(),
|
||||
fileName: `Anhang_${attachments.length + 1}.pdf`,
|
||||
fileType: 'application/pdf',
|
||||
fileSize: 200_000 + Math.floor(Math.random() * 600_000),
|
||||
})
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!offerDraftId) return
|
||||
if (!body.trim()) {
|
||||
showToast('Bitte Nachricht eingeben', 'warning')
|
||||
return
|
||||
}
|
||||
const res = await sendOffer.mutateAsync(offerDraftId)
|
||||
if (res.error) {
|
||||
showToast(`Fehler: ${res.error}`, 'error')
|
||||
return
|
||||
}
|
||||
showToast('Angebot erfolgreich gesendet', 'success')
|
||||
reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, bgcolor: '#f8fafc' }}>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Empfänger-Kontext
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#1e293b', mt: 0.5 }}>
|
||||
Bedarf: <strong>{needTitle}</strong> · {selectedPropertyIds.length} Objekt
|
||||
{selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Betreff"
|
||||
fullWidth
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Nachricht"
|
||||
fullWidth
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
multiline
|
||||
rows={8}
|
||||
/>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, mb: 0.75, display: 'block' }}>
|
||||
Anhänge
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{attachments.map(a => (
|
||||
<Box
|
||||
key={a.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: 'white',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
}}
|
||||
>
|
||||
<FileText size={14} color="#1e3a5f" />
|
||||
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
|
||||
{a.fileName}
|
||||
</Typography>
|
||||
{a.generated && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 0.125,
|
||||
bgcolor: '#e0e7ff',
|
||||
color: '#3730a3',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.65rem',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
PDF
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: '#64748b', ml: 'auto' }}>
|
||||
{formatFileSize(a.fileSize)}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => removeAttachment(a.id)} sx={{ p: 0.25 }}>
|
||||
<X size={12} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Paperclip size={14} />}
|
||||
onClick={handleAddAttachment}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Anhang
|
||||
</Button>
|
||||
<AiOfferEmailButton
|
||||
needTitle={needTitle}
|
||||
selectedProperties={propertyTitles}
|
||||
matchScores={scores}
|
||||
onGenerated={handleAiGenerated}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
bgcolor: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Button startIcon={<ArrowLeft size={14} />} onClick={() => setStep('checked')} sx={{ textTransform: 'none' }}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={sendOffer.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
|
||||
onClick={handleSend}
|
||||
disabled={sendOffer.isPending || !body.trim()}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
bgcolor: '#16a34a',
|
||||
fontWeight: 600,
|
||||
'&:hover': { bgcolor: '#15803d' },
|
||||
}}
|
||||
>
|
||||
Absenden
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user