587 lines
23 KiB
JavaScript
587 lines
23 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import UserList from '../users/UserList';
|
||
import ExportButton from './ExportButton';
|
||
import Trash from './Trash';
|
||
import AuditLogs from './AuditLogs';
|
||
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto, generateInviteToken } from '../../services/users';
|
||
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
|
||
import ErrorMessage from '../common/ErrorMessage';
|
||
import './AdminPanel.css';
|
||
|
||
const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||
const [notification, setNotification] = useState(null);
|
||
const [activeTab, setActiveTab] = useState('users');
|
||
const [config, setConfig] = useState(null);
|
||
const [configOriginal, setConfigOriginal] = useState(null);
|
||
const [configLoading, setConfigLoading] = useState(false);
|
||
const [configError, setConfigError] = useState(false);
|
||
const [logoUploading, setLogoUploading] = useState(false);
|
||
const [collapsed, setCollapsed] = useState({ logo: false, sections: false, rules: false, userTypes: false });
|
||
const logoInputRef = useRef(null);
|
||
const importInputRef = useRef(null);
|
||
|
||
const hasUnsavedChanges = config && configOriginal &&
|
||
JSON.stringify(config) !== JSON.stringify(configOriginal);
|
||
|
||
const loadConfig = useCallback(async () => {
|
||
setConfigLoading(true);
|
||
setConfigError(false);
|
||
try {
|
||
const response = await getFullConfig();
|
||
setConfig(response.data);
|
||
setConfigOriginal(response.data);
|
||
} catch (err) {
|
||
console.error('Config load error:', err);
|
||
setConfigError(true);
|
||
} finally {
|
||
setConfigLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === 'settings' && !config) {
|
||
loadConfig();
|
||
}
|
||
}, [activeTab, config, loadConfig]);
|
||
|
||
const showNotification = (type, message) => {
|
||
setNotification({ message, type });
|
||
setTimeout(() => setNotification(null), 3000);
|
||
};
|
||
|
||
const handleAvailabilityToggle = async (id, available) => {
|
||
const result = await updateAvailability(id, available);
|
||
if (result.success) {
|
||
showNotification('success', `Verfügbarkeit erfolgreich aktualisiert`);
|
||
onRefetch();
|
||
} else {
|
||
showNotification('error', result.message || 'Fehler beim Aktualisieren');
|
||
}
|
||
};
|
||
|
||
const handleGPSUpdate = async (id, lat, lng) => {
|
||
const result = await updateGPS(id, lat, lng);
|
||
if (result.success) {
|
||
showNotification('success', `GPS-Koordinaten erfolgreich aktualisiert`);
|
||
onRefetch();
|
||
} else {
|
||
showNotification('error', result.message || 'Fehler beim Aktualisieren');
|
||
}
|
||
};
|
||
|
||
const handleUserCreate = async (userData) => {
|
||
const result = await createUser(userData);
|
||
if (result.success) {
|
||
if (result.warning) {
|
||
showNotification('warning', `Erstellt – GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
|
||
} else {
|
||
showNotification('success', 'Stöberhundeführer erfolgreich erstellt (GPS gesetzt)');
|
||
}
|
||
onRefetch();
|
||
} else {
|
||
showNotification('error', result.message || 'Fehler beim Erstellen');
|
||
}
|
||
};
|
||
|
||
const handleUserUpdate = async (id, userData) => {
|
||
const result = await updateUser(id, userData);
|
||
if (result.success) {
|
||
if (result.warning) {
|
||
showNotification('warning', `Gespeichert – GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
|
||
} else {
|
||
showNotification('success', 'Stöberhundeführer erfolgreich aktualisiert');
|
||
}
|
||
onRefetch();
|
||
} else {
|
||
showNotification('error', result.message || 'Fehler beim Aktualisieren');
|
||
}
|
||
};
|
||
|
||
const handleUserDelete = async (id) => {
|
||
const result = await deleteUser(id);
|
||
if (result.success) {
|
||
showNotification('success', 'Stöberhundeführer erfolgreich gelöscht');
|
||
onRefetch();
|
||
} else {
|
||
showNotification('error', result.message || 'Fehler beim Löschen');
|
||
}
|
||
};
|
||
|
||
const handleUserPhotoUpload = async (id, dataUrl) => {
|
||
const result = await uploadUserPhoto(id, dataUrl);
|
||
if (result.success) {
|
||
showNotification('success', 'Foto erfolgreich hochgeladen');
|
||
onRefetch();
|
||
} else {
|
||
showNotification('error', result.message || 'Fehler beim Foto-Upload');
|
||
}
|
||
};
|
||
|
||
const handleUserPhotoDelete = async (id) => {
|
||
const result = await deleteUserPhoto(id);
|
||
if (result.success) {
|
||
showNotification('success', 'Foto entfernt');
|
||
onRefetch();
|
||
} else {
|
||
showNotification('error', result.message || 'Fehler beim Löschen des Fotos');
|
||
}
|
||
};
|
||
|
||
const handleGenerateInvite = async (id) => {
|
||
const result = await generateInviteToken(id);
|
||
if (result.success) {
|
||
showNotification('success', 'Einladungs-Token erzeugt (7 Tage gueltig)');
|
||
} else {
|
||
showNotification('error', result.message || 'Fehler beim Erzeugen des Einladungs-Tokens');
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const handleConfigUpdate = async (updatedConfig) => {
|
||
try {
|
||
const response = await updateConfig(updatedConfig);
|
||
setConfig(response.data);
|
||
setConfigOriginal(response.data);
|
||
showNotification('success', 'Konfiguration erfolgreich gespeichert');
|
||
} catch (err) {
|
||
showNotification('error', err.message || 'Fehler beim Aktualisieren der Konfiguration');
|
||
}
|
||
};
|
||
|
||
const handleConfigExport = () => {
|
||
const exportData = {
|
||
exportedAt: new Date().toISOString(),
|
||
userTypes: config.userTypes.map(({ code, label }) => ({ code, label })),
|
||
rules: config.rules,
|
||
sections: config.sections ? config.sections.map(({ key, title, content }) => ({ key, title, content })) : []
|
||
};
|
||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `stoeberhunde-einstellungen-${new Date().toISOString().slice(0, 10)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
showNotification('success', 'Einstellungen exportiert');
|
||
};
|
||
|
||
const handleConfigImport = (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
try {
|
||
const imported = JSON.parse(ev.target.result);
|
||
if (!imported.userTypes || !imported.rules) {
|
||
showNotification('error', 'Ungültiges Export-Format');
|
||
return;
|
||
}
|
||
setConfig(prev => ({
|
||
...prev,
|
||
userTypes: imported.userTypes,
|
||
rules: imported.rules,
|
||
sections: imported.sections || prev.sections || []
|
||
}));
|
||
showNotification('success', 'Einstellungen importiert – bitte prüfen und speichern');
|
||
} catch {
|
||
showNotification('error', 'Fehler beim Lesen der Datei');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value = '';
|
||
};
|
||
|
||
const toggleSection = (key) => setCollapsed(prev => ({ ...prev, [key]: !prev[key] }));
|
||
|
||
const handleLogoUpload = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
if (!file.type.startsWith('image/')) {
|
||
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
|
||
return;
|
||
}
|
||
if (file.size > 500 * 1024) {
|
||
showNotification('error', 'Logo darf max. 500 KB groß sein');
|
||
return;
|
||
}
|
||
setLogoUploading(true);
|
||
const reader = new FileReader();
|
||
reader.onload = async (ev) => {
|
||
try {
|
||
await uploadLogo(ev.target.result);
|
||
showNotification('success', 'Logo erfolgreich hochgeladen');
|
||
// Reload config to get new logo
|
||
const response = await getFullConfig();
|
||
setConfig(response.data);
|
||
setConfigOriginal(response.data);
|
||
window.location.reload(); // refresh ConfigContext logo
|
||
} catch (err) {
|
||
const msg = err?.response?.data?.message || err?.message || 'Fehler beim Logo-Upload';
|
||
showNotification('error', msg);
|
||
} finally {
|
||
setLogoUploading(false);
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const handleLogoDelete = async () => {
|
||
if (!window.confirm('Logo wirklich entfernen?')) return;
|
||
try {
|
||
await deleteLogo();
|
||
showNotification('success', 'Logo entfernt');
|
||
window.location.reload();
|
||
} catch (err) {
|
||
showNotification('error', 'Fehler beim Entfernen des Logos');
|
||
}
|
||
};
|
||
|
||
const updateSection = (key, content) => {
|
||
const sections = config.sections ? [...config.sections] : [];
|
||
const idx = sections.findIndex(s => s.key === key);
|
||
if (idx >= 0) {
|
||
sections[idx] = { ...sections[idx], content };
|
||
} else {
|
||
sections.push({ key, title: key, content });
|
||
}
|
||
setConfig({ ...config, sections });
|
||
};
|
||
|
||
const getSectionContent = (key) => {
|
||
if (!config || !config.sections) return '';
|
||
const s = config.sections.find(s => s.key === key);
|
||
return s ? s.content : '';
|
||
};
|
||
|
||
return (
|
||
<div className="admin-panel">
|
||
{notification && (
|
||
<div className={`notification ${notification.type}`}>
|
||
{notification.message}
|
||
</div>
|
||
)}
|
||
{error && <ErrorMessage message={error} />}
|
||
<div className="admin-tabs">
|
||
<button
|
||
className={`tab-button ${activeTab === 'users' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('users')}
|
||
>
|
||
Benutzer verwalten
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'trash' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('trash')}
|
||
>
|
||
🗑️ Papierkorb
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'audit' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('audit')}
|
||
>
|
||
📋 Audit-Logs
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'settings' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('settings')}
|
||
>
|
||
Einstellungen
|
||
</button>
|
||
</div>
|
||
{activeTab === 'users' && (
|
||
<>
|
||
<ExportButton onExport={showNotification} onRefetch={onRefetch} />
|
||
<UserList
|
||
users={users}
|
||
loading={loading}
|
||
error={null}
|
||
onAvailabilityToggle={handleAvailabilityToggle}
|
||
onGPSUpdate={handleGPSUpdate}
|
||
onUserCreate={handleUserCreate}
|
||
onUserUpdate={handleUserUpdate}
|
||
onUserDelete={handleUserDelete}
|
||
onPhotoUpload={handleUserPhotoUpload}
|
||
onPhotoDelete={handleUserPhotoDelete}
|
||
onGenerateInvite={handleGenerateInvite}
|
||
/>
|
||
</>
|
||
)}
|
||
{activeTab === 'trash' && (
|
||
<Trash onRefetch={onRefetch} />
|
||
)}
|
||
{activeTab === 'audit' && (
|
||
<AuditLogs />
|
||
)}
|
||
{activeTab === 'settings' && (
|
||
<div className="settings-panel">
|
||
<div className="settings-topbar">
|
||
<h3 className="panel-title">Einstellungen</h3>
|
||
{config && (
|
||
<div className="settings-topbar-actions">
|
||
{hasUnsavedChanges && (
|
||
<span className="unsaved-badge">Ungespeicherte Änderungen</span>
|
||
)}
|
||
<input
|
||
ref={importInputRef}
|
||
type="file"
|
||
accept=".json,application/json"
|
||
style={{ display: 'none' }}
|
||
onChange={handleConfigImport}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={() => importInputRef.current.click()}
|
||
title="Einstellungen aus JSON-Datei importieren"
|
||
>
|
||
⬆ Importieren
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={handleConfigExport}
|
||
title="Einstellungen als JSON exportieren"
|
||
>
|
||
⬇ Exportieren
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{configLoading ? (
|
||
<div className="settings-skeleton">
|
||
<div className="skeleton-line skeleton-line-wide" />
|
||
<div className="skeleton-line" />
|
||
<div className="skeleton-line" />
|
||
<div className="skeleton-line skeleton-line-short" />
|
||
</div>
|
||
) : configError ? (
|
||
<div className="settings-error-state">
|
||
<span className="settings-error-icon">⚠</span>
|
||
<p>Die Konfiguration konnte nicht geladen werden.</p>
|
||
<button type="button" className="btn btn-primary" onClick={() => { setConfig(null); loadConfig(); }}>
|
||
Erneut versuchen
|
||
</button>
|
||
</div>
|
||
) : config ? (
|
||
<>
|
||
{/* App-Name */}
|
||
<div className="settings-section">
|
||
<div className="settings-section-header" style={{ cursor: 'default' }}>
|
||
<h4 className="settings-heading">App-Name</h4>
|
||
</div>
|
||
<div className="settings-section-body">
|
||
<div className="settings-field">
|
||
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
|
||
<input id="settings-appname"
|
||
type="text"
|
||
className="settings-input"
|
||
value={config.appName || ''}
|
||
onChange={(e) => setConfig({ ...config, appName: e.target.value })}
|
||
placeholder="z.B. Stöberhunde Heidekreis"
|
||
maxLength={80}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Logo */}
|
||
<div className="settings-section">
|
||
<button type="button" className="settings-section-header" onClick={() => toggleSection('logo')}>
|
||
<h4 className="settings-heading">App-Logo</h4>
|
||
<span className={`settings-chevron ${collapsed.logo ? 'collapsed' : ''}`}>▾</span>
|
||
</button>
|
||
{!collapsed.logo && (
|
||
<div className="settings-section-body">
|
||
{config.logo && (
|
||
<div className="logo-preview">
|
||
<img src={config.logo} alt="Aktuelles Logo" style={{ maxHeight: 80, maxWidth: 200 }} />
|
||
</div>
|
||
)}
|
||
<div className="logo-actions">
|
||
<input
|
||
ref={logoInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
style={{ display: 'none' }}
|
||
onChange={handleLogoUpload}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => logoInputRef.current.click()}
|
||
disabled={logoUploading}
|
||
>
|
||
{logoUploading ? 'Wird hochgeladen…' : config.logo ? 'Logo ersetzen' : 'Logo hochladen'}
|
||
</button>
|
||
{config.logo && (
|
||
<button type="button" className="btn btn-danger" onClick={handleLogoDelete}>
|
||
Logo entfernen
|
||
</button>
|
||
)}
|
||
</div>
|
||
<p className="settings-hint">Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<form onSubmit={(e) => { e.preventDefault(); handleConfigUpdate(config); }}>
|
||
{/* Texte "Allgemeines" */}
|
||
<div className="settings-section">
|
||
<button type="button" className="settings-section-header" onClick={() => toggleSection('sections')}>
|
||
<h4 className="settings-heading">Texte im Bereich „Allgemeines"</h4>
|
||
<span className={`settings-chevron ${collapsed.sections ? 'collapsed' : ''}`}>▾</span>
|
||
</button>
|
||
{!collapsed.sections && (
|
||
<div className="settings-section-body">
|
||
{[
|
||
{ key: 'ueber-uns', label: 'Stöberhunde Heidekreis' },
|
||
{ key: 'anwendung', label: 'Anwendung der App' },
|
||
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
||
].map(({ key, label }) => (
|
||
<div key={key} className="settings-field">
|
||
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
|
||
<textarea id={`settings-section-${key}`}
|
||
value={getSectionContent(key)}
|
||
onChange={(e) => updateSection(key, e.target.value)}
|
||
className="settings-textarea settings-textarea-lg"
|
||
rows={6}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Verhaltensregeln */}
|
||
<div className="settings-section">
|
||
<button type="button" className="settings-section-header" onClick={() => toggleSection('rules')}>
|
||
<h4 className="settings-heading">Verhaltensregeln</h4>
|
||
<span className="settings-section-count">{config.rules.length} Regel{config.rules.length !== 1 ? 'n' : ''}</span>
|
||
<span className={`settings-chevron ${collapsed.rules ? 'collapsed' : ''}`}>▾</span>
|
||
</button>
|
||
{!collapsed.rules && (
|
||
<div className="settings-section-body">
|
||
{config.rules.map((rule, index) => (
|
||
<div key={index} className="settings-rule-row">
|
||
<span className="settings-rule-index">{index + 1}</span>
|
||
<textarea
|
||
value={rule}
|
||
onChange={(e) => {
|
||
const newRules = [...config.rules];
|
||
newRules[index] = e.target.value;
|
||
setConfig({ ...config, rules: newRules });
|
||
}}
|
||
className="settings-textarea"
|
||
rows={2}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn btn-icon btn-danger"
|
||
title="Regel entfernen"
|
||
onClick={() => {
|
||
const newRules = config.rules.filter((_, i) => i !== index);
|
||
setConfig({ ...config, rules: newRules });
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={() => setConfig({ ...config, rules: [...config.rules, ''] })}
|
||
>
|
||
+ Regel hinzufügen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Hunderassen / Benutzertypen */}
|
||
<div className="settings-section">
|
||
<button type="button" className="settings-section-header" onClick={() => toggleSection('userTypes')}>
|
||
<h4 className="settings-heading">Hunderassen</h4>
|
||
<span className="settings-section-count">{config.userTypes.length} Rassen</span>
|
||
<span className={`settings-chevron ${collapsed.userTypes ? 'collapsed' : ''}`}>▾</span>
|
||
</button>
|
||
{!collapsed.userTypes && (
|
||
<div className="settings-section-body">
|
||
<div className="settings-type-header">
|
||
<span className="settings-type-col-sm">Kürzel</span>
|
||
<span>Bezeichnung</span>
|
||
</div>
|
||
{config.userTypes.map((type, index) => (
|
||
<div key={index} className="settings-row">
|
||
<input
|
||
type="text"
|
||
placeholder="z.B. SH"
|
||
value={type.code}
|
||
onChange={(e) => {
|
||
const newTypes = [...config.userTypes];
|
||
newTypes[index] = { ...newTypes[index], code: e.target.value.toUpperCase() };
|
||
setConfig({ ...config, userTypes: newTypes });
|
||
}}
|
||
className="settings-input settings-input-sm"
|
||
maxLength={6}
|
||
/>
|
||
<input
|
||
type="text"
|
||
placeholder="Bezeichnung"
|
||
value={type.label}
|
||
onChange={(e) => {
|
||
const newTypes = [...config.userTypes];
|
||
newTypes[index] = { ...newTypes[index], label: e.target.value };
|
||
setConfig({ ...config, userTypes: newTypes });
|
||
}}
|
||
className="settings-input"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn btn-icon btn-danger"
|
||
title="Hunderasse entfernen"
|
||
onClick={() => {
|
||
const newTypes = config.userTypes.filter((_, i) => i !== index);
|
||
setConfig({ ...config, userTypes: newTypes });
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={() => setConfig({ ...config, userTypes: [...config.userTypes, { code: '', label: '' }] })}
|
||
>
|
||
+ Hunderasse hinzufügen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="settings-actions">
|
||
{hasUnsavedChanges && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setConfig(JSON.parse(JSON.stringify(configOriginal)))}
|
||
>
|
||
Änderungen verwerfen
|
||
</button>
|
||
)}
|
||
<button type="submit" className={`btn btn-primary${hasUnsavedChanges ? ' btn-pulse' : ''}`}>
|
||
Einstellungen speichern
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminPanel;
|