210 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|