mirror of
https://github.com/OHV-IT/collabrix.git
synced 2025-12-16 00:58:37 +01:00
- 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
1406 lines
64 KiB
TypeScript
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;
|