Quellcode des ICON CRM (2)
This commit is contained in:
@@ -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 $$;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 & 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+10
@@ -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;
|
||||
}
|
||||
@@ -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!
|
||||
-- ============================================================
|
||||
@@ -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: [],
|
||||
}
|
||||
Reference in New Issue
Block a user