Quellcode des ICON CRM (1)

This commit is contained in:
2026-09-14 16:55:29 +02:00
parent 3d09653630
commit c8db64af7e
25 changed files with 3015 additions and 0 deletions
+448
View File
@@ -0,0 +1,448 @@
import React, { useState, useEffect } from 'react';
import { Customer, Contact } from '../types';
import { supabase, TABLES } from '../lib/supabase';
interface CustomersProps {
customers: Customer[];
refreshData: () => Promise<void>;
}
export const Customers: React.FC<CustomersProps> = ({ customers, refreshData }) => {
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [customerToDeleteId, setCustomerToDeleteId] = useState<string | null>(null);
const [customerForm, setCustomerForm] = useState({
company: '',
name: '',
logo: '',
notes: ''
});
const [contactsForm, setContactsForm] = useState<Partial<Contact>[]>([]);
useEffect(() => {
if (!selectedCustomerId && customers.length > 0) {
setSelectedCustomerId(customers[0].id);
}
}, [customers, selectedCustomerId]);
const activeCustomer = customers.find(c => c.id === selectedCustomerId) || customers[0];
const handleOpenCreate = () => {
setEditingCustomer(null);
setCustomerForm({ company: '', name: '', logo: '', notes: '' });
setContactsForm([{ name: '', role: '', email: '', phone: '', avatar: '' }]);
setIsModalOpen(true);
};
const handleOpenEdit = (customer: Customer) => {
setEditingCustomer(customer);
setCustomerForm({
company: customer.company || '',
name: customer.name || '',
logo: customer.logo || '',
notes: customer.notes || ''
});
setContactsForm(customer.contacts && customer.contacts.length > 0
? customer.contacts
: [{ name: '', role: '', email: '', phone: '', avatar: '' }]
);
setIsModalOpen(true);
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => setCustomerForm(prev => ({ ...prev, logo: reader.result as string }));
reader.readAsDataURL(file);
}
};
const addContactRow = () => {
setContactsForm([...contactsForm, { name: '', role: '', email: '', phone: '', avatar: '' }]);
};
const removeContactRow = (index: number) => {
setContactsForm(contactsForm.filter((_, i) => i !== index));
};
const updateContactField = (index: number, field: keyof Contact, value: string) => {
const newContacts = [...contactsForm];
newContacts[index] = { ...newContacts[index], [field]: value };
setContactsForm(newContacts);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!customerForm.company.trim()) {
alert("Bitte geben Sie einen Firmennamen an.");
return;
}
setIsSaving(true);
try {
let finalCustomerId = editingCustomer?.id;
if (editingCustomer) {
const { error } = await supabase.from(TABLES.CUSTOMERS).update(customerForm).eq('id', editingCustomer.id);
if (error) throw error;
} else {
const { data, error } = await supabase.from(TABLES.CUSTOMERS).insert([customerForm]).select();
if (error) throw error;
finalCustomerId = data[0].id;
}
if (finalCustomerId) {
await supabase.from(TABLES.CONTACTS).delete().eq('customer_id', finalCustomerId);
const contactsToInsert = contactsForm
.filter(c => c.name && c.name.trim() !== '')
.map(c => {
const { id, ...cleanContact } = c as any;
return {
...cleanContact,
customer_id: finalCustomerId
};
});
if (contactsToInsert.length > 0) {
const { error: cError } = await supabase.from(TABLES.CONTACTS).insert(contactsToInsert);
if (cError) throw cError;
}
}
setIsModalOpen(false);
await refreshData();
if (!editingCustomer && finalCustomerId) setSelectedCustomerId(finalCustomerId);
} catch (error: any) {
console.error('Save error:', error);
alert('Fehler beim Speichern: ' + (error.message || 'Unbekannter Datenbankfehler'));
} finally {
setIsSaving(false);
}
};
const confirmDeleteCustomer = async () => {
if (!customerToDeleteId) return;
try {
await supabase.from(TABLES.CUSTOMERS).delete().eq('id', customerToDeleteId);
if (selectedCustomerId === customerToDeleteId) setSelectedCustomerId(null);
await refreshData();
} catch (error) {
console.error('Delete error:', error);
} finally {
setCustomerToDeleteId(null);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 h-full">
{/* Sidebar: Firmenliste */}
<div className="lg:col-span-1 space-y-4 flex flex-col min-h-0">
<button
onClick={handleOpenCreate}
className="w-full py-4 bg-brand-600 text-white font-extrabold rounded-2xl shadow-xl-brand hover:bg-brand-700 transition-all shrink-0 flex items-center justify-center gap-2 active:scale-95"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 4v16m8-8H4" /></svg>
Neues Unternehmen
</button>
<div className="flex-1 bg-white dark:bg-slate-900 rounded-[2rem] border-2 border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col shadow-card">
<div className="p-5 border-b-2 border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h3 className="font-bold text-[10px] uppercase tracking-[0.2em] text-slate-400">Kundenstamm</h3>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800">
{customers.map(c => (
<div
key={c.id}
onClick={() => setSelectedCustomerId(c.id)}
className={`p-5 cursor-pointer transition-all flex items-center justify-between group ${selectedCustomerId === c.id ? 'bg-brand-50/50 dark:bg-brand-500/10' : 'hover:bg-slate-50 dark:hover:bg-slate-800'}`}
>
<div className="flex items-center gap-4 min-w-0">
<div className={`w-12 h-12 rounded-xl overflow-hidden shrink-0 border-2 transition-colors flex items-center justify-center ${selectedCustomerId === c.id ? 'bg-brand-500 text-white border-brand-500 shadow-lg' : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700'}`}>
{c.logo ? (
<img src={c.logo} alt={c.company} className="w-full h-full object-cover" />
) : (
<span className={`font-extrabold text-lg ${selectedCustomerId === c.id ? 'text-white' : 'text-brand-500'}`}>{c.company.charAt(0)}</span>
)}
</div>
<div className="truncate">
<p className={`font-bold text-sm truncate ${selectedCustomerId === c.id ? 'text-brand-700 dark:text-brand-400' : 'text-slate-800 dark:text-slate-200'}`}>{c.company}</p>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-0.5 tracking-wider">{c.contacts?.length || 0} Personen</p>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setCustomerToDeleteId(c.id); }}
className="opacity-0 group-hover:opacity-100 p-2 text-slate-300 hover:text-red-500 transition-all hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
>
<svg className="w-4 h-4" 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>
</div>
))}
</div>
</div>
</div>
<div className="lg:col-span-3 min-w-0">
{activeCustomer ? (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-6 duration-500">
<div className="bg-white dark:bg-slate-900 rounded-[3rem] p-10 border-2 border-slate-200 dark:border-slate-800 shadow-card relative overflow-hidden">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-8">
<div className="flex items-center gap-8">
<div className="w-28 h-28 rounded-[2.5rem] bg-brand-500 text-white flex items-center justify-center font-extrabold text-5xl shadow-xl-brand overflow-hidden border-4 border-white dark:border-slate-800">
{activeCustomer.logo ? (
<img src={activeCustomer.logo} alt={activeCustomer.company} className="w-full h-full object-cover" />
) : (
activeCustomer.company.charAt(0)
)}
</div>
<div>
<h2 className="text-4xl font-extrabold text-slate-900 dark:text-white leading-tight tracking-tight">{activeCustomer.company}</h2>
<div className="flex items-center gap-3 mt-3">
<span className="px-3 py-1 bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 text-[10px] font-extrabold uppercase tracking-widest rounded-lg border border-brand-100 dark:border-brand-500/20">PREMIUM KUNDE</span>
</div>
</div>
</div>
<button
onClick={() => handleOpenEdit(activeCustomer)}
className="px-10 py-4 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border-2 border-slate-300 dark:border-slate-700 font-extrabold rounded-2xl hover:border-brand-500 hover:text-brand-600 transition-all shadow-soft active:scale-95"
>
Firma bearbeiten
</button>
</div>
<div className="mt-10 pt-10 border-t-2 border-slate-100 dark:border-slate-800">
<h3 className="text-xs font-extrabold text-slate-400 uppercase tracking-[0.25em] mb-6">Unternehmensprofil & Insights</h3>
<div className="p-8 bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] border-2 border-slate-200 dark:border-slate-800 text-sm leading-relaxed text-slate-600 dark:text-slate-300 min-h-[120px] whitespace-pre-wrap font-medium">
{activeCustomer.notes || 'Keine Firmennotizen hinterlegt.'}
</div>
</div>
</div>
<div className="space-y-8">
<div className="flex items-center gap-4">
<span className="w-2 h-10 bg-brand-500 rounded-full"></span>
<h3 className="text-2xl font-extrabold dark:text-white tracking-tight text-slate-900">Ansprechpartner ({activeCustomer.contacts?.length || 0})</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 pb-10">
{activeCustomer.contacts?.map(contact => (
<div key={contact.id} className="bg-white dark:bg-slate-900 p-8 rounded-[2.5rem] border-2 border-slate-200 dark:border-slate-800 shadow-heavy flex flex-col items-center text-center group hover:border-brand-500/50 transition-all relative">
<div className="w-24 h-24 rounded-full overflow-hidden mb-6 border-4 border-slate-100 dark:border-slate-800 shadow-lg group-hover:border-brand-500 transition-all shrink-0">
{contact.avatar ? (
<img src={contact.avatar} alt={contact.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center font-extrabold text-2xl text-slate-400 uppercase">{contact.name.charAt(0)}</div>
)}
</div>
<h4 className="font-extrabold text-xl dark:text-white leading-tight group-hover:text-brand-600 transition-colors text-slate-900">{contact.name}</h4>
<p className="text-[10px] text-brand-600 font-extrabold uppercase tracking-[0.2em] mt-2 mb-8">{contact.role || 'Mitarbeiter'}</p>
<div className="w-full space-y-3 pt-8 border-t-2 border-slate-50 dark:border-slate-800 mt-auto">
<div className="flex items-center gap-3 text-xs font-semibold text-slate-500 justify-center">
<svg className="w-4 h-4 text-brand-500 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span className="truncate">{contact.email || 'N/A'}</span>
</div>
<div className="flex items-center gap-3 text-xs font-semibold text-slate-500 justify-center">
<svg className="w-4 h-4 text-brand-500 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
<span>{contact.phone || 'N/A'}</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center p-20 bg-white dark:bg-slate-900 rounded-[3rem] border-2 border-dashed border-slate-300 dark:border-slate-800 text-slate-400 shadow-inner">
<div className="w-20 h-20 bg-slate-50 dark:bg-slate-800 rounded-3xl flex items-center justify-center mb-6 text-slate-200">
<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="font-extrabold tracking-widest text-xs uppercase opacity-50">Bereit zur Auswahl</p>
<p className="mt-2 font-medium">Wählen Sie ein Unternehmen aus der Liste links aus.</p>
</div>
)}
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300">
<div className="bg-white dark:bg-slate-900 w-full max-w-5xl rounded-[3rem] shadow-heavy p-10 max-h-[90vh] flex flex-col border border-slate-200 dark:border-slate-800">
<div className="flex justify-between items-center mb-10 shrink-0">
<h3 className="text-3xl font-black text-slate-900 dark:text-white tracking-tight">
{editingCustomer ? 'Firma aktualisieren' : 'Neues Unternehmen anlegen'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="p-3 bg-slate-50 dark:bg-slate-800 rounded-2xl text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto space-y-10 pr-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Logo & Basic Info */}
<div className="space-y-6">
<div className="flex flex-col items-center gap-4 bg-slate-50 dark:bg-slate-800/50 p-8 rounded-[2.5rem] border-2 border-dashed border-slate-200 dark:border-slate-700">
<div className="relative group">
{customerForm.logo ? (
<img src={customerForm.logo} alt="Preview" className="w-32 h-32 rounded-[2rem] object-cover shadow-xl border-4 border-white" />
) : (
<div className="w-32 h-32 rounded-[2rem] bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-400">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
)}
<label className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-[2rem] opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity font-bold text-xs uppercase">
Hochladen
<input type="file" className="hidden" accept="image/*" onChange={handleLogoUpload} />
</label>
</div>
<p className="text-[10px] text-slate-400 uppercase font-black tracking-widest">Firmenlogo (optional)</p>
</div>
<div className="space-y-4">
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Unternehmensname*</label>
<input
required
type="text"
value={customerForm.company}
onChange={e => setCustomerForm({...customerForm, company: e.target.value})}
className="w-full px-6 py-4 bg-slate-100 dark:bg-slate-800 border-2 border-transparent focus:border-brand-500 rounded-2xl outline-none font-bold text-slate-900 dark:text-white transition-all shadow-inner"
placeholder="Z.B. Acme Corp"
/>
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Hauptansprechpartner</label>
<input
type="text"
value={customerForm.name}
onChange={e => setCustomerForm({...customerForm, name: e.target.value})}
className="w-full px-6 py-4 bg-slate-100 dark:bg-slate-800 border-2 border-transparent focus:border-brand-500 rounded-2xl outline-none font-bold text-slate-900 dark:text-white transition-all shadow-inner"
placeholder="Z.B. Max Mustermann"
/>
</div>
</div>
</div>
<div className="flex flex-col h-full">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Firmenprofil & CRM-Notizen</label>
<textarea
value={customerForm.notes}
onChange={e => setCustomerForm({...customerForm, notes: e.target.value})}
className="flex-1 w-full px-6 py-6 bg-slate-100 dark:bg-slate-800 border-2 border-transparent focus:border-brand-500 rounded-[2rem] outline-none font-medium text-slate-800 dark:text-slate-200 resize-none transition-all shadow-inner min-h-[200px]"
placeholder="Wichtige Infos zur Firma, Rahmenverträge, etc..."
/>
</div>
</div>
{/* Optimized Dynamic Contacts Section */}
<div className="pt-10 border-t-2 border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center mb-6">
<h4 className="text-xl font-black text-slate-900 dark:text-white tracking-tight">Zusätzliche Ansprechpartner</h4>
<button
type="button"
onClick={addContactRow}
className="px-6 py-2 bg-brand-500 text-white font-bold rounded-xl shadow-lg shadow-brand-500/20 hover:bg-brand-600 transition-all flex items-center gap-2 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M12 4v16m8-8H4" /></svg>
Hinzufügen
</button>
</div>
<div className="space-y-3">
{contactsForm.map((contact, idx) => (
<div key={idx} className="grid grid-cols-12 gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border-2 border-slate-100 dark:border-slate-700/50 items-center">
<div className="col-span-12 md:col-span-3">
<input
type="text"
placeholder="Name"
value={contact.name}
onChange={(e) => updateContactField(idx, 'name', e.target.value)}
className="w-full px-4 py-3 bg-white dark:bg-slate-800 rounded-xl outline-none font-bold text-xs border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-12 md:col-span-2">
<input
type="text"
placeholder="Rolle"
value={contact.role}
onChange={(e) => updateContactField(idx, 'role', e.target.value)}
className="w-full px-4 py-3 bg-white dark:bg-slate-800 rounded-xl outline-none font-bold text-xs border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-12 md:col-span-3">
<input
type="email"
placeholder="E-Mail"
value={contact.email}
onChange={(e) => updateContactField(idx, 'email', e.target.value)}
className="w-full px-4 py-3 bg-white dark:bg-slate-800 rounded-xl outline-none font-bold text-xs border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-10 md:col-span-3">
<input
type="text"
placeholder="Telefon"
value={contact.phone}
onChange={(e) => updateContactField(idx, 'phone', e.target.value)}
className="w-full px-4 py-3 bg-white dark:bg-slate-800 rounded-xl outline-none font-bold text-xs border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-2 md:col-span-1 flex justify-end">
<button
type="button"
onClick={() => removeContactRow(idx)}
className="p-3 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-all"
>
<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>
</div>
</div>
))}
</div>
</div>
{/* Submit Area */}
<div className="pt-10 flex gap-4 shrink-0">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="flex-1 py-5 bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-300 font-black rounded-2xl hover:bg-slate-200 transition-all uppercase tracking-widest text-xs"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSaving}
className="flex-[2] py-5 bg-brand-600 text-white font-black rounded-2xl shadow-xl-brand hover:bg-brand-700 transition-all uppercase tracking-widest text-xs flex items-center justify-center gap-3 disabled:opacity-50"
>
{isSaving ? <div className="w-5 h-5 border-4 border-white/30 border-t-white rounded-full animate-spin"></div> : 'Unternehmen Speichern'}
</button>
</div>
</form>
</div>
</div>
)}
{customerToDeleteId && (
<div className="fixed inset-0 z-[120] 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-[2.5rem] p-10 text-center shadow-heavy border-2 border-red-500/20">
<div className="w-20 h-20 bg-red-100 dark:bg-red-900/30 text-red-600 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-inner">
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<h3 className="text-2xl font-black mb-3 text-slate-900 dark:text-white tracking-tight">Firma löschen?</h3>
<p className="text-slate-500 dark:text-slate-400 mb-10 text-sm font-medium leading-relaxed">Alle verknüpften Ansprechpartner und Journal-Einträge werden dauerhaft entfernt.</p>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => setCustomerToDeleteId(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-black text-slate-500 dark:text-slate-300 uppercase tracking-widest text-[10px]">Abbruch</button>
<button onClick={confirmDeleteCustomer} className="py-4 bg-red-600 text-white rounded-2xl font-black shadow-lg shadow-red-500/30 hover:bg-red-700 transition-all uppercase tracking-widest text-[10px]">Jetzt Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts';
const data = [
{ name: 'Mo', placement: 4, expert: 10 },
{ name: 'Di', placement: 3, expert: 12 },
{ name: 'Mi', placement: 6, expert: 15 },
{ name: 'Do', placement: 8, expert: 10 },
{ name: 'Fr', placement: 5, expert: 8 },
];
export const Dashboard: React.FC = () => {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ label: 'Aktive Ausschreibungen', value: '12', trend: '+12%', color: 'blue' },
{ label: 'Gesamt Experten', value: '1.450', trend: '+5%', color: 'green' },
{ label: 'Laufende Projekte', value: '34', trend: '+2', color: 'purple' },
{ label: 'Abschlussrate', value: '78%', trend: '+4%', color: 'orange' },
].map((stat) => (
<div key={stat.label} className="bg-white dark:bg-slate-900 p-6 rounded-3xl shadow-card border border-slate-200 dark:border-slate-800 transition-all hover:shadow-lg hover:-translate-y-1">
<p className="text-slate-500 text-sm font-semibold tracking-wide">{stat.label}</p>
<div className="flex items-baseline gap-3 mt-2">
<h3 className="text-3xl font-extrabold text-slate-900 dark:text-white">{stat.value}</h3>
<span className="text-xs font-bold text-brand-600 px-2 py-1 bg-brand-50 dark:bg-brand-500/10 rounded-full border border-brand-100 dark:border-brand-500/20">{stat.trend}</span>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-slate-900 p-6 rounded-[2.5rem] border border-slate-200 dark:border-slate-800 shadow-card h-[450px] flex flex-col overflow-hidden">
<h3 className="text-lg font-bold mb-6 shrink-0 flex items-center gap-2">
<span className="w-1.5 h-6 bg-brand-500 rounded-full"></span>
Placements vs. Experten-Anfragen
</h3>
<div className="flex-1 w-full overflow-hidden">
<ResponsiveContainer width="100%" height="100%" minHeight={300}>
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<Tooltip
contentStyle={{ backgroundColor: '#0f172a', border: 'none', borderRadius: '12px', color: '#fff', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1)' }}
itemStyle={{ color: '#fff' }}
/>
<Bar dataKey="placement" fill="#22c55e" radius={[6, 6, 0, 0]} barSize={24} />
<Bar dataKey="expert" fill="#3b82f6" radius={[6, 6, 0, 0]} barSize={24} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white dark:bg-slate-900 p-6 rounded-[2.5rem] border border-slate-200 dark:border-slate-800 shadow-card h-[450px] flex flex-col overflow-hidden">
<h3 className="text-lg font-bold mb-6 shrink-0 flex items-center gap-2">
<span className="w-1.5 h-6 bg-blue-500 rounded-full"></span>
Umsatzprognose
</h3>
<div className="flex-1 w-full overflow-hidden">
<ResponsiveContainer width="100%" height="100%" minHeight={300}>
<AreaChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#22c55e" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<YAxis axisLine={false} tickLine={false} hide />
<Tooltip />
<Area type="monotone" dataKey="placement" stroke="#22c55e" strokeWidth={3} fillOpacity={1} fill="url(#colorPv)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="bg-white dark:bg-slate-900 p-6 rounded-[2.5rem] border border-slate-200 dark:border-slate-800 shadow-card">
<h3 className="text-lg font-bold mb-6">Letzte Aktivitäten</h3>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between p-5 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border border-slate-100 dark:border-slate-700/50 hover:border-brand-500/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-brand-500/10 flex items-center justify-center border border-brand-500/20">
<svg className="w-6 h-6 text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
</div>
<div>
<p className="font-bold text-slate-800 dark:text-slate-100">Neuer Experte registriert</p>
<p className="text-xs text-slate-500 font-medium">Mila Superstar • Vor 10 Minuten</p>
</div>
</div>
<button className="px-4 py-2 text-xs font-bold text-brand-600 bg-brand-50 dark:bg-brand-500/10 rounded-lg hover:bg-brand-100 transition-all">Ansehen</button>
</div>
))}
</div>
</div>
</div>
);
};
+347
View File
@@ -0,0 +1,347 @@
import React, { useState } from 'react';
import { Expert } from '../types';
import { supabase, TABLES } from '../lib/supabase';
interface ExpertsProps {
experts: Expert[];
setExperts: React.Dispatch<React.SetStateAction<Expert[]>>;
refreshData: () => Promise<void>;
}
export const Experts: React.FC<ExpertsProps> = ({ experts, refreshData }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingExpert, setEditingExpert] = useState<Expert | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [expertToDeleteId, setExpertToDeleteId] = useState<string | null>(null);
const [tagInput, setTagInput] = useState('');
const [formState, setFormState] = useState({
name: '',
role: '',
email: '',
phone: '',
linkedin: '',
status: 'Verfügbar' as Expert['status'],
avatar: '',
bio: '',
tags: [] as string[]
});
const filteredExperts = experts.filter(e =>
e.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.role.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.tags?.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
const openEditModal = (expert: Expert) => {
setEditingExpert(expert);
setFormState({
name: expert.name,
role: expert.role,
email: expert.email || '',
phone: expert.phone || '',
linkedin: expert.linkedin || '',
status: expert.status,
avatar: expert.avatar || '',
bio: expert.bio || '',
tags: expert.tags || []
});
setTagInput('');
setIsModalOpen(true);
};
const openCreateModal = () => {
setEditingExpert(null);
setFormState({
name: '',
role: '',
email: '',
phone: '',
linkedin: '',
status: 'Verfügbar',
avatar: '',
bio: '',
tags: []
});
setTagInput('');
setIsModalOpen(true);
};
const handleAddTag = (e?: React.FormEvent) => {
if (e) e.preventDefault();
const tag = tagInput.trim();
if (tag && !formState.tags.includes(tag)) {
setFormState({ ...formState, tags: [...formState.tags, tag] });
setTagInput('');
}
};
const removeTag = (tagToRemove: string) => {
setFormState({ ...formState, tags: formState.tags.filter(t => t !== tagToRemove) });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (editingExpert) {
const { error } = await supabase
.from(TABLES.EXPERTS)
.update(formState)
.eq('id', editingExpert.id);
if (error) console.error(error);
} else {
const { error } = await supabase.from(TABLES.EXPERTS).insert([formState]);
if (error) console.error(error);
}
setIsModalOpen(false);
refreshData();
};
const confirmDeleteExpert = async () => {
if (!expertToDeleteId) return;
try {
await supabase.from(TABLES.EXPERTS).delete().eq('id', expertToDeleteId);
await refreshData();
} catch (error) {
console.error('Delete error:', error);
} finally {
setExpertToDeleteId(null);
}
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setFormState({ ...formState, avatar: reader.result as string });
};
reader.readAsDataURL(file);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center mb-8">
<div className="relative flex-1 w-full max-w-xl">
<input
type="text"
placeholder="Namen, Rollen oder Tags suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-12 pr-6 py-4 bg-white dark:bg-slate-900 border-none rounded-2xl shadow-sm focus:ring-2 focus:ring-brand-500 transition-all text-lg dark:text-white"
/>
<svg className="w-6 h-6 absolute left-4 top-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button
onClick={openCreateModal}
className="bg-brand-600 hover:bg-brand-700 text-white px-6 py-4 rounded-2xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2 whitespace-nowrap w-full md:w-auto justify-center"
>
<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>
Experte hinzufügen
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredExperts.map((expert) => (
<div key={expert.id} className="bg-white dark:bg-slate-900 rounded-[2.5rem] p-6 border border-slate-100 dark:border-slate-800 hover:shadow-2xl transition-all group flex flex-col relative overflow-hidden h-full">
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setExpertToDeleteId(expert.id)}
className="p-2 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm text-slate-400 hover:text-red-500 rounded-full shadow-sm"
>
<svg className="w-4 h-4" 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>
</div>
<div className="flex flex-col items-center text-center mb-6">
<div className="relative mb-4">
{expert.avatar ? (
<img src={expert.avatar} alt={expert.name} className="w-24 h-24 rounded-[2rem] object-cover shadow-xl ring-4 ring-slate-50 dark:ring-slate-800" />
) : (
<div className="w-24 h-24 rounded-[2rem] bg-brand-500/10 text-brand-600 flex items-center justify-center font-bold text-3xl uppercase ring-4 ring-slate-50 dark:ring-slate-800">
{expert.name.charAt(0)}
</div>
)}
<div className={`absolute -bottom-1 -right-1 w-6 h-6 rounded-full border-4 border-white dark:border-slate-900 ${
expert.status === 'Verfügbar' ? 'bg-brand-500' :
expert.status === 'Gebucht' ? 'bg-blue-500' : 'bg-slate-400'
}`}></div>
</div>
<h4 className="font-bold text-xl leading-tight text-slate-900 dark:text-white group-hover:text-brand-500 transition-colors">{expert.name}</h4>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{expert.role}</p>
</div>
<div className="flex-1 mb-4 flex flex-col">
<div className="p-4 bg-slate-50 dark:bg-slate-800/40 rounded-2xl border border-slate-100 dark:border-slate-800/50 min-h-[80px] mb-4">
<p className="text-slate-600 dark:text-slate-400 text-xs leading-relaxed line-clamp-3 italic">
{expert.bio || "Keine zusätzlichen Informationen hinterlegt."}
</p>
</div>
{/* Tags Display moved below bio Box */}
{expert.tags && expert.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 justify-center mb-2">
{expert.tags.slice(0, 4).map(tag => (
<span key={tag} className="px-2.5 py-1 bg-brand-50 dark:bg-brand-500/10 text-[9px] font-extrabold text-brand-700 dark:text-brand-400 rounded-lg uppercase tracking-wider border border-brand-100 dark:border-brand-500/20 shadow-sm">
{tag}
</span>
))}
{expert.tags.length > 4 && (
<span className="text-[9px] font-bold text-slate-400 self-center">+{expert.tags.length - 4}</span>
)}
</div>
)}
</div>
<div className="space-y-2 mb-6 text-[13px]">
<div className="flex items-center gap-3 text-slate-500">
<svg className="w-4 h-4 opacity-40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span className="truncate">{expert.email || 'n/a'}</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<svg className="w-4 h-4 opacity-40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
<span className="truncate">{expert.phone || 'n/a'}</span>
</div>
</div>
<button
onClick={() => openEditModal(expert)}
className="w-full py-3.5 bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 font-bold rounded-2xl hover:bg-brand-600 dark:hover:bg-brand-600 transition-all text-xs uppercase tracking-widest shadow-lg shadow-slate-900/10"
>
Profil bearbeiten
</button>
</div>
))}
{filteredExperts.length === 0 && (
<div className="col-span-full py-24 text-center bg-white dark:bg-slate-900 rounded-[3rem] border-2 border-dashed border-slate-200 dark:border-slate-800">
<div className="w-20 h-20 bg-slate-50 dark:bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300">
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
</div>
<p className="text-slate-500 font-medium mb-4">Keine Experten gefunden.</p>
<button onClick={openCreateModal} className="text-brand-600 font-bold hover:underline px-6 py-2 bg-brand-50 dark:bg-brand-500/10 rounded-xl transition-all">Neu anlegen</button>
</div>
)}
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 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-2xl rounded-[2.5rem] shadow-2xl p-10 max-h-[90vh] overflow-y-auto border border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center mb-8">
<h3 className="text-3xl font-bold dark:text-white">{editingExpert ? 'Profil bearbeiten' : 'Neuer Experte'}</h3>
<button onClick={() => setIsModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
<svg className="w-6 h-6" 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>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2 flex flex-col items-center gap-4 bg-slate-50 dark:bg-slate-800/50 p-6 rounded-3xl border border-dashed border-slate-200 dark:border-slate-700">
<div className="relative group">
{formState.avatar ? (
<img src={formState.avatar} alt="Preview" className="w-32 h-32 rounded-[2rem] object-cover shadow-xl" />
) : (
<div className="w-32 h-32 rounded-[2rem] bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-400">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
)}
<label className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-[2rem] opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity font-bold text-xs uppercase">
Ändern
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} />
</label>
</div>
<p className="text-[10px] text-slate-400 uppercase font-bold tracking-widest">Foto hochladen</p>
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Name</label>
<input required type="text" value={formState.name} onChange={e => setFormState({...formState, name: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Rolle</label>
<input required type="text" value={formState.role} onChange={e => setFormState({...formState, role: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">E-Mail</label>
<input type="email" value={formState.email} onChange={e => setFormState({...formState, email: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Telefon</label>
<input type="text" value={formState.phone} onChange={e => setFormState({...formState, phone: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
</div>
{/* Tags Input Section */}
<div className="md:col-span-2 pt-4">
<label className="block text-xs font-bold uppercase text-slate-400 mb-3 tracking-widest">Themenbereiche & Expertisen (Tags)</label>
<div className="flex flex-wrap gap-2 mb-3">
{formState.tags.map(tag => (
<span key={tag} className="flex items-center gap-2 px-3 py-1.5 bg-brand-500 text-white rounded-xl text-xs font-bold shadow-md shadow-brand-500/10">
{tag}
<button type="button" onClick={() => removeTag(tag)} className="hover:text-slate-200 transition-colors">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddTag(e); }}
placeholder="Tag eingeben (z.B. Cloud Computing)..."
className="flex-1 px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white text-sm"
/>
<button
type="button"
onClick={handleAddTag}
className="px-6 bg-brand-500 text-white rounded-2xl font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-600 transition-all"
>
Hinzufügen
</button>
</div>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Kurz-Bio / Informationen</label>
<textarea
value={formState.bio}
onChange={e => setFormState({...formState, bio: e.target.value})}
rows={4}
placeholder="Wichtige Skills, Projekterfahrung oder Hintergrund..."
className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white resize-none"
/>
</div>
</div>
<div className="flex gap-4 pt-4">
<button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-4 bg-slate-100 dark:bg-slate-800 text-slate-500 font-bold rounded-2xl hover:bg-slate-200 transition-all">Abbrechen</button>
<button type="submit" className="flex-[2] py-4 bg-brand-600 text-white font-bold rounded-2xl shadow-xl shadow-brand-500/20 hover:bg-brand-700 transition-all">Speichern</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{expertToDeleteId && (
<div className="fixed inset-0 z-[120] 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-sm rounded-[2rem] p-8 text-center shadow-2xl border border-red-100 dark:border-red-900/30">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/20 text-red-600 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<h3 className="text-2xl font-bold mb-2 dark:text-white">Experten löschen?</h3>
<p className="text-slate-500 mb-8 text-sm">Möchten Sie diesen Experten wirklich unwiderruflich aus der Datenbank löschen?</p>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => setExpertToDeleteId(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-bold text-slate-600 dark:text-slate-300">Abbruch</button>
<button onClick={confirmDeleteExpert} className="py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-500/20 hover:bg-red-700 transition-all">Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
interface HeaderProps {
title: string;
isDarkMode: boolean;
onToggleDarkMode: () => void;
onToggleSidebar: () => void;
}
export const Header: React.FC<HeaderProps> = ({ title, isDarkMode, onToggleDarkMode, onToggleSidebar }) => {
return (
<header className="h-20 border-b border-slate-300 dark:border-slate-800 bg-white/95 dark:bg-slate-950/80 backdrop-blur-md flex items-center justify-between px-8 sticky top-0 z-10 shadow-soft">
<div className="flex items-center gap-6">
<button
onClick={onToggleSidebar}
className="p-3 hover:bg-slate-100 dark:hover:bg-slate-900 rounded-xl transition-colors text-slate-500 dark:text-slate-400 border border-transparent hover:border-slate-200"
title="Menü umschalten"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 6h16M4 12h16m-7 6h7" />
</svg>
</button>
<h1 className="text-2xl font-black text-slate-900 dark:text-white tracking-tight">{title}</h1>
</div>
<div className="flex items-center gap-6">
<div className="relative hidden md:block">
<input
type="text"
placeholder="Suchen..."
className="pl-12 pr-6 py-2.5 bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-full w-80 text-sm font-medium focus:ring-4 focus:ring-brand-500/10 focus:border-brand-500 transition-all outline-none"
/>
<svg className="w-5 h-5 absolute left-4 top-2.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button
onClick={onToggleDarkMode}
className="p-2.5 rounded-full hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors border border-transparent hover:border-slate-200"
title={isDarkMode ? 'Light Mode' : 'Dark Mode'}
>
{isDarkMode ? (
<svg className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" /></svg>
) : (
<svg className="w-5 h-5 text-slate-500" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" /></svg>
)}
</button>
<div className="relative">
<div className="w-2.5 h-2.5 bg-red-500 rounded-full absolute right-1 top-1 ring-4 ring-white dark:ring-slate-950 animate-pulse"></div>
<button className="p-2.5 hover:bg-slate-100 dark:hover:bg-slate-900 rounded-xl border border-transparent hover:border-slate-200">
<svg className="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
</button>
</div>
</div>
</header>
);
};
+209
View File
@@ -0,0 +1,209 @@
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>
);
};
+359
View File
@@ -0,0 +1,359 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Customer, Project, Expert } from '../types';
interface OrgChartProps {
customers: Customer[];
projects: Project[];
experts: Expert[];
}
type Mode = 'Customer' | 'Project';
export const OrgChart: React.FC<OrgChartProps> = ({ customers, projects, experts }) => {
const [mode, setMode] = useState<Mode>('Customer');
const [selectedId, setSelectedId] = useState<string | null>(null);
// Navigation State
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const canvasRef = useRef<HTMLDivElement>(null);
const getRelatedData = useMemo(() => {
if (!selectedId) return null;
if (mode === 'Customer') {
const customer = customers.find(c => c.id === selectedId);
if (!customer) return null;
const companyProjects = projects.filter(p =>
p.customer === customer.company ||
(p.notes && p.notes.includes(`KUNDE: ${customer.company}`))
);
const relatedExperts = experts.filter(e =>
companyProjects.some(p => p.expert && p.expert.includes(e.name) || (p.notes && p.notes.includes(`EXPERTE: ${e.name}`)))
);
return { root: customer, contacts: customer.contacts || [], projects: companyProjects, experts: relatedExperts };
} else {
const project = projects.find(p => p.id === selectedId);
if (!project) return null;
const customerMatch = customers.find(c =>
c.company === project.customer ||
(project.notes && project.notes.includes(`KUNDE: ${c.company}`))
);
// Multi-Expert Support for Projects
const expertNames = (project.expert || '').split(', ').map(s => s.trim()).filter(Boolean);
const relatedExperts = experts.filter(e =>
expertNames.includes(e.name) ||
(project.notes && project.notes.includes(`EXPERTE: ${e.name}`))
);
return { root: project, customer: customerMatch, experts: relatedExperts, contacts: customerMatch?.contacts || [] };
}
}, [mode, selectedId, customers, projects, experts]);
// Native Wheel Listener for preventDefault support (necessary for Zoom override)
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
// Prevent page scrolling/zooming while interacting with the canvas
e.preventDefault();
if (e.ctrlKey) {
// ZOOM MODE
const zoomSpeed = 0.001;
setZoom(prevZoom => {
const newZoom = prevZoom - e.deltaY * zoomSpeed;
return Math.min(Math.max(newZoom, 0.2), 2.5);
});
} else if (e.shiftKey) {
// HORIZONTAL SCROLL
setOffset(prev => ({ ...prev, x: prev.x - e.deltaY }));
} else {
// VERTICAL SCROLL (Default)
setOffset(prev => ({ ...prev, y: prev.y - e.deltaY }));
}
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return () => canvas.removeEventListener('wheel', onWheel);
}, []);
// Mouse Handlers (Panning via Click & Drag)
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return; // Only left click
setIsDragging(true);
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return;
setOffset({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
});
};
const handleMouseUp = () => setIsDragging(false);
const resetView = () => {
setOffset({ x: 0, y: 0 });
setZoom(1);
};
const handleSelect = (id: string) => {
setSelectedId(id);
resetView();
};
const ConnectionLines = () => {
const color = mode === 'Customer' ? '#22c55e' : '#3b82f6';
return (
<div className="absolute inset-0 w-full h-full pointer-events-none z-0">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="w-full h-full overflow-visible"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter id="glowLine" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<linearGradient id="lineGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="60%" stopColor={color} stopOpacity="0.8" />
<stop offset="100%" stopColor={color} stopOpacity="0.1" />
</linearGradient>
</defs>
<g filter="url(#glowLine)">
<path
d="M 50 15 C 50 25, 15 25, 15 45"
stroke="url(#lineGrad)"
strokeWidth="1.5"
fill="none"
strokeDasharray="4 2"
className="animate-[flow_4s_linear_infinite]"
/>
<path
d="M 50 15 C 50 25, 85 25, 85 45"
stroke="url(#lineGrad)"
strokeWidth="1.5"
fill="none"
strokeDasharray="4 2"
className="animate-[flow_4s_linear_infinite]"
/>
<circle cx="50" cy="15" r="2" fill={color} className="animate-pulse shadow-lg" />
</g>
</svg>
<style>{`
@keyframes flow {
from { stroke-dashoffset: 40; }
to { stroke-dashoffset: 0; }
}
`}</style>
</div>
);
};
return (
<div className="h-full flex flex-col gap-8 animate-in fade-in duration-500 overflow-hidden">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0 px-2">
<div>
<h2 className="text-3xl font-black text-slate-900 dark:text-white tracking-tight">Relations Engine</h2>
<p className="text-slate-500 font-medium">Das ICON Beziehungs-Netzwerk</p>
</div>
<div className="flex bg-slate-200 dark:bg-slate-900 p-1.5 rounded-[1.5rem] border-2 border-slate-300 dark:border-slate-800 shadow-inner">
<button
onClick={() => { setMode('Customer'); setSelectedId(null); resetView(); }}
className={`px-8 py-3 rounded-[1rem] font-bold text-xs uppercase tracking-widest transition-all ${mode === 'Customer' ? 'bg-brand-500 text-white shadow-lg' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'}`}
>
Kunden-Hub
</button>
<button
onClick={() => { setMode('Project'); setSelectedId(null); resetView(); }}
className={`px-8 py-3 rounded-[1rem] font-bold text-xs uppercase tracking-widest transition-all ${mode === 'Project' ? 'bg-blue-500 text-white shadow-lg' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'}`}
>
Projekt-Hub
</button>
</div>
</div>
<div className="flex-1 flex flex-col lg:flex-row gap-8 overflow-hidden min-h-0">
{/* Sidebar */}
<div className="w-full lg:w-72 bg-white dark:bg-slate-900 rounded-[2rem] border-2 border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col shadow-card shrink-0">
<div className="p-5 border-b-2 border-slate-100 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/30">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">
{mode === 'Customer' ? 'Daten: Kunden' : 'Daten: Projekte'}
</h3>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800">
{(mode === 'Customer' ? customers : projects).map((item: any) => (
<div
key={item.id}
onClick={() => handleSelect(item.id)}
className={`p-4 cursor-pointer transition-all hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-4 ${selectedId === item.id ? 'bg-brand-50/50 dark:bg-brand-500/10' : ''}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center font-bold text-lg shrink-0 ${mode === 'Customer' ? 'bg-brand-500/10 text-brand-600' : 'bg-blue-500/10 text-blue-600'}`}>
{(item.company || item.title).charAt(0)}
</div>
<div className="truncate">
<p className="font-extrabold text-sm truncate dark:text-white">{item.company || item.title}</p>
</div>
</div>
))}
</div>
</div>
{/* Graph Area */}
<div
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className={`flex-1 bg-slate-100/30 dark:bg-slate-950/20 rounded-[4rem] border-4 border-dashed border-slate-200 dark:border-slate-800 relative overflow-hidden p-8 select-none transition-colors ${isDragging ? 'cursor-grabbing border-brand-500/50 bg-slate-100/50 dark:bg-slate-950/40' : 'cursor-grab hover:border-slate-300 dark:hover:border-slate-700'}`}
>
{selectedId && getRelatedData ? (
<div
className="w-full h-full relative"
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: 'center center',
transition: isDragging ? 'none' : 'transform 0.15s cubic-bezier(0.2, 0, 0.2, 1)'
}}
>
<div className="absolute inset-0 w-full h-[800px] min-w-[1200px]">
<ConnectionLines />
</div>
<div className="w-full relative z-10 py-10">
<div className="flex justify-center mb-48">
<div className={`p-8 rounded-[3rem] border-4 shadow-heavy flex flex-col items-center text-center w-72 animate-in zoom-in-95 duration-500 ${mode === 'Customer' ? 'bg-brand-500 text-white border-white dark:border-slate-800' : 'bg-blue-600 text-white border-white dark:border-slate-800'}`}>
<div className="w-20 h-20 bg-white/20 rounded-3xl flex items-center justify-center mb-4 text-4xl font-black">
{(getRelatedData.root as any).company?.charAt(0) || (getRelatedData.root as any).title?.charAt(0)}
</div>
<h3 className="text-2xl font-black tracking-tight leading-tight">{(getRelatedData.root as any).company || (getRelatedData.root as any).title}</h3>
<p className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-80 mt-2">{mode === 'Customer' ? 'UNTERNEHMEN' : 'AKTIVES PROJEKT'}</p>
</div>
</div>
<div className="flex flex-row justify-between w-full max-w-7xl mx-auto items-start px-4 gap-12">
<div className="w-[380px] space-y-4 animate-in slide-in-from-left-24 duration-1000">
<div className="flex items-center gap-3 mb-6 bg-white/80 dark:bg-slate-900/80 px-6 py-2 rounded-full border border-slate-200 dark:border-slate-800 shadow-sm">
<span className={`w-2 h-2 rounded-full ${mode === 'Customer' ? 'bg-brand-500' : 'bg-blue-500'} animate-pulse`}></span>
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{mode === 'Customer' ? 'Interne Kontakte' : 'Auftraggeber'}</h4>
</div>
{mode === 'Customer' ? (
<div className="space-y-4">
{getRelatedData.contacts.length > 0 ? getRelatedData.contacts.map((c: any) => (
<div key={c.id} className="bg-white dark:bg-slate-900 p-5 rounded-[2rem] border-2 border-slate-100 dark:border-slate-800 shadow-card flex items-center gap-4 hover:border-brand-500 transition-all">
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-sm font-black text-slate-400 uppercase">{c.name.charAt(0)}</div>
<div className="truncate text-left">
<p className="font-extrabold text-sm dark:text-white truncate">{c.name}</p>
<p className="text-[10px] text-brand-600 font-black uppercase tracking-wider">{c.role}</p>
</div>
</div>
)) : <div className="text-center py-12 bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Daten</div>}
</div>
) : (
getRelatedData.customer && (
<div className="bg-white dark:bg-slate-900 p-8 rounded-[3rem] border-2 border-brand-500 shadow-xl-brand flex flex-col items-center text-center">
<div className="w-16 h-16 bg-brand-50 dark:bg-brand-900/20 rounded-2xl flex items-center justify-center mb-4 text-3xl font-black text-brand-600">
{getRelatedData.customer.company.charAt(0)}
</div>
<p className="font-black text-base dark:text-white">{getRelatedData.customer.company}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase mt-2 tracking-widest">Zugeordneter Kunde</p>
</div>
)
)}
</div>
<div className="w-[380px] space-y-4 animate-in slide-in-from-right-24 duration-1000">
<div className="flex items-center gap-3 mb-6 bg-white/80 dark:bg-slate-900/80 px-6 py-2 rounded-full border border-slate-200 dark:border-slate-800 shadow-sm">
<span className={`w-2 h-2 rounded-full ${mode === 'Customer' ? 'bg-brand-500' : 'bg-blue-500'} animate-pulse`}></span>
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{mode === 'Customer' ? 'Laufende Beauftragungen' : 'Expertise'}</h4>
</div>
{mode === 'Customer' ? (
<div className="space-y-6">
{getRelatedData.projects.length > 0 ? getRelatedData.projects.map((p: any) => (
<div key={p.id} className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-[2.5rem] border-2 border-blue-200 dark:border-blue-800 shadow-heavy text-left relative overflow-hidden group">
<div className="absolute top-0 right-0 w-16 h-16 bg-blue-500/5 -mr-8 -mt-8 rounded-full"></div>
<p className="font-black text-sm dark:text-blue-100 mb-5">{p.title}</p>
<div className="space-y-3">
<p className="text-[9px] font-black text-blue-400 uppercase tracking-widest ml-1">Eingesetzte Experten</p>
{experts.filter(e => p.expert && p.expert.includes(e.name) || (p.notes && p.notes.includes(`EXPERTE: ${e.name}`))).map(e => (
<div key={e.id} className="bg-white dark:bg-slate-900 p-3 rounded-2xl border border-blue-100 dark:border-slate-800 flex items-center gap-3 shadow-sm">
<div className="w-8 h-8 rounded-xl bg-brand-500 text-white flex items-center justify-center text-[10px] font-black">{e.name.charAt(0)}</div>
<p className="font-bold text-xs dark:text-white truncate">{e.name}</p>
</div>
))}
</div>
</div>
)) : <div className="text-center py-12 bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Projekte</div>}
</div>
) : (
<div className="space-y-4">
{getRelatedData.experts && getRelatedData.experts.length > 0 ? getRelatedData.experts.map((expert: Expert) => (
<div key={expert.id} className="bg-white dark:bg-slate-900 p-6 rounded-[2rem] border-2 border-blue-500 shadow-heavy flex items-center gap-5 hover:scale-[1.02] transition-transform">
<div className="relative shrink-0">
<div className="w-16 h-16 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center overflow-hidden border-2 border-slate-200 dark:border-slate-700 shadow-md">
{expert.avatar ? <img src={expert.avatar} className="w-full h-full object-cover" /> : <span className="text-2xl font-black text-slate-300">{expert.name.charAt(0)}</span>}
</div>
<div className="absolute -bottom-1 -right-1 bg-brand-500 text-white p-1 rounded-lg shadow-md border-2 border-white dark:border-slate-900">
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
</div>
</div>
<div className="text-left truncate">
<p className="font-black text-base dark:text-white leading-tight truncate">{expert.name}</p>
<p className="text-[10px] font-black text-blue-600 uppercase mt-1 tracking-widest truncate">{expert.role}</p>
</div>
</div>
)) : (
<div className="py-12 text-center bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Experten zugewiesen</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div className="h-full w-full flex flex-col items-center justify-center animate-pulse">
<div className="w-24 h-24 bg-white dark:bg-slate-900 rounded-[2rem] flex items-center justify-center mb-8 text-slate-200 dark:text-slate-800 shadow-lg border-2 border-slate-100 dark:border-slate-800">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<h3 className="text-2xl font-black dark:text-white mb-2 uppercase tracking-tighter">Bereit zur Analyse</h3>
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest opacity-60 text-center">Wählen Sie ein Asset aus der Liste links aus.<br/>Rad: Scrollen | Strg + Rad: Zoom | Shift + Rad: Horiz. Scroll</p>
</div>
)}
{/* Canvas Controls */}
{selectedId && (
<div className="absolute bottom-8 right-8 flex flex-col gap-3 z-50">
<button
onClick={resetView}
title="Ansicht & Zoom zurücksetzen"
className="p-4 bg-white dark:bg-slate-800 text-slate-500 hover:text-brand-500 rounded-2xl shadow-heavy border border-slate-100 dark:border-slate-700 transition-all hover:scale-110 active:scale-95 flex items-center justify-center"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
<div className="p-3 bg-slate-900/10 dark:bg-white/10 rounded-xl backdrop-blur-md flex flex-col items-center gap-1 min-w-[100px]">
<p className="text-[9px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">DRAG / SCROLL</p>
<p className="text-[10px] font-bold text-brand-600 dark:text-brand-400 tracking-widest">{Math.round(zoom * 100)}% ZOOM</p>
</div>
</div>
)}
</div>
</div>
</div>
);
};
+495
View File
@@ -0,0 +1,495 @@
import React, { useState } from 'react';
import { Project, Customer, Expert } from '../types';
import { supabase, TABLES } from '../lib/supabase';
interface ProjectsProps {
projects: Project[];
setProjects: React.Dispatch<React.SetStateAction<Project[]>>;
customers: Customer[];
experts: Expert[];
refreshData: () => Promise<void>;
}
export const Projects: React.FC<ProjectsProps> = ({ projects, setProjects, customers, experts, refreshData }) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editModeId, setEditModeId] = useState<string | null>(null);
const [projectToDelete, setProjectToDelete] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Pickers
const [isCustomerPickerOpen, setIsCustomerPickerOpen] = useState(false);
const [isExpertPickerOpen, setIsExpertPickerOpen] = useState(false);
// Local state for editing
const [editForm, setEditForm] = useState<Partial<Project>>({});
const startEditing = (project: Project) => {
const customer = getDisplayInfo(project, 'KUNDE');
const expert = getDisplayInfo(project, 'EXPERTE');
const start = getDisplayInfo(project, 'START');
const end = getDisplayInfo(project, 'ENDE');
const formatDateForInput = (val: string) => {
if (!val || val === 'N/A' || val === 'TBD') return '';
if (val.includes('.')) {
const parts = val.split('.');
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
}
return val;
};
setEditForm({
...project,
customer: customer === 'N/A' ? '' : customer,
expert: expert === 'N/A' ? '' : expert,
start: formatDateForInput(start),
end: formatDateForInput(end),
});
setEditModeId(project.id);
};
const handleSaveProject = async () => {
if (!editModeId || isSaving) return;
setIsSaving(true);
try {
const baseNotes = editForm.notes?.split('---').pop()?.trim() || '';
const structuredNotes = [
`KUNDE: ${editForm.customer || 'Nicht angegeben'}`,
`KONTAKT: ${getDisplayInfo(projects.find(p => p.id === editModeId)!, 'KONTAKT')}`,
`EXPERTE: ${editForm.expert || 'Kein Experten zugewiesen'}`,
`START: ${editForm.start || 'N/A'}`,
`ENDE: ${editForm.end || 'TBD'}`,
`---`,
baseNotes
].join('\n');
const updatePayload = {
title: editForm.title,
customer: editForm.customer,
expert: editForm.expert,
start: editForm.start,
end: editForm.end,
status: editForm.status,
notes: structuredNotes
};
const { error } = await supabase
.from(TABLES.PROJECTS)
.update(updatePayload)
.eq('id', editModeId);
if (error) {
console.warn('Full update failed, trying safe mode (notes only):', error.message);
await supabase
.from(TABLES.PROJECTS)
.update({
title: editForm.title,
status: editForm.status,
notes: structuredNotes
})
.eq('id', editModeId);
}
setEditModeId(null);
await refreshData();
} catch (err) {
console.error('Save error:', err);
} finally {
setIsSaving(false);
}
};
const handleUpdateNotesOnly = async (id: string, newNotes: string) => {
setProjects(prev => prev.map(p => p.id === id ? { ...p, notes: newNotes } : p));
await supabase.from(TABLES.PROJECTS).update({ notes: newNotes }).eq('id', id);
};
const confirmDelete = async () => {
if (projectToDelete) {
await supabase.from(TABLES.PROJECTS).delete().eq('id', projectToDelete);
if (expandedId === projectToDelete) setExpandedId(null);
setProjectToDelete(null);
refreshData();
}
};
const getDisplayInfo = (project: Project, type: 'KUNDE' | 'EXPERTE' | 'KONTAKT' | 'START' | 'ENDE') => {
const key = type.toLowerCase();
const value = (project as any)[key];
if (value && value !== 'Nicht angegeben' && value !== 'Kein Experte zugewiesen' && value !== 'TBD' && value !== 'N/A') return value;
if (project.notes) {
const lines = project.notes.split('\n');
const prefix = `${type}: `;
const line = lines.find(l => l.startsWith(prefix));
if (line) return line.replace(prefix, '').trim();
}
if (type === 'START') return project.start || 'N/A';
if (type === 'ENDE') return project.end || 'TBD';
return 'N/A';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Laufende Projekte</h2>
<p className="text-slate-500">Übersicht aller aktiven Beauftragungen</p>
</div>
<button 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>
Projekt anlegen
</button>
</div>
<div className="bg-white dark:bg-slate-900 rounded-[2.5rem] 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-[900px]">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/50">
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Projekt</th>
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Kunde</th>
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Experten</th>
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Laufzeit</th>
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Status</th>
<th className="p-5 w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{projects.map((project) => {
const customerName = getDisplayInfo(project, 'KUNDE');
const expertNamesString = getDisplayInfo(project, 'EXPERTE');
const expertNames = expertNamesString.split(', ').filter(n => n && n !== 'N/A' && n !== 'Kein Experte zugewiesen');
const start = getDisplayInfo(project, 'START');
const end = getDisplayInfo(project, 'ENDE');
return (
<React.Fragment key={project.id}>
<tr
onClick={() => {
if (editModeId !== project.id) {
setExpandedId(expandedId === project.id ? null : project.id);
}
}}
className={`hover:bg-slate-50 dark:hover:bg-slate-800 transition-all cursor-pointer group ${expandedId === project.id ? 'bg-brand-50/30 dark:bg-brand-500/5' : ''}`}
>
<td className="p-5 font-bold text-slate-900 dark:text-white group-hover:text-brand-600 transition-colors">
{project.title}
</td>
<td className="p-5 text-slate-700 dark:text-slate-300 font-medium">
{customerName}
</td>
<td className="p-5">
<div className="flex items-center -space-x-3">
{expertNames.length > 0 ? (
expertNames.slice(0, 3).map((name, i) => (
<div
key={i}
title={name}
className="w-10 h-10 rounded-full bg-brand-500 text-white border-2 border-white dark:border-slate-800 flex items-center justify-center text-xs font-bold shadow-sm"
>
{name.charAt(0)}
</div>
))
) : (
<span className="text-xs text-slate-400 italic">Keine Experten</span>
)}
{expertNames.length > 3 && (
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-500 border-2 border-white dark:border-slate-800 flex items-center justify-center text-[10px] font-bold shadow-sm">
+{expertNames.length - 3}
</div>
)}
</div>
</td>
<td className="p-5 text-xs font-mono text-slate-600 dark:text-slate-400">
<div className="bg-slate-100 dark:bg-slate-800 px-3 py-1.5 rounded-lg inline-block">
{start} - {end}
</div>
</td>
<td className="p-5">
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${
project.status === 'Aktiv'
? 'bg-brand-500 text-white shadow-sm shadow-brand-500/20'
: 'bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400'
}`}>
{project.status}
</span>
</td>
<td className="p-5 flex items-center justify-end gap-1">
<button
onClick={(e) => { e.stopPropagation(); setProjectToDelete(project.id); }}
className="p-2 text-slate-300 hover:text-red-500 transition-colors opacity-0 group-hover: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="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 === project.id && (
<tr className="animate-in fade-in slide-in-from-top-2 duration-300">
<td colSpan={6} className="p-8 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-8">
<div className="lg:col-span-1 space-y-6">
<div className="bg-white dark:bg-slate-800 p-6 rounded-[2rem] border border-slate-100 dark:border-slate-700 shadow-sm relative overflow-hidden">
<div className="flex justify-between items-center mb-6">
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-widest flex items-center gap-2">
<span className="w-1.5 h-4 bg-brand-500 rounded-full"></span>
{editModeId === project.id ? 'Bearbeiten' : 'Details'}
</h4>
{editModeId !== project.id ? (
<button
onClick={() => startEditing(project)}
className="text-xs font-bold text-brand-600 hover:text-brand-700 underline"
>
Bearbeiten
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => setEditModeId(null)}
className="text-[10px] font-bold text-slate-400 uppercase tracking-widest hover:text-slate-600"
>
Abbruch
</button>
</div>
)}
</div>
{editModeId === project.id ? (
<div className="space-y-4 animate-in fade-in duration-200">
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Titel</label>
<input
type="text"
value={editForm.title || ''}
onChange={e => setEditForm({...editForm, title: e.target.value})}
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Beginn</label>
<input
type="date"
value={editForm.start || ''}
onChange={e => setEditForm({...editForm, start: e.target.value})}
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white"
/>
</div>
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Ende</label>
<input
type="date"
value={editForm.end || ''}
onChange={e => setEditForm({...editForm, end: e.target.value})}
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white"
/>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Kunde</label>
<div className="relative group/field">
<input
type="text"
readOnly
value={editForm.customer || ''}
onClick={() => setIsCustomerPickerOpen(true)}
placeholder="Kunden auswählen..."
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white cursor-pointer hover:border-brand-500 transition-colors"
/>
<svg className="w-4 h-4 absolute right-3 top-3 text-slate-400 group-hover/field:text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Experten ({editForm.expert ? editForm.expert.split(', ').length : 0})</label>
<div className="relative group/field">
<input
type="text"
readOnly
value={editForm.expert || ''}
onClick={() => setIsExpertPickerOpen(true)}
placeholder="Experten auswählen..."
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white cursor-pointer hover:border-brand-500 transition-colors truncate"
/>
<svg className="w-4 h-4 absolute right-3 top-3 text-slate-400 group-hover/field:text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
<button
onClick={handleSaveProject}
disabled={isSaving}
className="w-full py-3 bg-brand-600 text-white rounded-xl font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-700 transition-all flex items-center justify-center gap-2 mt-2"
>
{isSaving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : 'Änderungen speichern'}
</button>
</div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center py-2 border-b border-slate-50 dark:border-slate-700/50">
<span className="text-slate-500 text-[10px] font-bold uppercase">Beginn</span>
<span className="font-mono text-xs dark:text-white">{start}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-slate-50 dark:border-slate-700/50">
<span className="text-slate-500 text-[10px] font-bold uppercase">Geplantes Ende</span>
<span className="font-mono text-xs dark:text-white">{end}</span>
</div>
<div className="space-y-2 py-2 border-b border-slate-50 dark:border-slate-700/50">
<span className="text-slate-500 text-[10px] font-bold uppercase block mb-2">Eingesetzte Experten</span>
<div className="flex flex-wrap gap-1.5">
{expertNames.length > 0 ? expertNames.map((name, i) => (
<span key={i} className="px-2 py-1 bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 text-[10px] font-extrabold uppercase rounded-lg border border-brand-100 dark:border-brand-500/20">
{name}
</span>
)) : <span className="text-xs text-slate-400 italic">Keine Experten</span>}
</div>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-slate-500 text-[10px] font-bold uppercase">Kunde</span>
<span className="text-xs font-bold dark:text-white">{customerName}</span>
</div>
</div>
)}
</div>
<button className="w-full py-4 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-bold rounded-2xl hover:bg-slate-200 transition-all border border-transparent hover:border-slate-200 text-xs">
Vertragsdokumente verwalten
</button>
</div>
<div className="lg:col-span-2">
<div className="bg-white dark:bg-slate-800 p-6 rounded-[2rem] border border-slate-100 dark:border-slate-700 shadow-sm h-full flex flex-col">
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4 flex items-center gap-2">
<span className="w-1.5 h-4 bg-brand-500 rounded-full"></span>
Projektjournal & Protokoll
</h4>
<textarea
value={project.notes || ''}
onChange={(e) => handleUpdateNotesOnly(project.id, e.target.value)}
placeholder="Hier Meilensteine, Feedback oder Projektfortschritte festhalten..."
className="flex-1 w-full p-5 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 transition-all min-h-[250px]"
/>
<div className="mt-3 flex items-center gap-2 text-[10px] text-slate-400 uppercase font-bold tracking-widest">
<div className="w-2 h-2 rounded-full bg-brand-500 animate-pulse"></div>
Änderungen werden live synchronisiert
</div>
</div>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
{/* Customer Picker Modal */}
{isCustomerPickerOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
<div className="bg-white dark:bg-slate-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl p-8 max-h-[80vh] flex flex-col border border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold dark:text-white">Kunden auswählen</h3>
<button onClick={() => setIsCustomerPickerOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
<svg className="w-6 h-6" 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="overflow-y-auto space-y-3 pr-2">
{customers.map(c => (
<div
key={c.id}
onClick={() => { setEditForm({...editForm, customer: c.company}); setIsCustomerPickerOpen(false); }}
className="p-5 rounded-3xl bg-slate-50 dark:bg-slate-800 border-2 border-transparent hover:border-brand-500 cursor-pointer transition-all group"
>
<p className="font-bold text-lg dark:text-white group-hover:text-brand-500 transition-colors">{c.company}</p>
<p className="text-xs text-slate-500 uppercase font-bold tracking-widest">{c.name}</p>
</div>
))}
{customers.length === 0 && <p className="text-center py-10 text-slate-500 italic">Keine Kunden gefunden.</p>}
</div>
</div>
</div>
)}
{/* Multi-Expert Picker Modal */}
{isExpertPickerOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
<div className="bg-white dark:bg-slate-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl p-8 max-h-[80vh] flex flex-col border border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold dark:text-white">Experten auswählen</h3>
<button onClick={() => setIsExpertPickerOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
<svg className="w-6 h-6" 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="overflow-y-auto space-y-3 pr-2 flex-1">
{experts.map(e => {
const currentExpertList = (editForm.expert || '').split(', ').filter(Boolean);
const isSelected = currentExpertList.includes(e.name);
return (
<div
key={e.id}
onClick={() => {
const newList = isSelected
? currentExpertList.filter(name => name !== e.name)
: [...currentExpertList, e.name];
setEditForm({...editForm, expert: newList.join(', ')});
}}
className={`p-5 rounded-3xl border-2 transition-all group flex items-center gap-4 cursor-pointer ${
isSelected ? 'border-brand-500 bg-brand-50 dark:bg-brand-500/10' : 'border-transparent bg-slate-50 dark:bg-slate-800 hover:border-slate-300'
}`}
>
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center font-bold text-lg uppercase transition-all ${
isSelected ? 'bg-brand-500 text-white' : 'bg-slate-200 dark:bg-slate-700 text-slate-500'
}`}>
{e.name.charAt(0)}
</div>
<div className="flex-1">
<p className={`font-bold text-lg transition-colors ${isSelected ? 'text-brand-700 dark:text-brand-400' : 'dark:text-white'}`}>{e.name}</p>
<p className="text-xs text-slate-500 uppercase font-bold tracking-widest">{e.role}</p>
</div>
{isSelected && (
<div className="w-6 h-6 bg-brand-500 text-white rounded-full flex items-center justify-center shadow-lg">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>
</div>
)}
</div>
);
})}
{experts.length === 0 && <p className="text-center py-10 text-slate-500 italic">Keine Experten gefunden.</p>}
</div>
<div className="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800 flex justify-between items-center gap-4">
<p className="text-xs font-bold text-slate-400">{(editForm.expert || '').split(', ').filter(Boolean).length} Experten gewählt</p>
<button
onClick={() => setIsExpertPickerOpen(false)}
className="px-8 py-4 bg-brand-600 text-white font-bold rounded-2xl shadow-xl shadow-brand-500/20 hover:bg-brand-700 transition-all"
>
Fertig
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{projectToDelete && (
<div className="fixed inset-0 z-[120] 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 dark:border-red-900/30">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/20 text-red-600 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<h3 className="text-2xl font-bold mb-2 dark:text-white">Projekt löschen?</h3>
<p className="text-slate-500 mb-8 text-sm">Diese Aktion kann nicht rückgängig gemacht werden. Alle verknüpften Journal-Einträge gehen verloren.</p>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => setProjectToDelete(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-bold text-slate-600 dark:text-slate-300">Abbruch</button>
<button onClick={confirmDelete} className="py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-500/20 hover:bg-red-700 transition-all">Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};
+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
interface SettingsProps {
isDarkMode: boolean;
onToggleDarkMode: () => void;
}
export const Settings: React.FC<SettingsProps> = ({ isDarkMode, onToggleDarkMode }) => {
return (
<div className="max-w-2xl space-y-8">
<section>
<h3 className="text-xl font-bold mb-6">Erscheinungsbild</h3>
<div className="bg-white dark:bg-slate-900 rounded-3xl p-6 border border-slate-100 dark:border-slate-800 flex items-center justify-between">
<div>
<p className="font-bold">Dark Mode</p>
<p className="text-sm text-slate-500">Das Design der Anwendung umschalten</p>
</div>
<button
onClick={onToggleDarkMode}
className={`w-14 h-8 rounded-full transition-colors relative ${isDarkMode ? 'bg-brand-500' : 'bg-slate-200 dark:bg-slate-800'}`}
>
<div className={`absolute top-1 w-6 h-6 rounded-full bg-white shadow-sm transition-all ${isDarkMode ? 'left-7' : 'left-1'}`}></div>
</button>
</div>
</section>
<section>
<h3 className="text-xl font-bold mb-6">CRM-Konfiguration</h3>
<div className="bg-white dark:bg-slate-900 rounded-3xl p-6 border border-slate-100 dark:border-slate-800 space-y-6">
<div className="flex items-center justify-between py-2">
<div>
<p className="font-bold">AI Experten-Matching</p>
<p className="text-sm text-slate-500">Automatische Vorschläge basierend auf Tender-Skills</p>
</div>
<input type="checkbox" defaultChecked className="w-5 h-5 accent-brand-500" />
</div>
<div className="flex items-center justify-between py-2 border-t border-slate-50 dark:border-slate-800">
<div>
<p className="font-bold">LinkedIn-Synchronisierung</p>
<p className="text-sm text-slate-500">Profile automatisch mit LinkedIn-Daten anreichern</p>
</div>
<input type="checkbox" defaultChecked className="w-5 h-5 accent-brand-500" />
</div>
</div>
</section>
<section>
<h3 className="text-xl font-bold mb-6">Über ICON</h3>
<div className="p-6 bg-brand-500 rounded-3xl text-white shadow-xl shadow-brand-500/20">
<p className="font-bold text-lg mb-2">Version 2.4.0</p>
<p className="text-sm opacity-90 leading-relaxed">
Ihr smartes CRM für die Expertenvermittlung. Diese Anwendung wurde für höchste Performance und intuitive Bedienung auf Desktop und Mobilgeräten optimiert.
</p>
</div>
</section>
</div>
);
};
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import { ViewState } from '../types';
interface SidebarProps {
currentView: ViewState;
onViewChange: (view: ViewState) => void;
isOpen: boolean;
onToggle: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isOpen, onToggle }) => {
const menuItems: { name: ViewState; icon: string }[] = [
{ name: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ name: 'Live!', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ name: 'Ausschreibungen', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
{ name: 'Kunden', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
{ name: 'Experten', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
{ name: 'Laufende Projekte', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ name: 'Organigramm', icon: 'M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2' },
{ name: 'Einstellungen', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
];
return (
<>
{isOpen && (
<div
className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-20 lg:hidden"
onClick={onToggle}
/>
)}
<aside className={`
fixed lg:static inset-y-0 left-0 z-30
${isOpen ? 'w-64 translate-x-0' : 'w-0 lg:w-20 -translate-x-full lg:translate-x-0'}
bg-slate-900 text-white transition-all duration-300 flex flex-col h-full overflow-hidden
`}>
<div className="p-6 flex items-center gap-4 shrink-0">
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center shrink-0">
<span className="font-bold text-white text-xl">I</span>
</div>
{(isOpen || window.innerWidth < 1024) && <span className="text-xl font-bold tracking-wider">ICON</span>}
</div>
<nav className="flex-1 mt-4 px-3 space-y-1 overflow-y-auto overflow-x-hidden">
{menuItems.map((item) => (
<button
key={item.name}
onClick={() => {
onViewChange(item.name);
if (window.innerWidth < 1024) onToggle();
}}
className={`
w-full flex items-center p-3 rounded-xl transition-all group
${currentView === item.name
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'}
`}
>
<svg className="w-6 h-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={item.icon} />
</svg>
{(isOpen || window.innerWidth < 1024) && <span className="ml-4 font-medium truncate">{item.name}</span>}
</button>
))}
</nav>
<div className="p-4 border-t border-slate-800 shrink-0">
<div className="flex items-center gap-3">
<img src="https://picsum.photos/40/40" className="w-10 h-10 rounded-full border-2 border-slate-700" alt="Profile" />
{(isOpen || window.innerWidth < 1024) && (
<div className="overflow-hidden">
<p className="text-sm font-semibold truncate">Admin User</p>
<p className="text-xs text-slate-500 truncate">admin@icon.de</p>
</div>
)}
</div>
</div>
</aside>
</>
);
};
+355
View File
@@ -0,0 +1,355 @@
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>
);
};