128d28af8d
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>
164 lines
6.7 KiB
TypeScript
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 & 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>
|
|
)
|
|
}
|