feat(crm): Kanban-Board mit Drag & Drop (Phase 3.0)

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 22:29:01 +01:00
parent 63cb05d4d8
commit 237e0772e6
6 changed files with 695 additions and 3 deletions

View file

@ -8,6 +8,9 @@
"name": "@insight/frontend", "name": "@insight/frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "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", "@tanstack/react-query": "^5.56.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"react": "^18.3.0", "react": "^18.3.0",
@ -330,6 +333,60 @@
"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",
"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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -3469,6 +3526,12 @@
"typescript": ">=4.8.4" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -13,11 +13,14 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "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": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0"
"axios": "^1.7.0",
"@tanstack/react-query": "^5.56.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.0", "@types/react": "^18.3.0",

View file

@ -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;
}

View file

@ -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<DealStatus, { bg: string; color: string }> = {
OPEN: { bg: '#dbeafe', color: '#1e40af' },
WON: { bg: '#d1fae5', color: '#065f46' },
LOST: { bg: '#fee2e2', color: '#991b1b' },
};
const STATUS_LABELS: Record<DealStatus, string> = {
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 (
<div
ref={setNodeRef}
style={style}
className={styles.dealCard}
{...listeners}
{...attributes}
>
<div className={styles.dealCardTitle} onClick={() => onNavigate(deal.id)}>
{deal.title}
</div>
{deal.company && (
<div className={styles.dealCardMeta}>{deal.company.name}</div>
)}
{deal.contact && (
<div className={styles.dealCardMeta}>
{[deal.contact.firstName, deal.contact.lastName].filter(Boolean).join(' ') ||
deal.contact.companyName}
</div>
)}
<div className={styles.dealCardFooter}>
{deal.value && (
<span className={styles.dealCardValue}>
{currencyFormatter.format(Number(deal.value))}
</span>
)}
<span
className={styles.dealCardStatus}
style={{
background: STATUS_COLORS[deal.status].bg,
color: STATUS_COLORS[deal.status].color,
}}
>
{STATUS_LABELS[deal.status]}
</span>
</div>
</div>
);
}
// DealCard ohne DnD (für DragOverlay)
function DealCardStatic({ deal }: { deal: Deal }) {
return (
<div className={`${styles.dealCard} ${styles.dealCardOverlay}`}>
<div className={styles.dealCardTitle}>{deal.title}</div>
{deal.company && (
<div className={styles.dealCardMeta}>{deal.company.name}</div>
)}
<div className={styles.dealCardFooter}>
{deal.value && (
<span className={styles.dealCardValue}>
{currencyFormatter.format(Number(deal.value))}
</span>
)}
<span
className={styles.dealCardStatus}
style={{
background: STATUS_COLORS[deal.status].bg,
color: STATUS_COLORS[deal.status].color,
}}
>
{STATUS_LABELS[deal.status]}
</span>
</div>
</div>
);
}
// ============================================================
// 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 (
<div
ref={setNodeRef}
className={`${styles.column} ${isOver ? styles.columnOver : ''}`}
>
<div className={styles.columnHeader}>
<div className={styles.columnHeaderLeft}>
<span
className={styles.columnDot}
style={{ background: stage.color || 'var(--color-primary)' }}
/>
<span className={styles.columnName}>{stage.name}</span>
<span className={styles.columnCount}>{deals.length}</span>
</div>
{totalValue > 0 && (
<span className={styles.columnValue}>
{currencyFormatter.format(totalValue)}
</span>
)}
</div>
<div className={styles.columnBody}>
{deals.map((deal) => (
<DealCard
key={deal.id}
deal={deal}
isDragging={deal.id === activeDealId}
onNavigate={onNavigate}
/>
))}
{deals.length === 0 && (
<div className={styles.columnEmpty}>Keine Vorgänge</div>
)}
</div>
</div>
);
}
// ============================================================
// 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<string>('');
const [showClosed, setShowClosed] = useState(false);
const [activeDealId, setActiveDealId] = useState<string | null>(null);
const [overStageId, setOverStageId] = useState<string | null>(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<Record<string, string>>({});
const dealsByStage = useMemo(() => {
const map: Record<string, Deal[]> = {};
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 (
<div className={styles.page}>
{/* Header */}
<div className={styles.header}>
<h1 className={styles.title}>Kanban-Board</h1>
<div className={styles.controls}>
<select
className={styles.pipelineSelect}
value={pipelineId}
onChange={(e) => {
setSelectedPipelineId(e.target.value);
setLocalStageMap({});
}}
>
{pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.isDefault ? ' (Standard)' : ''}
</option>
))}
</select>
<button
className={`${styles.toggleBtn} ${showClosed ? styles.toggleBtnActive : ''}`}
onClick={() => setShowClosed((v) => !v)}
>
Abgeschlossene anzeigen
</button>
</div>
</div>
{/* Board */}
{!pipelineId ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.9rem' }}>
Keine Pipeline ausgewählt.
</p>
) : (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver as Parameters<typeof DndContext>[0]['onDragOver']}
onDragEnd={handleDragEnd}
>
<div className={styles.board}>
{stages.map((stage) => (
<KanbanColumn
key={stage.id}
stage={stage}
deals={dealsByStage[stage.id] ?? []}
isOver={overStageId === stage.id}
onNavigate={(id) => navigate(`/crm/deals/${id}`)}
activeDealId={activeDealId}
/>
))}
</div>
<DragOverlay dropAnimation={null}>
{activeDeal ? <DealCardStatic deal={activeDeal} /> : null}
</DragOverlay>
</DndContext>
)}
</div>
);
}

View file

@ -24,6 +24,7 @@ import { CrmSettingsProvider, CrmModuleGuard } from '../crm/settings/CrmSettings
import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage'; import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage';
import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage'; import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage';
import { ForecastPage } from '../crm/forecast/ForecastPage'; import { ForecastPage } from '../crm/forecast/ForecastPage';
import { KanbanPage } from '../crm/deals/KanbanPage';
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
@ -72,6 +73,7 @@ export function App() {
<Route path="crm/deals/:id" element={<CrmModuleGuard module="deals"><DealDetailPage /></CrmModuleGuard>} /> <Route path="crm/deals/:id" element={<CrmModuleGuard module="deals"><DealDetailPage /></CrmModuleGuard>} />
<Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} /> <Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} />
<Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} /> <Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} />
<Route path="crm/kanban" element={<CrmModuleGuard module="deals"><KanbanPage /></CrmModuleGuard>} />
<Route path="crm/import" element={<Navigate to="/crm/settings" replace />} /> <Route path="crm/import" element={<Navigate to="/crm/settings" replace />} />
<Route path="crm/settings" element={<CrmSettingsPage />} /> <Route path="crm/settings" element={<CrmSettingsPage />} />
<Route path="crm/lexware-sync" element={<LexwareSyncPage />} /> <Route path="crm/lexware-sync" element={<LexwareSyncPage />} />

View file

@ -391,6 +391,31 @@ export function AppLayout() {
{!collapsed && 'Prognose'} {!collapsed && 'Prognose'}
</NavLink> </NavLink>
)} )}
{isModuleEnabled('deals') && (
<NavLink
to="/crm/kanban"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Kanban"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="2" width="4" height="9" rx="0.5" />
<rect x="6" y="2" width="4" height="6" rx="0.5" />
<rect x="11" y="2" width="4" height="11" rx="0.5" />
</svg>
{!collapsed && 'Kanban'}
</NavLink>
)}
{/* CRM Einstellungen (nur Admins) */} {/* CRM Einstellungen (nur Admins) */}
{isAdmin && ( {isAdmin && (
<NavLink <NavLink