DGSoft cfd7068af5 feat: Add blinking envelope icons for unread messages
- Implement unread message indicators with Material-UI icons
- Add BlinkingEnvelope component with theme-compatible colors
- Create UnreadMessagesContext for managing unread states
- Integrate WebSocket message handling for real-time notifications
- Icons only appear for inactive channels/DMs, disappear when opened
- Add test functionality (double-click to mark as unread)
- Fix WebSocket URL handling for production deployment
- Unify WebSocket architecture using presence connection for all messages
2025-12-12 11:26:36 +01:00

1406 lines
64 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import api, { adminLanguagesAPI, adminTranslationsAPI, snippetsAPI } from '../../services/api';
import type {
Channel,
Department,
Language,
Snippet,
TranslationGroup,
User,
} from '../../types';
import { UserRole, isSuperAdmin } from '../../types';
type TabKey = 'users' | 'departments' | 'channels' | 'snippets' | 'languages';
type DepartmentSnippetEntry = {
snippet_id: number;
snippet_title: string;
snippet_language: string;
snippet_owner: string;
enabled: boolean;
};
type SnippetAccessEntry = {
department_id: number;
department_name: string;
enabled: boolean;
};
const AdminPanel: React.FC = () => {
const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState<TabKey>('users');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [languagesLoading, setLanguagesLoading] = useState(false);
const [translationsLoading, setTranslationsLoading] = useState(false);
const [loadingSnippets, setLoadingSnippets] = useState(false);
const [snippetAccessLoading, setSnippetAccessLoading] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [uiLanguages, setUiLanguages] = useState<Language[]>([]);
const [translations, setTranslations] = useState<TranslationGroup[]>([]);
const [languageModalOpen, setLanguageModalOpen] = useState(false);
const [usersLoaded, setUsersLoaded] = useState(false);
const [departmentsLoaded, setDepartmentsLoaded] = useState(false);
const [channelsLoaded, setChannelsLoaded] = useState(false);
const [snippetsLoaded, setSnippetsLoaded] = useState(false);
const [languagesLoaded, setLanguagesLoaded] = useState(false);
const [translationsLoaded, setTranslationsLoaded] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [assignDeptId, setAssignDeptId] = useState<number | null>(null);
const [newDeptName, setNewDeptName] = useState('');
const [newDeptDesc, setNewDeptDesc] = useState('');
const [editingDept, setEditingDept] = useState<Department | null>(null);
const [editDeptName, setEditDeptName] = useState('');
const [editDeptDesc, setEditDeptDesc] = useState('');
const [deptSnippets, setDeptSnippets] = useState<DepartmentSnippetEntry[]>([]);
const [newChannelName, setNewChannelName] = useState('');
const [newChannelDesc, setNewChannelDesc] = useState('');
const [channelDeptId, setChannelDeptId] = useState<number | null>(null);
const [selectedSnippetId, setSelectedSnippetId] = useState<number | null>(null);
const [snippetAccess, setSnippetAccess] = useState<SnippetAccessEntry[]>([]);
const [newLanguageCode, setNewLanguageCode] = useState('');
const [newLanguageName, setNewLanguageName] = useState('');
const [translationSaving, setTranslationSaving] = useState<number | null>(null);
const navItems = useMemo<Array<{ key: TabKey; label: string; description: string }>>(
() => [
{ key: 'users', label: 'Benutzer', description: 'Verwalten Sie Benutzer und Admin-Rechte.' },
{ key: 'departments', label: 'Abteilungen', description: 'Strukturieren Sie Teams und Rechte.' },
{ key: 'channels', label: 'Channels', description: 'Organisieren Sie Kommunikationsräume.' },
{ key: 'snippets', label: 'Snippets', description: 'Steuern Sie Code-Snippet Zugriff.' },
{ key: 'languages', label: 'Sprachen', description: 'Verwalten Sie UI-Sprachen & Texte.' },
],
[]
);
const setGlobalError = useCallback((message: string) => {
console.error(message);
setError(message);
}, []);
const loadUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await api.get<User[]>('/admin/users');
setUsers(response.data);
setUsersLoaded(true);
} catch (err) {
setGlobalError('Benutzer konnten nicht geladen werden.');
} finally {
setLoading(false);
}
}, [setGlobalError]);
const loadDepartments = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await api.get<Department[]>('/admin/departments');
setDepartments(response.data);
setDepartmentsLoaded(true);
} catch (err) {
setGlobalError('Abteilungen konnten nicht geladen werden.');
} finally {
setLoading(false);
}
}, [setGlobalError]);
const loadChannels = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await api.get<Channel[]>('/admin/channels');
setChannels(response.data);
setChannelsLoaded(true);
} catch (err) {
setGlobalError('Channels konnten nicht geladen werden.');
} finally {
setLoading(false);
}
}, [setGlobalError]);
const loadSnippets = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await snippetsAPI.getAll();
setSnippets(response);
setSnippetsLoaded(true);
} catch (err) {
setGlobalError('Snippets konnten nicht geladen werden.');
} finally {
setLoading(false);
}
}, [setGlobalError]);
const loadLanguages = useCallback(async () => {
setLanguagesLoading(true);
setError(null);
try {
const items = await adminLanguagesAPI.getAll();
setUiLanguages(items);
setLanguagesLoaded(true);
} catch (err) {
setGlobalError('Sprachen konnten nicht geladen werden.');
} finally {
setLanguagesLoading(false);
}
}, [setGlobalError]);
const loadTranslations = useCallback(async () => {
setTranslationsLoading(true);
setError(null);
try {
const items = await adminTranslationsAPI.getAll();
setTranslations(items);
setTranslationsLoaded(true);
} catch (err) {
setGlobalError('Übersetzungen konnten nicht geladen werden.');
} finally {
setTranslationsLoading(false);
}
}, [setGlobalError]);
const ensureDeptSnippetsLoaded = useCallback(
async (departmentId: number) => {
setLoadingSnippets(true);
setError(null);
try {
const departmentSnippets = await snippetsAPI.getAll({ visibility: 'department' });
const enriched = await Promise.all(
departmentSnippets.map(async (snippet: Snippet) => {
const accessResponse = await api.get<SnippetAccessEntry[]>(
`/admin/snippets/${snippet.id}/departments`
);
const entry = accessResponse.data.find((item) => item.department_id === departmentId);
return {
snippet_id: snippet.id,
snippet_title: snippet.title,
snippet_language: snippet.language,
snippet_owner: snippet.owner_username ?? 'Unbekannt',
enabled: entry?.enabled ?? false,
} as DepartmentSnippetEntry;
})
);
setDeptSnippets(enriched);
} catch (err) {
setGlobalError('Snippet-Berechtigungen konnten nicht geladen werden.');
} finally {
setLoadingSnippets(false);
}
},
[setGlobalError]
);
const fetchSnippetAccess = useCallback(async (snippetId: number) => {
setSnippetAccessLoading(true);
setError(null);
try {
const response = await api.get<SnippetAccessEntry[]>(`/admin/snippets/${snippetId}/departments`);
setSnippetAccess(response.data);
} catch (err) {
setGlobalError('Abteilungszugriffe konnten nicht geladen werden.');
} finally {
setSnippetAccessLoading(false);
}
}, [setGlobalError]);
useEffect(() => {
if (activeTab === 'users' && !usersLoaded) {
loadUsers();
}
if ((activeTab === 'departments' || activeTab === 'channels' || activeTab === 'snippets') && !departmentsLoaded) {
loadDepartments();
}
if (activeTab === 'channels' && !channelsLoaded) {
loadChannels();
}
if (activeTab === 'snippets' && !snippetsLoaded) {
loadSnippets();
}
if (activeTab === 'languages' && !languagesLoaded) {
loadLanguages();
}
if (activeTab === 'languages' && !translationsLoaded) {
loadTranslations();
}
}, [
activeTab,
channelsLoaded,
departmentsLoaded,
languagesLoaded,
loadChannels,
loadDepartments,
loadLanguages,
loadSnippets,
loadTranslations,
loadUsers,
snippetsLoaded,
translationsLoaded,
usersLoaded,
]);
const updateUserRole = useCallback(
async (userId: number, newRole: UserRole) => {
setError(null);
try {
await api.patch(`/admin/users/${userId}/role`, { role: newRole });
setUsers((prev) => {
const user = prev.find(u => u.id === userId);
setGlobalError(`Rolle von ${user?.username} wurde zu ${newRole} geändert.`);
return prev.map((user) => (user.id === userId ? { ...user, role: newRole } : user));
});
} catch (err) {
setGlobalError('Rolle konnte nicht geändert werden.');
}
},
[setGlobalError]
);
const assignUserToDepartment = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedUserId || !assignDeptId) {
setGlobalError('Bitte wählen Sie einen Benutzer und eine Abteilung aus.');
return;
}
setError(null);
try {
await api.post(`/admin/departments/${assignDeptId}/members`, null, {
params: { user_id: selectedUserId },
});
setSelectedUserId(null);
setAssignDeptId(null);
} catch (err) {
setGlobalError('Konnte Benutzer nicht zuweisen.');
}
},
[assignDeptId, selectedUserId, setGlobalError]
);
const createDepartment = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!newDeptName.trim()) {
setGlobalError('Der Abteilungsname darf nicht leer sein.');
return;
}
setError(null);
try {
const response = await api.post<Department>('/admin/departments', {
name: newDeptName.trim(),
description: newDeptDesc.trim() || undefined,
});
setDepartments((prev) => [...prev, response.data]);
setNewDeptName('');
setNewDeptDesc('');
} catch (err) {
setGlobalError('Abteilung konnte nicht erstellt werden.');
}
},
[newDeptDesc, newDeptName, setGlobalError]
);
const startEditDepartment = useCallback(
(department: Department) => {
setEditingDept(department);
setEditDeptName(department.name);
setEditDeptDesc(department.description ?? '');
if (department.snippets_enabled) {
ensureDeptSnippetsLoaded(department.id);
} else {
setDeptSnippets([]);
}
},
[ensureDeptSnippetsLoaded]
);
const cancelEditDepartment = useCallback(() => {
setEditingDept(null);
setEditDeptName('');
setEditDeptDesc('');
setDeptSnippets([]);
}, []);
const updateDepartment = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editingDept) {
return;
}
if (!editDeptName.trim()) {
setGlobalError('Der Abteilungsname darf nicht leer sein.');
return;
}
setError(null);
try {
const response = await api.put<Department>(`/admin/departments/${editingDept.id}`, {
name: editDeptName.trim(),
description: editDeptDesc.trim() || undefined,
});
setDepartments((prev) => prev.map((dept) => (dept.id === editingDept.id ? response.data : dept)));
setEditingDept(response.data);
} catch (err) {
setGlobalError('Abteilung konnte nicht aktualisiert werden.');
}
},
[editDeptDesc, editDeptName, editingDept, setGlobalError]
);
const deleteDepartment = useCallback(
async (departmentId: number) => {
if (!window.confirm('Möchten Sie diese Abteilung wirklich löschen?')) {
return;
}
setError(null);
try {
await api.delete(`/admin/departments/${departmentId}`);
setDepartments((prev) => prev.filter((dept) => dept.id !== departmentId));
if (editingDept && editingDept.id === departmentId) {
cancelEditDepartment();
}
} catch (err) {
setGlobalError('Abteilung konnte nicht gelöscht werden.');
}
},
[cancelEditDepartment, editingDept, setGlobalError]
);
const toggleDepartmentSnippetAccess = useCallback(
async (departmentId: number, enabled: boolean) => {
setError(null);
try {
await api.patch(`/admin/departments/${departmentId}/snippets`, null, { params: { enabled: !enabled } });
setDepartments((prev) =>
prev.map((dept) =>
dept.id === departmentId ? { ...dept, snippets_enabled: !enabled } : dept
)
);
if (editingDept && editingDept.id === departmentId) {
const updated = { ...editingDept, snippets_enabled: !enabled };
setEditingDept(updated);
if (!enabled) {
ensureDeptSnippetsLoaded(departmentId);
} else {
setDeptSnippets([]);
}
}
} catch (err) {
setGlobalError('Snippet-Hauptschalter konnte nicht gesetzt werden.');
}
},
[editingDept, ensureDeptSnippetsLoaded, setGlobalError]
);
const toggleDepartmentSnippet = useCallback(
async (snippetId: number, enabled: boolean) => {
if (!editingDept) {
return;
}
setError(null);
try {
await api.post('/admin/snippets/departments/toggle', {
snippet_id: snippetId,
department_id: editingDept.id,
enabled,
});
setDeptSnippets((prev) =>
prev.map((item) => (item.snippet_id === snippetId ? { ...item, enabled } : item))
);
} catch (err) {
setGlobalError('Snippet-Zugriff konnte nicht angepasst werden.');
}
},
[editingDept, setGlobalError]
);
const createChannel = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!newChannelName.trim() || !channelDeptId) {
setGlobalError('Bitte geben Sie einen Namen an und wählen Sie eine Abteilung.');
return;
}
setError(null);
try {
const response = await api.post<Channel>('/admin/channels', {
name: newChannelName.trim(),
description: newChannelDesc.trim() || undefined,
department_id: channelDeptId,
});
setChannels((prev) => [...prev, response.data]);
setNewChannelName('');
setNewChannelDesc('');
setChannelDeptId(null);
} catch (err) {
setGlobalError('Channel konnte nicht erstellt werden.');
}
},
[channelDeptId, newChannelDesc, newChannelName, setGlobalError]
);
const deleteChannel = useCallback(
async (channelId: number, channelName: string) => {
if (!window.confirm(`Channel "${channelName}" löschen?`)) {
return;
}
setError(null);
try {
await api.delete(`/admin/channels/${channelId}`);
setChannels((prev) => prev.filter((channel) => channel.id !== channelId));
} catch (err) {
setGlobalError('Channel konnte nicht gelöscht werden.');
}
},
[setGlobalError]
);
const toggleSnippetAccess = useCallback(
async (snippetId: number, departmentId: number, enabled: boolean) => {
setError(null);
try {
await api.post('/admin/snippets/departments/toggle', {
snippet_id: snippetId,
department_id: departmentId,
enabled,
});
setSnippetAccess((prev) =>
prev.map((entry) =>
entry.department_id === departmentId ? { ...entry, enabled } : entry
)
);
} catch (err) {
setGlobalError('Snippet-Berechtigung konnte nicht geändert werden.');
}
},
[setGlobalError]
);
const openLanguageModal = () => {
setError(null);
setNewLanguageCode('');
setNewLanguageName('');
setLanguageModalOpen(true);
};
const closeLanguageModal = useCallback(() => {
setLanguageModalOpen(false);
setNewLanguageCode('');
setNewLanguageName('');
}, []);
const createLanguage = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const code = newLanguageCode.trim().toLowerCase();
const name = newLanguageName.trim();
if (!code || !name) {
setGlobalError('Bitte geben Sie Code und Anzeigename an.');
return;
}
setError(null);
try {
const language = await adminLanguagesAPI.create({ code, name });
setUiLanguages((prev) => [...prev, language].sort((a, b) => a.name.localeCompare(b.name)));
closeLanguageModal();
if (translationsLoaded) {
loadTranslations();
}
} catch (err) {
setGlobalError('Sprache konnte nicht erstellt werden.');
}
},
[
closeLanguageModal,
loadTranslations,
newLanguageCode,
newLanguageName,
setGlobalError,
translationsLoaded,
]
);
const deleteLanguage = useCallback(
async (languageId: number, languageName: string) => {
if (!window.confirm(`Sprache "${languageName}" löschen?`)) {
return;
}
setError(null);
try {
await adminLanguagesAPI.delete(languageId);
setUiLanguages((prev) => prev.filter((language) => language.id !== languageId));
setTranslations((prev) =>
prev.map((group) => ({
...group,
entries: group.entries.filter((entry) => entry.language_id !== languageId),
}))
);
} catch (err) {
setGlobalError('Sprache konnte nicht gelöscht werden.');
}
},
[setGlobalError]
);
const updateTranslationDraft = useCallback((translationId: number, value: string) => {
setTranslations((prev) =>
prev.map((group) => ({
...group,
entries: group.entries.map((entry) =>
entry.translation_id === translationId ? { ...entry, value } : entry
),
}))
);
}, []);
const saveTranslationValue = useCallback(
async (translationId: number, value: string) => {
setError(null);
setTranslationSaving(translationId);
try {
await adminTranslationsAPI.update({ translation_id: translationId, value });
} catch (err) {
setGlobalError('Übersetzung konnte nicht gespeichert werden.');
} finally {
setTranslationSaving(null);
}
},
[setGlobalError]
);
const handleSnippetToggle = useCallback(
(snippet: Snippet) => {
if (selectedSnippetId === snippet.id) {
setSelectedSnippetId(null);
setSnippetAccess([]);
return;
}
setSelectedSnippetId(snippet.id);
setSnippetAccess([]);
fetchSnippetAccess(snippet.id);
},
[fetchSnippetAccess, selectedSnippetId]
);
const stats = useMemo(() => {
const departmentSnippets = snippets.filter((snippet) => snippet.visibility === 'department');
const organizationSnippets = snippets.filter((snippet) => snippet.visibility === 'organization');
return {
total: snippets.length,
department: departmentSnippets.length,
organization: organizationSnippets.length,
};
}, [snippets]);
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 py-8">
<div className="max-w-6xl mx-auto px-4 md:px-6 lg:px-8">
<div className="flex flex-col lg:flex-row bg-white dark:bg-gray-800 shadow-lg rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700">
<aside className="lg:w-64 bg-gray-50 dark:bg-gray-900 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-gray-700">
<nav className="flex lg:flex-col">
{navItems.map((item) => (
<button
key={item.key}
onClick={() => setActiveTab(item.key)}
className={`flex-1 lg:flex-none px-4 py-4 text-left border-l-4 lg:border-l-0 lg:border-b border-gray-200 dark:border-gray-700 transition-colors ${
activeTab === item.key
? 'bg-white dark:bg-gray-800 border-l-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-300 hover:bg-white/70 dark:hover:bg-gray-800/70'
}`}
>
<div className="text-sm font-semibold">{item.label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">{item.description}</div>
</button>
))}
</nav>
</aside>
<div className="flex-1 p-6 lg:p-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Adminbereich</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Verwalten Sie Benutzer, Strukturen und Inhalte Ihrer Organisation.
</p>
</div>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded text-sm">
{error}
</div>
)}
{activeTab === 'users' && (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
Benutzer-Verwaltung
</h2>
{loading ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Lädt...</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr className="text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-2">Username</th>
<th className="px-4 py-2">Email</th>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Admin</th>
<th className="px-4 py-2">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{users.map((user) => (
<tr key={user.id}>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{user.username}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{user.email}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{user.full_name || '-'}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
{user.role === 'superadmin' ? (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
SuperAdmin
</span>
) : user.role === 'admin' ? (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Admin
</span>
) : (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
User
</span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs">
{isSuperAdmin(currentUser) && user.id !== currentUser?.id ? (
<select
value={user.role}
onChange={(e) => updateUserRole(user.id, e.target.value as UserRole)}
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white dark:bg-gray-700 dark:border-gray-600"
>
<option value={UserRole.USER}>User</option>
<option value={UserRole.ADMIN}>Admin</option>
<option value={UserRole.SUPERADMIN}>SuperAdmin</option>
</select>
) : (
<span className="text-gray-500">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
User zu Abteilung zuweisen
</h2>
<form onSubmit={assignUserToDepartment} className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
User auswählen
</label>
<select
value={selectedUserId ?? ''}
onChange={(event) => setSelectedUserId(event.target.value ? Number(event.target.value) : null)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="">-- Benutzer wählen --</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username} ({user.email})
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Abteilung auswählen
</label>
<select
value={assignDeptId ?? ''}
onChange={(event) => setAssignDeptId(event.target.value ? Number(event.target.value) : null)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="">-- Abteilung wählen --</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm"
>
Zuweisen
</button>
</form>
</div>
</div>
)}
{activeTab === 'departments' && (
<div className="space-y-6">
{editingDept && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-400 dark:border-yellow-600 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
Abteilung bearbeiten: {editingDept.name}
</h2>
<form onSubmit={updateDepartment} className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Abteilungsname
</label>
<input
type="text"
value={editDeptName}
onChange={(event) => setEditDeptName(event.target.value)}
required
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Beschreibung
</label>
<textarea
value={editDeptDesc}
onChange={(event) => setEditDeptDesc(event.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
{editingDept.snippets_enabled && (
<div className="border-t border-yellow-300 dark:border-yellow-700 pt-3 mt-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
Code-Snippets Freischaltungen
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Aktivieren Sie die gewünschten Snippets für diese Abteilung.
</p>
{loadingSnippets ? (
<p className="text-xs text-gray-500 dark:text-gray-400 italic animate-pulse">
Lade Snippets...
</p>
) : deptSnippets.length === 0 ? (
<div className="text-center py-4 bg-gray-50 dark:bg-gray-900 rounded border border-dashed border-gray-300 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400">
Keine Department-Snippets vorhanden
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Snippets können im Chat mit Visibility "Department" erstellt werden.
</p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded p-2">
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 px-1">
{deptSnippets.filter((item) => item.enabled).length} von {deptSnippets.length} aktiviert
</div>
{deptSnippets.map((item) => (
<div
key={item.snippet_id}
className="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex-1 min-w-0 mr-2">
<div className="text-xs font-medium text-gray-900 dark:text-white truncate">
{item.snippet_title}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<span className="inline-block bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded mr-1">
{item.snippet_language}
</span>
<span className="mx-1 text-gray-400 dark:text-gray-500">-</span>
<span>von {item.snippet_owner}</span>
</div>
</div>
<button
type="button"
onClick={() => toggleDepartmentSnippet(item.snippet_id, !item.enabled)}
className={`text-xs px-3 py-1.5 rounded font-medium transition-all whitespace-nowrap ${
item.enabled
? 'bg-green-600 text-white hover:bg-green-700 shadow-sm'
: 'bg-gray-500 text-white hover:bg-gray-600'
}`}
>
{item.enabled ? 'Aktiv' : 'Inaktiv'}
</button>
</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-2 pt-2">
<button
type="submit"
className="flex-1 bg-green-600 text-white px-3 py-2 rounded-md hover:bg-green-700 text-sm"
>
Speichern
</button>
<button
type="button"
onClick={cancelEditDepartment}
className="flex-1 bg-gray-500 text-white px-3 py-2 rounded-md hover:bg-gray-600 text-sm"
>
Abbrechen
</button>
</div>
</form>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
Neue Abteilung erstellen
</h2>
<form onSubmit={createDepartment} className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Abteilungsname
</label>
<input
type="text"
value={newDeptName}
onChange={(event) => setNewDeptName(event.target.value)}
required
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="z.B. Entwicklung"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Beschreibung
</label>
<textarea
value={newDeptDesc}
onChange={(event) => setNewDeptDesc(event.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Beschreibung der Abteilung"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white px-3 py-2 rounded-md hover:bg-blue-700 text-sm"
>
Abteilung erstellen
</button>
</form>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
Bestehende Abteilungen
</h2>
{loading ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Lädt...</p>
) : (
<div className="space-y-2">
{departments.map((dept) => (
<div
key={dept.id}
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-md"
>
<div className="flex-1">
<h3 className="font-semibold text-sm text-gray-900 dark:text-white">
{dept.name}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{dept.description || 'Keine Beschreibung'}
</p>
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Snippet-Zugriff:</span>
<button
onClick={() => toggleDepartmentSnippetAccess(dept.id, dept.snippets_enabled ?? true)}
className={`text-xs px-2 py-1 rounded font-medium transition-colors ${
dept.snippets_enabled ?? true
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-red-600 text-white hover:bg-red-700'
}`}
>
{dept.snippets_enabled ?? true ? 'Aktiv' : 'Inaktiv'}
</button>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => startEditDepartment(dept)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 px-3 py-1 text-xs border border-blue-600 dark:border-blue-400 rounded-md"
>
Bearbeiten
</button>
<button
onClick={() => deleteDepartment(dept.id)}
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 px-3 py-1 text-xs border border-red-600 dark:border-red-400 rounded-md"
>
Löschen
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'channels' && (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
Neuen Channel erstellen
</h2>
<form onSubmit={createChannel} className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Channel-Name
</label>
<input
type="text"
value={newChannelName}
onChange={(event) => setNewChannelName(event.target.value)}
required
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="z.B. general"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Beschreibung
</label>
<textarea
value={newChannelDesc}
onChange={(event) => setNewChannelDesc(event.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Beschreibung des Channels"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Abteilung
</label>
<select
value={channelDeptId ?? ''}
onChange={(event) => setChannelDeptId(event.target.value ? Number(event.target.value) : null)}
required
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="">-- Abteilung wählen --</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white px-3 py-2 rounded-md hover:bg-blue-700 text-sm"
>
Channel erstellen
</button>
</form>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
Bestehende Channels
</h2>
{loading ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Lädt...</p>
) : channels.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Noch keine Channels vorhanden.</p>
) : (
<div className="space-y-2">
{channels.map((channel) => {
const dept = departments.find((item) => item.id === channel.department_id);
return (
<div
key={channel.id}
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-md"
>
<div>
<h3 className="font-semibold text-sm text-gray-900 dark:text-white">
#{channel.name}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{channel.description || 'Keine Beschreibung'}
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
Abteilung: {dept?.name || `ID ${channel.department_id}`}
</p>
</div>
<button
onClick={() => deleteChannel(channel.id, channel.name)}
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 px-3 py-1 text-xs border border-red-600 dark:border-red-400 rounded-md"
>
Löschen
</button>
</div>
);
})}
</div>
)}
</div>
</div>
)}
{activeTab === 'snippets' && (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
Code-Snippet Freischaltungen
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Verwalten Sie, welche Abteilungen Zugriff auf Department-Snippets haben.
</p>
{loading ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Snippets werden geladen...</p>
) : snippets.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Noch keine Snippets vorhanden.</p>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Gesamt Snippets</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.department}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Department-Snippets</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded">
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">{stats.organization}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Organisation-Snippets</div>
</div>
</div>
{stats.department === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p className="text-sm">Keine Department-Snippets vorhanden.</p>
</div>
) : (
<div className="space-y-4">
{snippets
.filter((snippet) => snippet.visibility === 'department')
.map((snippet) => (
<div
key={snippet.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-3">
<div className="flex-1">
<h3 className="font-semibold text-sm text-gray-900 dark:text-white">
{snippet.title}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<span className="inline-block bg-gray-200 dark:bg-gray-700 px-2 py-0.5 rounded">
{snippet.language}
</span>
<span className="mx-2">-</span>
von {snippet.owner_username || 'Unbekannt'}
</p>
</div>
<button
onClick={() => handleSnippetToggle(snippet)}
className="text-xs px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700 whitespace-nowrap"
>
{selectedSnippetId === snippet.id ? 'Ausblenden' : 'Abteilungen verwalten'}
</button>
</div>
{selectedSnippetId === snippet.id && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
{snippetAccessLoading ? (
<p className="text-xs text-gray-500 dark:text-gray-400 animate-pulse">
Abteilungen werden geladen...
</p>
) : (
<>
<h4 className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
Abteilungs-Zugriff ({
snippetAccess.filter((entry) => entry.enabled).length
} von {snippetAccess.length} aktiviert)
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{snippetAccess.map((entry) => (
<label
key={entry.department_id}
className="flex items-center p-2 bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={entry.enabled}
onChange={(event) =>
toggleSnippetAccess(snippet.id, entry.department_id, event.target.checked)
}
className="w-4 h-4 text-green-600 bg-gray-100 border-gray-300 rounded focus:ring-green-500 dark:focus:ring-green-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 cursor-pointer"
/>
<span className="ml-2 text-xs text-gray-700 dark:text-gray-300 font-medium">
{entry.department_name}
</span>
</label>
))}
</div>
</>
)}
</div>
)}
</div>
))}
</div>
)}
</>
)}
</div>
</div>
)}
{activeTab === 'languages' && (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-3 mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
UI-Sprachen verwalten
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Pflegen Sie die verfügbaren Sprachen für die Benutzeroberfläche.
</p>
</div>
<button
type="button"
onClick={openLanguageModal}
className="self-start bg-green-600 text-white px-3 py-2 rounded-md hover:bg-green-700 text-sm"
>
Neue Sprache
</button>
</div>
<div>
{languagesLoading ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Sprachen werden geladen...</p>
) : uiLanguages.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Noch keine UI-Sprachen vorhanden.</p>
) : (
<div className="space-y-2">
{uiLanguages.map((language) => (
<div
key={language.id}
className="flex justify-between items-center p-3 border border-gray-200 dark:border-gray-700 rounded-md"
>
<div>
<div className="font-semibold text-sm text-gray-900 dark:text-white flex items-center gap-2">
{language.name}
{language.is_default && (
<span className="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Standard
</span>
)}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Code: {language.code}</div>
</div>
<button
type="button"
onClick={() => deleteLanguage(language.id, language.name)}
disabled={language.is_default}
className={`px-3 py-1 text-xs rounded-md border ${
language.is_default
? 'border-gray-300 text-gray-400 dark:border-gray-600 dark:text-gray-500 cursor-not-allowed'
: 'border-red-600 text-red-600 hover:text-red-800 dark:border-red-400 dark:text-red-400 dark:hover:text-red-300'
}`}
>
{language.is_default ? 'Nicht entfernbar' : 'Entfernen'}
</button>
</div>
))}
</div>
)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
Übersetzungen bearbeiten
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Änderungen werden automatisch gespeichert, sobald ein Feld den Fokus verliert.
</p>
{translationsLoading ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Übersetzungen werden geladen...</p>
) : uiLanguages.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Bitte fügen Sie zunächst mindestens eine Sprache hinzu.</p>
) : translations.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">Keine Übersetzungseinträge vorhanden.</p>
) : (
<div className="space-y-4">
{translations.map((group) => (
<div
key={group.key}
className="border border-gray-200 dark:border-gray-700 rounded-md p-4"
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{group.label}
</h3>
{group.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{group.description}
</p>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
{group.key}
</span>
</div>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
{uiLanguages.map((language) => {
const entry = group.entries.find((item) => item.language_id === language.id);
if (!entry) {
return (
<div key={`${group.key}-${language.id}`} className="text-xs text-red-500">
Fehlender Eintrag für {language.name}
</div>
);
}
return (
<div key={entry.translation_id}>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{language.name} <span className="text-gray-400">({language.code})</span>
</label>
<input
type="text"
value={entry.value}
onChange={(event) => updateTranslationDraft(entry.translation_id, event.target.value)}
onBlur={(event) => saveTranslationValue(entry.translation_id, event.target.value)}
disabled={translationSaving === entry.translation_id}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Übersetzung eingeben"
/>
{translationSaving === entry.translation_id && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Wird gespeichert...</p>
)}
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
{languageModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div
className="absolute inset-0"
onClick={closeLanguageModal}
aria-hidden="true"
/>
<div className="relative z-10 w-full max-w-md bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Sprache hinzufügen</h3>
<button
type="button"
onClick={closeLanguageModal}
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
aria-label="Modal schließen"
>
</button>
</div>
<form onSubmit={createLanguage} className="px-4 py-4 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Sprachcode
</label>
<input
type="text"
value={newLanguageCode}
onChange={(event) => setNewLanguageCode(event.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="z. B. de"
autoFocus
required
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Kleingeschriebener ISO-639-1 Code ohne Leerzeichen.
</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Anzeigename
</label>
<input
type="text"
value={newLanguageName}
onChange={(event) => setNewLanguageName(event.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="z. B. Deutsch"
required
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={closeLanguageModal}
className="px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
type="submit"
className="px-3 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md"
>
Speichern
</button>
</div>
</form>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default AdminPanel;