Files
ICON-CRM/legacy/components/KanbanBoard.tsx
T
2026-09-14 16:55:29 +02:00

210 lines
10 KiB
TypeScript

import React, { useState } from 'react';
import { KanbanColumn, Tender } from '../types';
import { supabase, TABLES } from '../lib/supabase';
const columns: KanbanColumn[] = ['Ausschreibung', 'Experten sichten', 'Interview', 'Kunden Interview', 'Vertragsgestaltung', 'Abschluss', 'Geschlossen'];
interface KanbanBoardProps {
tenders: Tender[];
setTenders: React.Dispatch<React.SetStateAction<Tender[]>>;
refreshData: () => Promise<void>;
}
export const KanbanBoard: React.FC<KanbanBoardProps> = ({ tenders, setTenders, refreshData }) => {
const [draggedTenderId, setDraggedTenderId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState<string | null>(null);
const activePipelineTenders = tenders.filter(t => t.status === 'Aktiv');
const updateTenderColumn = async (tenderId: string, newCol: KanbanColumn) => {
setTenders(prev => prev.map(t => t.id === tenderId ? { ...t, kanbanStatus: newCol } : t));
await supabase.from(TABLES.TENDERS).update({ kanban_status: newCol }).eq('id', tenderId);
};
const moveTender = (tenderId: string, direction: 'next' | 'prev') => {
const tender = tenders.find(t => t.id === tenderId);
if (!tender) return;
const currentIndex = columns.indexOf(tender.kanbanStatus);
let nextIndex = currentIndex + (direction === 'next' ? 1 : -1);
nextIndex = Math.max(0, Math.min(columns.length - 1, nextIndex));
updateTenderColumn(tenderId, columns[nextIndex]);
};
const handleDeactivateTender = async (tender: Tender) => {
if (isProcessing) return;
setIsProcessing(tender.id);
try {
const { error } = await supabase
.from(TABLES.TENDERS)
.update({ status: 'Inaktiv' })
.eq('id', tender.id);
if (error) {
if (error.code === '23514') {
throw new Error('Die Datenbank erlaubt den Status "Inaktiv" noch nicht. Bitte führen Sie das SQL-Update für die Constraints aus.');
}
throw error;
}
await refreshData();
} catch (error: any) {
console.error('Deactivation Error:', error);
alert('Lösch-Fehler: ' + error.message);
} finally {
setIsProcessing(null);
}
};
const handleCompleteTender = async (tender: Tender) => {
if (isProcessing) return;
setIsProcessing(tender.id);
try {
const isoDate = new Date().toISOString().split('T')[0];
const customerName = tender.selectedCustomer?.company || 'Nicht angegeben';
const contactPerson = tender.selectedCustomer?.name || 'Unbekannt';
// Multi-Expert Support: Map all selected experts
const expertNames = tender.selectedExperts && tender.selectedExperts.length > 0
? tender.selectedExperts.map(e => e.name).join(', ')
: 'Kein Experte zugewiesen';
const structuredNotes = [
`KUNDE: ${customerName}`,
`KONTAKT: ${contactPerson}`,
`EXPERTE: ${expertNames}`,
`START: ${isoDate}`,
`ENDE: TBD`,
`---`,
tender.notes || ''
].join('\n');
const { error: insertError } = await supabase.from(TABLES.PROJECTS).insert([{
title: tender.title,
status: 'Aktiv',
notes: structuredNotes
}]);
if (insertError) throw insertError;
const { error: updateError } = await supabase
.from(TABLES.TENDERS)
.update({ status: 'Archiviert', kanban_status: 'Geschlossen' })
.eq('id', tender.id);
if (updateError) throw updateError;
await refreshData();
} catch (error: any) {
console.error('Full Process Error:', error);
alert('Fehler beim Projektabschluss: ' + error.message);
} finally {
setIsProcessing(null);
}
};
const handleDragStart = (e: React.DragEvent, tenderId: string) => {
setDraggedTenderId(tenderId);
e.dataTransfer.setData('tenderId', tenderId);
};
const handleDragEnd = () => {
setDraggedTenderId(null);
setDragOverColumn(null);
};
const handleDragOver = (e: React.DragEvent, col: string) => {
e.preventDefault();
setDragOverColumn(col);
};
const handleDrop = (e: React.DragEvent, targetCol: KanbanColumn) => {
e.preventDefault();
const tenderId = e.dataTransfer.getData('tenderId');
if (tenderId) updateTenderColumn(tenderId, targetCol);
setDragOverColumn(null);
setDraggedTenderId(null);
};
return (
<div className="h-full flex flex-col">
<div className="mb-8">
<h2 className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">Vermittlungspipeline</h2>
<p className="text-slate-500 font-medium">Aktive Leads in der Akquise</p>
</div>
<div className="flex-1 overflow-x-auto pb-10">
<div className="flex gap-6 h-full min-w-max">
{columns.map((col) => (
<div
key={col}
onDragOver={(e) => handleDragOver(e, col)}
onDrop={(e) => handleDrop(e, col as KanbanColumn)}
className={`w-80 flex flex-col rounded-[2.5rem] p-5 border transition-all ${
dragOverColumn === col
? 'bg-brand-50/70 dark:bg-brand-500/10 border-brand-500 ring-2 ring-brand-500/20'
: 'bg-slate-200/40 dark:bg-slate-900/40 border-slate-300/50 dark:border-slate-800'
}`}
>
<div className="flex items-center justify-between mb-5 px-2">
<h3 className="font-bold text-[11px] uppercase tracking-[0.2em] text-slate-600 dark:text-slate-500">{col}</h3>
<span className="text-xs font-extrabold bg-white dark:bg-slate-800 px-3 py-1 rounded-full shadow-soft border border-slate-300 dark:border-slate-700">
{activePipelineTenders.filter(t => t.kanbanStatus === col).length}
</span>
</div>
<div className="flex-1 space-y-5 overflow-y-auto pr-1 min-h-[150px]">
{activePipelineTenders.filter(t => t.kanbanStatus === col).map((tender) => (
<div
key={tender.id}
draggable="true"
onDragStart={(e) => handleDragStart(e, tender.id)}
onDragEnd={handleDragEnd}
className="bg-white dark:bg-slate-800 p-5 rounded-[1.5rem] shadow-heavy border border-slate-300 dark:border-slate-700 hover:border-brand-500 hover:shadow-2xl transition-all group relative cursor-grab active:cursor-grabbing scale-100 hover:scale-[1.02]"
>
<div className="flex justify-between items-start mb-3">
<h4 className="font-extrabold text-sm text-slate-900 dark:text-slate-100 group-hover:text-brand-600 transition-colors leading-snug">{tender.title}</h4>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => moveTender(tender.id, 'prev')} disabled={col === columns[0]} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-20"><svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 19l-7-7 7-7" /></svg></button>
<button onClick={() => moveTender(tender.id, 'next')} disabled={col === columns[columns.length - 1]} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-20"><svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" /></svg></button>
</div>
</div>
<div className="text-[11px] text-slate-500 font-bold mb-4 flex items-center gap-1.5 line-clamp-1">
<span className="text-brand-600 dark:text-brand-400">{tender.selectedCustomer?.company || 'Lead'}</span>
<span className="opacity-30">•</span>
<span>{tender.selectedExperts?.[0]?.name || 'Planung'}</span>
</div>
<div className="flex items-center justify-between border-t border-slate-100 dark:border-slate-700 pt-4 mt-2">
<span className="px-2 py-0.5 rounded-md bg-brand-50 dark:bg-brand-500/10 text-[9px] font-extrabold uppercase tracking-widest text-brand-600 dark:text-brand-400 border border-brand-100 dark:border-brand-500/20">{tender.status}</span>
<span className="text-[9px] font-bold uppercase tracking-wider text-slate-400">{tender.source}</span>
</div>
{col === 'Abschluss' && (
<div className="absolute inset-0 bg-white/95 dark:bg-slate-900/90 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center rounded-[1.5rem] border-2 border-brand-500/50">
<button onClick={() => handleCompleteTender(tender)} className="bg-brand-600 text-white p-4 rounded-2xl shadow-xl-brand hover:scale-110 active:scale-95 transition-all">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>
</button>
</div>
)}
{col === 'Geschlossen' && (
<div className="absolute inset-0 bg-white/95 dark:bg-slate-900/90 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center rounded-[1.5rem] border-2 border-red-500/50">
<button onClick={() => handleDeactivateTender(tender)} className="bg-red-600 text-white p-4 rounded-2xl shadow-lg shadow-red-500/20 hover:scale-110 active:scale-95 transition-all">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
};