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:
BIN
._index.html
BIN
._index.html
Binary file not shown.
BIN
._package.json
BIN
._package.json
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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')`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
24
nginx.conf
24
nginx.conf
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
src/._App.jsx
BIN
src/._App.jsx
Binary file not shown.
BIN
src/._components
BIN
src/._components
Binary file not shown.
BIN
src/._index.css
BIN
src/._index.css
Binary file not shown.
BIN
src/._main.jsx
BIN
src/._main.jsx
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Binary file not shown.
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user