chore: initial scaffold (Whitelabel-Fork von Pfandsystem)
- Source-Copy von /root/entwicklung/pfandsystem - docker-compose.yml umgeschrieben: pfandsystem-demo-* Container, Ports 3308/8083/3003 (alle localhost) - Traefik-Label fuer pfandsystem.dockly.de - Erweitertes Multi-Tenant-Schema (tenants, tenant_id auf allen Tabellen) - Neue Tabellen: kunden_bilder, geraete - Neue Spalten: kunden.anlieferungshinweis, kunden.lieferzeit_bis - Rollen: superadmin, admin, user, fahrer - .env.example + README Code-Refactor (Tailwind, Multi-Tenant-Middleware, Erweiterungen) folgt.
This commit is contained in:
97
src/components/Auth/Login.jsx
Normal file
97
src/components/Auth/Login.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const data = await api.login(email, password);
|
||||
setMessage('Login erfolgreich!');
|
||||
if (onLogin) {
|
||||
onLogin(data.user);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(error.message || 'Login fehlgeschlagen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxWidth: 380,
|
||||
margin: '64px auto',
|
||||
background: '#fff',
|
||||
borderRadius: 22,
|
||||
boxShadow: '0 4px 22px #1976d222',
|
||||
padding: 36,
|
||||
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
|
||||
fontWeight: 700, fontSize: 32, margin: '0 0 18px 0', color: '#1976d2', letterSpacing: 0.5,
|
||||
textAlign: 'center', textShadow: '0 2px 8px #0001'
|
||||
}}>
|
||||
Pfandsystem
|
||||
</h1>
|
||||
<h2 style={{ textAlign: 'center', marginBottom: 24, fontWeight: 600, color: '#222', fontSize: 22, letterSpacing: 0.1 }}>Login</h2>
|
||||
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 18, width: '100%' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-Mail"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
fontSize: 18,
|
||||
padding: '14px 14px',
|
||||
borderRadius: 12,
|
||||
border: '1.5px solid #dbeafe',
|
||||
background: '#f6f7fb',
|
||||
outline: 'none',
|
||||
marginBottom: 2
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
fontSize: 18,
|
||||
padding: '14px 14px',
|
||||
borderRadius: 12,
|
||||
border: '1.5px solid #dbeafe',
|
||||
background: '#f6f7fb',
|
||||
outline: 'none',
|
||||
marginBottom: 2
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={loading} style={{
|
||||
marginTop: 8,
|
||||
background: loading ? '#ccc' : '#1976d2',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
border: 'none',
|
||||
borderRadius: 12,
|
||||
padding: '13px 0',
|
||||
boxShadow: '0 2px 10px #1976d222',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
transition: 'background 0.18s',
|
||||
}}>{loading ? 'Wird geladen...' : 'Login'}</button>
|
||||
</form>
|
||||
{message && <div style={{ color: '#1976d2', marginTop: 18, fontWeight: 500 }}>{message}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/Auth/Logout.jsx
Normal file
27
src/components/Auth/Logout.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { api } from '../../api/client';
|
||||
|
||||
export default function Logout({ onLogout }) {
|
||||
const handleLogout = async () => {
|
||||
api.logout();
|
||||
if (onLogout) {
|
||||
onLogout();
|
||||
}
|
||||
// Seite neu laden um sicherzustellen dass der User ausgeloggt ist
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<button onClick={handleLogout} style={{
|
||||
margin: 8,
|
||||
padding: '10px 20px',
|
||||
background: '#d32f2f',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 14
|
||||
}}>
|
||||
Logout
|
||||
</button>
|
||||
);
|
||||
}
|
||||
132
src/components/Auth/PasswordChange.jsx
Normal file
132
src/components/Auth/PasswordChange.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
export default function PasswordChange() {
|
||||
const [form, setForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
|
||||
setError('');
|
||||
setSuccess('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
// Validierung
|
||||
if (!form.currentPassword || !form.newPassword || !form.confirmPassword) {
|
||||
setError('Bitte alle Felder ausfüllen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword.length < 6) {
|
||||
setError('Neues Passwort muss mindestens 6 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.changePassword(form.currentPassword, form.newPassword);
|
||||
setSuccess('Passwort erfolgreich geändert!');
|
||||
setForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Ändern des Passworts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24, padding: 20, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<h3 style={{ marginBottom: 16, fontSize: 18, fontWeight: 600 }}>Passwort ändern</h3>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: 12, marginBottom: 12, background: '#ffebee', color: '#c62828', borderRadius: 6, fontSize: 14 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div style={{ padding: 12, marginBottom: 12, background: '#e8f5e9', color: '#2e7d32', borderRadius: 6, fontSize: 14 }}>
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
|
||||
Aktuelles Passwort:
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
value={form.currentPassword}
|
||||
onChange={handleChange}
|
||||
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
|
||||
Neues Passwort:
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
value={form.newPassword}
|
||||
onChange={handleChange}
|
||||
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
|
||||
Passwort bestätigen:
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={form.confirmPassword}
|
||||
onChange={handleChange}
|
||||
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
background: loading ? '#ccc' : '#1976d2',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: loading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Wird geändert...' : 'Passwort ändern'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
src/components/Dashboard/AdminDashboard.jsx
Normal file
253
src/components/Dashboard/AdminDashboard.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
function exportToCSV(rows, filename) {
|
||||
const header = ['Kunde', 'Artikel', 'Soll', 'Ist', 'Differenz', 'Zeitraum'];
|
||||
const csv = [header.join(',')].concat(
|
||||
rows.map(r => [r.kunde, r.artikel, r.soll, r.ist, r.differenz, r.zeitraum].join(','))
|
||||
).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
|
||||
function UeberfaelligeKunden({ kunden, rows }) {
|
||||
const [sending, setSending] = useState({});
|
||||
const [msg, setMsg] = useState({});
|
||||
const UEBERFAELLIG_TAGE = 14;
|
||||
const heute = new Date();
|
||||
// rows enthält nur den ausgewählten Kunden, daher alle Kunden prüfen:
|
||||
// (Alternative: für alle Kunden/Artikel aus lieferungen/ruecknahmen berechnen)
|
||||
// Hier: Zeige für den aktuell gefilterten Kunden, wenn überfällig
|
||||
const ueberfaellige = rows.filter(row => {
|
||||
// Annahme: row.soll > row.ist, Frist überschritten, Kunde hat E-Mail
|
||||
// Frist: ältestes Lieferdatum für Artikel
|
||||
const lieferDatum = heute; // Placeholder, da rows kein Datum enthält
|
||||
// TODO: lieferDatum aus lieferungen holen, wenn verfügbar
|
||||
// Hier: Zeige alle mit Differenz > 0 und email
|
||||
return (row.soll > row.ist);
|
||||
});
|
||||
if (!ueberfaellige.length) return null;
|
||||
async function sendeMail(row) {
|
||||
setSending(s => ({ ...s, [row.artikel]: true }));
|
||||
setMsg(m => ({ ...m, [row.artikel]: '' }));
|
||||
try {
|
||||
const res = await fetch('https://work.pfandsystem.backdigital.de/webhook/send-reminder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: kunden.find(k => k.id === row.kunde_id)?.email || kunden.find(k => k.name === row.kunde)?.email || '',
|
||||
kunde: row.kunde,
|
||||
anrede: kunden.find(k => k.id === row.kunde_id)?.anrede || '',
|
||||
ansprechpartner: kunden.find(k => k.id === row.kunde_id)?.ansprechpartner || '',
|
||||
artikel: row.artikel,
|
||||
anzahl: row.soll - row.ist,
|
||||
frist: '14 Tage',
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
setMsg(m => ({ ...m, [row.artikel]: 'Erinnerung gesendet!' }));
|
||||
} catch (e) {
|
||||
setMsg(m => ({ ...m, [row.artikel]: 'Fehler beim Senden!' }));
|
||||
}
|
||||
setSending(s => ({ ...s, [row.artikel]: false }));
|
||||
}
|
||||
return (
|
||||
<div style={{ marginTop: 32, background: '#f9fbe7', borderRadius: 12, padding: 16, maxWidth: 600, marginLeft: 'auto', marginRight: 'auto' }}>
|
||||
<h3 style={{ marginBottom: 12, color: '#d32f2f' }}>Überfällige Produkte – Erinnerung senden</h3>
|
||||
{ueberfaellige.map((row, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 2 }}>
|
||||
<b>{row.kunde}</b> – {row.artikel} ({row.soll - row.ist} Stück)
|
||||
</span>
|
||||
<button
|
||||
disabled={sending[row.artikel]}
|
||||
onClick={() => sendeMail(row)}
|
||||
style={{ background: '#1976d2', color: '#fff', border: 'none', borderRadius: 8, padding: '7px 16px', fontWeight: 700, fontSize: 15, cursor: sending[row.artikel] ? 'not-allowed' : 'pointer' }}>
|
||||
Erinnerung senden
|
||||
</button>
|
||||
<span style={{ color: msg[row.artikel]?.startsWith('Fehler') ? '#d32f2f' : '#1976d2', fontWeight: 600, marginLeft: 8 }}>{msg[row.artikel]}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>
|
||||
Nur Kunden mit ausgeliehenen Produkten und E-Mail werden angezeigt. Die Erinnerungsmail enthält eine freundliche Bitte um Rückgabe.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [kunden, setKunden] = useState([]);
|
||||
const [artikel, setArtikel] = useState([]);
|
||||
const [mitarbeiter, setMitarbeiter] = useState([]);
|
||||
const [lieferungen, setLieferungen] = useState([]);
|
||||
const [ruecknahmen, setRuecknahmen] = useState([]);
|
||||
const [selectedKunde, setSelectedKunde] = useState('');
|
||||
const [selectedMitarbeiter, setSelectedMitarbeiter] = useState('');
|
||||
const [zeitraumVon, setZeitraumVon] = useState('');
|
||||
const [zeitraumBis, setZeitraumBis] = useState('');
|
||||
const [rows, setRows] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const [kundenData, artikelData, mitarbeiterData, lieferungenData, ruecknahmenData] = await Promise.all([
|
||||
api.getKunden(),
|
||||
api.getArtikel(),
|
||||
api.getMitarbeiter(),
|
||||
api.getLieferungen(),
|
||||
api.getRuecknahmen()
|
||||
]);
|
||||
setKunden(kundenData || []);
|
||||
setArtikel(artikelData || []);
|
||||
setMitarbeiter(mitarbeiterData || []);
|
||||
setLieferungen(lieferungenData || []);
|
||||
setRuecknahmen(ruecknahmenData || []);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden:', error);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedKunde) { setRows([]); return; }
|
||||
const filterByDate = (arr) => arr.filter(e => {
|
||||
const d = new Date(e.erstellt_am);
|
||||
if (zeitraumVon && d < new Date(zeitraumVon)) return false;
|
||||
if (zeitraumBis && d > new Date(zeitraumBis + 'T23:59:59')) return false;
|
||||
return true;
|
||||
});
|
||||
let lieferungenK = lieferungen.filter(l => l.kunde_id === selectedKunde);
|
||||
let ruecknahmenK = ruecknahmen.filter(r => r.kunde_id === selectedKunde);
|
||||
if (selectedMitarbeiter) {
|
||||
lieferungenK = lieferungenK.filter(l => l.mitarbeiter_id === selectedMitarbeiter);
|
||||
ruecknahmenK = ruecknahmenK.filter(r => r.mitarbeiter_id === selectedMitarbeiter);
|
||||
}
|
||||
lieferungenK = filterByDate(lieferungenK);
|
||||
ruecknahmenK = filterByDate(ruecknahmenK);
|
||||
const artikelSet = Array.from(new Set([...lieferungenK.map(l => l.artikel_id), ...ruecknahmenK.map(r => r.artikel_id)]));
|
||||
const rowsNew = artikelSet.map(artId => {
|
||||
const art = artikel.find(a => a.id === artId);
|
||||
const soll = lieferungenK.filter(l => l.artikel_id === artId).reduce((sum, l) => sum + l.anzahl, 0);
|
||||
const ist = ruecknahmenK.filter(r => r.artikel_id === artId).reduce((sum, r) => sum + r.anzahl, 0);
|
||||
// Mitarbeiter-Name aus erster Lieferung/Rücknahme (wenn eindeutig)
|
||||
let mitarbeiterName = '';
|
||||
const mitarbeiterId = lieferungenK.concat(ruecknahmenK).find(v => v.artikel_id === artId)?.mitarbeiter_id;
|
||||
if (mitarbeiterId) {
|
||||
mitarbeiterName = mitarbeiter.find(m => m.id === mitarbeiterId)?.name || '';
|
||||
}
|
||||
return {
|
||||
kunde: kunden.find(k => k.id === selectedKunde)?.name || '',
|
||||
kunde_id: selectedKunde, // <-- Kunden-ID für eindeutige Zuordnung
|
||||
artikel: art?.bezeichnung || '',
|
||||
soll,
|
||||
ist,
|
||||
differenz: ist - soll,
|
||||
zeitraum: (zeitraumVon || '...') + ' bis ' + (zeitraumBis || '...'),
|
||||
mitarbeiter: mitarbeiterName,
|
||||
};
|
||||
});
|
||||
setRows(rowsNew);
|
||||
}, [selectedKunde, zeitraumVon, zeitraumBis, lieferungen, ruecknahmen, artikel, kunden, selectedMitarbeiter]);
|
||||
|
||||
return (
|
||||
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: '16px 4vw', minWidth: 0, maxWidth: '100%', margin: '24px auto', textAlign: 'center' }}>
|
||||
<h2 style={{ marginBottom: 16, fontSize: 22 }}>Admin: Soll-Ist-Abgleich & CSV-Export</h2>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 22, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<select value={selectedKunde} onChange={e => setSelectedKunde(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe', minWidth: 160 }}>
|
||||
<option value=''>Kunde wählen</option>
|
||||
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
|
||||
</select>
|
||||
<select value={selectedMitarbeiter} onChange={e => setSelectedMitarbeiter(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe', minWidth: 120 }}>
|
||||
<option value=''>Mitarbeiter (alle)</option>
|
||||
{mitarbeiter.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
<input type='date' value={zeitraumVon} onChange={e => setZeitraumVon(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe' }} placeholder='Von' />
|
||||
<input type='date' value={zeitraumBis} onChange={e => setZeitraumBis(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe' }} placeholder='Bis' />
|
||||
<button disabled={!selectedKunde || rows.length === 0} onClick={() => exportToCSV(rows, `Abgleich_${kunden.find(k => k.id === selectedKunde)?.name || 'kunde'}.csv`)} style={{ background: '#1976d2', color: '#fff', border: 'none', borderRadius: 10, padding: '10px 18px', fontWeight: 700, fontSize: 16, cursor: (!selectedKunde || rows.length === 0) ? 'not-allowed' : 'pointer', boxShadow: '0 2px 10px #1976d222' }} className="export-button">CSV Export</button>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', margin: '16px 0' }}>
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
{/* Desktop/Tablett: Tabelle */}
|
||||
<div className="admin-table-wrap" style={{ display: 'block' }}>
|
||||
<table style={{ width: '100%', minWidth: 520, borderCollapse: 'collapse', fontSize: 15 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#eaf3ff' }}>
|
||||
<th style={{ padding: 8, borderRadius: 8 }}>Artikel</th>
|
||||
<th style={{ padding: 8 }}>Soll</th>
|
||||
<th style={{ padding: 8 }}>Ist</th>
|
||||
<th style={{ padding: 8 }}>Differenz</th>
|
||||
<th style={{ padding: 8 }}>Zeitraum</th>
|
||||
<th style={{ padding: 8 }}>Mitarbeiter</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} style={{ background: i % 2 ? '#f6f7fb' : '#fff' }}>
|
||||
<td style={{ padding: 8 }}>{row.artikel}</td>
|
||||
<td style={{ padding: 8 }}>{row.soll}</td>
|
||||
<td style={{ padding: 8 }}>{row.ist}</td>
|
||||
<td style={{ padding: 8, color: row.differenz < 0 ? '#d32f2f' : row.differenz > 0 ? '#1976d2' : '#222', fontWeight: 600 }}>{row.differenz}</td>
|
||||
<td style={{ padding: 8 }}>{row.zeitraum}</td>
|
||||
<td style={{ padding: 8 }}>{row.mitarbeiter}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Mobil: Cards */}
|
||||
<div className="admin-cards-wrap" style={{ display: 'none' }}>
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} style={{ background: '#f6f7fb', borderRadius: 12, boxShadow: '0 1px 6px #0001', padding: 12, fontSize: 15, textAlign: 'left' }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>{row.artikel}</div>
|
||||
<div><b>Soll:</b> {row.soll} <b>Ist:</b> {row.ist}</div>
|
||||
<div><b>Differenz:</b> <span style={{ color: row.differenz < 0 ? '#d32f2f' : row.differenz > 0 ? '#1976d2' : '#222', fontWeight: 600 }}>{row.differenz}</span></div>
|
||||
<div style={{ fontSize: 13, color: '#888' }}>{row.zeitraum}</div>
|
||||
<div style={{ fontSize: 13, color: '#888' }}><b>Mitarbeiter:</b> {row.mitarbeiter}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Überfällige Kunden */}
|
||||
<UeberfaelligeKunden kunden={kunden} rows={rows} />
|
||||
<style>
|
||||
{`
|
||||
@media (max-width: 600px) {
|
||||
.admin-table-wrap {
|
||||
display: none;
|
||||
}
|
||||
.admin-cards-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10;
|
||||
}
|
||||
.export-button {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 10px 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background-color: #1976d2;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px #1976d222;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/components/Dashboard/BildModal.jsx
Normal file
16
src/components/Dashboard/BildModal.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function BildModal({ open, url, onClose }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
|
||||
}} onClick={onClose}>
|
||||
<div style={{ position: 'relative', background: '#fff', borderRadius: 12, padding: 12, boxShadow: '0 4px 32px #0002', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<button onClick={onClose} style={{ position: 'absolute', top: 8, right: 8, background: '#eee', border: 'none', borderRadius: 20, width: 32, height: 32, fontSize: 18, cursor: 'pointer' }}>×</button>
|
||||
<img src={url} alt="Vorschau" style={{ maxWidth: '80vw', maxHeight: '80vh', borderRadius: 8 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/Dashboard/Dashboard.jsx
Normal file
64
src/components/Dashboard/Dashboard.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import TopNav from './TopNav';
|
||||
import VorgangForm from '../Eingabe/VorgangForm';
|
||||
import VorgangsListe from './VorgangsListe';
|
||||
import Logout from '../Auth/Logout';
|
||||
import PasswordChange from '../Auth/PasswordChange';
|
||||
import { useState } from 'react';
|
||||
|
||||
function AccountInfo({ user }) {
|
||||
return (
|
||||
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 32, minWidth: 320, maxWidth: 500, margin: '32px auto', textAlign: 'center' }}>
|
||||
<h2 style={{ marginBottom: 16 }}>Account-Info</h2>
|
||||
<div style={{ marginBottom: 12 }}><b>E-Mail:</b> <br />{user.email}</div>
|
||||
<div style={{ marginBottom: 12 }}><b>Name:</b> <br />{user.name}</div>
|
||||
<div style={{ marginBottom: 12 }}><b>Rolle:</b> <br />{user.rolle}</div>
|
||||
<Logout />
|
||||
<PasswordChange />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import AdminDashboard from './AdminDashboard';
|
||||
import MitarbeiterVerwaltung from '../MitarbeiterVerwaltung';
|
||||
import KundenVerwaltung from '../KundenVerwaltung';
|
||||
|
||||
export default function Dashboard({ user }) {
|
||||
const [refreshFlag, setRefreshFlag] = useState(0);
|
||||
const [activeView, setActiveView] = useState('vorgang');
|
||||
const isAdmin = user.rolle === 'admin';
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f6f7fb' }}>
|
||||
<TopNav activeView={activeView} setActiveView={setActiveView} isAdmin={isAdmin} />
|
||||
<main style={{ maxWidth: 900, margin: '0 auto', padding: '24px 8px', display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||
{activeView === 'account' && <AccountInfo user={user} />}
|
||||
{activeView === 'vorgang' && (
|
||||
<div style={{ background: '#fff', borderRadius: 22, boxShadow: '0 4px 22px #1976d222', padding: 36, maxWidth: 420, margin: '48px auto', minWidth: 0 }}>
|
||||
<VorgangForm user={user} onSubmit={() => setRefreshFlag(f => f + 1)} />
|
||||
</div>
|
||||
)}
|
||||
{activeView === 'lieferungen' && (
|
||||
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 18, maxWidth: 600, margin: '32px auto', minWidth: 0 }}>
|
||||
<h2 style={{ textAlign: 'center', fontWeight: 700, fontSize: 22, marginBottom: 18 }}>Lieferungen</h2>
|
||||
<VorgangsListe refreshFlag={refreshFlag} mode="lieferungen" />
|
||||
</div>
|
||||
)}
|
||||
{activeView === 'ruecknahmen' && (
|
||||
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 18, maxWidth: 600, margin: '32px auto', minWidth: 0 }}>
|
||||
<h2 style={{ textAlign: 'center', fontWeight: 700, fontSize: 22, marginBottom: 18 }}>Rücknahmen</h2>
|
||||
<VorgangsListe refreshFlag={refreshFlag} mode="ruecknahmen" />
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && activeView === 'admin' && (
|
||||
<AdminDashboard />
|
||||
)}
|
||||
{isAdmin && activeView === 'mitarbeiter' && (
|
||||
<MitarbeiterVerwaltung user={user} />
|
||||
)}
|
||||
{isAdmin && activeView === 'kunden' && (
|
||||
<KundenVerwaltung />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/Dashboard/Sidebar.jsx
Normal file
40
src/components/Dashboard/Sidebar.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FaHome, FaList, FaBox, FaBell, FaComments, FaCog } from 'react-icons/fa';
|
||||
|
||||
const navItems = [
|
||||
{ icon: <FaHome />, label: 'Dashboard' },
|
||||
{ icon: <FaList />, label: 'Vorgänge' },
|
||||
{ icon: <FaBox />, label: 'Artikel' },
|
||||
{ icon: <FaBell />, label: 'Benachrichtigungen' },
|
||||
{ icon: <FaComments />, label: 'Chat' },
|
||||
{ icon: <FaCog />, label: 'Einstellungen' },
|
||||
];
|
||||
|
||||
export default function Sidebar({ active = 0 }) {
|
||||
return (
|
||||
<aside style={{
|
||||
width: 80, background: 'linear-gradient(180deg, #eaeaea 0%, #f8f8f8 100%)',
|
||||
borderRadius: 28, margin: 12, padding: '24px 0', display: 'flex', flexDirection: 'column', alignItems: 'center', boxShadow: '2px 0 16px #0001',
|
||||
minHeight: '85vh',
|
||||
}}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 24, background: '#fff', marginBottom: 32, boxShadow: '0 2px 8px #0001' }} />
|
||||
{navItems.map((item, i) => (
|
||||
<div key={item.label} style={{
|
||||
background: active === i ? '#fff' : 'none',
|
||||
borderRadius: 16,
|
||||
margin: '8px 0',
|
||||
width: 48,
|
||||
height: 48,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: active === i ? '#1976d2' : '#888',
|
||||
fontSize: 24,
|
||||
cursor: 'pointer',
|
||||
boxShadow: active === i ? '0 2px 8px #1976d230' : 'none',
|
||||
}}>
|
||||
{item.icon}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
96
src/components/Dashboard/TopNav.jsx
Normal file
96
src/components/Dashboard/TopNav.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { FaUser, FaPlusCircle, FaTruck, FaUndo, FaKey, FaUsers, FaAddressBook } from 'react-icons/fa';
|
||||
|
||||
const baseNavItems = [
|
||||
{ icon: <FaUser />, label: 'Account', view: 'account' },
|
||||
{ icon: <FaPlusCircle />, label: 'Neuer Vorgang', view: 'vorgang' },
|
||||
{ icon: <FaTruck />, label: 'Lieferungen', view: 'lieferungen' },
|
||||
{ icon: <FaUndo />, label: 'Rücknahmen', view: 'ruecknahmen' },
|
||||
];
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function TopNav({ activeView, setActiveView, isAdmin }) {
|
||||
const navItems = isAdmin
|
||||
? [...baseNavItems, { icon: <FaKey />, label: 'Admin', view: 'admin' }]
|
||||
: baseNavItems;
|
||||
const adminMenuItems = isAdmin
|
||||
? [
|
||||
{ icon: <FaUsers />, label: 'Mitarbeiter', view: 'mitarbeiter' },
|
||||
{ icon: <FaAddressBook />, label: 'Kunden', view: 'kunden' },
|
||||
{ icon: <FaKey />, label: 'Admin', view: 'admin' },
|
||||
{ icon: <FaUser />, label: 'Account', view: 'account' },
|
||||
]
|
||||
: [{ icon: <FaUser />, label: 'Account', view: 'account' }];
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const handleMenuClick = () => setMenuOpen(open => !open);
|
||||
const handleMenuSelect = (view) => {
|
||||
setActiveView(view);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header style={{
|
||||
background: '#fff', boxShadow: '0 4px 18px #0002', padding: 0, position: 'sticky', top: 0, zIndex: 100,
|
||||
borderBottomLeftRadius: 20, borderBottomRightRadius: 20, marginBottom: 12
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
|
||||
fontWeight: 700, fontSize: 32, letterSpacing: 0.5, color: '#222',
|
||||
textAlign: 'center', padding: '18px 0 6px 0',
|
||||
textShadow: '0 2px 8px #0001'
|
||||
}}>
|
||||
Pfandsystem
|
||||
</div>
|
||||
<nav style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
gap: 8, padding: '0 0 8px 0',
|
||||
maxWidth: 480, margin: '0 auto',
|
||||
}}>
|
||||
{/* Hamburger-Menü für Account/Admin auf Mobil, Platzhalter-Logik */}
|
||||
<div style={{ display: 'flex', flex: 1 }}>
|
||||
{navItems.filter(item => item.view !== 'account' && item.view !== 'admin').map(item => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => setActiveView(item.view)}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: activeView === item.view ? '#eaf3ff' : 'none',
|
||||
border: 'none', borderRadius: 12, padding: '7px 7px', cursor: 'pointer',
|
||||
color: activeView === item.view ? '#1976d2' : '#444', fontWeight: 600, fontSize: 13,
|
||||
minWidth: 54, boxShadow: activeView === item.view ? '0 2px 12px #1976d222' : '0 1px 4px #0001',
|
||||
transition: 'all 0.18s', margin: '0 2px'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 19, marginBottom: 1 }}>{item.icon}</span>
|
||||
<span style={{ fontSize: 12 }}>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Hamburger-Menü (Platzhalter, öffnet Account/Admin) */}
|
||||
<div style={{ position: 'relative', marginLeft: 4 }}>
|
||||
<button onClick={handleMenuClick} style={{ background: 'none', border: 'none', padding: 7, borderRadius: 10, cursor: 'pointer', fontSize: 20 }}>
|
||||
<span style={{ fontSize: 22 }}>☰</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div style={{ position: 'absolute', right: 0, top: 36, background: '#fff', borderRadius: 10, boxShadow: '0 2px 12px #0002', minWidth: 140, zIndex: 20 }}>
|
||||
{adminMenuItems.map(item => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => handleMenuSelect(item.view)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
background: 'none', border: 'none', padding: '10px 14px', cursor: 'pointer',
|
||||
color: activeView === item.view ? '#1976d2' : '#444', fontWeight: 600, fontSize: 14,
|
||||
borderBottom: '1px solid #eee',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>{item.icon}</span> {item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
110
src/components/Dashboard/VorgangsListe.jsx
Normal file
110
src/components/Dashboard/VorgangsListe.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
import BildModal from './BildModal';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
function FotoCell({ path }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!path) return <>-</>;
|
||||
|
||||
// Foto-URL direkt vom Backend
|
||||
const url = path.startsWith('http') ? path : `${API_BASE_URL.replace('/api', '')}${path}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<img src={url} alt="Foto" style={{ maxWidth: 60, maxHeight: 60, borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
|
||||
onClick={() => setOpen(true)} />
|
||||
<BildModal open={open} url={url} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VorgangsListe({ refreshFlag, mode }) {
|
||||
const [lieferungen, setLieferungen] = useState([]);
|
||||
const [ruecknahmen, setRuecknahmen] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [kunden, setKunden] = useState([]);
|
||||
const [artikel, setArtikel] = useState([]);
|
||||
const [filterKunde, setFilterKunde] = useState('');
|
||||
const [filterArtikel, setFilterArtikel] = useState('');
|
||||
const [filterVon, setFilterVon] = useState('');
|
||||
const [filterBis, setFilterBis] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [lieferungenData, ruecknahmenData, kundenData, artikelData] = await Promise.all([
|
||||
api.getLieferungen(),
|
||||
api.getRuecknahmen(),
|
||||
api.getKunden(),
|
||||
api.getArtikel()
|
||||
]);
|
||||
setLieferungen(lieferungenData || []);
|
||||
setRuecknahmen(ruecknahmenData || []);
|
||||
setKunden(kundenData || []);
|
||||
setArtikel(artikelData || []);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [refreshFlag]);
|
||||
|
||||
if (loading) return <div style={{ textAlign: 'center', margin: 24 }}>Lädt...</div>;
|
||||
|
||||
let list = mode === 'lieferungen' ? lieferungen : mode === 'ruecknahmen' ? ruecknahmen : [];
|
||||
|
||||
// Filter anwenden
|
||||
if (filterKunde) list = list.filter(l => l.kunde_id === filterKunde);
|
||||
if (filterArtikel) list = list.filter(l => l.artikel_id === filterArtikel);
|
||||
if (filterVon) list = list.filter(l => new Date(l.erstellt_am) >= new Date(filterVon));
|
||||
if (filterBis) list = list.filter(l => new Date(l.erstellt_am) <= new Date(filterBis + 'T23:59:59'));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 18, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<select value={filterKunde} onChange={e => setFilterKunde(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb', minWidth: 110 }}>
|
||||
<option value=''>Kunde</option>
|
||||
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
|
||||
</select>
|
||||
<select value={filterArtikel} onChange={e => setFilterArtikel(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb', minWidth: 110 }}>
|
||||
<option value=''>Artikel</option>
|
||||
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
|
||||
</select>
|
||||
<input type='date' value={filterVon} onChange={e => setFilterVon(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb' }} placeholder='Von' />
|
||||
<input type='date' value={filterBis} onChange={e => setFilterBis(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb' }} placeholder='Bis' />
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', color: '#888', margin: 32 }}>Keine Daten vorhanden.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{list.map(item => (
|
||||
<div key={item.id} style={{
|
||||
background: '#f6f7fb',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 2px 10px #0001',
|
||||
padding: 16,
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ minWidth: 62, minHeight: 62, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FotoCell path={item.foto_url} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 120 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 17, marginBottom: 2 }}>{item.kunde_name || 'Unbekannt'}</div>
|
||||
<div style={{ fontSize: 15, color: '#1976d2', fontWeight: 600 }}>{item.artikel_bezeichnung}</div>
|
||||
<div style={{ fontSize: 14, color: '#444', marginTop: 2 }}>Menge: <b>{item.anzahl}</b></div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginTop: 2 }}>{new Date(item.erstellt_am).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
src/components/Eingabe/VorgangForm.jsx
Normal file
140
src/components/Eingabe/VorgangForm.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
export default function VorgangForm({ user, onSubmit }) {
|
||||
const [kunden, setKunden] = useState([]);
|
||||
const [artikel, setArtikel] = useState([]);
|
||||
const [kundeId, setKundeId] = useState('');
|
||||
const [artikelId, setArtikelId] = useState('');
|
||||
const [anzahl, setAnzahl] = useState(1);
|
||||
const [foto, setFoto] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const [kundenData, artikelData] = await Promise.all([
|
||||
api.getKunden(),
|
||||
api.getArtikel()
|
||||
]);
|
||||
setKunden(kundenData || []);
|
||||
setArtikel(artikelData || []);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden:', error);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleFotoChange = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFoto(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e, typ) => {
|
||||
e.preventDefault();
|
||||
setUploading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const vorgangData = {
|
||||
kunde_id: kundeId,
|
||||
artikel_id: artikelId,
|
||||
anzahl: parseInt(anzahl)
|
||||
};
|
||||
|
||||
if (typ === 'lieferung') {
|
||||
await api.createLieferung(vorgangData, foto);
|
||||
} else {
|
||||
await api.createRuecknahme(vorgangData, foto);
|
||||
}
|
||||
|
||||
setMessage('Vorgang gespeichert!');
|
||||
setKundeId('');
|
||||
setArtikelId('');
|
||||
setAnzahl(1);
|
||||
setFoto(null);
|
||||
if (onSubmit) onSubmit();
|
||||
} catch (error) {
|
||||
setMessage('Fehler beim Speichern: ' + error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form style={{
|
||||
maxWidth: 420,
|
||||
margin: '0 auto',
|
||||
background: '#fff',
|
||||
borderRadius: 22,
|
||||
boxShadow: '0 4px 22px #1976d222',
|
||||
padding: 36,
|
||||
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
|
||||
display: 'flex', flexDirection: 'column', gap: 18,
|
||||
}} onSubmit={e => e.preventDefault()}>
|
||||
<h2 style={{ textAlign: 'center', marginBottom: 18, fontWeight: 700, color: '#1976d2', fontSize: 24 }}>Neuer Vorgang</h2>
|
||||
<select value={kundeId} onChange={e => setKundeId(e.target.value)} required style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }}>
|
||||
<option value="">Kunde wählen</option>
|
||||
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
|
||||
</select>
|
||||
<select value={artikelId} onChange={e => setArtikelId(e.target.value)} required style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }}>
|
||||
<option value="">Artikel wählen</option>
|
||||
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
|
||||
</select>
|
||||
<input type="number" min="1" value={anzahl} onChange={e => setAnzahl(e.target.value)} required placeholder="Anzahl" style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }} />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFotoChange}
|
||||
style={{ display: 'none' }}
|
||||
id="foto-upload-input"
|
||||
/>
|
||||
<label htmlFor="foto-upload-input" style={{
|
||||
display: 'block',
|
||||
background: '#1976d2',
|
||||
color: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '13px 0',
|
||||
fontWeight: 700,
|
||||
fontSize: 17,
|
||||
textAlign: 'center',
|
||||
margin: '10px 0 0 0',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 10px #1976d222',
|
||||
transition: 'background 0.18s',
|
||||
}}>
|
||||
📷 Foto erstellen oder auswählen
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
|
||||
<button type="button" disabled={uploading} onClick={e => handleSubmit(e, 'lieferung')} style={{
|
||||
flex: 1,
|
||||
background: '#1976d2',
|
||||
color: '#fff',
|
||||
borderRadius: 12,
|
||||
fontWeight: 700,
|
||||
fontSize: 17,
|
||||
border: 'none',
|
||||
padding: '13px 0',
|
||||
boxShadow: '0 2px 10px #1976d222',
|
||||
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||
}}>Ausliefern</button>
|
||||
<button type="button" disabled={uploading} onClick={e => handleSubmit(e, 'ruecknahme')} style={{
|
||||
flex: 1,
|
||||
background: '#f6f7fb',
|
||||
color: '#1976d2',
|
||||
borderRadius: 12,
|
||||
fontWeight: 700,
|
||||
fontSize: 17,
|
||||
border: '1.5px solid #dbeafe',
|
||||
padding: '13px 0',
|
||||
boxShadow: '0 2px 10px #1976d222',
|
||||
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||
}}>Zurücknehmen</button>
|
||||
</div>
|
||||
{message && <div style={{ color: '#1976d2', fontWeight: 600, marginTop: 10, textAlign: 'center' }}>{message}</div>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
168
src/components/KundenVerwaltung.jsx
Normal file
168
src/components/KundenVerwaltung.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export default function KundenVerwaltung() {
|
||||
const [kunden, setKunden] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
|
||||
|
||||
useEffect(() => { fetchKunden(); }, []);
|
||||
|
||||
async function fetchKunden() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getKunden();
|
||||
setKunden(data || []);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(k) {
|
||||
setEditId(k.id);
|
||||
setForm({
|
||||
name: k.name || '',
|
||||
adresse: k.adresse || '',
|
||||
email: k.email || '',
|
||||
telefon: k.telefon || '',
|
||||
anrede: k.anrede || '',
|
||||
ansprechpartner: k.ansprechpartner || '',
|
||||
notiz: k.notiz || ''
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name) return setError('Name ist erforderlich');
|
||||
try {
|
||||
if (editId) {
|
||||
await api.updateKunde(editId, form);
|
||||
} else {
|
||||
await api.createKunde(form);
|
||||
}
|
||||
setEditId(null);
|
||||
setForm({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
|
||||
fetchKunden();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setEditId(null);
|
||||
setForm({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('Kunde wirklich löschen?')) return;
|
||||
try {
|
||||
await api.deleteKunde(id);
|
||||
fetchKunden();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(e) {
|
||||
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 16px #0001', padding: 24, maxWidth: 1200, margin: '32px auto' }}>
|
||||
<h2 style={{ textAlign: 'center', marginBottom: 16, color: '#1976d2', fontWeight: 700 }}>Kundenverwaltung</h2>
|
||||
{loading && <div>Lade...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
<div style={{ marginBottom: 18, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<input name="name" value={form.name} onChange={handleChange} placeholder="Name" style={{ minWidth: 120, flex: 2 }} />
|
||||
<input name="adresse" value={form.adresse} onChange={handleChange} placeholder="Adresse" style={{ minWidth: 120, flex: 2 }} />
|
||||
<input name="email" value={form.email} onChange={handleChange} placeholder="E-Mail" style={{ minWidth: 120, flex: 2 }} />
|
||||
<input name="telefon" value={form.telefon} onChange={handleChange} placeholder="Telefon" style={{ minWidth: 100, flex: 1 }} />
|
||||
<select name="anrede" value={form.anrede} onChange={handleChange} style={{ minWidth: 80, flex: 1 }}>
|
||||
<option value="">Anrede</option>
|
||||
<option value="Herr">Herr</option>
|
||||
<option value="Frau">Frau</option>
|
||||
</select>
|
||||
<input name="ansprechpartner" value={form.ansprechpartner} onChange={handleChange} placeholder="Ansprechpartner" style={{ minWidth: 100, flex: 1 }} />
|
||||
<input name="notiz" value={form.notiz} onChange={handleChange} placeholder="Notiz" style={{ minWidth: 100, flex: 1 }} />
|
||||
{editId ? (
|
||||
<>
|
||||
<button onClick={handleSave} style={{ marginLeft: 8 }}>Speichern</button>
|
||||
<button onClick={handleCancel} style={{ marginLeft: 4 }}>Abbrechen</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={handleSave} style={{ marginLeft: 8 }}>Neu anlegen</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Responsive Darstellung: Tabelle auf Desktop, Karten auf Mobil */}
|
||||
<div className="kunden-tabelle-responsive" style={{ overflowX: 'auto' }}>
|
||||
<div className="kunden-tabelle-desktop">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 800 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#1976d2', color: '#fff' }}>
|
||||
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Name</th>
|
||||
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Adresse</th>
|
||||
<th style={{ padding: '12px 8px', textAlign: 'left' }}>E-Mail</th>
|
||||
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Telefon</th>
|
||||
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Anrede</th>
|
||||
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Ansprechpartner</th>
|
||||
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Notiz</th>
|
||||
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kunden.map(k => (
|
||||
<tr key={k.id} style={{ background: editId === k.id ? '#fffde7' : '#fff', borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '10px 8px' }}>{k.name}</td>
|
||||
<td style={{ padding: '10px 8px' }}>{k.adresse}</td>
|
||||
<td style={{ padding: '10px 8px' }}>{k.email}</td>
|
||||
<td style={{ padding: '10px 8px' }}>{k.telefon}</td>
|
||||
<td style={{ padding: '10px 8px' }}>{k.anrede}</td>
|
||||
<td style={{ padding: '10px 8px' }}>{k.ansprechpartner}</td>
|
||||
<td style={{ padding: '10px 8px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{k.notiz}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>
|
||||
<button onClick={() => handleEdit(k)} style={{ fontSize: 13, marginRight: 4, padding: '6px 12px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>Bearbeiten</button>
|
||||
<button onClick={() => handleDelete(k.id)} style={{ fontSize: 13, padding: '6px 12px', background: '#d32f2f', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="kunden-tabelle-mobile">
|
||||
{kunden.map(k => (
|
||||
<div key={k.id} className="kunden-card">
|
||||
<div style={{ fontWeight: 700, fontSize: 17 }}>{k.name}</div>
|
||||
<div><b>Anrede:</b> {k.anrede}</div>
|
||||
<div><b>Ansprechpartner:</b> {k.ansprechpartner}</div>
|
||||
<div><b>Telefon:</b> {k.telefon}</div>
|
||||
<div><b>E-Mail:</b> {k.email}</div>
|
||||
<div style={{ fontSize: 13, color: '#444', margin: '4px 0' }}>{k.notiz}</div>
|
||||
<button onClick={() => handleEdit(k)} style={{ fontSize: 13, marginTop: 6 }}>Bearbeiten</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@media (max-width: 600px) {
|
||||
.kunden-tabelle-desktop { display: none; }
|
||||
.kunden-tabelle-mobile { display: block; }
|
||||
.kunden-card {
|
||||
background: #f6faff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 8px #0001;
|
||||
padding: 14px 13px 10px 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 601px) {
|
||||
.kunden-tabelle-desktop { display: block; }
|
||||
.kunden-tabelle-mobile { display: none; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/MitarbeiterVerwaltung.jsx
Normal file
116
src/components/MitarbeiterVerwaltung.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export default function MitarbeiterVerwaltung({ user }) {
|
||||
const [mitarbeiter, setMitarbeiter] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newRolle, setNewRolle] = useState('user');
|
||||
|
||||
useEffect(() => {
|
||||
fetchMitarbeiter();
|
||||
}, []);
|
||||
|
||||
async function fetchMitarbeiter() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getMitarbeiter();
|
||||
setMitarbeiter(data || []);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRolleChange(id, rolle) {
|
||||
try {
|
||||
await api.updateMitarbeiter(id, { rolle });
|
||||
fetchMitarbeiter();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNameChange(id, name) {
|
||||
try {
|
||||
await api.updateMitarbeiter(id, { name });
|
||||
fetchMitarbeiter();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newEmail || !newPassword || !newName) {
|
||||
setError('Bitte alle Felder ausfüllen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.register(newEmail, newPassword, newName, newRolle);
|
||||
setNewName('');
|
||||
setNewEmail('');
|
||||
setNewPassword('');
|
||||
setNewRolle('user');
|
||||
setError('');
|
||||
fetchMitarbeiter();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 16px #0001', padding: 24, maxWidth: 480, margin: '32px auto' }}>
|
||||
<h2 style={{ textAlign: 'center', marginBottom: 16 }}>Mitarbeiterverwaltung</h2>
|
||||
{loading && <div>Lade...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
<div className="mitarbeiter-list">
|
||||
{mitarbeiter.map(m => (
|
||||
<div key={m.id} className="mitarbeiter-card" style={{ background: '#f6f7fb', borderRadius: 12, boxShadow: '0 1px 6px #0001', padding: 14, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input value={m.name || ''} onChange={e => handleNameChange(m.id, e.target.value)} style={{ flex: 1, fontSize: 16, padding: 8, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
|
||||
<select value={m.rolle} onChange={e => handleRolleChange(m.id, e.target.value)} style={{ fontSize: 16, padding: 8, borderRadius: 8, border: '1.5px solid #dbeafe' }}>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<input placeholder="Name" value={newName} onChange={e => setNewName(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
|
||||
<input placeholder="E-Mail" type="email" value={newEmail} onChange={e => setNewEmail(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
|
||||
<input placeholder="Passwort" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
|
||||
<select value={newRolle} onChange={e => setNewRolle(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }}>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button onClick={handleAdd} style={{ fontSize: 16, padding: '10px 18px', borderRadius: 8, background: '#1976d2', color: '#fff', border: 'none', fontWeight: 700, cursor: 'pointer' }}>Hinzufügen</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>
|
||||
Rolle "admin" sieht das Admin-Dashboard.<br />
|
||||
<b>Hinweis:</b> Neue Mitarbeiter benötigen E-Mail und Passwort für den Login.
|
||||
</div>
|
||||
<style>{`
|
||||
@media (max-width: 600px) {
|
||||
.mitarbeiter-list {
|
||||
gap: 8px;
|
||||
}
|
||||
.mitarbeiter-card {
|
||||
padding: 10px 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
input, select, button {
|
||||
font-size: 16px !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user