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:
BIN
src/components/._Auth
Executable file
BIN
src/components/._Auth
Executable file
Binary file not shown.
BIN
src/components/._Dashboard
Executable file
BIN
src/components/._Dashboard
Executable file
Binary file not shown.
BIN
src/components/._Eingabe
Executable file
BIN
src/components/._Eingabe
Executable file
Binary file not shown.
BIN
src/components/._Geraete.jsx
Normal file
BIN
src/components/._Geraete.jsx
Normal file
Binary file not shown.
BIN
src/components/._KundenVerwaltung.jsx
Normal file
BIN
src/components/._KundenVerwaltung.jsx
Normal file
Binary file not shown.
BIN
src/components/._MitarbeiterVerwaltung.jsx
Normal file
BIN
src/components/._MitarbeiterVerwaltung.jsx
Normal file
Binary file not shown.
BIN
src/components/._SuperAdmin.jsx
Normal file
BIN
src/components/._SuperAdmin.jsx
Normal file
Binary file not shown.
BIN
src/components/._TourPlanung.jsx
Normal file
BIN
src/components/._TourPlanung.jsx
Normal file
Binary file not shown.
BIN
src/components/Auth/._Login.jsx
Normal file
BIN
src/components/Auth/._Login.jsx
Normal file
Binary file not shown.
BIN
src/components/Auth/._PasswordChange.jsx
Normal file
BIN
src/components/Auth/._PasswordChange.jsx
Normal file
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
src/components/Dashboard/._Account.jsx
Normal file
BIN
src/components/Dashboard/._Account.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._AdminDashboard.jsx
Normal file
BIN
src/components/Dashboard/._AdminDashboard.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._BildModal.jsx
Normal file
BIN
src/components/Dashboard/._BildModal.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._Dashboard.jsx
Normal file
BIN
src/components/Dashboard/._Dashboard.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._TopNav.jsx
Normal file
BIN
src/components/Dashboard/._TopNav.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._VorgangsListe.jsx
Normal file
BIN
src/components/Dashboard/._VorgangsListe.jsx
Normal file
Binary file not shown.
32
src/components/Dashboard/Account.jsx
Normal file
32
src/components/Dashboard/Account.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
src/components/Eingabe/._VorgangForm.jsx
Normal file
BIN
src/components/Eingabe/._VorgangForm.jsx
Normal file
Binary file not shown.
@@ -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
151
src/components/Geraete.jsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
190
src/components/SuperAdmin.jsx
Normal file
190
src/components/SuperAdmin.jsx
Normal 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>;
|
||||
}
|
||||
102
src/components/TourPlanung.jsx
Normal file
102
src/components/TourPlanung.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user