From a002597f5b44ea109aee8411c2b16bffbcb1b871 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 22 May 2026 21:34:00 +0200 Subject: [PATCH] feat: drag & drop for Pipeline Kanban columns (@dnd-kit) Cards can be dragged between all 7 stages. Drop target highlights with a dashed border + tinted background. A floating card overlay follows the cursor during drag. Click-to-select still works (fires only when no drag occurred, guarded by activeId check). Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 60 ++++- package.json | 3 + src/pages/demand/Pipeline.tsx | 423 +++++++++++++++++++--------------- 3 files changed, 297 insertions(+), 189 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a4502e..68c20bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "property-match", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.1", @@ -286,6 +289,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -3695,9 +3751,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index 32f5626..da3aa86 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.1", diff --git a/src/pages/demand/Pipeline.tsx b/src/pages/demand/Pipeline.tsx index 2509ae9..90b3ac6 100644 --- a/src/pages/demand/Pipeline.tsx +++ b/src/pages/demand/Pipeline.tsx @@ -1,56 +1,61 @@ import { useState } from 'react' import { - Box, Typography, Chip, Paper, Button, IconButton, - TextField, Divider, + Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, } from '@mui/material' +import { + DndContext, DragOverlay, PointerSensor, useSensor, useSensors, + useDroppable, useDraggable, closestCenter, +} from '@dnd-kit/core' +import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' +import { CSS } from '@dnd-kit/utilities' import { X, Sparkles, FileText, StickyNote, ChevronRight, TrendingUp, AlertTriangle, CheckCircle, Bookmark } from 'lucide-react' import { usePipelineStore } from '../../stores/pipelineStore' import { AddToPipelineDialog } from '../../components/shortlist' import type { PipelineItem, PipelineStage } from '../../domain/pipeline' -// ── Stage config ───────────────────────────────────────────────────────────── +// ── Stage config ────────────────────────────────────────────────────────────── const STAGES = [ - { key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc', icon: Bookmark }, - { key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff', icon: null }, - { key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff', icon: null }, - { key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb', icon: null }, - { key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff', icon: null }, - { key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4', icon: null }, - { key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2', icon: null }, + { key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' }, + { key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' }, + { key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' }, + { key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' }, + { key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' }, + { key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' }, + { key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' }, ] as const const NEXT_STAGE: Partial> = { - SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' }, - DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' }, - QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' }, - VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' }, + SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' }, + DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' }, + QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' }, + VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' }, NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' }, } const RESULT_TYPE_LABEL: Record = { VERIFIED_PORTFOLIO: 'Portfolio', - EXTERNAL_MARKET: 'Direktinserat', - MAISON_WORK: 'Maison Work', - FUTURE_AVAILABILITY: 'Future', + EXTERNAL_MARKET: 'Direktinserat', + MAISON_WORK: 'Maison Work', + FUTURE_AVAILABILITY:'Future', } const RESULT_TYPE_COLOR: Record = { - VERIFIED_PORTFOLIO: '#1e3a5f', - EXTERNAL_MARKET: '#d97706', - MAISON_WORK: '#0369a1', + VERIFIED_PORTFOLIO: '#1e3a5f', + EXTERNAL_MARKET: '#d97706', + MAISON_WORK: '#0369a1', FUTURE_AVAILABILITY: '#7c3aed', } const MOCK_DOCS: Record = { 'pl-001': [ - { name: 'Expose_Zollstrasse12.pdf', date: '05.05.2026' }, - { name: 'Grundriss_EG.pdf', date: '08.05.2026' }, - { name: 'Mietvertrag_Entwurf.docx', date: '14.05.2026' }, + { name: 'Expose_Zollstrasse12.pdf', date: '05.05.2026' }, + { name: 'Grundriss_EG.pdf', date: '08.05.2026' }, + { name: 'Mietvertrag_Entwurf.docx', date: '14.05.2026' }, ], 'pl-007': [ - { name: 'Expose_Stadthaus_Bern.pdf', date: '12.04.2026' }, - { name: 'Mietvertrag_unterschrieben.pdf', date: '02.05.2026' }, + { name: 'Expose_Stadthaus_Bern.pdf', date: '12.04.2026' }, + { name: 'Mietvertrag_unterschrieben.pdf', date: '02.05.2026' }, ], } @@ -59,26 +64,20 @@ function scoreColor(score: number) { } function getKiInsight(item: PipelineItem): { summary: string; positives: string[]; risks: string[] } { - if (item.stage === 'SAVED') { - return { - summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt für die Qualifizierung geeignet ist.`, - positives: [`Match-Score ${item.matchScore}%`], - risks: ['Noch nicht qualifiziert — Eignung prüfen'], - } + if (item.stage === 'SAVED') return { + summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt qualifiziert werden soll.`, + positives: [`Match ${item.matchScore}%`], + risks: ['Noch nicht qualifiziert'], } - if (item.stage === 'CLOSED_WON') { - return { - summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match-Score abgeschlossen.`, - positives: ['Vertraglich gesichert', `Match ${item.matchScore}%`, 'Alle Kriterien erfüllt'], - risks: [], - } + if (item.stage === 'CLOSED_WON') return { + summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match abgeschlossen.`, + positives: ['Vertraglich gesichert', `Match ${item.matchScore}%`, 'Alle Kriterien erfüllt'], + risks: [], } - if (item.stage === 'CLOSED_LOST') { - return { - summary: item.notes ?? 'Objekt nicht realisiert.', - positives: [], - risks: ['Nicht verfügbar', 'Alternative Optionen prüfen'], - } + if (item.stage === 'CLOSED_LOST') return { + summary: item.notes ?? 'Objekt nicht realisiert.', + positives: [], + risks: ['Nicht verfügbar', 'Alternative Optionen prüfen'], } const s = item.matchScore return { @@ -86,7 +85,7 @@ function getKiInsight(item: PipelineItem): { summary: string; positives: string[ ? `Starkes Objekt (${s}%) — deckt die wesentlichen Suchkriterien ab. Prozess aktiv weitertreiben.` : s >= 65 ? `Solides Objekt (${s}%) mit Potenzial. Gezielte Klärung offener Punkte empfohlen.` - : `Schwächerer Match (${s}%). Kritisch prüfen bevor weitere Ressourcen investiert werden.`, + : `Schwächerer Match (${s}%). Abweichungen kritisch prüfen bevor weitere Ressourcen investiert werden.`, positives: [ ...(s >= 80 ? [`Match ${s}% — hohe Übereinstimmung`] : []), ...(item.areaLabel ? [`Fläche: ${item.areaLabel}`] : []), @@ -101,29 +100,49 @@ function getKiInsight(item: PipelineItem): { summary: string; positives: string[ } } -// ── PipelineCard ───────────────────────────────────────────────────────────── +// ── DraggableCard ───────────────────────────────────────────────────────────── -function PipelineCard({ +function DraggableCard({ item, isSelected, onSelect, + isDragOverlay = false, }: { item: PipelineItem isSelected: boolean onSelect: (item: PipelineItem) => void + isDragOverlay?: boolean }) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id }) + + const style = !isDragOverlay ? { + transform: CSS.Translate.toString(transform), + opacity: isDragging ? 0.35 : 1, + transition: isDragging ? undefined : 'opacity 0.15s ease', + } : undefined + return ( onSelect(item)} + ref={!isDragOverlay ? setNodeRef : undefined} + style={style} + elevation={isDragOverlay ? 6 : 0} + onClick={() => !isDragging && onSelect(item)} sx={{ - p: 1.5, borderRadius: 1.5, - border: isSelected ? '2px solid #1e3a5f' : '1px solid #e2e8f0', - cursor: 'pointer', - bgcolor: isSelected ? '#eff6ff' : 'white', - '&:hover': { boxShadow: '0 2px 8px rgba(0,0,0,0.1)', borderColor: isSelected ? '#1e3a5f' : '#bfdbfe' }, - transition: 'box-shadow 0.15s, border-color 0.15s', + p: 1.5, + borderRadius: 1.5, + border: isSelected && !isDragOverlay ? '2px solid #1e3a5f' : '1px solid #e2e8f0', + cursor: isDragOverlay ? 'grabbing' : 'grab', + bgcolor: isDragOverlay ? 'white' : isSelected ? '#eff6ff' : 'white', + boxShadow: isDragOverlay ? '0 8px 24px rgba(0,0,0,0.18)' : undefined, + '&:hover': isDragOverlay ? {} : { + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + borderColor: isSelected ? '#1e3a5f' : '#bfdbfe', + }, + transition: isDragOverlay ? undefined : 'box-shadow 0.15s, border-color 0.15s', + userSelect: 'none', + rotate: isDragOverlay ? '2deg' : undefined, }} + {...(isDragOverlay ? {} : { ...attributes, ...listeners })} > @@ -143,7 +162,7 @@ function PipelineCard({ sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 18 }} /> {item.areaLabel && {item.areaLabel}} - {item.rentLabel && {item.rentLabel}} + {item.rentLabel && {item.rentLabel}} {item.notes && ( @@ -154,42 +173,81 @@ function PipelineCard({ ) } +// ── DroppableColumn ─────────────────────────────────────────────────────────── + +function DroppableColumn({ + stage, + items, + selectedId, + onSelect, + isOver, +}: { + stage: typeof STAGES[number] + items: PipelineItem[] + selectedId: string | null + onSelect: (item: PipelineItem) => void + isOver: boolean +}) { + const { setNodeRef } = useDroppable({ id: stage.key }) + + return ( + + {items.map(item => ( + + ))} + {items.length === 0 && ( + + + {isOver ? 'Hier ablegen' : 'Leer'} + + + )} + + ) +} + // ── DetailPanel ─────────────────────────────────────────────────────────────── -function DetailPanel({ - item, - onClose, -}: { - item: PipelineItem - onClose: () => void -}) { +function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) { const { moveStage, updateNotes, loseItem } = usePipelineStore() const [notes, setNotes] = useState(item.notes ?? '') const stageConfig = STAGES.find(s => s.key === item.stage)! - const stageIndex = STAGES.findIndex(s => s.key === item.stage) - const nextStage = NEXT_STAGE[item.stage] - const ki = getKiInsight(item) + const stageIndex = STAGES.findIndex(s => s.key === item.stage) + const nextStage = NEXT_STAGE[item.stage] + const ki = getKiInsight(item) const docs = MOCK_DOCS[item.id] ?? [] const isClosed = item.stage === 'CLOSED_WON' || item.stage === 'CLOSED_LOST' - - // progress bar: SAVED=0, DISCOVERED=1, QUALIFIED=2, VISITED=3, NEGOTIATION=4 - const activeStages = STAGES.slice(0, 5) const progressIdx = Math.min(stageIndex, 4) return ( {/* Header */} - - {item.title} - + {item.title} {item.location} @@ -201,30 +259,18 @@ function DetailPanel({ - - {/* Stage progress bar */} - {activeStages.map((s, idx) => ( - + {STAGES.slice(0, 5).map((s, idx) => ( + ))} - + - {/* KI insight */} + {/* KI */} @@ -259,22 +305,15 @@ function DetailPanel({ {nextStage && ( - )} - @@ -292,10 +331,7 @@ function DetailPanel({ setNotes(e.target.value)} @@ -315,21 +351,16 @@ function DetailPanel({ {docs.length === 0 ? ( - - Noch keine Dokumente. - + Noch keine Dokumente. ) : ( {docs.map((doc, i) => ( - + {doc.name} {doc.date} @@ -342,20 +373,11 @@ function DetailPanel({ - {/* Meta */} - {item.availabilityLabel && ( - - Verfügbar: {item.availabilityLabel} - - )} - {item.assignedTo && ( - - Verantwortlich: {item.assignedTo} - - )} + {item.availabilityLabel && Verfügbar: {item.availabilityLabel}} + {item.assignedTo && Verantwortlich: {item.assignedTo}} Hinzugefügt: {new Date(item.addedAt).toLocaleDateString('de-CH')} @@ -369,23 +391,47 @@ function DetailPanel({ // ── Pipeline page ───────────────────────────────────────────────────────────── export default function Pipeline() { - const { items } = usePipelineStore() + const { items, moveStage } = usePipelineStore() const [selectedItem, setSelectedItem] = useState(null) + const [activeId, setActiveId] = useState(null) + const [overId, setOverId] = useState(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 wonCount = items.filter(i => i.stage === 'CLOSED_WON').length - const wonScore = wonCount > 0 - ? Math.round(items.filter(i => i.stage === 'CLOSED_WON').reduce((sum, i) => sum + i.matchScore, 0) / wonCount) + 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 handleSelect(item: PipelineItem) { - setSelectedItem(prev => prev?.id === item.id ? null : item) + function handleDragStart({ active }: DragStartEvent) { + setActiveId(active.id as string) } - // Keep selected item in sync when store updates (stage change, notes, etc.) - const syncedSelected = selectedItem - ? items.find(i => i.id === selectedItem.id) ?? null - : null + function handleDragOver({ over }: { over: { id: string } | null }) { + setOverId(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(item.id, targetStage) + } + } + + function handleSelect(item: PipelineItem) { + if (activeId) return // ignore clicks that fire after a drag + setSelectedItem(prev => prev?.id === item.id ? null : item) + } return ( @@ -399,17 +445,13 @@ export default function Pipeline() { Deal Pipeline - Von der ersten Idee bis zum Abschluss — alles in einer Ansicht + Von der ersten Idee bis zum Abschluss — per Drag & Drop verschieben - {wonCount > 0 && ( - } - label={`${wonCount} gewonnen · ø ${wonScore}%`} - size="small" - sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600, border: '1px solid #86efac' }} - /> + {wonItems.length > 0 && ( + } label={`${wonItems.length} gewonnen · ø ${wonScore}%`} size="small" + sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600, border: '1px solid #86efac' }} /> )} @@ -417,53 +459,62 @@ export default function Pipeline() { {/* Body */} - {/* Kanban */} - - {STAGES.map(stage => { - const columnItems = items.filter(i => i.stage === stage.key) - return ( - - - - {stage.label} - - + {/* Kanban */} + + {STAGES.map(stage => { + const columnItems = items.filter(i => i.stage === stage.key) + const isOver = overId === stage.key + + return ( + + + + {stage.label} + + + + - - {columnItems.map(item => ( - - ))} - {columnItems.length === 0 && ( - - - Leer - - - )} - - - ) - })} - + ) + })} + + + {/* Drag overlay — the card that floats under the cursor */} + + {activeItem && ( + {}} + isDragOverlay + /> + )} + + {/* Detail panel */} {syncedSelected && ( - setSelectedItem(null)} - /> + setSelectedItem(null)} /> )}