jagd-apps/stoeberhunde/frontend/src/components/admin/AdminPanel.js

587 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;