fix: Umlaute, Foto-404, Account-Menu responsive

Bugs:
- nginx: /uploads/ wurde von der .jpg/.png-Regex-Location geschluckt
  -> ^~ Prefix gibt /uploads/ und /api/ Vorrang vor Regex
  -> Fotos werden jetzt korrekt vom Backend ausgeliefert
- TopNav menu-item Klasse war via <style>@apply definiert -
  Tailwind verarbeitet @apply nur in .css-Dateien, nicht in JSX-style-Tags
  -> alle Klassen direkt am Element + neuer responsive Aufbau
- TopNav mobile-friendlier: Brand-Truncate, Demo-Badge erst ab sm,
  Dropdown max-w fuer Viewport, Touch-Outside-Close, groessere Touch-Targets

Umlaute:
- Alle ASCII-Substitutionen (fuer, ueber, aendern, loeschen, Stueck, Geraete,
  Ruecknahme, Hintertuer, Schluessel, Kuehlschrank, Pruefe, gueltig, etc.)
  konsequent auf Unicode (für, über, ä, ö, ü, ß) umgestellt.
- 26 Dateien betroffen, SQL-Identifier + Import-Pfade + API-Routen ASCII gelassen.
- seed.js: Baeckerei -> Baeckerei.. ah Baeckerei -> Bäckerei
This commit is contained in:
christian
2026-05-26 14:01:30 +00:00
parent 9726d70c41
commit 0535fbe72a
55 changed files with 178 additions and 141 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
._src

Binary file not shown.

Binary file not shown.

View File

@@ -58,7 +58,7 @@ export async function sendTestMail(to) {
const html = `
<div style="font-family:system-ui,Segoe UI,Helvetica,Arial,sans-serif;max-width:560px;margin:auto">
<h2 style="color:#051e23">Dockly-Pfandsystem Demo - SMTP-Test</h2>
<p>Diese Test-Mail bestaetigt, dass der Mail-Versand ueber
<p>Diese Test-Mail bestätigt, dass der Mail-Versand über
<code>${process.env.SMTP_HOST || 'kein SMTP'}</code> funktioniert.</p>
<p>Absender: <code>${FROM}</code></p>
<p>Zeitstempel: ${new Date().toISOString()}</p>

View File

@@ -1,12 +1,12 @@
import crypto from 'crypto';
// Befuellt einen frischen Demo-Tenant mit Beispieldaten
// (5 Kunden, 3 Artikel, 6 Lieferungen, 2 Geraete).
// Befüllt einen frischen Demo-Tenant mit Beispieldaten
// (5 Kunden, 3 Artikel, 6 Lieferungen, 2 Geräte).
export async function seedDemoTenant(conn, tenantId) {
// Artikel
const artikel = [
{ id: crypto.randomUUID(), name: 'Brotkorb klein', pfand: 2.50 },
{ id: crypto.randomUUID(), name: 'Brotkorb gross', pfand: 5.00 },
{ id: crypto.randomUUID(), name: 'Brotkorb groß', pfand: 5.00 },
{ id: crypto.randomUUID(), name: 'Transportbox', pfand: 10.00 }
];
for (const a of artikel) {
@@ -18,11 +18,11 @@ export async function seedDemoTenant(conn, tenantId) {
// Kunden
const kunden = [
{ name: 'Cafe Sonnenschein', adresse: 'Bahnhofstr. 12, 10115 Berlin', zeit: '05:00', hinweis: 'Anlieferung Hintertuer. Code 1234#.' },
{ name: 'Restaurant Roma', adresse: 'Hauptstr. 88, 10117 Berlin', zeit: '06:30', hinweis: 'Schluesselkasten links neben Eingang.' },
{ name: 'Hotel Adlerhorst', adresse: 'Seestrasse 5, 14193 Berlin', zeit: '05:30', hinweis: 'Anlieferung Tiefgarage Ebene -1, Tor 3.' },
{ name: 'Cafe Sonnenschein', adresse: 'Bahnhofstr. 12, 10115 Berlin', zeit: '05:00', hinweis: 'Anlieferung Hintertür. Code 1234#.' },
{ name: 'Restaurant Roma', adresse: 'Hauptstr. 88, 10117 Berlin', zeit: '06:30', hinweis: 'Schlüsselkasten links neben Eingang.' },
{ name: 'Hotel Adlerhorst', adresse: 'Seestraße 5, 14193 Berlin', zeit: '05:30', hinweis: 'Anlieferung Tiefgarage Ebene -1, Tor 3.' },
{ name: 'Kantine Werk Nord', adresse: 'Industriestr. 200, 13407 Berlin', zeit: '06:00', hinweis: 'Werkschutz Bescheid geben, Tor B.' },
{ name: 'Baeckerei Schmitt', adresse: 'Marktplatz 4, 10178 Berlin', zeit: '04:45', hinweis: 'Direkt in den Verkaufsraum stellen.' }
{ name: 'Bäckerei Schmitt', adresse: 'Marktplatz 4, 10178 Berlin', zeit: '04:45', hinweis: 'Direkt in den Verkaufsraum stellen.' }
];
const kundenIds = [];
for (const k of kunden) {
@@ -35,7 +35,7 @@ export async function seedDemoTenant(conn, tenantId) {
);
}
// Lieferungen (verteilt ueber die letzten 14 Tage)
// Lieferungen (verteilt über die letzten 14 Tage)
for (let i = 0; i < 6; i++) {
const kid = kundenIds[i % kundenIds.length];
const aid = artikel[i % artikel.length].id;
@@ -47,12 +47,12 @@ export async function seedDemoTenant(conn, tenantId) {
);
}
// Geraete
const geraete = [
// Geräte
const geraeteList = [
{ sn: 'OF-2024-001', typ: 'Ofen', bez: 'Miwe Condo 3-Etagen', kid: kundenIds[0] },
{ sn: 'KS-2024-007', typ: 'Kuehlschrank', bez: 'Liebherr GKv 5710', kid: kundenIds[2] }
{ sn: 'KS-2024-007', typ: 'Kühlschrank', bez: 'Liebherr GKv 5710', kid: kundenIds[2] }
];
for (const g of geraete) {
for (const g of geraeteList) {
await conn.query(
`INSERT INTO geraete (id, tenant_id, seriennummer, typ, bezeichnung, kunde_id, status)
VALUES (UUID(), ?, ?, ?, ?, ?, 'aktiv')`,

View File

@@ -3,7 +3,7 @@ import pool from '../config/database.js';
// Verifiziert JWT und stellt req.user bereit
// req.user enthaelt: { id, email, name, rolle, tenant_id }
// tenant_id ist NULL fuer SuperAdmins.
// tenant_id ist NULL für SuperAdmins.
export const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
@@ -14,7 +14,7 @@ export const authenticateToken = (req, res, next) => {
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Ungueltiger oder abgelaufener Token' });
return res.status(403).json({ error: 'Ungültiger oder abgelaufener Token' });
}
req.user = user;
next();

View File

@@ -2,7 +2,7 @@
"name": "pfandsystem-backend",
"version": "1.0.0",
"type": "module",
"description": "Backend API fuer Pfandsystem mit MySQL",
"description": "Backend API für Pfandsystem mit MySQL",
"main": "server.js",
"scripts": {
"start": "node server.js",

View File

@@ -69,7 +69,7 @@ router.delete('/:id', async (req, res) => {
const [r] = await pool.query('DELETE FROM artikel WHERE id=? AND tenant_id=?',
[req.params.id, req.user.tenant_id]);
if (r.affectedRows === 0) return res.status(404).json({ error: 'Artikel nicht gefunden' });
res.json({ message: 'Artikel geloescht' });
res.json({ message: 'Artikel gelöscht' });
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
});

View File

@@ -46,13 +46,13 @@ router.post('/login', [
);
}
if (rows.length === 0) return res.status(401).json({ error: 'Ungueltige Anmeldedaten' });
if (rows.length === 0) return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
const m = rows[0];
const ok = await bcrypt.compare(password, m.password_hash);
if (!ok) return res.status(401).json({ error: 'Ungueltige Anmeldedaten' });
if (!ok) return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
// Demo-Ablauf-Pruefung
// Demo-Ablauf-Prüfung
if (m.tenant_id) {
const [tr] = await pool.query(
'SELECT status, demo_expires_at, typ, name FROM tenants WHERE id = ?',
@@ -114,7 +114,7 @@ router.post('/change-password', authenticateToken, [
if (!ok) return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
const hash = await bcrypt.hash(req.body.newPassword, 10);
await pool.query('UPDATE mitarbeiter SET password_hash = ? WHERE id = ?', [hash, req.user.id]);
res.json({ message: 'Passwort erfolgreich geaendert' });
res.json({ message: 'Passwort erfolgreich geändert' });
} catch (e) {
console.error('change-password-Fehler:', e);
res.status(500).json({ error: 'Serverfehler' });

View File

@@ -28,7 +28,7 @@ router.get('/:id', async (req, res) => {
WHERE g.id = ? AND g.tenant_id = ?`,
[req.params.id, req.user.tenant_id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Geraet nicht gefunden' });
if (rows.length === 0) return res.status(404).json({ error: 'Gerät nicht gefunden' });
res.json(rows[0]);
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
});
@@ -63,7 +63,7 @@ router.post('/', [
res.status(201).json(rows[0]);
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') return res.status(400).json({ error: 'Seriennummer bereits vergeben' });
console.error('POST geraete:', e);
console.error('POST geräte:', e);
res.status(500).json({ error: 'Serverfehler' });
}
});
@@ -88,7 +88,7 @@ router.put('/:id', [
[seriennummer, typ, bezeichnung || null, kunde_id || null,
status || 'aktiv', notiz || null, req.params.id, req.user.tenant_id]
);
if (r.affectedRows === 0) return res.status(404).json({ error: 'Geraet nicht gefunden' });
if (r.affectedRows === 0) return res.status(404).json({ error: 'Gerät nicht gefunden' });
const [rows] = await pool.query('SELECT * FROM geraete WHERE id=? AND tenant_id=?',
[req.params.id, req.user.tenant_id]);
res.json(rows[0]);
@@ -102,8 +102,8 @@ router.delete('/:id', async (req, res) => {
try {
const [r] = await pool.query('DELETE FROM geraete WHERE id=? AND tenant_id=?',
[req.params.id, req.user.tenant_id]);
if (r.affectedRows === 0) return res.status(404).json({ error: 'Geraet nicht gefunden' });
res.json({ message: 'Geraet geloescht' });
if (r.affectedRows === 0) return res.status(404).json({ error: 'Gerät nicht gefunden' });
res.json({ message: 'Gerät gelöscht' });
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
});

View File

@@ -51,7 +51,7 @@ router.patch('/bild/:id', async (req, res) => {
const values = [];
if (req.body.beschreibung !== undefined) { updates.push('beschreibung = ?'); values.push(req.body.beschreibung); }
if (req.body.sortierung !== undefined) { updates.push('sortierung = ?'); values.push(parseInt(req.body.sortierung)); }
if (updates.length === 0) return res.status(400).json({ error: 'Keine Aenderungen' });
if (updates.length === 0) return res.status(400).json({ error: 'Keine Änderungen' });
values.push(req.params.id, req.user.tenant_id);
try {
const [r] = await pool.query(
@@ -73,11 +73,11 @@ router.delete('/bild/:id', async (req, res) => {
if (rows.length === 0) return res.status(404).json({ error: 'Bild nicht gefunden' });
await pool.query('DELETE FROM kunden_bilder WHERE id = ? AND tenant_id = ?',
[req.params.id, req.user.tenant_id]);
// Datei loeschen (best effort)
// Datei löschen (best effort)
const filename = rows[0].dateiname.replace(/^\/uploads\//, '');
const fullPath = path.join(process.env.UPLOAD_DIR || '/app/uploads', filename);
try { fs.unlinkSync(fullPath); } catch {}
res.json({ message: 'Bild geloescht' });
res.json({ message: 'Bild gelöscht' });
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
});

View File

@@ -93,7 +93,7 @@ router.delete('/:id', async (req, res) => {
const [r] = await pool.query('DELETE FROM kunden WHERE id=? AND tenant_id=?',
[req.params.id, req.user.tenant_id]);
if (r.affectedRows === 0) return res.status(404).json({ error: 'Kunde nicht gefunden' });
res.json({ message: 'Kunde geloescht' });
res.json({ message: 'Kunde gelöscht' });
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
});

View File

@@ -70,7 +70,7 @@ router.delete('/:id', async (req, res) => {
const [r] = await pool.query('DELETE FROM lieferungen WHERE id=? AND tenant_id=?',
[req.params.id, req.user.tenant_id]);
if (r.affectedRows === 0) return res.status(404).json({ error: 'Lieferung nicht gefunden' });
res.json({ message: 'Lieferung geloescht' });
res.json({ message: 'Lieferung gelöscht' });
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
});

View File

@@ -27,7 +27,7 @@ router.post('/', requireRole(['admin']), [
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
try {
// Pruefe Limit
// Prüfe Limit
const [[t]] = await pool.query('SELECT max_users FROM tenants WHERE id = ?', [req.user.tenant_id]);
const [[c]] = await pool.query('SELECT COUNT(*) AS n FROM mitarbeiter WHERE tenant_id = ?',
[req.user.tenant_id]);
@@ -64,7 +64,7 @@ router.put('/:id', [
return res.status(403).json({ error: 'Keine Berechtigung' });
}
if ((req.body.rolle || req.body.aktiv !== undefined) && req.user.rolle !== 'admin') {
return res.status(403).json({ error: 'Nur Admin darf Rolle/Aktiv aendern' });
return res.status(403).json({ error: 'Nur Admin darf Rolle/Aktiv ändern' });
}
const updates = [];
@@ -72,7 +72,7 @@ router.put('/:id', [
if (req.body.name) { updates.push('name = ?'); values.push(req.body.name); }
if (req.body.rolle) { updates.push('rolle = ?'); values.push(req.body.rolle); }
if (req.body.aktiv !== undefined) { updates.push('aktiv = ?'); values.push(req.body.aktiv ? 1 : 0); }
if (updates.length === 0) return res.status(400).json({ error: 'Keine Aenderungen' });
if (updates.length === 0) return res.status(400).json({ error: 'Keine Änderungen' });
values.push(req.params.id, req.user.tenant_id);
try {
@@ -90,12 +90,12 @@ router.put('/:id', [
});
router.delete('/:id', requireRole(['admin']), async (req, res) => {
if (req.params.id === req.user.id) return res.status(400).json({ error: 'Sie koennen sich nicht selbst loeschen' });
if (req.params.id === req.user.id) return res.status(400).json({ error: 'Sie koennen sich nicht selbst löschen' });
try {
const [r] = await pool.query('DELETE FROM mitarbeiter WHERE id=? AND tenant_id=?',
[req.params.id, req.user.tenant_id]);
if (r.affectedRows === 0) return res.status(404).json({ error: 'Mitarbeiter nicht gefunden' });
res.json({ message: 'Mitarbeiter geloescht' });
res.json({ message: 'Mitarbeiter gelöscht' });
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
});

View File

@@ -59,7 +59,7 @@ router.post('/', upload.single('foto'), [
const [rows] = await pool.query(`${baseSelect} WHERE r.id = ?`, [result.insertId]);
res.status(201).json(rows[0]);
} catch (e) {
console.error('POST ruecknahmen:', e);
console.error('POST rücknahmen:', e);
res.status(500).json({ error: 'Serverfehler' });
}
});
@@ -68,8 +68,8 @@ router.delete('/:id', async (req, res) => {
try {
const [r] = await pool.query('DELETE FROM ruecknahmen WHERE id=? AND tenant_id=?',
[req.params.id, req.user.tenant_id]);
if (r.affectedRows === 0) return res.status(404).json({ error: 'Ruecknahme nicht gefunden' });
res.json({ message: 'Ruecknahme geloescht' });
if (r.affectedRows === 0) return res.status(404).json({ error: 'Rücknahme nicht gefunden' });
res.json({ message: 'Rücknahme gelöscht' });
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
});

View File

@@ -89,7 +89,7 @@ router.post('/', [
res.status(201).json({
tenant_id: tenantId, slug,
admin_email: kontakt_email,
// Passwort wird ausnahmsweise im Response zurueckgegeben, damit der SuperAdmin
// Passwort wird ausnahmsweise im Response zurückgegeben, damit der SuperAdmin
// es ggf. manuell weitergeben kann, falls die Mail nicht ankommt.
admin_password_oneshot: plainPw,
demo_expires_in_days: days
@@ -103,7 +103,7 @@ router.post('/', [
}
});
// PATCH /api/tenants/:id -> Demo verlaengern, Status aendern, Limits aendern
// PATCH /api/tenants/:id -> Demo verlaengern, Status ändern, Limits ändern
router.patch('/:id', async (req, res) => {
const updates = [];
const values = [];
@@ -115,7 +115,7 @@ router.patch('/:id', async (req, res) => {
if (req.body.status) { updates.push('status = ?'); values.push(req.body.status); }
if (req.body.max_users) { updates.push('max_users = ?'); values.push(parseInt(req.body.max_users)); }
if (req.body.typ) { updates.push('typ = ?'); values.push(req.body.typ); }
if (updates.length === 0) return res.status(400).json({ error: 'Keine Aenderungen' });
if (updates.length === 0) return res.status(400).json({ error: 'Keine Änderungen' });
values.push(req.params.id);
try {
await pool.query(`UPDATE tenants SET ${updates.join(', ')} WHERE id = ?`, values);
@@ -127,11 +127,11 @@ router.patch('/:id', async (req, res) => {
}
});
// DELETE /api/tenants/:id -> Tenant inkl. aller Daten loeschen (CASCADE)
// DELETE /api/tenants/:id -> Tenant inkl. aller Daten löschen (CASCADE)
router.delete('/:id', async (req, res) => {
try {
await pool.query('DELETE FROM tenants WHERE id = ?', [req.params.id]);
res.json({ message: 'Tenant geloescht' });
res.json({ message: 'Tenant gelöscht' });
} catch (e) {
res.status(500).json({ error: 'Serverfehler' });
}

View File

@@ -44,7 +44,7 @@ app.use('/api/geraete', geraeteRoutes);
app.use((err, req, res, next) => {
console.error('Fehler:', err);
if (err.name === 'MulterError') {
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Datei zu gross (max. 10MB)' });
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Datei zu groß (max. 10MB)' });
return res.status(400).json({ error: err.message });
}
res.status(err.status || 500).json({ error: err.message || 'Interner Serverfehler' });

View File

@@ -7,7 +7,6 @@ http {
sendfile on;
keepalive_timeout 65;
# gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
@@ -19,8 +18,8 @@ http {
root /usr/share/nginx/html;
index index.html;
# API Backend
location /api/ {
# API -> Backend (^~ damit Regex-Locations darunter nicht greifen)
location ^~ /api/ {
proxy_pass http://backend:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -29,23 +28,24 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Uploads Backend
location /uploads/ {
# Uploads -> Backend (^~ wichtig, sonst greift die .jpg-Regex-Location unten)
location ^~ /uploads/ {
proxy_pass http://backend:3001/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
add_header Cache-Control "public, max-age=86400";
}
# Cache fuer statische Assets im SPA
location ~* \.(?:js|css|woff2?|ttf|eot|jpg|jpeg|png|gif|svg|ico|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache statische Assets
location ~* \.(?:js|css|woff2?|ttf|eot|jpg|jpeg|png|gif|svg|ico|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
}

Binary file not shown.

BIN
src/._api

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -17,7 +17,7 @@ class ApiClient {
const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { throw new Error('Ungueltige Server-Antwort'); }
try { data = text ? JSON.parse(text) : null; } catch { throw new Error('Ungültige Server-Antwort'); }
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
return data;
}
@@ -89,7 +89,7 @@ class ApiClient {
}
deleteLieferung(id) { return this.request(`/lieferungen/${id}`, { method: 'DELETE' }); }
// Ruecknahmen
// Rücknahmen
getRuecknahmen() { return this.request('/ruecknahmen'); }
async createRuecknahme(r, foto) {
const fd = new FormData();
@@ -113,7 +113,7 @@ class ApiClient {
updateMitarbeiter(id, m) { return this.request(`/mitarbeiter/${id}`, { method: 'PUT', body: JSON.stringify(m) }); }
deleteMitarbeiter(id) { return this.request(`/mitarbeiter/${id}`, { method: 'DELETE' }); }
// Geraete
// Geräte
getGeraete() { return this.request('/geraete'); }
createGeraet(g) { return this.request('/geraete', { method: 'POST', body: JSON.stringify(g) }); }
updateGeraet(id, g) { return this.request(`/geraete/${id}`, { method: 'PUT', body: JSON.stringify(g) }); }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -11,18 +11,18 @@ export default function PasswordChange() {
const submit = async (e) => {
e.preventDefault();
if (form.newPassword.length < 6) return setErr('Neues Passwort min. 6 Zeichen');
if (form.newPassword !== form.confirm) return setErr('Passwoerter stimmen nicht ueberein');
if (form.newPassword !== form.confirm) return setErr('Passwoerter stimmen nicht überein');
setBusy(true);
try {
await api.changePassword(form.currentPassword, form.newPassword);
setOk('Passwort geaendert');
setOk('Passwort geändert');
setForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
<form onSubmit={submit} className="card space-y-4">
<h3 className="card-title">Passwort aendern</h3>
<h3 className="card-title">Passwort ändern</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>
@@ -34,10 +34,10 @@ export default function PasswordChange() {
<input type="password" name="newPassword" value={form.newPassword} onChange={update} className="input" />
</div>
<div>
<label className="label">Bestaetigen</label>
<label className="label">Bestätigen</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>
<button disabled={busy} className="btn-primary w-full">{busy ? 'Speichere…' : 'Ändern'}</button>
</form>
);
}

View File

@@ -18,7 +18,7 @@ function downloadCSV(rows, filename) {
}
export default function AdminDashboard() {
const [data, setData] = useState({ kunden: [], artikel: [], mitarbeiter: [], lieferungen: [], ruecknahmen: [] });
const [data, setData] = useState({ kunden: [], artikel: [], mitarbeiter: [], lieferungen: [], rücknahmen: [] });
const [loading, setLoading] = useState(true);
const [selectedKunde, setSelectedKunde] = useState('');
const [selectedMitarbeiter, setSelectedMitarbeiter] = useState('');
@@ -27,7 +27,7 @@ export default function AdminDashboard() {
useEffect(() => {
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 }))
.then(([k, a, m, l, r]) => setData({ kunden: k, artikel: a, mitarbeiter: m, lieferungen: l, rücknahmen: r }))
.finally(() => setLoading(false));
}, []);
@@ -41,7 +41,7 @@ export default function AdminDashboard() {
};
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)
const r = data.rücknahmen.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 => {
@@ -70,7 +70,7 @@ export default function AdminDashboard() {
<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>
<option value=""> wählen </option>
{data.kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</div>
@@ -125,7 +125,7 @@ export default function AdminDashboard() {
</div>
)
) : (
<div className="card text-center text-slate-500">Bitte Kunden waehlen, um Auswertung zu sehen.</div>
<div className="card text-center text-slate-500">Bitte Kunden wählen, um Auswertung zu sehen.</div>
)}
</div>
);

View File

@@ -29,9 +29,9 @@ export default function Dashboard({ user, onLogout }) {
<VorgangsListe refreshFlag={refreshFlag} mode="lieferungen" />
</Section>
)}
{activeView === 'ruecknahmen' && (
<Section title="Ruecknahmen">
<VorgangsListe refreshFlag={refreshFlag} mode="ruecknahmen" />
{activeView === 'rücknahmen' && (
<Section title="Rücknahmen">
<VorgangsListe refreshFlag={refreshFlag} mode="rücknahmen" />
</Section>
)}
{activeView === 'tour' && <TourPlanung />}

View File

@@ -1,55 +1,66 @@
import { useState, useRef, useEffect } from 'react';
import {
FaPlusCircle, FaTruck, FaUndo, FaUsers, FaAddressBook,
FaChartBar, FaBars, FaUserCircle, FaSignOutAlt, FaBoxes, FaMapMarkedAlt
FaChartBar, FaUserCircle, FaSignOutAlt, FaBoxes, FaMapMarkedAlt
} from 'react-icons/fa';
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' },
{ icon: FaPlusCircle, label: 'Vorgang', view: 'vorgang' },
{ icon: FaTruck, label: 'Lieferungen', view: 'lieferungen' },
{ icon: FaUndo, label: 'Rücknahmen', view: 'ruecknahmen' },
{ icon: FaMapMarkedAlt, label: 'Tour', view: 'tour' },
];
const adminItems = [
{ icon: FaChartBar, label: 'Auswertung', view: 'admin' },
{ icon: FaAddressBook, label: 'Kunden', view: 'kunden' },
{ icon: FaBoxes, label: 'Geraete', view: 'geraete' },
{ icon: FaChartBar, label: 'Auswertung', view: 'admin' },
{ icon: FaAddressBook, label: 'Kunden', view: 'kunden' },
{ icon: FaBoxes, label: 'Geräte', view: 'geraete' },
{ icon: FaUsers, label: 'Mitarbeiter', view: 'mitarbeiter' },
];
export default function TopNav({ user, activeView, setActiveView, onLogout }) {
const isAdmin = user.rolle === 'admin';
const items = mainItems;
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef(null);
useEffect(() => {
const handler = e => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); };
const handler = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
document.addEventListener('touchstart', handler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('touchstart', handler);
};
}, []);
const select = (v) => { setActiveView(v); setMenuOpen(false); };
const itemBaseCls = "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-slate-700 hover:bg-slate-50 active:bg-slate-100";
const itemActiveCls = "bg-brand-50 text-brand-900 font-medium";
return (
<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">
<img src="/pfandlogo.png" alt="" className="h-8 w-8 rounded-lg object-contain bg-white ring-1 ring-slate-200 p-0.5" />
<span className="font-semibold text-slate-900">Dockly-Pfandsystem</span>
<div className="max-w-6xl mx-auto px-3 sm:px-6 flex items-center h-14 gap-3">
{/* Brand */}
<div className="flex items-center gap-2 min-w-0">
<img src="/pfandlogo.png" alt=""
className="h-8 w-8 rounded-lg object-contain bg-white ring-1 ring-slate-200 p-0.5 flex-shrink-0" />
<span className="font-semibold text-slate-900 truncate text-sm sm:text-base">Dockly-Pfandsystem</span>
{user.tenant_typ === 'demo' && (
<span className="badge-amber ml-2">Demo</span>
<span className="badge-amber hidden sm:inline-flex ml-1 flex-shrink-0">Demo</span>
)}
</div>
<nav className="hidden md:flex items-center gap-1 flex-1">
{items.map(it => (
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1 ml-4 flex-1 overflow-x-auto">
{mainItems.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" />
<div className="h-5 w-px bg-slate-200 mx-2 flex-shrink-0" />
{adminItems.map(it => (
<NavBtn key={it.view} {...it} active={activeView === it.view} onClick={() => select(it.view)} />
))}
@@ -57,46 +68,72 @@ export default function TopNav({ user, activeView, setActiveView, onLogout }) {
)}
</nav>
<div className="flex-1 md:flex-none" />
<div className="flex-1 md:hidden" />
<div className="relative" ref={menuRef}>
{/* User Menu */}
<div className="relative flex-shrink-0" ref={menuRef}>
<button onClick={() => setMenuOpen(o => !o)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-100">
aria-label="Menü öffnen"
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-100 active:bg-slate-200">
<FaUserCircle className="text-slate-500" size={22} />
<span className="hidden sm:inline text-sm text-slate-700">{user.name || user.email}</span>
<span className="hidden lg:inline text-sm text-slate-700 max-w-[140px] truncate">
{user.name || user.email}
</span>
</button>
{menuOpen && (
<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 className="absolute right-0 top-full mt-1 w-64 max-w-[calc(100vw-1.5rem)]
bg-white rounded-xl shadow-soft ring-1 ring-slate-200 py-1
overflow-hidden">
{/* User Info */}
<div className="px-3 py-3 border-b border-slate-100 bg-slate-50">
<div className="text-sm font-semibold text-slate-900 truncate">{user.name}</div>
<div className="text-xs text-slate-500 truncate">{user.email}</div>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className="badge-brand capitalize">{user.rolle}</span>
{user.tenant_typ === 'demo' && <span className="badge-amber">Demo</span>}
</div>
</div>
<button className="menu-item" onClick={() => select('account')}>
<FaUserCircle /> Account
{/* Account */}
<button className={`${itemBaseCls} ${activeView === 'account' ? itemActiveCls : ''}`}
onClick={() => select('account')}>
<FaUserCircle className="text-slate-400" /> Account
</button>
{/* Mobile: alle Haupt-Items */}
<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}
<div className="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wide">Navigation</div>
{mainItems.map(it => (
<button key={it.view}
className={`${itemBaseCls} ${activeView === it.view ? itemActiveCls : ''}`}
onClick={() => select(it.view)}>
<it.icon className="text-slate-400" /> {it.label}
</button>
))}
{isAdmin && (
<>
<div className="px-3 py-1 mt-1 text-xs font-semibold text-slate-400 uppercase tracking-wide border-t border-slate-100">Verwaltung</div>
{adminItems.map(it => (
<button key={it.view}
className={`${itemBaseCls} ${activeView === it.view ? itemActiveCls : ''}`}
onClick={() => select(it.view)}>
<it.icon className="text-slate-400" /> {it.label}
</button>
))}
</>
)}
</div>
<button className="menu-item text-rose-600 border-t border-slate-100 mt-1 pt-2" onClick={onLogout}>
{/* Logout */}
<button className={`${itemBaseCls} text-rose-600 hover:bg-rose-50 border-t border-slate-100 mt-1`}
onClick={onLogout}>
<FaSignOutAlt /> Abmelden
</button>
</div>
)}
</div>
</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>
);
}
@@ -104,8 +141,8 @@ export default function TopNav({ user, activeView, setActiveView, onLogout }) {
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'}`}>
className={`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition flex-shrink-0
${active ? 'bg-brand-50 text-brand-900' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}`}>
<Icon size={14} />{label}
</button>
);

View File

@@ -77,7 +77,7 @@ export default function VorgangsListe({ refreshFlag, mode }) {
</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 className="text-xs text-slate-500">Stück</div>
</div>
</li>
))}

View File

@@ -28,7 +28,7 @@ export default function VorgangForm({ onSubmit }) {
};
const submit = async (typ) => {
if (!kundeId || !artikelId) return setMsg('Bitte Kunde und Artikel waehlen');
if (!kundeId || !artikelId) return setMsg('Bitte Kunde und Artikel wählen');
setBusy(true); setMsg('');
try {
const data = { kunde_id: kundeId, artikel_id: artikelId, anzahl: parseInt(anzahl) };
@@ -45,7 +45,7 @@ export default function VorgangForm({ onSubmit }) {
<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>
<option value=""> wählen </option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<button type="button" disabled={!kundeId} onClick={openInfo}
@@ -58,7 +58,7 @@ export default function VorgangForm({ onSubmit }) {
<div>
<label className="label">Artikel</label>
<select className="input" value={artikelId} onChange={e => setArtikelId(e.target.value)}>
<option value=""> waehlen </option>
<option value=""> wählen </option>
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
</select>
</div>
@@ -72,7 +72,7 @@ export default function VorgangForm({ onSubmit }) {
<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'}
<FaCamera /> {foto ? foto.name : 'Foto erstellen / auswählen'}
</label>
</div>
@@ -80,7 +80,7 @@ export default function VorgangForm({ onSubmit }) {
<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>
<button disabled={busy} onClick={() => submit('rücknahme')} className="btn-secondary">Zurücknehmen</button>
</div>
{infoOpen && kundenDetail && (

View File

@@ -11,7 +11,7 @@ const STATUS = {
const empty = { seriennummer: '', typ: '', bezeichnung: '', kunde_id: '', status: 'aktiv', notiz: '' };
export default function Geraete() {
export default function Geräte() {
const [list, setList] = useState([]);
const [kunden, setKunden] = useState([]);
const [form, setForm] = useState(empty);
@@ -43,7 +43,7 @@ export default function Geraete() {
cancel(); setError(''); load();
} catch (e) { setError(e.message); }
};
const del = async (id) => { if (!confirm('Geraet loeschen?')) return; await api.deleteGeraet(id); load(); };
const del = async (id) => { if (!confirm('Gerät löschen?')) return; await api.deleteGeraet(id); load(); };
const typen = Array.from(new Set(list.map(g => g.typ)));
const view = list.filter(g =>
@@ -53,19 +53,19 @@ export default function Geraete() {
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>
<h2 className="text-xl font-semibold text-slate-900">Geräte</h2>
<div className="text-sm text-slate-500">{list.length} Geräte</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>
<h3 className="font-medium text-slate-800 mb-3">{editId ? 'Gerät bearbeiten' : 'Neues Gerät'}</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}
<input className="input" placeholder="z. B. Ofen, Kühlschrank" value={form.typ}
onChange={e => setForm(f => ({ ...f, typ: e.target.value }))} />
</Field>
<Field label="Bezeichnung">
@@ -137,7 +137,7 @@ export default function Geraete() {
</td>
</tr>
))}
{view.length === 0 && <tr><td colSpan={6} className="text-center py-8 text-slate-400">Keine Geraete.</td></tr>}
{view.length === 0 && <tr><td colSpan={6} className="text-center py-8 text-slate-400">Keine Geräte.</td></tr>}
</tbody>
</table>
)}

View File

@@ -46,7 +46,7 @@ export default function KundenVerwaltung() {
};
const del = async (id) => {
if (!confirm('Kunde wirklich loeschen?')) return;
if (!confirm('Kunde wirklich löschen?')) return;
try { await api.deleteKunde(id); load(); } catch (e) { setError(e.message); }
};
@@ -91,7 +91,7 @@ export default function KundenVerwaltung() {
<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"
placeholder="z. B. Hintertür, Code 1234#, Schlüsselkasten links neben dem Eingang"
value={form.anlieferungshinweis}
onChange={e => setForm(f => ({ ...f, anlieferungshinweis: e.target.value }))} />
</div>
@@ -131,7 +131,7 @@ export default function KundenVerwaltung() {
<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>
<button onClick={() => del(k.id)} className="btn-ghost px-2 text-rose-600" title="Löschen"><FaTrash /></button>
</td>
</tr>
))}
@@ -166,7 +166,7 @@ function BilderModal({ kundeId, onClose }) {
catch (e) { alert(e.message); } finally { setBusy(false); }
};
const del = async (id) => {
if (!confirm('Bild loeschen?')) return;
if (!confirm('Bild löschen?')) return;
await api.deleteKundenBild(id); load();
};
@@ -204,7 +204,7 @@ function BilderModal({ kundeId, onClose }) {
{(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}
<input className="input" placeholder="Beschreibung (z. B. Hintertür)" 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} />

View File

@@ -16,7 +16,7 @@ export default function MitarbeiterVerwaltung({ user }) {
useEffect(() => { load(); }, []);
const add = async () => {
if (!form.name || !form.email || !form.password) return setError('Alle Felder ausfuellen');
if (!form.name || !form.email || !form.password) return setError('Alle Felder ausfüllen');
try {
await api.createMitarbeiter(form);
setForm({ name: '', email: '', password: '', rolle: 'user' }); setError(''); load();
@@ -25,7 +25,7 @@ export default function MitarbeiterVerwaltung({ user }) {
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(); };
const del = async (id) => { if (!confirm('Wirklich löschen?')) return; await api.deleteMitarbeiter(id); load(); };
return (
<div className="space-y-4 max-w-3xl mx-auto">

View File

@@ -19,7 +19,7 @@ export default function SuperAdmin({ user, onLogout }) {
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(); };
const del = async (id) => { if (!confirm('Tenant inkl. aller Daten löschen?')) return; await api.deleteTenant(id); load(); };
return (
<div className="min-h-screen bg-slate-50">
@@ -154,7 +154,7 @@ function NewTenantModal({ onClose, onCreated }) {
<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)
Mit Beispieldaten befüllen (5 Kunden, 3 Artikel, 2 Geräte)
</label>
<div className="flex gap-2 pt-2">
@@ -176,7 +176,7 @@ function CreatedInfoModal({ info, onClose }) {
<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>
<div><dt className="inline text-slate-500">Gültig: </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>

View File

@@ -44,7 +44,7 @@ export default function TourPlanung() {
<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>
<button onClick={resetTour} className="btn-ghost text-xs">Tour zurücksetzen</button>
)}
</div>