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 = `
|
const html = `
|
||||||
<div style="font-family:system-ui,Segoe UI,Helvetica,Arial,sans-serif;max-width:560px;margin:auto">
|
<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>
|
<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>
|
<code>${process.env.SMTP_HOST || 'kein SMTP'}</code> funktioniert.</p>
|
||||||
<p>Absender: <code>${FROM}</code></p>
|
<p>Absender: <code>${FROM}</code></p>
|
||||||
<p>Zeitstempel: ${new Date().toISOString()}</p>
|
<p>Zeitstempel: ${new Date().toISOString()}</p>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
// Befuellt einen frischen Demo-Tenant mit Beispieldaten
|
// Befüllt einen frischen Demo-Tenant mit Beispieldaten
|
||||||
// (5 Kunden, 3 Artikel, 6 Lieferungen, 2 Geraete).
|
// (5 Kunden, 3 Artikel, 6 Lieferungen, 2 Geräte).
|
||||||
export async function seedDemoTenant(conn, tenantId) {
|
export async function seedDemoTenant(conn, tenantId) {
|
||||||
// Artikel
|
// Artikel
|
||||||
const artikel = [
|
const artikel = [
|
||||||
{ id: crypto.randomUUID(), name: 'Brotkorb klein', pfand: 2.50 },
|
{ 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 }
|
{ id: crypto.randomUUID(), name: 'Transportbox', pfand: 10.00 }
|
||||||
];
|
];
|
||||||
for (const a of artikel) {
|
for (const a of artikel) {
|
||||||
@@ -18,11 +18,11 @@ export async function seedDemoTenant(conn, tenantId) {
|
|||||||
|
|
||||||
// Kunden
|
// Kunden
|
||||||
const kunden = [
|
const kunden = [
|
||||||
{ name: 'Cafe Sonnenschein', adresse: 'Bahnhofstr. 12, 10115 Berlin', zeit: '05:00', hinweis: 'Anlieferung Hintertuer. Code 1234#.' },
|
{ 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: 'Schluesselkasten links neben Eingang.' },
|
{ name: 'Restaurant Roma', adresse: 'Hauptstr. 88, 10117 Berlin', zeit: '06:30', hinweis: 'Schlüsselkasten links neben Eingang.' },
|
||||||
{ name: 'Hotel Adlerhorst', adresse: 'Seestrasse 5, 14193 Berlin', zeit: '05:30', hinweis: 'Anlieferung Tiefgarage Ebene -1, Tor 3.' },
|
{ 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: '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 = [];
|
const kundenIds = [];
|
||||||
for (const k of kunden) {
|
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++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const kid = kundenIds[i % kundenIds.length];
|
const kid = kundenIds[i % kundenIds.length];
|
||||||
const aid = artikel[i % artikel.length].id;
|
const aid = artikel[i % artikel.length].id;
|
||||||
@@ -47,12 +47,12 @@ export async function seedDemoTenant(conn, tenantId) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Geraete
|
// Geräte
|
||||||
const geraete = [
|
const geraeteList = [
|
||||||
{ sn: 'OF-2024-001', typ: 'Ofen', bez: 'Miwe Condo 3-Etagen', kid: kundenIds[0] },
|
{ 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(
|
await conn.query(
|
||||||
`INSERT INTO geraete (id, tenant_id, seriennummer, typ, bezeichnung, kunde_id, status)
|
`INSERT INTO geraete (id, tenant_id, seriennummer, typ, bezeichnung, kunde_id, status)
|
||||||
VALUES (UUID(), ?, ?, ?, ?, ?, 'aktiv')`,
|
VALUES (UUID(), ?, ?, ?, ?, ?, 'aktiv')`,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import pool from '../config/database.js';
|
|||||||
|
|
||||||
// Verifiziert JWT und stellt req.user bereit
|
// Verifiziert JWT und stellt req.user bereit
|
||||||
// req.user enthaelt: { id, email, name, rolle, tenant_id }
|
// 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) => {
|
export const authenticateToken = (req, res, next) => {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
const token = authHeader && authHeader.split(' ')[1];
|
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) => {
|
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
|
||||||
if (err) {
|
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;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "pfandsystem-backend",
|
"name": "pfandsystem-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Backend API fuer Pfandsystem mit MySQL",
|
"description": "Backend API für Pfandsystem mit MySQL",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"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=?',
|
const [r] = await pool.query('DELETE FROM artikel WHERE id=? AND tenant_id=?',
|
||||||
[req.params.id, req.user.tenant_id]);
|
[req.params.id, req.user.tenant_id]);
|
||||||
if (r.affectedRows === 0) return res.status(404).json({ error: 'Artikel nicht gefunden' });
|
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' }); }
|
} 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 m = rows[0];
|
||||||
|
|
||||||
const ok = await bcrypt.compare(password, m.password_hash);
|
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) {
|
if (m.tenant_id) {
|
||||||
const [tr] = await pool.query(
|
const [tr] = await pool.query(
|
||||||
'SELECT status, demo_expires_at, typ, name FROM tenants WHERE id = ?',
|
'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' });
|
if (!ok) return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
|
||||||
const hash = await bcrypt.hash(req.body.newPassword, 10);
|
const hash = await bcrypt.hash(req.body.newPassword, 10);
|
||||||
await pool.query('UPDATE mitarbeiter SET password_hash = ? WHERE id = ?', [hash, req.user.id]);
|
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) {
|
} catch (e) {
|
||||||
console.error('change-password-Fehler:', e);
|
console.error('change-password-Fehler:', e);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
WHERE g.id = ? AND g.tenant_id = ?`,
|
WHERE g.id = ? AND g.tenant_id = ?`,
|
||||||
[req.params.id, req.user.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]);
|
res.json(rows[0]);
|
||||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||||
});
|
});
|
||||||
@@ -63,7 +63,7 @@ router.post('/', [
|
|||||||
res.status(201).json(rows[0]);
|
res.status(201).json(rows[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === 'ER_DUP_ENTRY') return res.status(400).json({ error: 'Seriennummer bereits vergeben' });
|
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' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -88,7 +88,7 @@ router.put('/:id', [
|
|||||||
[seriennummer, typ, bezeichnung || null, kunde_id || null,
|
[seriennummer, typ, bezeichnung || null, kunde_id || null,
|
||||||
status || 'aktiv', notiz || null, req.params.id, req.user.tenant_id]
|
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=?',
|
const [rows] = await pool.query('SELECT * FROM geraete WHERE id=? AND tenant_id=?',
|
||||||
[req.params.id, req.user.tenant_id]);
|
[req.params.id, req.user.tenant_id]);
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
@@ -102,8 +102,8 @@ router.delete('/:id', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [r] = await pool.query('DELETE FROM geraete WHERE id=? AND tenant_id=?',
|
const [r] = await pool.query('DELETE FROM geraete WHERE id=? AND tenant_id=?',
|
||||||
[req.params.id, req.user.tenant_id]);
|
[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' });
|
||||||
res.json({ message: 'Geraet geloescht' });
|
res.json({ message: 'Gerät gelöscht' });
|
||||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ router.patch('/bild/:id', async (req, res) => {
|
|||||||
const values = [];
|
const values = [];
|
||||||
if (req.body.beschreibung !== undefined) { updates.push('beschreibung = ?'); values.push(req.body.beschreibung); }
|
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 (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);
|
values.push(req.params.id, req.user.tenant_id);
|
||||||
try {
|
try {
|
||||||
const [r] = await pool.query(
|
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' });
|
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 = ?',
|
await pool.query('DELETE FROM kunden_bilder WHERE id = ? AND tenant_id = ?',
|
||||||
[req.params.id, req.user.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 filename = rows[0].dateiname.replace(/^\/uploads\//, '');
|
||||||
const fullPath = path.join(process.env.UPLOAD_DIR || '/app/uploads', filename);
|
const fullPath = path.join(process.env.UPLOAD_DIR || '/app/uploads', filename);
|
||||||
try { fs.unlinkSync(fullPath); } catch {}
|
try { fs.unlinkSync(fullPath); } catch {}
|
||||||
res.json({ message: 'Bild geloescht' });
|
res.json({ message: 'Bild gelöscht' });
|
||||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
} 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=?',
|
const [r] = await pool.query('DELETE FROM kunden WHERE id=? AND tenant_id=?',
|
||||||
[req.params.id, req.user.tenant_id]);
|
[req.params.id, req.user.tenant_id]);
|
||||||
if (r.affectedRows === 0) return res.status(404).json({ error: 'Kunde nicht gefunden' });
|
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' }); }
|
} 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=?',
|
const [r] = await pool.query('DELETE FROM lieferungen WHERE id=? AND tenant_id=?',
|
||||||
[req.params.id, req.user.tenant_id]);
|
[req.params.id, req.user.tenant_id]);
|
||||||
if (r.affectedRows === 0) return res.status(404).json({ error: 'Lieferung nicht gefunden' });
|
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' }); }
|
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ router.post('/', requireRole(['admin']), [
|
|||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||||
try {
|
try {
|
||||||
// Pruefe Limit
|
// Prüfe Limit
|
||||||
const [[t]] = await pool.query('SELECT max_users FROM tenants WHERE id = ?', [req.user.tenant_id]);
|
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 = ?',
|
const [[c]] = await pool.query('SELECT COUNT(*) AS n FROM mitarbeiter WHERE tenant_id = ?',
|
||||||
[req.user.tenant_id]);
|
[req.user.tenant_id]);
|
||||||
@@ -64,7 +64,7 @@ router.put('/:id', [
|
|||||||
return res.status(403).json({ error: 'Keine Berechtigung' });
|
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||||
}
|
}
|
||||||
if ((req.body.rolle || req.body.aktiv !== undefined) && req.user.rolle !== 'admin') {
|
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 = [];
|
const updates = [];
|
||||||
@@ -72,7 +72,7 @@ router.put('/:id', [
|
|||||||
if (req.body.name) { updates.push('name = ?'); values.push(req.body.name); }
|
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.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 (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);
|
values.push(req.params.id, req.user.tenant_id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -90,12 +90,12 @@ router.put('/:id', [
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', requireRole(['admin']), async (req, res) => {
|
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 {
|
try {
|
||||||
const [r] = await pool.query('DELETE FROM mitarbeiter WHERE id=? AND tenant_id=?',
|
const [r] = await pool.query('DELETE FROM mitarbeiter WHERE id=? AND tenant_id=?',
|
||||||
[req.params.id, req.user.tenant_id]);
|
[req.params.id, req.user.tenant_id]);
|
||||||
if (r.affectedRows === 0) return res.status(404).json({ error: 'Mitarbeiter nicht gefunden' });
|
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' }); }
|
} 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]);
|
const [rows] = await pool.query(`${baseSelect} WHERE r.id = ?`, [result.insertId]);
|
||||||
res.status(201).json(rows[0]);
|
res.status(201).json(rows[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('POST ruecknahmen:', e);
|
console.error('POST rücknahmen:', e);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -68,8 +68,8 @@ router.delete('/:id', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [r] = await pool.query('DELETE FROM ruecknahmen WHERE id=? AND tenant_id=?',
|
const [r] = await pool.query('DELETE FROM ruecknahmen WHERE id=? AND tenant_id=?',
|
||||||
[req.params.id, req.user.tenant_id]);
|
[req.params.id, req.user.tenant_id]);
|
||||||
if (r.affectedRows === 0) return res.status(404).json({ error: 'Ruecknahme nicht gefunden' });
|
if (r.affectedRows === 0) return res.status(404).json({ error: 'Rücknahme nicht gefunden' });
|
||||||
res.json({ message: 'Ruecknahme geloescht' });
|
res.json({ message: 'Rücknahme gelöscht' });
|
||||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ router.post('/', [
|
|||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
tenant_id: tenantId, slug,
|
tenant_id: tenantId, slug,
|
||||||
admin_email: kontakt_email,
|
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.
|
// es ggf. manuell weitergeben kann, falls die Mail nicht ankommt.
|
||||||
admin_password_oneshot: plainPw,
|
admin_password_oneshot: plainPw,
|
||||||
demo_expires_in_days: days
|
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) => {
|
router.patch('/:id', async (req, res) => {
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const values = [];
|
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.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.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 (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);
|
values.push(req.params.id);
|
||||||
try {
|
try {
|
||||||
await pool.query(`UPDATE tenants SET ${updates.join(', ')} WHERE id = ?`, values);
|
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) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await pool.query('DELETE FROM tenants WHERE id = ?', [req.params.id]);
|
await pool.query('DELETE FROM tenants WHERE id = ?', [req.params.id]);
|
||||||
res.json({ message: 'Tenant geloescht' });
|
res.json({ message: 'Tenant gelöscht' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ app.use('/api/geraete', geraeteRoutes);
|
|||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error('Fehler:', err);
|
console.error('Fehler:', err);
|
||||||
if (err.name === 'MulterError') {
|
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 });
|
return res.status(400).json({ error: err.message });
|
||||||
}
|
}
|
||||||
res.status(err.status || 500).json({ error: err.message || 'Interner Serverfehler' });
|
res.status(err.status || 500).json({ error: err.message || 'Interner Serverfehler' });
|
||||||
|
|||||||
24
nginx.conf
24
nginx.conf
@@ -7,7 +7,6 @@ http {
|
|||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
|
|
||||||
# gzip
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
@@ -19,8 +18,8 @@ http {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# API → Backend
|
# API -> Backend (^~ damit Regex-Locations darunter nicht greifen)
|
||||||
location /api/ {
|
location ^~ /api/ {
|
||||||
proxy_pass http://backend:3001/api/;
|
proxy_pass http://backend:3001/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -29,23 +28,24 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Uploads → Backend
|
# Uploads -> Backend (^~ wichtig, sonst greift die .jpg-Regex-Location unten)
|
||||||
location /uploads/ {
|
location ^~ /uploads/ {
|
||||||
proxy_pass http://backend:3001/uploads/;
|
proxy_pass http://backend:3001/uploads/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
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
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
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 res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let data = null;
|
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}`);
|
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
deleteLieferung(id) { return this.request(`/lieferungen/${id}`, { method: 'DELETE' }); }
|
deleteLieferung(id) { return this.request(`/lieferungen/${id}`, { method: 'DELETE' }); }
|
||||||
|
|
||||||
// Ruecknahmen
|
// Rücknahmen
|
||||||
getRuecknahmen() { return this.request('/ruecknahmen'); }
|
getRuecknahmen() { return this.request('/ruecknahmen'); }
|
||||||
async createRuecknahme(r, foto) {
|
async createRuecknahme(r, foto) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -113,7 +113,7 @@ class ApiClient {
|
|||||||
updateMitarbeiter(id, m) { return this.request(`/mitarbeiter/${id}`, { method: 'PUT', body: JSON.stringify(m) }); }
|
updateMitarbeiter(id, m) { return this.request(`/mitarbeiter/${id}`, { method: 'PUT', body: JSON.stringify(m) }); }
|
||||||
deleteMitarbeiter(id) { return this.request(`/mitarbeiter/${id}`, { method: 'DELETE' }); }
|
deleteMitarbeiter(id) { return this.request(`/mitarbeiter/${id}`, { method: 'DELETE' }); }
|
||||||
|
|
||||||
// Geraete
|
// Geräte
|
||||||
getGeraete() { return this.request('/geraete'); }
|
getGeraete() { return this.request('/geraete'); }
|
||||||
createGeraet(g) { return this.request('/geraete', { method: 'POST', body: JSON.stringify(g) }); }
|
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) }); }
|
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) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (form.newPassword.length < 6) return setErr('Neues Passwort min. 6 Zeichen');
|
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);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.changePassword(form.currentPassword, form.newPassword);
|
await api.changePassword(form.currentPassword, form.newPassword);
|
||||||
setOk('Passwort geaendert');
|
setOk('Passwort geändert');
|
||||||
setForm({ currentPassword: '', newPassword: '', confirm: '' });
|
setForm({ currentPassword: '', newPassword: '', confirm: '' });
|
||||||
} catch (e) { setErr(e.message); } finally { setBusy(false); }
|
} catch (e) { setErr(e.message); } finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className="card space-y-4">
|
<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>}
|
{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>}
|
{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>
|
<div>
|
||||||
@@ -34,10 +34,10 @@ export default function PasswordChange() {
|
|||||||
<input type="password" name="newPassword" value={form.newPassword} onChange={update} className="input" />
|
<input type="password" name="newPassword" value={form.newPassword} onChange={update} className="input" />
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<input type="password" name="confirm" value={form.confirm} onChange={update} className="input" />
|
||||||
</div>
|
</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>
|
</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() {
|
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 [loading, setLoading] = useState(true);
|
||||||
const [selectedKunde, setSelectedKunde] = useState('');
|
const [selectedKunde, setSelectedKunde] = useState('');
|
||||||
const [selectedMitarbeiter, setSelectedMitarbeiter] = useState('');
|
const [selectedMitarbeiter, setSelectedMitarbeiter] = useState('');
|
||||||
@@ -27,7 +27,7 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.getKunden(), api.getArtikel(), api.getMitarbeiter(), api.getLieferungen(), api.getRuecknahmen()])
|
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));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export default function AdminDashboard() {
|
|||||||
};
|
};
|
||||||
const l = data.lieferungen.filter(x => x.kunde_id === selectedKunde && inRange(x)
|
const l = data.lieferungen.filter(x => x.kunde_id === selectedKunde && inRange(x)
|
||||||
&& (!selectedMitarbeiter || x.mitarbeiter_id === selectedMitarbeiter));
|
&& (!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));
|
&& (!selectedMitarbeiter || x.mitarbeiter_id === selectedMitarbeiter));
|
||||||
const artikelIds = Array.from(new Set([...l.map(x => x.artikel_id), ...r.map(x => x.artikel_id)]));
|
const artikelIds = Array.from(new Set([...l.map(x => x.artikel_id), ...r.map(x => x.artikel_id)]));
|
||||||
return artikelIds.map(aid => {
|
return artikelIds.map(aid => {
|
||||||
@@ -70,7 +70,7 @@ export default function AdminDashboard() {
|
|||||||
<div className="flex-1 min-w-[160px]">
|
<div className="flex-1 min-w-[160px]">
|
||||||
<label className="label">Kunde</label>
|
<label className="label">Kunde</label>
|
||||||
<select className="input" value={selectedKunde} onChange={e => setSelectedKunde(e.target.value)}>
|
<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>)}
|
{data.kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +125,7 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export default function Dashboard({ user, onLogout }) {
|
|||||||
<VorgangsListe refreshFlag={refreshFlag} mode="lieferungen" />
|
<VorgangsListe refreshFlag={refreshFlag} mode="lieferungen" />
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{activeView === 'ruecknahmen' && (
|
{activeView === 'rücknahmen' && (
|
||||||
<Section title="Ruecknahmen">
|
<Section title="Rücknahmen">
|
||||||
<VorgangsListe refreshFlag={refreshFlag} mode="ruecknahmen" />
|
<VorgangsListe refreshFlag={refreshFlag} mode="rücknahmen" />
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{activeView === 'tour' && <TourPlanung />}
|
{activeView === 'tour' && <TourPlanung />}
|
||||||
|
|||||||
@@ -1,55 +1,66 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
FaPlusCircle, FaTruck, FaUndo, FaUsers, FaAddressBook,
|
FaPlusCircle, FaTruck, FaUndo, FaUsers, FaAddressBook,
|
||||||
FaChartBar, FaBars, FaUserCircle, FaSignOutAlt, FaBoxes, FaMapMarkedAlt
|
FaChartBar, FaUserCircle, FaSignOutAlt, FaBoxes, FaMapMarkedAlt
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
const mainItems = [
|
const mainItems = [
|
||||||
{ icon: FaPlusCircle, label: 'Vorgang', view: 'vorgang' },
|
{ icon: FaPlusCircle, label: 'Vorgang', view: 'vorgang' },
|
||||||
{ icon: FaTruck, label: 'Lieferungen', view: 'lieferungen' },
|
{ icon: FaTruck, label: 'Lieferungen', view: 'lieferungen' },
|
||||||
{ icon: FaUndo, label: 'Ruecknahmen', view: 'ruecknahmen' },
|
{ icon: FaUndo, label: 'Rücknahmen', view: 'ruecknahmen' },
|
||||||
{ icon: FaMapMarkedAlt,label: 'Tour', view: 'tour' },
|
{ icon: FaMapMarkedAlt, label: 'Tour', view: 'tour' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminItems = [
|
const adminItems = [
|
||||||
{ icon: FaChartBar, label: 'Auswertung', view: 'admin' },
|
{ icon: FaChartBar, label: 'Auswertung', view: 'admin' },
|
||||||
{ icon: FaAddressBook, label: 'Kunden', view: 'kunden' },
|
{ icon: FaAddressBook, label: 'Kunden', view: 'kunden' },
|
||||||
{ icon: FaBoxes, label: 'Geraete', view: 'geraete' },
|
{ icon: FaBoxes, label: 'Geräte', view: 'geraete' },
|
||||||
{ icon: FaUsers, label: 'Mitarbeiter', view: 'mitarbeiter' },
|
{ icon: FaUsers, label: 'Mitarbeiter', view: 'mitarbeiter' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function TopNav({ user, activeView, setActiveView, onLogout }) {
|
export default function TopNav({ user, activeView, setActiveView, onLogout }) {
|
||||||
const isAdmin = user.rolle === 'admin';
|
const isAdmin = user.rolle === 'admin';
|
||||||
const items = mainItems;
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
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 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 (
|
return (
|
||||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-30">
|
<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="max-w-6xl mx-auto px-3 sm:px-6 flex items-center h-14 gap-3">
|
||||||
<div className="flex items-center gap-2 mr-6">
|
{/* Brand */}
|
||||||
<img src="/pfandlogo.png" alt="" className="h-8 w-8 rounded-lg object-contain bg-white ring-1 ring-slate-200 p-0.5" />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="font-semibold text-slate-900">Dockly-Pfandsystem</span>
|
<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' && (
|
{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>
|
</div>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-1 flex-1">
|
{/* Desktop Nav */}
|
||||||
{items.map(it => (
|
<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)} />
|
<NavBtn key={it.view} {...it} active={activeView === it.view} onClick={() => select(it.view)} />
|
||||||
))}
|
))}
|
||||||
{isAdmin && (
|
{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 => (
|
{adminItems.map(it => (
|
||||||
<NavBtn key={it.view} {...it} active={activeView === it.view} onClick={() => select(it.view)} />
|
<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>
|
</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)}
|
<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} />
|
<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>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{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="absolute right-0 top-full mt-1 w-64 max-w-[calc(100vw-1.5rem)]
|
||||||
<div className="px-3 py-2 border-b border-slate-100">
|
bg-white rounded-xl shadow-soft ring-1 ring-slate-200 py-1
|
||||||
<div className="text-sm font-medium text-slate-900">{user.name}</div>
|
overflow-hidden">
|
||||||
<div className="text-xs text-slate-500">{user.email}</div>
|
{/* User Info */}
|
||||||
<div className="text-xs text-slate-400 mt-0.5">Rolle: {user.rolle}</div>
|
<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>
|
</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>
|
</button>
|
||||||
|
|
||||||
|
{/* Mobile: alle Haupt-Items */}
|
||||||
<div className="md:hidden border-t border-slate-100 mt-1 pt-1">
|
<div className="md:hidden border-t border-slate-100 mt-1 pt-1">
|
||||||
{items.map(it => (
|
<div className="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wide">Navigation</div>
|
||||||
<button key={it.view} className="menu-item" onClick={() => select(it.view)}>
|
{mainItems.map(it => (
|
||||||
<it.icon /> {it.label}
|
<button key={it.view}
|
||||||
</button>
|
className={`${itemBaseCls} ${activeView === it.view ? itemActiveCls : ''}`}
|
||||||
))}
|
onClick={() => select(it.view)}>
|
||||||
{isAdmin && adminItems.map(it => (
|
<it.icon className="text-slate-400" /> {it.label}
|
||||||
<button key={it.view} className="menu-item" onClick={() => select(it.view)}>
|
|
||||||
<it.icon /> {it.label}
|
|
||||||
</button>
|
</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>
|
</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
|
<FaSignOutAlt /> Abmelden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,8 +141,8 @@ export default function TopNav({ user, activeView, setActiveView, onLogout }) {
|
|||||||
function NavBtn({ icon: Icon, label, active, onClick }) {
|
function NavBtn({ icon: Icon, label, active, onClick }) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick}
|
<button onClick={onClick}
|
||||||
className={`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition
|
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-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}`}>
|
${active ? 'bg-brand-50 text-brand-900' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}`}>
|
||||||
<Icon size={14} />{label}
|
<Icon size={14} />{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function VorgangsListe({ refreshFlag, mode }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-xl font-semibold text-slate-900">{item.anzahl}</div>
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Binary file not shown.
@@ -28,7 +28,7 @@ export default function VorgangForm({ onSubmit }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submit = async (typ) => {
|
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('');
|
setBusy(true); setMsg('');
|
||||||
try {
|
try {
|
||||||
const data = { kunde_id: kundeId, artikel_id: artikelId, anzahl: parseInt(anzahl) };
|
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>
|
<label className="label">Kunde</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select className="input flex-1" value={kundeId} onChange={e => setKundeId(e.target.value)}>
|
<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>)}
|
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" disabled={!kundeId} onClick={openInfo}
|
<button type="button" disabled={!kundeId} onClick={openInfo}
|
||||||
@@ -58,7 +58,7 @@ export default function VorgangForm({ onSubmit }) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="label">Artikel</label>
|
<label className="label">Artikel</label>
|
||||||
<select className="input" value={artikelId} onChange={e => setArtikelId(e.target.value)}>
|
<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>)}
|
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +72,7 @@ export default function VorgangForm({ onSubmit }) {
|
|||||||
<input type="file" accept="image/*" id="foto" className="sr-only"
|
<input type="file" accept="image/*" id="foto" className="sr-only"
|
||||||
onChange={e => setFoto(e.target.files?.[0] || null)} />
|
onChange={e => setFoto(e.target.files?.[0] || null)} />
|
||||||
<label htmlFor="foto" className="btn-secondary w-full cursor-pointer">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export default function VorgangForm({ onSubmit }) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
<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('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>
|
</div>
|
||||||
|
|
||||||
{infoOpen && kundenDetail && (
|
{infoOpen && kundenDetail && (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const STATUS = {
|
|||||||
|
|
||||||
const empty = { seriennummer: '', typ: '', bezeichnung: '', kunde_id: '', status: 'aktiv', notiz: '' };
|
const empty = { seriennummer: '', typ: '', bezeichnung: '', kunde_id: '', status: 'aktiv', notiz: '' };
|
||||||
|
|
||||||
export default function Geraete() {
|
export default function Geräte() {
|
||||||
const [list, setList] = useState([]);
|
const [list, setList] = useState([]);
|
||||||
const [kunden, setKunden] = useState([]);
|
const [kunden, setKunden] = useState([]);
|
||||||
const [form, setForm] = useState(empty);
|
const [form, setForm] = useState(empty);
|
||||||
@@ -43,7 +43,7 @@ export default function Geraete() {
|
|||||||
cancel(); setError(''); load();
|
cancel(); setError(''); load();
|
||||||
} catch (e) { setError(e.message); }
|
} 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 typen = Array.from(new Set(list.map(g => g.typ)));
|
||||||
const view = list.filter(g =>
|
const view = list.filter(g =>
|
||||||
@@ -53,19 +53,19 @@ export default function Geraete() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-slate-900">Geraete</h2>
|
<h2 className="text-xl font-semibold text-slate-900">Geräte</h2>
|
||||||
<div className="text-sm text-slate-500">{list.length} Geraete</div>
|
<div className="text-sm text-slate-500">{list.length} Geräte</div>
|
||||||
</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>}
|
{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">
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<Field label="Seriennummer *">
|
<Field label="Seriennummer *">
|
||||||
<input className="input" value={form.seriennummer} onChange={e => setForm(f => ({ ...f, seriennummer: e.target.value }))} />
|
<input className="input" value={form.seriennummer} onChange={e => setForm(f => ({ ...f, seriennummer: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Typ *">
|
<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 }))} />
|
onChange={e => setForm(f => ({ ...f, typ: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Bezeichnung">
|
<Field label="Bezeichnung">
|
||||||
@@ -137,7 +137,7 @@ export default function Geraete() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function KundenVerwaltung() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const del = async (id) => {
|
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); }
|
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">
|
<div className="sm:col-span-2">
|
||||||
<label className="label">Anlieferungshinweis</label>
|
<label className="label">Anlieferungshinweis</label>
|
||||||
<textarea rows={3} className="input"
|
<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}
|
value={form.anlieferungshinweis}
|
||||||
onChange={e => setForm(f => ({ ...f, anlieferungshinweis: e.target.value }))} />
|
onChange={e => setForm(f => ({ ...f, anlieferungshinweis: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@ export default function KundenVerwaltung() {
|
|||||||
<td className="px-4 py-3 text-right whitespace-nowrap">
|
<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={() => 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={() => 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -166,7 +166,7 @@ function BilderModal({ kundeId, onClose }) {
|
|||||||
catch (e) { alert(e.message); } finally { setBusy(false); }
|
catch (e) { alert(e.message); } finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
const del = async (id) => {
|
const del = async (id) => {
|
||||||
if (!confirm('Bild loeschen?')) return;
|
if (!confirm('Bild löschen?')) return;
|
||||||
await api.deleteKundenBild(id); load();
|
await api.deleteKundenBild(id); load();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ function BilderModal({ kundeId, onClose }) {
|
|||||||
|
|
||||||
{(data.bilder?.length || 0) < 5 && (
|
{(data.bilder?.length || 0) < 5 && (
|
||||||
<div className="border-t border-slate-200 pt-4 space-y-3">
|
<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)} />
|
onChange={e => setBeschreibung(e.target.value)} />
|
||||||
<input type="file" accept="image/*" id="bildup" className="sr-only"
|
<input type="file" accept="image/*" id="bildup" className="sr-only"
|
||||||
onChange={e => upload(e.target.files?.[0])} disabled={busy} />
|
onChange={e => upload(e.target.files?.[0])} disabled={busy} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function MitarbeiterVerwaltung({ user }) {
|
|||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const add = async () => {
|
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 {
|
try {
|
||||||
await api.createMitarbeiter(form);
|
await api.createMitarbeiter(form);
|
||||||
setForm({ name: '', email: '', password: '', rolle: 'user' }); setError(''); load();
|
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 updateRolle = async (id, rolle) => { await api.updateMitarbeiter(id, { rolle }); load(); };
|
||||||
const toggleAktiv = async (id, aktiv) => { await api.updateMitarbeiter(id, { aktiv: !aktiv }); 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 (
|
return (
|
||||||
<div className="space-y-4 max-w-3xl mx-auto">
|
<div className="space-y-4 max-w-3xl mx-auto">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function SuperAdmin({ user, onLogout }) {
|
|||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
const setStatus = async (id, status) => { await api.updateTenant(id, { status }); 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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<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">
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
<input type="checkbox" checked={form.seed}
|
<input type="checkbox" checked={form.seed}
|
||||||
onChange={e => setForm(f => ({ ...f, seed: e.target.checked }))} />
|
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>
|
</label>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<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">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">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">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>
|
</dl>
|
||||||
<p className="text-xs text-slate-400">Das Passwort wird nur einmal angezeigt.</p>
|
<p className="text-xs text-slate-400">Das Passwort wird nur einmal angezeigt.</p>
|
||||||
<button onClick={onClose} className="btn-primary w-full">OK</button>
|
<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>
|
<p className="text-sm text-slate-500">{done.size} / {sorted.length} erledigt</p>
|
||||||
</div>
|
</div>
|
||||||
{done.size > 0 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user