feat(frontend): Tailwind UI + Whitelabel + Multi-Tenant Components
Tech:
- Tailwind 3.4 + PostCSS + Autoprefixer
- Inter via rsms.me, Slate + Indigo (brand-* Farbpalette)
- Komponenten-Klassen .btn-{primary|secondary|danger|ghost}, .input, .label, .card, .badge-*
Visuelles Refresh:
- Logo entfernt - Wortmarke Pfandsystem mit Mono-Box
- Login: zentriertes Card-Layout mit Tenant-Slug-Toggle
- TopNav: cleanes Header mit Tabs + User-Menue, Demo-Badge
- Konsistente Card/Table/Badge-Patterns ueber alle Views
- Mobile-First (Tabellen scrollen, Filter wrap)
Komponenten neu/umgebaut:
- Auth/Login.jsx - Tenant-Slug aus URL-Param ?tenant=...
- Auth/PasswordChange.jsx
- Dashboard/Dashboard.jsx
- Dashboard/TopNav.jsx - inkl. Mobile-Hamburger
- Dashboard/Account.jsx (neu)
- Dashboard/AdminDashboard.jsx - Soll-Ist mit CSV-Export
- Dashboard/VorgangsListe.jsx
- Dashboard/BildModal.jsx
- Eingabe/VorgangForm.jsx - Kunden-Info-Popup mit Hinweis+Bildern+Maps
- KundenVerwaltung.jsx - inkl. Anlieferungshinweis, Lieferzeit, Bilder-Modal
- MitarbeiterVerwaltung.jsx - Aktiv-Toggle, Fahrer-Rolle
- Geraete.jsx (neu) - CRUD mit Status-Badges, Filter
- TourPlanung.jsx (neu) - Sortiert nach Lieferzeit, Navi-Button, Done-Persistenz
- SuperAdmin.jsx (neu) - Tenant-Liste + Demo-Anlage-Modal + Verlaengern
API Client:
- relative URL /api (Traefik-Proxy)
- Tenant-Slug im Login
- Endpoints fuer tenants, geraete, kunden-bilder
Sonstiges:
- nginx.conf: SSL raus (Traefik macht TLS), Host-Lock raus, Gzip, immutable Cache
- docker-compose: Frontend baut via Dockerfile (multi-stage)
- Manifest/Theme: #4338ca (Indigo)
- Aufgeraeumt: alte Supabase-Reste, Logout-Komponente (jetzt im Menu),
nicht mehr noetige Deploy-Skripte
2026-05-26 13:18:13 +00:00
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
import { FaPlus, FaSignOutAlt, FaTrash, FaClock, FaUsers, FaUser } from 'react-icons/fa';
|
|
|
|
|
import { api } from '../api/client';
|
|
|
|
|
|
|
|
|
|
export default function SuperAdmin({ user, onLogout }) {
|
|
|
|
|
const [tenants, setTenants] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [showNew, setShowNew] = useState(false);
|
|
|
|
|
const [createdInfo, setCreatedInfo] = useState(null);
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try { setTenants(await api.getTenants()); } finally { setLoading(false); }
|
|
|
|
|
};
|
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
|
|
|
|
|
|
const extend = async (id, days) => {
|
|
|
|
|
await api.updateTenant(id, { extend_days: days });
|
|
|
|
|
load();
|
|
|
|
|
};
|
|
|
|
|
const setStatus = async (id, status) => { await api.updateTenant(id, { status }); load(); };
|
|
|
|
|
const del = async (id) => { if (!confirm('Tenant inkl. aller Daten loeschen?')) return; await api.deleteTenant(id); load(); };
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-slate-50">
|
|
|
|
|
<header className="bg-white border-b border-slate-200 sticky top-0 z-30">
|
|
|
|
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center">
|
|
|
|
|
<div className="flex items-center gap-2 flex-1">
|
2026-05-26 13:37:39 +00:00
|
|
|
<img src="/pfandlogo.jpg" alt="" className="h-8 w-8 rounded-lg object-contain bg-white ring-1 ring-slate-200 p-0.5" />
|
feat(frontend): Tailwind UI + Whitelabel + Multi-Tenant Components
Tech:
- Tailwind 3.4 + PostCSS + Autoprefixer
- Inter via rsms.me, Slate + Indigo (brand-* Farbpalette)
- Komponenten-Klassen .btn-{primary|secondary|danger|ghost}, .input, .label, .card, .badge-*
Visuelles Refresh:
- Logo entfernt - Wortmarke Pfandsystem mit Mono-Box
- Login: zentriertes Card-Layout mit Tenant-Slug-Toggle
- TopNav: cleanes Header mit Tabs + User-Menue, Demo-Badge
- Konsistente Card/Table/Badge-Patterns ueber alle Views
- Mobile-First (Tabellen scrollen, Filter wrap)
Komponenten neu/umgebaut:
- Auth/Login.jsx - Tenant-Slug aus URL-Param ?tenant=...
- Auth/PasswordChange.jsx
- Dashboard/Dashboard.jsx
- Dashboard/TopNav.jsx - inkl. Mobile-Hamburger
- Dashboard/Account.jsx (neu)
- Dashboard/AdminDashboard.jsx - Soll-Ist mit CSV-Export
- Dashboard/VorgangsListe.jsx
- Dashboard/BildModal.jsx
- Eingabe/VorgangForm.jsx - Kunden-Info-Popup mit Hinweis+Bildern+Maps
- KundenVerwaltung.jsx - inkl. Anlieferungshinweis, Lieferzeit, Bilder-Modal
- MitarbeiterVerwaltung.jsx - Aktiv-Toggle, Fahrer-Rolle
- Geraete.jsx (neu) - CRUD mit Status-Badges, Filter
- TourPlanung.jsx (neu) - Sortiert nach Lieferzeit, Navi-Button, Done-Persistenz
- SuperAdmin.jsx (neu) - Tenant-Liste + Demo-Anlage-Modal + Verlaengern
API Client:
- relative URL /api (Traefik-Proxy)
- Tenant-Slug im Login
- Endpoints fuer tenants, geraete, kunden-bilder
Sonstiges:
- nginx.conf: SSL raus (Traefik macht TLS), Host-Lock raus, Gzip, immutable Cache
- docker-compose: Frontend baut via Dockerfile (multi-stage)
- Manifest/Theme: #4338ca (Indigo)
- Aufgeraeumt: alte Supabase-Reste, Logout-Komponente (jetzt im Menu),
nicht mehr noetige Deploy-Skripte
2026-05-26 13:18:13 +00:00
|
|
|
<span className="font-semibold">Pfandsystem · SuperAdmin</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-sm text-slate-500 mr-3 hidden sm:block">{user.email}</div>
|
|
|
|
|
<button onClick={onLogout} className="btn-ghost"><FaSignOutAlt /> Abmelden</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-6 sm:py-8 space-y-5">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-semibold text-slate-900">Tenants</h1>
|
|
|
|
|
<p className="text-sm text-slate-500">{tenants.length} Tenants · {tenants.filter(t => t.typ === 'demo').length} Demos</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={() => setShowNew(true)} className="btn-primary">
|
|
|
|
|
<FaPlus /> Neuer Demo-Account
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading ? <div className="text-center text-slate-500 py-8">Lade…</div> : (
|
|
|
|
|
<div className="card overflow-x-auto p-0">
|
|
|
|
|
<table className="min-w-full text-sm">
|
|
|
|
|
<thead className="bg-slate-50 text-xs uppercase text-slate-600">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="text-left px-4 py-3">Name</th>
|
|
|
|
|
<th className="text-left px-4 py-3 hidden md:table-cell">Slug</th>
|
|
|
|
|
<th className="text-left px-4 py-3">Typ</th>
|
|
|
|
|
<th className="text-left px-4 py-3">Status</th>
|
|
|
|
|
<th className="text-left px-4 py-3 hidden lg:table-cell">User / Kunden</th>
|
|
|
|
|
<th className="text-left px-4 py-3">Ablauf</th>
|
|
|
|
|
<th className="px-4 py-3"></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-slate-100">
|
|
|
|
|
{tenants.map(t => {
|
|
|
|
|
const expired = t.demo_expires_at && new Date(t.demo_expires_at) < new Date();
|
|
|
|
|
return (
|
|
|
|
|
<tr key={t.id} className="hover:bg-slate-50">
|
|
|
|
|
<td className="px-4 py-3">
|
|
|
|
|
<div className="font-medium text-slate-900">{t.name}</div>
|
|
|
|
|
<div className="text-xs text-slate-500">{t.kontakt_email}</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 font-mono text-xs hidden md:table-cell">{t.slug}</td>
|
|
|
|
|
<td className="px-4 py-3">
|
|
|
|
|
<span className={t.typ === 'demo' ? 'badge-amber' : 'badge-brand'}>{t.typ}</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3">
|
|
|
|
|
<select value={t.status} onChange={e => setStatus(t.id, e.target.value)}
|
|
|
|
|
className="input py-1 px-2 text-xs">
|
|
|
|
|
<option value="aktiv">aktiv</option>
|
|
|
|
|
<option value="abgelaufen">abgelaufen</option>
|
|
|
|
|
<option value="gesperrt">gesperrt</option>
|
|
|
|
|
</select>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-xs text-slate-600 hidden lg:table-cell">
|
|
|
|
|
<span className="inline-flex items-center gap-1"><FaUser size={10} /> {t.user_count}</span>
|
|
|
|
|
<span className="inline-flex items-center gap-1 ml-2"><FaUsers size={10} /> {t.kunden_count}</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3">
|
|
|
|
|
{t.demo_expires_at ? (
|
|
|
|
|
<span className={expired ? 'badge-rose' : 'badge-slate'}>
|
|
|
|
|
<FaClock size={10} className="mr-1" />
|
|
|
|
|
{new Date(t.demo_expires_at).toLocaleDateString('de-DE')}
|
|
|
|
|
</span>
|
|
|
|
|
) : <span className="text-slate-400">—</span>}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
|
|
|
|
{t.typ === 'demo' && (
|
|
|
|
|
<button onClick={() => extend(t.id, 30)} className="btn-ghost text-xs">+30 Tage</button>
|
|
|
|
|
)}
|
|
|
|
|
<button onClick={() => del(t.id)} className="btn-ghost px-2 text-rose-600"><FaTrash /></button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{tenants.length === 0 && (
|
|
|
|
|
<tr><td colSpan={7} className="text-center py-8 text-slate-400">Noch keine Tenants.</td></tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
{showNew && <NewTenantModal onClose={() => setShowNew(false)} onCreated={(info) => { setCreatedInfo(info); setShowNew(false); load(); }} />}
|
|
|
|
|
{createdInfo && <CreatedInfoModal info={createdInfo} onClose={() => setCreatedInfo(null)} />}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function NewTenantModal({ onClose, onCreated }) {
|
|
|
|
|
const [form, setForm] = useState({ name: '', kontakt_email: '', firma: '', days: 30, seed: true });
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
const [error, setError] = useState('');
|
|
|
|
|
|
|
|
|
|
const submit = async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setBusy(true); setError('');
|
|
|
|
|
try { onCreated(await api.createTenant(form)); }
|
|
|
|
|
catch (e) { setError(e.message); } finally { setBusy(false); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div onClick={onClose} className="fixed inset-0 z-50 bg-slate-900/60 flex items-center justify-center p-4">
|
|
|
|
|
<form onSubmit={submit} onClick={e => e.stopPropagation()}
|
|
|
|
|
className="bg-white rounded-xl max-w-md w-full p-6 shadow-soft space-y-4">
|
|
|
|
|
<h3 className="text-lg font-semibold">Neuer Demo-Account</h3>
|
|
|
|
|
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
|
|
|
|
|
|
|
|
|
|
<Field label="Ansprechpartner / Name *">
|
|
|
|
|
<input required className="input" value={form.name}
|
|
|
|
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
|
|
|
|
</Field>
|
|
|
|
|
<Field label="E-Mail *">
|
|
|
|
|
<input required type="email" className="input" value={form.kontakt_email}
|
|
|
|
|
onChange={e => setForm(f => ({ ...f, kontakt_email: e.target.value }))} />
|
|
|
|
|
</Field>
|
|
|
|
|
<Field label="Firma">
|
|
|
|
|
<input className="input" value={form.firma}
|
|
|
|
|
onChange={e => setForm(f => ({ ...f, firma: e.target.value }))} />
|
|
|
|
|
</Field>
|
|
|
|
|
<Field label="Demo-Dauer (Tage)">
|
|
|
|
|
<input type="number" min="1" max="365" className="input" value={form.days}
|
|
|
|
|
onChange={e => setForm(f => ({ ...f, days: parseInt(e.target.value) || 30 }))} />
|
|
|
|
|
</Field>
|
|
|
|
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
|
|
|
|
<input type="checkbox" checked={form.seed}
|
|
|
|
|
onChange={e => setForm(f => ({ ...f, seed: e.target.checked }))} />
|
|
|
|
|
Mit Beispieldaten befuellen (5 Kunden, 3 Artikel, 2 Geraete)
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
|
|
|
<button type="button" onClick={onClose} className="btn-secondary flex-1">Abbrechen</button>
|
|
|
|
|
<button disabled={busy} className="btn-primary flex-1">{busy ? 'Lege an…' : 'Anlegen + Mail senden'}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CreatedInfoModal({ info, onClose }) {
|
|
|
|
|
return (
|
|
|
|
|
<div onClick={onClose} className="fixed inset-0 z-50 bg-slate-900/60 flex items-center justify-center p-4">
|
|
|
|
|
<div onClick={e => e.stopPropagation()} className="bg-white rounded-xl max-w-md w-full p-6 shadow-soft space-y-3">
|
|
|
|
|
<h3 className="text-lg font-semibold text-emerald-700">Demo-Account angelegt</h3>
|
|
|
|
|
<p className="text-sm text-slate-600">Der Interessent erhaelt eine Einladungs-Mail. Hier zur Sicherheit die Daten:</p>
|
|
|
|
|
<dl className="bg-slate-50 rounded-lg p-3 text-sm space-y-1.5">
|
|
|
|
|
<div><dt className="inline text-slate-500">Slug: </dt><dd className="inline font-mono">{info.slug}</dd></div>
|
|
|
|
|
<div><dt className="inline text-slate-500">E-Mail: </dt><dd className="inline">{info.admin_email}</dd></div>
|
|
|
|
|
<div><dt className="inline text-slate-500">Passwort: </dt><dd className="inline font-mono">{info.admin_password_oneshot}</dd></div>
|
|
|
|
|
<div><dt className="inline text-slate-500">Gueltig: </dt><dd className="inline">{info.demo_expires_in_days} Tage</dd></div>
|
|
|
|
|
</dl>
|
|
|
|
|
<p className="text-xs text-slate-400">Das Passwort wird nur einmal angezeigt.</p>
|
|
|
|
|
<button onClick={onClose} className="btn-primary w-full">OK</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Field({ label, children }) {
|
|
|
|
|
return <div><label className="label">{label}</label>{children}</div>;
|
|
|
|
|
}
|