Quellcode des ICON CRM (1)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user