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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,6 +91,7 @@ function buildReverseHierarchy(hierarchy: Record<string, string[]>): Record<stri
const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = { const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
kalender: { kalender: {
'Termine': ['view', 'create'], 'Termine': ['view', 'create'],
'Einstellungen': ['configure'],
}, },
fahrzeugbuchungen: { fahrzeugbuchungen: {
'Buchungen': ['view', 'create', 'manage'], 'Buchungen': ['view', 'create', 'manage'],
@@ -112,6 +113,22 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'Bearbeiten': ['create', 'change_status', 'edit', 'delete'], 'Bearbeiten': ['create', 'change_status', 'edit', 'delete'],
'Admin': ['edit_settings'], '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: { checklisten: {
'Ansehen': ['view'], 'Ansehen': ['view'],
'Ausführen': ['execute', 'approve'], 'Ausführen': ['execute', 'approve'],

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,14 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { settingsApi } from '../services/settings'; import { settingsApi } from '../services/settings';
import { personalEquipmentApi } from '../services/personalEquipment'; 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'; import type { ZustandOption } from '../types/personalEquipment.types';
interface TabPanelProps { interface TabPanelProps {
@@ -82,9 +90,21 @@ const ADMIN_INTERVAL_OPTIONS = [
function AdminSettings() { function AdminSettings() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); 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 [tab, setTab] = useState(() => {
const t = Number(searchParams.get('tab')); 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(() => { useEffect(() => {
@@ -92,11 +112,31 @@ function AdminSettings() {
if (t >= 0 && t < SETTINGS_TAB_COUNT) setTab(t); if (t >= 0 && t < SETTINGS_TAB_COUNT) setTab(t);
}, [searchParams]); }, [searchParams]);
const { hasPermission } = usePermissionContext(); // Sub-tab state for Werkzeuge tab
const { showSuccess, showError } = useNotification(); const WERKZEUGE_SUB_TABS = [
const queryClient = useQueryClient(); { 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 // State for link collections
const [linkCollections, setLinkCollections] = useState<LinkCollection[]>([]); const [linkCollections, setLinkCollections] = useState<LinkCollection[]>([]);
@@ -350,16 +390,17 @@ function AdminSettings() {
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs <Tabs
value={tab} value={isFullAdmin ? tab : 1}
onChange={(_e, v) => { setTab(v); navigate(`/admin/settings?tab=${v}`, { replace: true }); }} onChange={(_e, v) => { setTab(v); navigate(`/admin/settings?tab=${v}`, { replace: true }); }}
variant="scrollable" variant="scrollable"
scrollButtons="auto" scrollButtons="auto"
> >
<Tab label="Allgemein" /> {isFullAdmin && <Tab label="Allgemein" value={0} />}
<Tab label="Werkzeuge" /> <Tab label="Werkzeuge" value={1} />
</Tabs> </Tabs>
</Box> </Box>
{isFullAdmin && (
<TabPanel value={tab} index={0}> <TabPanel value={tab} index={0}>
<Stack spacing={3}> <Stack spacing={3}>
{/* Section 1: General Settings (App Logo) */} {/* Section 1: General Settings (App Logo) */}
@@ -653,8 +694,36 @@ function AdminSettings() {
</Card> </Card>
</Stack> </Stack>
</TabPanel> </TabPanel>
)}
<TabPanel value={tab} index={1}> <TabPanel value={tab} index={1}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs
value={werkzeugeSubTab}
onChange={(_e, v) => {
setWerkzeugeSubTab(v);
const key = visibleSubTabs[v]?.key;
if (key) navigate(`/admin/settings?tab=1&subtab=${key}`, { replace: true });
}}
variant="scrollable"
scrollButtons="auto"
>
{visibleSubTabs.map((st) => (
<Tab key={st.key} label={st.label} />
))}
</Tabs>
</Box>
{visibleSubTabs[werkzeugeSubTab]?.key === 'wissen' && (
<ToolSettingsBookstack />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'vikunja' && (
<ToolSettingsVikunja />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'nextcloud' && (
<ToolSettingsNextcloud />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'ausruestung' && (
<Stack spacing={3}> <Stack spacing={3}>
{/* Zustandsoptionen (Persönliche Ausrüstung) */} {/* Zustandsoptionen (Persönliche Ausrüstung) */}
<Card> <Card>
@@ -747,6 +816,22 @@ function AdminSettings() {
</CardContent> </CardContent>
</Card> </Card>
</Stack> </Stack>
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'kalender' && (
<ModuleSettingsKalender />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'fahrzeugbuchungen' && (
<ModuleSettingsFahrzeugbuchungen />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'ausruestung-typen' && (
<ModuleSettingsAusruestung />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'fahrzeuge' && (
<ModuleSettingsFahrzeuge />
)}
{visibleSubTabs[werkzeugeSubTab]?.key === 'issues' && (
<ModuleSettingsIssues />
)}
</TabPanel> </TabPanel>
</Container> </Container>
</DashboardLayout> </DashboardLayout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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