diff --git a/backend/src/app.ts b/backend/src/app.ts index 45353f5..b91de9a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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'); diff --git a/backend/src/controllers/toolConfig.controller.ts b/backend/src/controllers/toolConfig.controller.ts new file mode 100644 index 0000000..6968849 --- /dev/null +++ b/backend/src/controllers/toolConfig.controller.ts @@ -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 = { + 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): Record { + const result: Record = {}; + 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 { + 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; + if (tool === 'bookstack') { + config = await toolConfigService.getBookstackConfig() as unknown as Record; + } else if (tool === 'vikunja') { + config = await toolConfigService.getVikunjaConfig() as unknown as Record; + } else { + config = await toolConfigService.getNextcloudConfig() as unknown as Record; + } + 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 { + 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 { + 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 } = {}; + + 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(); diff --git a/backend/src/database/migrations/092_tool_configure_permissions.sql b/backend/src/database/migrations/092_tool_configure_permissions.sql new file mode 100644 index 0000000..f268551 --- /dev/null +++ b/backend/src/database/migrations/092_tool_configure_permissions.sql @@ -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; diff --git a/backend/src/database/migrations/093_module_configure_permissions.sql b/backend/src/database/migrations/093_module_configure_permissions.sql new file mode 100644 index 0000000..2d9b333 --- /dev/null +++ b/backend/src/database/migrations/093_module_configure_permissions.sql @@ -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; diff --git a/backend/src/routes/toolConfig.routes.ts b/backend/src/routes/toolConfig.routes.ts new file mode 100644 index 0000000..5e74109 --- /dev/null +++ b/backend/src/routes/toolConfig.routes.ts @@ -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 = { + 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; diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index 909f022..7f42046 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -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 { - const { bookstack } = environment; +function buildHeaders(config: BookstackConfig): Record { 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> { 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> { } async function getRecentPages(): Promise { - 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 { 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 { } async function searchPages(query: string): Promise { - 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 { 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 { - 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 { 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, diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts index 94c7eb3..d211daa 100644 --- a/backend/src/services/nextcloud.service.ts +++ b/backend/src/services/nextcloud.service.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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> { - 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> { - 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'); } diff --git a/backend/src/services/toolConfig.service.ts b/backend/src/services/toolConfig.service.ts new file mode 100644 index 0000000..85241c6 --- /dev/null +++ b/backend/src/services/toolConfig.service.ts @@ -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 { + data: T; + expiresAt: number; +} + +const CACHE_TTL_MS = 60_000; // 60 seconds + +let bookstackCache: CacheEntry | null = null; +let vikunjaCache: CacheEntry | null = null; +let nextcloudCache: CacheEntry | null = null; + +async function getDbConfig(key: string): Promise> { + const setting = await settingsService.get(key); + if (!setting?.value || typeof setting.value !== 'object') return {}; + return setting.value as Record; +} + +async function getBookstackConfig(): Promise { + 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 { + 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 { + 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, +}; diff --git a/backend/src/services/vikunja.service.ts b/backend/src/services/vikunja.service.ts index 6fc1017..16d7616 100644 --- a/backend/src/services/vikunja.service.ts +++ b/backend/src/services/vikunja.service.ts @@ -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 { +function buildHeaders(config: VikunjaConfig): Record { return { - 'Authorization': `Bearer ${environment.vikunja.apiToken}`, + 'Authorization': `Bearer ${config.apiToken}`, 'Content-Type': 'application/json', }; } async function getMyTasks(): Promise { - 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( - `${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 { } async function getProjects(): Promise { - 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( - `${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 { } async function createTask(projectId: number, title: string, dueDate?: string): Promise { - 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( - `${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) { diff --git a/frontend/src/components/admin/ModuleSettingsAusruestung.tsx b/frontend/src/components/admin/ModuleSettingsAusruestung.tsx new file mode 100644 index 0000000..41f0088 --- /dev/null +++ b/frontend/src/components/admin/ModuleSettingsAusruestung.tsx @@ -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(null); + const [formName, setFormName] = useState(''); + const [formBeschreibung, setFormBeschreibung] = useState(''); + const [formIcon, setFormIcon] = useState(''); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingTyp, setDeletingTyp] = useState(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 Im Wartungsmodus; + } + + return ( + + Ausrüstungstypen + + {isLoading && ( + + + + )} + + {isError && ( + + Typen konnten nicht geladen werden. + + )} + + {!isLoading && !isError && ( + + + + + + Name + Beschreibung + Icon + Aktionen + + + + {typen.length === 0 && ( + + + + Noch keine Typen vorhanden. + + + + )} + {typen.map((typ) => ( + + {typ.name} + {typ.beschreibung || '---'} + {typ.icon || '---'} + + + openEditDialog(typ)}> + + + + + openDeleteDialog(typ)}> + + + + + + ))} + +
+
+ + + +
+ )} + + {/* Add/Edit dialog */} + + setFormName(e.target.value)} + inputProps={{ maxLength: 100 }} + /> + setFormBeschreibung(e.target.value)} + /> + setFormIcon(e.target.value)} + placeholder="z.B. Build, LocalFireDepartment" + /> + + + {/* Delete confirmation dialog */} + setDeleteDialogOpen(false)} + onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)} + title="Typ löschen" + message={<>Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.} + confirmLabel="Löschen" + confirmColor="error" + isLoading={deleteMutation.isPending} + /> +
+ ); +} diff --git a/frontend/src/components/admin/ModuleSettingsFahrzeugbuchungen.tsx b/frontend/src/components/admin/ModuleSettingsFahrzeugbuchungen.tsx new file mode 100644 index 0000000..f9f8baa --- /dev/null +++ b/frontend/src/components/admin/ModuleSettingsFahrzeugbuchungen.tsx @@ -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(null); + const [editRowData, setEditRowData] = useState>({}); + 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) => 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 }) => + 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 Im Wartungsmodus; + } + + if (isLoading) { + return ( + + + + + ); + } + + if (isError) { + return Fehler beim Laden der Buchungskategorien; + } + + return ( + + + Buchungskategorien + + + + + + + + Bezeichnung + Farbe + Sortierung + Aktiv + Aktionen + + + + {kategorien.map((kat) => { + const isEditing = editRowId === kat.id; + return ( + + + {isEditing ? ( + + setEditRowData((d) => ({ ...d, bezeichnung: e.target.value })) + } + /> + ) : ( + kat.bezeichnung + )} + + + {isEditing ? ( + + setEditRowData((d) => ({ ...d, farbe: e.target.value })) + } + style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }} + /> + ) : ( + + )} + + + {isEditing ? ( + + setEditRowData((d) => ({ + ...d, + sort_order: parseInt(e.target.value) || 0, + })) + } + sx={{ width: 80 }} + /> + ) : ( + kat.sort_order + )} + + + {isEditing ? ( + + setEditRowData((d) => ({ ...d, aktiv: e.target.checked })) + } + /> + ) : ( + + )} + + + {isEditing ? ( + + + updateKatMutation.mutate({ id: kat.id, data: editRowData }) + } + > + + + { + setEditRowId(null); + setEditRowData({}); + }} + > + + + + ) : ( + + { + setEditRowId(kat.id); + setEditRowData({ + bezeichnung: kat.bezeichnung, + farbe: kat.farbe, + sort_order: kat.sort_order, + aktiv: kat.aktiv, + }); + }} + > + + + deleteKatMutation.mutate(kat.id)} + > + + + + )} + + + ); + })} + {kategorien.length === 0 && ( + + + + Keine Kategorien vorhanden + + + + )} + +
+
+ + {/* New category dialog */} + setNewKatDialog(false)} + maxWidth="xs" + fullWidth + > + Neue Kategorie + + + + setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value })) + } + /> + + + Farbe + + setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} + style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }} + /> + + + + + + + + +
+ ); +} diff --git a/frontend/src/components/admin/ModuleSettingsFahrzeuge.tsx b/frontend/src/components/admin/ModuleSettingsFahrzeuge.tsx new file mode 100644 index 0000000..40e50a1 --- /dev/null +++ b/frontend/src/components/admin/ModuleSettingsFahrzeuge.tsx @@ -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(null); + const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' }); + const [deleteError, setDeleteError] = useState(null); + + const createMutation = useMutation({ + mutationFn: (data: Partial) => 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 }) => + 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 Im Wartungsmodus; + } + + return ( + + + Fahrzeugtypen + + + {deleteError && ( + setDeleteError(null)}> + {deleteError} + + )} + + {isLoading ? ( + + + + ) : ( + <> + + + + + + + + + Name + Beschreibung + Icon + Aktionen + + + + {fahrzeugTypen.length === 0 ? ( + + + Keine Fahrzeugtypen vorhanden + + + ) : ( + fahrzeugTypen.map((t) => ( + + {t.name} + {t.beschreibung ?? '–'} + {t.icon ?? '–'} + + openEdit(t)}> + + + deleteMutation.mutate(t.id)} + > + + + + + )) + )} + +
+
+ + )} + + setDialogOpen(false)} + onSubmit={handleSubmit} + title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'} + isSubmitting={isSaving} + > + setForm((f) => ({ ...f, name: e.target.value }))} + /> + setForm((f) => ({ ...f, beschreibung: e.target.value }))} + /> + setForm((f) => ({ ...f, icon: e.target.value }))} + placeholder="z.B. fire_truck" + /> + +
+ ); +} diff --git a/frontend/src/components/admin/ModuleSettingsIssues.tsx b/frontend/src/components/admin/ModuleSettingsIssues.tsx new file mode 100644 index 0000000..2eeae79 --- /dev/null +++ b/frontend/src/components/admin/ModuleSettingsIssues.tsx @@ -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 = { 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 = { + BugReport: , + FiberNew: , + HelpOutline: , +}; + +function getTypIcon(iconName: string | null, farbe: string | null): JSX.Element { + const icon = ICON_MAP[iconName || ''] || ; + const colorProp = farbe === 'error' ? 'error' : farbe === 'info' ? 'info' : farbe === 'action' ? 'action' : 'action'; + return {icon}; +} + +function ColorSwatch({ colors, value, onChange }: { colors: readonly string[]; value: string; onChange: (v: string) => void }) { + return ( + + {colors.map((c) => ( + 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 }, + }} + /> + ))} + + ); +} + +function HexColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( + + ) => onChange(e.target.value)} + sx={{ width: 28, height: 28, border: 'none', borderRadius: 1, cursor: 'pointer', p: 0, bgcolor: 'transparent' }} + /> + {value} + + ); +} + +// ── 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>({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 }); + const [statusEditId, setStatusEditId] = useState(null); + const [statusEditData, setStatusEditData] = useState>({}); + + // ── Priority state ── + const [prioCreateOpen, setPrioCreateOpen] = useState(false); + const [prioCreateData, setPrioCreateData] = useState>({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 }); + const [prioEditId, setPrioEditId] = useState(null); + const [prioEditData, setPrioEditData] = useState>({}); + + // ── Kategorien state ── + const [typeCreateOpen, setTypeCreateOpen] = useState(false); + const [typeCreateData, setTypeCreateData] = useState>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); + const [typeEditId, setTypeEditId] = useState(null); + const [typeEditData, setTypeEditData] = useState>({}); + + // ── 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) => 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 }) => 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) => 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 }) => 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) => 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 }) => 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 Im Wartungsmodus; + } + + return ( + + + {/* ──── Section 1: Status ──── */} + + + Status + + + {statusLoading ? : ( + + + + + Bezeichnung + Schlüssel + Farbe + Abschluss + Initial + Sort + Aktiv + Aktionen + + + + {issueStatuses.length === 0 ? ( + Keine Status vorhanden + ) : issueStatuses.map((s) => ( + + {statusEditId === s.id ? (<> + setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /> + {s.schluessel} + setStatusEditData({ ...statusEditData, farbe: v })} /> + setStatusEditData({ ...statusEditData, ist_abschluss: e.target.checked })} size="small" /> + setStatusEditData({ ...statusEditData, ist_initial: e.target.checked })} size="small" /> + setStatusEditData({ ...statusEditData, sort_order: parseInt(e.target.value) || 0 })} /> + setStatusEditData({ ...statusEditData, aktiv: e.target.checked })} size="small" /> + updateStatusMut.mutate({ id: s.id, data: statusEditData })}> setStatusEditId(null)}> + ) : (<> + + {s.schluessel} + + {s.ist_abschluss ? '\u2713' : '-'} + {s.ist_initial ? '\u2713' : '-'} + {s.sort_order} + updateStatusMut.mutate({ id: s.id, data: { aktiv: e.target.checked } })} size="small" /> + { 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 }); }}> deleteStatusMut.mutate(s.id)}> + )} + + ))} + +
+
+ )} +
+ + {/* ──── Section 2: Prioritäten ──── */} + + + Prioritäten + + + {prioLoading ? : ( + + + + + Bezeichnung + Schlüssel + Farbe + Sort + Aktiv + Aktionen + + + + {issuePriorities.length === 0 ? ( + Keine Prioritäten vorhanden + ) : issuePriorities.map((p) => ( + + {prioEditId === p.id ? (<> + setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /> + {p.schluessel} + setPrioEditData({ ...prioEditData, farbe: v })} /> + setPrioEditData({ ...prioEditData, sort_order: parseInt(e.target.value) || 0 })} /> + setPrioEditData({ ...prioEditData, aktiv: e.target.checked })} size="small" /> + updatePrioMut.mutate({ id: p.id, data: prioEditData })}> setPrioEditId(null)}> + ) : (<> + {p.bezeichnung} + {p.schluessel} + + {p.sort_order} + updatePrioMut.mutate({ id: p.id, data: { aktiv: e.target.checked } })} size="small" /> + { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}> deletePrioMut.mutate(p.id)}> + )} + + ))} + +
+
+ )} +
+ + {/* ──── Section 3: Kategorien ──── */} + + + Kategorien + + + {typesLoading ? : ( + + + + + Name + Icon + Farbe + Abgelehnt + Sort + Aktiv + Aktionen + + + + {flatTypes.length === 0 ? ( + Keine Kategorien vorhanden + ) : flatTypes.map(({ type: t, indent }) => ( + + {indent && }{typeEditId === t.id ? setTypeEditData({ ...typeEditData, name: e.target.value })} /> : {t.name}} + {typeEditId === t.id ? () : {getTypIcon(t.icon, t.farbe)}} + {typeEditId === t.id ? setTypeEditData({ ...typeEditData, farbe: v })} /> : } + {typeEditId === t.id ? setTypeEditData({ ...typeEditData, erlaubt_abgelehnt: e.target.checked })} size="small" /> : (t.erlaubt_abgelehnt ? '\u2713' : '-')} + {typeEditId === t.id ? setTypeEditData({ ...typeEditData, sort_order: parseInt(e.target.value) || 0 })} /> : t.sort_order} + { if (typeEditId === t.id) setTypeEditData({ ...typeEditData, aktiv: e.target.checked }); else updateTypeMut.mutate({ id: t.id, data: { aktiv: e.target.checked } }); }} size="small" /> + {typeEditId === t.id ? ( updateTypeMut.mutate({ id: t.id, data: typeEditData })}> setTypeEditId(null)}>) : ( { 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 }); }}> deleteTypeMut.mutate(t.id)}>)} + + ))} + +
+
+ )} +
+ + {/* ──── Create Status Dialog ──── */} + setStatusCreateOpen(false)} + onSubmit={() => createStatusMut.mutate(statusCreateData)} + title="Neuer Status" + submitLabel="Erstellen" + isSubmitting={createStatusMut.isPending} + > + setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus /> + setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} /> + Farbe setStatusCreateData({ ...statusCreateData, farbe: v })} /> + setStatusCreateData({ ...statusCreateData, sort_order: parseInt(e.target.value) || 0 })} /> + setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" /> + setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" /> + setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" /> + + + {/* ──── Create Priority Dialog ──── */} + setPrioCreateOpen(false)} + onSubmit={() => createPrioMut.mutate(prioCreateData)} + title="Neue Priorität" + submitLabel="Erstellen" + isSubmitting={createPrioMut.isPending} + > + setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus /> + setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} /> + Farbe setPrioCreateData({ ...prioCreateData, farbe: v })} /> + setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} /> + + + {/* ──── Create Kategorie Dialog ──── */} + setTypeCreateOpen(false)} + onSubmit={() => createTypeMut.mutate(typeCreateData)} + title="Neue Kategorie" + submitLabel="Erstellen" + isSubmitting={createTypeMut.isPending} + > + setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus /> + Übergeordnete Kategorie + Icon + Farbe setTypeCreateData({ ...typeCreateData, farbe: v })} /> + setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" /> + setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} /> + + +
+ ); +} diff --git a/frontend/src/components/admin/ModuleSettingsKalender.tsx b/frontend/src/components/admin/ModuleSettingsKalender.tsx new file mode 100644 index 0000000..387dd41 --- /dev/null +++ b/frontend/src/components/admin/ModuleSettingsKalender.tsx @@ -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(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 Im Wartungsmodus; + } + + if (isLoading) { + return ( + + + + + ); + } + + if (isError) { + return Fehler beim Laden der Kategorien; + } + + return ( + + + Veranstaltungskategorien + + + + + + + + Farbe + Name + Beschreibung + Aktionen + + + + {kategorien.map((k) => ( + + + + + {k.name} + {k.beschreibung ?? '—'} + + setEditingKat({ ...k })}> + + + handleDelete(k.id)}> + + + + + ))} + {kategorien.length === 0 && ( + + + Noch keine Kategorien vorhanden + + + )} + +
+
+ + {/* Edit dialog */} + setEditingKat(null)} maxWidth="xs" fullWidth> + Kategorie bearbeiten + + + setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required /> + + Farbe + setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} /> + {editingKat?.farbe} + + setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} /> + + + + + + + + + {/* New category dialog */} + setNewKatOpen(false)} maxWidth="xs" fullWidth> + Neue Kategorie + + + setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required /> + + Farbe + setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} /> + {newKatForm.farbe} + + setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} /> + + + + + + + +
+ ); +} diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index aef9d6d..b189711 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -91,6 +91,7 @@ function buildReverseHierarchy(hierarchy: Record): Record> = { kalender: { 'Termine': ['view', 'create'], + 'Einstellungen': ['configure'], }, fahrzeugbuchungen: { 'Buchungen': ['view', 'create', 'manage'], @@ -112,6 +113,22 @@ const PERMISSION_SUB_GROUPS: Record> = { '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'], diff --git a/frontend/src/components/admin/ToolSettingsBookstack.tsx b/frontend/src/components/admin/ToolSettingsBookstack.tsx new file mode 100644 index 0000000..e7f8a3b --- /dev/null +++ b/frontend/src/components/admin/ToolSettingsBookstack.tsx @@ -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 = { 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 ( + + + + ); + } + + return ( + + + BookStack + + + {!isFeatureEnabled('wissen') && ( + + Dieses Werkzeug befindet sich im Wartungsmodus. + + )} + + + setUrl(e.target.value)} + size="small" + fullWidth + placeholder="https://bookstack.example.com" + /> + setTokenId(e.target.value)} + size="small" + fullWidth + /> + setTokenSecret(e.target.value)} + size="small" + fullWidth + helperText="Leer lassen um vorhandenen Wert zu behalten" + /> + + + + + {testResult && ( + + )} + + + + + ); +} diff --git a/frontend/src/components/admin/ToolSettingsNextcloud.tsx b/frontend/src/components/admin/ToolSettingsNextcloud.tsx new file mode 100644 index 0000000..56606e0 --- /dev/null +++ b/frontend/src/components/admin/ToolSettingsNextcloud.tsx @@ -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 ( + + + + ); + } + + return ( + + + Nextcloud + + + {!isFeatureEnabled('nextcloud') && ( + + Dieses Werkzeug befindet sich im Wartungsmodus. + + )} + + + setUrl(e.target.value)} + size="small" + fullWidth + placeholder="https://nextcloud.example.com" + /> + + + + + {testResult && ( + + )} + + + + + ); +} diff --git a/frontend/src/components/admin/ToolSettingsVikunja.tsx b/frontend/src/components/admin/ToolSettingsVikunja.tsx new file mode 100644 index 0000000..c162bdb --- /dev/null +++ b/frontend/src/components/admin/ToolSettingsVikunja.tsx @@ -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 = { 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 ( + + + + ); + } + + return ( + + + Vikunja + + + {!isFeatureEnabled('vikunja') && ( + + Dieses Werkzeug befindet sich im Wartungsmodus. + + )} + + + setUrl(e.target.value)} + size="small" + fullWidth + placeholder="https://vikunja.example.com" + /> + setApiToken(e.target.value)} + size="small" + fullWidth + helperText="Leer lassen um vorhandenen Wert zu behalten" + /> + + + + + {testResult && ( + + )} + + + + + ); +} diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx index b0def15..f5f8387 100644 --- a/frontend/src/pages/AdminSettings.tsx +++ b/frontend/src/pages/AdminSettings.tsx @@ -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([]); @@ -350,16 +390,17 @@ function AdminSettings() { { setTab(v); navigate(`/admin/settings?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto" > - - + {isFullAdmin && } + + {isFullAdmin && ( {/* Section 1: General Settings (App Logo) */} @@ -653,100 +694,144 @@ function AdminSettings() { + )} - - {/* Zustandsoptionen (Persönliche Ausrüstung) */} - - - - - Zustandsoptionen — Persönliche Ausrüstung - - - - Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt. - - - {zustandOptions.map((opt, idx) => ( - - - setZustandOptions((prev) => - prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o)) - ) - } - size="small" - sx={{ flex: 1 }} - /> - - setZustandOptions((prev) => - prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o)) - ) - } - size="small" - sx={{ flex: 1 }} - /> - - Farbe - + setZustandOptions((prev) => + prev.map((o, i) => (i === idx ? { ...o, color: e.target.value } : o)) + ) + } + > + Grün + Gelb + Rot + Grau + Blau + Primär + + + + setZustandOptions((prev) => prev.filter((_, i) => i !== idx)) + } + aria-label="Option entfernen" + size="small" > - Grün - Gelb - Rot - Grau - Blau - Primär - - - + + + ))} + + + - ))} - - - - - - - - + + + + + )} + {visibleSubTabs[werkzeugeSubTab]?.key === 'kalender' && ( + + )} + {visibleSubTabs[werkzeugeSubTab]?.key === 'fahrzeugbuchungen' && ( + + )} + {visibleSubTabs[werkzeugeSubTab]?.key === 'ausruestung-typen' && ( + + )} + {visibleSubTabs[werkzeugeSubTab]?.key === 'fahrzeuge' && ( + + )} + {visibleSubTabs[werkzeugeSubTab]?.key === 'issues' && ( + + )} diff --git a/frontend/src/pages/Ausruestung.tsx b/frontend/src/pages/Ausruestung.tsx index 7ad4357..07e581f 100644 --- a/frontend/src/pages/Ausruestung.tsx +++ b/frontend/src/pages/Ausruestung.tsx @@ -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 = ({ 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(null); - const [formName, setFormName] = useState(''); - const [formBeschreibung, setFormBeschreibung] = useState(''); - const [formIcon, setFormIcon] = useState(''); - - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deletingTyp, setDeletingTyp] = useState(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 ( - - Ausrüstungstypen - - {isLoading && ( - - - - )} - - {isError && ( - - Typen konnten nicht geladen werden. - - )} - - {!isLoading && !isError && ( - - - - - - Name - Beschreibung - Icon - Aktionen - - - - {typen.length === 0 && ( - - - - Noch keine Typen vorhanden. - - - - )} - {typen.map((typ) => ( - - {typ.name} - {typ.beschreibung || '---'} - {typ.icon || '---'} - - - openEditDialog(typ)}> - - - - - openDeleteDialog(typ)}> - - - - - - ))} - -
-
- - - -
- )} - - {/* Add/Edit dialog */} - - setFormName(e.target.value)} - inputProps={{ maxLength: 100 }} - /> - setFormBeschreibung(e.target.value)} - /> - setFormIcon(e.target.value)} - placeholder="z.B. Build, LocalFireDepartment" - /> - - - {/* Delete confirmation dialog */} - setDeleteDialogOpen(false)} - onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)} - title="Typ löschen" - message={<>Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.} - confirmLabel="Löschen" - confirmColor="error" - isLoading={deleteMutation.isPending} - /> -
- ); -} // ── 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() {
)}
+ {canManageTypes && ( + + navigate('/admin/settings?tab=1&subtab=ausruestung-typen')}> + + + + )} - setSearchParams({ tab: String(v) })} - sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }} - > - - {canManageTypes && } - - - {tab === 0 && ( <> {/* Overdue alert */} {hasOverdue && ( @@ -735,11 +508,7 @@ function Ausruestung() { )} - )} - {tab === 1 && canManageTypes && ( - - )} ); diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index fb4d7b5..a481c32 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -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()); @@ -44,16 +50,27 @@ const ChatContent: React.FC = () => { }, [selectedRoomToken, queryClient]); return ( - + + {hasPermission('nextcloud:configure') && ( + + + navigate('/admin/settings?tab=1&subtab=nextcloud')}> + + + + + )} + {!connected ? ( @@ -89,6 +106,7 @@ const ChatContent: React.FC = () => { )} + ); }; diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index 3c0b944..e9cc8f0 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -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(null); - const [editRowData, setEditRowData] = useState>({}); - const [newKatDialog, setNewKatDialog] = useState(false); - const [newKatForm, setNewKatForm] = useState({ bezeichnung: '', farbe: '#1976d2' }); - - const createKatMutation = useMutation({ - mutationFn: (data: Omit) => 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 }) => - 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 ; @@ -244,23 +186,23 @@ function FahrzeugBuchungen() { Fahrzeugbuchungen - + + {canManage && ( + + navigate('/admin/settings?tab=1&subtab=fahrzeugbuchungen')}> + + + + )} + + - {/* Tabs */} - - setTabIndex(v)}> - - {canManage && } - - - - {/* ── Tab 0: Buchungen ─────────────────────────────────────────────── */} - {tabIndex === 0 && ( - <> - {/* Filters */} + {/* ── Buchungen ─────────────────────────────────────────────── */} + <> + {/* Filters */} ))} - )} - - {/* ── Tab 1: Einstellungen ─────────────────────────────────────────── */} - {tabIndex === 1 && canManage && ( - - - Buchungskategorien - - - - - - - - Bezeichnung - Farbe - Sortierung - Aktiv - Aktionen - - - - {kategorien.map((kat) => { - const isEditing = editRowId === kat.id; - return ( - - - {isEditing ? ( - - setEditRowData((d) => ({ ...d, bezeichnung: e.target.value })) - } - /> - ) : ( - kat.bezeichnung - )} - - - {isEditing ? ( - - setEditRowData((d) => ({ ...d, farbe: e.target.value })) - } - style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }} - /> - ) : ( - - )} - - - {isEditing ? ( - - setEditRowData((d) => ({ - ...d, - sort_order: parseInt(e.target.value) || 0, - })) - } - sx={{ width: 80 }} - /> - ) : ( - kat.sort_order - )} - - - {isEditing ? ( - - setEditRowData((d) => ({ ...d, aktiv: e.target.checked })) - } - /> - ) : ( - - )} - - - {isEditing ? ( - - - updateKatMutation.mutate({ id: kat.id, data: editRowData }) - } - > - - - { - setEditRowId(null); - setEditRowData({}); - }} - > - - - - ) : ( - - { - setEditRowId(kat.id); - setEditRowData({ - bezeichnung: kat.bezeichnung, - farbe: kat.farbe, - sort_order: kat.sort_order, - aktiv: kat.aktiv, - }); - }} - > - - - deleteKatMutation.mutate(kat.id)} - > - - - - )} - - - ); - })} - {kategorien.length === 0 && ( - - - - Keine Kategorien vorhanden - - - - )} - -
-
-
- )} {/* ── FAB ── */} - {canCreate && tabIndex === 0 && ( + {canCreate && ( - {/* ── New category dialog ── */} - setNewKatDialog(false)} - maxWidth="xs" - fullWidth - > - Neue Kategorie - - - - setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value })) - } - /> - - - Farbe - - setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} - style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }} - /> - - - - - - - - ); diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index a90b928..b27b7f3 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -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 = ({ 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(null); - const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' }); - const [deleteError, setDeleteError] = useState(null); - - const createMutation = useMutation({ - mutationFn: (data: Partial) => 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 }) => - 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 ( - - - Fahrzeugtypen - - - {deleteError && ( - setDeleteError(null)}> - {deleteError} - - )} - - {isLoading ? ( - - - - ) : ( - <> - - - - - - - - - Name - Beschreibung - Icon - Aktionen - - - - {fahrzeugTypen.length === 0 ? ( - - - Keine Fahrzeugtypen vorhanden - - - ) : ( - fahrzeugTypen.map((t) => ( - - {t.name} - {t.beschreibung ?? '–'} - {t.icon ?? '–'} - - openEdit(t)}> - - - deleteMutation.mutate(t.id)} - > - - - - - )) - )} - -
-
- - )} - - setDialogOpen(false)} - onSubmit={handleSubmit} - title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'} - isSubmitting={isSaving} - > - setForm((f) => ({ ...f, name: e.target.value }))} - /> - setForm((f) => ({ ...f, beschreibung: e.target.value }))} - /> - setForm((f) => ({ ...f, icon: e.target.value }))} - placeholder="z.B. fire_truck" - /> - -
- ); -} // ── 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([]); @@ -489,8 +300,6 @@ function Fahrzeuge() { const [search, setSearch] = useState(''); const [equipmentWarnings, setEquipmentWarnings] = useState>(new Map()); - const canEditSettings = hasPermission('checklisten:manage_templates'); - const fetchVehicles = useCallback(async () => { try { setLoading(true); @@ -579,7 +388,14 @@ function Fahrzeuge() { )} - {tab === 0 && ( + + {hasPermission('fahrzeuge:configure') && ( + + navigate('/admin/settings?tab=1&subtab=fahrzeuge')}> + + + + )} - )} + - setSearchParams({ tab: String(v) })} - sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }} - > - - {canEditSettings && } - - - {tab === 0 && ( <> {hasOverdue && ( }> @@ -670,11 +476,7 @@ function Fahrzeuge() {
)} - )} - {tab === 1 && canEditSettings && ( - - )} ); diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx index c6d33ca..f51f769 100644 --- a/frontend/src/pages/Issues.tsx +++ b/frontend/src/pages/Issues.tsx @@ -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 = { 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 ( - - {colors.map((c) => ( - 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 }, - }} - /> - ))} - - ); -} - -function HexColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { - return ( - - ) => onChange(e.target.value)} - sx={{ width: 28, height: 28, border: 'none', borderRadius: 1, cursor: 'pointer', p: 0, bgcolor: 'transparent' }} - /> - {value} - - ); -} - -// ── 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>({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 }); - const [statusEditId, setStatusEditId] = useState(null); - const [statusEditData, setStatusEditData] = useState>({}); - - // ── Priority state ── - const [prioCreateOpen, setPrioCreateOpen] = useState(false); - const [prioCreateData, setPrioCreateData] = useState>({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 }); - const [prioEditId, setPrioEditId] = useState(null); - const [prioEditData, setPrioEditData] = useState>({}); - - // ── Kategorien state ── - const [typeCreateOpen, setTypeCreateOpen] = useState(false); - const [typeCreateData, setTypeCreateData] = useState>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); - const [typeEditId, setTypeEditId] = useState(null); - const [typeEditData, setTypeEditData] = useState>({}); - - // ── 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) => 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 }) => 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) => 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 }) => 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) => 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 }) => 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 ( - - - {/* ──── Section 1: Status ──── */} - - - Status - - - {statusLoading ? : ( - - - - - Bezeichnung - Schlüssel - Farbe - Abschluss - Initial - Sort - Aktiv - Aktionen - - - - {issueStatuses.length === 0 ? ( - Keine Status vorhanden - ) : issueStatuses.map((s) => ( - - {statusEditId === s.id ? (<> - setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /> - {s.schluessel} - setStatusEditData({ ...statusEditData, farbe: v })} /> - setStatusEditData({ ...statusEditData, ist_abschluss: e.target.checked })} size="small" /> - setStatusEditData({ ...statusEditData, ist_initial: e.target.checked })} size="small" /> - setStatusEditData({ ...statusEditData, sort_order: parseInt(e.target.value) || 0 })} /> - setStatusEditData({ ...statusEditData, aktiv: e.target.checked })} size="small" /> - updateStatusMut.mutate({ id: s.id, data: statusEditData })}> setStatusEditId(null)}> - ) : (<> - - {s.schluessel} - - {s.ist_abschluss ? '✓' : '-'} - {s.ist_initial ? '✓' : '-'} - {s.sort_order} - updateStatusMut.mutate({ id: s.id, data: { aktiv: e.target.checked } })} size="small" /> - { 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 }); }}> deleteStatusMut.mutate(s.id)}> - )} - - ))} - -
-
- )} -
- - {/* ──── Section 2: Prioritäten ──── */} - - - Prioritäten - - - {prioLoading ? : ( - - - - - Bezeichnung - Schlüssel - Farbe - Sort - Aktiv - Aktionen - - - - {issuePriorities.length === 0 ? ( - Keine Prioritäten vorhanden - ) : issuePriorities.map((p) => ( - - {prioEditId === p.id ? (<> - setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /> - {p.schluessel} - setPrioEditData({ ...prioEditData, farbe: v })} /> - setPrioEditData({ ...prioEditData, sort_order: parseInt(e.target.value) || 0 })} /> - setPrioEditData({ ...prioEditData, aktiv: e.target.checked })} size="small" /> - updatePrioMut.mutate({ id: p.id, data: prioEditData })}> setPrioEditId(null)}> - ) : (<> - {p.bezeichnung} - {p.schluessel} - - {p.sort_order} - updatePrioMut.mutate({ id: p.id, data: { aktiv: e.target.checked } })} size="small" /> - { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}> deletePrioMut.mutate(p.id)}> - )} - - ))} - -
-
- )} -
- - {/* ──── Section 3: Kategorien ──── */} - - - Kategorien - - - {typesLoading ? : ( - - - - - Name - Icon - Farbe - Abgelehnt - Sort - Aktiv - Aktionen - - - - {flatTypes.length === 0 ? ( - Keine Kategorien vorhanden - ) : flatTypes.map(({ type: t, indent }) => ( - - {indent && }{typeEditId === t.id ? setTypeEditData({ ...typeEditData, name: e.target.value })} /> : {t.name}} - {typeEditId === t.id ? () : {getTypIcon(t.icon, t.farbe)}} - {typeEditId === t.id ? setTypeEditData({ ...typeEditData, farbe: v })} /> : } - {typeEditId === t.id ? setTypeEditData({ ...typeEditData, erlaubt_abgelehnt: e.target.checked })} size="small" /> : (t.erlaubt_abgelehnt ? '✓' : '-')} - {typeEditId === t.id ? setTypeEditData({ ...typeEditData, sort_order: parseInt(e.target.value) || 0 })} /> : t.sort_order} - { if (typeEditId === t.id) setTypeEditData({ ...typeEditData, aktiv: e.target.checked }); else updateTypeMut.mutate({ id: t.id, data: { aktiv: e.target.checked } }); }} size="small" /> - {typeEditId === t.id ? ( updateTypeMut.mutate({ id: t.id, data: typeEditData })}> setTypeEditId(null)}>) : ( { 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 }); }}> deleteTypeMut.mutate(t.id)}>)} - - ))} - -
-
- )} -
- - {/* ──── Create Status Dialog ──── */} - setStatusCreateOpen(false)} - onSubmit={() => createStatusMut.mutate(statusCreateData)} - title="Neuer Status" - submitLabel="Erstellen" - isSubmitting={createStatusMut.isPending} - > - setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus /> - setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} /> - Farbe setStatusCreateData({ ...statusCreateData, farbe: v })} /> - setStatusCreateData({ ...statusCreateData, sort_order: parseInt(e.target.value) || 0 })} /> - setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" /> - setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" /> - setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" /> - - - {/* ──── Create Priority Dialog ──── */} - setPrioCreateOpen(false)} - onSubmit={() => createPrioMut.mutate(prioCreateData)} - title="Neue Priorität" - submitLabel="Erstellen" - isSubmitting={createPrioMut.isPending} - > - setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus /> - setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} /> - Farbe setPrioCreateData({ ...prioCreateData, farbe: v })} /> - setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} /> - - - {/* ──── Create Kategorie Dialog ──── */} - setTypeCreateOpen(false)} - onSubmit={() => createTypeMut.mutate(typeCreateData)} - title="Neue Kategorie" - submitLabel="Erstellen" - isSubmitting={createTypeMut.isPending} - > - setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus /> - Übergeordnete Kategorie - Icon - Farbe setTypeCreateData({ ...typeCreateData, farbe: v })} /> - setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" /> - setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} /> - - -
- ); -} // ── 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() { Issues - - - - - - - - + + {hasEditSettings && ( + + navigate('/admin/settings?tab=1&subtab=issues')}> + + + + )} + + + + + + + + + @@ -738,12 +415,6 @@ export default function Issues() { )} - {/* Tab: Einstellungen (conditional) */} - {hasEditSettings && ( - t.key === 'settings')}> - - - )} {/* FAB */} diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index ee6426e..d65f893 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -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(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 ( - - - Veranstaltungskategorien - - - - - - - - Farbe - Name - Beschreibung - Aktionen - - - - {kategorien.map((k) => ( - - - - - {k.name} - {k.beschreibung ?? '—'} - - setEditingKat({ ...k })}> - - - handleDelete(k.id)}> - - - - - ))} - {kategorien.length === 0 && ( - - - Noch keine Kategorien vorhanden - - - )} - -
-
- - {/* Edit dialog */} - setEditingKat(null)} maxWidth="xs" fullWidth> - Kategorie bearbeiten - - - setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required /> - - Farbe - setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} /> - {editingKat?.farbe} - - setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} /> - - - - - - - - - {/* New category dialog */} - setNewKatOpen(false)} maxWidth="xs" fullWidth> - Neue Kategorie - - - setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required /> - - Farbe - setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} /> - {newKatForm.farbe} - - setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} /> - - - - - - - -
- ); -} // ────────────────────────────────────────────────────────────────────────────── // 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() { Kalender + {hasPermission('kalender:configure') && ( + + navigate('/admin/settings?tab=1&subtab=kalender')}> + + + + )}
- {canWriteEvents ? ( - setActiveTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}> - - } iconPosition="start" label="Einstellungen" value={1} /> - - ) : null} - - {activeTab === 0 && ( <> {/* ── Calendar ───────────────────────────────────────────── */} @@ -2630,11 +2474,6 @@ export default function Kalender() { - )} - - {activeTab === 1 && canWriteEvents && ( - - )}
diff --git a/frontend/src/pages/Wissen.tsx b/frontend/src/pages/Wissen.tsx index 898f5b1..61073ca 100644 --- a/frontend/src/pages/Wissen.tsx +++ b/frontend/src/pages/Wissen.tsx @@ -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(null); @@ -89,7 +93,17 @@ export default function Wissen() { return ( - + + {hasPermission('wissen:configure') && ( + + + navigate('/admin/settings?tab=1&subtab=wissen')}> + + + + + )} + {/* Left panel: search + list */} @@ -274,6 +288,7 @@ export default function Wissen() { )} + ); diff --git a/frontend/src/services/toolConfig.ts b/frontend/src/services/toolConfig.ts new file mode 100644 index 0000000..80b6769 --- /dev/null +++ b/frontend/src/services/toolConfig.ts @@ -0,0 +1,18 @@ +import { api } from './api'; +import type { ToolConfig, ToolTestResult } from '../types/toolConfig.types'; + +interface ApiResponse { + success: boolean; + data: T; +} + +export const toolConfigApi = { + get: (tool: string): Promise => + api.get>(`/api/admin/tools/config/${tool}`).then(r => r.data.data), + + update: (tool: string, config: Partial): Promise => + api.put(`/api/admin/tools/config/${tool}`, config).then(() => {}), + + test: (tool: string, config?: Partial): Promise => + api.post>(`/api/admin/tools/config/${tool}/test`, config ?? {}).then(r => r.data.data), +}; diff --git a/frontend/src/types/toolConfig.types.ts b/frontend/src/types/toolConfig.types.ts new file mode 100644 index 0000000..4901877 --- /dev/null +++ b/frontend/src/types/toolConfig.types.ts @@ -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; +}