Files
property-match/src/pages/demand/Pipeline.tsx
T
Benjamin Sutter 723f553939 refactor: move PipelineItems from Zustand to Provider→Service→React Query
PipelineItems are domain data and must not live in Zustand. Moves the
full stack to the correct layer: MockupPipelineProvider (localStorage
persistence + seed fallback) → pipelineService → usePipeline hooks
(useQuery for reads, useMutation for writes with cache invalidation).

pipelineStore is now UI-only: dialogOpen, pendingItem, openSavedDialog,
closeSavedDialog. All consumers updated to use the new hooks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:32:57 +02:00

154 lines
6.2 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 { AddToPipelineDialog } from '../../components/shortlist'
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 [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 />
{/* 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}`)}
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>
)
}