Files
property-match/src/pages/demand/Pipeline.tsx
T
Benjamin Sutter 128d28af8d 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>
2026-05-24 18:00:46 +02:00

164 lines
6.7 KiB
TypeScript

import { useState } from 'react'
import { useNavigate } from 'react-router'
import {
Box, Chip, Typography,
} from '@mui/material'
import {
DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { TrendingUp } from 'lucide-react'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
import { STAGES } from '../../components/pipeline/pipelineConstants'
import { DraggableCard } from '../../components/pipeline/PipelineCard'
import { DroppableColumn } from '../../components/pipeline/PipelineColumn'
import { DetailPanel } from '../../components/pipeline/PipelineDetailPanel'
// ── Pipeline page ─────────────────────────────────────────────────────────────
export default function Pipeline() {
const navigate = useNavigate()
const { data: items = [] } = usePipelineItems()
const { mutate: moveStage } = useMoveStage()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const [overId, setOverId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
)
const activeItem = activeId ? items.find(i => i.id === activeId) ?? null : null
const syncedSelected = selectedItem ? items.find(i => i.id === selectedItem.id) ?? null : null
const activeCount = items.filter(i => i.stage !== 'CLOSED_WON' && i.stage !== 'CLOSED_LOST').length
const wonItems = items.filter(i => i.stage === 'CLOSED_WON')
const wonScore = wonItems.length > 0
? Math.round(wonItems.reduce((s, i) => s + i.matchScore, 0) / wonItems.length)
: 0
function handleDragStart({ active }: DragStartEvent) {
setActiveId(active.id as string)
}
function handleDragOver({ over }: { over: { id: string | number } | null }) {
setOverId(over ? String(over.id) : null)
}
function handleDragEnd({ active, over }: DragEndEvent) {
setActiveId(null)
setOverId(null)
if (!over) return
const targetStage = over.id as PipelineStage
const item = items.find(i => i.id === active.id)
if (item && item.stage !== targetStage) {
moveStage({ id: item.id, stage: targetStage })
}
}
function handleSelect(item: PipelineItem) {
if (activeId) return // ignore clicks that fire after a drag
setSelectedItem(prev => prev?.id === item.id ? null : item)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<AddToPipelineDialog />
<InquiryQuickDialog />
{/* Header */}
<Box sx={{
bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
px: 3, py: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
}}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Deal Pipeline</Typography>
<Typography variant="body2" color="text.secondary">
Von der ersten Idee bis zum Abschluss per Drag &amp; Drop verschieben
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{wonItems.length > 0 && (
<Chip icon={<TrendingUp size={12} />} label={`${wonItems.length} gewonnen · ø ${wonScore}%`} size="small"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600, border: '1px solid #86efac' }} />
)}
<Chip label={`${activeCount} aktiv`} size="small" sx={{ fontWeight: 600 }} />
</Box>
</Box>
{/* Body */}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{/* Kanban */}
<Box sx={{ flex: 1, display: 'flex', gap: 2, px: 3, py: 2, overflowX: 'auto', alignItems: 'flex-start' }}>
{STAGES.map(stage => {
const columnItems = items.filter(i => i.stage === stage.key)
const isOver = overId === stage.key
return (
<Box
key={stage.key}
sx={{ minWidth: syncedSelected ? 190 : 230, maxWidth: syncedSelected ? 230 : 270, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
>
<Box sx={{ py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 700, color: stage.color, fontSize: '0.8125rem' }}>
{stage.label}
</Typography>
<Chip
label={columnItems.length}
size="small"
sx={{ bgcolor: stage.bgColor, color: stage.color, border: `1px solid ${stage.color}30`, fontWeight: 600, height: 20, fontSize: '0.7rem' }}
/>
</Box>
<DroppableColumn
stage={stage}
items={columnItems}
selectedId={syncedSelected?.id ?? null}
onSelect={handleSelect}
onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)}
onCardChatClick={(item) => openInquiryDialog({
propertyTitle: item.title,
location: item.location ?? '',
matchScore: item.matchScore ?? 0,
propertyId: item.propertyId,
})}
isOver={isOver}
/>
</Box>
)
})}
</Box>
{/* Drag overlay — the card that floats under the cursor */}
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
{activeItem && (
<DraggableCard
item={activeItem}
isSelected={false}
onSelect={() => {}}
isDragOverlay
/>
)}
</DragOverlay>
</DndContext>
{/* Detail panel */}
{syncedSelected && (
<DetailPanel item={syncedSelected} onClose={() => setSelectedItem(null)} />
)}
</Box>
</Box>
)
}