mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:36:38 +02:00
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:
parent
63cb05d4d8
commit
237e0772e6
6 changed files with 695 additions and 3 deletions
63
packages/frontend/package-lock.json
generated
63
packages/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
242
packages/frontend/src/crm/deals/KanbanPage.module.css
Normal file
242
packages/frontend/src/crm/deals/KanbanPage.module.css
Normal 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;
|
||||
}
|
||||
357
packages/frontend/src/crm/deals/KanbanPage.tsx
Normal file
357
packages/frontend/src/crm/deals/KanbanPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<Route path="crm/deals/:id" element={<CrmModuleGuard module="deals"><DealDetailPage /></CrmModuleGuard>} />
|
||||
<Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></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/settings" element={<CrmSettingsPage />} />
|
||||
<Route path="crm/lexware-sync" element={<LexwareSyncPage />} />
|
||||
|
|
|
|||
|
|
@ -391,6 +391,31 @@ export function AppLayout() {
|
|||
{!collapsed && 'Prognose'}
|
||||
</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) */}
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue