Quellcode des ICON CRM (1)

This commit is contained in:
2026-09-14 16:55:29 +02:00
parent 3d09653630
commit c8db64af7e
25 changed files with 3015 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.env.*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# .env.example soll mit ins Repo - sie enthaelt nur Platzhalter
!server/.env.example
Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+14
View File
@@ -0,0 +1,14 @@
18.02.2026 - Kai
Anforderungen an die Vertriebssoftware
- Kontakte
> Wer, wo, Position, Mail, Rufnummer, Linkedin
- Kanban Board (Drag & Drop)
Random:
- Reminder Funktion wenn ein Projekt ausläuft (2 Monate vorher)
- Bots die Recherche bzgl. Stellenausschreibungen / Projekte fahren und auf der anderen Seite nach Personen suchen können.
- Automatisierter CV (ggf. via Bots) > Kontaktdaten auf Kai ändern
- Automatisierte freundliche Absage mich Chance für das nächste Projekt
+106
View File
@@ -0,0 +1,106 @@
14.09.2026
Was muss geändert werden:
- Verfügbarkeitsstatus entfernen aus den Kontakten
- Können wir eine Liste automatisiert erstellen, der letzte Arbeitgeber (Branche & Unternehmen)
> Würde in den "UNternehmen" erscheinen und dort im Stammbaum der "Verlasenen" anzeigen
> "Unternehmenskarte" > Eine Interaktive Karte mit allen bekannten Unternhemen
- Historie der Externen
- Unsere Kunden
- etc.
> Ähnlich zur Netzwerkansicht im perinco CRM
- Wenn man auf den Hintergrund klickt soll sich das Fenster vorne schließen
- Pro Berater eine Historie (Log was haben wir mit ihm bereits gemacht, zB vorgestellt bei Kunde XY)
> Bei Kunden entsprechend ebenfalls darstellen, dort muss es eine Historie geben was für oder bei dem Kunden gleaufen ist (z.B. Bertaer XY wurde beworben + Datum etc. / Vertrag wurde geschlossen am XY von XY)
- Ansicht der Kunden so aufbauen wie die Expertenansicht > heißt dann Unternehmen
- NEU: Mit @ Zeichen Unternehmen oder Kontakte bennen können in allen Texten. Jedes mal wenn ein @ verwendet wurde, muss dies in der Historie ebenfalls auftauchen.
- Wenn Personen oder Unternehmen via @ hinzugefügt wurden, sollen diese anklickbar sein und jeweils das entsprechende Profil geöffnet werden.
- Das gesamte CRM muss durchsuchbar sein (ähnlich Google) bei dem ich nach Inhalten Personen Verträgen etc. suchen kann.
> Die vorhandene Sucher ausbauen/ersetzen
- Kunden wird in die neue Kategorie Unternehmen einbezogen und via "Umschalter" kann man dann zwischen unseren Kunden (aktiv/alumni) und allen Unternehmen umschalten. Also da wo ein Haken bei "aktiver Kunde" im Unternehmen hinterlegt ist.
> Am besten im Stammbaum Ansicht darstellen
- Ein Stammbaum Element "Verlassen", hier dann alle Ex Personen eintragen. Diese Info sollte auch in die Historie der jeweiligen Personen hinterlegt werden.
- Für Unternehmen und Kotnakte (ex Experten=:
- Arbeiten diese mit Freelancern oder nicht?
- Hatten wir schon einmal ein Interview oder nicht und falls ja wann mit wem?
> Historie jedoch je Kontakt oder Unternhemen in dessen Karte durchsuchbar machen.
- Live! Ansicht ist derzeit nicht bearbeitbar!
- Live Anischt wie im Perinco CRM.
- NEU: Lead Pipeline
- Für einen Lead Attribute zwischen "persönlicher Kontkat" oder "Noch kein Kontakt gehabt"
- NEU: Anlage von Expeten automatisiert (Upload CV Word/PDF)
> Auslesen und eintragen in CRM (inkl. CV anhängen)
> Die Option für einen Multiupload soll bestehen
- NEU: Reiter Historie > Hier sollen die letzte gesamten Tätigkeiten chronologisch aufgelistet werden: Timestamp|Aktivität (z.B. 14.09.'26 09:35| Hans Peter neu angelegt).
- NEU: LESENDER Zugriff auf perinco CRM für Kontakte!
- Mit Schalter einschalten oder Ausschalten und mit grünem Rahmen versehen.
- ACHTUNG: Auch im perinco CRM anpassen!
- Bei den Unterhmen ein Feld "Gruppe" das z.B. bei Der Finanzinformatik die Finanzinformatik zusammenfasst und unterhalb der Gruppe gibt es dann die einzelnen Firmen z.B. FI-Hamburg FI-Frankfurt etc.
- Falls nicht vorhandne ignorieren
- "Branche" der Unternehmen bei Unternehmen in Stammdaten hinterlegen und bearbeiten lassen.
- Mitarbeiterzahl für die Unternehmen hinzufügen
- Automatische Bildziehung bei Linkein (wie im perinco CRM)
- Ggf. auch aus Word bzw. PDF beim Import inkl. resize auf 400x400px (ca.)
- WICHTIG!! "Experten" muss "Kontakte" heißen da hier auch die Ansprechpartne der Kunden rein müssen inkl. Lead-Quelle!
- Für die Unternehmen ein 5-Sterne Rating (dies gibt Info darüber wie interessant der Kunde für uns ist)
- Filter dazu in die Netzwerkübersicht
- KANBAN zu den Leads erstellen
- Checkliste mit Historie innerhalb der einzelnen dort liegenden Kacheln als ToDo Liste behandeln
- Status: Für Später (Zurückgestellte), Neue Lead, Erstkontakt, 1. Gespräch, Followup geplant, Followup Gespräch, Closed, Absage
- Archiviert Unter-Reiter mit einer art Excelliste inkl. aller vergangenen Closed Leads oder Absagen.
- Inkl. Option zurückzuschieben
- In den Kacheln brauchen wir:
- Daten des Unternehmens, Daten des Ansprechpartners (Kontakt)
- Historie
- Next Steps / ToDos (Checkliste)
- WICHTIG: Reminderfunktion! Mit Timer Einstellung in Tagen (z.B. 2 Tage dann würde im CRM ein Pop-up aufgegen "Erinnerung: Kunde XY anrufen!")
+ Autreminder: Wenn ein Lead älter als einen Monat ist, Pop-up: "Achtung, Lead seit einem Monat nicht kontaktiert! Soll dieser archiviert werden?". Falls nicht archiviert, Läuft Autoreminder von vorne (wieder ein Monat)
- Automatisch in der Historie (egal in welcher) Welcher User hat was bearbeitet
- Scraper für Stellenausschreibungen (ähnlich der Scanner).
- Kai liefert Quellen &
- Morning Post für Kai > Anhand der letzten 30 Unternehmen die in der Historie zu letzt benannt wurden recherchieren und als eigene Zeitung jeden Morgen bereitstellen.
- Themes für Kai > Duknukem3d / Half-Life mit Crowbar
ALT:
Needed Features:
Experten:
X CV mit hochladen bzw. Link hinterlegen für Onedrive
X Wenn man auf einen Kontakt klickt, soll die Karte im Bearbeitungsmodus geöffnet werden.
X 2. Ansicht - Listenansicht und einen Umsschalter um zwischen den Kacheln und Listen zu switchen.
X Tag Feld so bearbeiten, dass mit Komma getrennte Wörter bei "Hinzufügen" als einzelen Tags angelegt werden (Das Auslesetool soll später aus den CVs dann einen Komma getrennten Tag Text ausgeben um ihn hier einzufügen).
X Farbe ändern, Interview Bubble im Bild soll auch blau sein bei Interview und bei gebucht blau.
Ausschreibungen:
X einen WYSIWYG Editor mit KI Verknüpfung zu OpenAI erstellen.
Kunden
X Überall wo Firma stehet in "Kunde" ändern
X Feld für eine Website hinzufügen
X Das Feld Hauptansprechparnter soll eine Auswahl aus den unten angelegten Ansprechpartnern sein.
X In der Übersicht eines Kunden, soll die Mailadresse der Ansprechpartner klickbar sein, damit sich direkt Outlook öffnet.
X Warum hat John und Jaja ein Bild und wo kann ich das hitnerlegen?
Next Steps:
- Automatisiert PDF/Word Umwandlung in ICON - CVs
- Automatisierte CV Analyse inkl. Eintagung in Datenbank > PDF / Word auslesen.
> Drei Ordner (Eingang|Screening|Auswahl) in denen CVs abgelegt werden und von der KI analysiert
> Eine Liste soll anzeigen welche CVs zur Sichtung stehen, hier soll man mit ja oder nein die CVs zur KI übergeben können, welche dann diese in die Datenbank übernimmt.
+60
View File
@@ -0,0 +1,60 @@
# ICON CRM - Deployment auf icrm.icon-experts.de (31.70.144.60)
#
# Gebaut wird lokal, hochgeladen werden nur dist/ und die Backend-Quellen.
#
# Aufruf aus dem Projektverzeichnis:
# $env:ICON_CRM_SSH_PASSWORD = '...'
# powershell -File deploy/deploy.ps1
# powershell -File deploy/deploy.ps1 -SkipBuild
# powershell -File deploy/deploy.ps1 -Deps # bei neuen Backend-Paketen
param(
[string]$ServerHost = '31.70.144.60',
[int]$Port = 22,
[string]$User = 'root',
[string]$Password = $env:ICON_CRM_SSH_PASSWORD,
[string]$RemotePath = '/var/www/icon_CRM',
[switch]$SkipBuild,
[switch]$Deps
)
$ErrorActionPreference = 'Stop'
if (-not $Password) {
throw 'Kein Passwort. Setze $env:ICON_CRM_SSH_PASSWORD oder uebergib -Password.'
}
$plink = 'C:\Program Files\PuTTY\plink.exe'
$pscp = 'C:\Program Files\PuTTY\pscp.exe'
foreach ($tool in @($plink, $pscp)) {
if (-not (Test-Path $tool)) { throw "Nicht gefunden: $tool" }
}
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location $projectRoot
if (-not $SkipBuild) {
Write-Host '==> Frontend bauen' -ForegroundColor Yellow
npm run build
if ($LASTEXITCODE -ne 0) { throw 'Build fehlgeschlagen - es wird nichts hochgeladen.' }
}
$target = "$User@$ServerHost"
Write-Host '==> dist/ hochladen' -ForegroundColor Yellow
& $pscp -r -batch -P $Port -pw $Password 'dist' "${target}:$RemotePath/"
Write-Host '==> Backend hochladen' -ForegroundColor Yellow
& $pscp -r -batch -P $Port -pw $Password 'server\src' "${target}:$RemotePath/server/"
& $pscp -batch -P $Port -pw $Password 'server\package.json' "${target}:$RemotePath/server/"
$remoteCommand = "systemctl restart icon-crm-backend; sleep 3; systemctl is-active icon-crm-backend; curl -s http://127.0.0.1:5007/api/health"
if ($Deps) {
$remoteCommand = "cd $RemotePath/server && npm install --omit=dev --no-audit --no-fund; $remoteCommand"
}
Write-Host '==> Backend neu starten und pruefen' -ForegroundColor Yellow
& $plink -ssh -batch -P $Port -pw $Password $target $remoteCommand
Write-Host ''
Write-Host 'Fertig.' -ForegroundColor Green
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=ICON CRM Backend (Express + MariaDB)
After=network.target mariadb.service
Wants=mariadb.service
[Service]
Type=simple
User=root
WorkingDirectory=/var/www/icon_CRM/server
EnvironmentFile=/var/www/icon_CRM/server/.env
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
RestartSec=5
# Der Server hat 8 GB. Die Grenze ist kein Sparzwang, sondern ein Sicherheitsnetz:
# ein Leck im Backend soll nicht Apache und MariaDB mitreissen.
MemoryMax=1024M
[Install]
WantedBy=multi-user.target
+43
View File
@@ -0,0 +1,43 @@
# ICON CRM - Apache Vhost
# Ablage: /etc/apache2/sites-available/icrm.icon-experts.de.conf
# Aktivieren: a2ensite icrm.icon-experts.de && systemctl reload apache2
#
# Reihenfolge beim Aufsetzen:
# 1. DNS A-Record icrm.icon-experts.de -> 31.70.144.60
# 2. Diese Datei aktivieren (HTTP-Teil), Apache neu laden
# 3. certbot --apache -d icrm.icon-experts.de (legt den :443-Block selbst an)
<VirtualHost *:80>
ServerName icrm.icon-experts.de
DocumentRoot /var/www/icon_CRM/dist
<Directory /var/www/icon_CRM/dist>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
# Single Page Application: alle unbekannten Pfade auf index.html
FallbackResource /index.html
</Directory>
# Die mit Hash benannten Build-Dateien aendern sich nie - die darf der
# Browser dauerhaft behalten. index.html dagegen nie, sonst sieht ein
# Benutzer nach dem Rollout weiter die alte Anwendung.
<IfModule mod_expires.c>
ExpiresActive On
<FilesMatch "\.(js|css|woff2|png|jpg|svg|ico)$">
ExpiresDefault "access plus 1 year"
</FilesMatch>
</IfModule>
<FilesMatch "index\.html$">
Header set Cache-Control "no-cache, must-revalidate"
</FilesMatch>
# Die API laeuft lokal auf 127.0.0.1:5007 und ist von aussen
# ausschliesslich hierueber erreichbar.
ProxyPreserveHost On
ProxyPass /api http://127.0.0.1:5007/api
ProxyPassReverse /api http://127.0.0.1:5007/api
ErrorLog ${APACHE_LOG_DIR}/icon_crm_error.log
CustomLog ${APACHE_LOG_DIR}/icon_crm_access.log combined
</VirtualHost>
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ICON CRM – Kontakte, Unternehmen, Ausschreibungen und Projekte an einer Stelle." />
<title>ICON CRM</title>
<!--
Das ICON Zeichen (die drei verbundenen Punkte), erzeugt aus dem
Originallogo der Website. Die .ico enthält 16/32/48/64 px - der Browser
nimmt die passende Größe; die .png ist das, was iOS für den
Startbildschirm verwendet.
-->
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!--
Montserrat trägt die Anwendung, Oswald und Silkscreen nur die
Überschriften der Designs Half-Life und Pixel Art. Beide in einer Anfrage - ein zweiter Aufruf würde das
erste Rendern verzögern, und bedingtes Nachladen je Design lohnt bei
einer schmalen Schnittmenge von Schnitten nicht.
-->
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700;800&family=Oswald:wght@400;500;600;700&family=Silkscreen:wght@400;700&display=swap"
rel="stylesheet"
/>
<script>
/*
* Setzt die Klassen fuer Modus und Design, bevor der erste Frame steht.
* Ohne das blitzt beim Laden die helle Variante auf.
*/
(function () {
try {
var mode = localStorage.getItem('icon_crm_theme_mode');
var theme = localStorage.getItem('icon_crm_theme');
var dark = mode === 'dark' || (!mode && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
if (theme && theme !== 'icon') document.documentElement.classList.add('theme-' + theme);
} catch (e) {
/* localStorage kann gesperrt sein - dann gilt der Standard. */
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+149
View File
@@ -0,0 +1,149 @@
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';
const App: React.FC = () => {
const [currentView, setCurrentView] = useState<ViewState>('Dashboard');
const [isDarkMode, setIsDarkMode] = useState<boolean>(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 fetchData = async (showRefreshIndicator = false) => {
if (showRefreshIndicator) setIsRefreshing(true);
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,
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);
} finally {
setIsInitialLoading(false);
setIsRefreshing(false);
}
};
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
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 bg-slate-950">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
<p className="text-white font-bold tracking-widest animate-pulse">ICON DATABASE CONNECTING...</p>
</div>
</div>
);
}
const renderContent = () => {
switch (currentView) {
case 'Dashboard': return <Dashboard />;
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} />;
default: return <Dashboard />;
}
};
return (
<div className="flex h-screen overflow-hidden bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 font-sans">
<Sidebar
currentView={currentView}
onViewChange={setCurrentView}
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen(!isSidebarOpen)}
/>
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Header
title={currentView}
isDarkMode={isDarkMode}
onToggleDarkMode={toggleDarkMode}
onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
/>
<div className="flex-1 overflow-y-auto p-4 md:p-8 relative">
{isRefreshing && (
<div className="absolute top-4 right-8 z-50 flex items-center gap-2 bg-white/80 dark:bg-slate-900/80 px-4 py-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-800 backdrop-blur-sm animate-in fade-in slide-in-from-top-2">
<div className="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
<span className="text-[10px] font-bold uppercase tracking-widest">Synchronisiere...</span>
</div>
)}
{renderContent()}
</div>
</main>
</div>
);
};
export default App;
+448
View File
@@ -0,0 +1,448 @@
import React, { useState, useEffect } from 'react';
import { Customer, Contact } from '../types';
import { supabase, TABLES } from '../lib/supabase';
interface CustomersProps {
customers: Customer[];
refreshData: () => Promise<void>;
}
export const Customers: React.FC<CustomersProps> = ({ customers, refreshData }) => {
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [customerToDeleteId, setCustomerToDeleteId] = useState<string | null>(null);
const [customerForm, setCustomerForm] = useState({
company: '',
name: '',
logo: '',
notes: ''
});
const [contactsForm, setContactsForm] = useState<Partial<Contact>[]>([]);
useEffect(() => {
if (!selectedCustomerId && customers.length > 0) {
setSelectedCustomerId(customers[0].id);
}
}, [customers, selectedCustomerId]);
const activeCustomer = customers.find(c => c.id === selectedCustomerId) || customers[0];
const handleOpenCreate = () => {
setEditingCustomer(null);
setCustomerForm({ company: '', name: '', logo: '', notes: '' });
setContactsForm([{ name: '', role: '', email: '', phone: '', avatar: '' }]);
setIsModalOpen(true);
};
const handleOpenEdit = (customer: Customer) => {
setEditingCustomer(customer);
setCustomerForm({
company: customer.company || '',
name: customer.name || '',
logo: customer.logo || '',
notes: customer.notes || ''
});
setContactsForm(customer.contacts && customer.contacts.length > 0
? customer.contacts
: [{ name: '', role: '', email: '', phone: '', avatar: '' }]
);
setIsModalOpen(true);
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => setCustomerForm(prev => ({ ...prev, logo: reader.result as string }));
reader.readAsDataURL(file);
}
};
const addContactRow = () => {
setContactsForm([...contactsForm, { name: '', role: '', email: '', phone: '', avatar: '' }]);
};
const removeContactRow = (index: number) => {
setContactsForm(contactsForm.filter((_, i) => i !== index));
};
const updateContactField = (index: number, field: keyof Contact, value: string) => {
const newContacts = [...contactsForm];
newContacts[index] = { ...newContacts[index], [field]: value };
setContactsForm(newContacts);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!customerForm.company.trim()) {
alert("Bitte geben Sie einen Firmennamen an.");
return;
}
setIsSaving(true);
try {
let finalCustomerId = editingCustomer?.id;
if (editingCustomer) {
const { error } = await supabase.from(TABLES.CUSTOMERS).update(customerForm).eq('id', editingCustomer.id);
if (error) throw error;
} else {
const { data, error } = await supabase.from(TABLES.CUSTOMERS).insert([customerForm]).select();
if (error) throw error;
finalCustomerId = data[0].id;
}
if (finalCustomerId) {
await supabase.from(TABLES.CONTACTS).delete().eq('customer_id', finalCustomerId);
const contactsToInsert = contactsForm
.filter(c => c.name && c.name.trim() !== '')
.map(c => {
const { id, ...cleanContact } = c as any;
return {
...cleanContact,
customer_id: finalCustomerId
};
});
if (contactsToInsert.length > 0) {
const { error: cError } = await supabase.from(TABLES.CONTACTS).insert(contactsToInsert);
if (cError) throw cError;
}
}
setIsModalOpen(false);
await refreshData();
if (!editingCustomer && finalCustomerId) setSelectedCustomerId(finalCustomerId);
} catch (error: any) {
console.error('Save error:', error);
alert('Fehler beim Speichern: ' + (error.message || 'Unbekannter Datenbankfehler'));
} finally {
setIsSaving(false);
}
};
const confirmDeleteCustomer = async () => {
if (!customerToDeleteId) return;
try {
await supabase.from(TABLES.CUSTOMERS).delete().eq('id', customerToDeleteId);
if (selectedCustomerId === customerToDeleteId) setSelectedCustomerId(null);
await refreshData();
} catch (error) {
console.error('Delete error:', error);
} finally {
setCustomerToDeleteId(null);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 h-full">
{/* Sidebar: Firmenliste */}
<div className="lg:col-span-1 space-y-4 flex flex-col min-h-0">
<button
onClick={handleOpenCreate}
className="w-full py-4 bg-brand-600 text-white font-extrabold rounded-2xl shadow-xl-brand hover:bg-brand-700 transition-all shrink-0 flex items-center justify-center gap-2 active:scale-95"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 4v16m8-8H4" /></svg>
Neues Unternehmen
</button>
<div className="flex-1 bg-white dark:bg-slate-900 rounded-[2rem] border-2 border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col shadow-card">
<div className="p-5 border-b-2 border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h3 className="font-bold text-[10px] uppercase tracking-[0.2em] text-slate-400">Kundenstamm</h3>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800">
{customers.map(c => (
<div
key={c.id}
onClick={() => setSelectedCustomerId(c.id)}
className={`p-5 cursor-pointer transition-all flex items-center justify-between group ${selectedCustomerId === c.id ? 'bg-brand-50/50 dark:bg-brand-500/10' : 'hover:bg-slate-50 dark:hover:bg-slate-800'}`}
>
<div className="flex items-center gap-4 min-w-0">
<div className={`w-12 h-12 rounded-xl overflow-hidden shrink-0 border-2 transition-colors flex items-center justify-center ${selectedCustomerId === c.id ? 'bg-brand-500 text-white border-brand-500 shadow-lg' : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700'}`}>
{c.logo ? (
<img src={c.logo} alt={c.company} className="w-full h-full object-cover" />
) : (
<span className={`font-extrabold text-lg ${selectedCustomerId === c.id ? 'text-white' : 'text-brand-500'}`}>{c.company.charAt(0)}</span>
)}
</div>
<div className="truncate">
<p className={`font-bold text-sm truncate ${selectedCustomerId === c.id ? 'text-brand-700 dark:text-brand-400' : 'text-slate-800 dark:text-slate-200'}`}>{c.company}</p>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-0.5 tracking-wider">{c.contacts?.length || 0} Personen</p>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setCustomerToDeleteId(c.id); }}
className="opacity-0 group-hover:opacity-100 p-2 text-slate-300 hover:text-red-500 transition-all hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
))}
</div>
</div>
</div>
<div className="lg:col-span-3 min-w-0">
{activeCustomer ? (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-6 duration-500">
<div className="bg-white dark:bg-slate-900 rounded-[3rem] p-10 border-2 border-slate-200 dark:border-slate-800 shadow-card relative overflow-hidden">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-8">
<div className="flex items-center gap-8">
<div className="w-28 h-28 rounded-[2.5rem] bg-brand-500 text-white flex items-center justify-center font-extrabold text-5xl shadow-xl-brand overflow-hidden border-4 border-white dark:border-slate-800">
{activeCustomer.logo ? (
<img src={activeCustomer.logo} alt={activeCustomer.company} className="w-full h-full object-cover" />
) : (
activeCustomer.company.charAt(0)
)}
</div>
<div>
<h2 className="text-4xl font-extrabold text-slate-900 dark:text-white leading-tight tracking-tight">{activeCustomer.company}</h2>
<div className="flex items-center gap-3 mt-3">
<span className="px-3 py-1 bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 text-[10px] font-extrabold uppercase tracking-widest rounded-lg border border-brand-100 dark:border-brand-500/20">PREMIUM KUNDE</span>
</div>
</div>
</div>
<button
onClick={() => handleOpenEdit(activeCustomer)}
className="px-10 py-4 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border-2 border-slate-300 dark:border-slate-700 font-extrabold rounded-2xl hover:border-brand-500 hover:text-brand-600 transition-all shadow-soft active:scale-95"
>
Firma bearbeiten
</button>
</div>
<div className="mt-10 pt-10 border-t-2 border-slate-100 dark:border-slate-800">
<h3 className="text-xs font-extrabold text-slate-400 uppercase tracking-[0.25em] mb-6">Unternehmensprofil & Insights</h3>
<div className="p-8 bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] border-2 border-slate-200 dark:border-slate-800 text-sm leading-relaxed text-slate-600 dark:text-slate-300 min-h-[120px] whitespace-pre-wrap font-medium">
{activeCustomer.notes || 'Keine Firmennotizen hinterlegt.'}
</div>
</div>
</div>
<div className="space-y-8">
<div className="flex items-center gap-4">
<span className="w-2 h-10 bg-brand-500 rounded-full"></span>
<h3 className="text-2xl font-extrabold dark:text-white tracking-tight text-slate-900">Ansprechpartner ({activeCustomer.contacts?.length || 0})</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 pb-10">
{activeCustomer.contacts?.map(contact => (
<div key={contact.id} className="bg-white dark:bg-slate-900 p-8 rounded-[2.5rem] border-2 border-slate-200 dark:border-slate-800 shadow-heavy flex flex-col items-center text-center group hover:border-brand-500/50 transition-all relative">
<div className="w-24 h-24 rounded-full overflow-hidden mb-6 border-4 border-slate-100 dark:border-slate-800 shadow-lg group-hover:border-brand-500 transition-all shrink-0">
{contact.avatar ? (
<img src={contact.avatar} alt={contact.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center font-extrabold text-2xl text-slate-400 uppercase">{contact.name.charAt(0)}</div>
)}
</div>
<h4 className="font-extrabold text-xl dark:text-white leading-tight group-hover:text-brand-600 transition-colors text-slate-900">{contact.name}</h4>
<p className="text-[10px] text-brand-600 font-extrabold uppercase tracking-[0.2em] mt-2 mb-8">{contact.role || 'Mitarbeiter'}</p>
<div className="w-full space-y-3 pt-8 border-t-2 border-slate-50 dark:border-slate-800 mt-auto">
<div className="flex items-center gap-3 text-xs font-semibold text-slate-500 justify-center">
<svg className="w-4 h-4 text-brand-500 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span className="truncate">{contact.email || 'N/A'}</span>
</div>
<div className="flex items-center gap-3 text-xs font-semibold text-slate-500 justify-center">
<svg className="w-4 h-4 text-brand-500 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
<span>{contact.phone || 'N/A'}</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center p-20 bg-white dark:bg-slate-900 rounded-[3rem] border-2 border-dashed border-slate-300 dark:border-slate-800 text-slate-400 shadow-inner">
<div className="w-20 h-20 bg-slate-50 dark:bg-slate-800 rounded-3xl flex items-center justify-center mb-6 text-slate-200">
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
</div>
<p className="font-extrabold tracking-widest text-xs uppercase opacity-50">Bereit zur Auswahl</p>
<p className="mt-2 font-medium">Wählen Sie ein Unternehmen aus der Liste links aus.</p>
</div>
)}
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300">
<div className="bg-white dark:bg-slate-900 w-full max-w-5xl rounded-[3rem] shadow-heavy p-10 max-h-[90vh] flex flex-col border border-slate-200 dark:border-slate-800">
<div className="flex justify-between items-center mb-10 shrink-0">
<h3 className="text-3xl font-black text-slate-900 dark:text-white tracking-tight">
{editingCustomer ? 'Firma aktualisieren' : 'Neues Unternehmen anlegen'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="p-3 bg-slate-50 dark:bg-slate-800 rounded-2xl text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto space-y-10 pr-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Logo & Basic Info */}
<div className="space-y-6">
<div className="flex flex-col items-center gap-4 bg-slate-50 dark:bg-slate-800/50 p-8 rounded-[2.5rem] border-2 border-dashed border-slate-200 dark:border-slate-700">
<div className="relative group">
{customerForm.logo ? (
<img src={customerForm.logo} alt="Preview" className="w-32 h-32 rounded-[2rem] object-cover shadow-xl border-4 border-white" />
) : (
<div className="w-32 h-32 rounded-[2rem] bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-400">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
)}
<label className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-[2rem] opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity font-bold text-xs uppercase">
Hochladen
<input type="file" className="hidden" accept="image/*" onChange={handleLogoUpload} />
</label>
</div>
<p className="text-[10px] text-slate-400 uppercase font-black tracking-widest">Firmenlogo (optional)</p>
</div>
<div className="space-y-4">
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Unternehmensname*</label>
<input
required
type="text"
value={customerForm.company}
onChange={e => setCustomerForm({...customerForm, company: e.target.value})}
className="w-full px-6 py-4 bg-slate-100 dark:bg-slate-800 border-2 border-transparent focus:border-brand-500 rounded-2xl outline-none font-bold text-slate-900 dark:text-white transition-all shadow-inner"
placeholder="Z.B. Acme Corp"
/>
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Hauptansprechpartner</label>
<input
type="text"
value={customerForm.name}
onChange={e => setCustomerForm({...customerForm, name: e.target.value})}
className="w-full px-6 py-4 bg-slate-100 dark:bg-slate-800 border-2 border-transparent focus:border-brand-500 rounded-2xl outline-none font-bold text-slate-900 dark:text-white transition-all shadow-inner"
placeholder="Z.B. Max Mustermann"
/>
</div>
</div>
</div>
<div className="flex flex-col h-full">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">Firmenprofil & CRM-Notizen</label>
<textarea
value={customerForm.notes}
onChange={e => setCustomerForm({...customerForm, notes: e.target.value})}
className="flex-1 w-full px-6 py-6 bg-slate-100 dark:bg-slate-800 border-2 border-transparent focus:border-brand-500 rounded-[2rem] outline-none font-medium text-slate-800 dark:text-slate-200 resize-none transition-all shadow-inner min-h-[200px]"
placeholder="Wichtige Infos zur Firma, Rahmenverträge, etc..."
/>
</div>
</div>
{/* Optimized Dynamic Contacts Section */}
<div className="pt-10 border-t-2 border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center mb-6">
<h4 className="text-xl font-black text-slate-900 dark:text-white tracking-tight">Zusätzliche Ansprechpartner</h4>
<button
type="button"
onClick={addContactRow}
className="px-6 py-2 bg-brand-500 text-white font-bold rounded-xl shadow-lg shadow-brand-500/20 hover:bg-brand-600 transition-all flex items-center gap-2 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M12 4v16m8-8H4" /></svg>
Hinzufügen
</button>
</div>
<div className="space-y-3">
{contactsForm.map((contact, idx) => (
<div key={idx} className="grid grid-cols-12 gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border-2 border-slate-100 dark:border-slate-700/50 items-center">
<div className="col-span-12 md:col-span-3">
<input
type="text"
placeholder="Name"
value={contact.name}
onChange={(e) => updateContactField(idx, 'name', e.target.value)}
className="w-full px-4 py-3 bg-white dark:bg-slate-800 rounded-xl outline-none font-bold text-xs border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-12 md:col-span-2">
<input
type="text"
placeholder="Rolle"
value={contact.role}
onChange={(e) => updateContactField(idx, 'role', e.target.value)}
className="w-full px-4 py-3 bg-white dark:bg-slate-800 rounded-xl outline-none font-bold text-xs border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-12 md:col-span-3">
<input
type="email"
placeholder="E-Mail"
value={contact.email}
onChange={(e) => updateContactField(idx, 'email', e.target.value)}
className="w-full px-4 py-3 bg-white dark:bg-slate-800 rounded-xl outline-none font-bold text-xs border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-10 md:col-span-3">
<input
type="text"
placeholder="Telefon"
value={contact.phone}
onChange={(e) => updateContactField(idx, 'phone', e.target.value)}
className="w-full px-4 py-3 bg-white dark:bg-slate-800 rounded-xl outline-none font-bold text-xs border-2 border-transparent focus:border-brand-500 shadow-sm"
/>
</div>
<div className="col-span-2 md:col-span-1 flex justify-end">
<button
type="button"
onClick={() => removeContactRow(idx)}
className="p-3 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
))}
</div>
</div>
{/* Submit Area */}
<div className="pt-10 flex gap-4 shrink-0">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="flex-1 py-5 bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-300 font-black rounded-2xl hover:bg-slate-200 transition-all uppercase tracking-widest text-xs"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSaving}
className="flex-[2] py-5 bg-brand-600 text-white font-black rounded-2xl shadow-xl-brand hover:bg-brand-700 transition-all uppercase tracking-widest text-xs flex items-center justify-center gap-3 disabled:opacity-50"
>
{isSaving ? <div className="w-5 h-5 border-4 border-white/30 border-t-white rounded-full animate-spin"></div> : 'Unternehmen Speichern'}
</button>
</div>
</form>
</div>
</div>
)}
{customerToDeleteId && (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-sm rounded-[2.5rem] p-10 text-center shadow-heavy border-2 border-red-500/20">
<div className="w-20 h-20 bg-red-100 dark:bg-red-900/30 text-red-600 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-inner">
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<h3 className="text-2xl font-black mb-3 text-slate-900 dark:text-white tracking-tight">Firma löschen?</h3>
<p className="text-slate-500 dark:text-slate-400 mb-10 text-sm font-medium leading-relaxed">Alle verknüpften Ansprechpartner und Journal-Einträge werden dauerhaft entfernt.</p>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => setCustomerToDeleteId(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-black text-slate-500 dark:text-slate-300 uppercase tracking-widest text-[10px]">Abbruch</button>
<button onClick={confirmDeleteCustomer} className="py-4 bg-red-600 text-white rounded-2xl font-black shadow-lg shadow-red-500/30 hover:bg-red-700 transition-all uppercase tracking-widest text-[10px]">Jetzt Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts';
const data = [
{ name: 'Mo', placement: 4, expert: 10 },
{ name: 'Di', placement: 3, expert: 12 },
{ name: 'Mi', placement: 6, expert: 15 },
{ name: 'Do', placement: 8, expert: 10 },
{ name: 'Fr', placement: 5, expert: 8 },
];
export const Dashboard: React.FC = () => {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ label: 'Aktive Ausschreibungen', value: '12', trend: '+12%', color: 'blue' },
{ label: 'Gesamt Experten', value: '1.450', trend: '+5%', color: 'green' },
{ label: 'Laufende Projekte', value: '34', trend: '+2', color: 'purple' },
{ label: 'Abschlussrate', value: '78%', trend: '+4%', color: 'orange' },
].map((stat) => (
<div key={stat.label} className="bg-white dark:bg-slate-900 p-6 rounded-3xl shadow-card border border-slate-200 dark:border-slate-800 transition-all hover:shadow-lg hover:-translate-y-1">
<p className="text-slate-500 text-sm font-semibold tracking-wide">{stat.label}</p>
<div className="flex items-baseline gap-3 mt-2">
<h3 className="text-3xl font-extrabold text-slate-900 dark:text-white">{stat.value}</h3>
<span className="text-xs font-bold text-brand-600 px-2 py-1 bg-brand-50 dark:bg-brand-500/10 rounded-full border border-brand-100 dark:border-brand-500/20">{stat.trend}</span>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-slate-900 p-6 rounded-[2.5rem] border border-slate-200 dark:border-slate-800 shadow-card h-[450px] flex flex-col overflow-hidden">
<h3 className="text-lg font-bold mb-6 shrink-0 flex items-center gap-2">
<span className="w-1.5 h-6 bg-brand-500 rounded-full"></span>
Placements vs. Experten-Anfragen
</h3>
<div className="flex-1 w-full overflow-hidden">
<ResponsiveContainer width="100%" height="100%" minHeight={300}>
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<Tooltip
contentStyle={{ backgroundColor: '#0f172a', border: 'none', borderRadius: '12px', color: '#fff', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1)' }}
itemStyle={{ color: '#fff' }}
/>
<Bar dataKey="placement" fill="#22c55e" radius={[6, 6, 0, 0]} barSize={24} />
<Bar dataKey="expert" fill="#3b82f6" radius={[6, 6, 0, 0]} barSize={24} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white dark:bg-slate-900 p-6 rounded-[2.5rem] border border-slate-200 dark:border-slate-800 shadow-card h-[450px] flex flex-col overflow-hidden">
<h3 className="text-lg font-bold mb-6 shrink-0 flex items-center gap-2">
<span className="w-1.5 h-6 bg-blue-500 rounded-full"></span>
Umsatzprognose
</h3>
<div className="flex-1 w-full overflow-hidden">
<ResponsiveContainer width="100%" height="100%" minHeight={300}>
<AreaChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#22c55e" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<YAxis axisLine={false} tickLine={false} hide />
<Tooltip />
<Area type="monotone" dataKey="placement" stroke="#22c55e" strokeWidth={3} fillOpacity={1} fill="url(#colorPv)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="bg-white dark:bg-slate-900 p-6 rounded-[2.5rem] border border-slate-200 dark:border-slate-800 shadow-card">
<h3 className="text-lg font-bold mb-6">Letzte Aktivitäten</h3>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between p-5 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border border-slate-100 dark:border-slate-700/50 hover:border-brand-500/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-brand-500/10 flex items-center justify-center border border-brand-500/20">
<svg className="w-6 h-6 text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
</div>
<div>
<p className="font-bold text-slate-800 dark:text-slate-100">Neuer Experte registriert</p>
<p className="text-xs text-slate-500 font-medium">Mila Superstar • Vor 10 Minuten</p>
</div>
</div>
<button className="px-4 py-2 text-xs font-bold text-brand-600 bg-brand-50 dark:bg-brand-500/10 rounded-lg hover:bg-brand-100 transition-all">Ansehen</button>
</div>
))}
</div>
</div>
</div>
);
};
+347
View File
@@ -0,0 +1,347 @@
import React, { useState } from 'react';
import { Expert } from '../types';
import { supabase, TABLES } from '../lib/supabase';
interface ExpertsProps {
experts: Expert[];
setExperts: React.Dispatch<React.SetStateAction<Expert[]>>;
refreshData: () => Promise<void>;
}
export const Experts: React.FC<ExpertsProps> = ({ experts, refreshData }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingExpert, setEditingExpert] = useState<Expert | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [expertToDeleteId, setExpertToDeleteId] = useState<string | null>(null);
const [tagInput, setTagInput] = useState('');
const [formState, setFormState] = useState({
name: '',
role: '',
email: '',
phone: '',
linkedin: '',
status: 'Verfügbar' as Expert['status'],
avatar: '',
bio: '',
tags: [] as string[]
});
const filteredExperts = experts.filter(e =>
e.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.role.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.tags?.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
const openEditModal = (expert: Expert) => {
setEditingExpert(expert);
setFormState({
name: expert.name,
role: expert.role,
email: expert.email || '',
phone: expert.phone || '',
linkedin: expert.linkedin || '',
status: expert.status,
avatar: expert.avatar || '',
bio: expert.bio || '',
tags: expert.tags || []
});
setTagInput('');
setIsModalOpen(true);
};
const openCreateModal = () => {
setEditingExpert(null);
setFormState({
name: '',
role: '',
email: '',
phone: '',
linkedin: '',
status: 'Verfügbar',
avatar: '',
bio: '',
tags: []
});
setTagInput('');
setIsModalOpen(true);
};
const handleAddTag = (e?: React.FormEvent) => {
if (e) e.preventDefault();
const tag = tagInput.trim();
if (tag && !formState.tags.includes(tag)) {
setFormState({ ...formState, tags: [...formState.tags, tag] });
setTagInput('');
}
};
const removeTag = (tagToRemove: string) => {
setFormState({ ...formState, tags: formState.tags.filter(t => t !== tagToRemove) });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (editingExpert) {
const { error } = await supabase
.from(TABLES.EXPERTS)
.update(formState)
.eq('id', editingExpert.id);
if (error) console.error(error);
} else {
const { error } = await supabase.from(TABLES.EXPERTS).insert([formState]);
if (error) console.error(error);
}
setIsModalOpen(false);
refreshData();
};
const confirmDeleteExpert = async () => {
if (!expertToDeleteId) return;
try {
await supabase.from(TABLES.EXPERTS).delete().eq('id', expertToDeleteId);
await refreshData();
} catch (error) {
console.error('Delete error:', error);
} finally {
setExpertToDeleteId(null);
}
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setFormState({ ...formState, avatar: reader.result as string });
};
reader.readAsDataURL(file);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center mb-8">
<div className="relative flex-1 w-full max-w-xl">
<input
type="text"
placeholder="Namen, Rollen oder Tags suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-12 pr-6 py-4 bg-white dark:bg-slate-900 border-none rounded-2xl shadow-sm focus:ring-2 focus:ring-brand-500 transition-all text-lg dark:text-white"
/>
<svg className="w-6 h-6 absolute left-4 top-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button
onClick={openCreateModal}
className="bg-brand-600 hover:bg-brand-700 text-white px-6 py-4 rounded-2xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2 whitespace-nowrap w-full md:w-auto justify-center"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
Experte hinzufügen
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredExperts.map((expert) => (
<div key={expert.id} className="bg-white dark:bg-slate-900 rounded-[2.5rem] p-6 border border-slate-100 dark:border-slate-800 hover:shadow-2xl transition-all group flex flex-col relative overflow-hidden h-full">
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setExpertToDeleteId(expert.id)}
className="p-2 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm text-slate-400 hover:text-red-500 rounded-full shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
<div className="flex flex-col items-center text-center mb-6">
<div className="relative mb-4">
{expert.avatar ? (
<img src={expert.avatar} alt={expert.name} className="w-24 h-24 rounded-[2rem] object-cover shadow-xl ring-4 ring-slate-50 dark:ring-slate-800" />
) : (
<div className="w-24 h-24 rounded-[2rem] bg-brand-500/10 text-brand-600 flex items-center justify-center font-bold text-3xl uppercase ring-4 ring-slate-50 dark:ring-slate-800">
{expert.name.charAt(0)}
</div>
)}
<div className={`absolute -bottom-1 -right-1 w-6 h-6 rounded-full border-4 border-white dark:border-slate-900 ${
expert.status === 'Verfügbar' ? 'bg-brand-500' :
expert.status === 'Gebucht' ? 'bg-blue-500' : 'bg-slate-400'
}`}></div>
</div>
<h4 className="font-bold text-xl leading-tight text-slate-900 dark:text-white group-hover:text-brand-500 transition-colors">{expert.name}</h4>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{expert.role}</p>
</div>
<div className="flex-1 mb-4 flex flex-col">
<div className="p-4 bg-slate-50 dark:bg-slate-800/40 rounded-2xl border border-slate-100 dark:border-slate-800/50 min-h-[80px] mb-4">
<p className="text-slate-600 dark:text-slate-400 text-xs leading-relaxed line-clamp-3 italic">
{expert.bio || "Keine zusätzlichen Informationen hinterlegt."}
</p>
</div>
{/* Tags Display moved below bio Box */}
{expert.tags && expert.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 justify-center mb-2">
{expert.tags.slice(0, 4).map(tag => (
<span key={tag} className="px-2.5 py-1 bg-brand-50 dark:bg-brand-500/10 text-[9px] font-extrabold text-brand-700 dark:text-brand-400 rounded-lg uppercase tracking-wider border border-brand-100 dark:border-brand-500/20 shadow-sm">
{tag}
</span>
))}
{expert.tags.length > 4 && (
<span className="text-[9px] font-bold text-slate-400 self-center">+{expert.tags.length - 4}</span>
)}
</div>
)}
</div>
<div className="space-y-2 mb-6 text-[13px]">
<div className="flex items-center gap-3 text-slate-500">
<svg className="w-4 h-4 opacity-40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span className="truncate">{expert.email || 'n/a'}</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<svg className="w-4 h-4 opacity-40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
<span className="truncate">{expert.phone || 'n/a'}</span>
</div>
</div>
<button
onClick={() => openEditModal(expert)}
className="w-full py-3.5 bg-slate-900 dark:bg-slate-800 text-white dark:text-slate-200 font-bold rounded-2xl hover:bg-brand-600 dark:hover:bg-brand-600 transition-all text-xs uppercase tracking-widest shadow-lg shadow-slate-900/10"
>
Profil bearbeiten
</button>
</div>
))}
{filteredExperts.length === 0 && (
<div className="col-span-full py-24 text-center bg-white dark:bg-slate-900 rounded-[3rem] border-2 border-dashed border-slate-200 dark:border-slate-800">
<div className="w-20 h-20 bg-slate-50 dark:bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300">
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
</div>
<p className="text-slate-500 font-medium mb-4">Keine Experten gefunden.</p>
<button onClick={openCreateModal} className="text-brand-600 font-bold hover:underline px-6 py-2 bg-brand-50 dark:bg-brand-500/10 rounded-xl transition-all">Neu anlegen</button>
</div>
)}
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-2xl rounded-[2.5rem] shadow-2xl p-10 max-h-[90vh] overflow-y-auto border border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center mb-8">
<h3 className="text-3xl font-bold dark:text-white">{editingExpert ? 'Profil bearbeiten' : 'Neuer Experte'}</h3>
<button onClick={() => setIsModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2 flex flex-col items-center gap-4 bg-slate-50 dark:bg-slate-800/50 p-6 rounded-3xl border border-dashed border-slate-200 dark:border-slate-700">
<div className="relative group">
{formState.avatar ? (
<img src={formState.avatar} alt="Preview" className="w-32 h-32 rounded-[2rem] object-cover shadow-xl" />
) : (
<div className="w-32 h-32 rounded-[2rem] bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-400">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
)}
<label className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-[2rem] opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity font-bold text-xs uppercase">
Ändern
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} />
</label>
</div>
<p className="text-[10px] text-slate-400 uppercase font-bold tracking-widest">Foto hochladen</p>
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Name</label>
<input required type="text" value={formState.name} onChange={e => setFormState({...formState, name: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Rolle</label>
<input required type="text" value={formState.role} onChange={e => setFormState({...formState, role: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">E-Mail</label>
<input type="email" value={formState.email} onChange={e => setFormState({...formState, email: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
</div>
<div>
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Telefon</label>
<input type="text" value={formState.phone} onChange={e => setFormState({...formState, phone: e.target.value})} className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white" />
</div>
{/* Tags Input Section */}
<div className="md:col-span-2 pt-4">
<label className="block text-xs font-bold uppercase text-slate-400 mb-3 tracking-widest">Themenbereiche & Expertisen (Tags)</label>
<div className="flex flex-wrap gap-2 mb-3">
{formState.tags.map(tag => (
<span key={tag} className="flex items-center gap-2 px-3 py-1.5 bg-brand-500 text-white rounded-xl text-xs font-bold shadow-md shadow-brand-500/10">
{tag}
<button type="button" onClick={() => removeTag(tag)} className="hover:text-slate-200 transition-colors">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddTag(e); }}
placeholder="Tag eingeben (z.B. Cloud Computing)..."
className="flex-1 px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white text-sm"
/>
<button
type="button"
onClick={handleAddTag}
className="px-6 bg-brand-500 text-white rounded-2xl font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-600 transition-all"
>
Hinzufügen
</button>
</div>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 tracking-widest">Kurz-Bio / Informationen</label>
<textarea
value={formState.bio}
onChange={e => setFormState({...formState, bio: e.target.value})}
rows={4}
placeholder="Wichtige Skills, Projekterfahrung oder Hintergrund..."
className="w-full px-5 py-4 bg-slate-50 dark:bg-slate-800 border-none rounded-2xl outline-none focus:ring-2 focus:ring-brand-500 transition-all dark:text-white resize-none"
/>
</div>
</div>
<div className="flex gap-4 pt-4">
<button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-4 bg-slate-100 dark:bg-slate-800 text-slate-500 font-bold rounded-2xl hover:bg-slate-200 transition-all">Abbrechen</button>
<button type="submit" className="flex-[2] py-4 bg-brand-600 text-white font-bold rounded-2xl shadow-xl shadow-brand-500/20 hover:bg-brand-700 transition-all">Speichern</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{expertToDeleteId && (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-sm rounded-[2rem] p-8 text-center shadow-2xl border border-red-100 dark:border-red-900/30">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/20 text-red-600 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<h3 className="text-2xl font-bold mb-2 dark:text-white">Experten löschen?</h3>
<p className="text-slate-500 mb-8 text-sm">Möchten Sie diesen Experten wirklich unwiderruflich aus der Datenbank löschen?</p>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => setExpertToDeleteId(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-bold text-slate-600 dark:text-slate-300">Abbruch</button>
<button onClick={confirmDeleteExpert} className="py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-500/20 hover:bg-red-700 transition-all">Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
interface HeaderProps {
title: string;
isDarkMode: boolean;
onToggleDarkMode: () => void;
onToggleSidebar: () => void;
}
export const Header: React.FC<HeaderProps> = ({ title, isDarkMode, onToggleDarkMode, onToggleSidebar }) => {
return (
<header className="h-20 border-b border-slate-300 dark:border-slate-800 bg-white/95 dark:bg-slate-950/80 backdrop-blur-md flex items-center justify-between px-8 sticky top-0 z-10 shadow-soft">
<div className="flex items-center gap-6">
<button
onClick={onToggleSidebar}
className="p-3 hover:bg-slate-100 dark:hover:bg-slate-900 rounded-xl transition-colors text-slate-500 dark:text-slate-400 border border-transparent hover:border-slate-200"
title="Menü umschalten"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 6h16M4 12h16m-7 6h7" />
</svg>
</button>
<h1 className="text-2xl font-black text-slate-900 dark:text-white tracking-tight">{title}</h1>
</div>
<div className="flex items-center gap-6">
<div className="relative hidden md:block">
<input
type="text"
placeholder="Suchen..."
className="pl-12 pr-6 py-2.5 bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-full w-80 text-sm font-medium focus:ring-4 focus:ring-brand-500/10 focus:border-brand-500 transition-all outline-none"
/>
<svg className="w-5 h-5 absolute left-4 top-2.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button
onClick={onToggleDarkMode}
className="p-2.5 rounded-full hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors border border-transparent hover:border-slate-200"
title={isDarkMode ? 'Light Mode' : 'Dark Mode'}
>
{isDarkMode ? (
<svg className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" /></svg>
) : (
<svg className="w-5 h-5 text-slate-500" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" /></svg>
)}
</button>
<div className="relative">
<div className="w-2.5 h-2.5 bg-red-500 rounded-full absolute right-1 top-1 ring-4 ring-white dark:ring-slate-950 animate-pulse"></div>
<button className="p-2.5 hover:bg-slate-100 dark:hover:bg-slate-900 rounded-xl border border-transparent hover:border-slate-200">
<svg className="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
</button>
</div>
</div>
</header>
);
};
+209
View File
@@ -0,0 +1,209 @@
import React, { useState } from 'react';
import { KanbanColumn, Tender } from '../types';
import { supabase, TABLES } from '../lib/supabase';
const columns: KanbanColumn[] = ['Ausschreibung', 'Experten sichten', 'Interview', 'Kunden Interview', 'Vertragsgestaltung', 'Abschluss', 'Geschlossen'];
interface KanbanBoardProps {
tenders: Tender[];
setTenders: React.Dispatch<React.SetStateAction<Tender[]>>;
refreshData: () => Promise<void>;
}
export const KanbanBoard: React.FC<KanbanBoardProps> = ({ tenders, setTenders, refreshData }) => {
const [draggedTenderId, setDraggedTenderId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState<string | null>(null);
const activePipelineTenders = tenders.filter(t => t.status === 'Aktiv');
const updateTenderColumn = async (tenderId: string, newCol: KanbanColumn) => {
setTenders(prev => prev.map(t => t.id === tenderId ? { ...t, kanbanStatus: newCol } : t));
await supabase.from(TABLES.TENDERS).update({ kanban_status: newCol }).eq('id', tenderId);
};
const moveTender = (tenderId: string, direction: 'next' | 'prev') => {
const tender = tenders.find(t => t.id === tenderId);
if (!tender) return;
const currentIndex = columns.indexOf(tender.kanbanStatus);
let nextIndex = currentIndex + (direction === 'next' ? 1 : -1);
nextIndex = Math.max(0, Math.min(columns.length - 1, nextIndex));
updateTenderColumn(tenderId, columns[nextIndex]);
};
const handleDeactivateTender = async (tender: Tender) => {
if (isProcessing) return;
setIsProcessing(tender.id);
try {
const { error } = await supabase
.from(TABLES.TENDERS)
.update({ status: 'Inaktiv' })
.eq('id', tender.id);
if (error) {
if (error.code === '23514') {
throw new Error('Die Datenbank erlaubt den Status "Inaktiv" noch nicht. Bitte führen Sie das SQL-Update für die Constraints aus.');
}
throw error;
}
await refreshData();
} catch (error: any) {
console.error('Deactivation Error:', error);
alert('Lösch-Fehler: ' + error.message);
} finally {
setIsProcessing(null);
}
};
const handleCompleteTender = async (tender: Tender) => {
if (isProcessing) return;
setIsProcessing(tender.id);
try {
const isoDate = new Date().toISOString().split('T')[0];
const customerName = tender.selectedCustomer?.company || 'Nicht angegeben';
const contactPerson = tender.selectedCustomer?.name || 'Unbekannt';
// Multi-Expert Support: Map all selected experts
const expertNames = tender.selectedExperts && tender.selectedExperts.length > 0
? tender.selectedExperts.map(e => e.name).join(', ')
: 'Kein Experte zugewiesen';
const structuredNotes = [
`KUNDE: ${customerName}`,
`KONTAKT: ${contactPerson}`,
`EXPERTE: ${expertNames}`,
`START: ${isoDate}`,
`ENDE: TBD`,
`---`,
tender.notes || ''
].join('\n');
const { error: insertError } = await supabase.from(TABLES.PROJECTS).insert([{
title: tender.title,
status: 'Aktiv',
notes: structuredNotes
}]);
if (insertError) throw insertError;
const { error: updateError } = await supabase
.from(TABLES.TENDERS)
.update({ status: 'Archiviert', kanban_status: 'Geschlossen' })
.eq('id', tender.id);
if (updateError) throw updateError;
await refreshData();
} catch (error: any) {
console.error('Full Process Error:', error);
alert('Fehler beim Projektabschluss: ' + error.message);
} finally {
setIsProcessing(null);
}
};
const handleDragStart = (e: React.DragEvent, tenderId: string) => {
setDraggedTenderId(tenderId);
e.dataTransfer.setData('tenderId', tenderId);
};
const handleDragEnd = () => {
setDraggedTenderId(null);
setDragOverColumn(null);
};
const handleDragOver = (e: React.DragEvent, col: string) => {
e.preventDefault();
setDragOverColumn(col);
};
const handleDrop = (e: React.DragEvent, targetCol: KanbanColumn) => {
e.preventDefault();
const tenderId = e.dataTransfer.getData('tenderId');
if (tenderId) updateTenderColumn(tenderId, targetCol);
setDragOverColumn(null);
setDraggedTenderId(null);
};
return (
<div className="h-full flex flex-col">
<div className="mb-8">
<h2 className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">Vermittlungspipeline</h2>
<p className="text-slate-500 font-medium">Aktive Leads in der Akquise</p>
</div>
<div className="flex-1 overflow-x-auto pb-10">
<div className="flex gap-6 h-full min-w-max">
{columns.map((col) => (
<div
key={col}
onDragOver={(e) => handleDragOver(e, col)}
onDrop={(e) => handleDrop(e, col as KanbanColumn)}
className={`w-80 flex flex-col rounded-[2.5rem] p-5 border transition-all ${
dragOverColumn === col
? 'bg-brand-50/70 dark:bg-brand-500/10 border-brand-500 ring-2 ring-brand-500/20'
: 'bg-slate-200/40 dark:bg-slate-900/40 border-slate-300/50 dark:border-slate-800'
}`}
>
<div className="flex items-center justify-between mb-5 px-2">
<h3 className="font-bold text-[11px] uppercase tracking-[0.2em] text-slate-600 dark:text-slate-500">{col}</h3>
<span className="text-xs font-extrabold bg-white dark:bg-slate-800 px-3 py-1 rounded-full shadow-soft border border-slate-300 dark:border-slate-700">
{activePipelineTenders.filter(t => t.kanbanStatus === col).length}
</span>
</div>
<div className="flex-1 space-y-5 overflow-y-auto pr-1 min-h-[150px]">
{activePipelineTenders.filter(t => t.kanbanStatus === col).map((tender) => (
<div
key={tender.id}
draggable="true"
onDragStart={(e) => handleDragStart(e, tender.id)}
onDragEnd={handleDragEnd}
className="bg-white dark:bg-slate-800 p-5 rounded-[1.5rem] shadow-heavy border border-slate-300 dark:border-slate-700 hover:border-brand-500 hover:shadow-2xl transition-all group relative cursor-grab active:cursor-grabbing scale-100 hover:scale-[1.02]"
>
<div className="flex justify-between items-start mb-3">
<h4 className="font-extrabold text-sm text-slate-900 dark:text-slate-100 group-hover:text-brand-600 transition-colors leading-snug">{tender.title}</h4>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => moveTender(tender.id, 'prev')} disabled={col === columns[0]} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-20"><svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 19l-7-7 7-7" /></svg></button>
<button onClick={() => moveTender(tender.id, 'next')} disabled={col === columns[columns.length - 1]} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-20"><svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" /></svg></button>
</div>
</div>
<div className="text-[11px] text-slate-500 font-bold mb-4 flex items-center gap-1.5 line-clamp-1">
<span className="text-brand-600 dark:text-brand-400">{tender.selectedCustomer?.company || 'Lead'}</span>
<span className="opacity-30">•</span>
<span>{tender.selectedExperts?.[0]?.name || 'Planung'}</span>
</div>
<div className="flex items-center justify-between border-t border-slate-100 dark:border-slate-700 pt-4 mt-2">
<span className="px-2 py-0.5 rounded-md bg-brand-50 dark:bg-brand-500/10 text-[9px] font-extrabold uppercase tracking-widest text-brand-600 dark:text-brand-400 border border-brand-100 dark:border-brand-500/20">{tender.status}</span>
<span className="text-[9px] font-bold uppercase tracking-wider text-slate-400">{tender.source}</span>
</div>
{col === 'Abschluss' && (
<div className="absolute inset-0 bg-white/95 dark:bg-slate-900/90 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center rounded-[1.5rem] border-2 border-brand-500/50">
<button onClick={() => handleCompleteTender(tender)} className="bg-brand-600 text-white p-4 rounded-2xl shadow-xl-brand hover:scale-110 active:scale-95 transition-all">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>
</button>
</div>
)}
{col === 'Geschlossen' && (
<div className="absolute inset-0 bg-white/95 dark:bg-slate-900/90 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center rounded-[1.5rem] border-2 border-red-500/50">
<button onClick={() => handleDeactivateTender(tender)} className="bg-red-600 text-white p-4 rounded-2xl shadow-lg shadow-red-500/20 hover:scale-110 active:scale-95 transition-all">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
};
+359
View File
@@ -0,0 +1,359 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Customer, Project, Expert } from '../types';
interface OrgChartProps {
customers: Customer[];
projects: Project[];
experts: Expert[];
}
type Mode = 'Customer' | 'Project';
export const OrgChart: React.FC<OrgChartProps> = ({ customers, projects, experts }) => {
const [mode, setMode] = useState<Mode>('Customer');
const [selectedId, setSelectedId] = useState<string | null>(null);
// Navigation State
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const canvasRef = useRef<HTMLDivElement>(null);
const getRelatedData = useMemo(() => {
if (!selectedId) return null;
if (mode === 'Customer') {
const customer = customers.find(c => c.id === selectedId);
if (!customer) return null;
const companyProjects = projects.filter(p =>
p.customer === customer.company ||
(p.notes && p.notes.includes(`KUNDE: ${customer.company}`))
);
const relatedExperts = experts.filter(e =>
companyProjects.some(p => p.expert && p.expert.includes(e.name) || (p.notes && p.notes.includes(`EXPERTE: ${e.name}`)))
);
return { root: customer, contacts: customer.contacts || [], projects: companyProjects, experts: relatedExperts };
} else {
const project = projects.find(p => p.id === selectedId);
if (!project) return null;
const customerMatch = customers.find(c =>
c.company === project.customer ||
(project.notes && project.notes.includes(`KUNDE: ${c.company}`))
);
// Multi-Expert Support for Projects
const expertNames = (project.expert || '').split(', ').map(s => s.trim()).filter(Boolean);
const relatedExperts = experts.filter(e =>
expertNames.includes(e.name) ||
(project.notes && project.notes.includes(`EXPERTE: ${e.name}`))
);
return { root: project, customer: customerMatch, experts: relatedExperts, contacts: customerMatch?.contacts || [] };
}
}, [mode, selectedId, customers, projects, experts]);
// Native Wheel Listener for preventDefault support (necessary for Zoom override)
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
// Prevent page scrolling/zooming while interacting with the canvas
e.preventDefault();
if (e.ctrlKey) {
// ZOOM MODE
const zoomSpeed = 0.001;
setZoom(prevZoom => {
const newZoom = prevZoom - e.deltaY * zoomSpeed;
return Math.min(Math.max(newZoom, 0.2), 2.5);
});
} else if (e.shiftKey) {
// HORIZONTAL SCROLL
setOffset(prev => ({ ...prev, x: prev.x - e.deltaY }));
} else {
// VERTICAL SCROLL (Default)
setOffset(prev => ({ ...prev, y: prev.y - e.deltaY }));
}
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return () => canvas.removeEventListener('wheel', onWheel);
}, []);
// Mouse Handlers (Panning via Click & Drag)
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return; // Only left click
setIsDragging(true);
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return;
setOffset({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
});
};
const handleMouseUp = () => setIsDragging(false);
const resetView = () => {
setOffset({ x: 0, y: 0 });
setZoom(1);
};
const handleSelect = (id: string) => {
setSelectedId(id);
resetView();
};
const ConnectionLines = () => {
const color = mode === 'Customer' ? '#22c55e' : '#3b82f6';
return (
<div className="absolute inset-0 w-full h-full pointer-events-none z-0">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="w-full h-full overflow-visible"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter id="glowLine" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<linearGradient id="lineGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="60%" stopColor={color} stopOpacity="0.8" />
<stop offset="100%" stopColor={color} stopOpacity="0.1" />
</linearGradient>
</defs>
<g filter="url(#glowLine)">
<path
d="M 50 15 C 50 25, 15 25, 15 45"
stroke="url(#lineGrad)"
strokeWidth="1.5"
fill="none"
strokeDasharray="4 2"
className="animate-[flow_4s_linear_infinite]"
/>
<path
d="M 50 15 C 50 25, 85 25, 85 45"
stroke="url(#lineGrad)"
strokeWidth="1.5"
fill="none"
strokeDasharray="4 2"
className="animate-[flow_4s_linear_infinite]"
/>
<circle cx="50" cy="15" r="2" fill={color} className="animate-pulse shadow-lg" />
</g>
</svg>
<style>{`
@keyframes flow {
from { stroke-dashoffset: 40; }
to { stroke-dashoffset: 0; }
}
`}</style>
</div>
);
};
return (
<div className="h-full flex flex-col gap-8 animate-in fade-in duration-500 overflow-hidden">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0 px-2">
<div>
<h2 className="text-3xl font-black text-slate-900 dark:text-white tracking-tight">Relations Engine</h2>
<p className="text-slate-500 font-medium">Das ICON Beziehungs-Netzwerk</p>
</div>
<div className="flex bg-slate-200 dark:bg-slate-900 p-1.5 rounded-[1.5rem] border-2 border-slate-300 dark:border-slate-800 shadow-inner">
<button
onClick={() => { setMode('Customer'); setSelectedId(null); resetView(); }}
className={`px-8 py-3 rounded-[1rem] font-bold text-xs uppercase tracking-widest transition-all ${mode === 'Customer' ? 'bg-brand-500 text-white shadow-lg' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'}`}
>
Kunden-Hub
</button>
<button
onClick={() => { setMode('Project'); setSelectedId(null); resetView(); }}
className={`px-8 py-3 rounded-[1rem] font-bold text-xs uppercase tracking-widest transition-all ${mode === 'Project' ? 'bg-blue-500 text-white shadow-lg' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'}`}
>
Projekt-Hub
</button>
</div>
</div>
<div className="flex-1 flex flex-col lg:flex-row gap-8 overflow-hidden min-h-0">
{/* Sidebar */}
<div className="w-full lg:w-72 bg-white dark:bg-slate-900 rounded-[2rem] border-2 border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col shadow-card shrink-0">
<div className="p-5 border-b-2 border-slate-100 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/30">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">
{mode === 'Customer' ? 'Daten: Kunden' : 'Daten: Projekte'}
</h3>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800">
{(mode === 'Customer' ? customers : projects).map((item: any) => (
<div
key={item.id}
onClick={() => handleSelect(item.id)}
className={`p-4 cursor-pointer transition-all hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-4 ${selectedId === item.id ? 'bg-brand-50/50 dark:bg-brand-500/10' : ''}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center font-bold text-lg shrink-0 ${mode === 'Customer' ? 'bg-brand-500/10 text-brand-600' : 'bg-blue-500/10 text-blue-600'}`}>
{(item.company || item.title).charAt(0)}
</div>
<div className="truncate">
<p className="font-extrabold text-sm truncate dark:text-white">{item.company || item.title}</p>
</div>
</div>
))}
</div>
</div>
{/* Graph Area */}
<div
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className={`flex-1 bg-slate-100/30 dark:bg-slate-950/20 rounded-[4rem] border-4 border-dashed border-slate-200 dark:border-slate-800 relative overflow-hidden p-8 select-none transition-colors ${isDragging ? 'cursor-grabbing border-brand-500/50 bg-slate-100/50 dark:bg-slate-950/40' : 'cursor-grab hover:border-slate-300 dark:hover:border-slate-700'}`}
>
{selectedId && getRelatedData ? (
<div
className="w-full h-full relative"
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: 'center center',
transition: isDragging ? 'none' : 'transform 0.15s cubic-bezier(0.2, 0, 0.2, 1)'
}}
>
<div className="absolute inset-0 w-full h-[800px] min-w-[1200px]">
<ConnectionLines />
</div>
<div className="w-full relative z-10 py-10">
<div className="flex justify-center mb-48">
<div className={`p-8 rounded-[3rem] border-4 shadow-heavy flex flex-col items-center text-center w-72 animate-in zoom-in-95 duration-500 ${mode === 'Customer' ? 'bg-brand-500 text-white border-white dark:border-slate-800' : 'bg-blue-600 text-white border-white dark:border-slate-800'}`}>
<div className="w-20 h-20 bg-white/20 rounded-3xl flex items-center justify-center mb-4 text-4xl font-black">
{(getRelatedData.root as any).company?.charAt(0) || (getRelatedData.root as any).title?.charAt(0)}
</div>
<h3 className="text-2xl font-black tracking-tight leading-tight">{(getRelatedData.root as any).company || (getRelatedData.root as any).title}</h3>
<p className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-80 mt-2">{mode === 'Customer' ? 'UNTERNEHMEN' : 'AKTIVES PROJEKT'}</p>
</div>
</div>
<div className="flex flex-row justify-between w-full max-w-7xl mx-auto items-start px-4 gap-12">
<div className="w-[380px] space-y-4 animate-in slide-in-from-left-24 duration-1000">
<div className="flex items-center gap-3 mb-6 bg-white/80 dark:bg-slate-900/80 px-6 py-2 rounded-full border border-slate-200 dark:border-slate-800 shadow-sm">
<span className={`w-2 h-2 rounded-full ${mode === 'Customer' ? 'bg-brand-500' : 'bg-blue-500'} animate-pulse`}></span>
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{mode === 'Customer' ? 'Interne Kontakte' : 'Auftraggeber'}</h4>
</div>
{mode === 'Customer' ? (
<div className="space-y-4">
{getRelatedData.contacts.length > 0 ? getRelatedData.contacts.map((c: any) => (
<div key={c.id} className="bg-white dark:bg-slate-900 p-5 rounded-[2rem] border-2 border-slate-100 dark:border-slate-800 shadow-card flex items-center gap-4 hover:border-brand-500 transition-all">
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-sm font-black text-slate-400 uppercase">{c.name.charAt(0)}</div>
<div className="truncate text-left">
<p className="font-extrabold text-sm dark:text-white truncate">{c.name}</p>
<p className="text-[10px] text-brand-600 font-black uppercase tracking-wider">{c.role}</p>
</div>
</div>
)) : <div className="text-center py-12 bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Daten</div>}
</div>
) : (
getRelatedData.customer && (
<div className="bg-white dark:bg-slate-900 p-8 rounded-[3rem] border-2 border-brand-500 shadow-xl-brand flex flex-col items-center text-center">
<div className="w-16 h-16 bg-brand-50 dark:bg-brand-900/20 rounded-2xl flex items-center justify-center mb-4 text-3xl font-black text-brand-600">
{getRelatedData.customer.company.charAt(0)}
</div>
<p className="font-black text-base dark:text-white">{getRelatedData.customer.company}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase mt-2 tracking-widest">Zugeordneter Kunde</p>
</div>
)
)}
</div>
<div className="w-[380px] space-y-4 animate-in slide-in-from-right-24 duration-1000">
<div className="flex items-center gap-3 mb-6 bg-white/80 dark:bg-slate-900/80 px-6 py-2 rounded-full border border-slate-200 dark:border-slate-800 shadow-sm">
<span className={`w-2 h-2 rounded-full ${mode === 'Customer' ? 'bg-brand-500' : 'bg-blue-500'} animate-pulse`}></span>
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{mode === 'Customer' ? 'Laufende Beauftragungen' : 'Expertise'}</h4>
</div>
{mode === 'Customer' ? (
<div className="space-y-6">
{getRelatedData.projects.length > 0 ? getRelatedData.projects.map((p: any) => (
<div key={p.id} className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-[2.5rem] border-2 border-blue-200 dark:border-blue-800 shadow-heavy text-left relative overflow-hidden group">
<div className="absolute top-0 right-0 w-16 h-16 bg-blue-500/5 -mr-8 -mt-8 rounded-full"></div>
<p className="font-black text-sm dark:text-blue-100 mb-5">{p.title}</p>
<div className="space-y-3">
<p className="text-[9px] font-black text-blue-400 uppercase tracking-widest ml-1">Eingesetzte Experten</p>
{experts.filter(e => p.expert && p.expert.includes(e.name) || (p.notes && p.notes.includes(`EXPERTE: ${e.name}`))).map(e => (
<div key={e.id} className="bg-white dark:bg-slate-900 p-3 rounded-2xl border border-blue-100 dark:border-slate-800 flex items-center gap-3 shadow-sm">
<div className="w-8 h-8 rounded-xl bg-brand-500 text-white flex items-center justify-center text-[10px] font-black">{e.name.charAt(0)}</div>
<p className="font-bold text-xs dark:text-white truncate">{e.name}</p>
</div>
))}
</div>
</div>
)) : <div className="text-center py-12 bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Projekte</div>}
</div>
) : (
<div className="space-y-4">
{getRelatedData.experts && getRelatedData.experts.length > 0 ? getRelatedData.experts.map((expert: Expert) => (
<div key={expert.id} className="bg-white dark:bg-slate-900 p-6 rounded-[2rem] border-2 border-blue-500 shadow-heavy flex items-center gap-5 hover:scale-[1.02] transition-transform">
<div className="relative shrink-0">
<div className="w-16 h-16 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center overflow-hidden border-2 border-slate-200 dark:border-slate-700 shadow-md">
{expert.avatar ? <img src={expert.avatar} className="w-full h-full object-cover" /> : <span className="text-2xl font-black text-slate-300">{expert.name.charAt(0)}</span>}
</div>
<div className="absolute -bottom-1 -right-1 bg-brand-500 text-white p-1 rounded-lg shadow-md border-2 border-white dark:border-slate-900">
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
</div>
</div>
<div className="text-left truncate">
<p className="font-black text-base dark:text-white leading-tight truncate">{expert.name}</p>
<p className="text-[10px] font-black text-blue-600 uppercase mt-1 tracking-widest truncate">{expert.role}</p>
</div>
</div>
)) : (
<div className="py-12 text-center bg-white/50 dark:bg-slate-900/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 text-slate-400 italic text-xs">Keine Experten zugewiesen</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div className="h-full w-full flex flex-col items-center justify-center animate-pulse">
<div className="w-24 h-24 bg-white dark:bg-slate-900 rounded-[2rem] flex items-center justify-center mb-8 text-slate-200 dark:text-slate-800 shadow-lg border-2 border-slate-100 dark:border-slate-800">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<h3 className="text-2xl font-black dark:text-white mb-2 uppercase tracking-tighter">Bereit zur Analyse</h3>
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest opacity-60 text-center">Wählen Sie ein Asset aus der Liste links aus.<br/>Rad: Scrollen | Strg + Rad: Zoom | Shift + Rad: Horiz. Scroll</p>
</div>
)}
{/* Canvas Controls */}
{selectedId && (
<div className="absolute bottom-8 right-8 flex flex-col gap-3 z-50">
<button
onClick={resetView}
title="Ansicht & Zoom zurücksetzen"
className="p-4 bg-white dark:bg-slate-800 text-slate-500 hover:text-brand-500 rounded-2xl shadow-heavy border border-slate-100 dark:border-slate-700 transition-all hover:scale-110 active:scale-95 flex items-center justify-center"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
<div className="p-3 bg-slate-900/10 dark:bg-white/10 rounded-xl backdrop-blur-md flex flex-col items-center gap-1 min-w-[100px]">
<p className="text-[9px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">DRAG / SCROLL</p>
<p className="text-[10px] font-bold text-brand-600 dark:text-brand-400 tracking-widest">{Math.round(zoom * 100)}% ZOOM</p>
</div>
</div>
)}
</div>
</div>
</div>
);
};
+495
View File
@@ -0,0 +1,495 @@
import React, { useState } from 'react';
import { Project, Customer, Expert } from '../types';
import { supabase, TABLES } from '../lib/supabase';
interface ProjectsProps {
projects: Project[];
setProjects: React.Dispatch<React.SetStateAction<Project[]>>;
customers: Customer[];
experts: Expert[];
refreshData: () => Promise<void>;
}
export const Projects: React.FC<ProjectsProps> = ({ projects, setProjects, customers, experts, refreshData }) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editModeId, setEditModeId] = useState<string | null>(null);
const [projectToDelete, setProjectToDelete] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Pickers
const [isCustomerPickerOpen, setIsCustomerPickerOpen] = useState(false);
const [isExpertPickerOpen, setIsExpertPickerOpen] = useState(false);
// Local state for editing
const [editForm, setEditForm] = useState<Partial<Project>>({});
const startEditing = (project: Project) => {
const customer = getDisplayInfo(project, 'KUNDE');
const expert = getDisplayInfo(project, 'EXPERTE');
const start = getDisplayInfo(project, 'START');
const end = getDisplayInfo(project, 'ENDE');
const formatDateForInput = (val: string) => {
if (!val || val === 'N/A' || val === 'TBD') return '';
if (val.includes('.')) {
const parts = val.split('.');
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
}
return val;
};
setEditForm({
...project,
customer: customer === 'N/A' ? '' : customer,
expert: expert === 'N/A' ? '' : expert,
start: formatDateForInput(start),
end: formatDateForInput(end),
});
setEditModeId(project.id);
};
const handleSaveProject = async () => {
if (!editModeId || isSaving) return;
setIsSaving(true);
try {
const baseNotes = editForm.notes?.split('---').pop()?.trim() || '';
const structuredNotes = [
`KUNDE: ${editForm.customer || 'Nicht angegeben'}`,
`KONTAKT: ${getDisplayInfo(projects.find(p => p.id === editModeId)!, 'KONTAKT')}`,
`EXPERTE: ${editForm.expert || 'Kein Experten zugewiesen'}`,
`START: ${editForm.start || 'N/A'}`,
`ENDE: ${editForm.end || 'TBD'}`,
`---`,
baseNotes
].join('\n');
const updatePayload = {
title: editForm.title,
customer: editForm.customer,
expert: editForm.expert,
start: editForm.start,
end: editForm.end,
status: editForm.status,
notes: structuredNotes
};
const { error } = await supabase
.from(TABLES.PROJECTS)
.update(updatePayload)
.eq('id', editModeId);
if (error) {
console.warn('Full update failed, trying safe mode (notes only):', error.message);
await supabase
.from(TABLES.PROJECTS)
.update({
title: editForm.title,
status: editForm.status,
notes: structuredNotes
})
.eq('id', editModeId);
}
setEditModeId(null);
await refreshData();
} catch (err) {
console.error('Save error:', err);
} finally {
setIsSaving(false);
}
};
const handleUpdateNotesOnly = async (id: string, newNotes: string) => {
setProjects(prev => prev.map(p => p.id === id ? { ...p, notes: newNotes } : p));
await supabase.from(TABLES.PROJECTS).update({ notes: newNotes }).eq('id', id);
};
const confirmDelete = async () => {
if (projectToDelete) {
await supabase.from(TABLES.PROJECTS).delete().eq('id', projectToDelete);
if (expandedId === projectToDelete) setExpandedId(null);
setProjectToDelete(null);
refreshData();
}
};
const getDisplayInfo = (project: Project, type: 'KUNDE' | 'EXPERTE' | 'KONTAKT' | 'START' | 'ENDE') => {
const key = type.toLowerCase();
const value = (project as any)[key];
if (value && value !== 'Nicht angegeben' && value !== 'Kein Experte zugewiesen' && value !== 'TBD' && value !== 'N/A') return value;
if (project.notes) {
const lines = project.notes.split('\n');
const prefix = `${type}: `;
const line = lines.find(l => l.startsWith(prefix));
if (line) return line.replace(prefix, '').trim();
}
if (type === 'START') return project.start || 'N/A';
if (type === 'ENDE') return project.end || 'TBD';
return 'N/A';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Laufende Projekte</h2>
<p className="text-slate-500">Übersicht aller aktiven Beauftragungen</p>
</div>
<button className="bg-brand-600 hover:bg-brand-700 text-white px-5 py-2.5 rounded-xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
Projekt anlegen
</button>
</div>
<div className="bg-white dark:bg-slate-900 rounded-[2.5rem] overflow-hidden border border-slate-100 dark:border-slate-800 shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[900px]">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/50">
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Projekt</th>
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Kunde</th>
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Experten</th>
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Laufzeit</th>
<th className="p-5 font-bold text-slate-500 uppercase text-xs tracking-wider">Status</th>
<th className="p-5 w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{projects.map((project) => {
const customerName = getDisplayInfo(project, 'KUNDE');
const expertNamesString = getDisplayInfo(project, 'EXPERTE');
const expertNames = expertNamesString.split(', ').filter(n => n && n !== 'N/A' && n !== 'Kein Experte zugewiesen');
const start = getDisplayInfo(project, 'START');
const end = getDisplayInfo(project, 'ENDE');
return (
<React.Fragment key={project.id}>
<tr
onClick={() => {
if (editModeId !== project.id) {
setExpandedId(expandedId === project.id ? null : project.id);
}
}}
className={`hover:bg-slate-50 dark:hover:bg-slate-800 transition-all cursor-pointer group ${expandedId === project.id ? 'bg-brand-50/30 dark:bg-brand-500/5' : ''}`}
>
<td className="p-5 font-bold text-slate-900 dark:text-white group-hover:text-brand-600 transition-colors">
{project.title}
</td>
<td className="p-5 text-slate-700 dark:text-slate-300 font-medium">
{customerName}
</td>
<td className="p-5">
<div className="flex items-center -space-x-3">
{expertNames.length > 0 ? (
expertNames.slice(0, 3).map((name, i) => (
<div
key={i}
title={name}
className="w-10 h-10 rounded-full bg-brand-500 text-white border-2 border-white dark:border-slate-800 flex items-center justify-center text-xs font-bold shadow-sm"
>
{name.charAt(0)}
</div>
))
) : (
<span className="text-xs text-slate-400 italic">Keine Experten</span>
)}
{expertNames.length > 3 && (
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-500 border-2 border-white dark:border-slate-800 flex items-center justify-center text-[10px] font-bold shadow-sm">
+{expertNames.length - 3}
</div>
)}
</div>
</td>
<td className="p-5 text-xs font-mono text-slate-600 dark:text-slate-400">
<div className="bg-slate-100 dark:bg-slate-800 px-3 py-1.5 rounded-lg inline-block">
{start} - {end}
</div>
</td>
<td className="p-5">
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${
project.status === 'Aktiv'
? 'bg-brand-500 text-white shadow-sm shadow-brand-500/20'
: 'bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400'
}`}>
{project.status}
</span>
</td>
<td className="p-5 flex items-center justify-end gap-1">
<button
onClick={(e) => { e.stopPropagation(); setProjectToDelete(project.id); }}
className="p-2 text-slate-300 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</td>
</tr>
{expandedId === project.id && (
<tr className="animate-in fade-in slide-in-from-top-2 duration-300">
<td colSpan={6} className="p-8 bg-slate-50/50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-1 space-y-6">
<div className="bg-white dark:bg-slate-800 p-6 rounded-[2rem] border border-slate-100 dark:border-slate-700 shadow-sm relative overflow-hidden">
<div className="flex justify-between items-center mb-6">
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-widest flex items-center gap-2">
<span className="w-1.5 h-4 bg-brand-500 rounded-full"></span>
{editModeId === project.id ? 'Bearbeiten' : 'Details'}
</h4>
{editModeId !== project.id ? (
<button
onClick={() => startEditing(project)}
className="text-xs font-bold text-brand-600 hover:text-brand-700 underline"
>
Bearbeiten
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => setEditModeId(null)}
className="text-[10px] font-bold text-slate-400 uppercase tracking-widest hover:text-slate-600"
>
Abbruch
</button>
</div>
)}
</div>
{editModeId === project.id ? (
<div className="space-y-4 animate-in fade-in duration-200">
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Titel</label>
<input
type="text"
value={editForm.title || ''}
onChange={e => setEditForm({...editForm, title: e.target.value})}
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Beginn</label>
<input
type="date"
value={editForm.start || ''}
onChange={e => setEditForm({...editForm, start: e.target.value})}
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white"
/>
</div>
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Ende</label>
<input
type="date"
value={editForm.end || ''}
onChange={e => setEditForm({...editForm, end: e.target.value})}
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white"
/>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Kunde</label>
<div className="relative group/field">
<input
type="text"
readOnly
value={editForm.customer || ''}
onClick={() => setIsCustomerPickerOpen(true)}
placeholder="Kunden auswählen..."
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white cursor-pointer hover:border-brand-500 transition-colors"
/>
<svg className="w-4 h-4 absolute right-3 top-3 text-slate-400 group-hover/field:text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">Experten ({editForm.expert ? editForm.expert.split(', ').length : 0})</label>
<div className="relative group/field">
<input
type="text"
readOnly
value={editForm.expert || ''}
onClick={() => setIsExpertPickerOpen(true)}
placeholder="Experten auswählen..."
className="w-full p-2.5 text-sm bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg outline-none focus:ring-2 focus:ring-brand-500/20 text-slate-900 dark:text-white cursor-pointer hover:border-brand-500 transition-colors truncate"
/>
<svg className="w-4 h-4 absolute right-3 top-3 text-slate-400 group-hover/field:text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
<button
onClick={handleSaveProject}
disabled={isSaving}
className="w-full py-3 bg-brand-600 text-white rounded-xl font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-700 transition-all flex items-center justify-center gap-2 mt-2"
>
{isSaving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : 'Änderungen speichern'}
</button>
</div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center py-2 border-b border-slate-50 dark:border-slate-700/50">
<span className="text-slate-500 text-[10px] font-bold uppercase">Beginn</span>
<span className="font-mono text-xs dark:text-white">{start}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-slate-50 dark:border-slate-700/50">
<span className="text-slate-500 text-[10px] font-bold uppercase">Geplantes Ende</span>
<span className="font-mono text-xs dark:text-white">{end}</span>
</div>
<div className="space-y-2 py-2 border-b border-slate-50 dark:border-slate-700/50">
<span className="text-slate-500 text-[10px] font-bold uppercase block mb-2">Eingesetzte Experten</span>
<div className="flex flex-wrap gap-1.5">
{expertNames.length > 0 ? expertNames.map((name, i) => (
<span key={i} className="px-2 py-1 bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 text-[10px] font-extrabold uppercase rounded-lg border border-brand-100 dark:border-brand-500/20">
{name}
</span>
)) : <span className="text-xs text-slate-400 italic">Keine Experten</span>}
</div>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-slate-500 text-[10px] font-bold uppercase">Kunde</span>
<span className="text-xs font-bold dark:text-white">{customerName}</span>
</div>
</div>
)}
</div>
<button className="w-full py-4 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-bold rounded-2xl hover:bg-slate-200 transition-all border border-transparent hover:border-slate-200 text-xs">
Vertragsdokumente verwalten
</button>
</div>
<div className="lg:col-span-2">
<div className="bg-white dark:bg-slate-800 p-6 rounded-[2rem] border border-slate-100 dark:border-slate-700 shadow-sm h-full flex flex-col">
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4 flex items-center gap-2">
<span className="w-1.5 h-4 bg-brand-500 rounded-full"></span>
Projektjournal & Protokoll
</h4>
<textarea
value={project.notes || ''}
onChange={(e) => handleUpdateNotesOnly(project.id, e.target.value)}
placeholder="Hier Meilensteine, Feedback oder Projektfortschritte festhalten..."
className="flex-1 w-full p-5 bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 rounded-2xl text-sm leading-relaxed text-slate-700 dark:text-slate-200 resize-none outline-none focus:ring-2 focus:ring-brand-500/20 transition-all min-h-[250px]"
/>
<div className="mt-3 flex items-center gap-2 text-[10px] text-slate-400 uppercase font-bold tracking-widest">
<div className="w-2 h-2 rounded-full bg-brand-500 animate-pulse"></div>
Änderungen werden live synchronisiert
</div>
</div>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
{/* Customer Picker Modal */}
{isCustomerPickerOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
<div className="bg-white dark:bg-slate-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl p-8 max-h-[80vh] flex flex-col border border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold dark:text-white">Kunden auswählen</h3>
<button onClick={() => setIsCustomerPickerOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="overflow-y-auto space-y-3 pr-2">
{customers.map(c => (
<div
key={c.id}
onClick={() => { setEditForm({...editForm, customer: c.company}); setIsCustomerPickerOpen(false); }}
className="p-5 rounded-3xl bg-slate-50 dark:bg-slate-800 border-2 border-transparent hover:border-brand-500 cursor-pointer transition-all group"
>
<p className="font-bold text-lg dark:text-white group-hover:text-brand-500 transition-colors">{c.company}</p>
<p className="text-xs text-slate-500 uppercase font-bold tracking-widest">{c.name}</p>
</div>
))}
{customers.length === 0 && <p className="text-center py-10 text-slate-500 italic">Keine Kunden gefunden.</p>}
</div>
</div>
</div>
)}
{/* Multi-Expert Picker Modal */}
{isExpertPickerOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
<div className="bg-white dark:bg-slate-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl p-8 max-h-[80vh] flex flex-col border border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold dark:text-white">Experten auswählen</h3>
<button onClick={() => setIsExpertPickerOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="overflow-y-auto space-y-3 pr-2 flex-1">
{experts.map(e => {
const currentExpertList = (editForm.expert || '').split(', ').filter(Boolean);
const isSelected = currentExpertList.includes(e.name);
return (
<div
key={e.id}
onClick={() => {
const newList = isSelected
? currentExpertList.filter(name => name !== e.name)
: [...currentExpertList, e.name];
setEditForm({...editForm, expert: newList.join(', ')});
}}
className={`p-5 rounded-3xl border-2 transition-all group flex items-center gap-4 cursor-pointer ${
isSelected ? 'border-brand-500 bg-brand-50 dark:bg-brand-500/10' : 'border-transparent bg-slate-50 dark:bg-slate-800 hover:border-slate-300'
}`}
>
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center font-bold text-lg uppercase transition-all ${
isSelected ? 'bg-brand-500 text-white' : 'bg-slate-200 dark:bg-slate-700 text-slate-500'
}`}>
{e.name.charAt(0)}
</div>
<div className="flex-1">
<p className={`font-bold text-lg transition-colors ${isSelected ? 'text-brand-700 dark:text-brand-400' : 'dark:text-white'}`}>{e.name}</p>
<p className="text-xs text-slate-500 uppercase font-bold tracking-widest">{e.role}</p>
</div>
{isSelected && (
<div className="w-6 h-6 bg-brand-500 text-white rounded-full flex items-center justify-center shadow-lg">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>
</div>
)}
</div>
);
})}
{experts.length === 0 && <p className="text-center py-10 text-slate-500 italic">Keine Experten gefunden.</p>}
</div>
<div className="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800 flex justify-between items-center gap-4">
<p className="text-xs font-bold text-slate-400">{(editForm.expert || '').split(', ').filter(Boolean).length} Experten gewählt</p>
<button
onClick={() => setIsExpertPickerOpen(false)}
className="px-8 py-4 bg-brand-600 text-white font-bold rounded-2xl shadow-xl shadow-brand-500/20 hover:bg-brand-700 transition-all"
>
Fertig
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{projectToDelete && (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-sm rounded-[2rem] p-8 text-center shadow-2xl border border-red-100 dark:border-red-900/30">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/20 text-red-600 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<h3 className="text-2xl font-bold mb-2 dark:text-white">Projekt löschen?</h3>
<p className="text-slate-500 mb-8 text-sm">Diese Aktion kann nicht rückgängig gemacht werden. Alle verknüpften Journal-Einträge gehen verloren.</p>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => setProjectToDelete(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-bold text-slate-600 dark:text-slate-300">Abbruch</button>
<button onClick={confirmDelete} className="py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-500/20 hover:bg-red-700 transition-all">Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};
+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
interface SettingsProps {
isDarkMode: boolean;
onToggleDarkMode: () => void;
}
export const Settings: React.FC<SettingsProps> = ({ isDarkMode, onToggleDarkMode }) => {
return (
<div className="max-w-2xl space-y-8">
<section>
<h3 className="text-xl font-bold mb-6">Erscheinungsbild</h3>
<div className="bg-white dark:bg-slate-900 rounded-3xl p-6 border border-slate-100 dark:border-slate-800 flex items-center justify-between">
<div>
<p className="font-bold">Dark Mode</p>
<p className="text-sm text-slate-500">Das Design der Anwendung umschalten</p>
</div>
<button
onClick={onToggleDarkMode}
className={`w-14 h-8 rounded-full transition-colors relative ${isDarkMode ? 'bg-brand-500' : 'bg-slate-200 dark:bg-slate-800'}`}
>
<div className={`absolute top-1 w-6 h-6 rounded-full bg-white shadow-sm transition-all ${isDarkMode ? 'left-7' : 'left-1'}`}></div>
</button>
</div>
</section>
<section>
<h3 className="text-xl font-bold mb-6">CRM-Konfiguration</h3>
<div className="bg-white dark:bg-slate-900 rounded-3xl p-6 border border-slate-100 dark:border-slate-800 space-y-6">
<div className="flex items-center justify-between py-2">
<div>
<p className="font-bold">AI Experten-Matching</p>
<p className="text-sm text-slate-500">Automatische Vorschläge basierend auf Tender-Skills</p>
</div>
<input type="checkbox" defaultChecked className="w-5 h-5 accent-brand-500" />
</div>
<div className="flex items-center justify-between py-2 border-t border-slate-50 dark:border-slate-800">
<div>
<p className="font-bold">LinkedIn-Synchronisierung</p>
<p className="text-sm text-slate-500">Profile automatisch mit LinkedIn-Daten anreichern</p>
</div>
<input type="checkbox" defaultChecked className="w-5 h-5 accent-brand-500" />
</div>
</div>
</section>
<section>
<h3 className="text-xl font-bold mb-6">Über ICON</h3>
<div className="p-6 bg-brand-500 rounded-3xl text-white shadow-xl shadow-brand-500/20">
<p className="font-bold text-lg mb-2">Version 2.4.0</p>
<p className="text-sm opacity-90 leading-relaxed">
Ihr smartes CRM für die Expertenvermittlung. Diese Anwendung wurde für höchste Performance und intuitive Bedienung auf Desktop und Mobilgeräten optimiert.
</p>
</div>
</section>
</div>
);
};
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import { ViewState } from '../types';
interface SidebarProps {
currentView: ViewState;
onViewChange: (view: ViewState) => void;
isOpen: boolean;
onToggle: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isOpen, onToggle }) => {
const menuItems: { name: ViewState; icon: string }[] = [
{ name: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ name: 'Live!', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ name: 'Ausschreibungen', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
{ name: 'Kunden', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
{ name: 'Experten', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
{ name: 'Laufende Projekte', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ name: 'Organigramm', icon: 'M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2' },
{ name: 'Einstellungen', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
];
return (
<>
{isOpen && (
<div
className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-20 lg:hidden"
onClick={onToggle}
/>
)}
<aside className={`
fixed lg:static inset-y-0 left-0 z-30
${isOpen ? 'w-64 translate-x-0' : 'w-0 lg:w-20 -translate-x-full lg:translate-x-0'}
bg-slate-900 text-white transition-all duration-300 flex flex-col h-full overflow-hidden
`}>
<div className="p-6 flex items-center gap-4 shrink-0">
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center shrink-0">
<span className="font-bold text-white text-xl">I</span>
</div>
{(isOpen || window.innerWidth < 1024) && <span className="text-xl font-bold tracking-wider">ICON</span>}
</div>
<nav className="flex-1 mt-4 px-3 space-y-1 overflow-y-auto overflow-x-hidden">
{menuItems.map((item) => (
<button
key={item.name}
onClick={() => {
onViewChange(item.name);
if (window.innerWidth < 1024) onToggle();
}}
className={`
w-full flex items-center p-3 rounded-xl transition-all group
${currentView === item.name
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'}
`}
>
<svg className="w-6 h-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={item.icon} />
</svg>
{(isOpen || window.innerWidth < 1024) && <span className="ml-4 font-medium truncate">{item.name}</span>}
</button>
))}
</nav>
<div className="p-4 border-t border-slate-800 shrink-0">
<div className="flex items-center gap-3">
<img src="https://picsum.photos/40/40" className="w-10 h-10 rounded-full border-2 border-slate-700" alt="Profile" />
{(isOpen || window.innerWidth < 1024) && (
<div className="overflow-hidden">
<p className="text-sm font-semibold truncate">Admin User</p>
<p className="text-xs text-slate-500 truncate">admin@icon.de</p>
</div>
)}
</div>
</div>
</aside>
</>
);
};
+355
View File
@@ -0,0 +1,355 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Tender, Expert, Customer, KanbanColumn } from '../types';
import { supabase, TABLES } from '../lib/supabase';
interface TendersProps {
tenders: Tender[];
setTenders: React.Dispatch<React.SetStateAction<Tender[]>>;
customers: Customer[];
experts: Expert[];
refreshData: () => Promise<void>;
}
type SortConfig = {
key: keyof Tender | 'none';
direction: 'asc' | 'desc';
};
type FilterStatus = 'Alle' | 'Aktiv' | 'Inaktiv';
export const Tenders: React.FC<TendersProps> = ({ tenders, setTenders, customers, experts, refreshData }) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [tenderToDelete, setTenderToDelete] = useState<string | null>(null);
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'none', direction: 'asc' });
const [filterStatus, setFilterStatus] = useState<FilterStatus>('Alle');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTender, setEditingTender] = useState<Tender | null>(null);
const [formState, setFormState] = useState({ title: '', source: '', status: 'Aktiv' as const, link: '' });
const [isExpertModalOpen, setIsExpertModalOpen] = useState(false);
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
const [activeTenderIdForSelection, setActiveTenderIdForSelection] = useState<string | null>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (filterMenuRef.current && !filterMenuRef.current.contains(event.target as Node)) {
setIsFilterMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const displayTenders = useMemo(() => {
let items = [...tenders];
if (filterStatus === 'Aktiv') {
items = items.filter(t => t.status === 'Aktiv');
} else if (filterStatus === 'Inaktiv') {
items = items.filter(t => t.status === 'Inaktiv');
}
if (sortConfig.key !== 'none') {
items.sort((a, b) => {
const aValue = a[sortConfig.key as keyof Tender];
const bValue = b[sortConfig.key as keyof Tender];
if (aValue === undefined || bValue === undefined) return 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sortConfig.direction === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
}
return 0;
});
}
return items;
}, [tenders, sortConfig, filterStatus]);
const requestSort = (key: keyof Tender) => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') direction = 'desc';
setSortConfig({ key, direction });
};
const getSortIndicator = (key: keyof Tender) => {
if (sortConfig.key !== key) return <span className="opacity-20 text-[10px]">↕</span>;
return sortConfig.direction === 'asc' ? <span className="text-brand-500 text-[10px]">↑</span> : <span className="text-brand-500 text-[10px]">↓</span>;
};
const openModal = (tender?: Tender) => {
if (tender) {
setEditingTender(tender);
setFormState({ title: tender.title, source: tender.source, status: tender.status as any, link: tender.link || '' });
} else {
setEditingTender(null);
setFormState({ title: '', source: '', status: 'Aktiv', link: '' });
}
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (editingTender) {
await supabase.from(TABLES.TENDERS).update({
title: formState.title,
source: formState.source,
status: formState.status,
link: formState.link
}).eq('id', editingTender.id);
} else {
await supabase.from(TABLES.TENDERS).insert([{
title: formState.title,
source: formState.source,
status: formState.status,
kanban_status: 'Ausschreibung',
link: formState.link || null,
notes: ''
}]);
}
setIsModalOpen(false);
refreshData();
};
const confirmDelete = async () => {
if (tenderToDelete) {
await supabase.from(TABLES.TENDERS).delete().eq('id', tenderToDelete);
if (expandedId === tenderToDelete) setExpandedId(null);
setTenderToDelete(null);
refreshData();
}
};
const handleUpdateNotes = async (id: string, newNotes: string) => {
setTenders(prev => prev.map(t => t.id === id ? { ...t, notes: newNotes } : t));
await supabase.from(TABLES.TENDERS).update({ notes: newNotes }).eq('id', id);
};
const assignCustomer = async (tenderId: string, customer: Customer) => {
await supabase.from(TABLES.TENDERS).update({ customer_id: customer.id }).eq('id', tenderId);
setIsCustomerModalOpen(false);
refreshData();
};
const toggleExpertSelection = async (tenderId: string, expertId: string) => {
const tender = tenders.find(t => t.id === tenderId);
const isAlreadySelected = tender?.selectedExperts?.some(e => e.id === expertId);
if (isAlreadySelected) {
await supabase.from(TABLES.TENDER_EXPERTS).delete().match({ tender_id: tenderId, expert_id: expertId });
} else {
await supabase.from(TABLES.TENDER_EXPERTS).insert([{ tender_id: tenderId, expert_id: expertId }]);
}
refreshData();
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Ausschreibungen</h2>
<div className="flex items-center gap-3">
<div className="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-xl">
{(['Alle', 'Aktiv', 'Inaktiv'] as FilterStatus[]).map(s => (
<button
key={s}
onClick={() => setFilterStatus(s)}
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all ${filterStatus === s ? 'bg-white dark:bg-slate-700 shadow-sm text-brand-600 dark:text-brand-400' : 'text-slate-500 hover:text-slate-700'}`}
>
{s === 'Inaktiv' ? 'Nicht aktiv' : s}
</button>
))}
</div>
<button type="button" onClick={() => openModal()} className="bg-brand-600 hover:bg-brand-700 text-white px-5 py-2.5 rounded-xl font-bold transition-all shadow-lg shadow-brand-500/20 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
Ausschreibung hinzufügen
</button>
</div>
</div>
<div className="bg-white dark:bg-slate-900 rounded-3xl overflow-hidden border border-slate-100 dark:border-slate-800 shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[1050px]">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/50">
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer" onClick={() => requestSort('title')}>
<div className="flex items-center gap-2">Titel {getSortIndicator('title')}</div>
</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider">Kunde / Kontakt</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider cursor-pointer" onClick={() => requestSort('source')}>
<div className="flex items-center gap-2">Quelle {getSortIndicator('source')}</div>
</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider text-center">Experten</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs tracking-wider">Status</th>
<th className="p-4 w-32"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{displayTenders.map((tender) => (
<React.Fragment key={tender.id}>
<tr className={`hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors cursor-pointer ${expandedId === tender.id ? 'bg-brand-50/50 dark:bg-brand-500/5' : ''}`} onClick={() => setExpandedId(expandedId === tender.id ? null : tender.id)}>
<td className="p-4 font-bold text-slate-800 dark:text-slate-100">{tender.title}</td>
<td className="p-4 font-medium text-slate-700 dark:text-slate-300">
{tender.selectedCustomer ? (
<div className="flex flex-col">
<span className="font-bold">{tender.selectedCustomer.name}</span>
<span className="text-[10px] text-slate-500 uppercase">{tender.selectedCustomer.company}</span>
</div>
) : <span className="text-slate-400 italic text-xs">Nicht zugewiesen</span>}
</td>
<td className="p-4 font-medium text-slate-600 dark:text-slate-400 text-sm">{tender.source}</td>
<td className="p-4 text-center">
<span className="font-bold bg-slate-100 dark:bg-slate-700 px-3 py-1 rounded-full text-sm text-slate-700 dark:text-white border border-slate-200 dark:border-slate-700">{tender.applicantsCount}</span>
</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${
tender.status === 'Aktiv' ? 'bg-brand-500 text-white' :
tender.status === 'Inaktiv' ? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400' :
'bg-slate-200 dark:bg-slate-700 text-slate-600'
}`}>
{tender.status === 'Inaktiv' ? 'Nicht aktiv' : tender.status}
</span>
</td>
<td className="p-4 flex items-center justify-end gap-1">
<button type="button" onClick={(e) => { e.stopPropagation(); openModal(tender); }} className="p-2 text-slate-400 hover:text-brand-500"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
<button type="button" onClick={(e) => { e.stopPropagation(); setTenderToDelete(tender.id); }} className="p-2 text-slate-400 hover:text-red-500"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
</td>
</tr>
{expandedId === tender.id && (
<tr>
<td colSpan={6} className="p-6 bg-slate-50/50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Experts */}
<div className="bg-white dark:bg-slate-800 rounded-3xl p-6 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col h-[400px]">
<h4 className="text-xl font-bold flex items-center gap-2 mb-6 text-slate-900 dark:text-white"><span className="w-2 h-8 bg-brand-500 rounded-full inline-block"></span>Experten</h4>
<div className="flex-1 overflow-y-auto space-y-3 mb-4 pr-2">
{tender.selectedExperts && tender.selectedExperts.length > 0 ? tender.selectedExperts.map(expert => (
<div key={expert.id} className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900/80 rounded-2xl border border-slate-100 dark:border-slate-800 group/expert">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-500/10 text-brand-600 flex items-center justify-center font-bold text-sm uppercase">{expert.name.charAt(0)}</div>
<div>
<p className="font-bold text-sm text-slate-900 dark:text-slate-100">{expert.name}</p>
<p className="text-[11px] text-slate-500">{expert.role}</p>
</div>
</div>
<button type="button" onClick={() => toggleExpertSelection(tender.id, expert.id)} className="p-2 text-slate-400 hover:text-red-500 opacity-0 group-hover/expert:opacity-100"><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
)) : <div className="py-12 text-center bg-slate-50 dark:bg-slate-900/30 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800"><p className="text-sm text-slate-500 italic">Noch keine Experten zugewiesen.</p></div>}
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); setActiveTenderIdForSelection(tender.id); setIsExpertModalOpen(true); }} className="w-full py-3.5 bg-white dark:bg-slate-800 text-brand-600 border-2 border-brand-500 hover:bg-brand-500 hover:text-white rounded-2xl font-bold transition-all shadow-sm">Experten zuweisen</button>
</div>
{/* Customer */}
<div className="bg-white dark:bg-slate-800 rounded-3xl p-6 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col h-[400px]">
<h4 className="text-xl font-bold flex items-center gap-2 mb-6 text-slate-900 dark:text-white"><span className="w-2 h-8 bg-blue-500 rounded-full inline-block"></span>Kundenkontakt</h4>
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 bg-slate-50 dark:bg-slate-900/50 rounded-3xl border border-slate-100 dark:border-slate-800 mb-6">
{tender.selectedCustomer ? (
<div>
<div className="w-20 h-20 rounded-3xl bg-blue-500/10 text-blue-600 flex items-center justify-center mb-6 mx-auto"><svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg></div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Zugeordneter Kontakt</p>
<p className="font-bold text-slate-900 dark:text-white text-xl leading-tight">{tender.selectedCustomer.name}</p>
<p className="text-sm text-slate-500 font-medium">{tender.selectedCustomer.company}</p>
</div>
) : <p className="text-slate-500 italic text-sm">Kein Kunde ausgewählt</p>}
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); setActiveTenderIdForSelection(tender.id); setIsCustomerModalOpen(true); }} className="w-full py-4 bg-blue-500 hover:bg-blue-600 text-white rounded-2xl font-bold transition-all shadow-lg shadow-blue-500/20">Kunden auswählen</button>
</div>
{/* Notes */}
<div className="bg-white dark:bg-slate-800 rounded-3xl p-6 shadow-sm border border-slate-100 dark:border-slate-700 flex flex-col h-[400px]">
<h4 className="text-xl font-bold flex items-center gap-2 mb-6 text-slate-900 dark:text-white"><span className="w-2 h-8 bg-brand-500 rounded-full inline-block"></span>Notizen</h4>
<textarea
value={tender.notes || ''}
onChange={(e) => handleUpdateNotes(tender.id, e.target.value)}
placeholder="Interne Informationen..."
className="flex-1 w-full p-4 bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 rounded-2xl text-sm leading-relaxed text-slate-700 dark:text-slate-200 resize-none outline-none focus:ring-2 focus:ring-brand-500/20"
/>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
{/* MODALS */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
<div className="bg-white dark:bg-slate-900 w-full max-w-md rounded-3xl shadow-2xl p-6">
<h3 className="text-xl font-bold mb-4">{editingTender ? 'Bearbeiten' : 'Neu'}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<input required type="text" value={formState.title} onChange={e => setFormState({ ...formState, title: e.target.value })} placeholder="Titel" className="w-full px-4 py-3 bg-slate-50 dark:bg-slate-800 rounded-xl outline-none" />
<input required type="text" value={formState.source} onChange={e => setFormState({ ...formState, source: e.target.value })} placeholder="Quelle" className="w-full px-4 py-3 bg-slate-50 dark:bg-slate-800 rounded-xl outline-none" />
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Status</label>
<select
value={formState.status}
onChange={e => setFormState({ ...formState, status: e.target.value as any })}
className="w-full px-4 py-3 bg-slate-50 dark:bg-slate-800 rounded-xl outline-none"
>
<option value="Aktiv">Aktiv</option>
<option value="Inaktiv">Nicht aktiv</option>
<option value="Ausgelaufen">Ausgelaufen</option>
</select>
</div>
<button type="submit" className="w-full py-3 bg-brand-600 text-white font-bold rounded-xl">Speichern</button>
<button type="button" onClick={() => setIsModalOpen(false)} className="w-full py-2 text-slate-500">Abbrechen</button>
</form>
</div>
</div>
)}
{isExpertModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/70 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl p-8 max-h-[80vh] overflow-y-auto">
<h3 className="text-2xl font-bold mb-6">Experten auswählen</h3>
<div className="space-y-3">
{experts.map(expert => {
const isSelected = tenders.find(t => t.id === activeTenderIdForSelection)?.selectedExperts?.some(e => e.id === expert.id);
return (
<div key={expert.id} onClick={() => toggleExpertSelection(activeTenderIdForSelection!, expert.id)} className={`flex items-center justify-between p-4 rounded-3xl border-2 cursor-pointer ${isSelected ? 'border-brand-500 bg-brand-50' : 'border-transparent bg-slate-50 dark:bg-slate-800'}`}>
<span className="font-bold">{expert.name}</span>
{isSelected && <span className="text-brand-500 font-bold">✓</span>}
</div>
);
})}
</div>
<button onClick={() => setIsExpertModalOpen(false)} className="w-full mt-8 py-4 bg-brand-600 text-white font-bold rounded-2xl">Fertig</button>
</div>
</div>
)}
{isCustomerModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/70 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl p-8 max-h-[80vh] overflow-y-auto">
<h3 className="text-2xl font-bold mb-6">Kunden zuweisen</h3>
<div className="space-y-3">
{customers.map(customer => (
<div key={customer.id} onClick={() => assignCustomer(activeTenderIdForSelection!, customer)} className="p-5 rounded-3xl bg-slate-50 dark:bg-slate-800 border-2 border-transparent hover:border-blue-500 cursor-pointer">
<p className="font-bold text-lg">{customer.name}</p>
<p className="text-xs text-slate-500 uppercase font-bold">{customer.company}</p>
</div>
))}
</div>
<button onClick={() => setIsCustomerModalOpen(false)} className="w-full mt-8 py-4 bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-bold rounded-2xl">Schließen</button>
</div>
</div>
)}
{tenderToDelete && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 w-full max-w-sm rounded-[2rem] p-8 text-center shadow-2xl border border-red-100">
<h3 className="text-2xl font-bold mb-2">Bist du sicher?</h3>
<p className="text-slate-500 mb-8">Dieser Tender wird unwiderruflich gelöscht.</p>
<div className="grid grid-cols-2 gap-3">
<button onClick={() => setTenderToDelete(null)} className="py-4 bg-slate-100 dark:bg-slate-800 rounded-2xl font-bold">Abbruch</button>
<button onClick={confirmDelete} className="py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-500/20">Löschen</button>
</div>
</div>
</div>
)}
</div>
);
};
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
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>
<App />
</React.StrictMode>
);
+16
View File
@@ -0,0 +1,16 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://icondb.perinco.de';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzA2NDg2NDAwLCJleHAiOjIwMjIwNDQ0MDB9.IRqXLxcWw-ePPtbw7nkzC3PlXKoTllr4FeNXq8kwDeE';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
export const TABLES = {
CUSTOMERS: 'icon_customers',
CONTACTS: 'icon_contacts', // Neue Tabelle für Ansprechpartner
EXPERTS: 'icon_experts',
TENDERS: 'icon_tenders',
TENDER_EXPERTS: 'icon_tender_experts',
PROJECTS: 'icon_projects'
};