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

355 lines
22 KiB
TypeScript

import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Tender, Expert, Customer, KanbanColumn } from '../types';
import { supabase, TABLES } from '../lib/supabase';
interface TendersProps {
tenders: Tender[];
setTenders: React.Dispatch<React.SetStateAction<Tender[]>>;
customers: Customer[];
experts: Expert[];
refreshData: () => Promise<void>;
}
type SortConfig = {
key: keyof Tender | 'none';
direction: 'asc' | 'desc';
};
type FilterStatus = 'Alle' | 'Aktiv' | 'Inaktiv';
export const Tenders: React.FC<TendersProps> = ({ tenders, setTenders, customers, experts, refreshData }) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [tenderToDelete, setTenderToDelete] = useState<string | null>(null);
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'none', direction: 'asc' });
const [filterStatus, setFilterStatus] = useState<FilterStatus>('Alle');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTender, setEditingTender] = useState<Tender | null>(null);
const [formState, setFormState] = useState({ title: '', source: '', status: 'Aktiv' as const, link: '' });
const [isExpertModalOpen, setIsExpertModalOpen] = useState(false);
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
const [activeTenderIdForSelection, setActiveTenderIdForSelection] = useState<string | null>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (filterMenuRef.current && !filterMenuRef.current.contains(event.target as Node)) {
setIsFilterMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const displayTenders = useMemo(() => {
let items = [...tenders];
if (filterStatus === 'Aktiv') {
items = items.filter(t => t.status === 'Aktiv');
} else if (filterStatus === 'Inaktiv') {
items = items.filter(t => t.status === 'Inaktiv');
}
if (sortConfig.key !== 'none') {
items.sort((a, b) => {
const aValue = a[sortConfig.key as keyof Tender];
const bValue = b[sortConfig.key as keyof Tender];
if (aValue === undefined || bValue === undefined) return 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sortConfig.direction === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
}
return 0;
});
}
return items;
}, [tenders, sortConfig, filterStatus]);
const requestSort = (key: keyof Tender) => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') direction = 'desc';
setSortConfig({ key, direction });
};
const getSortIndicator = (key: keyof Tender) => {
if (sortConfig.key !== key) return <span className="opacity-20 text-[10px]">↕</span>;
return sortConfig.direction === 'asc' ? <span className="text-brand-500 text-[10px]">↑</span> : <span className="text-brand-500 text-[10px]">↓</span>;
};
const openModal = (tender?: Tender) => {
if (tender) {
setEditingTender(tender);
setFormState({ title: tender.title, source: tender.source, status: tender.status as any, link: tender.link || '' });
} else {
setEditingTender(null);
setFormState({ title: '', source: '', status: 'Aktiv', link: '' });
}
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (editingTender) {
await supabase.from(TABLES.TENDERS).update({
title: formState.title,
source: formState.source,
status: formState.status,
link: formState.link
}).eq('id', editingTender.id);
} else {
await supabase.from(TABLES.TENDERS).insert([{
title: formState.title,
source: formState.source,
status: formState.status,
kanban_status: 'Ausschreibung',
link: formState.link || null,
notes: ''
}]);
}
setIsModalOpen(false);
refreshData();
};
const confirmDelete = async () => {
if (tenderToDelete) {
await supabase.from(TABLES.TENDERS).delete().eq('id', tenderToDelete);
if (expandedId === tenderToDelete) setExpandedId(null);
setTenderToDelete(null);
refreshData();
}
};
const handleUpdateNotes = async (id: string, newNotes: string) => {
setTenders(prev => prev.map(t => t.id === id ? { ...t, notes: newNotes } : t));
await supabase.from(TABLES.TENDERS).update({ notes: newNotes }).eq('id', id);
};
const assignCustomer = async (tenderId: string, customer: Customer) => {
await supabase.from(TABLES.TENDERS).update({ customer_id: customer.id }).eq('id', tenderId);
setIsCustomerModalOpen(false);
refreshData();
};
const toggleExpertSelection = async (tenderId: string, expertId: string) => {
const tender = tenders.find(t => t.id === tenderId);
const isAlreadySelected = tender?.selectedExperts?.some(e => e.id === expertId);
if (isAlreadySelected) {
await supabase.from(TABLES.TENDER_EXPERTS).delete().match({ tender_id: tenderId, expert_id: expertId });
} else {
await supabase.from(TABLES.TENDER_EXPERTS).insert([{ tender_id: tenderId, expert_id: expertId }]);
}
refreshData();
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Ausschreibungen</h2>
<div className="flex items-center gap-3">
<div className="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-xl">
{(['Alle', 'Aktiv', 'Inaktiv'] as FilterStatus[]).map(s => (
<button
key={s}
onClick={() => setFilterStatus(s)}
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all ${filterStatus === s ? 'bg-white dark:bg-slate-700 shadow-sm text-brand-600 dark:text-brand-400' : 'text-slate-500 hover:text-slate-700'}`}
>
{s === 'Inaktiv' ? 'Nicht aktiv' : s}
</button>
))}
</div>
<button type="button" onClick={() => openModal()} className="bg-brand-600 hover:bg-brand-700 text-white px-5 py-2.5 rounded-xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
Ausschreibung hinzufügen
</button>
</div>
</div>
<div className="bg-white dark:bg-slate-900 rounded-3xl overflow-hidden border border-slate-100 dark:border-slate-800 shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[1050px]">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/50">
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer" onClick={() => requestSort('title')}>
<div className="flex items-center gap-2">Titel {getSortIndicator('title')}</div>
</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider">Kunde / Kontakt</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer" onClick={() => requestSort('source')}>
<div className="flex items-center gap-2">Quelle {getSortIndicator('source')}</div>
</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider text-center">Experten</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider">Status</th>
<th className="p-4 w-32"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{displayTenders.map((tender) => (
<React.Fragment key={tender.id}>
<tr className={`hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors cursor-pointer ${expandedId === tender.id ? 'bg-brand-50/50 dark:bg-brand-500/5' : ''}`} onClick={() => setExpandedId(expandedId === tender.id ? null : tender.id)}>
<td className="p-4 font-bold text-slate-800 dark:text-slate-100">{tender.title}</td>
<td className="p-4 font-medium text-slate-700 dark:text-slate-300">
{tender.selectedCustomer ? (
<div className="flex flex-col">
<span className="font-bold">{tender.selectedCustomer.name}</span>
<span className="text-[10px] text-slate-500 uppercase">{tender.selectedCustomer.company}</span>
</div>
) : <span className="text-slate-400 italic text-xs">Nicht zugewiesen</span>}
</td>
<td className="p-4 font-medium text-slate-600 dark:text-slate-400 text-sm">{tender.source}</td>
<td className="p-4 text-center">
<span className="font-bold bg-slate-100 dark:bg-slate-700 px-3 py-1 rounded-full text-sm text-slate-700 dark:text-white border border-slate-200 dark:border-slate-700">{tender.applicantsCount}</span>
</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${
tender.status === 'Aktiv' ? 'bg-brand-500 text-white' :
tender.status === 'Inaktiv' ? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400' :
'bg-slate-200 dark:bg-slate-700 text-slate-600'
}`}>
{tender.status === 'Inaktiv' ? 'Nicht aktiv' : tender.status}
</span>
</td>
<td className="p-4 flex items-center justify-end gap-1">
<button type="button" onClick={(e) => { e.stopPropagation(); openModal(tender); }} className="p-2 text-slate-400 hover:text-brand-500"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
<button type="button" onClick={(e) => { e.stopPropagation(); setTenderToDelete(tender.id); }} className="p-2 text-slate-400 hover:text-red-500"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
</td>
</tr>
{expandedId === tender.id && (
<tr>
<td colSpan={6} className="p-6 bg-slate-50/50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Experts */}
<div className="bg-white dark:bg-slate-800 rounded-3xl p-6 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col h-[400px]">
<h4 className="text-xl font-bold flex items-center gap-2 mb-6 text-slate-900 dark:text-white"><span className="w-2 h-8 bg-brand-500 rounded-full inline-block"></span>Experten</h4>
<div className="flex-1 overflow-y-auto space-y-3 mb-4 pr-2">
{tender.selectedExperts && tender.selectedExperts.length > 0 ? tender.selectedExperts.map(expert => (
<div key={expert.id} className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900/80 rounded-2xl border border-slate-100 dark:border-slate-800 group/expert">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-500/10 text-brand-600 flex items-center justify-center font-bold text-sm uppercase">{expert.name.charAt(0)}</div>
<div>
<p className="font-bold text-sm text-slate-900 dark:text-slate-100">{expert.name}</p>
<p className="text-[11px] text-slate-500">{expert.role}</p>
</div>
</div>
<button type="button" onClick={() => toggleExpertSelection(tender.id, expert.id)} className="p-2 text-slate-400 hover:text-red-500 opacity-0 group-hover/expert:opacity-100"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
)) : <div className="py-12 text-center bg-slate-50 dark:bg-slate-900/30 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800"><p className="text-sm text-slate-500 italic">Noch keine Experten zugewiesen.</p></div>}
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); setActiveTenderIdForSelection(tender.id); setIsExpertModalOpen(true); }} className="w-full py-3.5 bg-white dark:bg-slate-800 text-brand-600 border-2 border-brand-500 hover:bg-brand-500 hover:text-white rounded-2xl font-bold transition-all shadow-sm">Experten zuweisen</button>
</div>
{/* Customer */}
<div className="bg-white dark:bg-slate-800 rounded-3xl p-6 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col h-[400px]">
<h4 className="text-xl font-bold flex items-center gap-2 mb-6 text-slate-900 dark:text-white"><span className="w-2 h-8 bg-blue-500 rounded-full inline-block"></span>Kundenkontakt</h4>
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 bg-slate-50 dark:bg-slate-900/50 rounded-3xl border border-slate-100 dark:border-slate-800 mb-6">
{tender.selectedCustomer ? (
<div>
<div className="w-20 h-20 rounded-3xl bg-blue-500/10 text-blue-600 flex items-center justify-center mb-6 mx-auto"><svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg></div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Zugeordneter Kontakt</p>
<p className="font-bold text-slate-900 dark:text-white text-xl leading-tight">{tender.selectedCustomer.name}</p>
<p className="text-sm text-slate-500 font-medium">{tender.selectedCustomer.company}</p>
</div>
) : <p className="text-slate-500 italic text-sm">Kein Kunde ausgewählt</p>}
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); setActiveTenderIdForSelection(tender.id); setIsCustomerModalOpen(true); }} className="w-full py-4 bg-blue-500 hover:bg-blue-600 text-white rounded-2xl font-bold transition-all shadow-lg shadow-blue-500/20">Kunden auswählen</button>
</div>
{/* Notes */}
<div className="bg-white dark:bg-slate-800 rounded-3xl p-6 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col h-[400px]">
<h4 className="text-xl font-bold flex items-center gap-2 mb-6 text-slate-900 dark:text-white"><span className="w-2 h-8 bg-brand-500 rounded-full inline-block"></span>Notizen</h4>
<textarea
value={tender.notes || ''}
onChange={(e) => handleUpdateNotes(tender.id, e.target.value)}
placeholder="Interne Informationen..."
className="flex-1 w-full p-4 bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 rounded-2xl text-sm leading-relaxed text-slate-700 dark:text-slate-200 resize-none outline-none focus:ring-2 focus:ring-brand-500/20"
/>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
{/* MODALS */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
<div className="bg-white dark:bg-slate-900 w-full max-w-md rounded-3xl shadow-2xl p-6">
<h3 className="text-xl font-bold mb-4">{editingTender ? 'Bearbeiten' : 'Neu'}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<input required type="text" value={formState.title} onChange={e => setFormState({ ...formState, title: e.target.value })} placeholder="Titel" className="w-full px-4 py-3 bg-slate-50 dark:bg-slate-800 rounded-xl outline-none" />
<input required type="text" value={formState.source} onChange={e => setFormState({ ...formState, source: e.target.value })} placeholder="Quelle" className="w-full px-4 py-3 bg-slate-50 dark:bg-slate-800 rounded-xl outline-none" />
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Status</label>
<select
value={formState.status}
onChange={e => setFormState({ ...formState, status: e.target.value as any })}
className="w-full px-4 py-3 bg-slate-50 dark:bg-slate-800 rounded-xl outline-none"
>
<option value="Aktiv">Aktiv</option>
<option value="Inaktiv">Nicht aktiv</option>
<option value="Ausgelaufen">Ausgelaufen</option>
</select>
</div>
<button type="submit" className="w-full py-3 bg-brand-600 text-white font-bold rounded-xl">Speichern</button>
<button type="button" onClick={() => setIsModalOpen(false)} className="w-full py-2 text-slate-500">Abbrechen</button>
</form>
</div>
</div>
)}
{isExpertModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/70 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl p-8 max-h-[80vh] overflow-y-auto">
<h3 className="text-2xl font-bold mb-6">Experten auswählen</h3>
<div className="space-y-3">
{experts.map(expert => {
const isSelected = tenders.find(t => t.id === activeTenderIdForSelection)?.selectedExperts?.some(e => e.id === expert.id);
return (
<div key={expert.id} onClick={() => toggleExpertSelection(activeTenderIdForSelection!, expert.id)} className={`flex items-center justify-between p-4 rounded-3xl border-2 cursor-pointer ${isSelected ? 'border-brand-500 bg-brand-50' : 'border-transparent bg-slate-50 dark:bg-slate-800'}`}>
<span className="font-bold">{expert.name}</span>
{isSelected && <span className="text-brand-500 font-bold">✓</span>}
</div>
);
})}
</div>
<button onClick={() => setIsExpertModalOpen(false)} className="w-full mt-8 py-4 bg-brand-600 text-white font-bold rounded-2xl">Fertig</button>
</div>
</div>
)}
{isCustomerModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/70 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl p-8 max-h-[80vh] overflow-y-auto">
<h3 className="text-2xl font-bold mb-6">Kunden zuweisen</h3>
<div className="space-y-3">
{customers.map(customer => (
<div key={customer.id} onClick={() => assignCustomer(activeTenderIdForSelection!, customer)} className="p-5 rounded-3xl bg-slate-50 dark:bg-slate-800 border-2 border-transparent hover:border-blue-500 cursor-pointer">
<p className="font-bold text-lg">{customer.name}</p>
<p className="text-xs text-slate-500 uppercase font-bold">{customer.company}</p>
</div>
))}
</div>
<button onClick={() => setIsCustomerModalOpen(false)} className="w-full mt-8 py-4 bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-bold rounded-2xl">Schließen</button>
</div>
</div>
)}
{tenderToDelete && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-sm rounded-[2rem] p-8 text-center shadow-2xl border border-red-100">
<h3 className="text-2xl font-bold mb-2">Bist du sicher?</h3>
<p className="text-slate-500 mb-8">Dieser Tender wird unwiderruflich gelöscht.</p>
<div className="grid grid-cols-2 gap-3">
<button onClick={() => setTenderToDelete(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-bold">Abbruch</button>
<button onClick={confirmDelete} className="py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-500/20">Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};