feat: in-memory inquiry flow — send from card, pipeline, and detail

New inquiryStore (Zustand) holds sent inquiries for the session.
InquiryQuickDialog reads from the store (no props), renders wherever needed.
Sent inquiries appear immediately at the top of the Anfragen page.

Entry points:
- Match cards: "Anfrage" button on every card in the results feed
- MatchDetail: primary action in NextActionsPanel
- Pipeline: chat icon on every DraggableCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 18:00:46 +02:00
parent 9df5d285f5
commit 128d28af8d
9 changed files with 252 additions and 58 deletions
@@ -0,0 +1,87 @@
import { useState, useEffect } from 'react'
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
TextField, Typography,
} from '@mui/material'
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
}
export function InquiryQuickDialog() {
const showToast = useToastStore(s => s.showToast)
const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
const [message, setMessage] = useState('')
useEffect(() => {
if (dialogOpen && pendingInquiry) {
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
}
}, [dialogOpen, pendingInquiry])
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
closeInquiryDialog()
}
if (!pendingInquiry) return null
return (
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Send size={16} />
Anfrage senden
</DialogTitle>
<DialogContent sx={{ pt: 0 }}>
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{pendingInquiry.location}
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
{' · '}Match {pendingInquiry.matchScore}%
</Typography>
</Box>
<TextField
fullWidth
multiline
rows={8}
value={message}
onChange={e => setMessage(e.target.value)}
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
Der Text kann vor dem Absenden angepasst werden.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
<Button
onClick={handleSend}
variant="contained"
size="small"
disabled={!message.trim()}
startIcon={<Send size={14} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
</DialogActions>
</Dialog>
)
}
@@ -1,23 +1,17 @@
import { Box, Button, Paper, Typography } from '@mui/material'
import { MessageSquare } from 'lucide-react'
import type { Match, NextBestAction } from '../../domain/match'
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
HIGH: 'contained',
MEDIUM: 'outlined',
LOW: 'outlined',
}
interface Props {
match: Match
onCompare?: () => void
onShortlist?: () => void
onReject?: () => void
onReview?: () => void
onPipeline?: () => void
onInquire?: () => void
}
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
)
@@ -26,37 +20,41 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
{engineActions.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
{engineActions.map((action, i) => (
<Box key={i}>
<Button
fullWidth
size="small"
variant={PRIORITY_COLOR[action.priority]}
sx={
action.priority === 'HIGH'
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
: { justifyContent: 'flex-start' }
}
>
{action.label}
</Button>
{action.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
{action.description}
</Typography>
)}
</Box>
))}
</Box>
)}
{/* Standard actions */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{onShortlist && (
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
Zur Shortlist hinzufügen
{onInquire && (
<Button
fullWidth
size="small"
variant="contained"
startIcon={<MessageSquare size={14} />}
onClick={onInquire}
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
)}
{engineActions.length > 0 && engineActions.map((action, i) => (
<Box key={i}>
<Button
fullWidth
size="small"
variant="outlined"
sx={{ justifyContent: 'flex-start' }}
>
{action.label}
</Button>
{action.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
{action.description}
</Typography>
)}
</Box>
))}
{onPipeline && (
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
Zur Pipeline hinzufügen
</Button>
)}
{onCompare && (
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
Zum Vergleich hinzufügen
</Button>
)}
{onReview && (
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
Zur Überprüfung senden
</Button>
)}
{onReject && (
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
Match ablehnen
</Button>
)}
</Box>
</Paper>
)