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
This commit is contained in:
christian
2026-05-26 13:18:13 +00:00
parent 43c75e5dd3
commit 51aa85df6f
93 changed files with 1497 additions and 5464 deletions

BIN
src/components/._Auth Executable file

Binary file not shown.

BIN
src/components/._Dashboard Executable file

Binary file not shown.

BIN
src/components/._Eingabe Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,96 +2,81 @@ import { useState } from 'react';
import { api } from '../../api/client';
export default function Login({ onLogin }) {
const params = new URLSearchParams(window.location.search);
const tenantFromUrl = params.get('tenant') || '';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const [tenantSlug, setTenantSlug] = useState(tenantFromUrl);
const [showTenant, setShowTenant] = useState(!!tenantFromUrl);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e) => {
const submit = async (e) => {
e.preventDefault();
setLoading(true);
setMessage('');
setError(''); setLoading(true);
try {
const data = await api.login(email, password);
setMessage('Login erfolgreich!');
if (onLogin) {
onLogin(data.user);
}
} catch (error) {
setMessage(error.message || 'Login fehlgeschlagen');
} finally {
setLoading(false);
}
const data = await api.login(email, password, tenantSlug || undefined);
onLogin?.(data.user);
} catch (err) {
setError(err.message || 'Login fehlgeschlagen');
} finally { setLoading(false); }
};
return (
<div style={{
maxWidth: 380,
margin: '64px auto',
background: '#fff',
borderRadius: 22,
boxShadow: '0 4px 22px #1976d222',
padding: 36,
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
display: 'flex', flexDirection: 'column', alignItems: 'center',
}}>
<h1 style={{
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
fontWeight: 700, fontSize: 32, margin: '0 0 18px 0', color: '#1976d2', letterSpacing: 0.5,
textAlign: 'center', textShadow: '0 2px 8px #0001'
}}>
Pfandsystem
</h1>
<h2 style={{ textAlign: 'center', marginBottom: 24, fontWeight: 600, color: '#222', fontSize: 22, letterSpacing: 0.1 }}>Login</h2>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 18, width: '100%' }}>
<input
type="email"
placeholder="E-Mail"
value={email}
onChange={e => setEmail(e.target.value)}
required
style={{
fontSize: 18,
padding: '14px 14px',
borderRadius: 12,
border: '1.5px solid #dbeafe',
background: '#f6f7fb',
outline: 'none',
marginBottom: 2
}}
/>
<input
type="password"
placeholder="Passwort"
value={password}
onChange={e => setPassword(e.target.value)}
required
style={{
fontSize: 18,
padding: '14px 14px',
borderRadius: 12,
border: '1.5px solid #dbeafe',
background: '#f6f7fb',
outline: 'none',
marginBottom: 2
}}
/>
<button type="submit" disabled={loading} style={{
marginTop: 8,
background: loading ? '#ccc' : '#1976d2',
color: '#fff',
fontWeight: 700,
fontSize: 18,
border: 'none',
borderRadius: 12,
padding: '13px 0',
boxShadow: '0 2px 10px #1976d222',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'background 0.18s',
}}>{loading ? 'Wird geladen...' : 'Login'}</button>
</form>
{message && <div style={{ color: '#1976d2', marginTop: 18, fontWeight: 500 }}>{message}</div>}
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-gradient-to-br from-slate-50 to-slate-100">
<div className="w-full max-w-sm">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center h-14 w-14 rounded-2xl bg-brand-600 text-white text-xl font-bold mb-3 shadow-soft">
P
</div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Pfandsystem</h1>
<p className="text-sm text-slate-500 mt-1">Demo-Portal</p>
</div>
<form onSubmit={submit} className="card space-y-4">
<div>
<label className="label">E-Mail</label>
<input type="email" required value={email}
onChange={e => setEmail(e.target.value)}
className="input" placeholder="name@firma.de" />
</div>
<div>
<label className="label">Passwort</label>
<input type="password" required value={password}
onChange={e => setPassword(e.target.value)}
className="input" placeholder="••••••••" />
</div>
{showTenant ? (
<div>
<label className="label">Tenant-Kennung</label>
<input type="text" value={tenantSlug}
onChange={e => setTenantSlug(e.target.value)}
className="input" placeholder="z. B. firma-a1b2" />
</div>
) : (
<button type="button" onClick={() => setShowTenant(true)}
className="text-xs text-brand-600 hover:text-brand-700">
Mehrere Konten? Tenant-Kennung angeben
</button>
)}
{error && (
<div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">
{error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
<p className="text-center text-xs text-slate-400 mt-6">
Kein Zugang? Demo-Account anfragen unter <a className="text-brand-600 hover:underline" href="mailto:demo@dockly.de">demo@dockly.de</a>
</p>
</div>
</div>
);
}

View File

@@ -1,27 +0,0 @@
import { api } from '../../api/client';
export default function Logout({ onLogout }) {
const handleLogout = async () => {
api.logout();
if (onLogout) {
onLogout();
}
// Seite neu laden um sicherzustellen dass der User ausgeloggt ist
window.location.reload();
};
return (
<button onClick={handleLogout} style={{
margin: 8,
padding: '10px 20px',
background: '#d32f2f',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
fontWeight: 600,
fontSize: 14
}}>
Logout
</button>
);
}

View File

@@ -2,131 +2,42 @@ import { useState } from 'react';
import { api } from '../../api/client';
export default function PasswordChange() {
const [form, setForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [err, setErr] = useState('');
const [ok, setOk] = useState('');
const [busy, setBusy] = useState(false);
const update = e => { setForm(f => ({ ...f, [e.target.name]: e.target.value })); setErr(''); setOk(''); };
const handleChange = (e) => {
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
setError('');
setSuccess('');
};
const handleSubmit = async (e) => {
const submit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
// Validierung
if (!form.currentPassword || !form.newPassword || !form.confirmPassword) {
setError('Bitte alle Felder ausfüllen');
return;
}
if (form.newPassword.length < 6) {
setError('Neues Passwort muss mindestens 6 Zeichen lang sein');
return;
}
if (form.newPassword !== form.confirmPassword) {
setError('Passwörter stimmen nicht überein');
return;
}
setLoading(true);
if (form.newPassword.length < 6) return setErr('Neues Passwort min. 6 Zeichen');
if (form.newPassword !== form.confirm) return setErr('Passwoerter stimmen nicht ueberein');
setBusy(true);
try {
await api.changePassword(form.currentPassword, form.newPassword);
setSuccess('Passwort erfolgreich geändert!');
setForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (err) {
setError(err.message || 'Fehler beim Ändern des Passworts');
} finally {
setLoading(false);
}
setOk('Passwort geaendert');
setForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
<div style={{ marginTop: 24, padding: 20, background: '#f5f5f5', borderRadius: 8 }}>
<h3 style={{ marginBottom: 16, fontSize: 18, fontWeight: 600 }}>Passwort ändern</h3>
{error && (
<div style={{ padding: 12, marginBottom: 12, background: '#ffebee', color: '#c62828', borderRadius: 6, fontSize: 14 }}>
{error}
</div>
)}
{success && (
<div style={{ padding: 12, marginBottom: 12, background: '#e8f5e9', color: '#2e7d32', borderRadius: 6, fontSize: 14 }}>
{success}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
Aktuelles Passwort:
</label>
<input
type="password"
name="currentPassword"
value={form.currentPassword}
onChange={handleChange}
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
disabled={loading}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
Neues Passwort:
</label>
<input
type="password"
name="newPassword"
value={form.newPassword}
onChange={handleChange}
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
disabled={loading}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
Passwort bestätigen:
</label>
<input
type="password"
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
disabled={loading}
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: 12,
background: loading ? '#ccc' : '#1976d2',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: 15,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'Wird geändert...' : 'Passwort ändern'}
</button>
</form>
</div>
<form onSubmit={submit} className="card space-y-4">
<h3 className="card-title">Passwort aendern</h3>
{err && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{err}</div>}
{ok && <div className="rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700 ring-1 ring-emerald-200">{ok}</div>}
<div>
<label className="label">Aktuelles Passwort</label>
<input type="password" name="currentPassword" value={form.currentPassword} onChange={update} className="input" />
</div>
<div>
<label className="label">Neues Passwort</label>
<input type="password" name="newPassword" value={form.newPassword} onChange={update} className="input" />
</div>
<div>
<label className="label">Bestaetigen</label>
<input type="password" name="confirm" value={form.confirm} onChange={update} className="input" />
</div>
<button disabled={busy} className="btn-primary w-full">{busy ? 'Speichere…' : 'Aendern'}</button>
</form>
);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,32 @@
import PasswordChange from '../Auth/PasswordChange';
export default function Account({ user }) {
return (
<div className="max-w-xl mx-auto space-y-6">
<div className="card">
<h2 className="card-title">Account</h2>
<dl className="grid grid-cols-3 gap-y-3 text-sm">
<dt className="col-span-1 text-slate-500">Name</dt><dd className="col-span-2 font-medium">{user.name}</dd>
<dt className="col-span-1 text-slate-500">E-Mail</dt><dd className="col-span-2">{user.email}</dd>
<dt className="col-span-1 text-slate-500">Rolle</dt><dd className="col-span-2">
<span className="badge-brand capitalize">{user.rolle}</span>
</dd>
{user.tenant_name && (<>
<dt className="col-span-1 text-slate-500">Tenant</dt>
<dd className="col-span-2">
{user.tenant_name}
{user.tenant_typ === 'demo' && (
<span className="badge-amber ml-2">Demo</span>
)}
</dd>
</>)}
{user.demo_expires_at && (<>
<dt className="col-span-1 text-slate-500">Demo-Ablauf</dt>
<dd className="col-span-2">{new Date(user.demo_expires_at).toLocaleDateString('de-DE')}</dd>
</>)}
</dl>
</div>
<PasswordChange />
</div>
);
}

View File

@@ -1,253 +1,132 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { FaFileCsv } from 'react-icons/fa';
import { api } from '../../api/client';
function exportToCSV(rows, filename) {
const header = ['Kunde', 'Artikel', 'Soll', 'Ist', 'Differenz', 'Zeitraum'];
const csv = [header.join(',')].concat(
rows.map(r => [r.kunde, r.artikel, r.soll, r.ist, r.differenz, r.zeitraum].join(','))
).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
function toCSV(rows) {
const header = ['Kunde', 'Artikel', 'Soll', 'Ist', 'Differenz', 'Zeitraum', 'Mitarbeiter'];
const escape = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
return [header.join(',')]
.concat(rows.map(r => [r.kunde, r.artikel, r.soll, r.ist, r.differenz, r.zeitraum, r.mitarbeiter].map(escape).join(',')))
.join('\n');
}
function downloadCSV(rows, filename) {
const blob = new Blob([toCSV(rows)], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function UeberfaelligeKunden({ kunden, rows }) {
const [sending, setSending] = useState({});
const [msg, setMsg] = useState({});
const UEBERFAELLIG_TAGE = 14;
const heute = new Date();
// rows enthält nur den ausgewählten Kunden, daher alle Kunden prüfen:
// (Alternative: für alle Kunden/Artikel aus lieferungen/ruecknahmen berechnen)
// Hier: Zeige für den aktuell gefilterten Kunden, wenn überfällig
const ueberfaellige = rows.filter(row => {
// Annahme: row.soll > row.ist, Frist überschritten, Kunde hat E-Mail
// Frist: ältestes Lieferdatum für Artikel
const lieferDatum = heute; // Placeholder, da rows kein Datum enthält
// TODO: lieferDatum aus lieferungen holen, wenn verfügbar
// Hier: Zeige alle mit Differenz > 0 und email
return (row.soll > row.ist);
});
if (!ueberfaellige.length) return null;
async function sendeMail(row) {
setSending(s => ({ ...s, [row.artikel]: true }));
setMsg(m => ({ ...m, [row.artikel]: '' }));
try {
const res = await fetch('https://work.pfandsystem.backdigital.de/webhook/send-reminder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: kunden.find(k => k.id === row.kunde_id)?.email || kunden.find(k => k.name === row.kunde)?.email || '',
kunde: row.kunde,
anrede: kunden.find(k => k.id === row.kunde_id)?.anrede || '',
ansprechpartner: kunden.find(k => k.id === row.kunde_id)?.ansprechpartner || '',
artikel: row.artikel,
anzahl: row.soll - row.ist,
frist: '14 Tage',
})
});
if (!res.ok) throw new Error('HTTP ' + res.status);
setMsg(m => ({ ...m, [row.artikel]: 'Erinnerung gesendet!' }));
} catch (e) {
setMsg(m => ({ ...m, [row.artikel]: 'Fehler beim Senden!' }));
}
setSending(s => ({ ...s, [row.artikel]: false }));
}
return (
<div style={{ marginTop: 32, background: '#f9fbe7', borderRadius: 12, padding: 16, maxWidth: 600, marginLeft: 'auto', marginRight: 'auto' }}>
<h3 style={{ marginBottom: 12, color: '#d32f2f' }}>Überfällige Produkte Erinnerung senden</h3>
{ueberfaellige.map((row, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
<span style={{ flex: 2 }}>
<b>{row.kunde}</b> {row.artikel} ({row.soll - row.ist} Stück)
</span>
<button
disabled={sending[row.artikel]}
onClick={() => sendeMail(row)}
style={{ background: '#1976d2', color: '#fff', border: 'none', borderRadius: 8, padding: '7px 16px', fontWeight: 700, fontSize: 15, cursor: sending[row.artikel] ? 'not-allowed' : 'pointer' }}>
Erinnerung senden
</button>
<span style={{ color: msg[row.artikel]?.startsWith('Fehler') ? '#d32f2f' : '#1976d2', fontWeight: 600, marginLeft: 8 }}>{msg[row.artikel]}</span>
</div>
))}
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>
Nur Kunden mit ausgeliehenen Produkten und E-Mail werden angezeigt. Die Erinnerungsmail enthält eine freundliche Bitte um Rückgabe.
</div>
</div>
);
}
export default function AdminDashboard() {
const [kunden, setKunden] = useState([]);
const [artikel, setArtikel] = useState([]);
const [mitarbeiter, setMitarbeiter] = useState([]);
const [lieferungen, setLieferungen] = useState([]);
const [ruecknahmen, setRuecknahmen] = useState([]);
const [data, setData] = useState({ kunden: [], artikel: [], mitarbeiter: [], lieferungen: [], ruecknahmen: [] });
const [loading, setLoading] = useState(true);
const [selectedKunde, setSelectedKunde] = useState('');
const [selectedMitarbeiter, setSelectedMitarbeiter] = useState('');
const [zeitraumVon, setZeitraumVon] = useState('');
const [zeitraumBis, setZeitraumBis] = useState('');
const [rows, setRows] = useState([]);
const [von, setVon] = useState('');
const [bis, setBis] = useState('');
useEffect(() => {
async function loadData() {
try {
const [kundenData, artikelData, mitarbeiterData, lieferungenData, ruecknahmenData] = await Promise.all([
api.getKunden(),
api.getArtikel(),
api.getMitarbeiter(),
api.getLieferungen(),
api.getRuecknahmen()
]);
setKunden(kundenData || []);
setArtikel(artikelData || []);
setMitarbeiter(mitarbeiterData || []);
setLieferungen(lieferungenData || []);
setRuecknahmen(ruecknahmenData || []);
} catch (error) {
console.error('Fehler beim Laden:', error);
}
}
loadData();
Promise.all([api.getKunden(), api.getArtikel(), api.getMitarbeiter(), api.getLieferungen(), api.getRuecknahmen()])
.then(([k, a, m, l, r]) => setData({ kunden: k, artikel: a, mitarbeiter: m, lieferungen: l, ruecknahmen: r }))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!selectedKunde) { setRows([]); return; }
const filterByDate = (arr) => arr.filter(e => {
const rows = useMemo(() => {
if (!selectedKunde) return [];
const inRange = e => {
const d = new Date(e.erstellt_am);
if (zeitraumVon && d < new Date(zeitraumVon)) return false;
if (zeitraumBis && d > new Date(zeitraumBis + 'T23:59:59')) return false;
if (von && d < new Date(von)) return false;
if (bis && d > new Date(bis + 'T23:59:59')) return false;
return true;
});
let lieferungenK = lieferungen.filter(l => l.kunde_id === selectedKunde);
let ruecknahmenK = ruecknahmen.filter(r => r.kunde_id === selectedKunde);
if (selectedMitarbeiter) {
lieferungenK = lieferungenK.filter(l => l.mitarbeiter_id === selectedMitarbeiter);
ruecknahmenK = ruecknahmenK.filter(r => r.mitarbeiter_id === selectedMitarbeiter);
}
lieferungenK = filterByDate(lieferungenK);
ruecknahmenK = filterByDate(ruecknahmenK);
const artikelSet = Array.from(new Set([...lieferungenK.map(l => l.artikel_id), ...ruecknahmenK.map(r => r.artikel_id)]));
const rowsNew = artikelSet.map(artId => {
const art = artikel.find(a => a.id === artId);
const soll = lieferungenK.filter(l => l.artikel_id === artId).reduce((sum, l) => sum + l.anzahl, 0);
const ist = ruecknahmenK.filter(r => r.artikel_id === artId).reduce((sum, r) => sum + r.anzahl, 0);
// Mitarbeiter-Name aus erster Lieferung/Rücknahme (wenn eindeutig)
let mitarbeiterName = '';
const mitarbeiterId = lieferungenK.concat(ruecknahmenK).find(v => v.artikel_id === artId)?.mitarbeiter_id;
if (mitarbeiterId) {
mitarbeiterName = mitarbeiter.find(m => m.id === mitarbeiterId)?.name || '';
}
};
const l = data.lieferungen.filter(x => x.kunde_id === selectedKunde && inRange(x)
&& (!selectedMitarbeiter || x.mitarbeiter_id === selectedMitarbeiter));
const r = data.ruecknahmen.filter(x => x.kunde_id === selectedKunde && inRange(x)
&& (!selectedMitarbeiter || x.mitarbeiter_id === selectedMitarbeiter));
const artikelIds = Array.from(new Set([...l.map(x => x.artikel_id), ...r.map(x => x.artikel_id)]));
return artikelIds.map(aid => {
const art = data.artikel.find(a => a.id === aid);
const soll = l.filter(x => x.artikel_id === aid).reduce((s, x) => s + x.anzahl, 0);
const ist = r.filter(x => x.artikel_id === aid).reduce((s, x) => s + x.anzahl, 0);
const mitId = [...l, ...r].find(v => v.artikel_id === aid)?.mitarbeiter_id;
const mit = mitId ? data.mitarbeiter.find(m => m.id === mitId)?.name : '';
return {
kunde: kunden.find(k => k.id === selectedKunde)?.name || '',
kunde_id: selectedKunde, // <-- Kunden-ID für eindeutige Zuordnung
kunde: data.kunden.find(k => k.id === selectedKunde)?.name || '',
artikel: art?.bezeichnung || '',
soll,
ist,
differenz: ist - soll,
zeitraum: (zeitraumVon || '...') + ' bis ' + (zeitraumBis || '...'),
mitarbeiter: mitarbeiterName,
soll, ist, differenz: ist - soll,
zeitraum: (von || '…') + ' ' + (bis || '…'),
mitarbeiter: mit || '',
};
});
setRows(rowsNew);
}, [selectedKunde, zeitraumVon, zeitraumBis, lieferungen, ruecknahmen, artikel, kunden, selectedMitarbeiter]);
}, [data, selectedKunde, selectedMitarbeiter, von, bis]);
if (loading) return <div className="text-center py-8 text-slate-500">Lade</div>;
return (
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: '16px 4vw', minWidth: 0, maxWidth: '100%', margin: '24px auto', textAlign: 'center' }}>
<h2 style={{ marginBottom: 16, fontSize: 22 }}>Admin: Soll-Ist-Abgleich & CSV-Export</h2>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 22, alignItems: 'center', justifyContent: 'center' }}>
<select value={selectedKunde} onChange={e => setSelectedKunde(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe', minWidth: 160 }}>
<option value=''>Kunde wählen</option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<select value={selectedMitarbeiter} onChange={e => setSelectedMitarbeiter(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe', minWidth: 120 }}>
<option value=''>Mitarbeiter (alle)</option>
{mitarbeiter.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
<input type='date' value={zeitraumVon} onChange={e => setZeitraumVon(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe' }} placeholder='Von' />
<input type='date' value={zeitraumBis} onChange={e => setZeitraumBis(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe' }} placeholder='Bis' />
<button disabled={!selectedKunde || rows.length === 0} onClick={() => exportToCSV(rows, `Abgleich_${kunden.find(k => k.id === selectedKunde)?.name || 'kunde'}.csv`)} style={{ background: '#1976d2', color: '#fff', border: 'none', borderRadius: 10, padding: '10px 18px', fontWeight: 700, fontSize: 16, cursor: (!selectedKunde || rows.length === 0) ? 'not-allowed' : 'pointer', boxShadow: '0 2px 10px #1976d222' }} className="export-button">CSV Export</button>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-slate-900">Auswertung · Soll-Ist-Abgleich</h2>
<div className="card flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-[160px]">
<label className="label">Kunde</label>
<select className="input" value={selectedKunde} onChange={e => setSelectedKunde(e.target.value)}>
<option value=""> waehlen </option>
{data.kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</div>
<div className="flex-1 min-w-[160px]">
<label className="label">Mitarbeiter</label>
<select className="input" value={selectedMitarbeiter} onChange={e => setSelectedMitarbeiter(e.target.value)}>
<option value="">Alle</option>
{data.mitarbeiter.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
<div className="flex-1 min-w-[140px]">
<label className="label">Von</label>
<input type="date" className="input" value={von} onChange={e => setVon(e.target.value)} />
</div>
<div className="flex-1 min-w-[140px]">
<label className="label">Bis</label>
<input type="date" className="input" value={bis} onChange={e => setBis(e.target.value)} />
</div>
<button onClick={() => downloadCSV(rows, `abgleich_${selectedKunde || 'alle'}.csv`)}
disabled={!selectedKunde || rows.length === 0} className="btn-primary">
<FaFileCsv /> CSV-Export
</button>
</div>
<div style={{ overflowX: 'auto', margin: '16px 0' }}>
{rows.length > 0 && (
<>
{/* Desktop/Tablett: Tabelle */}
<div className="admin-table-wrap" style={{ display: 'block' }}>
<table style={{ width: '100%', minWidth: 520, borderCollapse: 'collapse', fontSize: 15 }}>
<thead>
<tr style={{ background: '#eaf3ff' }}>
<th style={{ padding: 8, borderRadius: 8 }}>Artikel</th>
<th style={{ padding: 8 }}>Soll</th>
<th style={{ padding: 8 }}>Ist</th>
<th style={{ padding: 8 }}>Differenz</th>
<th style={{ padding: 8 }}>Zeitraum</th>
<th style={{ padding: 8 }}>Mitarbeiter</th>
{selectedKunde ? (
rows.length === 0 ? <div className="card text-center text-slate-500">Keine Eintraege im Zeitraum.</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">Artikel</th>
<th className="text-right px-4 py-3">Soll</th>
<th className="text-right px-4 py-3">Ist</th>
<th className="text-right px-4 py-3">Differenz</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Mitarbeiter</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((r, i) => (
<tr key={i}>
<td className="px-4 py-3 font-medium">{r.artikel}</td>
<td className="px-4 py-3 text-right">{r.soll}</td>
<td className="px-4 py-3 text-right">{r.ist}</td>
<td className={`px-4 py-3 text-right font-semibold ${r.differenz < 0 ? 'text-rose-600' : r.differenz > 0 ? 'text-emerald-600' : ''}`}>
{r.differenz}
</td>
<td className="px-4 py-3 text-slate-600 hidden md:table-cell">{r.mitarbeiter || '—'}</td>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} style={{ background: i % 2 ? '#f6f7fb' : '#fff' }}>
<td style={{ padding: 8 }}>{row.artikel}</td>
<td style={{ padding: 8 }}>{row.soll}</td>
<td style={{ padding: 8 }}>{row.ist}</td>
<td style={{ padding: 8, color: row.differenz < 0 ? '#d32f2f' : row.differenz > 0 ? '#1976d2' : '#222', fontWeight: 600 }}>{row.differenz}</td>
<td style={{ padding: 8 }}>{row.zeitraum}</td>
<td style={{ padding: 8 }}>{row.mitarbeiter}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobil: Cards */}
<div className="admin-cards-wrap" style={{ display: 'none' }}>
{rows.map((row, i) => (
<div key={i} style={{ background: '#f6f7fb', borderRadius: 12, boxShadow: '0 1px 6px #0001', padding: 12, fontSize: 15, textAlign: 'left' }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{row.artikel}</div>
<div><b>Soll:</b> {row.soll} &nbsp; <b>Ist:</b> {row.ist}</div>
<div><b>Differenz:</b> <span style={{ color: row.differenz < 0 ? '#d32f2f' : row.differenz > 0 ? '#1976d2' : '#222', fontWeight: 600 }}>{row.differenz}</span></div>
<div style={{ fontSize: 13, color: '#888' }}>{row.zeitraum}</div>
<div style={{ fontSize: 13, color: '#888' }}><b>Mitarbeiter:</b> {row.mitarbeiter}</div>
</div>
))}
</div>
</>
)}
</div>
{/* Überfällige Kunden */}
<UeberfaelligeKunden kunden={kunden} rows={rows} />
<style>
{`
@media (max-width: 600px) {
.admin-table-wrap {
display: none;
}
.admin-cards-wrap {
display: flex;
flex-direction: column;
gap: 10;
}
.export-button {
position: sticky;
bottom: 0;
left: 0;
width: 100%;
padding: 10px 18px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
background-color: #1976d2;
color: #fff;
border: none;
border-radius: 10px;
box-shadow: 0 2px 10px #1976d222;
}
}
`}
</style>
))}
</tbody>
</table>
</div>
)
) : (
<div className="card text-center text-slate-500">Bitte Kunden waehlen, um Auswertung zu sehen.</div>
)}
</div>
);
}

View File

@@ -1,15 +1,13 @@
import React from 'react';
export default function BildModal({ open, url, onClose }) {
if (!open) return null;
return (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
}} onClick={onClose}>
<div style={{ position: 'relative', background: '#fff', borderRadius: 12, padding: 12, boxShadow: '0 4px 32px #0002', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<button onClick={onClose} style={{ position: 'absolute', top: 8, right: 8, background: '#eee', border: 'none', borderRadius: 20, width: 32, height: 32, fontSize: 18, cursor: 'pointer' }}>×</button>
<img src={url} alt="Vorschau" style={{ maxWidth: '80vw', maxHeight: '80vh', borderRadius: 8 }} />
<div onClick={onClose}
className="fixed inset-0 z-50 bg-slate-900/70 backdrop-blur-sm flex items-center justify-center p-4">
<div onClick={e => e.stopPropagation()}
className="relative bg-white rounded-xl p-3 max-w-3xl max-h-[90vh] shadow-soft">
<button onClick={onClose}
className="absolute -top-2 -right-2 h-8 w-8 rounded-full bg-white shadow-soft ring-1 ring-slate-200 text-slate-600 hover:bg-slate-50">×</button>
<img src={url} alt="Vorschau" className="max-w-full max-h-[80vh] rounded-lg" />
</div>
</div>
);

View File

@@ -1,64 +1,55 @@
import { useState } from 'react';
import TopNav from './TopNav';
import VorgangForm from '../Eingabe/VorgangForm';
import VorgangsListe from './VorgangsListe';
import Logout from '../Auth/Logout';
import PasswordChange from '../Auth/PasswordChange';
import { useState } from 'react';
function AccountInfo({ user }) {
return (
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 32, minWidth: 320, maxWidth: 500, margin: '32px auto', textAlign: 'center' }}>
<h2 style={{ marginBottom: 16 }}>Account-Info</h2>
<div style={{ marginBottom: 12 }}><b>E-Mail:</b> <br />{user.email}</div>
<div style={{ marginBottom: 12 }}><b>Name:</b> <br />{user.name}</div>
<div style={{ marginBottom: 12 }}><b>Rolle:</b> <br />{user.rolle}</div>
<Logout />
<PasswordChange />
</div>
);
}
import AdminDashboard from './AdminDashboard';
import MitarbeiterVerwaltung from '../MitarbeiterVerwaltung';
import KundenVerwaltung from '../KundenVerwaltung';
import MitarbeiterVerwaltung from '../MitarbeiterVerwaltung';
import Geraete from '../Geraete';
import TourPlanung from '../TourPlanung';
import Account from './Account';
export default function Dashboard({ user }) {
export default function Dashboard({ user, onLogout }) {
const [refreshFlag, setRefreshFlag] = useState(0);
const [activeView, setActiveView] = useState('vorgang');
const isAdmin = user.rolle === 'admin';
return (
<div style={{ minHeight: '100vh', background: '#f6f7fb' }}>
<TopNav activeView={activeView} setActiveView={setActiveView} isAdmin={isAdmin} />
<main style={{ maxWidth: 900, margin: '0 auto', padding: '24px 8px', display: 'flex', flexDirection: 'column', gap: 28 }}>
{activeView === 'account' && <AccountInfo user={user} />}
<div className="min-h-screen bg-slate-50">
<TopNav user={user} activeView={activeView} setActiveView={setActiveView} onLogout={onLogout} />
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{activeView === 'vorgang' && (
<div style={{ background: '#fff', borderRadius: 22, boxShadow: '0 4px 22px #1976d222', padding: 36, maxWidth: 420, margin: '48px auto', minWidth: 0 }}>
<div className="max-w-md mx-auto">
<h2 className="text-xl font-semibold text-slate-900 mb-4">Neuer Vorgang</h2>
<VorgangForm user={user} onSubmit={() => setRefreshFlag(f => f + 1)} />
</div>
)}
{activeView === 'lieferungen' && (
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 18, maxWidth: 600, margin: '32px auto', minWidth: 0 }}>
<h2 style={{ textAlign: 'center', fontWeight: 700, fontSize: 22, marginBottom: 18 }}>Lieferungen</h2>
<Section title="Lieferungen">
<VorgangsListe refreshFlag={refreshFlag} mode="lieferungen" />
</div>
</Section>
)}
{activeView === 'ruecknahmen' && (
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 18, maxWidth: 600, margin: '32px auto', minWidth: 0 }}>
<h2 style={{ textAlign: 'center', fontWeight: 700, fontSize: 22, marginBottom: 18 }}>Rücknahmen</h2>
<Section title="Ruecknahmen">
<VorgangsListe refreshFlag={refreshFlag} mode="ruecknahmen" />
</div>
)}
{isAdmin && activeView === 'admin' && (
<AdminDashboard />
)}
{isAdmin && activeView === 'mitarbeiter' && (
<MitarbeiterVerwaltung user={user} />
)}
{isAdmin && activeView === 'kunden' && (
<KundenVerwaltung />
</Section>
)}
{activeView === 'tour' && <TourPlanung />}
{activeView === 'account' && <Account user={user} />}
{isAdmin && activeView === 'admin' && <AdminDashboard />}
{isAdmin && activeView === 'kunden' && <KundenVerwaltung />}
{isAdmin && activeView === 'mitarbeiter' && <MitarbeiterVerwaltung user={user} />}
{isAdmin && activeView === 'geraete' && <Geraete />}
</main>
</div>
);
}
function Section({ title, children }) {
return (
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">{title}</h2>
{children}
</div>
);
}

View File

@@ -1,40 +0,0 @@
import { FaHome, FaList, FaBox, FaBell, FaComments, FaCog } from 'react-icons/fa';
const navItems = [
{ icon: <FaHome />, label: 'Dashboard' },
{ icon: <FaList />, label: 'Vorgänge' },
{ icon: <FaBox />, label: 'Artikel' },
{ icon: <FaBell />, label: 'Benachrichtigungen' },
{ icon: <FaComments />, label: 'Chat' },
{ icon: <FaCog />, label: 'Einstellungen' },
];
export default function Sidebar({ active = 0 }) {
return (
<aside style={{
width: 80, background: 'linear-gradient(180deg, #eaeaea 0%, #f8f8f8 100%)',
borderRadius: 28, margin: 12, padding: '24px 0', display: 'flex', flexDirection: 'column', alignItems: 'center', boxShadow: '2px 0 16px #0001',
minHeight: '85vh',
}}>
<div style={{ width: 48, height: 48, borderRadius: 24, background: '#fff', marginBottom: 32, boxShadow: '0 2px 8px #0001' }} />
{navItems.map((item, i) => (
<div key={item.label} style={{
background: active === i ? '#fff' : 'none',
borderRadius: 16,
margin: '8px 0',
width: 48,
height: 48,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: active === i ? '#1976d2' : '#888',
fontSize: 24,
cursor: 'pointer',
boxShadow: active === i ? '0 2px 8px #1976d230' : 'none',
}}>
{item.icon}
</div>
))}
</aside>
);
}

View File

@@ -1,96 +1,112 @@
import { FaUser, FaPlusCircle, FaTruck, FaUndo, FaKey, FaUsers, FaAddressBook } from 'react-icons/fa';
import { useState, useRef, useEffect } from 'react';
import {
FaPlusCircle, FaTruck, FaUndo, FaUsers, FaAddressBook,
FaChartBar, FaBars, FaUserCircle, FaSignOutAlt, FaBoxes, FaMapMarkedAlt
} from 'react-icons/fa';
const baseNavItems = [
{ icon: <FaUser />, label: 'Account', view: 'account' },
{ icon: <FaPlusCircle />, label: 'Neuer Vorgang', view: 'vorgang' },
{ icon: <FaTruck />, label: 'Lieferungen', view: 'lieferungen' },
{ icon: <FaUndo />, label: 'Rücknahmen', view: 'ruecknahmen' },
const mainItems = [
{ icon: FaPlusCircle, label: 'Vorgang', view: 'vorgang' },
{ icon: FaTruck, label: 'Lieferungen', view: 'lieferungen' },
{ icon: FaUndo, label: 'Ruecknahmen', view: 'ruecknahmen' },
{ icon: FaMapMarkedAlt,label: 'Tour', view: 'tour' },
];
import { useState } from 'react';
const adminItems = [
{ icon: FaChartBar, label: 'Auswertung', view: 'admin' },
{ icon: FaAddressBook, label: 'Kunden', view: 'kunden' },
{ icon: FaBoxes, label: 'Geraete', view: 'geraete' },
{ icon: FaUsers, label: 'Mitarbeiter', view: 'mitarbeiter' },
];
export default function TopNav({ activeView, setActiveView, isAdmin }) {
const navItems = isAdmin
? [...baseNavItems, { icon: <FaKey />, label: 'Admin', view: 'admin' }]
: baseNavItems;
const adminMenuItems = isAdmin
? [
{ icon: <FaUsers />, label: 'Mitarbeiter', view: 'mitarbeiter' },
{ icon: <FaAddressBook />, label: 'Kunden', view: 'kunden' },
{ icon: <FaKey />, label: 'Admin', view: 'admin' },
{ icon: <FaUser />, label: 'Account', view: 'account' },
]
: [{ icon: <FaUser />, label: 'Account', view: 'account' }];
export default function TopNav({ user, activeView, setActiveView, onLogout }) {
const isAdmin = user.rolle === 'admin';
const items = mainItems;
const [menuOpen, setMenuOpen] = useState(false);
const handleMenuClick = () => setMenuOpen(open => !open);
const handleMenuSelect = (view) => {
setActiveView(view);
setMenuOpen(false);
};
const menuRef = useRef(null);
useEffect(() => {
const handler = e => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const select = (v) => { setActiveView(v); setMenuOpen(false); };
return (
<header style={{
background: '#fff', boxShadow: '0 4px 18px #0002', padding: 0, position: 'sticky', top: 0, zIndex: 100,
borderBottomLeftRadius: 20, borderBottomRightRadius: 20, marginBottom: 12
}}>
<div style={{
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
fontWeight: 700, fontSize: 32, letterSpacing: 0.5, color: '#222',
textAlign: 'center', padding: '18px 0 6px 0',
textShadow: '0 2px 8px #0001'
}}>
Pfandsystem
</div>
<nav style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: 8, padding: '0 0 8px 0',
maxWidth: 480, margin: '0 auto',
}}>
{/* Hamburger-Menü für Account/Admin auf Mobil, Platzhalter-Logik */}
<div style={{ display: 'flex', flex: 1 }}>
{navItems.filter(item => item.view !== 'account' && item.view !== 'admin').map(item => (
<button
key={item.label}
onClick={() => setActiveView(item.view)}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: activeView === item.view ? '#eaf3ff' : 'none',
border: 'none', borderRadius: 12, padding: '7px 7px', cursor: 'pointer',
color: activeView === item.view ? '#1976d2' : '#444', fontWeight: 600, fontSize: 13,
minWidth: 54, boxShadow: activeView === item.view ? '0 2px 12px #1976d222' : '0 1px 4px #0001',
transition: 'all 0.18s', margin: '0 2px'
}}
>
<span style={{ fontSize: 19, marginBottom: 1 }}>{item.icon}</span>
<span style={{ fontSize: 12 }}>{item.label}</span>
</button>
))}
<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 flex items-center h-14">
<div className="flex items-center gap-2 mr-6">
<div className="h-8 w-8 rounded-lg bg-brand-600 text-white grid place-items-center font-bold">P</div>
<span className="font-semibold text-slate-900">Pfandsystem</span>
{user.tenant_typ === 'demo' && (
<span className="badge-amber ml-2">Demo</span>
)}
</div>
{/* Hamburger-Menü (Platzhalter, öffnet Account/Admin) */}
<div style={{ position: 'relative', marginLeft: 4 }}>
<button onClick={handleMenuClick} style={{ background: 'none', border: 'none', padding: 7, borderRadius: 10, cursor: 'pointer', fontSize: 20 }}>
<span style={{ fontSize: 22 }}></span>
<nav className="hidden md:flex items-center gap-1 flex-1">
{items.map(it => (
<NavBtn key={it.view} {...it} active={activeView === it.view} onClick={() => select(it.view)} />
))}
{isAdmin && (
<>
<div className="h-5 w-px bg-slate-200 mx-2" />
{adminItems.map(it => (
<NavBtn key={it.view} {...it} active={activeView === it.view} onClick={() => select(it.view)} />
))}
</>
)}
</nav>
<div className="flex-1 md:flex-none" />
<div className="relative" ref={menuRef}>
<button onClick={() => setMenuOpen(o => !o)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-100">
<FaUserCircle className="text-slate-500" size={22} />
<span className="hidden sm:inline text-sm text-slate-700">{user.name || user.email}</span>
</button>
{menuOpen && (
<div style={{ position: 'absolute', right: 0, top: 36, background: '#fff', borderRadius: 10, boxShadow: '0 2px 12px #0002', minWidth: 140, zIndex: 20 }}>
{adminMenuItems.map(item => (
<button
key={item.label}
onClick={() => handleMenuSelect(item.view)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', padding: '10px 14px', cursor: 'pointer',
color: activeView === item.view ? '#1976d2' : '#444', fontWeight: 600, fontSize: 14,
borderBottom: '1px solid #eee',
}}
>
<span style={{ fontSize: 18 }}>{item.icon}</span> {item.label}
</button>
))}
<div className="absolute right-0 top-full mt-1 w-56 bg-white rounded-lg shadow-soft ring-1 ring-slate-200 py-1">
<div className="px-3 py-2 border-b border-slate-100">
<div className="text-sm font-medium text-slate-900">{user.name}</div>
<div className="text-xs text-slate-500">{user.email}</div>
<div className="text-xs text-slate-400 mt-0.5">Rolle: {user.rolle}</div>
</div>
<button className="menu-item" onClick={() => select('account')}>
<FaUserCircle /> Account
</button>
<div className="md:hidden border-t border-slate-100 mt-1 pt-1">
{items.map(it => (
<button key={it.view} className="menu-item" onClick={() => select(it.view)}>
<it.icon /> {it.label}
</button>
))}
{isAdmin && adminItems.map(it => (
<button key={it.view} className="menu-item" onClick={() => select(it.view)}>
<it.icon /> {it.label}
</button>
))}
</div>
<button className="menu-item text-rose-600 border-t border-slate-100 mt-1 pt-2" onClick={onLogout}>
<FaSignOutAlt /> Abmelden
</button>
</div>
)}
</div>
</nav>
</div>
<style>{`
.menu-item { @apply flex w-full items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50; }
`}</style>
</header>
);
}
function NavBtn({ icon: Icon, label, active, onClick }) {
return (
<button onClick={onClick}
className={`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition
${active ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}`}>
<Icon size={14} />{label}
</button>
);
}

View File

@@ -1,110 +1,88 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import BildModal from './BildModal';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
function FotoCell({ path }) {
function Foto({ path }) {
const [open, setOpen] = useState(false);
if (!path) return <>-</>;
// Foto-URL direkt vom Backend
const url = path.startsWith('http') ? path : `${API_BASE_URL.replace('/api', '')}${path}`;
if (!path) return <div className="h-16 w-16 rounded-lg bg-slate-100 grid place-items-center text-slate-300 text-xs"></div>;
const url = path.startsWith('http') ? path : `${API_BASE.replace('/api', '')}${path}`;
return (
<>
<img src={url} alt="Foto" style={{ maxWidth: 60, maxHeight: 60, borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
onClick={() => setOpen(true)} />
<img src={url} alt="Foto" onClick={() => setOpen(true)}
className="h-16 w-16 object-cover rounded-lg ring-1 ring-slate-200 cursor-pointer hover:opacity-90" />
<BildModal open={open} url={url} onClose={() => setOpen(false)} />
</>
);
}
export default function VorgangsListe({ refreshFlag, mode }) {
const [lieferungen, setLieferungen] = useState([]);
const [ruecknahmen, setRuecknahmen] = useState([]);
const [loading, setLoading] = useState(true);
const [list, setList] = useState([]);
const [kunden, setKunden] = useState([]);
const [artikel, setArtikel] = useState([]);
const [filterKunde, setFilterKunde] = useState('');
const [filterArtikel, setFilterArtikel] = useState('');
const [filterVon, setFilterVon] = useState('');
const [filterBis, setFilterBis] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadData() {
setLoading(true);
try {
const [lieferungenData, ruecknahmenData, kundenData, artikelData] = await Promise.all([
api.getLieferungen(),
api.getRuecknahmen(),
api.getKunden(),
api.getArtikel()
]);
setLieferungen(lieferungenData || []);
setRuecknahmen(ruecknahmenData || []);
setKunden(kundenData || []);
setArtikel(artikelData || []);
} catch (error) {
console.error('Fehler beim Laden:', error);
} finally {
setLoading(false);
}
}
loadData();
}, [refreshFlag]);
setLoading(true);
Promise.all([
mode === 'lieferungen' ? api.getLieferungen() : api.getRuecknahmen(),
api.getKunden(),
api.getArtikel(),
]).then(([d, k, a]) => { setList(d || []); setKunden(k || []); setArtikel(a || []); })
.finally(() => setLoading(false));
}, [refreshFlag, mode]);
if (loading) return <div style={{ textAlign: 'center', margin: 24 }}>Lädt...</div>;
let list = mode === 'lieferungen' ? lieferungen : mode === 'ruecknahmen' ? ruecknahmen : [];
// Filter anwenden
if (filterKunde) list = list.filter(l => l.kunde_id === filterKunde);
if (filterArtikel) list = list.filter(l => l.artikel_id === filterArtikel);
if (filterVon) list = list.filter(l => new Date(l.erstellt_am) >= new Date(filterVon));
if (filterBis) list = list.filter(l => new Date(l.erstellt_am) <= new Date(filterBis + 'T23:59:59'));
let view = list;
if (filterKunde) view = view.filter(l => l.kunde_id === filterKunde);
if (filterArtikel) view = view.filter(l => l.artikel_id === filterArtikel);
if (filterVon) view = view.filter(l => new Date(l.erstellt_am) >= new Date(filterVon));
if (filterBis) view = view.filter(l => new Date(l.erstellt_am) <= new Date(filterBis + 'T23:59:59'));
return (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 18, alignItems: 'center', justifyContent: 'center' }}>
<select value={filterKunde} onChange={e => setFilterKunde(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb', minWidth: 110 }}>
<option value=''>Kunde</option>
<div className="space-y-4">
<div className="card flex flex-wrap gap-3">
<select className="input flex-1 min-w-[140px]" value={filterKunde} onChange={e => setFilterKunde(e.target.value)}>
<option value="">Alle Kunden</option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<select value={filterArtikel} onChange={e => setFilterArtikel(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb', minWidth: 110 }}>
<option value=''>Artikel</option>
<select className="input flex-1 min-w-[140px]" value={filterArtikel} onChange={e => setFilterArtikel(e.target.value)}>
<option value="">Alle Artikel</option>
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
</select>
<input type='date' value={filterVon} onChange={e => setFilterVon(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb' }} placeholder='Von' />
<input type='date' value={filterBis} onChange={e => setFilterBis(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb' }} placeholder='Bis' />
<input type="date" className="input flex-1 min-w-[140px]" value={filterVon} onChange={e => setFilterVon(e.target.value)} />
<input type="date" className="input flex-1 min-w-[140px]" value={filterBis} onChange={e => setFilterBis(e.target.value)} />
</div>
{list.length === 0 ? (
<div style={{ textAlign: 'center', color: '#888', margin: 32 }}>Keine Daten vorhanden.</div>
{loading ? (
<div className="text-center text-slate-500 py-12">Lade</div>
) : view.length === 0 ? (
<div className="card text-center text-slate-500">Keine Eintraege.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{list.map(item => (
<div key={item.id} style={{
background: '#f6f7fb',
borderRadius: 16,
boxShadow: '0 2px 10px #0001',
padding: 16,
display: 'flex', alignItems: 'center', gap: 14,
flexWrap: 'wrap',
}}>
<div style={{ minWidth: 62, minHeight: 62, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FotoCell path={item.foto_url} />
<ul className="space-y-2">
{view.map(item => (
<li key={item.id} className="card flex items-center gap-4 py-3">
<Foto path={item.foto_url} />
<div className="flex-1 min-w-0">
<div className="font-semibold text-slate-900 truncate">{item.kunde_name || 'Unbekannt'}</div>
<div className="text-sm text-brand-700 truncate">{item.artikel_bezeichnung}</div>
<div className="text-xs text-slate-500 mt-0.5">
{new Date(item.erstellt_am).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })}
{item.mitarbeiter_name && <> · {item.mitarbeiter_name}</>}
</div>
</div>
<div style={{ flex: 1, minWidth: 120 }}>
<div style={{ fontWeight: 700, fontSize: 17, marginBottom: 2 }}>{item.kunde_name || 'Unbekannt'}</div>
<div style={{ fontSize: 15, color: '#1976d2', fontWeight: 600 }}>{item.artikel_bezeichnung}</div>
<div style={{ fontSize: 14, color: '#444', marginTop: 2 }}>Menge: <b>{item.anzahl}</b></div>
<div style={{ fontSize: 13, color: '#888', marginTop: 2 }}>{new Date(item.erstellt_am).toLocaleString()}</div>
<div className="text-right">
<div className="text-xl font-semibold text-slate-900">{item.anzahl}</div>
<div className="text-xs text-slate-500">Stueck</div>
</div>
</div>
</li>
))}
</div>
</ul>
)}
</>
</div>
);
}

Binary file not shown.

View File

@@ -1,140 +1,141 @@
import { useEffect, useState } from 'react';
import { FaCamera, FaInfoCircle } from 'react-icons/fa';
import { api } from '../../api/client';
export default function VorgangForm({ user, onSubmit }) {
const API_BASE = import.meta.env.VITE_API_URL || '/api';
export default function VorgangForm({ onSubmit }) {
const [kunden, setKunden] = useState([]);
const [artikel, setArtikel] = useState([]);
const [kundeId, setKundeId] = useState('');
const [artikelId, setArtikelId] = useState('');
const [anzahl, setAnzahl] = useState(1);
const [foto, setFoto] = useState(null);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [infoOpen, setInfoOpen] = useState(false);
const [kundenDetail, setKundenDetail] = useState(null);
useEffect(() => {
async function loadData() {
try {
const [kundenData, artikelData] = await Promise.all([
api.getKunden(),
api.getArtikel()
]);
setKunden(kundenData || []);
setArtikel(artikelData || []);
} catch (error) {
console.error('Fehler beim Laden:', error);
}
}
loadData();
Promise.all([api.getKunden(), api.getArtikel()])
.then(([k, a]) => { setKunden(k || []); setArtikel(a || []); });
}, []);
const handleFotoChange = (e) => {
if (e.target.files && e.target.files[0]) {
setFoto(e.target.files[0]);
}
const openInfo = async () => {
if (!kundeId) return;
try { setKundenDetail(await api.getKunde(kundeId)); setInfoOpen(true); }
catch (e) { setMsg(e.message); }
};
const handleSubmit = async (e, typ) => {
e.preventDefault();
setUploading(true);
setMessage('');
const submit = async (typ) => {
if (!kundeId || !artikelId) return setMsg('Bitte Kunde und Artikel waehlen');
setBusy(true); setMsg('');
try {
const vorgangData = {
kunde_id: kundeId,
artikel_id: artikelId,
anzahl: parseInt(anzahl)
};
if (typ === 'lieferung') {
await api.createLieferung(vorgangData, foto);
} else {
await api.createRuecknahme(vorgangData, foto);
}
setMessage('Vorgang gespeichert!');
setKundeId('');
setArtikelId('');
setAnzahl(1);
setFoto(null);
if (onSubmit) onSubmit();
} catch (error) {
setMessage('Fehler beim Speichern: ' + error.message);
} finally {
setUploading(false);
}
const data = { kunde_id: kundeId, artikel_id: artikelId, anzahl: parseInt(anzahl) };
if (typ === 'lieferung') await api.createLieferung(data, foto);
else await api.createRuecknahme(data, foto);
setMsg('Gespeichert.'); setKundeId(''); setArtikelId(''); setAnzahl(1); setFoto(null);
onSubmit?.();
} catch (e) { setMsg(e.message); } finally { setBusy(false); }
};
return (
<form style={{
maxWidth: 420,
margin: '0 auto',
background: '#fff',
borderRadius: 22,
boxShadow: '0 4px 22px #1976d222',
padding: 36,
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
display: 'flex', flexDirection: 'column', gap: 18,
}} onSubmit={e => e.preventDefault()}>
<h2 style={{ textAlign: 'center', marginBottom: 18, fontWeight: 700, color: '#1976d2', fontSize: 24 }}>Neuer Vorgang</h2>
<select value={kundeId} onChange={e => setKundeId(e.target.value)} required style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }}>
<option value="">Kunde wählen</option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<select value={artikelId} onChange={e => setArtikelId(e.target.value)} required style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }}>
<option value="">Artikel wählen</option>
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
</select>
<input type="number" min="1" value={anzahl} onChange={e => setAnzahl(e.target.value)} required placeholder="Anzahl" style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }} />
<input
type="file"
accept="image/*"
onChange={handleFotoChange}
style={{ display: 'none' }}
id="foto-upload-input"
/>
<label htmlFor="foto-upload-input" style={{
display: 'block',
background: '#1976d2',
color: '#fff',
borderRadius: 12,
padding: '13px 0',
fontWeight: 700,
fontSize: 17,
textAlign: 'center',
margin: '10px 0 0 0',
cursor: 'pointer',
boxShadow: '0 2px 10px #1976d222',
transition: 'background 0.18s',
}}>
📷 Foto erstellen oder auswählen
</label>
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
<button type="button" disabled={uploading} onClick={e => handleSubmit(e, 'lieferung')} style={{
flex: 1,
background: '#1976d2',
color: '#fff',
borderRadius: 12,
fontWeight: 700,
fontSize: 17,
border: 'none',
padding: '13px 0',
boxShadow: '0 2px 10px #1976d222',
cursor: uploading ? 'not-allowed' : 'pointer',
}}>Ausliefern</button>
<button type="button" disabled={uploading} onClick={e => handleSubmit(e, 'ruecknahme')} style={{
flex: 1,
background: '#f6f7fb',
color: '#1976d2',
borderRadius: 12,
fontWeight: 700,
fontSize: 17,
border: '1.5px solid #dbeafe',
padding: '13px 0',
boxShadow: '0 2px 10px #1976d222',
cursor: uploading ? 'not-allowed' : 'pointer',
}}>Zurücknehmen</button>
<div className="card space-y-4">
<div>
<label className="label">Kunde</label>
<div className="flex gap-2">
<select className="input flex-1" value={kundeId} onChange={e => setKundeId(e.target.value)}>
<option value=""> waehlen </option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<button type="button" disabled={!kundeId} onClick={openInfo}
className="btn-secondary px-3" title="Anlieferungs-Infos anzeigen">
<FaInfoCircle />
</button>
</div>
</div>
{message && <div style={{ color: '#1976d2', fontWeight: 600, marginTop: 10, textAlign: 'center' }}>{message}</div>}
</form>
<div>
<label className="label">Artikel</label>
<select className="input" value={artikelId} onChange={e => setArtikelId(e.target.value)}>
<option value=""> waehlen </option>
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
</select>
</div>
<div>
<label className="label">Anzahl</label>
<input type="number" min="1" className="input" value={anzahl} onChange={e => setAnzahl(e.target.value)} />
</div>
<div>
<input type="file" accept="image/*" id="foto" className="sr-only"
onChange={e => setFoto(e.target.files?.[0] || null)} />
<label htmlFor="foto" className="btn-secondary w-full cursor-pointer">
<FaCamera /> {foto ? foto.name : 'Foto erstellen / auswaehlen'}
</label>
</div>
{msg && <div className="text-sm text-brand-700 text-center">{msg}</div>}
<div className="grid grid-cols-2 gap-2 pt-2">
<button disabled={busy} onClick={() => submit('lieferung')} className="btn-primary">Ausliefern</button>
<button disabled={busy} onClick={() => submit('ruecknahme')} className="btn-secondary">Zuruecknehmen</button>
</div>
{infoOpen && kundenDetail && (
<KundeInfoModal kunde={kundenDetail} onClose={() => setInfoOpen(false)} />
)}
</div>
);
}
function KundeInfoModal({ kunde, 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-lg w-full p-6 max-h-[85vh] overflow-y-auto shadow-soft">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">{kunde.name}</h3>
{kunde.adresse && <p className="text-sm text-slate-500 mt-1">{kunde.adresse}</p>}
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 text-xl leading-none">×</button>
</div>
{kunde.lieferzeit_bis && (
<div className="mb-3 inline-flex items-center gap-2 badge-amber">
Anlieferung bis {kunde.lieferzeit_bis} Uhr
</div>
)}
{kunde.anlieferungshinweis && (
<div className="mb-4 rounded-lg bg-amber-50 ring-1 ring-amber-200 p-3 text-sm text-amber-900 whitespace-pre-line">
{kunde.anlieferungshinweis}
</div>
)}
{kunde.bilder?.length > 0 && (
<div>
<div className="text-sm font-medium text-slate-700 mb-2">Anlieferungs-Fotos</div>
<div className="grid grid-cols-2 gap-2">
{kunde.bilder.map(b => {
const url = b.dateiname.startsWith('http')
? b.dateiname
: `${(import.meta.env.VITE_API_URL || '/api').replace('/api', '')}${b.dateiname}`;
return (
<div key={b.id} className="space-y-1">
<img src={url} className="w-full aspect-square object-cover rounded-lg ring-1 ring-slate-200" />
{b.beschreibung && <div className="text-xs text-slate-500">{b.beschreibung}</div>}
</div>
);
})}
</div>
</div>
)}
{kunde.adresse && (
<a target="_blank" rel="noopener" className="btn-primary w-full mt-4"
href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(kunde.adresse)}`}>
Navigieren
</a>
)}
</div>
</div>
);
}

151
src/components/Geraete.jsx Normal file
View File

@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import { FaPlus, FaEdit, FaTrash, FaTimes } from 'react-icons/fa';
import { api } from '../api/client';
const STATUS = {
aktiv: 'badge-green',
in_reparatur: 'badge-amber',
lager: 'badge-slate',
ausgemustert: 'badge-rose',
};
const empty = { seriennummer: '', typ: '', bezeichnung: '', kunde_id: '', status: 'aktiv', notiz: '' };
export default function Geraete() {
const [list, setList] = useState([]);
const [kunden, setKunden] = useState([]);
const [form, setForm] = useState(empty);
const [editId, setEditId] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [filterTyp, setFilterTyp] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const load = async () => {
setLoading(true);
try { const [g, k] = await Promise.all([api.getGeraete(), api.getKunden()]); setList(g); setKunden(k); }
catch (e) { setError(e.message); } finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const startEdit = (g) => {
setEditId(g.id);
setForm({
seriennummer: g.seriennummer, typ: g.typ, bezeichnung: g.bezeichnung || '',
kunde_id: g.kunde_id || '', status: g.status, notiz: g.notiz || ''
});
};
const cancel = () => { setEditId(null); setForm(empty); };
const save = async () => {
if (!form.seriennummer || !form.typ) return setError('Seriennummer + Typ erforderlich');
try {
if (editId) await api.updateGeraet(editId, form); else await api.createGeraet(form);
cancel(); setError(''); load();
} catch (e) { setError(e.message); }
};
const del = async (id) => { if (!confirm('Geraet loeschen?')) return; await api.deleteGeraet(id); load(); };
const typen = Array.from(new Set(list.map(g => g.typ)));
const view = list.filter(g =>
(!filterTyp || g.typ === filterTyp) && (!filterStatus || g.status === filterStatus)
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">Geraete</h2>
<div className="text-sm text-slate-500">{list.length} Geraete</div>
</div>
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
<div className="card">
<h3 className="font-medium text-slate-800 mb-3">{editId ? 'Geraet bearbeiten' : 'Neues Geraet'}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<Field label="Seriennummer *">
<input className="input" value={form.seriennummer} onChange={e => setForm(f => ({ ...f, seriennummer: e.target.value }))} />
</Field>
<Field label="Typ *">
<input className="input" placeholder="z. B. Ofen, Kuehlschrank" value={form.typ}
onChange={e => setForm(f => ({ ...f, typ: e.target.value }))} />
</Field>
<Field label="Bezeichnung">
<input className="input" placeholder="z. B. Miwe Condo 3-Etagen" value={form.bezeichnung}
onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} />
</Field>
<Field label="Bei Kunde">
<select className="input" value={form.kunde_id} onChange={e => setForm(f => ({ ...f, kunde_id: e.target.value }))}>
<option value=""> keiner </option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</Field>
<Field label="Status">
<select className="input" value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
<option value="aktiv">aktiv</option>
<option value="in_reparatur">in Reparatur</option>
<option value="lager">im Lager</option>
<option value="ausgemustert">ausgemustert</option>
</select>
</Field>
<Field label="Notiz">
<input className="input" value={form.notiz} onChange={e => setForm(f => ({ ...f, notiz: e.target.value }))} />
</Field>
</div>
<div className="flex gap-2 mt-3">
<button onClick={save} className="btn-primary"><FaPlus /> {editId ? 'Speichern' : 'Anlegen'}</button>
{editId && <button onClick={cancel} className="btn-secondary"><FaTimes /> Abbrechen</button>}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<select className="input flex-1 min-w-[160px]" value={filterTyp} onChange={e => setFilterTyp(e.target.value)}>
<option value="">Alle Typen</option>
{typen.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select className="input flex-1 min-w-[160px]" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="">Alle Status</option>
<option value="aktiv">aktiv</option>
<option value="in_reparatur">in Reparatur</option>
<option value="lager">im Lager</option>
<option value="ausgemustert">ausgemustert</option>
</select>
</div>
<div className="card overflow-x-auto p-0">
{loading ? <div className="text-center py-8 text-slate-500">Lade</div> : (
<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">SN</th>
<th className="text-left px-4 py-3">Typ</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Bezeichnung</th>
<th className="text-left px-4 py-3">Bei Kunde</th>
<th className="text-left px-4 py-3">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{view.map(g => (
<tr key={g.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-xs">{g.seriennummer}</td>
<td className="px-4 py-3">{g.typ}</td>
<td className="px-4 py-3 text-slate-600 hidden md:table-cell">{g.bezeichnung || '—'}</td>
<td className="px-4 py-3">{g.kunde_name || <span className="text-slate-400"></span>}</td>
<td className="px-4 py-3"><span className={STATUS[g.status] || 'badge-slate'}>{g.status}</span></td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<button onClick={() => startEdit(g)} className="btn-ghost px-2"><FaEdit /></button>
<button onClick={() => del(g.id)} className="btn-ghost px-2 text-rose-600"><FaTrash /></button>
</td>
</tr>
))}
{view.length === 0 && <tr><td colSpan={6} className="text-center py-8 text-slate-400">Keine Geraete.</td></tr>}
</tbody>
</table>
)}
</div>
</div>
);
}
function Field({ label, children }) {
return <div><label className="label">{label}</label>{children}</div>;
}

View File

@@ -1,168 +1,220 @@
import { useEffect, useState } from 'react';
import { FaEdit, FaTrash, FaCamera, FaTimes, FaPlus } from 'react-icons/fa';
import { api } from '../api/client';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
const emptyForm = {
name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '',
notiz: '', anlieferungshinweis: '', lieferzeit_bis: ''
};
export default function KundenVerwaltung() {
const [kunden, setKunden] = useState([]);
const [editId, setEditId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editId, setEditId] = useState(null);
const [form, setForm] = useState({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
const [openBilder, setOpenBilder] = useState(null); // kunde-id mit bilder-modal
useEffect(() => { fetchKunden(); }, []);
async function fetchKunden() {
const load = async () => {
setLoading(true);
try {
const data = await api.getKunden();
setKunden(data || []);
setError('');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
try { setKunden(await api.getKunden()); setError(''); }
catch (e) { setError(e.message); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
function handleEdit(k) {
const startEdit = (k) => {
setEditId(k.id);
setForm({
name: k.name || '',
adresse: k.adresse || '',
email: k.email || '',
telefon: k.telefon || '',
anrede: k.anrede || '',
ansprechpartner: k.ansprechpartner || '',
notiz: k.notiz || ''
name: k.name || '', adresse: k.adresse || '', email: k.email || '',
telefon: k.telefon || '', anrede: k.anrede || '',
ansprechpartner: k.ansprechpartner || '', notiz: k.notiz || '',
anlieferungshinweis: k.anlieferungshinweis || '',
lieferzeit_bis: k.lieferzeit_bis || ''
});
}
};
const cancel = () => { setEditId(null); setForm(emptyForm); };
async function handleSave() {
if (!form.name) return setError('Name ist erforderlich');
const save = async () => {
if (!form.name) return setError('Name erforderlich');
try {
if (editId) {
await api.updateKunde(editId, form);
} else {
await api.createKunde(form);
}
setEditId(null);
setForm({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
fetchKunden();
} catch (err) {
setError(err.message);
}
}
if (editId) await api.updateKunde(editId, form);
else await api.createKunde(form);
cancel(); load();
} catch (e) { setError(e.message); }
};
function handleCancel() {
setEditId(null);
setForm({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
}
async function handleDelete(id) {
if (!confirm('Kunde wirklich löschen?')) return;
try {
await api.deleteKunde(id);
fetchKunden();
} catch (err) {
setError(err.message);
}
}
function handleChange(e) {
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
}
const del = async (id) => {
if (!confirm('Kunde wirklich loeschen?')) return;
try { await api.deleteKunde(id); load(); } catch (e) { setError(e.message); }
};
return (
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 16px #0001', padding: 24, maxWidth: 1200, margin: '32px auto' }}>
<h2 style={{ textAlign: 'center', marginBottom: 16, color: '#1976d2', fontWeight: 700 }}>Kundenverwaltung</h2>
{loading && <div>Lade...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div style={{ marginBottom: 18, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<input name="name" value={form.name} onChange={handleChange} placeholder="Name" style={{ minWidth: 120, flex: 2 }} />
<input name="adresse" value={form.adresse} onChange={handleChange} placeholder="Adresse" style={{ minWidth: 120, flex: 2 }} />
<input name="email" value={form.email} onChange={handleChange} placeholder="E-Mail" style={{ minWidth: 120, flex: 2 }} />
<input name="telefon" value={form.telefon} onChange={handleChange} placeholder="Telefon" style={{ minWidth: 100, flex: 1 }} />
<select name="anrede" value={form.anrede} onChange={handleChange} style={{ minWidth: 80, flex: 1 }}>
<option value="">Anrede</option>
<option value="Herr">Herr</option>
<option value="Frau">Frau</option>
</select>
<input name="ansprechpartner" value={form.ansprechpartner} onChange={handleChange} placeholder="Ansprechpartner" style={{ minWidth: 100, flex: 1 }} />
<input name="notiz" value={form.notiz} onChange={handleChange} placeholder="Notiz" style={{ minWidth: 100, flex: 1 }} />
{editId ? (
<>
<button onClick={handleSave} style={{ marginLeft: 8 }}>Speichern</button>
<button onClick={handleCancel} style={{ marginLeft: 4 }}>Abbrechen</button>
</>
) : (
<button onClick={handleSave} style={{ marginLeft: 8 }}>Neu anlegen</button>
)}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">Kunden</h2>
<div className="text-sm text-slate-500">{kunden.length} Kunden</div>
</div>
{/* Responsive Darstellung: Tabelle auf Desktop, Karten auf Mobil */}
<div className="kunden-tabelle-responsive" style={{ overflowX: 'auto' }}>
<div className="kunden-tabelle-desktop">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 800 }}>
<thead>
<tr style={{ background: '#1976d2', color: '#fff' }}>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Name</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Adresse</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>E-Mail</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Telefon</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Anrede</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Ansprechpartner</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Notiz</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Aktionen</th>
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
<div className="card">
<h3 className="font-medium text-slate-800 mb-3">{editId ? 'Kunde bearbeiten' : 'Neuer Kunde'}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Name *">
<input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="Adresse">
<input className="input" value={form.adresse} onChange={e => setForm(f => ({ ...f, adresse: e.target.value }))} />
</Field>
<Field label="E-Mail">
<input type="email" className="input" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
</Field>
<Field label="Telefon">
<input className="input" value={form.telefon} onChange={e => setForm(f => ({ ...f, telefon: e.target.value }))} />
</Field>
<Field label="Anrede">
<select className="input" value={form.anrede} onChange={e => setForm(f => ({ ...f, anrede: e.target.value }))}>
<option value=""></option><option>Herr</option><option>Frau</option><option>Firma</option>
</select>
</Field>
<Field label="Ansprechpartner">
<input className="input" value={form.ansprechpartner} onChange={e => setForm(f => ({ ...f, ansprechpartner: e.target.value }))} />
</Field>
<Field label="Lieferzeit bis (HH:MM)">
<input className="input" placeholder="05:15" value={form.lieferzeit_bis} onChange={e => setForm(f => ({ ...f, lieferzeit_bis: e.target.value }))} />
</Field>
<Field label="Notiz">
<input className="input" value={form.notiz} onChange={e => setForm(f => ({ ...f, notiz: e.target.value }))} />
</Field>
<div className="sm:col-span-2">
<label className="label">Anlieferungshinweis</label>
<textarea rows={3} className="input"
placeholder="z. B. Hintertuer, Code 1234#, Schluesselkasten links neben dem Eingang"
value={form.anlieferungshinweis}
onChange={e => setForm(f => ({ ...f, anlieferungshinweis: e.target.value }))} />
</div>
</div>
<div className="flex gap-2 mt-4">
<button onClick={save} className="btn-primary"><FaPlus /> {editId ? 'Speichern' : 'Anlegen'}</button>
{editId && <button onClick={cancel} className="btn-secondary"><FaTimes /> Abbrechen</button>}
</div>
</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-slate-600 text-xs uppercase tracking-wide">
<tr>
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Adresse</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">Lieferzeit</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">Hinweis</th>
<th className="text-right px-4 py-3"></th>
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-slate-100">
{kunden.map(k => (
<tr key={k.id} style={{ background: editId === k.id ? '#fffde7' : '#fff', borderBottom: '1px solid #eee' }}>
<td style={{ padding: '10px 8px' }}>{k.name}</td>
<td style={{ padding: '10px 8px' }}>{k.adresse}</td>
<td style={{ padding: '10px 8px' }}>{k.email}</td>
<td style={{ padding: '10px 8px' }}>{k.telefon}</td>
<td style={{ padding: '10px 8px' }}>{k.anrede}</td>
<td style={{ padding: '10px 8px' }}>{k.ansprechpartner}</td>
<td style={{ padding: '10px 8px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{k.notiz}</td>
<td style={{ whiteSpace: 'nowrap' }}>
<button onClick={() => handleEdit(k)} style={{ fontSize: 13, marginRight: 4, padding: '6px 12px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>Bearbeiten</button>
<button onClick={() => handleDelete(k.id)} style={{ fontSize: 13, padding: '6px 12px', background: '#d32f2f', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>Löschen</button>
<tr key={k.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="font-medium text-slate-900">{k.name}</div>
{k.ansprechpartner && <div className="text-xs text-slate-500">{k.anrede} {k.ansprechpartner}</div>}
</td>
<td className="px-4 py-3 text-slate-600 hidden md:table-cell">{k.adresse || '—'}</td>
<td className="px-4 py-3 hidden lg:table-cell">
{k.lieferzeit_bis ? <span className="badge-amber">{k.lieferzeit_bis}</span> : '—'}
</td>
<td className="px-4 py-3 hidden lg:table-cell text-slate-600 max-w-xs truncate">{k.anlieferungshinweis || '—'}</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<button onClick={() => setOpenBilder(k.id)} className="btn-ghost px-2" title="Bilder verwalten"><FaCamera /></button>
<button onClick={() => startEdit(k)} className="btn-ghost px-2" title="Bearbeiten"><FaEdit /></button>
<button onClick={() => del(k.id)} className="btn-ghost px-2 text-rose-600" title="Loeschen"><FaTrash /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="kunden-tabelle-mobile">
{kunden.map(k => (
<div key={k.id} className="kunden-card">
<div style={{ fontWeight: 700, fontSize: 17 }}>{k.name}</div>
<div><b>Anrede:</b> {k.anrede}</div>
<div><b>Ansprechpartner:</b> {k.ansprechpartner}</div>
<div><b>Telefon:</b> {k.telefon}</div>
<div><b>E-Mail:</b> {k.email}</div>
<div style={{ fontSize: 13, color: '#444', margin: '4px 0' }}>{k.notiz}</div>
<button onClick={() => handleEdit(k)} style={{ fontSize: 13, marginTop: 6 }}>Bearbeiten</button>
</div>
))}
</div>
</div>
<style>{`
@media (max-width: 600px) {
.kunden-tabelle-desktop { display: none; }
.kunden-tabelle-mobile { display: block; }
.kunden-card {
background: #f6faff;
border-radius: 12px;
box-shadow: 0 1px 8px #0001;
padding: 14px 13px 10px 13px;
margin-bottom: 14px;
}
}
@media (min-width: 601px) {
.kunden-tabelle-desktop { display: block; }
.kunden-tabelle-mobile { display: none; }
}
`}</style>
)}
{openBilder && (
<BilderModal kundeId={openBilder} onClose={() => setOpenBilder(null)} />
)}
</div>
);
}
function Field({ label, children }) {
return (<div><label className="label">{label}</label>{children}</div>);
}
function BilderModal({ kundeId, onClose }) {
const [data, setData] = useState(null);
const [busy, setBusy] = useState(false);
const [beschreibung, setBeschreibung] = useState('');
const load = async () => setData(await api.getKunde(kundeId));
useEffect(() => { load(); }, [kundeId]);
const upload = async (file) => {
if (!file) return;
setBusy(true);
try { await api.uploadKundenBild(kundeId, file, beschreibung); setBeschreibung(''); await load(); }
catch (e) { alert(e.message); } finally { setBusy(false); }
};
const del = async (id) => {
if (!confirm('Bild loeschen?')) return;
await api.deleteKundenBild(id); load();
};
if (!data) return null;
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-2xl w-full p-6 max-h-[85vh] overflow-y-auto shadow-soft">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">Anlieferungs-Fotos</h3>
<p className="text-sm text-slate-500">{data.name}</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 text-xl">×</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-5">
{(data.bilder || []).map(b => {
const url = b.dateiname.startsWith('http') ? b.dateiname
: `${API_BASE.replace('/api', '')}${b.dateiname}`;
return (
<div key={b.id} className="relative group">
<img src={url} className="aspect-square object-cover rounded-lg ring-1 ring-slate-200 w-full" />
{b.beschreibung && <div className="text-xs text-slate-500 mt-1">{b.beschreibung}</div>}
<button onClick={() => del(b.id)}
className="absolute top-1 right-1 h-7 w-7 bg-white/90 rounded-full text-rose-600 opacity-0 group-hover:opacity-100 transition">
×
</button>
</div>
);
})}
{(data.bilder?.length || 0) === 0 && (
<div className="col-span-full text-center text-slate-400 text-sm py-6">Noch keine Bilder.</div>
)}
</div>
{(data.bilder?.length || 0) < 5 && (
<div className="border-t border-slate-200 pt-4 space-y-3">
<input className="input" placeholder="Beschreibung (z. B. Hintertuer)" value={beschreibung}
onChange={e => setBeschreibung(e.target.value)} />
<input type="file" accept="image/*" id="bildup" className="sr-only"
onChange={e => upload(e.target.files?.[0])} disabled={busy} />
<label htmlFor="bildup" className="btn-primary w-full cursor-pointer">
<FaCamera /> {busy ? 'Lade hoch…' : 'Bild hochladen'}
</label>
<p className="text-xs text-slate-400 text-center">Max. 5 Bilder pro Kunde</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,116 +1,97 @@
import { useEffect, useState } from 'react';
import { FaPlus, FaTrash } from 'react-icons/fa';
import { api } from '../api/client';
export default function MitarbeiterVerwaltung({ user }) {
const [mitarbeiter, setMitarbeiter] = useState([]);
const [loading, setLoading] = useState(true);
const [list, setList] = useState([]);
const [form, setForm] = useState({ name: '', email: '', password: '', rolle: 'user' });
const [error, setError] = useState('');
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRolle, setNewRolle] = useState('user');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMitarbeiter();
}, []);
async function fetchMitarbeiter() {
const load = async () => {
setLoading(true);
try {
const data = await api.getMitarbeiter();
setMitarbeiter(data || []);
setError('');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
try { setList(await api.getMitarbeiter()); setError(''); }
catch (e) { setError(e.message); } finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
async function handleRolleChange(id, rolle) {
const add = async () => {
if (!form.name || !form.email || !form.password) return setError('Alle Felder ausfuellen');
try {
await api.updateMitarbeiter(id, { rolle });
fetchMitarbeiter();
} catch (err) {
setError(err.message);
}
}
await api.createMitarbeiter(form);
setForm({ name: '', email: '', password: '', rolle: 'user' }); setError(''); load();
} catch (e) { setError(e.message); }
};
async function handleNameChange(id, name) {
try {
await api.updateMitarbeiter(id, { name });
fetchMitarbeiter();
} catch (err) {
setError(err.message);
}
}
async function handleAdd() {
if (!newEmail || !newPassword || !newName) {
setError('Bitte alle Felder ausfüllen');
return;
}
try {
await api.register(newEmail, newPassword, newName, newRolle);
setNewName('');
setNewEmail('');
setNewPassword('');
setNewRolle('user');
setError('');
fetchMitarbeiter();
} catch (err) {
setError(err.message);
}
}
const updateRolle = async (id, rolle) => { await api.updateMitarbeiter(id, { rolle }); load(); };
const toggleAktiv = async (id, aktiv) => { await api.updateMitarbeiter(id, { aktiv: !aktiv }); load(); };
const del = async (id) => { if (!confirm('Wirklich loeschen?')) return; await api.deleteMitarbeiter(id); load(); };
return (
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 16px #0001', padding: 24, maxWidth: 480, margin: '32px auto' }}>
<h2 style={{ textAlign: 'center', marginBottom: 16 }}>Mitarbeiterverwaltung</h2>
{loading && <div>Lade...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div className="mitarbeiter-list">
{mitarbeiter.map(m => (
<div key={m.id} className="mitarbeiter-card" style={{ background: '#f6f7fb', borderRadius: 12, boxShadow: '0 1px 6px #0001', padding: 14, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input value={m.name || ''} onChange={e => handleNameChange(m.id, e.target.value)} style={{ flex: 1, fontSize: 16, padding: 8, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
<select value={m.rolle} onChange={e => handleRolleChange(m.id, e.target.value)} style={{ fontSize: 16, padding: 8, borderRadius: 8, border: '1.5px solid #dbeafe' }}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
))}
<div className="space-y-4 max-w-3xl mx-auto">
<h2 className="text-xl font-semibold text-slate-900">Mitarbeiter</h2>
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
<div className="card">
<h3 className="font-medium text-slate-800 mb-3">Neuer Mitarbeiter</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input className="input" placeholder="Name" value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
<input className="input" type="email" placeholder="E-Mail" value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
<input className="input" type="password" placeholder="Passwort (min. 6)" value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} />
<select className="input" value={form.rolle} onChange={e => setForm(f => ({ ...f, rolle: e.target.value }))}>
<option value="user">User</option>
<option value="fahrer">Fahrer</option>
<option value="admin">Admin</option>
</select>
</div>
<button onClick={add} className="btn-primary mt-3"><FaPlus /> Hinzufuegen</button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<input placeholder="Name" value={newName} onChange={e => setNewName(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
<input placeholder="E-Mail" type="email" value={newEmail} onChange={e => setNewEmail(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
<input placeholder="Passwort" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
<select value={newRolle} onChange={e => setNewRolle(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button onClick={handleAdd} style={{ fontSize: 16, padding: '10px 18px', borderRadius: 8, background: '#1976d2', color: '#fff', border: 'none', fontWeight: 700, cursor: 'pointer' }}>Hinzufügen</button>
<div className="card overflow-x-auto p-0">
{loading ? <div className="text-center py-8 text-slate-500">Lade</div> : (
<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">E-Mail</th>
<th className="text-left px-4 py-3">Rolle</th>
<th className="text-left px-4 py-3">Aktiv</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{list.map(m => (
<tr key={m.id} className={m.id === user.id ? 'bg-brand-50/40' : ''}>
<td className="px-4 py-3 font-medium">{m.name} {m.id === user.id && <span className="badge-brand ml-1">Ich</span>}</td>
<td className="px-4 py-3 text-slate-600">{m.email}</td>
<td className="px-4 py-3">
<select value={m.rolle} onChange={e => updateRolle(m.id, e.target.value)}
disabled={m.id === user.id} className="input py-1 px-2 text-xs">
<option value="user">User</option>
<option value="fahrer">Fahrer</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-4 py-3">
<button onClick={() => toggleAktiv(m.id, m.aktiv)} disabled={m.id === user.id}
className={m.aktiv ? 'badge-green' : 'badge-slate'}>
{m.aktiv ? 'aktiv' : 'inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
{m.id !== user.id && (
<button onClick={() => del(m.id)} className="btn-ghost text-rose-600 px-2"><FaTrash /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>
Rolle "admin" sieht das Admin-Dashboard.<br />
<b>Hinweis:</b> Neue Mitarbeiter benötigen E-Mail und Passwort für den Login.
</div>
<style>{`
@media (max-width: 600px) {
.mitarbeiter-list {
gap: 8px;
}
.mitarbeiter-card {
padding: 10px 6px;
font-size: 15px;
}
input, select, button {
font-size: 16px !important;
padding: 10px !important;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,190 @@
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">
<div className="h-8 w-8 rounded-lg bg-slate-900 text-white grid place-items-center font-bold">P</div>
<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>;
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { FaMapMarkedAlt, FaCheckCircle, FaClock, FaInfoCircle } from 'react-icons/fa';
import { api } from '../api/client';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
export default function TourPlanung() {
const [kunden, setKunden] = useState([]);
const [done, setDone] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem('tour_done') || '[]')); } catch { return new Set(); }
});
const [loading, setLoading] = useState(true);
const [showInfo, setShowInfo] = useState(null);
useEffect(() => {
api.getKunden().then(d => setKunden(d || [])).finally(() => setLoading(false));
}, []);
const toggle = (id) => {
const next = new Set(done);
if (next.has(id)) next.delete(id); else next.add(id);
setDone(next);
localStorage.setItem('tour_done', JSON.stringify([...next]));
};
const sorted = [...kunden].sort((a, b) => {
if (a.lieferzeit_bis && b.lieferzeit_bis) return a.lieferzeit_bis.localeCompare(b.lieferzeit_bis);
if (a.lieferzeit_bis) return -1;
if (b.lieferzeit_bis) return 1;
return a.name.localeCompare(b.name);
});
const openMaps = (adresse) => {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(adresse)}`, '_blank');
};
const resetTour = () => { setDone(new Set()); localStorage.removeItem('tour_done'); };
return (
<div className="space-y-4 max-w-3xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Meine Tour</h2>
<p className="text-sm text-slate-500">{done.size} / {sorted.length} erledigt</p>
</div>
{done.size > 0 && (
<button onClick={resetTour} className="btn-ghost text-xs">Tour zuruecksetzen</button>
)}
</div>
{loading ? <div className="text-center py-8 text-slate-500">Lade</div> : (
<ul className="space-y-2">
{sorted.map((k, idx) => {
const erledigt = done.has(k.id);
return (
<li key={k.id} className={`card flex items-center gap-3 ${erledigt ? 'opacity-50' : ''}`}>
<button onClick={() => toggle(k.id)}
className={`h-9 w-9 rounded-full grid place-items-center text-sm font-semibold ring-2 transition
${erledigt ? 'bg-emerald-500 text-white ring-emerald-500' : 'bg-white text-slate-500 ring-slate-300 hover:ring-brand-500'}`}>
{erledigt ? <FaCheckCircle /> : idx + 1}
</button>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900 truncate">{k.name}</div>
<div className="text-xs text-slate-500 truncate">{k.adresse || '— keine Adresse —'}</div>
<div className="flex gap-2 mt-1.5 flex-wrap">
{k.lieferzeit_bis && (
<span className="badge-amber gap-1"><FaClock size={10} /> bis {k.lieferzeit_bis}</span>
)}
{k.anlieferungshinweis && (
<button onClick={() => setShowInfo(k)} className="badge-brand gap-1 hover:bg-brand-100">
<FaInfoCircle size={10} /> Hinweis
</button>
)}
</div>
</div>
<button onClick={() => k.adresse && openMaps(k.adresse)} disabled={!k.adresse}
className="btn-primary px-3 py-2" title="Navigieren">
<FaMapMarkedAlt />
</button>
</li>
);
})}
{sorted.length === 0 && <li className="card text-center text-slate-500">Keine Kunden in der Tour.</li>}
</ul>
)}
{showInfo && (
<div onClick={() => setShowInfo(null)} 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">
<div className="flex justify-between items-start mb-3">
<h3 className="font-semibold">{showInfo.name}</h3>
<button onClick={() => setShowInfo(null)} className="text-slate-400 hover:text-slate-600 text-xl">×</button>
</div>
<div className="rounded-lg bg-amber-50 ring-1 ring-amber-200 p-3 text-sm text-amber-900 whitespace-pre-line">
{showInfo.anlieferungshinweis}
</div>
</div>
</div>
)}
</div>
);
}