feat(admin): centralize tool & module settings in Werkzeuge tab with per-tool permissions, DB-backed config, connection tests, and cog-button navigation

This commit is contained in:
Matthias Hochmeister
2026-04-17 08:37:29 +02:00
parent 6ead698294
commit 6614fbaa68
28 changed files with 2472 additions and 1426 deletions

View File

@@ -109,6 +109,7 @@ import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
import buchhaltungRoutes from './routes/buchhaltung.routes';
import personalEquipmentRoutes from './routes/personalEquipment.routes';
import toolConfigRoutes from './routes/toolConfig.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -140,6 +141,7 @@ app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
app.use('/api/buchhaltung', buchhaltungRoutes);
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
app.use('/api/admin/tools', toolConfigRoutes);
// Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');

View File

@@ -0,0 +1,175 @@
import { Request, Response } from 'express';
import httpClient from '../config/httpClient';
import toolConfigService from '../services/toolConfig.service';
import settingsService from '../services/settings.service';
import logger from '../utils/logger';
const TOOL_SETTINGS_KEYS: Record<string, string> = {
bookstack: 'tool_config_bookstack',
vikunja: 'tool_config_vikunja',
nextcloud: 'tool_config_nextcloud',
};
const MASKED_FIELDS = ['tokenSecret', 'apiToken'];
function maskValue(value: string): string {
if (!value || value.length <= 4) return '****';
return '*'.repeat(value.length - 4) + value.slice(-4);
}
function maskConfig(config: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(config)) {
result[k] = MASKED_FIELDS.includes(k) ? maskValue(v) : v;
}
return result;
}
/**
* Validates that a URL is safe to use as an outbound service endpoint.
* Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF.
*/
function isValidServiceUrl(raw: string): boolean {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const hostname = parsed.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '::1') {
return false;
}
const ipv4Parts = hostname.split('.');
if (ipv4Parts.length === 4) {
const [a, b] = ipv4Parts.map(Number);
if (
a === 127 ||
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
) {
return false;
}
}
return true;
}
class ToolConfigController {
async getConfig(req: Request, res: Response): Promise<void> {
const tool = req.params.tool as string;
if (!TOOL_SETTINGS_KEYS[tool]) {
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
return;
}
try {
let config: Record<string, string>;
if (tool === 'bookstack') {
config = await toolConfigService.getBookstackConfig() as unknown as Record<string, string>;
} else if (tool === 'vikunja') {
config = await toolConfigService.getVikunjaConfig() as unknown as Record<string, string>;
} else {
config = await toolConfigService.getNextcloudConfig() as unknown as Record<string, string>;
}
res.status(200).json({ success: true, data: maskConfig(config) });
} catch (error) {
logger.error('ToolConfigController.getConfig error', { error, tool });
res.status(500).json({ success: false, message: 'Konfiguration konnte nicht geladen werden' });
}
}
async updateConfig(req: Request, res: Response): Promise<void> {
const tool = req.params.tool as string;
const settingsKey = TOOL_SETTINGS_KEYS[tool];
if (!settingsKey) {
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
return;
}
try {
const userId = req.user!.id;
await settingsService.set(settingsKey, req.body, userId);
toolConfigService.clearCache();
res.status(200).json({ success: true, message: 'Konfiguration gespeichert' });
} catch (error) {
logger.error('ToolConfigController.updateConfig error', { error, tool });
res.status(500).json({ success: false, message: 'Konfiguration konnte nicht gespeichert werden' });
}
}
async testConnection(req: Request, res: Response): Promise<void> {
const tool = req.params.tool as string;
if (!TOOL_SETTINGS_KEYS[tool]) {
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
return;
}
try {
let url: string;
let requestConfig: { headers?: Record<string, string> } = {};
if (tool === 'bookstack') {
const current = await toolConfigService.getBookstackConfig();
const merged = { ...current, ...req.body };
url = merged.url;
if (!url || !isValidServiceUrl(url)) {
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
return;
}
requestConfig.headers = {
'Authorization': `Token ${merged.tokenId}:${merged.tokenSecret}`,
'Content-Type': 'application/json',
};
url = `${url}/api/books?count=1`;
} else if (tool === 'vikunja') {
const current = await toolConfigService.getVikunjaConfig();
const merged = { ...current, ...req.body };
url = merged.url;
if (!url || !isValidServiceUrl(url)) {
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
return;
}
requestConfig.headers = {
'Authorization': `Bearer ${merged.apiToken}`,
'Content-Type': 'application/json',
};
url = `${url}/api/v1/info`;
} else {
const current = await toolConfigService.getNextcloudConfig();
const merged = { ...current, ...req.body };
url = merged.url;
if (!url || !isValidServiceUrl(url)) {
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
return;
}
url = `${url}/status.php`;
}
const start = Date.now();
await httpClient.get(url, { ...requestConfig, timeout: 10_000 });
const latencyMs = Date.now() - start;
res.status(200).json({ success: true, message: 'Verbindung erfolgreich', latencyMs });
} catch (error: any) {
const latencyMs = 0;
const status = error?.response?.status;
const message = status
? `Verbindung fehlgeschlagen (HTTP ${status})`
: 'Verbindung fehlgeschlagen';
logger.error('ToolConfigController.testConnection error', { error, tool });
res.status(200).json({ success: false, message, latencyMs });
}
}
}
export default new ToolConfigController();

View File

@@ -0,0 +1,29 @@
-- =============================================================================
-- Migration 092: Tool Configure Permissions + Nextcloud Feature Group
-- =============================================================================
-- Feature group: nextcloud
INSERT INTO feature_groups (id, label, sort_order)
VALUES ('nextcloud', 'Nextcloud', 11)
ON CONFLICT (id) DO NOTHING;
-- Configure permissions for wissen, vikunja, nextcloud
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('wissen:configure', 'wissen', 'Konfigurieren', 'BookStack-Verbindung konfigurieren', 10),
('vikunja:configure', 'vikunja', 'Konfigurieren', 'Vikunja-Verbindung konfigurieren', 10),
('nextcloud:configure', 'nextcloud', 'Konfigurieren', 'Nextcloud-Verbindung konfigurieren', 1)
ON CONFLICT (id) DO NOTHING;
-- Grant all 3 configure permissions to dashboard_kommando
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_kommando', 'wissen:configure'),
('dashboard_kommando', 'vikunja:configure'),
('dashboard_kommando', 'nextcloud:configure')
ON CONFLICT DO NOTHING;
-- Seed default app_settings for tool configs
INSERT INTO app_settings (key, value) VALUES
('tool_config_bookstack', '{}'::jsonb),
('tool_config_vikunja', '{}'::jsonb),
('tool_config_nextcloud', '{}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -0,0 +1,15 @@
-- =============================================================================
-- Migration 093: Module Configure Permissions (kalender, fahrzeuge)
-- =============================================================================
-- Configure permissions for kalender, fahrzeuge
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('kalender:configure', 'kalender', 'Konfigurieren', 'Kalender-Kategorien verwalten', 10),
('fahrzeuge:configure', 'fahrzeuge', 'Konfigurieren', 'Fahrzeugtypen verwalten', 10)
ON CONFLICT (id) DO NOTHING;
-- Grant both configure permissions to dashboard_kommando
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_kommando', 'kalender:configure'),
('dashboard_kommando', 'fahrzeuge:configure')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,32 @@
import { Router, Request, Response, NextFunction } from 'express';
import toolConfigController from '../controllers/toolConfig.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
const TOOL_PERMISSION_MAP: Record<string, string> = {
bookstack: 'wissen:configure',
vikunja: 'vikunja:configure',
nextcloud: 'nextcloud:configure',
};
/**
* Middleware that resolves the permission from the :tool param
* and delegates to requirePermission().
*/
function requireToolPermission(req: Request, res: Response, next: NextFunction): void {
const tool = req.params.tool as string;
const permission = TOOL_PERMISSION_MAP[tool];
if (!permission) {
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
return;
}
requirePermission(permission)(req, res, next);
}
router.get('/config/:tool', authenticate, requireToolPermission, toolConfigController.getConfig.bind(toolConfigController));
router.put('/config/:tool', authenticate, requireToolPermission, toolConfigController.updateConfig.bind(toolConfigController));
router.post('/config/:tool/test', authenticate, requireToolPermission, toolConfigController.testConnection.bind(toolConfigController));
export default router;

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
import httpClient from '../config/httpClient';
import environment from '../config/environment';
import toolConfigService, { BookstackConfig } from './toolConfig.service';
import logger from '../utils/logger';
export interface BookStackPage {
@@ -74,10 +74,9 @@ function isValidServiceUrl(raw: string): boolean {
return true;
}
function buildHeaders(): Record<string, string> {
const { bookstack } = environment;
function buildHeaders(config: BookstackConfig): Record<string, string> {
return {
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
'Authorization': `Token ${config.tokenId}:${config.tokenSecret}`,
'Content-Type': 'application/json',
};
}
@@ -95,11 +94,11 @@ async function getBookSlugMap(): Promise<Map<number, string>> {
if (bookSlugMapCache && Date.now() < bookSlugMapCache.expiresAt) {
return bookSlugMapCache.map;
}
const { bookstack } = environment;
const config = await toolConfigService.getBookstackConfig();
try {
const response = await httpClient.get(
`${bookstack.url}/api/books`,
{ params: { count: 500 }, headers: buildHeaders() },
`${config.url}/api/books`,
{ params: { count: 500 }, headers: buildHeaders(config) },
);
const books: Array<{ id: number; slug: string }> = response.data?.data ?? [];
const map = new Map(books.map((b) => [b.id, b.slug]));
@@ -111,18 +110,18 @@ async function getBookSlugMap(): Promise<Map<number, string>> {
}
async function getRecentPages(): Promise<BookStackPage[]> {
const { bookstack } = environment;
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
const config = await toolConfigService.getBookstackConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
}
try {
const [response, bookSlugMap] = await Promise.all([
httpClient.get(
`${bookstack.url}/api/pages`,
`${config.url}/api/pages`,
{
params: { sort: '-updated_at', count: 20 },
headers: buildHeaders(),
headers: buildHeaders(config),
},
),
getBookSlugMap(),
@@ -130,7 +129,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
const pages: BookStackPage[] = response.data?.data ?? [];
return pages.map((p) => ({
...p,
url: `${bookstack.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`,
url: `${config.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`,
}));
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -145,17 +144,17 @@ async function getRecentPages(): Promise<BookStackPage[]> {
}
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
const { bookstack } = environment;
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
const config = await toolConfigService.getBookstackConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get(
`${bookstack.url}/api/search`,
`${config.url}/api/search`,
{
params: { query, count: 50 },
headers: buildHeaders(),
headers: buildHeaders(config),
},
);
const bookSlugMap = await getBookSlugMap();
@@ -167,7 +166,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
slug: item.slug,
book_id: item.book_id ?? 0,
book_slug: item.book_slug ?? '',
url: `${bookstack.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
url: `${config.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
preview_html: item.preview_html ?? { content: '' },
tags: item.tags ?? [],
}));
@@ -201,16 +200,16 @@ export interface BookStackPageDetail {
}
async function getPageById(id: number): Promise<BookStackPageDetail> {
const { bookstack } = environment;
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
const config = await toolConfigService.getBookstackConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
}
try {
const [response, bookSlugMap] = await Promise.all([
httpClient.get(
`${bookstack.url}/api/pages/${id}`,
{ headers: buildHeaders() },
`${config.url}/api/pages/${id}`,
{ headers: buildHeaders(config) },
),
getBookSlugMap(),
]);
@@ -226,7 +225,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
html: page.html ?? '',
created_at: page.created_at,
updated_at: page.updated_at,
url: `${bookstack.url}/books/${bookSlug}/page/${page.slug}`,
url: `${config.url}/books/${bookSlug}/page/${page.slug}`,
book: page.book,
createdBy: page.created_by,
updatedBy: page.updated_by,

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
import httpClient from '../config/httpClient';
import environment from '../config/environment';
import toolConfigService from './toolConfig.service';
import logger from '../utils/logger';
interface NextcloudLastMessage {
@@ -77,7 +77,7 @@ function isValidServiceUrl(raw: string): boolean {
}
async function initiateLoginFlow(): Promise<LoginFlowResult> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -145,7 +145,7 @@ interface NextcloudChatMessage {
}
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -206,7 +206,7 @@ interface GetMessagesOptions {
}
async function getMessages(token: string, loginName: string, appPassword: string, options?: GetMessagesOptions): Promise<NextcloudChatMessage[]> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -294,7 +294,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
}
async function sendMessage(token: string, message: string, loginName: string, appPassword: string, replyTo?: number): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -331,7 +331,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap
}
async function markAsRead(token: string, loginName: string, appPassword: string): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -368,7 +368,7 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
}
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -441,7 +441,7 @@ async function uploadFileToTalk(
loginName: string,
appPassword: string,
): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -503,7 +503,7 @@ async function downloadFile(
loginName: string,
appPassword: string,
): Promise<any> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -544,7 +544,7 @@ async function getFilePreview(
loginName: string,
appPassword: string,
): Promise<any> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -580,7 +580,7 @@ async function getFilePreview(
}
async function searchUsers(query: string, loginName: string, appPassword: string): Promise<any[]> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -616,7 +616,7 @@ async function searchUsers(query: string, loginName: string, appPassword: string
}
async function createRoom(roomType: number, invite: string, roomName: string | undefined, loginName: string, appPassword: string): Promise<{ token: string }> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -654,7 +654,7 @@ async function createRoom(roomType: number, invite: string, roomName: string | u
}
async function addReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -691,7 +691,7 @@ async function addReaction(token: string, messageId: number, reaction: string, l
}
async function removeReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -727,7 +727,7 @@ async function removeReaction(token: string, messageId: number, reaction: string
}
async function getReactions(token: string, messageId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
@@ -763,7 +763,7 @@ async function getReactions(token: string, messageId: number, loginName: string,
}
async function getPollDetails(token: string, pollId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
const baseUrl = environment.nextcloudUrl;
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}

View File

@@ -0,0 +1,92 @@
import settingsService from './settings.service';
import environment from '../config/environment';
export interface BookstackConfig {
url: string;
tokenId: string;
tokenSecret: string;
}
export interface VikunjaConfig {
url: string;
apiToken: string;
}
export interface NextcloudConfig {
url: string;
}
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const CACHE_TTL_MS = 60_000; // 60 seconds
let bookstackCache: CacheEntry<BookstackConfig> | null = null;
let vikunjaCache: CacheEntry<VikunjaConfig> | null = null;
let nextcloudCache: CacheEntry<NextcloudConfig> | null = null;
async function getDbConfig(key: string): Promise<Record<string, string>> {
const setting = await settingsService.get(key);
if (!setting?.value || typeof setting.value !== 'object') return {};
return setting.value as Record<string, string>;
}
async function getBookstackConfig(): Promise<BookstackConfig> {
if (bookstackCache && Date.now() < bookstackCache.expiresAt) {
return bookstackCache.data;
}
const db = await getDbConfig('tool_config_bookstack');
const config: BookstackConfig = {
url: db.url || environment.bookstack.url,
tokenId: db.tokenId || environment.bookstack.tokenId,
tokenSecret: db.tokenSecret || environment.bookstack.tokenSecret,
};
bookstackCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
return config;
}
async function getVikunjaConfig(): Promise<VikunjaConfig> {
if (vikunjaCache && Date.now() < vikunjaCache.expiresAt) {
return vikunjaCache.data;
}
const db = await getDbConfig('tool_config_vikunja');
const config: VikunjaConfig = {
url: db.url || environment.vikunja.url,
apiToken: db.apiToken || environment.vikunja.apiToken,
};
vikunjaCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
return config;
}
async function getNextcloudConfig(): Promise<NextcloudConfig> {
if (nextcloudCache && Date.now() < nextcloudCache.expiresAt) {
return nextcloudCache.data;
}
const db = await getDbConfig('tool_config_nextcloud');
const config: NextcloudConfig = {
url: db.url || environment.nextcloudUrl,
};
nextcloudCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
return config;
}
function clearCache(): void {
bookstackCache = null;
vikunjaCache = null;
nextcloudCache = null;
}
export default {
getBookstackConfig,
getVikunjaConfig,
getNextcloudConfig,
clearCache,
};

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
import httpClient from '../config/httpClient';
import environment from '../config/environment';
import toolConfigService, { VikunjaConfig } from './toolConfig.service';
import logger from '../utils/logger';
export interface VikunjaTask {
@@ -58,23 +58,23 @@ function isValidServiceUrl(raw: string): boolean {
return true;
}
function buildHeaders(): Record<string, string> {
function buildHeaders(config: VikunjaConfig): Record<string, string> {
return {
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
'Authorization': `Bearer ${config.apiToken}`,
'Content-Type': 'application/json',
};
}
async function getMyTasks(): Promise<VikunjaTask[]> {
const { vikunja } = environment;
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
const config = await toolConfigService.getVikunjaConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get<VikunjaTask[]>(
`${vikunja.url}/api/v1/tasks/all`,
{ headers: buildHeaders() },
`${config.url}/api/v1/tasks/all`,
{ headers: buildHeaders(config) },
);
return (response.data ?? []).filter((t) => !t.done);
} catch (error) {
@@ -99,15 +99,15 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
}
async function getProjects(): Promise<VikunjaProject[]> {
const { vikunja } = environment;
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
const config = await toolConfigService.getVikunjaConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
}
try {
const response = await httpClient.get<VikunjaProject[]>(
`${vikunja.url}/api/v1/projects`,
{ headers: buildHeaders() },
`${config.url}/api/v1/projects`,
{ headers: buildHeaders(config) },
);
return response.data ?? [];
} catch (error) {
@@ -123,8 +123,8 @@ async function getProjects(): Promise<VikunjaProject[]> {
}
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
const { vikunja } = environment;
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
const config = await toolConfigService.getVikunjaConfig();
if (!config.url || !isValidServiceUrl(config.url)) {
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
}
@@ -134,9 +134,9 @@ async function createTask(projectId: number, title: string, dueDate?: string): P
body.due_date = dueDate;
}
const response = await httpClient.put<VikunjaTask>(
`${vikunja.url}/api/v1/projects/${projectId}/tasks`,
`${config.url}/api/v1/projects/${projectId}/tasks`,
body,
{ headers: buildHeaders() },
{ headers: buildHeaders(config) },
);
return response.data;
} catch (error) {

View File

@@ -0,0 +1,237 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { Add as AddIcon, Delete, Edit } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ausruestungTypenApi, AusruestungTyp } from '../../services/ausruestungTypen';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import { ConfirmDialog, FormDialog } from '../templates';
export default function ModuleSettingsAusruestung() {
const { isFeatureEnabled } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const { data: typen = [], isLoading, isError } = useQuery({
queryKey: ['ausruestungTypen'],
queryFn: ausruestungTypenApi.getAll,
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTyp, setEditingTyp] = useState<AusruestungTyp | null>(null);
const [formName, setFormName] = useState('');
const [formBeschreibung, setFormBeschreibung] = useState('');
const [formIcon, setFormIcon] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingTyp, setDeletingTyp] = useState<AusruestungTyp | null>(null);
const createMutation = useMutation({
mutationFn: (data: { name: string; beschreibung?: string; icon?: string }) =>
ausruestungTypenApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ erstellt');
closeDialog();
},
onError: () => showError('Typ konnte nicht erstellt werden'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; beschreibung: string; icon: string }> }) =>
ausruestungTypenApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ aktualisiert');
closeDialog();
},
onError: () => showError('Typ konnte nicht aktualisiert werden'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => ausruestungTypenApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ gelöscht');
setDeleteDialogOpen(false);
setDeletingTyp(null);
},
onError: () => showError('Typ konnte nicht gelöscht werden. Möglicherweise ist er noch Geräten zugeordnet.'),
});
const openAddDialog = () => {
setEditingTyp(null);
setFormName('');
setFormBeschreibung('');
setFormIcon('');
setDialogOpen(true);
};
const openEditDialog = (typ: AusruestungTyp) => {
setEditingTyp(typ);
setFormName(typ.name);
setFormBeschreibung(typ.beschreibung ?? '');
setFormIcon(typ.icon ?? '');
setDialogOpen(true);
};
const closeDialog = () => {
setDialogOpen(false);
setEditingTyp(null);
};
const handleSave = () => {
if (!formName.trim()) return;
const data = {
name: formName.trim(),
beschreibung: formBeschreibung.trim() || undefined,
icon: formIcon.trim() || undefined,
};
if (editingTyp) {
updateMutation.mutate({ id: editingTyp.id, data });
} else {
createMutation.mutate(data);
}
};
const openDeleteDialog = (typ: AusruestungTyp) => {
setDeletingTyp(typ);
setDeleteDialogOpen(true);
};
const isSaving = createMutation.isPending || updateMutation.isPending;
if (!isFeatureEnabled('ausruestung')) {
return <Alert severity="warning">Im Wartungsmodus</Alert>;
}
return (
<Box>
<Typography variant="h6" gutterBottom>Ausrüstungstypen</Typography>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
)}
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Typen konnten nicht geladen werden.
</Alert>
)}
{!isLoading && !isError && (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{typen.length === 0 && (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Noch keine Typen vorhanden.
</Typography>
</TableCell>
</TableRow>
)}
{typen.map((typ) => (
<TableRow key={typ.id}>
<TableCell>{typ.name}</TableCell>
<TableCell>{typ.beschreibung || '---'}</TableCell>
<TableCell>{typ.icon || '---'}</TableCell>
<TableCell align="right">
<Tooltip title="Bearbeiten">
<IconButton size="small" onClick={() => openEditDialog(typ)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Löschen">
<IconButton size="small" color="error" onClick={() => openDeleteDialog(typ)}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" startIcon={<AddIcon />} onClick={openAddDialog}>
Neuer Typ
</Button>
</Box>
</Paper>
)}
{/* Add/Edit dialog */}
<FormDialog
open={dialogOpen}
onClose={closeDialog}
onSubmit={handleSave}
title={editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
isSubmitting={isSaving}
>
<TextField
label="Name *"
fullWidth
value={formName}
onChange={(e) => setFormName(e.target.value)}
inputProps={{ maxLength: 100 }}
/>
<TextField
label="Beschreibung"
fullWidth
multiline
rows={2}
value={formBeschreibung}
onChange={(e) => setFormBeschreibung(e.target.value)}
/>
<TextField
label="Icon (MUI Icon-Name)"
fullWidth
value={formIcon}
onChange={(e) => setFormIcon(e.target.value)}
placeholder="z.B. Build, LocalFireDepartment"
/>
</FormDialog>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
title="Typ löschen"
message={<>Möchten Sie den Typ &quot;{deletingTyp?.name}&quot; wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.</>}
confirmLabel="Löschen"
confirmColor="error"
isLoading={deleteMutation.isPending}
/>
</Box>
);
}

View File

@@ -0,0 +1,313 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Paper,
Skeleton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { Add, Delete, Edit, Save, Close } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { kategorieApi } from '../../services/bookings';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import type { BuchungsKategorie } from '../../types/booking.types';
export default function ModuleSettingsFahrzeugbuchungen() {
const { isFeatureEnabled } = usePermissionContext();
const notification = useNotification();
const queryClient = useQueryClient();
const { data: kategorien = [], isLoading, isError } = useQuery({
queryKey: ['buchungskategorien-all'],
queryFn: kategorieApi.getAll,
});
const [editRowId, setEditRowId] = useState<number | null>(null);
const [editRowData, setEditRowData] = useState<Partial<BuchungsKategorie>>({});
const [newKatDialog, setNewKatDialog] = useState(false);
const [newKatForm, setNewKatForm] = useState({ bezeichnung: '', farbe: '#1976d2' });
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
};
const createKatMutation = useMutation({
mutationFn: (data: Omit<BuchungsKategorie, 'id'>) => kategorieApi.create(data),
onSuccess: () => {
notification.showSuccess('Kategorie erstellt');
invalidate();
setNewKatDialog(false);
setNewKatForm({ bezeichnung: '', farbe: '#1976d2' });
},
onError: () => notification.showError('Fehler beim Erstellen'),
});
const updateKatMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BuchungsKategorie> }) =>
kategorieApi.update(id, data),
onSuccess: () => {
notification.showSuccess('Kategorie aktualisiert');
invalidate();
setEditRowId(null);
},
onError: () => notification.showError('Fehler beim Aktualisieren'),
});
const deleteKatMutation = useMutation({
mutationFn: (id: number) => kategorieApi.delete(id),
onSuccess: () => {
notification.showSuccess('Kategorie deaktiviert');
invalidate();
},
onError: () => notification.showError('Fehler beim Löschen'),
});
if (!isFeatureEnabled('fahrzeugbuchungen')) {
return <Alert severity="warning">Im Wartungsmodus</Alert>;
}
if (isLoading) {
return (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" height={200} />
</Box>
);
}
if (isError) {
return <Alert severity="error">Fehler beim Laden der Buchungskategorien</Alert>;
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Buchungskategorien</Typography>
<Button
startIcon={<Add />}
variant="contained"
size="small"
onClick={() => setNewKatDialog(true)}
>
Neue Kategorie
</Button>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Sortierung</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kategorien.map((kat) => {
const isEditing = editRowId === kat.id;
return (
<TableRow key={kat.id}>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editRowData.bezeichnung ?? kat.bezeichnung}
onChange={(e) =>
setEditRowData((d) => ({ ...d, bezeichnung: e.target.value }))
}
/>
) : (
kat.bezeichnung
)}
</TableCell>
<TableCell>
{isEditing ? (
<input
type="color"
value={editRowData.farbe ?? kat.farbe}
onChange={(e) =>
setEditRowData((d) => ({ ...d, farbe: e.target.value }))
}
style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }}
/>
) : (
<Box
sx={{
width: 24,
height: 24,
borderRadius: 1,
bgcolor: kat.farbe,
border: '1px solid',
borderColor: 'divider',
}}
/>
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
type="number"
value={editRowData.sort_order ?? kat.sort_order}
onChange={(e) =>
setEditRowData((d) => ({
...d,
sort_order: parseInt(e.target.value) || 0,
}))
}
sx={{ width: 80 }}
/>
) : (
kat.sort_order
)}
</TableCell>
<TableCell>
{isEditing ? (
<Checkbox
checked={editRowData.aktiv ?? kat.aktiv}
onChange={(e) =>
setEditRowData((d) => ({ ...d, aktiv: e.target.checked }))
}
/>
) : (
<Checkbox checked={kat.aktiv} disabled />
)}
</TableCell>
<TableCell align="right">
{isEditing ? (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton
size="small"
color="primary"
onClick={() =>
updateKatMutation.mutate({ id: kat.id, data: editRowData })
}
>
<Save fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setEditRowId(null);
setEditRowData({});
}}
>
<Close fontSize="small" />
</IconButton>
</Stack>
) : (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton
size="small"
onClick={() => {
setEditRowId(kat.id);
setEditRowData({
bezeichnung: kat.bezeichnung,
farbe: kat.farbe,
sort_order: kat.sort_order,
aktiv: kat.aktiv,
});
}}
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteKatMutation.mutate(kat.id)}
>
<Delete fontSize="small" />
</IconButton>
</Stack>
)}
</TableCell>
</TableRow>
);
})}
{kategorien.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Keine Kategorien vorhanden
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* New category dialog */}
<Dialog
open={newKatDialog}
onClose={() => setNewKatDialog(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
fullWidth
size="small"
label="Bezeichnung"
required
value={newKatForm.bezeichnung}
onChange={(e) =>
setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value }))
}
/>
<Box>
<Typography variant="body2" sx={{ mb: 0.5 }}>
Farbe
</Typography>
<input
type="color"
value={newKatForm.farbe}
onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))}
style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }}
/>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewKatDialog(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={() =>
createKatMutation.mutate({
bezeichnung: newKatForm.bezeichnung,
farbe: newKatForm.farbe,
aktiv: true,
sort_order: kategorien.length,
})
}
disabled={!newKatForm.bezeichnung || createKatMutation.isPending}
>
{createKatMutation.isPending ? <CircularProgress size={20} /> : 'Erstellen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,199 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fahrzeugTypenApi } from '../../services/fahrzeugTypen';
import type { FahrzeugTyp } from '../../types/checklist.types';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import { FormDialog } from '../templates';
export default function ModuleSettingsFahrzeuge() {
const { isFeatureEnabled } = usePermissionContext();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: fahrzeugTypen = [], isLoading } = useQuery({
queryKey: ['fahrzeug-typen'],
queryFn: fahrzeugTypenApi.getAll,
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<FahrzeugTyp | null>(null);
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
const [deleteError, setDeleteError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: Partial<FahrzeugTyp>) => fahrzeugTypenApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDialogOpen(false);
showSuccess('Fahrzeugtyp erstellt');
},
onError: () => showError('Fehler beim Erstellen'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<FahrzeugTyp> }) =>
fahrzeugTypenApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDialogOpen(false);
showSuccess('Fahrzeugtyp aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => fahrzeugTypenApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDeleteError(null);
showSuccess('Fahrzeugtyp gelöscht');
},
onError: (err: any) => {
const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.';
setDeleteError(msg);
},
});
const openCreate = () => {
setEditing(null);
setForm({ name: '', beschreibung: '', icon: '' });
setDialogOpen(true);
};
const openEdit = (t: FahrzeugTyp) => {
setEditing(t);
setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' });
setDialogOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) return;
if (editing) {
updateMutation.mutate({ id: editing.id, data: form });
} else {
createMutation.mutate(form);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
if (!isFeatureEnabled('fahrzeuge')) {
return <Alert severity="warning">Im Wartungsmodus</Alert>;
}
return (
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>
Fahrzeugtypen
</Typography>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError(null)}>
{deleteError}
</Alert>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
Neuer Fahrzeugtyp
</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fahrzeugTypen.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
Keine Fahrzeugtypen vorhanden
</TableCell>
</TableRow>
) : (
fahrzeugTypen.map((t) => (
<TableRow key={t.id} hover>
<TableCell>{t.name}</TableCell>
<TableCell>{t.beschreibung ?? ''}</TableCell>
<TableCell>{t.icon ?? ''}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(t)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(t.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</>
)}
<FormDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onSubmit={handleSubmit}
title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
isSubmitting={isSaving}
>
<TextField
label="Name *"
fullWidth
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<TextField
label="Beschreibung"
fullWidth
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
<TextField
label="Icon"
fullWidth
value={form.icon}
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
placeholder="z.B. fire_truck"
/>
</FormDialog>
</Box>
);
}

View File

@@ -0,0 +1,388 @@
import React, { useState, useMemo } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
FormControlLabel,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Edit as EditIcon,
BugReport,
FiberNew,
HelpOutline,
DragIndicator,
Check as CheckIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { issuesApi } from '../../services/issues';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import { FormDialog } from '../templates';
import type { IssueTyp, IssueStatusDef, IssuePriorityDef } from '../../types/issue.types';
// ── Shared color picker helpers ──
const MUI_CHIP_COLORS = ['default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'] as const;
const MUI_THEME_COLORS: Record<string, string> = { default: '#9e9e9e', primary: '#1976d2', secondary: '#9c27b0', error: '#d32f2f', info: '#0288d1', success: '#2e7d32', warning: '#ed6c02', action: '#757575' };
const ICON_COLORS = ['action', 'error', 'info', 'success', 'warning', 'primary', 'secondary'] as const;
const ICON_MAP: Record<string, JSX.Element> = {
BugReport: <BugReport fontSize="small" />,
FiberNew: <FiberNew fontSize="small" />,
HelpOutline: <HelpOutline fontSize="small" />,
};
function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element {
const icon = ICON_MAP[iconName || ''] || <HelpOutline fontSize="small" />;
const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action';
return <Box component="span" sx={{ display: 'inline-flex', color: `${colorProp}.main` }}>{icon}</Box>;
}
function ColorSwatch({ colors, value, onChange }: { colors: readonly string[]; value: string; onChange: (v: string) => void }) {
return (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
{colors.map((c) => (
<Box
key={c}
onClick={() => onChange(c)}
sx={{
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer',
bgcolor: MUI_THEME_COLORS[c] ?? c,
border: value === c ? '2.5px solid' : '2px solid transparent',
borderColor: value === c ? 'text.primary' : 'transparent',
'&:hover': { opacity: 0.8 },
}}
/>
))}
</Box>
);
}
function HexColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
component="input"
type="color"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
sx={{ width: 28, height: 28, border: 'none', borderRadius: 1, cursor: 'pointer', p: 0, bgcolor: 'transparent' }}
/>
<Typography variant="caption" color="text.secondary">{value}</Typography>
</Box>
);
}
// ── Main Component ──
export default function ModuleSettingsIssues() {
const { isFeatureEnabled } = usePermissionContext();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
// ── Status state ──
const [statusCreateOpen, setStatusCreateOpen] = useState(false);
const [statusCreateData, setStatusCreateData] = useState<Partial<IssueStatusDef>>({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 });
const [statusEditId, setStatusEditId] = useState<number | null>(null);
const [statusEditData, setStatusEditData] = useState<Partial<IssueStatusDef>>({});
// ── Priority state ──
const [prioCreateOpen, setPrioCreateOpen] = useState(false);
const [prioCreateData, setPrioCreateData] = useState<Partial<IssuePriorityDef>>({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 });
const [prioEditId, setPrioEditId] = useState<number | null>(null);
const [prioEditData, setPrioEditData] = useState<Partial<IssuePriorityDef>>({});
// ── Kategorien state ──
const [typeCreateOpen, setTypeCreateOpen] = useState(false);
const [typeCreateData, setTypeCreateData] = useState<Partial<IssueTyp>>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true });
const [typeEditId, setTypeEditId] = useState<number | null>(null);
const [typeEditData, setTypeEditData] = useState<Partial<IssueTyp>>({});
// ── Queries ──
const { data: issueStatuses = [], isLoading: statusLoading } = useQuery({ queryKey: ['issue-statuses'], queryFn: issuesApi.getStatuses });
const { data: issuePriorities = [], isLoading: prioLoading } = useQuery({ queryKey: ['issue-priorities'], queryFn: issuesApi.getPriorities });
const { data: types = [], isLoading: typesLoading } = useQuery({ queryKey: ['issue-types'], queryFn: issuesApi.getTypes });
// ── Status mutations ──
const createStatusMut = useMutation({
mutationFn: (data: Partial<IssueStatusDef>) => issuesApi.createStatus(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status erstellt'); setStatusCreateOpen(false); setStatusCreateData({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 }); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateStatusMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IssueStatusDef> }) => issuesApi.updateStatus(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status aktualisiert'); setStatusEditId(null); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteStatusMut = useMutation({
mutationFn: (id: number) => issuesApi.deleteStatus(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status deaktiviert'); },
onError: () => showError('Fehler beim Deaktivieren'),
});
// ── Priority mutations ──
const createPrioMut = useMutation({
mutationFn: (data: Partial<IssuePriorityDef>) => issuesApi.createPriority(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität erstellt'); setPrioCreateOpen(false); setPrioCreateData({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 }); },
onError: () => showError('Fehler beim Erstellen'),
});
const updatePrioMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IssuePriorityDef> }) => issuesApi.updatePriority(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität aktualisiert'); setPrioEditId(null); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deletePrioMut = useMutation({
mutationFn: (id: number) => issuesApi.deletePriority(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität deaktiviert'); },
onError: () => showError('Fehler beim Deaktivieren'),
});
// ── Type mutations ──
const createTypeMut = useMutation({
mutationFn: (data: Partial<IssueTyp>) => issuesApi.createType(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie erstellt'); setTypeCreateOpen(false); setTypeCreateData({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateTypeMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IssueTyp> }) => issuesApi.updateType(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie aktualisiert'); setTypeEditId(null); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteTypeMut = useMutation({
mutationFn: (id: number) => issuesApi.deleteType(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie deaktiviert'); },
onError: () => showError('Fehler beim Deaktivieren'),
});
const flatTypes = useMemo(() => {
const roots = types.filter(t => !t.parent_id);
const result: { type: IssueTyp; indent: boolean }[] = [];
for (const root of roots) {
result.push({ type: root, indent: false });
for (const child of types.filter(t => t.parent_id === root.id)) result.push({ type: child, indent: true });
}
const listed = new Set(result.map(r => r.type.id));
for (const t of types) { if (!listed.has(t.id)) result.push({ type: t, indent: false }); }
return result;
}, [types]);
if (!isFeatureEnabled('issues')) {
return <Alert severity="warning">Im Wartungsmodus</Alert>;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* ──── Section 1: Status ──── */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Status</Typography>
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setStatusCreateOpen(true)}>Neuer Status</Button>
</Box>
{statusLoading ? <CircularProgress /> : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Schlüssel</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Abschluss</TableCell>
<TableCell>Initial</TableCell>
<TableCell>Sort</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{issueStatuses.length === 0 ? (
<TableRow><TableCell colSpan={8} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Status vorhanden</TableCell></TableRow>
) : issueStatuses.map((s) => (
<TableRow key={s.id}>
{statusEditId === s.id ? (<>
<TableCell><TextField size="small" value={statusEditData.bezeichnung ?? s.bezeichnung} onChange={(e) => setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /></TableCell>
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
<TableCell><ColorSwatch colors={MUI_CHIP_COLORS} value={statusEditData.farbe ?? s.farbe} onChange={(v) => setStatusEditData({ ...statusEditData, farbe: v })} /></TableCell>
<TableCell><Switch checked={statusEditData.ist_abschluss ?? s.ist_abschluss} onChange={(e) => setStatusEditData({ ...statusEditData, ist_abschluss: e.target.checked })} size="small" /></TableCell>
<TableCell><Switch checked={statusEditData.ist_initial ?? s.ist_initial} onChange={(e) => setStatusEditData({ ...statusEditData, ist_initial: e.target.checked })} size="small" /></TableCell>
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={statusEditData.sort_order ?? s.sort_order} onChange={(e) => setStatusEditData({ ...statusEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
<TableCell><Switch checked={statusEditData.aktiv ?? s.aktiv} onChange={(e) => setStatusEditData({ ...statusEditData, aktiv: e.target.checked })} size="small" /></TableCell>
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updateStatusMut.mutate({ id: s.id, data: statusEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setStatusEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box></TableCell>
</>) : (<>
<TableCell><Chip label={s.bezeichnung} color={s.farbe as any} size="small" /></TableCell>
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
<TableCell><Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: MUI_THEME_COLORS[s.farbe] ?? s.farbe }} /></TableCell>
<TableCell>{s.ist_abschluss ? '\u2713' : '-'}</TableCell>
<TableCell>{s.ist_initial ? '\u2713' : '-'}</TableCell>
<TableCell>{s.sort_order}</TableCell>
<TableCell><Switch checked={s.aktiv} onChange={(e) => updateStatusMut.mutate({ id: s.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setStatusEditId(s.id); setStatusEditData({ bezeichnung: s.bezeichnung, farbe: s.farbe, ist_abschluss: s.ist_abschluss, ist_initial: s.ist_initial, benoetigt_typ_freigabe: s.benoetigt_typ_freigabe, sort_order: s.sort_order, aktiv: s.aktiv }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deleteStatusMut.mutate(s.id)}><DeleteIcon fontSize="small" /></IconButton></Box></TableCell>
</>)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* ──── Section 2: Prioritäten ──── */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Prioritäten</Typography>
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setPrioCreateOpen(true)}>Neue Priorität</Button>
</Box>
{prioLoading ? <CircularProgress /> : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Schlüssel</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Sort</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{issuePriorities.length === 0 ? (
<TableRow><TableCell colSpan={6} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Prioritäten vorhanden</TableCell></TableRow>
) : issuePriorities.map((p) => (
<TableRow key={p.id}>
{prioEditId === p.id ? (<>
<TableCell><TextField size="small" value={prioEditData.bezeichnung ?? p.bezeichnung} onChange={(e) => setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /></TableCell>
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
<TableCell><HexColorInput value={prioEditData.farbe ?? p.farbe} onChange={(v) => setPrioEditData({ ...prioEditData, farbe: v })} /></TableCell>
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={prioEditData.sort_order ?? p.sort_order} onChange={(e) => setPrioEditData({ ...prioEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
<TableCell><Switch checked={prioEditData.aktiv ?? p.aktiv} onChange={(e) => setPrioEditData({ ...prioEditData, aktiv: e.target.checked })} size="small" /></TableCell>
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updatePrioMut.mutate({ id: p.id, data: prioEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setPrioEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box></TableCell>
</>) : (<>
<TableCell><Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: p.farbe }} /><Typography variant="body2">{p.bezeichnung}</Typography></Box></TableCell>
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
<TableCell><Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: p.farbe, border: '1px solid', borderColor: 'divider' }} /></TableCell>
<TableCell>{p.sort_order}</TableCell>
<TableCell><Switch checked={p.aktiv} onChange={(e) => updatePrioMut.mutate({ id: p.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deletePrioMut.mutate(p.id)}><DeleteIcon fontSize="small" /></IconButton></Box></TableCell>
</>)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* ──── Section 3: Kategorien ──── */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Kategorien</Typography>
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setTypeCreateOpen(true)}>Neue Kategorie</Button>
</Box>
{typesLoading ? <CircularProgress /> : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Icon</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Abgelehnt</TableCell>
<TableCell>Sort</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{flatTypes.length === 0 ? (
<TableRow><TableCell colSpan={7} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Kategorien vorhanden</TableCell></TableRow>
) : flatTypes.map(({ type: t, indent }) => (
<TableRow key={t.id} sx={indent ? { bgcolor: 'action.hover' } : undefined}>
<TableCell><Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{indent && <DragIndicator fontSize="small" sx={{ opacity: 0.3, ml: 2 }} />}{typeEditId === t.id ? <TextField size="small" value={typeEditData.name || ''} onChange={(e) => setTypeEditData({ ...typeEditData, name: e.target.value })} /> : <Typography variant="body2">{t.name}</Typography>}</Box></TableCell>
<TableCell>{typeEditId === t.id ? (<Select size="small" value={typeEditData.icon || 'HelpOutline'} onChange={(e) => setTypeEditData({ ...typeEditData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select>) : <Box sx={{ display: 'inline-flex' }}>{getTypIcon(t.icon, t.farbe)}</Box>}</TableCell>
<TableCell>{typeEditId === t.id ? <ColorSwatch colors={ICON_COLORS} value={typeEditData.farbe || 'action'} onChange={(v) => setTypeEditData({ ...typeEditData, farbe: v })} /> : <Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: MUI_THEME_COLORS[t.farbe || 'action'] ?? '#757575' }} />}</TableCell>
<TableCell>{typeEditId === t.id ? <Switch checked={typeEditData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeEditData({ ...typeEditData, erlaubt_abgelehnt: e.target.checked })} size="small" /> : (t.erlaubt_abgelehnt ? '\u2713' : '-')}</TableCell>
<TableCell>{typeEditId === t.id ? <TextField size="small" type="number" sx={{ width: 60 }} value={typeEditData.sort_order ?? 0} onChange={(e) => setTypeEditData({ ...typeEditData, sort_order: parseInt(e.target.value) || 0 })} /> : t.sort_order}</TableCell>
<TableCell><Switch checked={typeEditId === t.id ? (typeEditData.aktiv ?? t.aktiv) : t.aktiv} onChange={(e) => { if (typeEditId === t.id) setTypeEditData({ ...typeEditData, aktiv: e.target.checked }); else updateTypeMut.mutate({ id: t.id, data: { aktiv: e.target.checked } }); }} size="small" /></TableCell>
<TableCell>{typeEditId === t.id ? (<Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updateTypeMut.mutate({ id: t.id, data: typeEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setTypeEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box>) : (<Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setTypeEditId(t.id); setTypeEditData({ name: t.name, icon: t.icon, farbe: t.farbe, erlaubt_abgelehnt: t.erlaubt_abgelehnt, sort_order: t.sort_order, aktiv: t.aktiv, parent_id: t.parent_id }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deleteTypeMut.mutate(t.id)}><DeleteIcon fontSize="small" /></IconButton></Box>)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* ──── Create Status Dialog ──── */}
<FormDialog
open={statusCreateOpen}
onClose={() => setStatusCreateOpen(false)}
onSubmit={() => createStatusMut.mutate(statusCreateData)}
title="Neuer Status"
submitLabel="Erstellen"
isSubmitting={createStatusMut.isPending}
>
<TextField label="Schlüssel" required fullWidth value={statusCreateData.schluessel || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus />
<TextField label="Bezeichnung" required fullWidth value={statusCreateData.bezeichnung || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} />
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={MUI_CHIP_COLORS} value={statusCreateData.farbe || 'default'} onChange={(v) => setStatusCreateData({ ...statusCreateData, farbe: v })} /></Box>
<TextField label="Sortierung" type="number" value={statusCreateData.sort_order ?? 0} onChange={(e) => setStatusCreateData({ ...statusCreateData, sort_order: parseInt(e.target.value) || 0 })} />
<FormControlLabel control={<Switch checked={statusCreateData.ist_abschluss ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" />
<FormControlLabel control={<Switch checked={statusCreateData.ist_initial ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" />
<FormControlLabel control={<Switch checked={statusCreateData.benoetigt_typ_freigabe ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" />
</FormDialog>
{/* ──── Create Priority Dialog ──── */}
<FormDialog
open={prioCreateOpen}
onClose={() => setPrioCreateOpen(false)}
onSubmit={() => createPrioMut.mutate(prioCreateData)}
title="Neue Priorität"
submitLabel="Erstellen"
isSubmitting={createPrioMut.isPending}
>
<TextField label="Schlüssel" required fullWidth value={prioCreateData.schluessel || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus />
<TextField label="Bezeichnung" required fullWidth value={prioCreateData.bezeichnung || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} />
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><HexColorInput value={prioCreateData.farbe || '#9e9e9e'} onChange={(v) => setPrioCreateData({ ...prioCreateData, farbe: v })} /></Box>
<TextField label="Sortierung" type="number" value={prioCreateData.sort_order ?? 0} onChange={(e) => setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} />
</FormDialog>
{/* ──── Create Kategorie Dialog ──── */}
<FormDialog
open={typeCreateOpen}
onClose={() => setTypeCreateOpen(false)}
onSubmit={() => createTypeMut.mutate(typeCreateData)}
title="Neue Kategorie"
submitLabel="Erstellen"
isSubmitting={createTypeMut.isPending}
>
<TextField label="Name" required fullWidth value={typeCreateData.name || ''} onChange={(e) => setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus />
<FormControl fullWidth><InputLabel>Übergeordnete Kategorie</InputLabel><Select value={typeCreateData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setTypeCreateData({ ...typeCreateData, parent_id: e.target.value ? Number(e.target.value) : null })}><MenuItem value="">Keine</MenuItem>{types.filter(t => !t.parent_id).map(t => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}</Select></FormControl>
<FormControl fullWidth><InputLabel>Icon</InputLabel><Select value={typeCreateData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setTypeCreateData({ ...typeCreateData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select></FormControl>
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={ICON_COLORS} value={typeCreateData.farbe || 'action'} onChange={(v) => setTypeCreateData({ ...typeCreateData, farbe: v })} /></Box>
<FormControlLabel control={<Switch checked={typeCreateData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" />
<TextField label="Sortierung" type="number" value={typeCreateData.sort_order ?? 0} onChange={(e) => setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} />
</FormDialog>
</Box>
);
}

View File

@@ -0,0 +1,191 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Paper,
Skeleton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { Add, DeleteForever as DeleteForeverIcon, Edit as EditIcon } from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { eventsApi } from '../../services/events';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import type { VeranstaltungKategorie } from '../../types/events.types';
export default function ModuleSettingsKalender() {
const { isFeatureEnabled } = usePermissionContext();
const notification = useNotification();
const queryClient = useQueryClient();
const { data: kategorien = [], isLoading, isError } = useQuery({
queryKey: ['kalender-kategorien'],
queryFn: eventsApi.getKategorien,
});
const [editingKat, setEditingKat] = useState<VeranstaltungKategorie | null>(null);
const [newKatOpen, setNewKatOpen] = useState(false);
const [newKatForm, setNewKatForm] = useState({ name: '', farbe: '#1976d2', beschreibung: '' });
const [saving, setSaving] = useState(false);
const reload = () => queryClient.invalidateQueries({ queryKey: ['kalender-kategorien'] });
const handleCreate = async () => {
if (!newKatForm.name.trim()) return;
setSaving(true);
try {
await eventsApi.createKategorie({ name: newKatForm.name.trim(), farbe: newKatForm.farbe, beschreibung: newKatForm.beschreibung || undefined });
notification.showSuccess('Kategorie erstellt');
setNewKatOpen(false);
setNewKatForm({ name: '', farbe: '#1976d2', beschreibung: '' });
reload();
} catch { notification.showError('Fehler beim Erstellen'); }
finally { setSaving(false); }
};
const handleUpdate = async () => {
if (!editingKat) return;
setSaving(true);
try {
await eventsApi.updateKategorie(editingKat.id, { name: editingKat.name, farbe: editingKat.farbe, beschreibung: editingKat.beschreibung ?? undefined });
notification.showSuccess('Kategorie gespeichert');
setEditingKat(null);
reload();
} catch { notification.showError('Fehler beim Speichern'); }
finally { setSaving(false); }
};
const handleDelete = async (id: string) => {
try {
await eventsApi.deleteKategorie(id);
notification.showSuccess('Kategorie gelöscht');
reload();
} catch { notification.showError('Fehler beim Löschen'); }
};
if (!isFeatureEnabled('kalender')) {
return <Alert severity="warning">Im Wartungsmodus</Alert>;
}
if (isLoading) {
return (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" height={200} />
</Box>
);
}
if (isError) {
return <Alert severity="error">Fehler beim Laden der Kategorien</Alert>;
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>Veranstaltungskategorien</Typography>
<Button startIcon={<Add />} variant="contained" size="small" onClick={() => setNewKatOpen(true)}>
Neue Kategorie
</Button>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Farbe</TableCell>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kategorien.map((k) => (
<TableRow key={k.id}>
<TableCell>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', bgcolor: k.farbe, border: '1px solid', borderColor: 'divider' }} />
</TableCell>
<TableCell>{k.name}</TableCell>
<TableCell>{k.beschreibung ?? '—'}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setEditingKat({ ...k })}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDelete(k.id)}>
<DeleteForeverIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{kategorien.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 3, color: 'text.secondary' }}>
Noch keine Kategorien vorhanden
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Edit dialog */}
<Dialog open={Boolean(editingKat)} onClose={() => setEditingKat(null)} maxWidth="xs" fullWidth>
<DialogTitle>Kategorie bearbeiten</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Name" value={editingKat?.name ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">Farbe</Typography>
<input type="color" value={editingKat?.farbe ?? '#1976d2'} onChange={(e) => setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
<Typography variant="body2" color="text.secondary">{editingKat?.farbe}</Typography>
</Box>
<TextField label="Beschreibung" value={editingKat?.beschreibung ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditingKat(null)}>Abbrechen</Button>
<Button variant="contained" onClick={handleUpdate} disabled={saving || !editingKat?.name.trim()}>
{saving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
{/* New category dialog */}
<Dialog open={newKatOpen} onClose={() => setNewKatOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Name" value={newKatForm.name} onChange={(e) => setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">Farbe</Typography>
<input type="color" value={newKatForm.farbe} onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
<Typography variant="body2" color="text.secondary">{newKatForm.farbe}</Typography>
</Box>
<TextField label="Beschreibung" value={newKatForm.beschreibung} onChange={(e) => setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewKatOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !newKatForm.name.trim()}>
{saving ? <CircularProgress size={20} /> : 'Erstellen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -91,6 +91,7 @@ function buildReverseHierarchy(hierarchy: Record<string, string[]>): Record<stri
const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
kalender: {
'Termine': ['view', 'create'],
'Einstellungen': ['configure'],
},
fahrzeugbuchungen: {
'Buchungen': ['view', 'create', 'manage'],
@@ -112,6 +113,22 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'Bearbeiten': ['create', 'change_status', 'edit', 'delete'],
'Admin': ['edit_settings'],
},
wissen: {
'Ansehen': ['view'],
'Widgets': ['widget_recent', 'widget_search'],
'Einstellungen': ['configure'],
},
vikunja: {
'Aufgaben': ['create_tasks'],
'Widgets': ['widget_tasks', 'widget_quick_add'],
'Einstellungen': ['configure'],
},
nextcloud: {
'Einstellungen': ['configure'],
},
fahrzeuge: {
'Einstellungen': ['configure'],
},
checklisten: {
'Ansehen': ['view'],
'Ausführen': ['execute', 'approve'],

View File

@@ -0,0 +1,147 @@
import { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
TextField,
Button,
Alert,
Chip,
Typography,
CircularProgress,
Divider,
Stack,
} from '@mui/material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import { toolConfigApi } from '../../services/toolConfig';
export default function ToolSettingsBookstack() {
const queryClient = useQueryClient();
const { isFeatureEnabled } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const { data, isLoading } = useQuery({
queryKey: ['tool-config', 'bookstack'],
queryFn: () => toolConfigApi.get('bookstack'),
});
const [url, setUrl] = useState('');
const [tokenId, setTokenId] = useState('');
const [tokenSecret, setTokenSecret] = useState('');
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latencyMs: number } | null>(null);
useEffect(() => {
if (data) {
setUrl(data.url ?? '');
setTokenId(data.tokenId ?? '');
setTokenSecret('');
}
}, [data]);
const saveMutation = useMutation({
mutationFn: () => {
const payload: Record<string, string> = { url };
if (tokenId) payload.tokenId = tokenId;
if (tokenSecret) payload.tokenSecret = tokenSecret;
return toolConfigApi.update('bookstack', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tool-config', 'bookstack'] });
showSuccess('BookStack-Konfiguration gespeichert');
},
onError: () => showError('Fehler beim Speichern der Konfiguration'),
});
const testMutation = useMutation({
mutationFn: () =>
toolConfigApi.test('bookstack', {
url: url || undefined,
tokenId: tokenId || undefined,
tokenSecret: tokenSecret || undefined,
}),
onSuccess: (result) => setTestResult(result),
onError: () => setTestResult({ success: false, message: 'Verbindungstest fehlgeschlagen', latencyMs: 0 }),
});
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>BookStack</Typography>
<Divider sx={{ mb: 2 }} />
{!isFeatureEnabled('wissen') && (
<Alert severity="warning" sx={{ mb: 2 }}>
Dieses Werkzeug befindet sich im Wartungsmodus.
</Alert>
)}
<Stack spacing={2}>
<TextField
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
size="small"
fullWidth
placeholder="https://bookstack.example.com"
/>
<TextField
label="Token ID"
value={tokenId}
onChange={(e) => setTokenId(e.target.value)}
size="small"
fullWidth
/>
<TextField
label="Token Secret"
type="password"
value={tokenSecret}
onChange={(e) => setTokenSecret(e.target.value)}
size="small"
fullWidth
helperText="Leer lassen um vorhandenen Wert zu behalten"
/>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Button
variant="contained"
size="small"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
Speichern
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
disabled={testMutation.isPending}
>
{testMutation.isPending ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
Verbindung testen
</Button>
{testResult && (
<Chip
label={testResult.success ? `Verbunden (${testResult.latencyMs}ms)` : `Fehler: ${testResult.message}`}
color={testResult.success ? 'success' : 'error'}
size="small"
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,118 @@
import { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
TextField,
Button,
Alert,
Chip,
Typography,
CircularProgress,
Divider,
Stack,
} from '@mui/material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import { toolConfigApi } from '../../services/toolConfig';
export default function ToolSettingsNextcloud() {
const queryClient = useQueryClient();
const { isFeatureEnabled } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const { data, isLoading } = useQuery({
queryKey: ['tool-config', 'nextcloud'],
queryFn: () => toolConfigApi.get('nextcloud'),
});
const [url, setUrl] = useState('');
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latencyMs: number } | null>(null);
useEffect(() => {
if (data) {
setUrl(data.url ?? '');
}
}, [data]);
const saveMutation = useMutation({
mutationFn: () => toolConfigApi.update('nextcloud', { url }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tool-config', 'nextcloud'] });
showSuccess('Nextcloud-Konfiguration gespeichert');
},
onError: () => showError('Fehler beim Speichern der Konfiguration'),
});
const testMutation = useMutation({
mutationFn: () =>
toolConfigApi.test('nextcloud', { url: url || undefined }),
onSuccess: (result) => setTestResult(result),
onError: () => setTestResult({ success: false, message: 'Verbindungstest fehlgeschlagen', latencyMs: 0 }),
});
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>Nextcloud</Typography>
<Divider sx={{ mb: 2 }} />
{!isFeatureEnabled('nextcloud') && (
<Alert severity="warning" sx={{ mb: 2 }}>
Dieses Werkzeug befindet sich im Wartungsmodus.
</Alert>
)}
<Stack spacing={2}>
<TextField
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
size="small"
fullWidth
placeholder="https://nextcloud.example.com"
/>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Button
variant="contained"
size="small"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
Speichern
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
disabled={testMutation.isPending}
>
{testMutation.isPending ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
Verbindung testen
</Button>
{testResult && (
<Chip
label={testResult.success ? `Verbunden (${testResult.latencyMs}ms)` : `Fehler: ${testResult.message}`}
color={testResult.success ? 'success' : 'error'}
size="small"
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,136 @@
import { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
TextField,
Button,
Alert,
Chip,
Typography,
CircularProgress,
Divider,
Stack,
} from '@mui/material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { useNotification } from '../../contexts/NotificationContext';
import { toolConfigApi } from '../../services/toolConfig';
export default function ToolSettingsVikunja() {
const queryClient = useQueryClient();
const { isFeatureEnabled } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const { data, isLoading } = useQuery({
queryKey: ['tool-config', 'vikunja'],
queryFn: () => toolConfigApi.get('vikunja'),
});
const [url, setUrl] = useState('');
const [apiToken, setApiToken] = useState('');
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latencyMs: number } | null>(null);
useEffect(() => {
if (data) {
setUrl(data.url ?? '');
setApiToken('');
}
}, [data]);
const saveMutation = useMutation({
mutationFn: () => {
const payload: Record<string, string> = { url };
if (apiToken) payload.apiToken = apiToken;
return toolConfigApi.update('vikunja', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tool-config', 'vikunja'] });
showSuccess('Vikunja-Konfiguration gespeichert');
},
onError: () => showError('Fehler beim Speichern der Konfiguration'),
});
const testMutation = useMutation({
mutationFn: () =>
toolConfigApi.test('vikunja', {
url: url || undefined,
apiToken: apiToken || undefined,
}),
onSuccess: (result) => setTestResult(result),
onError: () => setTestResult({ success: false, message: 'Verbindungstest fehlgeschlagen', latencyMs: 0 }),
});
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>Vikunja</Typography>
<Divider sx={{ mb: 2 }} />
{!isFeatureEnabled('vikunja') && (
<Alert severity="warning" sx={{ mb: 2 }}>
Dieses Werkzeug befindet sich im Wartungsmodus.
</Alert>
)}
<Stack spacing={2}>
<TextField
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
size="small"
fullWidth
placeholder="https://vikunja.example.com"
/>
<TextField
label="API Token"
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
size="small"
fullWidth
helperText="Leer lassen um vorhandenen Wert zu behalten"
/>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Button
variant="contained"
size="small"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
Speichern
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
disabled={testMutation.isPending}
>
{testMutation.isPending ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
Verbindung testen
</Button>
{testResult && (
<Chip
label={testResult.success ? `Verbunden (${testResult.latencyMs}ms)` : `Fehler: ${testResult.message}`}
color={testResult.success ? 'success' : 'error'}
size="small"
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -39,6 +39,14 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { settingsApi } from '../services/settings';
import { personalEquipmentApi } from '../services/personalEquipment';
import ToolSettingsBookstack from '../components/admin/ToolSettingsBookstack';
import ToolSettingsVikunja from '../components/admin/ToolSettingsVikunja';
import ToolSettingsNextcloud from '../components/admin/ToolSettingsNextcloud';
import ModuleSettingsKalender from '../components/admin/ModuleSettingsKalender';
import ModuleSettingsFahrzeugbuchungen from '../components/admin/ModuleSettingsFahrzeugbuchungen';
import ModuleSettingsAusruestung from '../components/admin/ModuleSettingsAusruestung';
import ModuleSettingsFahrzeuge from '../components/admin/ModuleSettingsFahrzeuge';
import ModuleSettingsIssues from '../components/admin/ModuleSettingsIssues';
import type { ZustandOption } from '../types/personalEquipment.types';
interface TabPanelProps {
@@ -82,9 +90,21 @@ const ADMIN_INTERVAL_OPTIONS = [
function AdminSettings() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { hasPermission, hasAnyPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const isFullAdmin = hasPermission('admin:write');
const canAccess = isFullAdmin || hasAnyPermission(
'wissen:configure', 'vikunja:configure', 'nextcloud:configure',
'kalender:configure', 'fahrzeugbuchungen:manage', 'ausruestung:manage_types',
'fahrzeuge:configure', 'issues:edit_settings',
);
const [tab, setTab] = useState(() => {
const t = Number(searchParams.get('tab'));
return t >= 0 && t < SETTINGS_TAB_COUNT ? t : 0;
if (t >= 0 && t < SETTINGS_TAB_COUNT) return t;
return isFullAdmin ? 0 : 1;
});
useEffect(() => {
@@ -92,11 +112,31 @@ function AdminSettings() {
if (t >= 0 && t < SETTINGS_TAB_COUNT) setTab(t);
}, [searchParams]);
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
// Sub-tab state for Werkzeuge tab
const WERKZEUGE_SUB_TABS = [
{ key: 'wissen', label: 'Wissen', visible: hasPermission('wissen:configure') },
{ key: 'vikunja', label: 'Vikunja', visible: hasPermission('vikunja:configure') },
{ key: 'nextcloud', label: 'Nextcloud', visible: hasPermission('nextcloud:configure') },
{ key: 'ausruestung', label: 'Pers. Ausrüstung', visible: isFullAdmin },
{ key: 'kalender', label: 'Kalender', visible: hasPermission('kalender:configure') },
{ key: 'fahrzeugbuchungen', label: 'Fahrzeugbuchungen', visible: hasPermission('fahrzeugbuchungen:manage') },
{ key: 'ausruestung-typen', label: 'Ausrüstung', visible: hasPermission('ausruestung:manage_types') },
{ key: 'fahrzeuge', label: 'Fahrzeuge', visible: hasPermission('fahrzeuge:configure') },
{ key: 'issues', label: 'Issues', visible: hasPermission('issues:edit_settings') },
];
const visibleSubTabs = WERKZEUGE_SUB_TABS.filter((st) => st.visible);
const [werkzeugeSubTab, setWerkzeugeSubTab] = useState(() => {
const st = searchParams.get('subtab');
const idx = visibleSubTabs.findIndex((t) => t.key === st);
return idx >= 0 ? idx : 0;
});
const canAccess = hasPermission('admin:write');
useEffect(() => {
const st = searchParams.get('subtab');
const idx = visibleSubTabs.findIndex((t) => t.key === st);
if (idx >= 0 && idx !== werkzeugeSubTab) setWerkzeugeSubTab(idx);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// State for link collections
const [linkCollections, setLinkCollections] = useState<LinkCollection[]>([]);
@@ -350,16 +390,17 @@ function AdminSettings() {
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs
value={tab}
value={isFullAdmin ? tab : 1}
onChange={(_e, v) => { setTab(v); navigate(`/admin/settings?tab=${v}`, { replace: true }); }}
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Allgemein" />
<Tab label="Werkzeuge" />
{isFullAdmin && <Tab label="Allgemein" value={0} />}
<Tab label="Werkzeuge" value={1} />
</Tabs>
</Box>
{isFullAdmin && (
<TabPanel value={tab} index={0}>
<Stack spacing={3}>
{/* Section 1: General Settings (App Logo) */}
@@ -653,100 +694,144 @@ function AdminSettings() {
</Card>
</Stack>
</TabPanel>
)}
<TabPanel value={tab} index={1}>
<Stack spacing={3}>
{/* Zustandsoptionen (Persönliche Ausrüstung) */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckroomIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Zustandsoptionen Persönliche Ausrüstung</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt.
</Typography>
<Stack spacing={2}>
{zustandOptions.map((opt, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="Schlüssel"
value={opt.key}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="Label"
value={opt.label}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>Farbe</InputLabel>
<Select
value={opt.color}
label="Farbe"
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs
value={werkzeugeSubTab}
onChange={(_e, v) => {
setWerkzeugeSubTab(v);
const key = visibleSubTabs[v]?.key;
if (key) navigate(`/admin/settings?tab=1&subtab=${key}`, { replace: true });
}}
variant="scrollable"
scrollButtons="auto"
>
{visibleSubTabs.map((st) => (
<Tab key={st.key} label={st.label} />
))}
</Tabs>
</Box>
{visibleSubTabs[werkzeugeSubTab]?.key === 'wissen' && (
<ToolSettingsBookstack />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'vikunja' && (
<ToolSettingsVikunja />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'nextcloud' && (
<ToolSettingsNextcloud />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'ausruestung' && (
<Stack spacing={3}>
{/* Zustandsoptionen (Persönliche Ausrüstung) */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckroomIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Zustandsoptionen Persönliche Ausrüstung</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt.
</Typography>
<Stack spacing={2}>
{zustandOptions.map((opt, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="Schlüssel"
value={opt.key}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, color: e.target.value } : o))
prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="Label"
value={opt.label}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>Farbe</InputLabel>
<Select
value={opt.color}
label="Farbe"
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, color: e.target.value } : o))
)
}
>
<MenuItem value="success">Grün</MenuItem>
<MenuItem value="warning">Gelb</MenuItem>
<MenuItem value="error">Rot</MenuItem>
<MenuItem value="default">Grau</MenuItem>
<MenuItem value="info">Blau</MenuItem>
<MenuItem value="primary">Primär</MenuItem>
</Select>
</FormControl>
<IconButton
color="error"
onClick={() =>
setZustandOptions((prev) => prev.filter((_, i) => i !== idx))
}
aria-label="Option entfernen"
size="small"
>
<MenuItem value="success">Grün</MenuItem>
<MenuItem value="warning">Gelb</MenuItem>
<MenuItem value="error">Rot</MenuItem>
<MenuItem value="default">Grau</MenuItem>
<MenuItem value="info">Blau</MenuItem>
<MenuItem value="primary">Primär</MenuItem>
</Select>
</FormControl>
<IconButton
color="error"
<Delete />
</IconButton>
</Box>
))}
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
startIcon={<Add />}
onClick={() =>
setZustandOptions((prev) => prev.filter((_, i) => i !== idx))
setZustandOptions((prev) => [...prev, { key: '', label: '', color: 'default' }])
}
aria-label="Option entfernen"
variant="outlined"
size="small"
>
<Delete />
</IconButton>
Option hinzufügen
</Button>
<Button
onClick={() => zustandMutation.mutate(zustandOptions)}
variant="contained"
size="small"
disabled={zustandMutation.isPending}
>
Speichern
</Button>
</Box>
))}
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
startIcon={<Add />}
onClick={() =>
setZustandOptions((prev) => [...prev, { key: '', label: '', color: 'default' }])
}
variant="outlined"
size="small"
>
Option hinzufügen
</Button>
<Button
onClick={() => zustandMutation.mutate(zustandOptions)}
variant="contained"
size="small"
disabled={zustandMutation.isPending}
>
Speichern
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
</CardContent>
</Card>
</Stack>
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'kalender' && (
<ModuleSettingsKalender />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'fahrzeugbuchungen' && (
<ModuleSettingsFahrzeugbuchungen />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'ausruestung-typen' && (
<ModuleSettingsAusruestung />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'fahrzeuge' && (
<ModuleSettingsFahrzeuge />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'issues' && (
<ModuleSettingsIssues />
)}
</TabPanel>
</Container>
</DashboardLayout>

View File

@@ -16,41 +16,30 @@ import {
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
Switch,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
Add as AddIcon,
Build,
CheckCircle,
Delete,
Edit,
Error as ErrorIcon,
LinkRounded,
PauseCircle,
RemoveCircle,
Search,
Settings as SettingsIcon,
Star,
Warning,
} from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import { ausruestungTypenApi, AusruestungTyp } from '../services/ausruestungTypen';
import { ausruestungTypenApi } from '../services/ausruestungTypen';
import {
AusruestungListItem,
AusruestungKategorie,
@@ -59,9 +48,7 @@ import {
EquipmentStats,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { ConfirmDialog, FormDialog } from '../components/templates';
// ── Status chip config ────────────────────────────────────────────────────────
@@ -246,222 +233,11 @@ const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
);
};
// ── Ausrüstungstypen-Verwaltung (Einstellungen Tab) ──────────────────────────
function AusruestungTypenSettings() {
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const { data: typen = [], isLoading, isError } = useQuery({
queryKey: ['ausruestungTypen'],
queryFn: ausruestungTypenApi.getAll,
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTyp, setEditingTyp] = useState<AusruestungTyp | null>(null);
const [formName, setFormName] = useState('');
const [formBeschreibung, setFormBeschreibung] = useState('');
const [formIcon, setFormIcon] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingTyp, setDeletingTyp] = useState<AusruestungTyp | null>(null);
const createMutation = useMutation({
mutationFn: (data: { name: string; beschreibung?: string; icon?: string }) =>
ausruestungTypenApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ erstellt');
closeDialog();
},
onError: () => showError('Typ konnte nicht erstellt werden'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; beschreibung: string; icon: string }> }) =>
ausruestungTypenApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ aktualisiert');
closeDialog();
},
onError: () => showError('Typ konnte nicht aktualisiert werden'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => ausruestungTypenApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
showSuccess('Typ gelöscht');
setDeleteDialogOpen(false);
setDeletingTyp(null);
},
onError: () => showError('Typ konnte nicht gelöscht werden. Möglicherweise ist er noch Geräten zugeordnet.'),
});
const openAddDialog = () => {
setEditingTyp(null);
setFormName('');
setFormBeschreibung('');
setFormIcon('');
setDialogOpen(true);
};
const openEditDialog = (typ: AusruestungTyp) => {
setEditingTyp(typ);
setFormName(typ.name);
setFormBeschreibung(typ.beschreibung ?? '');
setFormIcon(typ.icon ?? '');
setDialogOpen(true);
};
const closeDialog = () => {
setDialogOpen(false);
setEditingTyp(null);
};
const handleSave = () => {
if (!formName.trim()) return;
const data = {
name: formName.trim(),
beschreibung: formBeschreibung.trim() || undefined,
icon: formIcon.trim() || undefined,
};
if (editingTyp) {
updateMutation.mutate({ id: editingTyp.id, data });
} else {
createMutation.mutate(data);
}
};
const openDeleteDialog = (typ: AusruestungTyp) => {
setDeletingTyp(typ);
setDeleteDialogOpen(true);
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Box>
<Typography variant="h6" gutterBottom>Ausrüstungstypen</Typography>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
)}
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Typen konnten nicht geladen werden.
</Alert>
)}
{!isLoading && !isError && (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{typen.length === 0 && (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Noch keine Typen vorhanden.
</Typography>
</TableCell>
</TableRow>
)}
{typen.map((typ) => (
<TableRow key={typ.id}>
<TableCell>{typ.name}</TableCell>
<TableCell>{typ.beschreibung || '---'}</TableCell>
<TableCell>{typ.icon || '---'}</TableCell>
<TableCell align="right">
<Tooltip title="Bearbeiten">
<IconButton size="small" onClick={() => openEditDialog(typ)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Löschen">
<IconButton size="small" color="error" onClick={() => openDeleteDialog(typ)}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" startIcon={<AddIcon />} onClick={openAddDialog}>
Neuer Typ
</Button>
</Box>
</Paper>
)}
{/* Add/Edit dialog */}
<FormDialog
open={dialogOpen}
onClose={closeDialog}
onSubmit={handleSave}
title={editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
isSubmitting={isSaving}
>
<TextField
label="Name *"
fullWidth
value={formName}
onChange={(e) => setFormName(e.target.value)}
inputProps={{ maxLength: 100 }}
/>
<TextField
label="Beschreibung"
fullWidth
multiline
rows={2}
value={formBeschreibung}
onChange={(e) => setFormBeschreibung(e.target.value)}
/>
<TextField
label="Icon (MUI Icon-Name)"
fullWidth
value={formIcon}
onChange={(e) => setFormIcon(e.target.value)}
placeholder="z.B. Build, LocalFireDepartment"
/>
</FormDialog>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
title="Typ löschen"
message={<>Möchten Sie den Typ &quot;{deletingTyp?.name}&quot; wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.</>}
confirmLabel="Löschen"
confirmColor="error"
isLoading={deleteMutation.isPending}
/>
</Box>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function Ausruestung() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
const { canManageEquipment, hasPermission } = usePermissions();
const canManageTypes = hasPermission('ausruestung:manage_types');
@@ -568,18 +344,15 @@ function Ausruestung() {
</Box>
)}
</Box>
{canManageTypes && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=ausruestung-typen')}>
<SettingsIcon />
</IconButton>
</Tooltip>
)}
</Box>
<Tabs
value={tab}
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="Übersicht" />
{canManageTypes && <Tab label="Einstellungen" />}
</Tabs>
{tab === 0 && (
<>
{/* Overdue alert */}
{hasOverdue && (
@@ -735,11 +508,7 @@ function Ausruestung() {
</ChatAwareFab>
)}
</>
)}
{tab === 1 && canManageTypes && (
<AusruestungTypenSettings />
)}
</Container>
</DashboardLayout>
);

View File

@@ -1,10 +1,14 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { Link, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Settings } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useChat } from '../contexts/ChatContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { nextcloudApi } from '../services/nextcloud';
import { notificationsApi } from '../services/notifications';
import ChatRoomList from '../components/chat/ChatRoomList';
@@ -12,6 +16,8 @@ import ChatMessageView from '../components/chat/ChatMessageView';
const ChatContent: React.FC = () => {
const { rooms, selectedRoomToken, connected } = useChat();
const { hasPermission } = usePermissionContext();
const navigate = useNavigate();
const queryClient = useQueryClient();
const markedRoomsRef = React.useRef(new Set<string>());
@@ -44,16 +50,27 @@ const ChatContent: React.FC = () => {
}, [selectedRoomToken, queryClient]);
return (
<Box
sx={{
height: 'calc(100vh - 112px)',
display: 'flex',
border: 1,
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 112px)' }}>
{hasPermission('nextcloud:configure') && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 1 }}>
<Tooltip title="Einstellungen">
<IconButton size="small" onClick={() => navigate('/admin/settings?tab=1&subtab=nextcloud')}>
<Settings fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
<Box
sx={{
flex: 1,
display: 'flex',
border: 1,
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
minHeight: 0,
}}
>
{!connected ? (
<Box sx={{ p: 3, display: 'flex', alignItems: 'center' }}>
<Typography variant="body1" color="text.secondary">
@@ -89,6 +106,7 @@ const ChatContent: React.FC = () => {
</Box>
</>
)}
</Box>
</Box>
);
};

View File

@@ -20,15 +20,6 @@ import {
DialogTitle,
DialogContent,
DialogActions,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Checkbox,
Stack,
Tooltip,
} from '@mui/material';
@@ -38,12 +29,10 @@ import {
ContentCopy,
Cancel,
Edit,
Delete,
Save,
Close,
EventBusy,
Settings,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { format, parseISO } from 'date-fns';
import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -103,8 +92,6 @@ function FahrzeugBuchungen() {
const canCreate = hasPermission('fahrzeugbuchungen:create');
const canManage = hasPermission('fahrzeugbuchungen:manage');
const [tabIndex, setTabIndex] = useState(0);
// ── Filters ────────────────────────────────────────────────────────────────
const today = new Date();
const defaultFrom = format(today, 'yyyy-MM-dd');
@@ -120,11 +107,6 @@ function FahrzeugBuchungen() {
queryFn: fetchVehicles,
});
const { data: kategorien = [] } = useQuery({
queryKey: ['buchungskategorien-all'],
queryFn: kategorieApi.getAll,
});
const { data: activeKategorien = [] } = useQuery({
queryKey: ['buchungskategorien'],
queryFn: kategorieApi.getActive,
@@ -191,46 +173,6 @@ function FahrzeugBuchungen() {
}
};
// ── Einstellungen: Categories management ───────────────────────────────────
const [editRowId, setEditRowId] = useState<number | null>(null);
const [editRowData, setEditRowData] = useState<Partial<BuchungsKategorie>>({});
const [newKatDialog, setNewKatDialog] = useState(false);
const [newKatForm, setNewKatForm] = useState({ bezeichnung: '', farbe: '#1976d2' });
const createKatMutation = useMutation({
mutationFn: (data: Omit<BuchungsKategorie, 'id'>) => kategorieApi.create(data),
onSuccess: () => {
notification.showSuccess('Kategorie erstellt');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
setNewKatDialog(false);
setNewKatForm({ bezeichnung: '', farbe: '#1976d2' });
},
onError: () => notification.showError('Fehler beim Erstellen'),
});
const updateKatMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BuchungsKategorie> }) =>
kategorieApi.update(id, data),
onSuccess: () => {
notification.showSuccess('Kategorie aktualisiert');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
setEditRowId(null);
},
onError: () => notification.showError('Fehler beim Aktualisieren'),
});
const deleteKatMutation = useMutation({
mutationFn: (id: number) => kategorieApi.delete(id),
onSuccess: () => {
notification.showSuccess('Kategorie deaktiviert');
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
},
onError: () => notification.showError('Fehler beim Löschen'),
});
// ── Render ─────────────────────────────────────────────────────────────────
if (!isFeatureEnabled('fahrzeugbuchungen')) {
return <ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." />;
@@ -244,23 +186,23 @@ function FahrzeugBuchungen() {
<Typography variant="h4" fontWeight={700}>
Fahrzeugbuchungen
</Typography>
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
iCal abonnieren
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
{canManage && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=fahrzeugbuchungen')}>
<Settings />
</IconButton>
</Tooltip>
)}
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
iCal abonnieren
</Button>
</Box>
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}>
<Tab label="Buchungen" />
{canManage && <Tab label="Einstellungen" />}
</Tabs>
</Box>
{/* ── Tab 0: Buchungen ─────────────────────────────────────────────── */}
{tabIndex === 0 && (
<>
{/* Filters */}
{/* ── Buchungen ─────────────────────────────────────────────── */}
<>
{/* Filters */}
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
<GermanDateField
@@ -410,173 +352,9 @@ function FahrzeugBuchungen() {
</Card>
))}
</>
)}
{/* ── Tab 1: Einstellungen ─────────────────────────────────────────── */}
{tabIndex === 1 && canManage && (
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Buchungskategorien</Typography>
<Button
startIcon={<Add />}
variant="contained"
size="small"
onClick={() => setNewKatDialog(true)}
>
Neue Kategorie
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Sortierung</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kategorien.map((kat) => {
const isEditing = editRowId === kat.id;
return (
<TableRow key={kat.id}>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editRowData.bezeichnung ?? kat.bezeichnung}
onChange={(e) =>
setEditRowData((d) => ({ ...d, bezeichnung: e.target.value }))
}
/>
) : (
kat.bezeichnung
)}
</TableCell>
<TableCell>
{isEditing ? (
<input
type="color"
value={editRowData.farbe ?? kat.farbe}
onChange={(e) =>
setEditRowData((d) => ({ ...d, farbe: e.target.value }))
}
style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }}
/>
) : (
<Box
sx={{
width: 24,
height: 24,
borderRadius: 1,
bgcolor: kat.farbe,
border: '1px solid',
borderColor: 'divider',
}}
/>
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
type="number"
value={editRowData.sort_order ?? kat.sort_order}
onChange={(e) =>
setEditRowData((d) => ({
...d,
sort_order: parseInt(e.target.value) || 0,
}))
}
sx={{ width: 80 }}
/>
) : (
kat.sort_order
)}
</TableCell>
<TableCell>
{isEditing ? (
<Checkbox
checked={editRowData.aktiv ?? kat.aktiv}
onChange={(e) =>
setEditRowData((d) => ({ ...d, aktiv: e.target.checked }))
}
/>
) : (
<Checkbox checked={kat.aktiv} disabled />
)}
</TableCell>
<TableCell align="right">
{isEditing ? (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton
size="small"
color="primary"
onClick={() =>
updateKatMutation.mutate({ id: kat.id, data: editRowData })
}
>
<Save fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setEditRowId(null);
setEditRowData({});
}}
>
<Close fontSize="small" />
</IconButton>
</Stack>
) : (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton
size="small"
onClick={() => {
setEditRowId(kat.id);
setEditRowData({
bezeichnung: kat.bezeichnung,
farbe: kat.farbe,
sort_order: kat.sort_order,
aktiv: kat.aktiv,
});
}}
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteKatMutation.mutate(kat.id)}
>
<Delete fontSize="small" />
</IconButton>
</Stack>
)}
</TableCell>
</TableRow>
);
})}
{kategorien.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Keine Kategorien vorhanden
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
{/* ── FAB ── */}
{canCreate && tabIndex === 0 && (
{canCreate && (
<ChatAwareFab
color="primary"
aria-label="Buchung erstellen"
@@ -655,57 +433,6 @@ function FahrzeugBuchungen() {
</DialogActions>
</Dialog>
{/* ── New category dialog ── */}
<Dialog
open={newKatDialog}
onClose={() => setNewKatDialog(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
fullWidth
size="small"
label="Bezeichnung"
required
value={newKatForm.bezeichnung}
onChange={(e) =>
setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value }))
}
/>
<Box>
<Typography variant="body2" sx={{ mb: 0.5 }}>
Farbe
</Typography>
<input
type="color"
value={newKatForm.farbe}
onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))}
style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }}
/>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewKatDialog(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={() =>
createKatMutation.mutate({
bezeichnung: newKatForm.bezeichnung,
farbe: newKatForm.farbe,
aktiv: true,
sort_order: kategorien.length,
})
}
disabled={!newKatForm.bezeichnung || createKatMutation.isPending}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);

View File

@@ -13,41 +13,28 @@ import {
Grid,
IconButton,
InputAdornment,
Paper,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
Add as AddIcon,
CheckCircle,
Delete as DeleteIcon,
DirectionsCar,
Edit as EditIcon,
Error as ErrorIcon,
FileDownload,
PauseCircle,
School,
Search,
Settings as SettingsIcon,
Warning,
ReportProblem,
} from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import type { VehicleEquipmentWarning } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import {
@@ -55,11 +42,8 @@ import {
FahrzeugStatus,
FahrzeugStatusLabel,
} from '../types/vehicle.types';
import type { FahrzeugTyp } from '../types/checklist.types';
import { usePermissions } from '../hooks/usePermissions';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { FormDialog } from '../components/templates';
// ── Status chip config ────────────────────────────────────────────────────────
@@ -303,184 +287,11 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings =
);
};
// ── Fahrzeugtypen-Verwaltung (Einstellungen Tab) ─────────────────────────────
function FahrzeugTypenSettings() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { data: fahrzeugTypen = [], isLoading } = useQuery({
queryKey: ['fahrzeug-typen'],
queryFn: fahrzeugTypenApi.getAll,
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<FahrzeugTyp | null>(null);
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
const [deleteError, setDeleteError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: Partial<FahrzeugTyp>) => fahrzeugTypenApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDialogOpen(false);
showSuccess('Fahrzeugtyp erstellt');
},
onError: () => showError('Fehler beim Erstellen'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<FahrzeugTyp> }) =>
fahrzeugTypenApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDialogOpen(false);
showSuccess('Fahrzeugtyp aktualisiert');
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => fahrzeugTypenApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
setDeleteError(null);
showSuccess('Fahrzeugtyp gelöscht');
},
onError: (err: any) => {
const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.';
setDeleteError(msg);
},
});
const openCreate = () => {
setEditing(null);
setForm({ name: '', beschreibung: '', icon: '' });
setDialogOpen(true);
};
const openEdit = (t: FahrzeugTyp) => {
setEditing(t);
setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' });
setDialogOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) return;
if (editing) {
updateMutation.mutate({ id: editing.id, data: form });
} else {
createMutation.mutate(form);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>
Fahrzeugtypen
</Typography>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError(null)}>
{deleteError}
</Alert>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
Neuer Fahrzeugtyp
</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fahrzeugTypen.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
Keine Fahrzeugtypen vorhanden
</TableCell>
</TableRow>
) : (
fahrzeugTypen.map((t) => (
<TableRow key={t.id} hover>
<TableCell>{t.name}</TableCell>
<TableCell>{t.beschreibung ?? ''}</TableCell>
<TableCell>{t.icon ?? ''}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(t)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(t.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</>
)}
<FormDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onSubmit={handleSubmit}
title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
isSubmitting={isSaving}
>
<TextField
label="Name *"
fullWidth
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<TextField
label="Beschreibung"
fullWidth
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
<TextField
label="Icon"
fullWidth
value={form.icon}
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
placeholder="z.B. fire_truck"
/>
</FormDialog>
</Box>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
function Fahrzeuge() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
const { isAdmin } = usePermissions();
const { hasPermission } = usePermissionContext();
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
@@ -489,8 +300,6 @@ function Fahrzeuge() {
const [search, setSearch] = useState('');
const [equipmentWarnings, setEquipmentWarnings] = useState<Map<string, VehicleEquipmentWarning[]>>(new Map());
const canEditSettings = hasPermission('checklisten:manage_templates');
const fetchVehicles = useCallback(async () => {
try {
setLoading(true);
@@ -579,7 +388,14 @@ function Fahrzeuge() {
</Typography>
)}
</Box>
{tab === 0 && (
<Box sx={{ display: 'flex', gap: 1 }}>
{hasPermission('fahrzeuge:configure') && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=fahrzeuge')}>
<SettingsIcon />
</IconButton>
</Tooltip>
)}
<Button
variant="outlined"
size="small"
@@ -588,19 +404,9 @@ function Fahrzeuge() {
>
Prüfungen CSV
</Button>
)}
</Box>
</Box>
<Tabs
value={tab}
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="Übersicht" />
{canEditSettings && <Tab label="Einstellungen" />}
</Tabs>
{tab === 0 && (
<>
{hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
@@ -670,11 +476,7 @@ function Fahrzeuge() {
</ChatAwareFab>
)}
</>
)}
{tab === 1 && canEditSettings && (
<FahrzeugTypenSettings />
)}
</Container>
</DashboardLayout>
);

View File

@@ -1,23 +1,21 @@
import { useState, useMemo } from 'react';
import {
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Chip, IconButton, Button, TextField, MenuItem, Select, FormControl,
InputLabel, CircularProgress, FormControlLabel, Switch,
Autocomplete, ToggleButtonGroup, ToggleButton,
Box, Tab, Tabs, Typography, Chip, IconButton, Button, TextField,
CircularProgress, FormControlLabel, Switch,
Autocomplete, ToggleButtonGroup, ToggleButton, Tooltip,
} from '@mui/material';
// Note: Table/TableBody/etc still needed for IssueSettings tables
import {
Add as AddIcon, Delete as DeleteIcon,
Add as AddIcon,
BugReport, FiberNew, HelpOutline,
Circle as CircleIcon, Edit as EditIcon,
DragIndicator, Check as CheckIcon, Close as CloseIcon,
Circle as CircleIcon,
ViewList as ViewListIcon, ViewKanban as ViewKanbanIcon,
Settings as SettingsIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { DataTable, FormDialog } from '../components/templates';
import { DataTable } from '../components/templates';
import type { Column } from '../components/templates';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
@@ -211,335 +209,6 @@ function FilterBar({
);
}
// ── Shared color picker helpers ──
const MUI_CHIP_COLORS = ['default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'] as const;
const MUI_THEME_COLORS: Record<string, string> = { default: '#9e9e9e', primary: '#1976d2', secondary: '#9c27b0', error: '#d32f2f', info: '#0288d1', success: '#2e7d32', warning: '#ed6c02', action: '#757575' };
const ICON_COLORS = ['action', 'error', 'info', 'success', 'warning', 'primary', 'secondary'] as const;
function ColorSwatch({ colors, value, onChange }: { colors: readonly string[]; value: string; onChange: (v: string) => void }) {
return (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
{colors.map((c) => (
<Box
key={c}
onClick={() => onChange(c)}
sx={{
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer',
bgcolor: MUI_THEME_COLORS[c] ?? c,
border: value === c ? '2.5px solid' : '2px solid transparent',
borderColor: value === c ? 'text.primary' : 'transparent',
'&:hover': { opacity: 0.8 },
}}
/>
))}
</Box>
);
}
function HexColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
component="input"
type="color"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
sx={{ width: 28, height: 28, border: 'none', borderRadius: 1, cursor: 'pointer', p: 0, bgcolor: 'transparent' }}
/>
<Typography variant="caption" color="text.secondary">{value}</Typography>
</Box>
);
}
// ── Issue Settings (consolidated: Status + Prioritäten + Kategorien) ──
function IssueSettings() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
// ── Status state ──
const [statusCreateOpen, setStatusCreateOpen] = useState(false);
const [statusCreateData, setStatusCreateData] = useState<Partial<IssueStatusDef>>({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 });
const [statusEditId, setStatusEditId] = useState<number | null>(null);
const [statusEditData, setStatusEditData] = useState<Partial<IssueStatusDef>>({});
// ── Priority state ──
const [prioCreateOpen, setPrioCreateOpen] = useState(false);
const [prioCreateData, setPrioCreateData] = useState<Partial<IssuePriorityDef>>({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 });
const [prioEditId, setPrioEditId] = useState<number | null>(null);
const [prioEditData, setPrioEditData] = useState<Partial<IssuePriorityDef>>({});
// ── Kategorien state ──
const [typeCreateOpen, setTypeCreateOpen] = useState(false);
const [typeCreateData, setTypeCreateData] = useState<Partial<IssueTyp>>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true });
const [typeEditId, setTypeEditId] = useState<number | null>(null);
const [typeEditData, setTypeEditData] = useState<Partial<IssueTyp>>({});
// ── Queries ──
const { data: issueStatuses = [], isLoading: statusLoading } = useQuery({ queryKey: ['issue-statuses'], queryFn: issuesApi.getStatuses });
const { data: issuePriorities = [], isLoading: prioLoading } = useQuery({ queryKey: ['issue-priorities'], queryFn: issuesApi.getPriorities });
const { data: types = [], isLoading: typesLoading } = useQuery({ queryKey: ['issue-types'], queryFn: issuesApi.getTypes });
// ── Status mutations ──
const createStatusMut = useMutation({
mutationFn: (data: Partial<IssueStatusDef>) => issuesApi.createStatus(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status erstellt'); setStatusCreateOpen(false); setStatusCreateData({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 }); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateStatusMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IssueStatusDef> }) => issuesApi.updateStatus(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status aktualisiert'); setStatusEditId(null); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteStatusMut = useMutation({
mutationFn: (id: number) => issuesApi.deleteStatus(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status deaktiviert'); },
onError: () => showError('Fehler beim Deaktivieren'),
});
// ── Priority mutations ──
const createPrioMut = useMutation({
mutationFn: (data: Partial<IssuePriorityDef>) => issuesApi.createPriority(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität erstellt'); setPrioCreateOpen(false); setPrioCreateData({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 }); },
onError: () => showError('Fehler beim Erstellen'),
});
const updatePrioMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IssuePriorityDef> }) => issuesApi.updatePriority(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität aktualisiert'); setPrioEditId(null); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deletePrioMut = useMutation({
mutationFn: (id: number) => issuesApi.deletePriority(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität deaktiviert'); },
onError: () => showError('Fehler beim Deaktivieren'),
});
// ── Type mutations ──
const createTypeMut = useMutation({
mutationFn: (data: Partial<IssueTyp>) => issuesApi.createType(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie erstellt'); setTypeCreateOpen(false); setTypeCreateData({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); },
onError: () => showError('Fehler beim Erstellen'),
});
const updateTypeMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<IssueTyp> }) => issuesApi.updateType(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie aktualisiert'); setTypeEditId(null); },
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteTypeMut = useMutation({
mutationFn: (id: number) => issuesApi.deleteType(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie deaktiviert'); },
onError: () => showError('Fehler beim Deaktivieren'),
});
const flatTypes = useMemo(() => {
const roots = types.filter(t => !t.parent_id);
const result: { type: IssueTyp; indent: boolean }[] = [];
for (const root of roots) {
result.push({ type: root, indent: false });
for (const child of types.filter(t => t.parent_id === root.id)) result.push({ type: child, indent: true });
}
const listed = new Set(result.map(r => r.type.id));
for (const t of types) { if (!listed.has(t.id)) result.push({ type: t, indent: false }); }
return result;
}, [types]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* ──── Section 1: Status ──── */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Status</Typography>
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setStatusCreateOpen(true)}>Neuer Status</Button>
</Box>
{statusLoading ? <CircularProgress /> : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Schlüssel</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Abschluss</TableCell>
<TableCell>Initial</TableCell>
<TableCell>Sort</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{issueStatuses.length === 0 ? (
<TableRow><TableCell colSpan={8} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Status vorhanden</TableCell></TableRow>
) : issueStatuses.map((s) => (
<TableRow key={s.id}>
{statusEditId === s.id ? (<>
<TableCell><TextField size="small" value={statusEditData.bezeichnung ?? s.bezeichnung} onChange={(e) => setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /></TableCell>
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
<TableCell><ColorSwatch colors={MUI_CHIP_COLORS} value={statusEditData.farbe ?? s.farbe} onChange={(v) => setStatusEditData({ ...statusEditData, farbe: v })} /></TableCell>
<TableCell><Switch checked={statusEditData.ist_abschluss ?? s.ist_abschluss} onChange={(e) => setStatusEditData({ ...statusEditData, ist_abschluss: e.target.checked })} size="small" /></TableCell>
<TableCell><Switch checked={statusEditData.ist_initial ?? s.ist_initial} onChange={(e) => setStatusEditData({ ...statusEditData, ist_initial: e.target.checked })} size="small" /></TableCell>
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={statusEditData.sort_order ?? s.sort_order} onChange={(e) => setStatusEditData({ ...statusEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
<TableCell><Switch checked={statusEditData.aktiv ?? s.aktiv} onChange={(e) => setStatusEditData({ ...statusEditData, aktiv: e.target.checked })} size="small" /></TableCell>
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updateStatusMut.mutate({ id: s.id, data: statusEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setStatusEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box></TableCell>
</>) : (<>
<TableCell><Chip label={s.bezeichnung} color={s.farbe as any} size="small" /></TableCell>
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
<TableCell><Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: MUI_THEME_COLORS[s.farbe] ?? s.farbe }} /></TableCell>
<TableCell>{s.ist_abschluss ? '✓' : '-'}</TableCell>
<TableCell>{s.ist_initial ? '✓' : '-'}</TableCell>
<TableCell>{s.sort_order}</TableCell>
<TableCell><Switch checked={s.aktiv} onChange={(e) => updateStatusMut.mutate({ id: s.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setStatusEditId(s.id); setStatusEditData({ bezeichnung: s.bezeichnung, farbe: s.farbe, ist_abschluss: s.ist_abschluss, ist_initial: s.ist_initial, benoetigt_typ_freigabe: s.benoetigt_typ_freigabe, sort_order: s.sort_order, aktiv: s.aktiv }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deleteStatusMut.mutate(s.id)}><DeleteIcon fontSize="small" /></IconButton></Box></TableCell>
</>)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* ──── Section 2: Prioritäten ──── */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Prioritäten</Typography>
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setPrioCreateOpen(true)}>Neue Priorität</Button>
</Box>
{prioLoading ? <CircularProgress /> : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Schlüssel</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Sort</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{issuePriorities.length === 0 ? (
<TableRow><TableCell colSpan={6} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Prioritäten vorhanden</TableCell></TableRow>
) : issuePriorities.map((p) => (
<TableRow key={p.id}>
{prioEditId === p.id ? (<>
<TableCell><TextField size="small" value={prioEditData.bezeichnung ?? p.bezeichnung} onChange={(e) => setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /></TableCell>
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
<TableCell><HexColorInput value={prioEditData.farbe ?? p.farbe} onChange={(v) => setPrioEditData({ ...prioEditData, farbe: v })} /></TableCell>
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={prioEditData.sort_order ?? p.sort_order} onChange={(e) => setPrioEditData({ ...prioEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
<TableCell><Switch checked={prioEditData.aktiv ?? p.aktiv} onChange={(e) => setPrioEditData({ ...prioEditData, aktiv: e.target.checked })} size="small" /></TableCell>
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updatePrioMut.mutate({ id: p.id, data: prioEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setPrioEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box></TableCell>
</>) : (<>
<TableCell><Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><CircleIcon sx={{ fontSize: 12, color: p.farbe }} /><Typography variant="body2">{p.bezeichnung}</Typography></Box></TableCell>
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
<TableCell><Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: p.farbe, border: '1px solid', borderColor: 'divider' }} /></TableCell>
<TableCell>{p.sort_order}</TableCell>
<TableCell><Switch checked={p.aktiv} onChange={(e) => updatePrioMut.mutate({ id: p.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deletePrioMut.mutate(p.id)}><DeleteIcon fontSize="small" /></IconButton></Box></TableCell>
</>)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* ──── Section 3: Kategorien ──── */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Kategorien</Typography>
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setTypeCreateOpen(true)}>Neue Kategorie</Button>
</Box>
{typesLoading ? <CircularProgress /> : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Icon</TableCell>
<TableCell>Farbe</TableCell>
<TableCell>Abgelehnt</TableCell>
<TableCell>Sort</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{flatTypes.length === 0 ? (
<TableRow><TableCell colSpan={7} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Kategorien vorhanden</TableCell></TableRow>
) : flatTypes.map(({ type: t, indent }) => (
<TableRow key={t.id} sx={indent ? { bgcolor: 'action.hover' } : undefined}>
<TableCell><Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{indent && <DragIndicator fontSize="small" sx={{ opacity: 0.3, ml: 2 }} />}{typeEditId === t.id ? <TextField size="small" value={typeEditData.name || ''} onChange={(e) => setTypeEditData({ ...typeEditData, name: e.target.value })} /> : <Typography variant="body2">{t.name}</Typography>}</Box></TableCell>
<TableCell>{typeEditId === t.id ? (<Select size="small" value={typeEditData.icon || 'HelpOutline'} onChange={(e) => setTypeEditData({ ...typeEditData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select>) : <Box sx={{ display: 'inline-flex' }}>{getTypIcon(t.icon, t.farbe)}</Box>}</TableCell>
<TableCell>{typeEditId === t.id ? <ColorSwatch colors={ICON_COLORS} value={typeEditData.farbe || 'action'} onChange={(v) => setTypeEditData({ ...typeEditData, farbe: v })} /> : <Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: MUI_THEME_COLORS[t.farbe || 'action'] ?? '#757575' }} />}</TableCell>
<TableCell>{typeEditId === t.id ? <Switch checked={typeEditData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeEditData({ ...typeEditData, erlaubt_abgelehnt: e.target.checked })} size="small" /> : (t.erlaubt_abgelehnt ? '✓' : '-')}</TableCell>
<TableCell>{typeEditId === t.id ? <TextField size="small" type="number" sx={{ width: 60 }} value={typeEditData.sort_order ?? 0} onChange={(e) => setTypeEditData({ ...typeEditData, sort_order: parseInt(e.target.value) || 0 })} /> : t.sort_order}</TableCell>
<TableCell><Switch checked={typeEditId === t.id ? (typeEditData.aktiv ?? t.aktiv) : t.aktiv} onChange={(e) => { if (typeEditId === t.id) setTypeEditData({ ...typeEditData, aktiv: e.target.checked }); else updateTypeMut.mutate({ id: t.id, data: { aktiv: e.target.checked } }); }} size="small" /></TableCell>
<TableCell>{typeEditId === t.id ? (<Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updateTypeMut.mutate({ id: t.id, data: typeEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setTypeEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box>) : (<Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setTypeEditId(t.id); setTypeEditData({ name: t.name, icon: t.icon, farbe: t.farbe, erlaubt_abgelehnt: t.erlaubt_abgelehnt, sort_order: t.sort_order, aktiv: t.aktiv, parent_id: t.parent_id }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deleteTypeMut.mutate(t.id)}><DeleteIcon fontSize="small" /></IconButton></Box>)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* ──── Create Status Dialog ──── */}
<FormDialog
open={statusCreateOpen}
onClose={() => setStatusCreateOpen(false)}
onSubmit={() => createStatusMut.mutate(statusCreateData)}
title="Neuer Status"
submitLabel="Erstellen"
isSubmitting={createStatusMut.isPending}
>
<TextField label="Schlüssel" required fullWidth value={statusCreateData.schluessel || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus />
<TextField label="Bezeichnung" required fullWidth value={statusCreateData.bezeichnung || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} />
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={MUI_CHIP_COLORS} value={statusCreateData.farbe || 'default'} onChange={(v) => setStatusCreateData({ ...statusCreateData, farbe: v })} /></Box>
<TextField label="Sortierung" type="number" value={statusCreateData.sort_order ?? 0} onChange={(e) => setStatusCreateData({ ...statusCreateData, sort_order: parseInt(e.target.value) || 0 })} />
<FormControlLabel control={<Switch checked={statusCreateData.ist_abschluss ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" />
<FormControlLabel control={<Switch checked={statusCreateData.ist_initial ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" />
<FormControlLabel control={<Switch checked={statusCreateData.benoetigt_typ_freigabe ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" />
</FormDialog>
{/* ──── Create Priority Dialog ──── */}
<FormDialog
open={prioCreateOpen}
onClose={() => setPrioCreateOpen(false)}
onSubmit={() => createPrioMut.mutate(prioCreateData)}
title="Neue Priorität"
submitLabel="Erstellen"
isSubmitting={createPrioMut.isPending}
>
<TextField label="Schlüssel" required fullWidth value={prioCreateData.schluessel || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus />
<TextField label="Bezeichnung" required fullWidth value={prioCreateData.bezeichnung || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} />
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><HexColorInput value={prioCreateData.farbe || '#9e9e9e'} onChange={(v) => setPrioCreateData({ ...prioCreateData, farbe: v })} /></Box>
<TextField label="Sortierung" type="number" value={prioCreateData.sort_order ?? 0} onChange={(e) => setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} />
</FormDialog>
{/* ──── Create Kategorie Dialog ──── */}
<FormDialog
open={typeCreateOpen}
onClose={() => setTypeCreateOpen(false)}
onSubmit={() => createTypeMut.mutate(typeCreateData)}
title="Neue Kategorie"
submitLabel="Erstellen"
isSubmitting={createTypeMut.isPending}
>
<TextField label="Name" required fullWidth value={typeCreateData.name || ''} onChange={(e) => setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus />
<FormControl fullWidth><InputLabel>Übergeordnete Kategorie</InputLabel><Select value={typeCreateData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setTypeCreateData({ ...typeCreateData, parent_id: e.target.value ? Number(e.target.value) : null })}><MenuItem value="">Keine</MenuItem>{types.filter(t => !t.parent_id).map(t => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}</Select></FormControl>
<FormControl fullWidth><InputLabel>Icon</InputLabel><Select value={typeCreateData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setTypeCreateData({ ...typeCreateData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select></FormControl>
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={ICON_COLORS} value={typeCreateData.farbe || 'action'} onChange={(v) => setTypeCreateData({ ...typeCreateData, farbe: v })} /></Box>
<FormControlLabel control={<Switch checked={typeCreateData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" />
<TextField label="Sortierung" type="number" value={typeCreateData.sort_order ?? 0} onChange={(e) => setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} />
</FormDialog>
</Box>
);
}
// ── Main Page ──
export default function Issues() {
@@ -563,9 +232,8 @@ export default function Issues() {
{ label: 'Zugewiesene Issues', key: 'assigned' },
];
if (canViewAll) t.push({ label: 'Alle Issues', key: 'all' });
if (hasEditSettings) t.push({ label: 'Einstellungen', key: 'settings' });
return t;
}, [canViewAll, hasEditSettings]);
}, [canViewAll]);
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
const tab = isNaN(tabParam) || tabParam < 0 || tabParam >= tabs.length ? 0 : tabParam;
@@ -649,19 +317,28 @@ export default function Issues() {
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h5">Issues</Typography>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={handleViewModeChange}
size="small"
>
<ToggleButton value="list" aria-label="Listenansicht">
<ViewListIcon fontSize="small" />
</ToggleButton>
<ToggleButton value="kanban" aria-label="Kanban-Ansicht">
<ViewKanbanIcon fontSize="small" />
</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{hasEditSettings && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=issues')}>
<SettingsIcon />
</IconButton>
</Tooltip>
)}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={handleViewModeChange}
size="small"
>
<ToggleButton value="list" aria-label="Listenansicht">
<ViewListIcon fontSize="small" />
</ToggleButton>
<ToggleButton value="kanban" aria-label="Kanban-Ansicht">
<ViewKanbanIcon fontSize="small" />
</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
@@ -738,12 +415,6 @@ export default function Issues() {
</TabPanel>
)}
{/* Tab: Einstellungen (conditional) */}
{hasEditSettings && (
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'settings')}>
<IssueSettings />
</TabPanel>
)}
</Box>
{/* FAB */}

View File

@@ -30,14 +30,12 @@ import {
Skeleton,
Stack,
Switch,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
@@ -66,7 +64,7 @@ import {
ViewDay as ViewDayIcon,
ViewWeek as ViewWeekIcon,
} from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ServiceModePage from '../components/shared/ServiceModePage';
import ChatAwareFab from '../components/shared/ChatAwareFab';
@@ -1570,154 +1568,6 @@ function VeranstaltungFormDialog({
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Settings Tab (Kategorien CRUD)
// ──────────────────────────────────────────────────────────────────────────────
interface SettingsTabProps {
kategorien: VeranstaltungKategorie[];
onKategorienChange: (k: VeranstaltungKategorie[]) => void;
}
function SettingsTab({ kategorien, onKategorienChange }: SettingsTabProps) {
const notification = useNotification();
const [editingKat, setEditingKat] = useState<VeranstaltungKategorie | null>(null);
const [newKatOpen, setNewKatOpen] = useState(false);
const [newKatForm, setNewKatForm] = useState({ name: '', farbe: '#1976d2', beschreibung: '' });
const [saving, setSaving] = useState(false);
const reload = async () => {
const kat = await eventsApi.getKategorien();
onKategorienChange(kat);
};
const handleCreate = async () => {
if (!newKatForm.name.trim()) return;
setSaving(true);
try {
await eventsApi.createKategorie({ name: newKatForm.name.trim(), farbe: newKatForm.farbe, beschreibung: newKatForm.beschreibung || undefined });
notification.showSuccess('Kategorie erstellt');
setNewKatOpen(false);
setNewKatForm({ name: '', farbe: '#1976d2', beschreibung: '' });
await reload();
} catch { notification.showError('Fehler beim Erstellen'); }
finally { setSaving(false); }
};
const handleUpdate = async () => {
if (!editingKat) return;
setSaving(true);
try {
await eventsApi.updateKategorie(editingKat.id, { name: editingKat.name, farbe: editingKat.farbe, beschreibung: editingKat.beschreibung ?? undefined });
notification.showSuccess('Kategorie gespeichert');
setEditingKat(null);
await reload();
} catch { notification.showError('Fehler beim Speichern'); }
finally { setSaving(false); }
};
const handleDelete = async (id: string) => {
try {
await eventsApi.deleteKategorie(id);
notification.showSuccess('Kategorie gelöscht');
await reload();
} catch { notification.showError('Fehler beim Löschen'); }
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>Veranstaltungskategorien</Typography>
<Button startIcon={<Add />} variant="contained" size="small" onClick={() => setNewKatOpen(true)}>
Neue Kategorie
</Button>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Farbe</TableCell>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kategorien.map((k) => (
<TableRow key={k.id}>
<TableCell>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', bgcolor: k.farbe, border: '1px solid', borderColor: 'divider' }} />
</TableCell>
<TableCell>{k.name}</TableCell>
<TableCell>{k.beschreibung ?? '—'}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setEditingKat({ ...k })}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDelete(k.id)}>
<DeleteForeverIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{kategorien.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 3, color: 'text.secondary' }}>
Noch keine Kategorien vorhanden
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Edit dialog */}
<Dialog open={Boolean(editingKat)} onClose={() => setEditingKat(null)} maxWidth="xs" fullWidth>
<DialogTitle>Kategorie bearbeiten</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Name" value={editingKat?.name ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">Farbe</Typography>
<input type="color" value={editingKat?.farbe ?? '#1976d2'} onChange={(e) => setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
<Typography variant="body2" color="text.secondary">{editingKat?.farbe}</Typography>
</Box>
<TextField label="Beschreibung" value={editingKat?.beschreibung ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditingKat(null)}>Abbrechen</Button>
<Button variant="contained" onClick={handleUpdate} disabled={saving || !editingKat?.name.trim()}>
{saving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
{/* New category dialog */}
<Dialog open={newKatOpen} onClose={() => setNewKatOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Name" value={newKatForm.name} onChange={(e) => setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">Farbe</Typography>
<input type="color" value={newKatForm.farbe} onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
<Typography variant="body2" color="text.secondary">{newKatForm.farbe}</Typography>
</Box>
<TextField label="Beschreibung" value={newKatForm.beschreibung} onChange={(e) => setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewKatOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !newKatForm.name.trim()}>
{saving ? <CircularProgress size={20} /> : 'Erstellen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Main Kalender Page
@@ -1732,11 +1582,6 @@ export default function Kalender() {
const canWriteEvents = hasPermission('kalender:create');
// ── Tab / search params ───────────────────────────────────────────────────
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = Number(searchParams.get('tab') ?? 0);
const setActiveTab = (n: number) => setSearchParams({ tab: String(n) });
// ── Calendar state ─────────────────────────────────────────────────────────
const today = new Date();
const [viewMonth, setViewMonth] = useState({
@@ -2031,16 +1876,15 @@ export default function Kalender() {
<Typography variant="h5" sx={{ fontWeight: 700, flexGrow: 1 }}>
Kalender
</Typography>
{hasPermission('kalender:configure') && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=kalender')}>
<SettingsIcon />
</IconButton>
</Tooltip>
)}
</Box>
{canWriteEvents ? (
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tab label="Kalender" value={0} />
<Tab icon={<SettingsIcon fontSize="small" />} iconPosition="start" label="Einstellungen" value={1} />
</Tabs>
) : null}
{activeTab === 0 && (
<>
{/* ── Calendar ───────────────────────────────────────────── */}
<Box>
@@ -2630,11 +2474,6 @@ export default function Kalender() {
</Dialog>
</Box>
</>
)}
{activeTab === 1 && canWriteEvents && (
<SettingsTab kategorien={kategorien} onKategorienChange={setKategorien} />
)}
</Box>

View File

@@ -15,17 +15,21 @@ import {
IconButton,
Tooltip,
} from '@mui/material';
import { Search as SearchIcon, OpenInNew } from '@mui/icons-material';
import { Search as SearchIcon, OpenInNew, Settings } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DOMPurify from 'dompurify';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { bookstackApi } from '../services/bookstack';
import { safeOpenUrl } from '../utils/safeOpenUrl';
import { usePermissionContext } from '../contexts/PermissionContext';
import type { BookStackPage, BookStackSearchResult } from '../types/bookstack.types';
export default function Wissen() {
const navigate = useNavigate();
const { hasPermission } = usePermissionContext();
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
@@ -89,7 +93,17 @@ export default function Wissen() {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', height: 'calc(100vh - 120px)', gap: 2, p: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
{hasPermission('wissen:configure') && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 2, pt: 1 }}>
<Tooltip title="Einstellungen">
<IconButton size="small" onClick={() => navigate('/admin/settings?tab=1&subtab=wissen')}>
<Settings fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
<Box sx={{ display: 'flex', flex: 1, gap: 2, p: 2, minHeight: 0 }}>
{/* Left panel: search + list */}
<Paper sx={{ width: '40%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ p: 2 }}>
@@ -274,6 +288,7 @@ export default function Wissen() {
</Typography>
)}
</Paper>
</Box>
</Box>
</DashboardLayout>
);

View File

@@ -0,0 +1,18 @@
import { api } from './api';
import type { ToolConfig, ToolTestResult } from '../types/toolConfig.types';
interface ApiResponse<T> {
success: boolean;
data: T;
}
export const toolConfigApi = {
get: (tool: string): Promise<ToolConfig> =>
api.get<ApiResponse<ToolConfig>>(`/api/admin/tools/config/${tool}`).then(r => r.data.data),
update: (tool: string, config: Partial<ToolConfig>): Promise<void> =>
api.put(`/api/admin/tools/config/${tool}`, config).then(() => {}),
test: (tool: string, config?: Partial<ToolConfig>): Promise<ToolTestResult> =>
api.post<ApiResponse<ToolTestResult>>(`/api/admin/tools/config/${tool}/test`, config ?? {}).then(r => r.data.data),
};

View File

@@ -0,0 +1,12 @@
export interface ToolConfig {
url: string;
tokenId?: string; // bookstack only
tokenSecret?: string; // bookstack only (masked — last 4 chars visible)
apiToken?: string; // vikunja only (masked)
}
export interface ToolTestResult {
success: boolean;
message: string;
latencyMs: number;
}