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:
Generated
+57
-3
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+187
-136
@@ -1,23 +1,28 @@
|
|||||||
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 }>> = {
|
||||||
@@ -32,7 +37,7 @@ 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> = {
|
||||||
@@ -59,34 +64,28 @@ 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') return {
|
||||||
if (item.stage === 'CLOSED_WON') {
|
summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match abgeschlossen.`,
|
||||||
return {
|
|
||||||
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') return {
|
||||||
if (item.stage === 'CLOSED_LOST') {
|
|
||||||
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 {
|
||||||
summary: s >= 80
|
summary: s >= 80
|
||||||
? `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>
|
||||||
@@ -154,15 +173,60 @@ 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)!
|
||||||
@@ -171,25 +235,19 @@ function DetailPanel({
|
|||||||
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}
|
|
||||||
sx={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 1,
|
display: 'flex', alignItems: 'center', gap: 1,
|
||||||
px: 1.5, py: 0.75, bgcolor: '#f8fafc',
|
px: 1.5, py: 0.75, bgcolor: '#f8fafc',
|
||||||
borderRadius: 1.5, border: '1px solid #e2e8f0',
|
borderRadius: 1.5, border: '1px solid #e2e8f0',
|
||||||
cursor: 'pointer', '&:hover': { bgcolor: '#f1f5f9' },
|
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 & 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,10 +459,19 @@ export default function Pipeline() {
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
{/* Kanban */}
|
{/* Kanban */}
|
||||||
<Box sx={{ flex: 1, display: 'flex', gap: 2, px: 3, py: 2, overflowX: 'auto', alignItems: 'flex-start' }}>
|
<Box sx={{ flex: 1, display: 'flex', gap: 2, px: 3, py: 2, overflowX: 'auto', alignItems: 'flex-start' }}>
|
||||||
{STAGES.map(stage => {
|
{STAGES.map(stage => {
|
||||||
const columnItems = items.filter(i => i.stage === stage.key)
|
const columnItems = items.filter(i => i.stage === stage.key)
|
||||||
|
const isOver = overId === stage.key
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={stage.key}
|
key={stage.key}
|
||||||
@@ -436,34 +487,34 @@ export default function Pipeline() {
|
|||||||
sx={{ bgcolor: stage.bgColor, color: stage.color, border: `1px solid ${stage.color}30`, fontWeight: 600, height: 20, fontSize: '0.7rem' }}
|
sx={{ bgcolor: stage.bgColor, color: stage.color, border: `1px solid ${stage.color}30`, fontWeight: 600, height: 20, fontSize: '0.7rem' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, overflowY: 'auto' }}>
|
<DroppableColumn
|
||||||
{columnItems.map(item => (
|
stage={stage}
|
||||||
<PipelineCard
|
items={columnItems}
|
||||||
key={item.id}
|
selectedId={syncedSelected?.id ?? null}
|
||||||
item={item}
|
|
||||||
isSelected={syncedSelected?.id === item.id}
|
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
isOver={isOver}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
{columnItems.length === 0 && (
|
|
||||||
<Box sx={{ py: 2, textAlign: 'center' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
|
||||||
Leer
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</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 */}
|
{/* Detail panel */}
|
||||||
{syncedSelected && (
|
{syncedSelected && (
|
||||||
<DetailPanel
|
<DetailPanel item={syncedSelected} onClose={() => setSelectedItem(null)} />
|
||||||
item={syncedSelected}
|
|
||||||
onClose={() => setSelectedItem(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user