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,184 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Send, Paperclip, X } from 'lucide-react'
|
||||
import { useSendInquiryReply, useUpdateInquiryStatus } from '../../hooks/useInquiries'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { Attachment } from '../../domain/inquiry'
|
||||
import { formatFileSize } from './inquiryUtils'
|
||||
|
||||
interface InquiryReplyComposerProps {
|
||||
inquiryId: string
|
||||
defaultSubject: string
|
||||
onSent?: () => void
|
||||
}
|
||||
|
||||
export function InquiryReplyComposer({
|
||||
inquiryId,
|
||||
defaultSubject,
|
||||
onSent,
|
||||
}: InquiryReplyComposerProps) {
|
||||
const [subject, setSubject] = useState(
|
||||
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
|
||||
)
|
||||
const [body, setBody] = useState('')
|
||||
const [markAnswered, setMarkAnswered] = useState(true)
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
|
||||
const sendReply = useSendInquiryReply()
|
||||
const updateStatus = useUpdateInquiryStatus()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
const sending = sendReply.isPending || updateStatus.isPending
|
||||
|
||||
const handleAddMockAttachment = () => {
|
||||
const name = `Anhang_${attachments.length + 1}.pdf`
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
fileName: name,
|
||||
fileType: 'application/pdf',
|
||||
fileSize: 240_000 + Math.floor(Math.random() * 800_000),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.id !== id))
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!body.trim()) {
|
||||
showToast('Bitte geben Sie eine Nachricht ein', 'warning')
|
||||
return
|
||||
}
|
||||
const result = await sendReply.mutateAsync({
|
||||
inquiryId,
|
||||
payload: { subject, body, attachments },
|
||||
})
|
||||
if (result.error) {
|
||||
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([])
|
||||
onSent?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
bgcolor: '#f8fafc',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.25,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Betreff"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Nachricht"
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="Antwort verfassen..."
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{attachments.map(a => (
|
||||
<Box
|
||||
key={a.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
bgcolor: 'white',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Paperclip size={12} />
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||
{a.fileName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
|
||||
{formatFileSize(a.fileSize)}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
|
||||
<X size={12} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Paperclip size={14} />}
|
||||
onClick={handleAddMockAttachment}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Anhang
|
||||
</Button>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={markAnswered}
|
||||
onChange={e => setMarkAnswered(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="caption" sx={{ fontSize: '0.8rem' }}>
|
||||
Status auf "Beantwortet" setzen
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={
|
||||
sending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />
|
||||
}
|
||||
onClick={handleSend}
|
||||
disabled={sending || !body.trim()}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Antwort senden
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user