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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-22 21:34:00 +02:00
parent de00f758e9
commit a002597f5b
3 changed files with 297 additions and 189 deletions
+57 -3
View File
@@ -8,6 +8,9 @@
"name": "property-match", "name": "property-match",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "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/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.1", "@mui/icons-material": "^9.0.1",
@@ -286,6 +289,59 @@
"node": ">=6.9.0" "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": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -3695,9 +3751,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
+3
View File
@@ -10,6 +10,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.1", "@mui/icons-material": "^9.0.1",
+237 -186
View File
@@ -1,56 +1,61 @@
import { useState } from 'react' import { useState } from 'react'
import { import {
Box, Typography, Chip, Paper, Button, IconButton, Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider,
TextField, Divider,
} from '@mui/material' } 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 { X, Sparkles, FileText, StickyNote, ChevronRight, TrendingUp, AlertTriangle, CheckCircle, Bookmark } from 'lucide-react'
import { usePipelineStore } from '../../stores/pipelineStore' import { usePipelineStore } from '../../stores/pipelineStore'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import type { PipelineItem, PipelineStage } from '../../domain/pipeline' import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
// ── Stage config ───────────────────────────────────────────────────────────── // ── Stage config ─────────────────────────────────────────────────────────────
const STAGES = [ const STAGES = [
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc', icon: Bookmark }, { key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff', icon: null }, { key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff', icon: null }, { key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb', icon: null }, { key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff', icon: null }, { key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4', icon: null }, { key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2', icon: null }, { key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
] as const ] as const
const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = { const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' }, SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' }, DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' }, QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' }, VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' }, NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
} }
const RESULT_TYPE_LABEL: Record<string, string> = { const RESULT_TYPE_LABEL: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Portfolio', VERIFIED_PORTFOLIO: 'Portfolio',
EXTERNAL_MARKET: 'Direktinserat', EXTERNAL_MARKET: 'Direktinserat',
MAISON_WORK: 'Maison Work', MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY: 'Future', FUTURE_AVAILABILITY:'Future',
} }
const RESULT_TYPE_COLOR: Record<string, string> = { const RESULT_TYPE_COLOR: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f', VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706', EXTERNAL_MARKET: '#d97706',
MAISON_WORK: '#0369a1', MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed', FUTURE_AVAILABILITY: '#7c3aed',
} }
const MOCK_DOCS: Record<string, { name: string; date: string }[]> = { const MOCK_DOCS: Record<string, { name: string; date: string }[]> = {
'pl-001': [ 'pl-001': [
{ name: 'Expose_Zollstrasse12.pdf', date: '05.05.2026' }, { name: 'Expose_Zollstrasse12.pdf', date: '05.05.2026' },
{ name: 'Grundriss_EG.pdf', date: '08.05.2026' }, { name: 'Grundriss_EG.pdf', date: '08.05.2026' },
{ name: 'Mietvertrag_Entwurf.docx', date: '14.05.2026' }, { name: 'Mietvertrag_Entwurf.docx', date: '14.05.2026' },
], ],
'pl-007': [ 'pl-007': [
{ name: 'Expose_Stadthaus_Bern.pdf', date: '12.04.2026' }, { name: 'Expose_Stadthaus_Bern.pdf', date: '12.04.2026' },
{ name: 'Mietvertrag_unterschrieben.pdf', date: '02.05.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[] } { function getKiInsight(item: PipelineItem): { summary: string; positives: string[]; risks: string[] } {
if (item.stage === 'SAVED') { if (item.stage === 'SAVED') return {
return { summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt qualifiziert werden soll.`,
summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt für die Qualifizierung geeignet ist.`, positives: [`Match ${item.matchScore}%`],
positives: [`Match-Score ${item.matchScore}%`], risks: ['Noch nicht qualifiziert'],
risks: ['Noch nicht qualifiziert — Eignung prüfen'],
}
} }
if (item.stage === 'CLOSED_WON') { if (item.stage === 'CLOSED_WON') return {
return { summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match abgeschlossen.`,
summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match-Score abgeschlossen.`, positives: ['Vertraglich gesichert', `Match ${item.matchScore}%`, 'Alle Kriterien erfüllt'],
positives: ['Vertraglich gesichert', `Match ${item.matchScore}%`, 'Alle Kriterien erfüllt'], risks: [],
risks: [],
}
} }
if (item.stage === 'CLOSED_LOST') { if (item.stage === 'CLOSED_LOST') return {
return { summary: item.notes ?? 'Objekt nicht realisiert.',
summary: item.notes ?? 'Objekt nicht realisiert.', positives: [],
positives: [], risks: ['Nicht verfügbar', 'Alternative Optionen prüfen'],
risks: ['Nicht verfügbar', 'Alternative Optionen prüfen'],
}
} }
const s = item.matchScore const s = item.matchScore
return { return {
@@ -86,7 +85,7 @@ function getKiInsight(item: PipelineItem): { summary: string; positives: string[
? `Starkes Objekt (${s}%) — deckt die wesentlichen Suchkriterien ab. Prozess aktiv weitertreiben.` ? `Starkes Objekt (${s}%) — deckt die wesentlichen Suchkriterien ab. Prozess aktiv weitertreiben.`
: s >= 65 : s >= 65
? `Solides Objekt (${s}%) mit Potenzial. Gezielte Klärung offener Punkte empfohlen.` ? `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: [ positives: [
...(s >= 80 ? [`Match ${s}% — hohe Übereinstimmung`] : []), ...(s >= 80 ? [`Match ${s}% — hohe Übereinstimmung`] : []),
...(item.areaLabel ? [`Fläche: ${item.areaLabel}`] : []), ...(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, item,
isSelected, isSelected,
onSelect, onSelect,
isDragOverlay = false,
}: { }: {
item: PipelineItem item: PipelineItem
isSelected: boolean isSelected: boolean
onSelect: (item: PipelineItem) => void 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 ( return (
<Paper <Paper
elevation={0} ref={!isDragOverlay ? setNodeRef : undefined}
onClick={() => onSelect(item)} style={style}
elevation={isDragOverlay ? 6 : 0}
onClick={() => !isDragging && onSelect(item)}
sx={{ sx={{
p: 1.5, borderRadius: 1.5, p: 1.5,
border: isSelected ? '2px solid #1e3a5f' : '1px solid #e2e8f0', borderRadius: 1.5,
cursor: 'pointer', border: isSelected && !isDragOverlay ? '2px solid #1e3a5f' : '1px solid #e2e8f0',
bgcolor: isSelected ? '#eff6ff' : 'white', cursor: isDragOverlay ? 'grabbing' : 'grab',
'&:hover': { boxShadow: '0 2px 8px rgba(0,0,0,0.1)', borderColor: isSelected ? '#1e3a5f' : '#bfdbfe' }, bgcolor: isDragOverlay ? 'white' : isSelected ? '#eff6ff' : 'white',
transition: 'box-shadow 0.15s, border-color 0.15s', 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 })}
> >
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1, minWidth: 0, lineHeight: 1.3 }} noWrap> <Typography variant="body2" sx={{ fontWeight: 700, flex: 1, minWidth: 0, lineHeight: 1.3 }} noWrap>
@@ -143,7 +162,7 @@ function PipelineCard({
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 18 }} sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 18 }}
/> />
{item.areaLabel && <Typography variant="caption" sx={{ color: '#475569' }}>{item.areaLabel}</Typography>} {item.areaLabel && <Typography variant="caption" sx={{ color: '#475569' }}>{item.areaLabel}</Typography>}
{item.rentLabel && <Typography variant="caption" sx={{ color: '#475569' }}>{item.rentLabel}</Typography>} {item.rentLabel && <Typography variant="caption" sx={{ color: '#475569' }}>{item.rentLabel}</Typography>}
</Box> </Box>
{item.notes && ( {item.notes && (
<Typography variant="caption" sx={{ fontStyle: 'italic', color: '#94a3b8', mt: 0.5, display: 'block' }} noWrap> <Typography variant="caption" sx={{ fontStyle: 'italic', color: '#94a3b8', mt: 0.5, display: 'block' }} noWrap>
@@ -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 (
<Box
ref={setNodeRef}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
minHeight: 80,
p: 0.75,
borderRadius: 2,
bgcolor: isOver ? `${stage.color}10` : 'transparent',
border: isOver ? `2px dashed ${stage.color}60` : '2px solid transparent',
transition: 'background-color 0.15s, border-color 0.15s',
}}
>
{items.map(item => (
<DraggableCard
key={item.id}
item={item}
isSelected={item.id === selectedId}
onSelect={onSelect}
/>
))}
{items.length === 0 && (
<Box sx={{ py: 2, textAlign: 'center' }}>
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
{isOver ? 'Hier ablegen' : 'Leer'}
</Typography>
</Box>
)}
</Box>
)
}
// ── DetailPanel ─────────────────────────────────────────────────────────────── // ── DetailPanel ───────────────────────────────────────────────────────────────
function DetailPanel({ function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) {
item,
onClose,
}: {
item: PipelineItem
onClose: () => void
}) {
const { moveStage, updateNotes, loseItem } = usePipelineStore() const { moveStage, updateNotes, loseItem } = usePipelineStore()
const [notes, setNotes] = useState(item.notes ?? '') const [notes, setNotes] = useState(item.notes ?? '')
const stageConfig = STAGES.find(s => s.key === item.stage)! const stageConfig = STAGES.find(s => s.key === item.stage)!
const stageIndex = STAGES.findIndex(s => s.key === item.stage) const stageIndex = STAGES.findIndex(s => s.key === item.stage)
const nextStage = NEXT_STAGE[item.stage] const nextStage = NEXT_STAGE[item.stage]
const ki = getKiInsight(item) const ki = getKiInsight(item)
const docs = MOCK_DOCS[item.id] ?? [] const docs = MOCK_DOCS[item.id] ?? []
const isClosed = item.stage === 'CLOSED_WON' || item.stage === 'CLOSED_LOST' 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) const progressIdx = Math.min(stageIndex, 4)
return ( return (
<Box sx={{ <Box sx={{
width: { xs: '100%', md: 340 }, flexShrink: 0, width: { xs: '100%', md: 340 }, flexShrink: 0,
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
bgcolor: 'white', borderLeft: '1px solid #e2e8f0', bgcolor: 'white', borderLeft: '1px solid #e2e8f0', overflow: 'hidden',
overflow: 'hidden',
}}> }}>
{/* Header */} {/* Header */}
<Box sx={{ px: 2, py: 2, borderBottom: '1px solid #e2e8f0' }}> <Box sx={{ px: 2, py: 2, borderBottom: '1px solid #e2e8f0' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}> <Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>{item.title}</Typography>
{item.title}
</Typography>
<Typography variant="caption" color="text.secondary">{item.location}</Typography> <Typography variant="caption" color="text.secondary">{item.location}</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
@@ -201,30 +259,18 @@ function DetailPanel({
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
{/* Stage progress bar */}
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', gap: 0.25, mb: 1 }}> <Box sx={{ display: 'flex', gap: 0.25, mb: 1 }}>
{activeStages.map((s, idx) => ( {STAGES.slice(0, 5).map((s, idx) => (
<Box <Box key={s.key} sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: idx <= progressIdx ? stageConfig.color : '#e2e8f0' }} />
key={s.key}
sx={{
flex: 1, height: 4, borderRadius: 2,
bgcolor: idx <= progressIdx ? stageConfig.color : '#e2e8f0',
}}
/>
))} ))}
</Box> </Box>
<Chip <Chip label={stageConfig.label} size="small" sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontWeight: 700, height: 22, fontSize: '0.75rem' }} />
label={stageConfig.label}
size="small"
sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontWeight: 700, height: 22, fontSize: '0.75rem' }}
/>
</Box> </Box>
</Box> </Box>
<Box sx={{ flex: 1, overflowY: 'auto' }}> <Box sx={{ flex: 1, overflowY: 'auto' }}>
{/* KI insight */} {/* KI */}
<Box sx={{ px: 2, pt: 2, pb: 1.5 }}> <Box sx={{ px: 2, pt: 2, pb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
<Sparkles size={13} color="#7c3aed" /> <Sparkles size={13} color="#7c3aed" />
@@ -259,22 +305,15 @@ function DetailPanel({
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{nextStage && ( {nextStage && (
<Button <Button size="small" variant="contained" endIcon={<ChevronRight size={14} />}
size="small"
variant="contained"
endIcon={<ChevronRight size={14} />}
onClick={() => moveStage(item.id, nextStage.key)} onClick={() => moveStage(item.id, nextStage.key)}
sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }} sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }}
> >
{nextStage.label} {nextStage.label}
</Button> </Button>
)} )}
<Button <Button size="small" variant="outlined" onClick={() => loseItem(item.id)}
size="small" sx={{ color: '#c0392b', borderColor: '#c0392b', fontSize: '0.75rem', py: 0.5 }}>
variant="outlined"
onClick={() => loseItem(item.id)}
sx={{ color: '#c0392b', borderColor: '#c0392b', fontSize: '0.75rem', py: 0.5 }}
>
Ablehnen Ablehnen
</Button> </Button>
</Box> </Box>
@@ -292,10 +331,7 @@ function DetailPanel({
</Typography> </Typography>
</Box> </Box>
<TextField <TextField
size="small" size="small" multiline minRows={3} fullWidth
multiline
minRows={3}
fullWidth
placeholder="Notiz hinzufügen…" placeholder="Notiz hinzufügen…"
value={notes} value={notes}
onChange={e => setNotes(e.target.value)} onChange={e => setNotes(e.target.value)}
@@ -315,21 +351,16 @@ function DetailPanel({
</Typography> </Typography>
</Box> </Box>
{docs.length === 0 ? ( {docs.length === 0 ? (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}> <Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>Noch keine Dokumente.</Typography>
Noch keine Dokumente.
</Typography>
) : ( ) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{docs.map((doc, i) => ( {docs.map((doc, i) => (
<Box <Box key={i} sx={{
key={i} display: 'flex', alignItems: 'center', gap: 1,
sx={{ px: 1.5, py: 0.75, bgcolor: '#f8fafc',
display: 'flex', alignItems: 'center', gap: 1, borderRadius: 1.5, border: '1px solid #e2e8f0',
px: 1.5, py: 0.75, bgcolor: '#f8fafc', cursor: 'pointer', '&:hover': { bgcolor: '#f1f5f9' },
borderRadius: 1.5, border: '1px solid #e2e8f0', }}>
cursor: 'pointer', '&:hover': { bgcolor: '#f1f5f9' },
}}
>
<FileText size={13} color="#475569" /> <FileText size={13} color="#475569" />
<Typography variant="caption" sx={{ flex: 1, color: '#1e293b' }} noWrap>{doc.name}</Typography> <Typography variant="caption" sx={{ flex: 1, color: '#1e293b' }} noWrap>{doc.name}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{doc.date}</Typography> <Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{doc.date}</Typography>
@@ -342,20 +373,11 @@ function DetailPanel({
</Button> </Button>
</Box> </Box>
{/* Meta */}
<Box sx={{ px: 2, pb: 2 }}> <Box sx={{ px: 2, pb: 2 }}>
<Divider sx={{ mb: 1.5 }} /> <Divider sx={{ mb: 1.5 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{item.availabilityLabel && ( {item.availabilityLabel && <Typography variant="caption" color="text.secondary"><strong>Verfügbar:</strong> {item.availabilityLabel}</Typography>}
<Typography variant="caption" color="text.secondary"> {item.assignedTo && <Typography variant="caption" color="text.secondary"><strong>Verantwortlich:</strong> {item.assignedTo}</Typography>}
<strong>Verfügbar:</strong> {item.availabilityLabel}
</Typography>
)}
{item.assignedTo && (
<Typography variant="caption" color="text.secondary">
<strong>Verantwortlich:</strong> {item.assignedTo}
</Typography>
)}
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
<strong>Hinzugefügt:</strong> {new Date(item.addedAt).toLocaleDateString('de-CH')} <strong>Hinzugefügt:</strong> {new Date(item.addedAt).toLocaleDateString('de-CH')}
</Typography> </Typography>
@@ -369,23 +391,47 @@ function DetailPanel({
// ── Pipeline page ───────────────────────────────────────────────────────────── // ── Pipeline page ─────────────────────────────────────────────────────────────
export default function Pipeline() { export default function Pipeline() {
const { items } = usePipelineStore() const { items, moveStage } = usePipelineStore()
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null) 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 activeCount = items.filter(i => i.stage !== 'CLOSED_WON' && i.stage !== 'CLOSED_LOST').length
const wonCount = items.filter(i => i.stage === 'CLOSED_WON').length const wonItems = items.filter(i => i.stage === 'CLOSED_WON')
const wonScore = wonCount > 0 const wonScore = wonItems.length > 0
? Math.round(items.filter(i => i.stage === 'CLOSED_WON').reduce((sum, i) => sum + i.matchScore, 0) / wonCount) ? Math.round(wonItems.reduce((s, i) => s + i.matchScore, 0) / wonItems.length)
: 0 : 0
function handleSelect(item: PipelineItem) { function handleDragStart({ active }: DragStartEvent) {
setSelectedItem(prev => prev?.id === item.id ? null : item) setActiveId(active.id as string)
} }
// Keep selected item in sync when store updates (stage change, notes, etc.) function handleDragOver({ over }: { over: { id: string } | null }) {
const syncedSelected = selectedItem setOverId(over?.id ?? null)
? items.find(i => i.id === selectedItem.id) ?? null }
: 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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -399,17 +445,13 @@ export default function Pipeline() {
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Deal Pipeline</Typography> <Typography variant="h5" sx={{ fontWeight: 700 }}>Deal Pipeline</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Von der ersten Idee bis zum Abschluss alles in einer Ansicht Von der ersten Idee bis zum Abschluss per Drag &amp; Drop verschieben
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{wonCount > 0 && ( {wonItems.length > 0 && (
<Chip <Chip icon={<TrendingUp size={12} />} label={`${wonItems.length} gewonnen · ø ${wonScore}%`} size="small"
icon={<TrendingUp size={12} />} sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600, border: '1px solid #86efac' }} />
label={`${wonCount} gewonnen · ø ${wonScore}%`}
size="small"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600, border: '1px solid #86efac' }}
/>
)} )}
<Chip label={`${activeCount} aktiv`} size="small" sx={{ fontWeight: 600 }} /> <Chip label={`${activeCount} aktiv`} size="small" sx={{ fontWeight: 600 }} />
</Box> </Box>
@@ -417,53 +459,62 @@ export default function Pipeline() {
{/* Body */} {/* Body */}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}> <Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Kanban */} <DndContext
<Box sx={{ flex: 1, display: 'flex', gap: 2, px: 3, py: 2, overflowX: 'auto', alignItems: 'flex-start' }}> sensors={sensors}
{STAGES.map(stage => { collisionDetection={closestCenter}
const columnItems = items.filter(i => i.stage === stage.key) onDragStart={handleDragStart}
return ( onDragOver={handleDragOver}
<Box onDragEnd={handleDragEnd}
key={stage.key} >
sx={{ minWidth: syncedSelected ? 190 : 230, maxWidth: syncedSelected ? 230 : 270, flexShrink: 0, display: 'flex', flexDirection: 'column' }} {/* Kanban */}
> <Box sx={{ flex: 1, display: 'flex', gap: 2, px: 3, py: 2, overflowX: 'auto', alignItems: 'flex-start' }}>
<Box sx={{ py: 1, display: 'flex', alignItems: 'center', gap: 1 }}> {STAGES.map(stage => {
<Typography sx={{ fontWeight: 700, color: stage.color, fontSize: '0.8125rem' }}> const columnItems = items.filter(i => i.stage === stage.key)
{stage.label} const isOver = overId === stage.key
</Typography>
<Chip return (
label={columnItems.length} <Box
size="small" key={stage.key}
sx={{ bgcolor: stage.bgColor, color: stage.color, border: `1px solid ${stage.color}30`, fontWeight: 600, height: 20, fontSize: '0.7rem' }} 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}
isOver={isOver}
/> />
</Box> </Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, overflowY: 'auto' }}> )
{columnItems.map(item => ( })}
<PipelineCard </Box>
key={item.id}
item={item} {/* Drag overlay — the card that floats under the cursor */}
isSelected={syncedSelected?.id === item.id} <DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
onSelect={handleSelect} {activeItem && (
/> <DraggableCard
))} item={activeItem}
{columnItems.length === 0 && ( isSelected={false}
<Box sx={{ py: 2, textAlign: 'center' }}> onSelect={() => {}}
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}> isDragOverlay
Leer />
</Typography> )}
</Box> </DragOverlay>
)} </DndContext>
</Box>
</Box>
)
})}
</Box>
{/* Detail panel */} {/* Detail panel */}
{syncedSelected && ( {syncedSelected && (
<DetailPanel <DetailPanel item={syncedSelected} onClose={() => setSelectedItem(null)} />
item={syncedSelected}
onClose={() => setSelectedItem(null)}
/>
)} )}
</Box> </Box>
</Box> </Box>