diff --git a/legacy/scripts/add_customer_address.sql b/legacy/scripts/add_customer_address.sql new file mode 100644 index 0000000..9daadf7 --- /dev/null +++ b/legacy/scripts/add_customer_address.sql @@ -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 $$; diff --git a/legacy/scripts/update_expert_rates.sql b/legacy/scripts/update_expert_rates.sql new file mode 100644 index 0000000..e89144b --- /dev/null +++ b/legacy/scripts/update_expert_rates.sql @@ -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 $$; diff --git a/legacy/scripts/update_expert_timestamps.sql b/legacy/scripts/update_expert_timestamps.sql new file mode 100644 index 0000000..eb852b3 --- /dev/null +++ b/legacy/scripts/update_expert_timestamps.sql @@ -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(); diff --git a/legacy/src/App.tsx b/legacy/src/App.tsx new file mode 100644 index 0000000..cf3572e --- /dev/null +++ b/legacy/src/App.tsx @@ -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(() => getSession()); + const [currentView, setCurrentView] = useState('Dashboard'); + const [isDarkMode, setIsDarkMode] = useState(() => { + const saved = localStorage.getItem('isDarkMode'); + return saved !== null ? JSON.parse(saved) : true; + }); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + + const [tenders, setTenders] = useState([]); + const [customers, setCustomers] = useState([]); + const [experts, setExperts] = useState([]); + const [projects, setProjects] = useState([]); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [fetchError, setFetchError] = useState(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 ( +
+
+ ICON Experts +
+

Verbinde zur Datenbank...

+
+
+ ); + } + + const renderContent = () => { + switch (currentView) { + case 'Dashboard': return ; + case 'Live!': return fetchData(true)} />; + case 'Ausschreibungen': return fetchData(true)} />; + case 'Kunden': return fetchData(true)} />; + case 'Experten': return fetchData(true)} />; + case 'Laufende Projekte': return fetchData(true)} />; + case 'Organigramm': return ; + case 'Einstellungen': return ; + default: return ; + } + }; + + const handleUserUpdated = (updatedUser: AppUser) => { + if (currentUser && updatedUser.id === currentUser.id) { + setCurrentUser(updatedUser); + saveSession(updatedUser); + } + }; + + const handleLogout = () => { + clearSession(); + setCurrentUser(null); + }; + + if (!currentUser) { + return setCurrentUser(user)} />; + } + + return ( +
+ setIsSidebarOpen(!isSidebarOpen)} + currentUser={currentUser} + onLogout={handleLogout} + /> + +
+
setIsSidebarOpen(!isSidebarOpen)} + tenders={tenders} + experts={experts} + customers={customers} + projects={projects} + onNavigate={setCurrentView} + /> +
+ {isRefreshing && ( +
+
+ Synchronisiere... +
+ )} + {fetchError && ( +
+ + {fetchError} + +
+ )} + {renderContent()} +
+
+
+ ); +}; + +export default App; diff --git a/legacy/src/components/Customers.tsx b/legacy/src/components/Customers.tsx new file mode 100644 index 0000000..e6c57e2 --- /dev/null +++ b/legacy/src/components/Customers.tsx @@ -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; +} + +export const Customers: React.FC = ({ customers, refreshData }) => { + const { addToast } = useToast(); + const [selectedCustomerId, setSelectedCustomerId] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [customerToDeleteId, setCustomerToDeleteId] = useState(null); + + const [customerForm, setCustomerForm] = useState({ + company: '', + name: '', + logo: '', + notes: '', + address: '', + website: '' + }); + + const [contactsForm, setContactsForm] = useState[]>([]); + + 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) => { + 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) => { + 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 ( +
+ {/* Sidebar: Firmenliste */} +
+ + +
+
+

Kundenstamm

+
+
+ {customers.map(c => ( +
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'}`} + > +
+
+ {c.logo ? ( + {c.company} + ) : ( + {c.company.charAt(0)} + )} +
+
+

{c.company}

+

{c.contacts?.length || 0} Personen

+
+
+ +
+ ))} +
+
+
+ +
+ {activeCustomer ? ( +
+
+
+
+
+ {activeCustomer.logo ? ( + {activeCustomer.company} + ) : ( + activeCustomer.company.charAt(0) + )} +
+
+

{activeCustomer.company}

+
+
+ PREMIUM KUNDE +
+ {activeCustomer.address && ( + + + {activeCustomer.address} + + )} + {activeCustomer.website && ( + + + {activeCustomer.website} + + )} +
+
+
+ +
+ +
+

Kundenprofil & Insights

+
+ {activeCustomer.notes || 'Keine Kundennotizen hinterlegt.'} +
+
+
+ +
+
+ +

Ansprechpartner ({activeCustomer.contacts?.length || 0})

+
+ +
+ {activeCustomer.contacts?.map(contact => ( +
+
+ {contact.avatar ? ( + {contact.name} + ) : ( +
{contact.name.charAt(0)}
+ )} +
+

{contact.name}

+

{contact.role || 'Mitarbeiter'}

+ +
+ {contact.email ? ( + + + {contact.email} + + ) : ( +
+ + N/A +
+ )} + {contact.phone ? ( + + + {contact.phone} + + ) : ( +
+ + N/A +
+ )} + {contact.bio && ( +
+ {contact.bio} +
+ )} +
+
+ ))} +
+
+
+ ) : ( +
+
+ +
+

Bereit zur Auswahl

+

Wählen Sie einen Kunden aus der Liste links aus.

+
+ )} +
+ + {isModalOpen && ( +
+
+
+

+ {editingCustomer ? 'Kunde aktualisieren' : 'Neuer Kunde anlegen'} +

+ +
+ +
+
+ {/* Logo & Basic Info */} +
+
+
+ {customerForm.logo ? ( + Preview + ) : ( +
+ +
+ )} + +
+

Kundenlogo (optional)

+
+ +
+
+ + 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" + /> +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ +
+ +