Quellcode des ICON CRM (2)

This commit is contained in:
2026-09-14 16:55:32 +02:00
parent c8db64af7e
commit 0a597ae527
25 changed files with 5057 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
-- Database Schema Update for Customer Profiles
-- Add 'address' column to 'icon_customers' if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'icon_customers' AND column_name = 'address') THEN
ALTER TABLE icon_customers ADD COLUMN "address" text;
END IF;
END $$;
+20
View File
@@ -0,0 +1,20 @@
-- Database Schema Update for Expert Profiles
-- Check if the table 'icon_experts' exists and add the new columns
-- 'hourlyRateOnsite' and 'hourlyRateRemote'.
-- Using numeric(10, 2) is suitable for currency values.
DO $$
BEGIN
-- Add hourlyRateOnsite column if it doesn't exist
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'icon_experts' AND column_name = 'hourlyRateOnsite') THEN
ALTER TABLE icon_experts ADD COLUMN "hourlyRateOnsite" numeric(10, 2);
END IF;
-- Add hourlyRateRemote column if it doesn't exist
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'icon_experts' AND column_name = 'hourlyRateRemote') THEN
ALTER TABLE icon_experts ADD COLUMN "hourlyRateRemote" numeric(10, 2);
END IF;
END $$;
@@ -0,0 +1,18 @@
-- Add updated_at column if it doesn't exist
ALTER TABLE icon_experts ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now());
-- Function to handle updating the updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = timezone('utc'::text, now());
RETURN NEW;
END;
$$ language 'plpgsql';
-- Trigger to execute the function before each update
DROP TRIGGER IF EXISTS update_icon_experts_updated_at ON icon_experts;
CREATE TRIGGER update_icon_experts_updated_at
BEFORE UPDATE ON icon_experts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
+193
View File
@@ -0,0 +1,193 @@
import React, { useState, useEffect } from 'react';
import { Sidebar } from './components/Sidebar';
import { Header } from './components/Header';
import { Dashboard } from './components/Dashboard';
import { KanbanBoard } from './components/KanbanBoard';
import { Tenders } from './components/Tenders';
import { Customers } from './components/Customers';
import { Experts } from './components/Experts';
import { Projects } from './components/Projects';
import { OrgChart } from './components/OrgChart';
import { Settings } from './components/Settings';
import { ViewState, Tender, Expert, Customer, Project } from './types';
import { supabase, TABLES } from './lib/supabase';
import { LoginScreen } from './components/LoginScreen';
import { AppUser, getSession, clearSession, saveSession } from './lib/auth';
const App: React.FC = () => {
const [currentUser, setCurrentUser] = useState<AppUser | null>(() => getSession());
const [currentView, setCurrentView] = useState<ViewState>('Dashboard');
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
const saved = localStorage.getItem('isDarkMode');
return saved !== null ? JSON.parse(saved) : true;
});
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [tenders, setTenders] = useState<Tender[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [experts, setExperts] = useState<Expert[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const fetchData = async (showRefreshIndicator = false) => {
if (showRefreshIndicator) setIsRefreshing(true);
setFetchError(null);
try {
const { data: customersData } = await supabase
.from(TABLES.CUSTOMERS)
.select('*, icon_contacts(*)');
const formattedCustomers = (customersData || []).map(c => ({
...c,
contacts: c.icon_contacts || []
}));
setCustomers(formattedCustomers);
const { data: expertsData } = await supabase.from(TABLES.EXPERTS).select('*');
setExperts(expertsData || []);
const { data: projectsData } = await supabase.from(TABLES.PROJECTS).select('*');
setProjects(projectsData || []);
const { data: tendersData } = await supabase
.from(TABLES.TENDERS)
.select(`
*,
selectedCustomer:icon_customers(*),
selectedExperts:icon_tender_experts(icon_experts(*))
`)
.neq('status', 'Archiviert');
if (tendersData) {
const formattedTenders: Tender[] = tendersData.map(t => ({
id: t.id,
title: t.title,
source: t.source,
link: t.link,
details: t.details,
notes: t.notes,
status: t.status,
kanbanStatus: t.kanban_status,
online: t.online ?? false,
selectedCustomer: t.selectedCustomer,
selectedExperts: t.selectedExperts?.map((se: any) => se.icon_experts) || [],
applicantsCount: t.selectedExperts?.length || 0
}));
setTenders(formattedTenders);
}
} catch (error) {
console.error('Error fetching data:', error);
setFetchError('Verbindung zur Datenbank fehlgeschlagen. Bitte Seite neu laden.');
} finally {
setIsInitialLoading(false);
setIsRefreshing(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(() => fetchData(true), 60_000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
localStorage.setItem('isDarkMode', JSON.stringify(isDarkMode));
if (isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDarkMode]);
const toggleDarkMode = () => setIsDarkMode(!isDarkMode);
if (isInitialLoading) {
return (
<div className="h-screen w-screen flex items-center justify-center" style={{ backgroundColor: '#0A0A0A' }}>
<div className="flex flex-col items-center gap-6">
<img src="/logo.png" alt="ICON Experts" className="h-10 brightness-0 invert opacity-80" />
<div className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: '#C1862F', borderTopColor: 'transparent' }}></div>
<p className="text-xs font-semibold tracking-widest uppercase animate-pulse" style={{ color: '#C1862F' }}>Verbinde zur Datenbank...</p>
</div>
</div>
);
}
const renderContent = () => {
switch (currentView) {
case 'Dashboard': return <Dashboard tenders={tenders} experts={experts} customers={customers} projects={projects} />;
case 'Live!': return <KanbanBoard tenders={tenders} setTenders={setTenders} refreshData={() => fetchData(true)} />;
case 'Ausschreibungen': return <Tenders tenders={tenders} setTenders={setTenders} customers={customers} experts={experts} refreshData={() => fetchData(true)} />;
case 'Kunden': return <Customers customers={customers} refreshData={() => fetchData(true)} />;
case 'Experten': return <Experts experts={experts} setExperts={setExperts} refreshData={() => fetchData(true)} />;
case 'Laufende Projekte': return <Projects projects={projects} setProjects={setProjects} customers={customers} experts={experts} refreshData={() => fetchData(true)} />;
case 'Organigramm': return <OrgChart customers={customers} projects={projects} experts={experts} />;
case 'Einstellungen': return <Settings isDarkMode={isDarkMode} onToggleDarkMode={toggleDarkMode} currentUser={currentUser} onUserUpdated={handleUserUpdated} />;
default: return <Dashboard tenders={tenders} experts={experts} customers={customers} projects={projects} onNavigate={setCurrentView} />;
}
};
const handleUserUpdated = (updatedUser: AppUser) => {
if (currentUser && updatedUser.id === currentUser.id) {
setCurrentUser(updatedUser);
saveSession(updatedUser);
}
};
const handleLogout = () => {
clearSession();
setCurrentUser(null);
};
if (!currentUser) {
return <LoginScreen onLogin={(user) => setCurrentUser(user)} />;
}
return (
<div className="flex h-screen overflow-hidden bg-slate-50 dark:bg-[#0A0A0A] text-slate-900 dark:text-slate-100 font-sans">
<Sidebar
currentView={currentView}
onViewChange={setCurrentView}
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen(!isSidebarOpen)}
currentUser={currentUser}
onLogout={handleLogout}
/>
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Header
title={currentView}
isDarkMode={isDarkMode}
onToggleDarkMode={toggleDarkMode}
onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
tenders={tenders}
experts={experts}
customers={customers}
projects={projects}
onNavigate={setCurrentView}
/>
<div className="flex-1 overflow-y-auto p-3 md:p-5 relative">
{isRefreshing && (
<div className="absolute top-4 right-8 z-50 flex items-center gap-2 bg-white/90 dark:bg-[#141414]/90 px-4 py-2 rounded shadow-lg border border-slate-200 dark:border-[#2a2a2a] backdrop-blur-sm">
<div className="w-3.5 h-3.5 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: '#C1862F', borderTopColor: 'transparent' }}></div>
<span className="text-[10px] font-semibold uppercase tracking-widest" style={{ color: '#C1862F' }}>Synchronisiere...</span>
</div>
)}
{fetchError && (
<div className="mb-4 flex items-center gap-3 p-4 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900/30 rounded-lg text-red-700 dark:text-red-400">
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /></svg>
<span className="text-sm font-medium">{fetchError}</span>
<button onClick={() => fetchData(true)} className="ml-auto text-xs font-semibold uppercase tracking-wider underline hover:no-underline">Erneut versuchen</button>
</div>
)}
{renderContent()}
</div>
</main>
</div>
);
};
export default App;
+565
View File
@@ -0,0 +1,565 @@
import React, { useState, useEffect } from 'react';
import { Customer, Contact } from '../types';
import { supabase, TABLES } from '../lib/supabase';
import { useToast } from './Toast';
interface CustomersProps {
customers: Customer[];
refreshData: () => Promise<void>;
}
export const Customers: React.FC<CustomersProps> = ({ customers, refreshData }) => {
const { addToast } = useToast();
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: '',
address: '',
website: ''
});
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: '', address: '', website: '' });
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 || '',
address: customer.address || '',
website: customer.website || ''
});
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 handleContactPhotoUpload = (index: number, e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
updateContactField(index, 'avatar', reader.result as string);
};
reader.readAsDataURL(file);
}
};
const addContactRow = () => {
setContactsForm([...contactsForm, { name: '', role: '', email: '', phone: '', avatar: '', bio: '' }]);
};
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()) {
addToast('Bitte geben Sie einen Kundennamen an.', 'error');
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);
addToast(editingCustomer ? 'Kunde aktualisiert' : 'Neuer Kunde angelegt', 'success');
await refreshData();
if (!editingCustomer && finalCustomerId) setSelectedCustomerId(finalCustomerId);
} catch (error: any) {
addToast('Fehler beim Speichern: ' + (error.message || 'Unbekannter Fehler'), 'error');
} finally {
setIsSaving(false);
}
};
const confirmDeleteCustomer = async () => {
if (!customerToDeleteId) return;
try {
await supabase.from(TABLES.CUSTOMERS).delete().eq('id', customerToDeleteId);
if (selectedCustomerId === customerToDeleteId) setSelectedCustomerId(null);
addToast('Kunde gelöscht', 'success');
await refreshData();
} catch (error: any) {
addToast('Fehler beim Löschen: ' + (error.message || 'Unbekannt'), 'error');
} finally {
setCustomerToDeleteId(null);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-8 gap-5 h-full">
{/* Sidebar: Firmenliste */}
<div className="lg:col-span-1 space-y-3 flex flex-col min-h-0">
<button
onClick={handleOpenCreate}
className="w-full py-2.5 bg-brand-600 text-white font-extrabold rounded-xl shadow-xl-brand hover:bg-brand-700 transition-all shrink-0 flex items-center justify-center gap-2 active:scale-95 text-sm"
>
<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>
Neuer Kunde
</button>
<div className="flex-1 bg-white dark:bg-slate-900 rounded-2xl border-2 border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col shadow-card">
<div className="p-3 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-3 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-3 min-w-0">
<div className={`w-9 h-9 rounded-lg 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-md' : '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-7 min-w-0">
{activeCustomer ? (
<div className="space-y-5 animate-in fade-in slide-in-from-bottom-6 duration-500">
<div className="bg-white dark:bg-slate-900 rounded-2xl p-6 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-5">
<div className="flex items-center gap-5">
<div className="w-16 h-16 rounded-2xl bg-brand-500 text-white flex items-center justify-center font-extrabold text-3xl shadow-xl-brand overflow-hidden border-2 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-2xl font-extrabold text-slate-900 dark:text-white leading-tight tracking-tight">{activeCustomer.company}</h2>
<div className="flex flex-col gap-2 mt-3">
<div className="flex items-center gap-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>
{activeCustomer.address && (
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(activeCustomer.address)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-slate-500 dark:text-slate-400 text-sm font-medium hover:text-brand-600 dark:hover:text-brand-400 transition-colors group/address w-fit"
title="Route in Google Maps berechnen"
>
<svg className="w-4 h-4 text-brand-500 group-hover/address:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
<span className="hover:underline decoration-brand-500/30 underline-offset-4">{activeCustomer.address}</span>
</a>
)}
{activeCustomer.website && (
<a
href={activeCustomer.website.startsWith('http') ? activeCustomer.website : `https://${activeCustomer.website}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-slate-500 dark:text-slate-400 text-sm font-medium hover:text-brand-600 dark:hover:text-brand-400 transition-colors group/website w-fit"
title="Website öffnen"
>
<svg className="w-4 h-4 text-brand-500 group-hover/website:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
<span className="hover:underline decoration-brand-500/30 underline-offset-4">{activeCustomer.website}</span>
</a>
)}
</div>
</div>
</div>
<button
onClick={() => handleOpenEdit(activeCustomer)}
className="px-6 py-2.5 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-xl hover:border-brand-500 hover:text-brand-600 transition-all shadow-soft active:scale-95 text-sm"
>
Kunde bearbeiten
</button>
</div>
<div className="mt-6 pt-6 border-t-2 border-slate-100 dark:border-slate-800">
<h3 className="text-xs font-extrabold text-slate-400 uppercase tracking-[0.25em] mb-4">Kundenprofil & Insights</h3>
<div className="p-4 bg-slate-50 dark:bg-slate-800/40 rounded-2xl border-2 border-slate-200 dark:border-slate-800 text-sm leading-relaxed text-slate-600 dark:text-slate-300 min-h-[80px] whitespace-pre-wrap font-medium">
{activeCustomer.notes || 'Keine Kundennotizen hinterlegt.'}
</div>
</div>
</div>
<div className="space-y-5">
<div className="flex items-center gap-4">
<span className="w-2 h-10 bg-brand-500 rounded-full"></span>
<h3 className="text-xl 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 lg:grid-cols-4 xl:grid-cols-6 gap-3 pb-6">
{activeCustomer.contacts?.map(contact => (
<div key={contact.id} className="bg-white dark:bg-slate-900 p-3 rounded-xl border border-slate-200 dark:border-slate-800 shadow-md flex flex-col items-center text-center group hover:border-brand-500/50 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 relative">
<div className="w-12 h-12 rounded-full overflow-hidden mb-3 border border-slate-100 dark:border-slate-800 shadow-sm 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-base 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-1 mb-4">{contact.role || 'Mitarbeiter'}</p>
<div className="w-full space-y-2 pt-4 border-t-2 border-slate-50 dark:border-slate-800 mt-auto">
{contact.email ? (
<a href={`mailto:${contact.email}`} className="flex items-center gap-3 text-xs font-semibold text-slate-500 justify-center hover:text-brand-600 transition-colors">
<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}</span>
</a>
) : (
<div className="flex items-center gap-3 text-xs font-semibold text-slate-400 justify-center">
<svg className="w-4 h-4 text-slate-300 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">N/A</span>
</div>
)}
{contact.phone ? (
<a href={`tel:${contact.phone}`} className="flex items-center gap-3 text-xs font-semibold text-slate-500 justify-center hover:text-brand-600 transition-colors">
<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}</span>
</a>
) : (
<div className="flex items-center gap-3 text-xs font-semibold text-slate-400 justify-center">
<svg className="w-4 h-4 text-slate-300 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>N/A</span>
</div>
)}
{contact.bio && (
<div className="mt-3 p-2 bg-slate-50 dark:bg-slate-800/80 rounded-lg text-[9px] text-slate-500 dark:text-slate-400 italic text-center border border-slate-100 dark:border-slate-800 leading-snug">
{contact.bio}
</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 einen Kunden 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 ? 'Kunde aktualisieren' : 'Neuer Kunde 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">Kundenlogo (optional)</p>
</div>
<div className="space-y-4">
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Kundenname*</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>
<select
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 appearance-none cursor-pointer"
>
<option value="">Kein Hauptansprechpartner</option>
{contactsForm.map((contact, idx) => (
contact.name && <option key={idx} value={contact.name}>{contact.name} {contact.role ? `(${contact.role})` : ''}</option>
))}
{/* Fallback falls der aktuelle Name nicht in der Liste ist */}
{customerForm.name && !contactsForm.some(c => c.name === customerForm.name) && (
<option value={customerForm.name}>{customerForm.name} (Aktuell)</option>
)}
</select>
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Adresse</label>
<input
type="text"
value={customerForm.address}
onChange={e => setCustomerForm({ ...customerForm, address: 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="Straße, PLZ Ort"
/>
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Website</label>
<input
type="url"
value={customerForm.website}
onChange={e => setCustomerForm({ ...customerForm, website: 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="https://www.beispiel.de"
/>
</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">Kundenprofil & 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 zum Kunden, 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="p-5 bg-slate-50 dark:bg-slate-800/50 rounded-[2rem] border-2 border-slate-100 dark:border-slate-700/50 space-y-4">
<div className="grid grid-cols-12 gap-3 items-center">
<div className="col-span-12 md:col-span-2">
<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-[10px] 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-[10px] 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-[10px] border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-12 md:col-span-2">
<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-[10px] border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-12 md:col-span-2">
<div className="flex items-center gap-2">
<label className="flex-1 flex flex-col items-center justify-center h-[46px] bg-white dark:bg-slate-800 border-2 border-dashed border-slate-200 dark:border-slate-700 rounded-xl cursor-pointer hover:border-brand-500 transition-all overflow-hidden relative group">
{contact.avatar ? (
<img src={contact.avatar} alt="Contact" className="w-full h-full object-cover" />
) : (
<svg className="w-5 h-5 text-slate-300" 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 className="absolute inset-0 bg-black/40 text-white opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity text-[8px] font-bold uppercase">Upload</div>
<input type="file" className="hidden" accept="image/*" onChange={(e) => handleContactPhotoUpload(idx, e)} />
</label>
{contact.avatar && (
<button type="button" onClick={() => updateContactField(idx, 'avatar', '')} className="p-2 text-slate-400 hover:text-red-500 transition-colors"><svg className="w-4 h-4" 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>
<div className="col-span-12 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 className="relative">
<input
type="text"
placeholder="Zusätzliche Informationen (z.B. Notizen, Vorlieben, Erreichbarkeit...)"
value={contact.bio}
onChange={(e) => updateContactField(idx, 'bio', e.target.value)}
className="w-full px-5 py-3 bg-white dark:bg-slate-800 rounded-2xl outline-none font-medium text-[11px] border-2 border-transparent focus:border-brand-500 shadow-inner text-slate-600 dark:text-slate-300 transition-all"
/>
</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> : 'Kunde 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">Kunde 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>
);
};
+323
View File
@@ -0,0 +1,323 @@
import React, { useMemo } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend } from 'recharts';
import { Tender, Expert, Customer, Project, KanbanColumn } from '../types';
interface DashboardProps {
tenders: Tender[];
experts: Expert[];
customers: Customer[];
projects: Project[];
onNavigate?: (view: any) => void;
}
const KANBAN_COLUMNS: KanbanColumn[] = [
'Ausschreibung',
'Experten sichten',
'Interview',
'Kunden Interview',
'Vertragsgestaltung',
'Abschluss',
'Geschlossen',
];
const KANBAN_COLORS: Record<KanbanColumn, string> = {
'Ausschreibung': '#C1862F',
'Experten sichten': '#d4973a',
'Interview': '#e6a844',
'Kunden Interview': '#0ea5e9',
'Vertragsgestaltung': '#7c3aed',
'Abschluss': '#16a34a',
'Geschlossen': '#6b7280',
};
const EXPERT_STATUS_COLORS: Record<string, string> = {
'Interessiert': '#C1862F',
'Interview': '#dfa85a',
'Verfügbar': '#16a34a',
'Gebucht': '#a97228',
};
export const Dashboard: React.FC<DashboardProps> = ({ tenders, experts, customers, projects, onNavigate }) => {
// --- KPI Calculations ---
const activeTenders = useMemo(() => tenders.filter(t => t.status === 'Aktiv').length, [tenders]);
const activeProjects = useMemo(() => projects.filter(p => p.status === 'Aktiv').length, [projects]);
const closedTenders = useMemo(() => tenders.filter(t => t.kanbanStatus === 'Abschluss' || t.kanbanStatus === 'Geschlossen').length, [tenders]);
const closingRate = useMemo(() => {
if (tenders.length === 0) return '—';
return Math.round((closedTenders / tenders.length) * 100) + '%';
}, [tenders, closedTenders]);
// --- Pipeline Chart Data (Kanban columns) ---
const pipelineData = useMemo(() =>
KANBAN_COLUMNS
.filter(col => col !== 'Geschlossen')
.map(col => ({
name: col,
count: tenders.filter(t => t.kanbanStatus === col).length,
color: KANBAN_COLORS[col],
})),
[tenders]
);
// --- Expert Status Chart Data ---
const expertStatusData = useMemo(() => {
const statusMap: Record<string, number> = {};
experts.forEach(e => {
statusMap[e.status] = (statusMap[e.status] || 0) + 1;
});
return Object.entries(statusMap).map(([name, value]) => ({ name, value }));
}, [experts]);
// --- Recent Activity (last 5 tenders & experts by order in array) ---
const recentTenders = useMemo(() => tenders.slice(0, 3), [tenders]);
const kpis = [
{
label: 'Aktive Ausschreibungen',
value: activeTenders.toString(),
sub: `${tenders.length} gesamt`,
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',
accent: '#C1862F',
},
{
label: 'Gesamt Experten',
value: experts.length.toString(),
sub: `${experts.filter(e => e.status === 'Verfügbar' || e.status === 'Interessiert').length} verfügbar`,
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',
accent: '#dfa85a',
},
{
label: 'Laufende Projekte',
value: activeProjects.toString(),
sub: `${projects.length} gesamt`,
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',
accent: '#a97228',
},
{
label: 'Abschlussrate',
value: closingRate,
sub: `${closedTenders} Abschlüsse`,
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
accent: '#8a5c20',
},
];
return (
<div className="space-y-4">
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{kpis.map((stat) => (
<div key={stat.label} className="bg-white dark:bg-[#141414] p-3.5 rounded-xl shadow-card border border-slate-200 dark:border-[#2a2a2a] transition-all hover:shadow-lg hover:-translate-y-0.5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: stat.accent + '18', border: `1px solid ${stat.accent}30` }}
>
<svg className="w-5 h-5" style={{ color: stat.accent }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={stat.icon} />
</svg>
</div>
<div className="min-w-0">
<p className="text-slate-500 dark:text-slate-400 text-xs font-semibold tracking-wide leading-tight truncate uppercase">{stat.label}</p>
<p className="text-[10px] text-slate-400 dark:text-slate-500 font-medium mt-0.5 truncate">{stat.sub}</p>
</div>
</div>
<div className="text-right shrink-0">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white leading-none" style={{ color: stat.accent }}>{stat.value}</h3>
</div>
</div>
))}
</div>
{/* Pipeline Widget */}
<div className="bg-white dark:bg-[#141414] p-4 rounded-2xl border border-slate-200 dark:border-[#2a2a2a] shadow-card">
<h3 className="text-sm font-bold mb-3 flex items-center gap-2">
<span className="w-1 h-5 rounded-sm" style={{ backgroundColor: '#C1862F' }}></span>
<span className="uppercase tracking-wider text-xs font-semibold">Ausschreibungs-Pipeline</span>
</h3>
<div className="flex items-stretch gap-2 overflow-x-auto pb-2">
{pipelineData.map((phase, idx) => (
<React.Fragment key={phase.name}>
<div className="flex flex-col items-center min-w-[110px] flex-1">
<div
className="w-full rounded-xl flex flex-col items-center justify-center py-3 px-2 border"
style={{ backgroundColor: phase.color + '15', borderColor: phase.color + '40' }}
>
<span className="text-xl font-extrabold" style={{ color: phase.color }}>{phase.count}</span>
<span className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 text-center mt-1 leading-tight">{phase.name}</span>
</div>
</div>
{idx < pipelineData.length - 1 && (
<div className="flex items-center text-slate-300 dark:text-slate-600 self-center text-lg shrink-0">›</div>
)}
</React.Fragment>
))}
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Bar Chart: Kanban Pipeline */}
<div className="bg-white dark:bg-[#141414] p-4 rounded-xl border border-slate-200 dark:border-[#2a2a2a] shadow-card h-[300px] flex flex-col overflow-hidden">
<h3 className="text-xs font-semibold mb-3 shrink-0 flex items-center gap-2 uppercase tracking-wider text-slate-600 dark:text-slate-400">
<span className="w-1 h-4 rounded-sm" style={{ backgroundColor: '#C1862F' }}></span>
Ausschreibungen nach Phase
</h3>
<div className="flex-1 w-full overflow-hidden">
{tenders.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400 text-sm">Keine Daten vorhanden</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={pipelineData} margin={{ top: 10, right: 10, left: -20, bottom: 20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{ fill: '#94a3b8', fontSize: 10, fontFamily: 'Montserrat' }}
angle={0}
textAnchor="middle"
interval={0}
height={30}
/>
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 11 }} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1A1A1A', border: '1px solid #C1862F30', borderRadius: '8px', color: '#fff', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.3)', fontFamily: 'Montserrat' }}
itemStyle={{ color: '#dfa85a' }}
formatter={(value: number) => [value, 'Ausschreibungen']}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} barSize={28}>
{pipelineData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Bar Chart: Expert Status */}
<div className="bg-white dark:bg-[#141414] p-4 rounded-xl border border-slate-200 dark:border-[#2a2a2a] shadow-card h-[300px] flex flex-col overflow-hidden">
<h3 className="text-xs font-semibold mb-3 shrink-0 flex items-center gap-2 uppercase tracking-wider text-slate-600 dark:text-slate-400">
<span className="w-1 h-4 rounded-sm" style={{ backgroundColor: '#dfa85a' }}></span>
Experten nach Status
</h3>
<div className="flex-1 w-full overflow-hidden">
{expertStatusData.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400 text-sm">Keine Daten vorhanden</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={expertStatusData} margin={{ top: 10, right: 10, left: -20, bottom: 20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#2a2a2a" />
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{ fill: '#94a3b8', fontSize: 11 }}
/>
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 11 }} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1A1A1A', border: '1px solid #C1862F30', borderRadius: '8px', fontFamily: 'Montserrat' }}
itemStyle={{ color: '#dfa85a' }}
cursor={{ fill: '#C1862F', opacity: 0.05 }}
formatter={(value: number) => [value + ' Experten', 'Anzahl']}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]} barSize={40}>
{expertStatusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={EXPERT_STATUS_COLORS[entry.name] || '#C1862F'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
{/* Recent Activity */}
<div className="bg-white dark:bg-[#141414] p-4 rounded-xl border border-slate-200 dark:border-[#2a2a2a] shadow-card">
<h3 className="text-xs font-semibold mb-4 flex items-center gap-2 uppercase tracking-wider text-slate-600 dark:text-slate-400">
<span className="w-1 h-4 rounded-sm" style={{ backgroundColor: '#C1862F' }}></span>
Letzte Ausschreibungen
</h3>
{recentTenders.length === 0 ? (
<p className="text-slate-400 text-sm text-center py-6">Keine Ausschreibungen vorhanden</p>
) : (
<div className="space-y-2">
{recentTenders.map((tender) => (
<div
key={tender.id}
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-[#1a1a1a] rounded-lg border border-slate-100 dark:border-[#2a2a2a] transition-all cursor-pointer hover:border-[#C1862F]/30 hover:shadow-sm"
onClick={() => onNavigate?.('Ausschreibungen')}
>
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border"
style={{
backgroundColor: (KANBAN_COLORS[tender.kanbanStatus] || '#C1862F') + '20',
borderColor: (KANBAN_COLORS[tender.kanbanStatus] || '#C1862F') + '40',
}}
>
<svg className="w-4 h-4" style={{ color: KANBAN_COLORS[tender.kanbanStatus] || '#C1862F' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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-2" />
</svg>
</div>
<div className="min-w-0">
<p className="font-semibold text-sm text-slate-800 dark:text-slate-100 truncate">{tender.title}</p>
<p className="text-xs text-slate-500 font-medium truncate">
{tender.selectedCustomer?.company || tender.source} · {tender.kanbanStatus}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
<span
className="px-2 py-0.5 text-[10px] font-bold rounded uppercase tracking-wider"
style={tender.status === 'Aktiv'
? { backgroundColor: '#C1862F18', color: '#C1862F', border: '1px solid #C1862F30' }
: { backgroundColor: '#94a3b818', color: '#94a3b8', border: '1px solid #94a3b830' }
}
>
{tender.status}
</span>
{tender.applicantsCount > 0 && (
<span className="text-xs text-slate-400">· {tender.applicantsCount} Exp.</span>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Customers & Projects summary row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-[#141414] p-4 rounded-xl border border-slate-200 dark:border-[#2a2a2a] shadow-card">
<h3 className="text-xs font-semibold mb-3 flex items-center gap-2 uppercase tracking-wider text-slate-600 dark:text-slate-400">
<span className="w-1 h-4 rounded-sm" style={{ backgroundColor: '#C1862F' }}></span>
Kunden
</h3>
<p className="text-3xl font-bold" style={{ color: '#C1862F' }}>{customers.length}</p>
<p className="text-xs text-slate-400 mt-1 uppercase tracking-wider">Aktive Kundenbeziehungen</p>
</div>
<div className="bg-white dark:bg-[#141414] p-4 rounded-xl border border-slate-200 dark:border-[#2a2a2a] shadow-card">
<h3 className="text-xs font-semibold mb-3 flex items-center gap-2 uppercase tracking-wider text-slate-600 dark:text-slate-400">
<span className="w-1 h-4 rounded-sm" style={{ backgroundColor: '#dfa85a' }}></span>
Projekte nach Status
</h3>
<div className="flex gap-4">
{['Aktiv', 'Nicht aktiv', 'Abgeschlossen'].map(status => (
<div key={status} className="flex-1 text-center">
<p className="text-2xl font-bold text-slate-900 dark:text-white">
{projects.filter(p => p.status === status).length}
</p>
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider">{status}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
};
+673
View File
@@ -0,0 +1,673 @@
import React, { useState } from 'react';
import { Expert } from '../types';
import { supabase, TABLES } from '../lib/supabase';
import { useToast } from './Toast';
interface ExpertsProps {
experts: Expert[];
setExperts: React.Dispatch<React.SetStateAction<Expert[]>>;
refreshData: () => Promise<void>;
}
export const Experts: React.FC<ExpertsProps> = ({ experts, refreshData }) => {
const { addToast } = useToast();
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 [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortConfig, setSortConfig] = useState<{ key: keyof Expert | 'hourlyRate', direction: 'asc' | 'desc' } | null>(null);
const handleSort = (key: keyof Expert | 'hourlyRate') => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
const [formState, setFormState] = useState({
name: '',
role: '',
email: '',
phone: '',
linkedin: '',
status: 'Verfügbar' as Expert['status'],
avatar: '',
bio: '',
tags: [] as string[],
hourlyRateOnsite: '' as number | '',
hourlyRateRemote: '' as number | '',
cv_url: ''
});
const searchTerms = searchTerm.toLowerCase().split(',').map(term => term.trim()).filter(Boolean);
const filteredExperts = experts.filter(e => {
if (searchTerms.length === 0) return true;
// Check if EVERY search term matches at least one of name, role, or tags
return searchTerms.every(term =>
e.name.toLowerCase().includes(term) ||
e.role.toLowerCase().includes(term) ||
e.tags?.some(tag => tag.toLowerCase().includes(term))
);
}).sort((a, b) => {
if (sortConfig) {
const { key, direction } = sortConfig;
let valA: any;
let valB: any;
if (key === 'hourlyRate') {
// Sort by the highest available rate for simplified sorting
valA = Math.max(Number(a.hourlyRateOnsite) || 0, Number(a.hourlyRateRemote) || 0);
valB = Math.max(Number(b.hourlyRateOnsite) || 0, Number(b.hourlyRateRemote) || 0);
} else {
valA = a[key as keyof Expert];
valB = b[key as keyof Expert];
}
if (valA === undefined || valA === null) valA = '';
if (valB === undefined || valB === null) valB = '';
if (typeof valA === 'string') {
const compare = valA.localeCompare(String(valB));
return direction === 'asc' ? compare : -compare;
} else {
const compare = (valA as number) - (valB as number);
return direction === 'asc' ? compare : -compare;
}
}
// Sort descending by updated_at if available, otherwise fallback to 0
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return dateB - dateA;
});
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 || [],
hourlyRateOnsite: expert.hourlyRateOnsite ?? '',
hourlyRateRemote: expert.hourlyRateRemote ?? '',
cv_url: expert.cv_url || ''
});
setTagInput('');
setIsModalOpen(true);
};
const openCreateModal = () => {
setEditingExpert(null);
setFormState({
name: '',
role: '',
email: '',
phone: '',
linkedin: '',
status: 'Verfügbar',
avatar: '',
bio: '',
tags: [],
hourlyRateOnsite: '',
hourlyRateRemote: '',
cv_url: ''
});
setTagInput('');
setIsModalOpen(true);
};
const handleAddTag = (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!tagInput.trim()) return;
const newTags = tagInput.split(',')
.map(t => t.trim())
.filter(t => t && !formState.tags.includes(t));
if (newTags.length > 0) {
setFormState({ ...formState, tags: [...formState.tags, ...newTags] });
}
setTagInput('');
};
const removeTag = (tagToRemove: string) => {
setFormState({ ...formState, tags: formState.tags.filter(t => t !== tagToRemove) });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (formState.linkedin && !formState.linkedin.includes('linkedin.com/')) {
addToast('Bitte geben Sie eine gültige LinkedIn-URL ein.', 'error');
return;
}
try {
const payload = {
...formState,
hourlyRateOnsite: formState.hourlyRateOnsite === '' ? null : formState.hourlyRateOnsite,
hourlyRateRemote: formState.hourlyRateRemote === '' ? null : formState.hourlyRateRemote
};
if (editingExpert) {
const { error } = await supabase
.from(TABLES.EXPERTS)
.update(payload)
.eq('id', editingExpert.id);
if (error) throw error;
addToast('Experte erfolgreich aktualisiert', 'success');
} else {
const { error } = await supabase.from(TABLES.EXPERTS).insert([payload]);
if (error) throw error;
addToast('Neuer Experte erfolgreich angelegt', 'success');
}
setIsModalOpen(false);
refreshData();
} catch (error: any) {
addToast('Fehler beim Speichern: ' + (error.message || 'Unbekannter Fehler'), 'error');
}
};
const confirmDeleteExpert = async () => {
if (!expertToDeleteId) return;
try {
await supabase.from(TABLES.EXPERTS).delete().eq('id', expertToDeleteId);
addToast('Experte wurde gelöscht', 'success');
await refreshData();
} catch (error: any) {
addToast('Fehler beim Löschen: ' + (error.message || 'Unbekannter Fehler'), '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-4">
<div className="flex flex-col md:flex-row gap-3 justify-between items-start md:items-center mb-4">
<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-10 pr-4 py-2.5 bg-white dark:bg-slate-900 border-none rounded-xl shadow-sm focus:ring-2 focus:ring-brand-500 transition-all text-sm dark:text-white"
/>
<svg className="w-4 h-4 absolute left-3.5 top-3 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>
<div className="flex items-center gap-3 w-full md:w-auto">
{/* View Switcher */}
<div className="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-xl border border-slate-200 dark:border-slate-700">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-lg transition-all ${viewMode === 'grid' ? 'bg-white dark:bg-slate-900 text-brand-600 shadow-sm' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'}`}
title="Kachelansicht"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-lg transition-all ${viewMode === 'list' ? 'bg-white dark:bg-slate-900 text-brand-600 shadow-sm' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'}`}
title="Listenansicht"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button>
</div>
<button
onClick={openCreateModal}
className="bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2 whitespace-nowrap flex-1 md:flex-none justify-center text-sm"
>
<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>
{filteredExperts.length === 0 ? (
<div className="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>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{filteredExperts.map((expert) => (
<div key={expert.id} className="bg-white dark:bg-slate-900 rounded-2xl p-4 border border-slate-100 dark:border-slate-800 hover:shadow-2xl hover:-translate-y-1 transition-all duration-300 group flex flex-col relative overflow-hidden h-full cursor-pointer" onClick={() => openEditModal(expert)}>
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
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-4">
<div className="relative mb-3">
{expert.avatar ? (
<img src={expert.avatar} alt={expert.name} className="w-16 h-16 rounded-xl object-cover shadow-lg ring-2 ring-slate-50 dark:ring-slate-800" />
) : (
<div className="w-16 h-16 rounded-xl bg-brand-500/10 text-brand-600 flex items-center justify-center font-bold text-2xl uppercase ring-2 ring-slate-50 dark:ring-slate-800">
{expert.name.charAt(0)}
</div>
)}
<div className={`absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full border-[3px] border-white dark:border-slate-900 ${
expert.status === 'Verfügbar' ? 'bg-green-500' :
expert.status === 'Gebucht' ? 'bg-orange-500' :
expert.status === 'Interview' ? 'bg-blue-500' :
'bg-brand-500'
}`}></div>
</div>
<h4 className="font-bold text-base 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 mb-2">{expert.role}</p>
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-extrabold uppercase tracking-widest border shadow-sm ${expert.status === 'Verfügbar' ? 'bg-green-50 text-green-600 border-green-200 dark:bg-green-500/10 dark:text-green-400 dark:border-green-500/20' :
expert.status === 'Gebucht' ? 'bg-orange-50 text-orange-600 border-orange-200 dark:bg-orange-500/10 dark:text-orange-400 dark:border-orange-500/20' :
expert.status === 'Interview' ? 'bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20' :
'bg-brand-50 text-brand-600 border-brand-200 dark:bg-brand-500/10 dark:text-brand-400 dark:border-brand-500/20'
}`}>{expert.status}</span>
{/* Display Hourly Rates if available */}
{(expert.hourlyRateOnsite || expert.hourlyRateRemote) && (
<div className="flex gap-2 mt-3 text-xs font-medium text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-800/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-slate-800">
{expert.hourlyRateOnsite && <span>Onsite: {expert.hourlyRateOnsite}€/h</span>}
{expert.hourlyRateOnsite && expert.hourlyRateRemote && <span className="text-slate-300 dark:text-slate-600">|</span>}
{expert.hourlyRateRemote && <span>Remote: {expert.hourlyRateRemote}€/h</span>}
</div>
)}
</div>
<div className="flex-1 mb-3 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-1.5 mb-4 text-[12px]">
{expert.email ? (
<a
href={`mailto:${expert.email}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-3 text-slate-500 hover:text-brand-500 transition-colors group/link"
>
<svg className="w-4 h-4 opacity-40 group-hover/link:opacity-100 shrink-0 transition-opacity" 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}</span>
</a>
) : (
<div className="flex items-center gap-3 text-slate-500/50">
<svg className="w-4 h-4 opacity-20 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 text-[10px] uppercase font-bold tracking-widest">Keine E-Mail</span>
</div>
)}
{expert.phone ? (
<a
href={`tel:${expert.phone}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-3 text-slate-500 hover:text-brand-500 transition-colors group/link"
>
<svg className="w-4 h-4 opacity-40 group-hover/link:opacity-100 shrink-0 transition-opacity" 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}</span>
</a>
) : (
<div className="flex items-center gap-3 text-slate-500/50">
<svg className="w-4 h-4 opacity-20 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 text-[10px] uppercase font-bold tracking-widest">Keine Telefonnummer</span>
</div>
)}
{expert.linkedin && (
<a
href={expert.linkedin}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-3 text-blue-500 hover:text-blue-600 transition-colors"
>
<svg className="w-4 h-4 opacity-60 shrink-0" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" /></svg>
<span className="truncate text-xs font-medium">LinkedIn</span>
</a>
)}
{expert.cv_url && (
<a
href={expert.cv_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-3 text-emerald-500 hover:text-emerald-600 transition-colors"
>
<svg className="w-4 h-4 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<span className="truncate text-xs font-medium">CV / Lebenslauf</span>
</a>
)}
</div>
</div>
))}
</div>
) : (
<div className="bg-white dark:bg-slate-900 rounded-[2rem] border border-slate-100 dark:border-slate-800 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-100 dark:border-slate-800">
<th
onClick={() => handleSort('name')}
className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-slate-400 cursor-pointer hover:text-brand-500 transition-colors select-none"
>
<div className="flex items-center gap-2">
Experte
{sortConfig?.key === 'name' && (
<svg className={`w-3 h-3 transition-transform ${sortConfig.direction === 'desc' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" /></svg>
)}
</div>
</th>
<th
onClick={() => handleSort('status')}
className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-slate-400 cursor-pointer hover:text-brand-500 transition-colors select-none"
>
<div className="flex items-center gap-2">
Status
{sortConfig?.key === 'status' && (
<svg className={`w-3 h-3 transition-transform ${sortConfig.direction === 'desc' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" /></svg>
)}
</div>
</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-slate-400 select-none">Kontakt</th>
<th
onClick={() => handleSort('hourlyRate')}
className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-slate-400 cursor-pointer hover:text-brand-500 transition-colors select-none"
>
<div className="flex items-center gap-2">
Sätze
{sortConfig?.key === 'hourlyRate' && (
<svg className={`w-3 h-3 transition-transform ${sortConfig.direction === 'desc' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" /></svg>
)}
</div>
</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-slate-400 select-none">Tags</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-slate-400 text-right select-none">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{filteredExperts.map((expert) => (
<tr
key={expert.id}
onClick={() => openEditModal(expert)}
className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors cursor-pointer group"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{expert.avatar ? (
<img src={expert.avatar} alt={expert.name} className="w-10 h-10 rounded-lg object-cover shadow-sm" />
) : (
<div className="w-10 h-10 rounded-lg bg-brand-500/10 text-brand-600 flex items-center justify-center font-bold uppercase">
{expert.name.charAt(0)}
</div>
)}
<div>
<div className="font-bold text-slate-900 dark:text-white group-hover:text-brand-500 transition-colors">{expert.name}</div>
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{expert.role}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-extrabold uppercase tracking-widest border shadow-sm ${expert.status === 'Verfügbar' ? 'bg-green-50 text-green-600 border-green-200 dark:bg-green-500/10 dark:text-green-400 dark:border-green-500/20' :
expert.status === 'Gebucht' ? 'bg-orange-50 text-orange-600 border-orange-200 dark:bg-orange-500/10 dark:text-orange-400 dark:border-orange-500/20' :
expert.status === 'Interview' ? 'bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20' :
'bg-brand-50 text-brand-600 border-brand-200 dark:bg-brand-500/10 dark:text-brand-400 dark:border-brand-500/20'
}`}>{expert.status}</span>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
{expert.email && (
<a href={`mailto:${expert.email}`} onClick={e => e.stopPropagation()} className="p-1.5 text-slate-400 hover:text-brand-500 transition-colors" title={expert.email}>
<svg className="w-4 h-4" 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>
</a>
)}
{expert.linkedin && (
<a href={expert.linkedin} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} className="p-1.5 text-slate-400 hover:text-blue-500 transition-colors">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" /></svg>
</a>
)}
{expert.phone && (
<a href={`tel:${expert.phone}`} onClick={e => e.stopPropagation()} className="p-1.5 text-slate-400 hover:text-brand-500 transition-colors" title={expert.phone}>
<svg className="w-4 h-4" 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>
</a>
)}
{expert.cv_url && (
<a href={expert.cv_url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} className="p-1.5 text-slate-400 hover:text-emerald-500 transition-colors" title="CV / Lebenslauf">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</a>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{expert.hourlyRateOnsite && <div>O: {expert.hourlyRateOnsite}€</div>}
{expert.hourlyRateRemote && <div>R: {expert.hourlyRateRemote}€</div>}
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{expert.tags?.slice(0, 2).map(tag => (
<span key={tag} className="px-2 py-0.5 bg-slate-100 dark:bg-slate-800 text-[9px] font-bold text-slate-500 rounded uppercase tracking-wider">
{tag}
</span>
))}
{expert.tags && expert.tags.length > 2 && <span className="text-[9px] text-slate-400 font-bold">+{expert.tags.length - 2}</span>}
</div>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
setExpertToDeleteId(expert.id);
}}
className="p-2 text-slate-300 hover:text-red-500 transition-colors"
>
<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>
</td>
</tr>
))}
</tbody>
</table>
</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 scrollbar-hide 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>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">LinkedIn Profil (URL)</label>
<input type="url" value={formState.linkedin} onChange={e => setFormState({ ...formState, linkedin: e.target.value })} placeholder="https://linkedin.com/in/..." 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">Verfügbarkeit (Status)</label>
<select value={formState.status} onChange={e => setFormState({ ...formState, status: e.target.value as any })} 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 cursor-pointer">
<option value="Interessiert">Interessiert</option>
<option value="Interview">Interview</option>
<option value="Verfügbar">Verfügbar</option>
<option value="Gebucht">Gebucht</option>
</select>
</div>
{/* Hourly Rates Section */}
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Stundensatz Onsite (€)</label>
<input type="number" step="0.01" min="0" value={formState.hourlyRateOnsite} onChange={e => setFormState({ ...formState, hourlyRateOnsite: e.target.value ? Number(e.target.value) : '' })} placeholder="z.B. 120" 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">Stundensatz Remote (€)</label>
<input type="number" step="0.01" min="0" value={formState.hourlyRateRemote} onChange={e => setFormState({ ...formState, hourlyRateRemote: e.target.value ? Number(e.target.value) : '' })} placeholder="z.B. 100" 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="Tags mit Komma trennen (z.B. PMO, Jira, Excel)..."
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>
{/* CV Link Section */}
<div className="md:col-span-2">
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">CV / Lebenslauf (URL)</label>
<input
type="url"
value={formState.cv_url}
onChange={e => setFormState({ ...formState, cv_url: e.target.value })}
placeholder="https://..."
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 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">Dieser Experte wird unwiderruflich aus der Datenbank entfernt.</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>
);
};
+151
View File
@@ -0,0 +1,151 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Tender, Expert, Customer, Project, ViewState } from '../types';
interface HeaderProps {
title: string;
isDarkMode: boolean;
onToggleDarkMode: () => void;
onToggleSidebar: () => void;
tenders?: Tender[];
experts?: Expert[];
customers?: Customer[];
projects?: Project[];
onNavigate?: (view: ViewState) => void;
}
interface SearchResult {
type: 'Experte' | 'Kunde' | 'Ausschreibung' | 'Projekt';
title: string;
subtitle: string;
view: ViewState;
}
export const Header: React.FC<HeaderProps> = ({ title, isDarkMode, onToggleDarkMode, onToggleSidebar, tenders = [], experts = [], customers = [], projects = [], onNavigate }) => {
const [searchQuery, setSearchQuery] = useState('');
const [isSearchFocused, setIsSearchFocused] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setIsSearchFocused(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const searchResults = useMemo<SearchResult[]>(() => {
const q = searchQuery.toLowerCase().trim();
if (q.length < 2) return [];
const results: SearchResult[] = [];
experts.forEach(e => {
if (e.name.toLowerCase().includes(q) || e.role.toLowerCase().includes(q) || e.tags?.some(t => t.toLowerCase().includes(q))) {
results.push({ type: 'Experte', title: e.name, subtitle: e.role, view: 'Experten' });
}
});
customers.forEach(c => {
if (c.company.toLowerCase().includes(q) || c.name?.toLowerCase().includes(q)) {
results.push({ type: 'Kunde', title: c.company, subtitle: c.name || '', view: 'Kunden' });
}
});
tenders.forEach(t => {
if (t.title.toLowerCase().includes(q) || t.source.toLowerCase().includes(q)) {
results.push({ type: 'Ausschreibung', title: t.title, subtitle: t.source, view: 'Ausschreibungen' });
}
});
projects.forEach(p => {
if (p.title.toLowerCase().includes(q)) {
results.push({ type: 'Projekt', title: p.title, subtitle: p.status, view: 'Laufende Projekte' });
}
});
return results.slice(0, 8);
}, [searchQuery, experts, customers, tenders, projects]);
return (
<header className="h-14 border-b border-slate-200 dark:border-[#2a2a2a] bg-white/95 dark:bg-[#0A0A0A]/90 backdrop-blur-md flex items-center justify-between px-5 sticky top-0 z-10 shadow-soft">
<div className="flex items-center gap-4">
<button
onClick={onToggleSidebar}
className="p-2 hover:bg-slate-100 dark:hover:bg-[#1a1a1a] rounded-lg transition-colors text-slate-500 dark:text-slate-400 border border-transparent hover:border-slate-200 dark:hover:border-[#333]"
title="Menü umschalten"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16m-7 6h7" />
</svg>
</button>
<h1 className="text-sm font-semibold uppercase tracking-widest text-slate-700 dark:text-slate-300">{title}</h1>
</div>
<div className="flex items-center gap-3">
{/* Global Search */}
<div className="relative hidden md:block" ref={searchRef}>
<input
type="text"
placeholder="Suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setIsSearchFocused(true)}
className="pl-10 pr-4 py-2 bg-slate-100 dark:bg-[#1a1a1a] border border-slate-200 dark:border-[#2a2a2a] rounded w-72 text-sm font-medium transition-all outline-none focus:border-[#C1862F] focus:ring-2 focus:ring-[#C1862F]/10"
/>
<svg className="w-4 h-4 absolute left-3 top-2.5 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>
{isSearchFocused && searchQuery.length >= 2 && (
<div className="absolute top-full mt-2 w-[400px] right-0 bg-white dark:bg-[#141414] rounded-lg shadow-2xl border border-slate-200 dark:border-[#2a2a2a] overflow-hidden z-50">
{searchResults.length > 0 ? (
<div className="max-h-[380px] overflow-y-auto divide-y divide-slate-100 dark:divide-[#2a2a2a]">
{searchResults.map((result, i) => (
<button
key={i}
onClick={() => {
onNavigate?.(result.view);
setSearchQuery('');
setIsSearchFocused(false);
}}
className="w-full text-left px-4 py-3 hover:bg-slate-50 dark:hover:bg-[#1a1a1a] transition-colors flex items-center gap-3"
>
<span
className="px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded"
style={{ backgroundColor: '#C1862F18', color: '#C1862F', border: '1px solid #C1862F30' }}
>
{result.type}
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm text-slate-900 dark:text-white truncate">{result.title}</p>
{result.subtitle && <p className="text-xs text-slate-400 truncate">{result.subtitle}</p>}
</div>
<svg className="w-4 h-4 text-slate-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</button>
))}
</div>
) : (
<div className="px-5 py-8 text-center text-slate-400 text-sm">
Keine Ergebnisse für „{searchQuery}"
</div>
)}
</div>
)}
</div>
<button
onClick={onToggleDarkMode}
className="p-2 rounded hover:bg-slate-100 dark:hover:bg-[#1a1a1a] transition-colors border border-transparent hover:border-slate-200 dark:hover:border-[#333]"
title={isDarkMode ? 'Light Mode' : 'Dark Mode'}
>
{isDarkMode ? (
<svg className="w-4 h-4" style={{ color: '#C1862F' }} 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-4 h-4 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>
</header>
);
};
+211
View File
@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { KanbanColumn, Tender } from '../types';
import { supabase, TABLES } from '../lib/supabase';
import { useToast } from './Toast';
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 { addToast } = useToast();
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);
addToast('Fehler: ' + error.message, 'error');
} 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;
addToast('Ausschreibung als Projekt abgeschlossen!', 'success');
await refreshData();
} catch (error: any) {
console.error('Full Process Error:', error);
addToast('Fehler beim Projektabschluss: ' + error.message, 'error');
} 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-4">
<h2 className="text-xl 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-3 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-64 flex flex-col rounded-2xl p-3 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-3 px-1">
<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-3 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-3.5 rounded-xl 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-2">
<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-[10px] text-slate-500 font-bold mb-3 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>
);
};
+166
View File
@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { login, ensureAdminExists, AppUser } from '../lib/auth';
interface LoginScreenProps {
onLogin: (user: AppUser) => void;
}
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin }) => {
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!identifier || !password) {
setError('Bitte alle Felder ausfüllen.');
return;
}
setIsLoading(true);
setError(null);
try {
await ensureAdminExists();
const user = await login(identifier, password);
onLogin(user);
} catch (err: any) {
setError(err.message || 'Anmeldung fehlgeschlagen.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen w-screen flex items-center justify-center bg-slate-950 relative overflow-hidden">
{/* Background glow effects */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -left-40 w-96 h-96 bg-brand-500/20 rounded-full blur-3xl" />
<div className="absolute -bottom-40 -right-40 w-96 h-96 bg-brand-600/15 rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-brand-500/5 rounded-full blur-3xl" />
</div>
{/* Grid pattern overlay */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage:
'linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
<div className="relative z-10 w-full max-w-md px-6">
{/* Logo */}
<div className="flex flex-col items-center mb-10">
<div className="w-16 h-16 bg-brand-500 rounded-2xl flex items-center justify-center shadow-2xl shadow-brand-500/40 mb-4">
<span className="font-black text-white text-3xl">I</span>
</div>
<h1 className="text-3xl font-black text-white tracking-wider">ICON</h1>
<p className="text-slate-500 text-sm mt-1 tracking-wide">Expert CRM System</p>
</div>
{/* Card */}
<div className="bg-slate-900/80 backdrop-blur-xl border border-slate-800 rounded-3xl p-8 shadow-2xl">
<h2 className="text-xl font-bold text-white mb-1">Willkommen zurück</h2>
<p className="text-slate-500 text-sm mb-8">Bitte melden Sie sich an, um fortzufahren.</p>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email / Username */}
<div>
<label htmlFor="login-username" className="block text-sm font-semibold text-slate-300 mb-2">
Benutzername oder E-Mail
</label>
<div className="relative">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500">
<svg className="w-4 h-4" 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>
<input
id="login-username"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 text-white rounded-xl pl-11 pr-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent placeholder-slate-600 transition-all"
placeholder="z. B. Admin oder admin@icon-experts.de"
autoComplete="username"
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="login-password" className="block text-sm font-semibold text-slate-300 mb-2">
Passwort
</label>
<div className="relative">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
id="login-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 text-white rounded-xl pl-11 pr-12 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent placeholder-slate-600 transition-all"
placeholder="••••••••"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
{showPassword ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-3 p-3.5 bg-red-500/10 border border-red-500/30 rounded-xl">
<svg className="w-4 h-4 text-red-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className="w-full py-3.5 bg-brand-500 hover:bg-brand-600 disabled:opacity-60 disabled:cursor-not-allowed text-white font-bold rounded-xl transition-all shadow-lg shadow-brand-500/30 hover:shadow-brand-500/50 flex items-center justify-center gap-2 mt-2"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
<span>Anmelden...</span>
</>
) : (
'Anmelden'
)}
</button>
</form>
</div>
<p className="text-center text-slate-700 text-xs mt-6">
ICON Expert CRM · Alle Rechte vorbehalten
</p>
</div>
</div>
);
};
+359
View File
@@ -0,0 +1,359 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Customer, Project, Expert } from '../types';
interface OrgChartProps {
customers: Customer[];
projects: Project[];
experts: Expert[];
}
type Mode = 'Customer' | 'Project';
export const OrgChart: React.FC<OrgChartProps> = ({ customers, projects, experts }) => {
const [mode, setMode] = useState<Mode>('Customer');
const [selectedId, setSelectedId] = useState<string | null>(null);
// Navigation State
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const canvasRef = useRef<HTMLDivElement>(null);
const getRelatedData = useMemo(() => {
if (!selectedId) return null;
if (mode === 'Customer') {
const customer = customers.find(c => c.id === selectedId);
if (!customer) return null;
const companyProjects = projects.filter(p =>
p.customer === customer.company ||
(p.notes && p.notes.includes(`KUNDE: ${customer.company}`))
);
const relatedExperts = experts.filter(e =>
companyProjects.some(p => p.expert && p.expert.includes(e.name) || (p.notes && p.notes.includes(`EXPERTE: ${e.name}`)))
);
return { root: customer, contacts: customer.contacts || [], projects: companyProjects, experts: relatedExperts };
} else {
const project = projects.find(p => p.id === selectedId);
if (!project) return null;
const customerMatch = customers.find(c =>
c.company === project.customer ||
(project.notes && project.notes.includes(`KUNDE: ${c.company}`))
);
// Multi-Expert Support for Projects
const expertNames = (project.expert || '').split(', ').map(s => s.trim()).filter(Boolean);
const relatedExperts = experts.filter(e =>
expertNames.includes(e.name) ||
(project.notes && project.notes.includes(`EXPERTE: ${e.name}`))
);
return { root: project, customer: customerMatch, experts: relatedExperts, contacts: customerMatch?.contacts || [] };
}
}, [mode, selectedId, customers, projects, experts]);
// Native Wheel Listener for preventDefault support (necessary for Zoom override)
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
// Prevent page scrolling/zooming while interacting with the canvas
e.preventDefault();
if (e.ctrlKey) {
// ZOOM MODE
const zoomSpeed = 0.001;
setZoom(prevZoom => {
const newZoom = prevZoom - e.deltaY * zoomSpeed;
return Math.min(Math.max(newZoom, 0.2), 2.5);
});
} else if (e.shiftKey) {
// HORIZONTAL SCROLL
setOffset(prev => ({ ...prev, x: prev.x - e.deltaY }));
} else {
// VERTICAL SCROLL (Default)
setOffset(prev => ({ ...prev, y: prev.y - e.deltaY }));
}
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return () => canvas.removeEventListener('wheel', onWheel);
}, []);
// Mouse Handlers (Panning via Click & Drag)
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return; // Only left click
setIsDragging(true);
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return;
setOffset({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
});
};
const handleMouseUp = () => setIsDragging(false);
const resetView = () => {
setOffset({ x: 0, y: 0 });
setZoom(1);
};
const handleSelect = (id: string) => {
setSelectedId(id);
resetView();
};
const ConnectionLines = () => {
const color = mode === 'Customer' ? '#22c55e' : '#3b82f6';
return (
<div className="absolute inset-0 w-full h-full pointer-events-none z-0">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="w-full h-full overflow-visible"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter id="glowLine" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<linearGradient id="lineGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="60%" stopColor={color} stopOpacity="0.8" />
<stop offset="100%" stopColor={color} stopOpacity="0.1" />
</linearGradient>
</defs>
<g filter="url(#glowLine)">
<path
d="M 50 15 C 50 25, 15 25, 15 45"
stroke="url(#lineGrad)"
strokeWidth="1.5"
fill="none"
strokeDasharray="4 2"
className="animate-[flow_4s_linear_infinite]"
/>
<path
d="M 50 15 C 50 25, 85 25, 85 45"
stroke="url(#lineGrad)"
strokeWidth="1.5"
fill="none"
strokeDasharray="4 2"
className="animate-[flow_4s_linear_infinite]"
/>
<circle cx="50" cy="15" r="2" fill={color} className="animate-pulse shadow-lg" />
</g>
</svg>
<style>{`
@keyframes flow {
from { stroke-dashoffset: 40; }
to { stroke-dashoffset: 0; }
}
`}</style>
</div>
);
};
return (
<div className="h-full flex flex-col gap-4 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-xl 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 rounded-xl border-2 border-slate-300 dark:border-slate-800 shadow-inner">
<button
onClick={() => { setMode('Customer'); setSelectedId(null); resetView(); }}
className={`px-5 py-2 rounded-lg 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-5 py-2 rounded-lg 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-4 overflow-hidden min-h-0">
{/* Sidebar */}
<div className="w-full lg:w-64 bg-white dark:bg-slate-900 rounded-2xl border-2 border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col shadow-card shrink-0">
<div className="p-3 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-3 cursor-pointer transition-all hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-3 ${selectedId === item.id ? 'bg-brand-50/50 dark:bg-brand-500/10' : ''}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center font-bold text-sm 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-2xl border-4 border-dashed border-slate-200 dark:border-slate-800 relative overflow-hidden p-5 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-x-0 top-0 max-w-7xl mx-auto h-[800px] pointer-events-none">
<ConnectionLines />
</div>
<div className="w-full relative z-10 py-10">
<div className="flex justify-center mb-48">
<div className={`p-5 rounded-2xl border-4 shadow-heavy flex flex-col items-center text-center w-56 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-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mb-3 text-3xl font-black">
{(getRelatedData.root as any).company?.charAt(0) || (getRelatedData.root as any).title?.charAt(0)}
</div>
<h3 className="text-xl 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-8">
<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>
);
};
+617
View File
@@ -0,0 +1,617 @@
import React, { useState, useRef, useCallback } from 'react';
import { Project, Customer, Expert } from '../types';
import { supabase, TABLES } from '../lib/supabase';
import { useToast } from './Toast';
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 { addToast } = useToast();
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);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [createForm, setCreateForm] = useState({ title: '', status: 'Aktiv' as string });
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>({ key: 'title', direction: 'asc' });
// 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);
addToast('Projekt gespeichert', 'success');
await refreshData();
} catch (err: any) {
addToast('Fehler beim Speichern: ' + (err.message || 'Unbekannt'), 'error');
} finally {
setIsSaving(false);
}
};
// Debounced notes saving
const notesTimerRef = useRef<ReturnType<typeof setTimeout>>();
const handleUpdateNotesOnly = useCallback((id: string, newNotes: string) => {
setProjects(prev => prev.map(p => p.id === id ? { ...p, notes: newNotes } : p));
if (notesTimerRef.current) clearTimeout(notesTimerRef.current);
notesTimerRef.current = setTimeout(async () => {
await supabase.from(TABLES.PROJECTS).update({ notes: newNotes }).eq('id', id);
}, 800);
}, [setProjects]);
const confirmDelete = async () => {
if (projectToDelete) {
try {
await supabase.from(TABLES.PROJECTS).delete().eq('id', projectToDelete);
if (expandedId === projectToDelete) setExpandedId(null);
addToast('Projekt gelöscht', 'success');
refreshData();
} catch (error: any) {
addToast('Fehler beim Löschen: ' + (error.message || 'Unbekannt'), 'error');
} finally {
setProjectToDelete(null);
}
}
};
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault();
try {
const { error } = await supabase.from(TABLES.PROJECTS).insert([{
title: createForm.title,
status: createForm.status,
notes: ''
}]);
if (error) throw error;
addToast('Neues Projekt angelegt', 'success');
setIsCreateModalOpen(false);
setCreateForm({ title: '', status: 'Aktiv' });
refreshData();
} catch (error: any) {
addToast('Fehler: ' + (error.message || 'Unbekannt'), 'error');
}
};
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';
};
const sortedProjects = [...projects].sort((a, b) => {
if (!sortConfig) return 0;
const { key, direction } = sortConfig;
let valA: any;
let valB: any;
if (key === 'title' || key === 'status') {
valA = a[key as keyof Project];
valB = b[key as keyof Project];
} else {
// For derived info like Kunde, Expert, Laufzeit
valA = getDisplayInfo(a, key.toUpperCase() as any);
valB = getDisplayInfo(b, key.toUpperCase() as any);
}
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
const requestSort = (key: string) => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
const getSortIndicator = (key: string) => {
if (!sortConfig || sortConfig.key !== key) return (
<svg className="w-3 h-3 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" /></svg>
);
return sortConfig.direction === 'asc' ? (
<svg className="w-3 h-3 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 15l7-7 7 7" /></svg>
) : (
<svg className="w-3 h-3 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" /></svg>
);
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Laufende Projekte</h2>
<p className="text-slate-500">Übersicht aller aktiven Beauftragungen</p>
</div>
<button onClick={() => setIsCreateModalOpen(true)} className="bg-brand-600 hover:bg-brand-700 text-white px-4 py-2 rounded-xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2 text-sm">
<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-2xl 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
onClick={() => requestSort('title')}
className="p-3 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2"> Projekt {getSortIndicator('title')} </div>
</th>
<th
onClick={() => requestSort('kunde')}
className="p-3 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2"> Kunde {getSortIndicator('kunde')} </div>
</th>
<th
onClick={() => requestSort('experte')}
className="p-3 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2"> Experten {getSortIndicator('experte')} </div>
</th>
<th
onClick={() => requestSort('start')}
className="p-3 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2"> Laufzeit {getSortIndicator('start')} </div>
</th>
<th
onClick={() => requestSort('status')}
className="p-3 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2"> Status {getSortIndicator('status')} </div>
</th>
<th className="p-3 w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{sortedProjects.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-5 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-5">
<div className="lg:col-span-1 space-y-4">
<div className="bg-white dark:bg-slate-800 p-4 rounded-2xl border border-slate-100 dark:border-slate-700 shadow-sm relative overflow-hidden">
<div className="flex justify-between items-center mb-4">
<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-4 rounded-2xl 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>
)}
{/* Create Project Modal */}
{isCreateModalOpen && (
<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 border border-slate-100 dark:border-slate-800">
<h3 className="text-xl font-bold mb-4 dark:text-white">Neues Projekt</h3>
<form onSubmit={handleCreateProject} className="space-y-4">
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Titel</label>
<input required type="text" value={createForm.title} onChange={e => setCreateForm({ ...createForm, title: e.target.value })} placeholder="Projekttitel" className="w-full px-4 py-3 bg-slate-50 dark:bg-slate-800 rounded-xl outline-none focus:ring-2 focus:ring-brand-500 dark:text-white" />
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Status</label>
<select value={createForm.status} onChange={e => setCreateForm({ ...createForm, status: e.target.value })} className="w-full px-4 py-3 bg-slate-50 dark:bg-slate-800 rounded-xl outline-none dark:text-white">
<option value="Aktiv">Aktiv</option>
<option value="Nicht aktiv">Nicht aktiv</option>
<option value="Abgeschlossen">Abgeschlossen</option>
</select>
</div>
<button type="submit" className="w-full py-3 bg-brand-600 text-white font-bold rounded-xl shadow-lg shadow-brand-500/20 hover:bg-brand-700 transition-all">Erstellen</button>
<button type="button" onClick={() => setIsCreateModalOpen(false)} className="w-full py-2 text-slate-500 font-medium">Abbrechen</button>
</form>
</div>
</div>
)}
</div>
);
};
+205
View File
@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { useToast } from './Toast';
import { AppUser } from '../lib/auth';
import { UserManagement } from './UserManagement';
interface SettingsProps {
isDarkMode: boolean;
onToggleDarkMode: () => void;
currentUser: AppUser | null;
onUserUpdated: (user: AppUser) => void;
}
export const Settings: React.FC<SettingsProps> = ({
isDarkMode,
onToggleDarkMode,
currentUser,
onUserUpdated
}) => {
const { addToast } = useToast();
const [supabaseUrl, setSupabaseUrl] = useState(localStorage.getItem('SUPABASE_URL') || import.meta.env.VITE_SUPABASE_URL || '');
const [supabaseKey, setSupabaseKey] = useState(localStorage.getItem('SUPABASE_ANON_KEY') || '');
const [isSaved, setIsSaved] = useState(false);
// Persisted feature toggles
const [aiMatching, setAiMatching] = useState(() => {
const saved = localStorage.getItem('setting_aiMatching');
return saved !== null ? JSON.parse(saved) : true;
});
const [linkedinSync, setLinkedinSync] = useState(() => {
const saved = localStorage.getItem('setting_linkedinSync');
return saved !== null ? JSON.parse(saved) : true;
});
const handleToggleAiMatching = () => {
const newVal = !aiMatching;
setAiMatching(newVal);
localStorage.setItem('setting_aiMatching', JSON.stringify(newVal));
addToast(newVal ? 'AI Experten-Matching aktiviert' : 'AI Experten-Matching deaktiviert', 'info');
};
const handleToggleLinkedinSync = () => {
const newVal = !linkedinSync;
setLinkedinSync(newVal);
localStorage.setItem('setting_linkedinSync', JSON.stringify(newVal));
addToast(newVal ? 'LinkedIn-Synchronisierung aktiviert' : 'LinkedIn-Synchronisierung deaktiviert', 'info');
};
const handleSaveSupabase = () => {
localStorage.setItem('SUPABASE_URL', supabaseUrl);
localStorage.setItem('SUPABASE_ANON_KEY', supabaseKey);
setIsSaved(true);
addToast('Datenbankeinstellungen gespeichert. Seite wird neu geladen...', 'success');
setTimeout(() => {
setIsSaved(false);
window.location.reload();
}, 1500);
};
const handleResetSupabase = () => {
localStorage.removeItem('SUPABASE_URL');
localStorage.removeItem('SUPABASE_ANON_KEY');
addToast('Datenbankeinstellungen zurückgesetzt', 'info');
setTimeout(() => window.location.reload(), 1000);
};
const [showUserMgmt, setShowUserMgmt] = useState(false);
return (
<div className="max-w-3xl mx-auto space-y-5">
<section>
<h3 className="text-base font-bold mb-4">Erscheinungsbild</h3>
<div className="bg-white dark:bg-slate-900 rounded-2xl p-5 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>
<h2 className="text-base font-bold text-slate-900 dark:text-white mb-4">CRM-Konfiguration</h2>
<div className="bg-white dark:bg-slate-900 rounded-2xl p-5 border border-slate-100 dark:border-slate-800 space-y-4">
<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>
<button
onClick={handleToggleAiMatching}
className={`w-14 h-8 rounded-full transition-colors relative ${aiMatching ? '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 ${aiMatching ? 'left-7' : 'left-1'}`}></div>
</button>
</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>
<button
onClick={handleToggleLinkedinSync}
className={`w-14 h-8 rounded-full transition-colors relative ${linkedinSync ? '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 ${linkedinSync ? 'left-7' : 'left-1'}`}></div>
</button>
</div>
</div>
</section>
<section>
<h3 className="text-base font-bold mb-4">Datenbank-Konfiguration (Supabase)</h3>
<div className="bg-white dark:bg-slate-900 rounded-2xl p-5 border border-slate-200 dark:border-slate-800 shadow-sm space-y-4">
<div>
<label className="block text-sm font-bold mb-2">Supabase URL</label>
<input
type="text"
value={supabaseUrl}
onChange={(e) => setSupabaseUrl(e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="https://your-project.supabase.co"
/>
</div>
<div>
<label className="block text-sm font-bold mb-2">Anon Key</label>
<input
type="password"
value={supabaseKey}
onChange={(e) => setSupabaseKey(e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="Wird aus .env geladen (Override hier möglich)"
/>
</div>
<div className="flex gap-4">
<button
onClick={handleSaveSupabase}
className={`flex-1 py-3 rounded-xl font-bold transition-all ${isSaved ? 'bg-emerald-500 text-white' : 'bg-brand-500 text-white hover:bg-brand-600'}`}
>
{isSaved ? '✓ Gespeichert!' : 'Einstellungen speichern'}
</button>
<button
onClick={handleResetSupabase}
className="px-6 py-3 rounded-xl font-bold bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all"
>
Reset
</button>
</div>
<p className="text-xs text-slate-500 italic">
* Supabase-Credentials werden primär aus der .env-Datei geladen. Hier kann ein Override gesetzt werden.
</p>
</div>
</section>
{currentUser && (
<section>
<h3 className="text-base font-bold mb-4">Benutzerverwaltung</h3>
<div className="bg-white dark:bg-slate-900 rounded-2xl p-5 border border-slate-100 dark:border-slate-800 flex items-center justify-between">
<div>
<p className="font-bold">Benutzer &amp; Passwörter verwalten</p>
<p className="text-sm text-slate-500">
{currentUser.role === 'admin'
? 'Verwalten Sie Benutzerkonten, Rollen und Passwörter für das ICON CRM.'
: 'Ändern Sie Ihr persönliches Passwort.'}
</p>
</div>
<button
onClick={() => setShowUserMgmt(true)}
className="px-5 py-2.5 rounded-xl font-bold bg-brand-500 hover:bg-brand-600 text-white transition-all text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
{currentUser.role === 'admin' ? 'Benutzer verwalten' : 'Passwort ändern'}
</button>
</div>
</section>
)}
{showUserMgmt && currentUser && (
<UserManagement
currentUser={currentUser}
onClose={() => setShowUserMgmt(false)}
onUserUpdated={onUserUpdated}
/>
)}
<section>
<h3 className="text-base font-bold mb-4">Über ICON</h3>
<div className="p-5 bg-brand-500 rounded-2xl text-white shadow-xl shadow-brand-500/20">
<p className="font-bold text-lg mb-2">Version 2.5.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>
);
};
+162
View File
@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { ViewState } from '../types';
import { AppUser } from '../lib/auth';
interface SidebarProps {
currentView: ViewState;
onViewChange: (view: ViewState) => void;
isOpen: boolean;
onToggle: () => void;
currentUser: AppUser | null;
onLogout: () => void;
}
const useIsMobile = () => {
const [isMobile, setIsMobile] = useState(typeof window !== 'undefined' ? window.innerWidth < 1024 : false);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 1024);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return isMobile;
};
export const Sidebar: React.FC<SidebarProps> = ({
currentView,
onViewChange,
isOpen,
onToggle,
currentUser,
onLogout
}) => {
const isMobile = useIsMobile();
const showLabels = isOpen || isMobile;
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-black/60 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-[72px] -translate-x-full lg:translate-x-0'}
bg-[#1A1A1A] text-white transition-all duration-300 flex flex-col h-full overflow-hidden
border-r border-[#2a2a2a]
`}>
{/* Logo area */}
<div className={`shrink-0 flex items-center border-b border-[#2a2a2a] ${showLabels ? 'px-5 py-4' : 'px-0 py-4 justify-center'}`}>
{showLabels ? (
<img src="/logo.png" alt="ICON Experts" className="h-8 brightness-0 invert" />
) : (
<div
className="w-9 h-9 rounded flex items-center justify-center shrink-0"
style={{ backgroundColor: '#C1862F' }}
>
<span className="font-bold text-white text-base tracking-wider">I</span>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 mt-3 px-2 space-y-0.5 overflow-y-auto overflow-x-hidden">
{menuItems.map((item) => {
const isActive = currentView === item.name;
return (
<button
key={item.name}
onClick={() => {
onViewChange(item.name);
if (isMobile) onToggle();
}}
title={!showLabels ? item.name : undefined}
className={`
w-full flex items-center rounded transition-all duration-200 group
${showLabels ? 'px-3 py-2.5 gap-3' : 'px-0 py-2.5 justify-center'}
${isActive
? 'text-white'
: 'text-[#999] hover:text-white hover:bg-[#2a2a2a]'}
`}
style={isActive ? { backgroundColor: '#C1862F' } : undefined}
>
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={isActive ? 2.5 : 1.8} d={item.icon} />
</svg>
{showLabels && (
<span className="font-medium text-sm tracking-wide truncate">{item.name}</span>
)}
{isActive && showLabels && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-white/70 shrink-0" />
)}
</button>
);
})}
</nav>
{/* User area with profile info & logout button */}
<div className={`shrink-0 border-t border-[#2a2a2a] ${showLabels ? 'p-4' : 'p-3 flex flex-col items-center gap-3'}`}>
<div className="w-full flex items-center justify-between gap-3 min-w-0">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center border shrink-0 font-bold text-xs"
style={{
borderColor: '#C1862F33',
backgroundColor: '#C1862F15',
color: '#C1862F'
}}
>
{currentUser?.name ? currentUser.name.charAt(0).toUpperCase() : '?'}
</div>
{showLabels && (
<div className="overflow-hidden min-w-0">
<p className="text-sm font-semibold truncate text-white">{currentUser?.name || 'Gast'}</p>
<p className="text-xs truncate font-mono" style={{ color: '#888' }}>@{currentUser?.username || 'gast'}</p>
</div>
)}
</div>
{showLabels && (
<button
onClick={onLogout}
className="p-1.5 text-[#666] hover:text-red-400 hover:bg-[#2a2a2a] rounded transition-all shrink-0"
title="Abmelden"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
)}
</div>
{!showLabels && (
<button
onClick={onLogout}
className="w-8 h-8 flex items-center justify-center text-[#666] hover:text-red-400 hover:bg-[#2a2a2a] rounded transition-all"
title="Abmelden"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
)}
</div>
</aside>
</>
);
};
+491
View File
@@ -0,0 +1,491 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { Tender, Expert, Customer, KanbanColumn } from '../types';
import { supabase, TABLES } from '../lib/supabase';
import { useToast } from './Toast';
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
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 { addToast } = useToast();
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();
try {
if (editingTender) {
const { error } = await supabase.from(TABLES.TENDERS).update({
title: formState.title,
source: formState.source,
status: formState.status,
link: formState.link
}).eq('id', editingTender.id);
if (error) throw error;
addToast('Ausschreibung aktualisiert', 'success');
} else {
const { error } = await supabase.from(TABLES.TENDERS).insert([{
title: formState.title,
source: formState.source,
status: formState.status,
kanban_status: 'Ausschreibung',
link: formState.link || null,
notes: ''
}]);
if (error) throw error;
addToast('Neue Ausschreibung erstellt', 'success');
}
setIsModalOpen(false);
refreshData();
} catch (error: any) {
addToast('Fehler: ' + (error.message || 'Unbekannt'), 'error');
}
};
const confirmDelete = async () => {
if (tenderToDelete) {
try {
await supabase.from(TABLES.TENDERS).delete().eq('id', tenderToDelete);
if (expandedId === tenderToDelete) setExpandedId(null);
addToast('Ausschreibung gelöscht', 'success');
refreshData();
} catch (error: any) {
addToast('Fehler beim Löschen: ' + (error.message || 'Unbekannt'), 'error');
} finally {
setTenderToDelete(null);
}
}
};
// Debounced notes saving
const notesTimerRef = useRef<ReturnType<typeof setTimeout>>();
const handleUpdateNotes = useCallback((id: string, newNotes: string) => {
setTenders(prev => prev.map(t => t.id === id ? { ...t, notes: newNotes } : t));
if (notesTimerRef.current) clearTimeout(notesTimerRef.current);
notesTimerRef.current = setTimeout(async () => {
await supabase.from(TABLES.TENDERS).update({ notes: newNotes }).eq('id', id);
}, 800);
}, [setTenders]);
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();
};
const toggleOnline = async (e: React.MouseEvent, tender: Tender) => {
e.stopPropagation();
const newValue = !tender.online;
try {
const { error } = await supabase.from(TABLES.TENDERS).update({ online: newValue }).eq('id', tender.id);
if (error) throw error;
setTenders(prev => prev.map(t => t.id === tender.id ? { ...t, online: newValue } : t));
addToast(newValue ? 'Ausschreibung ist jetzt online' : 'Ausschreibung offline genommen', newValue ? 'success' : 'info');
} catch (error: any) {
addToast('Fehler: ' + (error.message || 'Unbekannt'), 'error');
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl 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-4 py-2 rounded-xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2 text-sm">
<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-2xl 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-3 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-3 font-bold text-slate-500 uppercase text-xs tracking-wider">Kunde / Kontakt</th>
<th className="p-3 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-3 font-bold text-slate-500 uppercase text-xs tracking-wider text-center">Link</th>
<th className="p-3 font-bold text-slate-500 uppercase text-xs tracking-wider text-center">Experten</th>
<th className="p-3 font-bold text-slate-500 uppercase text-xs tracking-wider text-center">Online</th>
<th className="p-3 font-bold text-slate-500 uppercase text-xs tracking-wider">Status</th>
<th className="p-3 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">
{tender.link && (
<a href={tender.link} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="text-brand-500 hover:text-brand-600 transition-colors inline-block" title="Ausschreibung öffnen">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</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 text-center">
<button
type="button"
title={tender.online ? 'Online – klicken zum Offline nehmen' : 'Offline – klicken zum Online stellen'}
onClick={(e) => toggleOnline(e, tender)}
className={`relative inline-flex items-center h-6 w-11 rounded-full transition-colors duration-200 focus:outline-none ${
tender.online
? 'bg-emerald-500 shadow-lg shadow-emerald-500/30'
: 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block w-4 h-4 bg-white rounded-full shadow transform transition-transform duration-200 ${
tender.online ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</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={7} className="p-4 bg-slate-50/50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800">
<div className="flex flex-col lg:flex-row gap-6">
{/* Left Side: 1/3 Width, Stacked Column */}
<div className="w-full lg:w-1/3 flex flex-col gap-6">
{/* Experts */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-5 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col h-[350px]">
<h4 className="text-base font-bold flex items-center gap-2 mb-4 text-slate-900 dark:text-white">
<span className="w-1.5 h-6 bg-brand-500 rounded-full inline-block"></span>
Experten ({tender.applicantsCount})
</h4>
<div className="flex-1 overflow-y-auto space-y-3 mb-4 pr-1 scrollbar-hide">
{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-xl border border-slate-100 dark:border-slate-800 group/expert">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-brand-500/10 text-brand-600 flex items-center justify-center font-bold text-xs uppercase overflow-hidden">
{expert.avatar ? <img src={expert.avatar} alt="" className="w-full h-full object-cover" /> : expert.name.charAt(0)}
</div>
<div>
<p className="font-bold text-xs text-slate-900 dark:text-slate-100">{expert.name}</p>
<p className="text-[10px] 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 transition-opacity">
<svg className="w-4 h-4" 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-2xl border border-dashed border-slate-200 dark:border-slate-800">
<p className="text-xs text-slate-500 italic px-4">Keine Experten zugewiesen.</p>
</div>
)}
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); setActiveTenderIdForSelection(tender.id); setIsExpertModalOpen(true); }} className="w-full py-3 bg-white dark:bg-slate-800 text-brand-600 border-2 border-brand-500 hover:bg-brand-500 hover:text-white rounded-xl font-bold transition-all shadow-sm text-xs">Experten zuweisen</button>
</div>
{/* Customer */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-5 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col h-[280px]">
<h4 className="text-base font-bold flex items-center gap-2 mb-4 text-slate-900 dark:text-white">
<span className="w-1.5 h-6 bg-blue-500 rounded-full inline-block"></span>
Kundenkontakt
</h4>
<div className="flex-1 flex flex-col items-center justify-center text-center p-4 bg-slate-50 dark:bg-slate-900/50 rounded-2xl border border-slate-100 dark:border-slate-800 mb-4">
{tender.selectedCustomer ? (
<div className="flex flex-col items-center">
<div className="w-14 h-14 rounded-2xl bg-blue-500/10 text-blue-600 flex items-center justify-center mb-3">
{tender.selectedCustomer.logo ? <img src={tender.selectedCustomer.logo} alt="" className="w-full h-full object-cover rounded-2xl" /> : <svg className="w-7 h-7" 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-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Zugeordneter Kontakt</p>
<p className="font-bold text-slate-900 dark:text-white text-base leading-tight">{tender.selectedCustomer.name}</p>
<p className="text-[10px] text-slate-500 font-medium">{tender.selectedCustomer.company}</p>
</div>
) : <p className="text-slate-500 italic text-xs">Kein Kunde ausgewählt</p>}
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); setActiveTenderIdForSelection(tender.id); setIsCustomerModalOpen(true); }} className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-500/20 text-xs text-center">Kunden auswählen</button>
</div>
</div>
{/* Right Side: 2/3 Width - WYSIWYG Editor */}
<div className="w-full lg:w-2/3 bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col min-h-[630px] h-full">
<div className="flex justify-between items-center mb-6">
<h4 className="text-lg font-bold flex items-center gap-2 text-slate-900 dark:text-white">
<span className="w-1.5 h-7 bg-brand-500 rounded-full inline-block"></span>
Ausschreibungstext & Details
</h4>
{tender.link && (
<a href={tender.link} target="_blank" rel="noopener noreferrer" className="px-4 py-1.5 bg-slate-50 dark:bg-slate-900 text-brand-600 dark:text-brand-400 font-bold text-xs hover:bg-brand-50 dark:hover:bg-brand-500/10 rounded-lg flex items-center gap-2 transition-all border border-slate-200 dark:border-slate-700">
Link öffnen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
)}
</div>
<div className="flex-1 overflow-hidden flex flex-col custom-quill-container">
<style>{`
.custom-quill-container .ql-container {
border-bottom-left-radius: 1.5rem;
border-bottom-right-radius: 1.5rem;
font-family: inherit;
font-size: 0.95rem;
border-color: #f1f5f9 !important;
background: white !important;
}
.custom-quill-container .ql-toolbar {
border-top-left-radius: 1.5rem;
border-top-right-radius: 1.5rem;
background: #f8fafc !important;
border-color: #f1f5f9 !important;
}
.custom-quill-container .ql-editor {
min-height: 500px;
color: #1e293b !important;
background: white !important;
}
/* Fix icons and pickers to be dark even in dark mode parent */
.custom-quill-container .ql-stroke { stroke: #475569 !important; }
.custom-quill-container .ql-fill { fill: #475569 !important; }
.custom-quill-container .ql-picker { color: #475569 !important; }
.custom-quill-container .ql-picker-options {
background-color: white !important;
color: #475569 !important;
}
`}</style>
<ReactQuill
theme="snow"
value={tender.notes || ''}
onChange={(content) => handleUpdateNotes(tender.id, content)}
placeholder="Details zur Ausschreibung hier einfügen..."
className="flex-1 flex flex-col h-full bg-slate-50 dark:bg-slate-900/30 rounded-3xl overflow-hidden"
modules={{
toolbar: [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['link', 'clean']
],
}}
/>
</div>
</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" />
<input type="url" value={formState.link} onChange={e => setFormState({ ...formState, link: e.target.value })} placeholder="Link zur Ausschreibung" 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">Ausschreibung löschen?</h3>
<p className="text-slate-500 mb-8">Diese Ausschreibung wird unwiderruflich entfernt.</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>
);
};
+83
View File
@@ -0,0 +1,83 @@
import React, { useState, useCallback, createContext, useContext } from 'react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: number;
message: string;
type: ToastType;
}
interface ToastContextType {
addToast: (message: string, type?: ToastType) => void;
}
const ToastContext = createContext<ToastContextType>({ addToast: () => { } });
export const useToast = () => useContext(ToastContext);
let toastId = 0;
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, type: ToastType = 'info') => {
const id = ++toastId;
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 4000);
}, []);
const removeToast = (id: number) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
const icons: Record<ToastType, string> = {
success: 'M5 13l4 4L19 7',
error: 'M6 18L18 6M6 6l12 12',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
};
const colors: Record<ToastType, string> = {
success: 'bg-emerald-500',
error: 'bg-red-500',
info: 'bg-blue-500',
};
const bgColors: Record<ToastType, string> = {
success: 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800',
error: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
};
return (
<ToastContext.Provider value={{ addToast }}>
{children}
<div className="fixed bottom-6 right-6 z-[200] flex flex-col gap-3 pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
className={`pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-2xl shadow-xl border backdrop-blur-sm animate-in slide-in-from-right-5 fade-in duration-300 min-w-[300px] max-w-[420px] ${bgColors[toast.type]}`}
>
<div className={`w-7 h-7 rounded-lg ${colors[toast.type]} flex items-center justify-center shrink-0`}>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d={icons[toast.type]} />
</svg>
</div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100 flex-1">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-white shrink-0"
>
<svg className="w-4 h-4" 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>
</ToastContext.Provider>
);
};
+418
View File
@@ -0,0 +1,418 @@
import React, { useState, useEffect } from 'react';
import { AppUser, listUsers, createUser, updatePassword, deleteUser, hashPassword } from '../lib/auth';
import { supabase } from '../lib/supabase';
import { useToast } from './Toast';
interface UserManagementProps {
currentUser: AppUser;
onClose: () => void;
onUserUpdated: (user: AppUser) => void;
}
type Tab = 'users' | 'change-password';
export const UserManagement: React.FC<UserManagementProps> = ({
currentUser,
onClose,
onUserUpdated,
}) => {
const { addToast } = useToast();
const [tab, setTab] = useState<Tab>(currentUser.role === 'admin' ? 'users' : 'change-password');
const [users, setUsers] = useState<AppUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(true);
// New user form
const [newUsername, setNewUsername] = useState('');
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState<'user' | 'admin'>('user');
const [isCreating, setIsCreating] = useState(false);
// Change password form
const [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [isChangingPw, setIsChangingPw] = useState(false);
// Per-user password change
const [editingPwUserId, setEditingPwUserId] = useState<string | null>(null);
const [editPw, setEditPw] = useState('');
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
setIsLoadingUsers(true);
try {
const data = await listUsers();
setUsers(data);
} catch (err: any) {
addToast(err.message, 'error');
} finally {
setIsLoadingUsers(false);
}
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername || !newName || !newPassword) {
addToast('Bitte Benutzername, Name und Passwort ausfüllen.', 'error');
return;
}
if (newPassword.length < 6) {
addToast('Passwort muss mindestens 6 Zeichen haben.', 'error');
return;
}
setIsCreating(true);
try {
await createUser(newUsername, newEmail || null, newName, newPassword, newRole);
addToast(`Benutzer "${newName}" wurde erstellt.`, 'success');
setNewUsername('');
setNewName('');
setNewEmail('');
setNewPassword('');
setNewRole('user');
await loadUsers();
} catch (err: any) {
addToast(err.message, 'error');
} finally {
setIsCreating(false);
}
};
const handleDeleteUser = async (user: AppUser) => {
if (user.id === currentUser.id) {
addToast('Du kannst dein eigenes Konto nicht löschen.', 'error');
return;
}
if (!confirm(`Benutzer "${user.name}" wirklich löschen?`)) return;
try {
await deleteUser(user.id);
addToast(`Benutzer "${user.name}" wurde gelöscht.`, 'success');
await loadUsers();
} catch (err: any) {
addToast(err.message, 'error');
}
};
const handleSetUserPw = async (userId: string) => {
if (!editPw || editPw.length < 6) {
addToast('Passwort muss mindestens 6 Zeichen haben.', 'error');
return;
}
try {
await updatePassword(userId, editPw);
addToast('Passwort wurde geändert.', 'success');
setEditingPwUserId(null);
setEditPw('');
} catch (err: any) {
addToast(err.message, 'error');
}
};
const handleChangeOwnPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentPw || !newPw || !confirmPw) {
addToast('Bitte alle Felder ausfüllen.', 'error');
return;
}
if (newPw !== confirmPw) {
addToast('Neues Passwort stimmt nicht überein.', 'error');
return;
}
if (newPw.length < 6) {
addToast('Passwort muss mindestens 6 Zeichen haben.', 'error');
return;
}
setIsChangingPw(true);
try {
// Verify current password by re-hashing
const hash = await hashPassword(currentPw);
const { data } = await supabase
.from('icon_users')
.select('id')
.eq('id', currentUser.id)
.eq('password_hash', hash)
.maybeSingle();
if (!data) {
addToast('Aktuelles Passwort ist falsch.', 'error');
return;
}
await updatePassword(currentUser.id, newPw);
addToast('Passwort erfolgreich geändert!', 'success');
setCurrentPw('');
setNewPw('');
setConfirmPw('');
} catch (err: any) {
addToast(err.message, 'error');
} finally {
setIsChangingPw(false);
}
};
const isAdmin = currentUser.role === 'admin';
const inputClass =
'w-full bg-slate-800 border border-slate-700 text-white rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 placeholder-slate-600 transition-all';
const roleLabel = (role: string) =>
role === 'admin' ? (
<span className="px-2 py-0.5 rounded-full bg-brand-500/20 text-brand-400 text-xs font-bold">Admin</span>
) : (
<span className="px-2 py-0.5 rounded-full bg-slate-700 text-slate-400 text-xs font-bold">User</span>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-3xl shadow-2xl flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-brand-500/20 flex items-center justify-center">
<svg className="w-5 h-5 text-brand-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
</div>
<div>
<h2 className="font-bold text-white text-lg">Benutzerverwaltung</h2>
<p className="text-slate-500 text-xs">Angemeldet als {currentUser.name}</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center text-slate-400 hover:text-white transition-all"
>
<svg className="w-4 h-4" 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>
{/* Tabs */}
<div className="flex gap-1 px-6 pt-4 shrink-0">
{isAdmin && (
<button
onClick={() => setTab('users')}
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-all ${tab === 'users' ? 'bg-brand-500 text-white' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
Alle Benutzer
</button>
)}
<button
onClick={() => setTab('change-password')}
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-all ${tab === 'change-password' ? 'bg-brand-500 text-white' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
Mein Passwort ändern
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 p-6 space-y-6">
{/* ---- Tab: Users ---- */}
{tab === 'users' && isAdmin && (
<>
{/* User list */}
<div>
<h3 className="text-sm font-bold text-slate-300 mb-3">Benutzer ({users.length})</h3>
{isLoadingUsers ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-brand-500 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<div className="space-y-2">
{users.map((u) => (
<div key={u.id} className="bg-slate-800/60 border border-slate-700 rounded-2xl p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-slate-700 flex items-center justify-center shrink-0 text-slate-300 font-bold text-sm">
{u.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="font-semibold text-white text-sm">{u.name}</span>
<span className="text-xs text-slate-400">(@{u.username})</span>
{roleLabel(u.role)}
{u.id === currentUser.id && (
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-xs font-bold">Ich</span>
)}
</div>
{u.email && <p className="text-slate-500 text-xs">{u.email}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => {
setEditingPwUserId(editingPwUserId === u.id ? null : u.id);
setEditPw('');
}}
className="px-3 py-1.5 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 text-xs font-semibold transition-all"
>
Passwort
</button>
{u.id !== currentUser.id && (
<button
onClick={() => handleDeleteUser(u)}
className="w-7 h-7 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-400 flex items-center justify-center transition-all"
>
<svg className="w-3.5 h-3.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>
{/* Inline password edit */}
{editingPwUserId === u.id && (
<div className="mt-3 pt-3 border-t border-slate-700 flex gap-2">
<input
type="password"
value={editPw}
onChange={(e) => setEditPw(e.target.value)}
className={inputClass + ' flex-1'}
placeholder="Neues Passwort (min. 6 Zeichen)"
/>
<button
onClick={() => handleSetUserPw(u.id)}
className="px-4 py-2 bg-brand-500 hover:bg-brand-600 text-white text-sm font-semibold rounded-xl transition-all"
>
Speichern
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Create new user */}
<div>
<h3 className="text-sm font-bold text-slate-300 mb-3">Neuen Benutzer erstellen</h3>
<form onSubmit={handleCreateUser} className="bg-slate-800/40 border border-slate-700 rounded-2xl p-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1.5">Benutzername *</label>
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className={inputClass}
placeholder="z. B. MaxM"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1.5">Name (Anzeigename) *</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className={inputClass}
placeholder="Max Mustermann"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1.5">E-Mail (optional)</label>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className={inputClass}
placeholder="max@icon-experts.de"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1.5">Rolle</label>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value as 'user' | 'admin')}
className={inputClass}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1.5">Passwort *</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputClass}
placeholder="Sicheres Passwort (mind. 6 Zeichen)"
required
/>
</div>
<button
type="submit"
disabled={isCreating}
className="w-full py-2.5 bg-brand-500 hover:bg-brand-600 disabled:opacity-60 text-white font-bold rounded-xl transition-all flex items-center justify-center gap-2"
>
{isCreating ? (
<><div className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" /><span>Erstelle...</span></>
) : (
<><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg><span>Benutzer erstellen</span></>
)}
</button>
</form>
</div>
</>
)}
{/* ---- Tab: Change own password ---- */}
{tab === 'change-password' && (
<div>
<h3 className="text-sm font-bold text-slate-300 mb-3">Eigenes Passwort ändern</h3>
<form onSubmit={handleChangeOwnPassword} className="bg-slate-800/40 border border-slate-700 rounded-2xl p-5 space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1.5">Aktuelles Passwort</label>
<input
type="password"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
className={inputClass}
placeholder="Aktuelles Passwort"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1.5">Neues Passwort</label>
<input
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
className={inputClass}
placeholder="Mindestens 6 Zeichen"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1.5">Neues Passwort bestätigen</label>
<input
type="password"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
className={inputClass}
placeholder="Wiederholen"
/>
</div>
<button
type="submit"
disabled={isChangingPw}
className="w-full py-2.5 bg-brand-500 hover:bg-brand-600 disabled:opacity-60 text-white font-bold rounded-xl transition-all flex items-center justify-center gap-2"
>
{isChangingPw ? (
<><div className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" /><span>Speichere...</span></>
) : 'Passwort ändern'}
</button>
</form>
</div>
)}
</div>
</div>
</div>
);
};
+38
View File
@@ -0,0 +1,38 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@config "../tailwind.config.js";
:root {
--primary: #C1862F;
--primary-light: #dfa85a;
--primary-rgb: 193, 134, 47;
--accent: #1A1A1A;
--transition: all 0.3s ease;
}
@layer base {
body {
@apply bg-slate-50 text-slate-900 transition-colors duration-300;
font-family: 'Montserrat', sans-serif;
font-weight: 400;
}
.dark body {
@apply bg-[#0A0A0A] text-slate-100;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
letter-spacing: -0.01em;
}
}
@layer utilities {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ToastProvider } from './components/Toast';
import './index.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<ToastProvider>
<App />
</ToastProvider>
</React.StrictMode>
);
+152
View File
@@ -0,0 +1,152 @@
import { supabase } from './supabase';
export interface AppUser {
id: string;
username: string;
email: string | null;
name: string;
role: 'admin' | 'user';
created_at?: string;
}
const SESSION_KEY = 'icon_crm_session';
// ---------- password hashing ----------
export async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ---------- session ----------
export function getSession(): AppUser | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function saveSession(user: AppUser) {
localStorage.setItem(SESSION_KEY, JSON.stringify(user));
}
export function clearSession() {
localStorage.removeItem(SESSION_KEY);
}
// ---------- ensure admin exists ----------
export async function ensureAdminExists() {
try {
const { data } = await supabase
.from('icon_users')
.select('id')
.eq('username', 'Admin')
.maybeSingle();
if (!data) {
const hash = await hashPassword('Elonsucks69!');
await supabase.from('icon_users').insert({
username: 'Admin',
email: 'admin@icon-experts.de',
name: 'Admin User',
role: 'admin',
password_hash: hash,
});
}
} catch (err) {
console.error('Error ensuring admin exists:', err);
}
}
// ---------- login ----------
export async function login(identifier: string, password: string): Promise<AppUser> {
const hash = await hashPassword(password);
const cleanId = identifier.trim();
// Search by either username or email
const { data, error } = await supabase
.from('icon_users')
.select('id, username, email, name, role, created_at')
.or(`username.eq."${cleanId}",email.eq."${cleanId.toLowerCase()}"`)
.eq('password_hash', hash)
.maybeSingle();
if (error) {
console.error('Login query error:', error);
throw new Error('Datenbankfehler beim Login.');
}
if (!data) throw new Error('Benutzername/E-Mail oder Passwort falsch.');
const user: AppUser = {
id: data.id,
username: data.username,
email: data.email,
name: data.name,
role: data.role,
created_at: data.created_at,
};
saveSession(user);
return user;
}
// ---------- user management ----------
export async function listUsers(): Promise<AppUser[]> {
const { data, error } = await supabase
.from('icon_users')
.select('id, username, email, name, role, created_at')
.order('created_at', { ascending: true });
if (error) throw new Error('Benutzer konnten nicht geladen werden.');
return data as AppUser[];
}
export async function createUser(
username: string,
email: string | null,
name: string,
password: string,
role: 'admin' | 'user' = 'user'
): Promise<void> {
const hash = await hashPassword(password);
const cleanEmail = email && email.trim() !== '' ? email.trim().toLowerCase() : null;
const { error } = await supabase.from('icon_users').insert({
username: username.trim(),
email: cleanEmail,
name: name.trim(),
role,
password_hash: hash,
});
if (error) {
console.error('Create user error:', error);
if (error.code === '23505') {
if (error.message?.includes('username') || error.details?.includes('username') || error.message?.includes('icon_users_username_key')) {
throw new Error('Dieser Benutzername ist bereits vergeben.');
}
throw new Error('Diese E-Mail-Adresse ist bereits vergeben.');
}
throw new Error('Benutzer konnte nicht erstellt werden.');
}
}
export async function updatePassword(userId: string, newPassword: string): Promise<void> {
const hash = await hashPassword(newPassword);
const { error } = await supabase
.from('icon_users')
.update({ password_hash: hash })
.eq('id', userId);
if (error) throw new Error('Passwort konnte nicht geändert werden.');
}
export async function deleteUser(userId: string): Promise<void> {
const { error } = await supabase.from('icon_users').delete().eq('id', userId);
if (error) throw new Error('Benutzer konnte nicht gelöscht werden.');
}
+16
View File
@@ -0,0 +1,16 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || localStorage.getItem('SUPABASE_URL') || 'https://icondb.perinco.de';
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || localStorage.getItem('SUPABASE_ANON_KEY') || '';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
export const TABLES = {
CUSTOMERS: 'icon_customers',
CONTACTS: 'icon_contacts',
EXPERTS: 'icon_experts',
TENDERS: 'icon_tenders',
TENDER_EXPERTS: 'icon_tender_experts',
PROJECTS: 'icon_projects'
};
+70
View File
@@ -0,0 +1,70 @@
export type ViewState = 'Dashboard' | 'Live!' | 'Ausschreibungen' | 'Kunden' | 'Experten' | 'Laufende Projekte' | 'Organigramm' | 'Einstellungen';
export interface Expert {
id: string;
name: string;
role: string;
email: string;
phone: string;
linkedin: string;
status: 'Interessiert' | 'Interview' | 'Verfügbar' | 'Gebucht';
avatar: string;
company?: string;
bio?: string;
tags?: string[];
hourlyRateOnsite?: number;
hourlyRateRemote?: number;
cv_url?: string;
updated_at?: string;
}
export interface Contact {
id: string;
customer_id: string;
name: string;
role: string;
email: string;
phone: string;
avatar?: string;
bio?: string;
}
export interface Customer {
id: string;
company: string;
name?: string;
logo?: string;
notes?: string;
address?: string;
website?: string;
contacts?: Contact[];
}
export interface Tender {
id: string;
source: string;
title: string;
applicantsCount: number;
status: 'Aktiv' | 'Ausgelaufen' | 'Archiviert' | 'Inaktiv';
kanbanStatus: KanbanColumn;
details?: string;
notes?: string;
link?: string;
online: boolean;
selectedCustomer?: Customer;
selectedExperts?: Expert[];
}
export type KanbanColumn = 'Ausschreibung' | 'Experten sichten' | 'Interview' | 'Kunden Interview' | 'Vertragsgestaltung' | 'Abschluss' | 'Geschlossen';
export interface Project {
id: string;
title: string;
customer: string;
expert: string;
start: string;
end: string;
status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
notes?: string;
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+47
View File
@@ -0,0 +1,47 @@
-- ============================================================
-- ICON CRM – Benutzerverwaltung (icon_users)
-- Dieses Skript einmalig in der Supabase SQL-Konsole ausführen.
-- ============================================================
-- 1. Tabelle erstellen (vorherige Tabelle löschen, falls vorhanden, um Schema-Konflikte zu vermeiden)
DROP TABLE IF EXISTS public.icon_users CASCADE;
CREATE TABLE public.icon_users (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
username text NOT NULL UNIQUE,
email text UNIQUE,
name text NOT NULL,
role text NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
password_hash text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- 2. Row-Level-Security aktivieren
ALTER TABLE public.icon_users ENABLE ROW LEVEL SECURITY;
-- 3. Anon-Key Zugriff erlauben (die App nutzt den Anon-Key)
DROP POLICY IF EXISTS "Allow anon full access to icon_users" ON public.icon_users;
CREATE POLICY "Allow anon full access to icon_users"
ON public.icon_users
FOR ALL
TO anon
USING (true)
WITH CHECK (true);
-- 4. Admin-Passwort-Hash berechnen und einfügen.
-- SHA-256 von "Elonsucks69!" wird direkt von Postgres berechnet.
INSERT INTO public.icon_users (username, email, name, role, password_hash)
VALUES (
'Admin',
'admin@icon-experts.de',
'Admin User',
'admin',
encode(sha256('Elonsucks69!'::bytea), 'hex')
)
ON CONFLICT (username)
DO UPDATE SET password_hash = EXCLUDED.password_hash;
-- ============================================================
-- FERTIG! Tabelle bereit. Erster Login: Admin / Elonsucks69!
-- ============================================================
+39
View File
@@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Montserrat', 'sans-serif'],
},
colors: {
brand: {
50: '#fdf8f0',
100: '#faefd8',
200: '#f5dbb0',
300: '#ecc07a',
400: '#e6a84d',
500: '#C1862F',
600: '#a97228',
700: '#8a5c20',
},
ui: {
sidebar: '#1A1A1A',
darkBg: '#0A0A0A',
card: '#141414',
}
},
boxShadow: {
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
'card': '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
'heavy': '0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
'xl-brand': '0 20px 25px -5px rgba(193, 134, 47, 0.2), 0 10px 10px -5px rgba(193, 134, 47, 0.08)',
}
},
},
plugins: [],
}