From 237e0772e65db31d1c9ba4d77ec779916a9694bb Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Thu, 12 Mar 2026 22:29:01 +0100 Subject: [PATCH] feat(crm): Kanban-Board mit Drag & Drop (Phase 3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue KanbanPage (/crm/kanban) mit @dnd-kit/core + @dnd-kit/sortable - Columns pro Pipeline-Stage mit Deal-Karten - Cross-Column Drag & Drop — stageId per PATCH /crm/deals/:id aktualisiert - Optimistisches Update: sofortiges visuelles Feedback, Rollback bei Fehler - Toggle-Button für abgeschlossene Vorgänge (WON/LOST) - Pipeline-Selektor, Gesamt-Wert pro Spalte (offene Deals) - NavLink "Kanban" in AppLayout.tsx Co-Authored-By: Claude Sonnet 4.6 --- packages/frontend/package-lock.json | 63 ++++ packages/frontend/package.json | 9 +- .../src/crm/deals/KanbanPage.module.css | 242 ++++++++++++ .../frontend/src/crm/deals/KanbanPage.tsx | 357 ++++++++++++++++++ packages/frontend/src/shell/App.tsx | 2 + packages/frontend/src/shell/AppLayout.tsx | 25 ++ 6 files changed, 695 insertions(+), 3 deletions(-) create mode 100644 packages/frontend/src/crm/deals/KanbanPage.module.css create mode 100644 packages/frontend/src/crm/deals/KanbanPage.tsx diff --git a/packages/frontend/package-lock.json b/packages/frontend/package-lock.json index d3f8e30..86f1f33 100644 --- a/packages/frontend/package-lock.json +++ b/packages/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "@insight/frontend", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.56.0", "axios": "^1.7.0", "react": "^18.3.0", @@ -330,6 +333,60 @@ "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", + "peer": true, + "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/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -3469,6 +3526,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9cd95dd..8a77e69 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -13,11 +13,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.56.0", + "axios": "^1.7.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "react-router-dom": "^6.26.0", - "axios": "^1.7.0", - "@tanstack/react-query": "^5.56.0" + "react-router-dom": "^6.26.0" }, "devDependencies": { "@types/react": "^18.3.0", diff --git a/packages/frontend/src/crm/deals/KanbanPage.module.css b/packages/frontend/src/crm/deals/KanbanPage.module.css new file mode 100644 index 0000000..9260512 --- /dev/null +++ b/packages/frontend/src/crm/deals/KanbanPage.module.css @@ -0,0 +1,242 @@ +/* ============================================================ + KanbanPage — Kanban-Board mit Drag & Drop + ============================================================ */ + +.page { + padding: 1.5rem 2rem; + height: 100%; + display: flex; + flex-direction: column; +} + +/* ── Header ── */ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 0.75rem; +} + +.title { + font-size: 1.375rem; + font-weight: 700; + color: var(--color-text); + margin: 0; +} + +.controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.pipelineSelect { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + background: var(--color-bg-card); + color: var(--color-text); + outline: none; + cursor: pointer; +} + +.pipelineSelect:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); +} + +.toggleBtn { + padding: 0.5rem 0.875rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.8125rem; + background: var(--color-bg-card); + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.toggleBtn:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.toggleBtnActive { + background: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +.toggleBtnActive:hover { + opacity: 0.9; + color: white; +} + +/* ── Board (horizontales Scroll-Layout) ── */ + +.board { + display: flex; + gap: 1rem; + align-items: flex-start; + overflow-x: auto; + flex: 1; + padding-bottom: 1rem; +} + +/* ── Spalte ── */ + +.column { + flex: 0 0 280px; + background: var(--color-bg-subtle, var(--color-bg-card)); + border: 1px solid var(--color-border); + border-radius: var(--radius-md, var(--radius-sm)); + display: flex; + flex-direction: column; + max-height: calc(100vh - 200px); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.columnOver { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); +} + +.columnHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.columnHeaderLeft { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.columnDot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.columnName { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text); +} + +.columnCount { + font-size: 0.75rem; + font-weight: 500; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0 0.4rem; + line-height: 1.4; + color: var(--color-text-secondary); +} + +.columnValue { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.columnBody { + padding: 0.75rem; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 80px; +} + +.columnEmpty { + font-size: 0.8125rem; + color: var(--color-text-muted); + text-align: center; + padding: 1.5rem 0; + border: 2px dashed var(--color-border); + border-radius: var(--radius-sm); +} + +/* ── Deal-Karte ── */ + +.dealCard { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 0.75rem; + cursor: grab; + display: flex; + flex-direction: column; + gap: 0.25rem; + transition: box-shadow 0.15s, border-color 0.15s; + user-select: none; +} + +.dealCard:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + border-color: var(--color-primary); +} + +.dealCard:active { + cursor: grabbing; +} + +.dealCardOverlay { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + cursor: grabbing; + transform: rotate(2deg); + opacity: 0.95; +} + +.dealCardTitle { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text); + cursor: pointer; + line-height: 1.3; +} + +.dealCardTitle:hover { + color: var(--color-primary); + text-decoration: underline; +} + +.dealCardMeta { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.dealCardFooter { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.25rem; +} + +.dealCardValue { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-secondary); +} + +.dealCardStatus { + font-size: 0.6875rem; + font-weight: 600; + padding: 0.125rem 0.4rem; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.03em; +} diff --git a/packages/frontend/src/crm/deals/KanbanPage.tsx b/packages/frontend/src/crm/deals/KanbanPage.tsx new file mode 100644 index 0000000..fc77843 --- /dev/null +++ b/packages/frontend/src/crm/deals/KanbanPage.tsx @@ -0,0 +1,357 @@ +import { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + useDroppable, + useDraggable, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core'; +import { useDeals, usePipelines, useUpdateDeal } from '../hooks'; +import type { Deal, DealStatus, Pipeline, PipelineStage } from '../types'; +import styles from './KanbanPage.module.css'; + +// ============================================================ +// Constants +// ============================================================ + +const STATUS_COLORS: Record = { + OPEN: { bg: '#dbeafe', color: '#1e40af' }, + WON: { bg: '#d1fae5', color: '#065f46' }, + LOST: { bg: '#fee2e2', color: '#991b1b' }, +}; + +const STATUS_LABELS: Record = { + OPEN: 'Offen', + WON: 'Gewonnen', + LOST: 'Verloren', +}; + +const currencyFormatter = new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', +}); + +// ============================================================ +// DealCard (draggable) +// ============================================================ + +interface DealCardProps { + deal: Deal; + isDragging?: boolean; + onNavigate: (id: string) => void; +} + +function DealCard({ deal, isDragging = false, onNavigate }: DealCardProps) { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: deal.id, + data: { stageId: deal.stageId }, + }); + + const style = transform + ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + opacity: isDragging ? 0.4 : 1, + zIndex: isDragging ? 1000 : undefined, + } + : { opacity: isDragging ? 0.4 : 1 }; + + return ( +
+
onNavigate(deal.id)}> + {deal.title} +
+ {deal.company && ( +
{deal.company.name}
+ )} + {deal.contact && ( +
+ {[deal.contact.firstName, deal.contact.lastName].filter(Boolean).join(' ') || + deal.contact.companyName} +
+ )} +
+ {deal.value && ( + + {currencyFormatter.format(Number(deal.value))} + + )} + + {STATUS_LABELS[deal.status]} + +
+
+ ); +} + +// DealCard ohne DnD (für DragOverlay) +function DealCardStatic({ deal }: { deal: Deal }) { + return ( +
+
{deal.title}
+ {deal.company && ( +
{deal.company.name}
+ )} +
+ {deal.value && ( + + {currencyFormatter.format(Number(deal.value))} + + )} + + {STATUS_LABELS[deal.status]} + +
+
+ ); +} + +// ============================================================ +// KanbanColumn (droppable) +// ============================================================ + +interface KanbanColumnProps { + stage: PipelineStage; + deals: Deal[]; + isOver: boolean; + onNavigate: (id: string) => void; + activeDealId: string | null; +} + +function KanbanColumn({ stage, deals, isOver, onNavigate, activeDealId }: KanbanColumnProps) { + const { setNodeRef } = useDroppable({ id: stage.id }); + + const totalValue = deals + .filter((d) => d.status === 'OPEN') + .reduce((sum, d) => sum + (d.value ? Number(d.value) : 0), 0); + + return ( +
+
+
+ + {stage.name} + {deals.length} +
+ {totalValue > 0 && ( + + {currencyFormatter.format(totalValue)} + + )} +
+ +
+ {deals.map((deal) => ( + + ))} + {deals.length === 0 && ( +
Keine Vorgänge
+ )} +
+
+ ); +} + +// ============================================================ +// KanbanPage +// ============================================================ + +export function KanbanPage() { + const navigate = useNavigate(); + const { data: pipelinesData } = usePipelines(); + const pipelines: Pipeline[] = pipelinesData?.data ?? []; + + const defaultPipeline = pipelines.find((p) => p.isDefault) ?? pipelines[0]; + const [selectedPipelineId, setSelectedPipelineId] = useState(''); + const [showClosed, setShowClosed] = useState(false); + const [activeDealId, setActiveDealId] = useState(null); + const [overStageId, setOverStageId] = useState(null); + + const pipelineId = selectedPipelineId || defaultPipeline?.id || ''; + const selectedPipeline = pipelines.find((p) => p.id === pipelineId); + + const { data: dealsData } = useDeals( + pipelineId ? { pipelineId, pageSize: 500 } : { pageSize: 0 }, + ); + const allDeals: Deal[] = dealsData?.data ?? []; + const updateDeal = useUpdateDeal(); + + // Deals nach Status filtern + const visibleDeals = useMemo( + () => + showClosed + ? allDeals + : allDeals.filter((d) => d.status === 'OPEN'), + [allDeals, showClosed], + ); + + // Optimistische Positionen: localStageMap überschreibt deal.stageId während Drag + const [localStageMap, setLocalStageMap] = useState>({}); + + const dealsByStage = useMemo(() => { + const map: Record = {}; + const stages = selectedPipeline?.stages ?? []; + for (const s of stages) map[s.id] = []; + for (const deal of visibleDeals) { + const effectiveStageId = localStageMap[deal.id] ?? deal.stageId; + if (map[effectiveStageId]) { + map[effectiveStageId].push(deal); + } else if (map[deal.stageId]) { + map[deal.stageId].push(deal); + } + } + return map; + }, [visibleDeals, selectedPipeline, localStageMap]); + + const activeDeal = allDeals.find((d) => d.id === activeDealId) ?? null; + + // Sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + + const handleDragStart = (event: DragStartEvent) => { + setActiveDealId(String(event.active.id)); + }; + + const handleDragOver = (event: { over: { id: string } | null }) => { + setOverStageId(event.over ? String(event.over.id) : null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const dealId = String(event.active.id); + const targetStageId = event.over ? String(event.over.id) : null; + const sourceStageId = (event.active.data.current as { stageId: string } | undefined)?.stageId; + + setActiveDealId(null); + setOverStageId(null); + + if (!targetStageId || targetStageId === sourceStageId) return; + + // Optimistisch verschieben + setLocalStageMap((prev) => ({ ...prev, [dealId]: targetStageId })); + + updateDeal.mutate( + { id: dealId, data: { stageId: targetStageId } }, + { + onError: () => { + // Bei Fehler: zurücksetzen + setLocalStageMap((prev) => { + const next = { ...prev }; + delete next[dealId]; + return next; + }); + }, + onSuccess: () => { + // Nach Erfolg: localStageMap bereinigen (React Query invalidiert den Cache) + setLocalStageMap((prev) => { + const next = { ...prev }; + delete next[dealId]; + return next; + }); + }, + }, + ); + }; + + const stages = selectedPipeline?.stages ?? []; + + return ( +
+ {/* Header */} +
+

Kanban-Board

+
+ + + +
+
+ + {/* Board */} + {!pipelineId ? ( +

+ Keine Pipeline ausgewählt. +

+ ) : ( + [0]['onDragOver']} + onDragEnd={handleDragEnd} + > +
+ {stages.map((stage) => ( + navigate(`/crm/deals/${id}`)} + activeDealId={activeDealId} + /> + ))} +
+ + + {activeDeal ? : null} + +
+ )} +
+ ); +} diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index 2813c0f..0199f58 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -24,6 +24,7 @@ import { CrmSettingsProvider, CrmModuleGuard } from '../crm/settings/CrmSettings import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage'; import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage'; import { ForecastPage } from '../crm/forecast/ForecastPage'; +import { KanbanPage } from '../crm/deals/KanbanPage'; function PrivateRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); @@ -72,6 +73,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/frontend/src/shell/AppLayout.tsx b/packages/frontend/src/shell/AppLayout.tsx index 32256d7..1aa0fad 100644 --- a/packages/frontend/src/shell/AppLayout.tsx +++ b/packages/frontend/src/shell/AppLayout.tsx @@ -391,6 +391,31 @@ export function AppLayout() { {!collapsed && 'Prognose'} )} + {isModuleEnabled('deals') && ( + + `${styles.navLink} ${isActive ? styles.active : ''}` + } + title="Kanban" + > + + + + + + {!collapsed && 'Kanban'} + + )} {/* CRM Einstellungen (nur Admins) */} {isAdmin && (