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:
@@ -109,6 +109,7 @@ import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
||||
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
||||
import buchhaltungRoutes from './routes/buchhaltung.routes';
|
||||
import personalEquipmentRoutes from './routes/personalEquipment.routes';
|
||||
import toolConfigRoutes from './routes/toolConfig.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -140,6 +141,7 @@ app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
||||
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
||||
app.use('/api/buchhaltung', buchhaltungRoutes);
|
||||
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
|
||||
app.use('/api/admin/tools', toolConfigRoutes);
|
||||
|
||||
// Static file serving for uploads (authenticated)
|
||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||
|
||||
175
backend/src/controllers/toolConfig.controller.ts
Normal file
175
backend/src/controllers/toolConfig.controller.ts
Normal 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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
32
backend/src/routes/toolConfig.routes.ts
Normal file
32
backend/src/routes/toolConfig.routes.ts
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import httpClient from '../config/httpClient';
|
||||
import environment from '../config/environment';
|
||||
import toolConfigService, { BookstackConfig } from './toolConfig.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export interface BookStackPage {
|
||||
@@ -74,10 +74,9 @@ function isValidServiceUrl(raw: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const { bookstack } = environment;
|
||||
function buildHeaders(config: BookstackConfig): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
|
||||
'Authorization': `Token ${config.tokenId}:${config.tokenSecret}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
@@ -95,11 +94,11 @@ async function getBookSlugMap(): Promise<Map<number, string>> {
|
||||
if (bookSlugMapCache && Date.now() < bookSlugMapCache.expiresAt) {
|
||||
return bookSlugMapCache.map;
|
||||
}
|
||||
const { bookstack } = environment;
|
||||
const config = await toolConfigService.getBookstackConfig();
|
||||
try {
|
||||
const response = await httpClient.get(
|
||||
`${bookstack.url}/api/books`,
|
||||
{ params: { count: 500 }, headers: buildHeaders() },
|
||||
`${config.url}/api/books`,
|
||||
{ params: { count: 500 }, headers: buildHeaders(config) },
|
||||
);
|
||||
const books: Array<{ id: number; slug: string }> = response.data?.data ?? [];
|
||||
const map = new Map(books.map((b) => [b.id, b.slug]));
|
||||
@@ -111,18 +110,18 @@ async function getBookSlugMap(): Promise<Map<number, string>> {
|
||||
}
|
||||
|
||||
async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
const config = await toolConfigService.getBookstackConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const [response, bookSlugMap] = await Promise.all([
|
||||
httpClient.get(
|
||||
`${bookstack.url}/api/pages`,
|
||||
`${config.url}/api/pages`,
|
||||
{
|
||||
params: { sort: '-updated_at', count: 20 },
|
||||
headers: buildHeaders(),
|
||||
headers: buildHeaders(config),
|
||||
},
|
||||
),
|
||||
getBookSlugMap(),
|
||||
@@ -130,7 +129,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
const pages: BookStackPage[] = response.data?.data ?? [];
|
||||
return pages.map((p) => ({
|
||||
...p,
|
||||
url: `${bookstack.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`,
|
||||
url: `${config.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -145,17 +144,17 @@ async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
}
|
||||
|
||||
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
const config = await toolConfigService.getBookstackConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient.get(
|
||||
`${bookstack.url}/api/search`,
|
||||
`${config.url}/api/search`,
|
||||
{
|
||||
params: { query, count: 50 },
|
||||
headers: buildHeaders(),
|
||||
headers: buildHeaders(config),
|
||||
},
|
||||
);
|
||||
const bookSlugMap = await getBookSlugMap();
|
||||
@@ -167,7 +166,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||
slug: item.slug,
|
||||
book_id: item.book_id ?? 0,
|
||||
book_slug: item.book_slug ?? '',
|
||||
url: `${bookstack.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
|
||||
url: `${config.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
|
||||
preview_html: item.preview_html ?? { content: '' },
|
||||
tags: item.tags ?? [],
|
||||
}));
|
||||
@@ -201,16 +200,16 @@ export interface BookStackPageDetail {
|
||||
}
|
||||
|
||||
async function getPageById(id: number): Promise<BookStackPageDetail> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
const config = await toolConfigService.getBookstackConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const [response, bookSlugMap] = await Promise.all([
|
||||
httpClient.get(
|
||||
`${bookstack.url}/api/pages/${id}`,
|
||||
{ headers: buildHeaders() },
|
||||
`${config.url}/api/pages/${id}`,
|
||||
{ headers: buildHeaders(config) },
|
||||
),
|
||||
getBookSlugMap(),
|
||||
]);
|
||||
@@ -226,7 +225,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
|
||||
html: page.html ?? '',
|
||||
created_at: page.created_at,
|
||||
updated_at: page.updated_at,
|
||||
url: `${bookstack.url}/books/${bookSlug}/page/${page.slug}`,
|
||||
url: `${config.url}/books/${bookSlug}/page/${page.slug}`,
|
||||
book: page.book,
|
||||
createdBy: page.created_by,
|
||||
updatedBy: page.updated_by,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import httpClient from '../config/httpClient';
|
||||
import environment from '../config/environment';
|
||||
import toolConfigService from './toolConfig.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
interface NextcloudLastMessage {
|
||||
@@ -77,7 +77,7 @@ function isValidServiceUrl(raw: string): boolean {
|
||||
}
|
||||
|
||||
async function initiateLoginFlow(): Promise<LoginFlowResult> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -145,7 +145,7 @@ interface NextcloudChatMessage {
|
||||
}
|
||||
|
||||
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -206,7 +206,7 @@ interface GetMessagesOptions {
|
||||
}
|
||||
|
||||
async function getMessages(token: string, loginName: string, appPassword: string, options?: GetMessagesOptions): Promise<NextcloudChatMessage[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -294,7 +294,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
|
||||
}
|
||||
|
||||
async function sendMessage(token: string, message: string, loginName: string, appPassword: string, replyTo?: number): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -331,7 +331,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap
|
||||
}
|
||||
|
||||
async function markAsRead(token: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -368,7 +368,7 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
|
||||
}
|
||||
|
||||
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -441,7 +441,7 @@ async function uploadFileToTalk(
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -503,7 +503,7 @@ async function downloadFile(
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<any> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -544,7 +544,7 @@ async function getFilePreview(
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<any> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -580,7 +580,7 @@ async function getFilePreview(
|
||||
}
|
||||
|
||||
async function searchUsers(query: string, loginName: string, appPassword: string): Promise<any[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -616,7 +616,7 @@ async function searchUsers(query: string, loginName: string, appPassword: string
|
||||
}
|
||||
|
||||
async function createRoom(roomType: number, invite: string, roomName: string | undefined, loginName: string, appPassword: string): Promise<{ token: string }> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -654,7 +654,7 @@ async function createRoom(roomType: number, invite: string, roomName: string | u
|
||||
}
|
||||
|
||||
async function addReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -691,7 +691,7 @@ async function addReaction(token: string, messageId: number, reaction: string, l
|
||||
}
|
||||
|
||||
async function removeReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -727,7 +727,7 @@ async function removeReaction(token: string, messageId: number, reaction: string
|
||||
}
|
||||
|
||||
async function getReactions(token: string, messageId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -763,7 +763,7 @@ async function getReactions(token: string, messageId: number, loginName: string,
|
||||
}
|
||||
|
||||
async function getPollDetails(token: string, pollId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
92
backend/src/services/toolConfig.service.ts
Normal file
92
backend/src/services/toolConfig.service.ts
Normal 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,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import httpClient from '../config/httpClient';
|
||||
import environment from '../config/environment';
|
||||
import toolConfigService, { VikunjaConfig } from './toolConfig.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export interface VikunjaTask {
|
||||
@@ -58,23 +58,23 @@ function isValidServiceUrl(raw: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
function buildHeaders(config: VikunjaConfig): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
|
||||
'Authorization': `Bearer ${config.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function getMyTasks(): Promise<VikunjaTask[]> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
const config = await toolConfigService.getVikunjaConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient.get<VikunjaTask[]>(
|
||||
`${vikunja.url}/api/v1/tasks/all`,
|
||||
{ headers: buildHeaders() },
|
||||
`${config.url}/api/v1/tasks/all`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
return (response.data ?? []).filter((t) => !t.done);
|
||||
} catch (error) {
|
||||
@@ -99,15 +99,15 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
|
||||
}
|
||||
|
||||
async function getProjects(): Promise<VikunjaProject[]> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
const config = await toolConfigService.getVikunjaConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient.get<VikunjaProject[]>(
|
||||
`${vikunja.url}/api/v1/projects`,
|
||||
{ headers: buildHeaders() },
|
||||
`${config.url}/api/v1/projects`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
return response.data ?? [];
|
||||
} catch (error) {
|
||||
@@ -123,8 +123,8 @@ async function getProjects(): Promise<VikunjaProject[]> {
|
||||
}
|
||||
|
||||
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
const config = await toolConfigService.getVikunjaConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
@@ -134,9 +134,9 @@ async function createTask(projectId: number, title: string, dueDate?: string): P
|
||||
body.due_date = dueDate;
|
||||
}
|
||||
const response = await httpClient.put<VikunjaTask>(
|
||||
`${vikunja.url}/api/v1/projects/${projectId}/tasks`,
|
||||
`${config.url}/api/v1/projects/${projectId}/tasks`,
|
||||
body,
|
||||
{ headers: buildHeaders() },
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
237
frontend/src/components/admin/ModuleSettingsAusruestung.tsx
Normal file
237
frontend/src/components/admin/ModuleSettingsAusruestung.tsx
Normal 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 "{deletingTyp?.name}" wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.</>}
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
199
frontend/src/components/admin/ModuleSettingsFahrzeuge.tsx
Normal file
199
frontend/src/components/admin/ModuleSettingsFahrzeuge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
388
frontend/src/components/admin/ModuleSettingsIssues.tsx
Normal file
388
frontend/src/components/admin/ModuleSettingsIssues.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/admin/ModuleSettingsKalender.tsx
Normal file
191
frontend/src/components/admin/ModuleSettingsKalender.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,7 @@ function buildReverseHierarchy(hierarchy: Record<string, string[]>): Record<stri
|
||||
const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
|
||||
kalender: {
|
||||
'Termine': ['view', 'create'],
|
||||
'Einstellungen': ['configure'],
|
||||
},
|
||||
fahrzeugbuchungen: {
|
||||
'Buchungen': ['view', 'create', 'manage'],
|
||||
@@ -112,6 +113,22 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
|
||||
'Bearbeiten': ['create', 'change_status', 'edit', 'delete'],
|
||||
'Admin': ['edit_settings'],
|
||||
},
|
||||
wissen: {
|
||||
'Ansehen': ['view'],
|
||||
'Widgets': ['widget_recent', 'widget_search'],
|
||||
'Einstellungen': ['configure'],
|
||||
},
|
||||
vikunja: {
|
||||
'Aufgaben': ['create_tasks'],
|
||||
'Widgets': ['widget_tasks', 'widget_quick_add'],
|
||||
'Einstellungen': ['configure'],
|
||||
},
|
||||
nextcloud: {
|
||||
'Einstellungen': ['configure'],
|
||||
},
|
||||
fahrzeuge: {
|
||||
'Einstellungen': ['configure'],
|
||||
},
|
||||
checklisten: {
|
||||
'Ansehen': ['view'],
|
||||
'Ausführen': ['execute', 'approve'],
|
||||
|
||||
147
frontend/src/components/admin/ToolSettingsBookstack.tsx
Normal file
147
frontend/src/components/admin/ToolSettingsBookstack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/admin/ToolSettingsNextcloud.tsx
Normal file
118
frontend/src/components/admin/ToolSettingsNextcloud.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
frontend/src/components/admin/ToolSettingsVikunja.tsx
Normal file
136
frontend/src/components/admin/ToolSettingsVikunja.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,14 @@ import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { settingsApi } from '../services/settings';
|
||||
import { personalEquipmentApi } from '../services/personalEquipment';
|
||||
import ToolSettingsBookstack from '../components/admin/ToolSettingsBookstack';
|
||||
import ToolSettingsVikunja from '../components/admin/ToolSettingsVikunja';
|
||||
import ToolSettingsNextcloud from '../components/admin/ToolSettingsNextcloud';
|
||||
import ModuleSettingsKalender from '../components/admin/ModuleSettingsKalender';
|
||||
import ModuleSettingsFahrzeugbuchungen from '../components/admin/ModuleSettingsFahrzeugbuchungen';
|
||||
import ModuleSettingsAusruestung from '../components/admin/ModuleSettingsAusruestung';
|
||||
import ModuleSettingsFahrzeuge from '../components/admin/ModuleSettingsFahrzeuge';
|
||||
import ModuleSettingsIssues from '../components/admin/ModuleSettingsIssues';
|
||||
import type { ZustandOption } from '../types/personalEquipment.types';
|
||||
|
||||
interface TabPanelProps {
|
||||
@@ -82,9 +90,21 @@ const ADMIN_INTERVAL_OPTIONS = [
|
||||
function AdminSettings() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { hasPermission, hasAnyPermission } = usePermissionContext();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isFullAdmin = hasPermission('admin:write');
|
||||
const canAccess = isFullAdmin || hasAnyPermission(
|
||||
'wissen:configure', 'vikunja:configure', 'nextcloud:configure',
|
||||
'kalender:configure', 'fahrzeugbuchungen:manage', 'ausruestung:manage_types',
|
||||
'fahrzeuge:configure', 'issues:edit_settings',
|
||||
);
|
||||
|
||||
const [tab, setTab] = useState(() => {
|
||||
const t = Number(searchParams.get('tab'));
|
||||
return t >= 0 && t < SETTINGS_TAB_COUNT ? t : 0;
|
||||
if (t >= 0 && t < SETTINGS_TAB_COUNT) return t;
|
||||
return isFullAdmin ? 0 : 1;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,11 +112,31 @@ function AdminSettings() {
|
||||
if (t >= 0 && t < SETTINGS_TAB_COUNT) setTab(t);
|
||||
}, [searchParams]);
|
||||
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
// Sub-tab state for Werkzeuge tab
|
||||
const WERKZEUGE_SUB_TABS = [
|
||||
{ key: 'wissen', label: 'Wissen', visible: hasPermission('wissen:configure') },
|
||||
{ key: 'vikunja', label: 'Vikunja', visible: hasPermission('vikunja:configure') },
|
||||
{ key: 'nextcloud', label: 'Nextcloud', visible: hasPermission('nextcloud:configure') },
|
||||
{ key: 'ausruestung', label: 'Pers. Ausrüstung', visible: isFullAdmin },
|
||||
{ key: 'kalender', label: 'Kalender', visible: hasPermission('kalender:configure') },
|
||||
{ key: 'fahrzeugbuchungen', label: 'Fahrzeugbuchungen', visible: hasPermission('fahrzeugbuchungen:manage') },
|
||||
{ key: 'ausruestung-typen', label: 'Ausrüstung', visible: hasPermission('ausruestung:manage_types') },
|
||||
{ key: 'fahrzeuge', label: 'Fahrzeuge', visible: hasPermission('fahrzeuge:configure') },
|
||||
{ key: 'issues', label: 'Issues', visible: hasPermission('issues:edit_settings') },
|
||||
];
|
||||
const visibleSubTabs = WERKZEUGE_SUB_TABS.filter((st) => st.visible);
|
||||
const [werkzeugeSubTab, setWerkzeugeSubTab] = useState(() => {
|
||||
const st = searchParams.get('subtab');
|
||||
const idx = visibleSubTabs.findIndex((t) => t.key === st);
|
||||
return idx >= 0 ? idx : 0;
|
||||
});
|
||||
|
||||
const canAccess = hasPermission('admin:write');
|
||||
useEffect(() => {
|
||||
const st = searchParams.get('subtab');
|
||||
const idx = visibleSubTabs.findIndex((t) => t.key === st);
|
||||
if (idx >= 0 && idx !== werkzeugeSubTab) setWerkzeugeSubTab(idx);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
// State for link collections
|
||||
const [linkCollections, setLinkCollections] = useState<LinkCollection[]>([]);
|
||||
@@ -350,16 +390,17 @@ function AdminSettings() {
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
value={isFullAdmin ? tab : 1}
|
||||
onChange={(_e, v) => { setTab(v); navigate(`/admin/settings?tab=${v}`, { replace: true }); }}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="Allgemein" />
|
||||
<Tab label="Werkzeuge" />
|
||||
{isFullAdmin && <Tab label="Allgemein" value={0} />}
|
||||
<Tab label="Werkzeuge" value={1} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{isFullAdmin && (
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Stack spacing={3}>
|
||||
{/* Section 1: General Settings (App Logo) */}
|
||||
@@ -653,100 +694,144 @@ function AdminSettings() {
|
||||
</Card>
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
<TabPanel value={tab} index={1}>
|
||||
<Stack spacing={3}>
|
||||
{/* Zustandsoptionen (Persönliche Ausrüstung) */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CheckroomIcon color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Zustandsoptionen — Persönliche Ausrüstung</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt.
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{zustandOptions.map((opt, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Schlüssel"
|
||||
value={opt.key}
|
||||
onChange={(e) =>
|
||||
setZustandOptions((prev) =>
|
||||
prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o))
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Label"
|
||||
value={opt.label}
|
||||
onChange={(e) =>
|
||||
setZustandOptions((prev) =>
|
||||
prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o))
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||
<InputLabel>Farbe</InputLabel>
|
||||
<Select
|
||||
value={opt.color}
|
||||
label="Farbe"
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs
|
||||
value={werkzeugeSubTab}
|
||||
onChange={(_e, v) => {
|
||||
setWerkzeugeSubTab(v);
|
||||
const key = visibleSubTabs[v]?.key;
|
||||
if (key) navigate(`/admin/settings?tab=1&subtab=${key}`, { replace: true });
|
||||
}}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
{visibleSubTabs.map((st) => (
|
||||
<Tab key={st.key} label={st.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'wissen' && (
|
||||
<ToolSettingsBookstack />
|
||||
)}
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'vikunja' && (
|
||||
<ToolSettingsVikunja />
|
||||
)}
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'nextcloud' && (
|
||||
<ToolSettingsNextcloud />
|
||||
)}
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'ausruestung' && (
|
||||
<Stack spacing={3}>
|
||||
{/* Zustandsoptionen (Persönliche Ausrüstung) */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CheckroomIcon color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Zustandsoptionen — Persönliche Ausrüstung</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt.
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{zustandOptions.map((opt, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Schlüssel"
|
||||
value={opt.key}
|
||||
onChange={(e) =>
|
||||
setZustandOptions((prev) =>
|
||||
prev.map((o, i) => (i === idx ? { ...o, color: e.target.value } : o))
|
||||
prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o))
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Label"
|
||||
value={opt.label}
|
||||
onChange={(e) =>
|
||||
setZustandOptions((prev) =>
|
||||
prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o))
|
||||
)
|
||||
}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||
<InputLabel>Farbe</InputLabel>
|
||||
<Select
|
||||
value={opt.color}
|
||||
label="Farbe"
|
||||
onChange={(e) =>
|
||||
setZustandOptions((prev) =>
|
||||
prev.map((o, i) => (i === idx ? { ...o, color: e.target.value } : o))
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="success">Grün</MenuItem>
|
||||
<MenuItem value="warning">Gelb</MenuItem>
|
||||
<MenuItem value="error">Rot</MenuItem>
|
||||
<MenuItem value="default">Grau</MenuItem>
|
||||
<MenuItem value="info">Blau</MenuItem>
|
||||
<MenuItem value="primary">Primär</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() =>
|
||||
setZustandOptions((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
aria-label="Option entfernen"
|
||||
size="small"
|
||||
>
|
||||
<MenuItem value="success">Grün</MenuItem>
|
||||
<MenuItem value="warning">Gelb</MenuItem>
|
||||
<MenuItem value="error">Rot</MenuItem>
|
||||
<MenuItem value="default">Grau</MenuItem>
|
||||
<MenuItem value="info">Blau</MenuItem>
|
||||
<MenuItem value="primary">Primär</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
color="error"
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
onClick={() =>
|
||||
setZustandOptions((prev) => prev.filter((_, i) => i !== idx))
|
||||
setZustandOptions((prev) => [...prev, { key: '', label: '', color: 'default' }])
|
||||
}
|
||||
aria-label="Option entfernen"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
Option hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => zustandMutation.mutate(zustandOptions)}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={zustandMutation.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
onClick={() =>
|
||||
setZustandOptions((prev) => [...prev, { key: '', label: '', color: 'default' }])
|
||||
}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Option hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => zustandMutation.mutate(zustandOptions)}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={zustandMutation.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'kalender' && (
|
||||
<ModuleSettingsKalender />
|
||||
)}
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'fahrzeugbuchungen' && (
|
||||
<ModuleSettingsFahrzeugbuchungen />
|
||||
)}
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'ausruestung-typen' && (
|
||||
<ModuleSettingsAusruestung />
|
||||
)}
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'fahrzeuge' && (
|
||||
<ModuleSettingsFahrzeuge />
|
||||
)}
|
||||
{visibleSubTabs[werkzeugeSubTab]?.key === 'issues' && (
|
||||
<ModuleSettingsIssues />
|
||||
)}
|
||||
</TabPanel>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -16,41 +16,30 @@ import {
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Switch,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Add as AddIcon,
|
||||
Build,
|
||||
CheckCircle,
|
||||
Delete,
|
||||
Edit,
|
||||
Error as ErrorIcon,
|
||||
LinkRounded,
|
||||
PauseCircle,
|
||||
RemoveCircle,
|
||||
Search,
|
||||
Settings as SettingsIcon,
|
||||
Star,
|
||||
Warning,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { equipmentApi } from '../services/equipment';
|
||||
import { ausruestungTypenApi, AusruestungTyp } from '../services/ausruestungTypen';
|
||||
import { ausruestungTypenApi } from '../services/ausruestungTypen';
|
||||
import {
|
||||
AusruestungListItem,
|
||||
AusruestungKategorie,
|
||||
@@ -59,9 +48,7 @@ import {
|
||||
EquipmentStats,
|
||||
} from '../types/equipment.types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { ConfirmDialog, FormDialog } from '../components/templates';
|
||||
|
||||
// ── Status chip config ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -246,222 +233,11 @@ const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ── Ausrüstungstypen-Verwaltung (Einstellungen Tab) ──────────────────────────
|
||||
|
||||
function AusruestungTypenSettings() {
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: typen = [], isLoading, isError } = useQuery({
|
||||
queryKey: ['ausruestungTypen'],
|
||||
queryFn: ausruestungTypenApi.getAll,
|
||||
});
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingTyp, setEditingTyp] = useState<AusruestungTyp | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formBeschreibung, setFormBeschreibung] = useState('');
|
||||
const [formIcon, setFormIcon] = useState('');
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deletingTyp, setDeletingTyp] = useState<AusruestungTyp | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string; beschreibung?: string; icon?: string }) =>
|
||||
ausruestungTypenApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
|
||||
showSuccess('Typ erstellt');
|
||||
closeDialog();
|
||||
},
|
||||
onError: () => showError('Typ konnte nicht erstellt werden'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; beschreibung: string; icon: string }> }) =>
|
||||
ausruestungTypenApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
|
||||
showSuccess('Typ aktualisiert');
|
||||
closeDialog();
|
||||
},
|
||||
onError: () => showError('Typ konnte nicht aktualisiert werden'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => ausruestungTypenApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] });
|
||||
showSuccess('Typ gelöscht');
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingTyp(null);
|
||||
},
|
||||
onError: () => showError('Typ konnte nicht gelöscht werden. Möglicherweise ist er noch Geräten zugeordnet.'),
|
||||
});
|
||||
|
||||
const openAddDialog = () => {
|
||||
setEditingTyp(null);
|
||||
setFormName('');
|
||||
setFormBeschreibung('');
|
||||
setFormIcon('');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (typ: AusruestungTyp) => {
|
||||
setEditingTyp(typ);
|
||||
setFormName(typ.name);
|
||||
setFormBeschreibung(typ.beschreibung ?? '');
|
||||
setFormIcon(typ.icon ?? '');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingTyp(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formName.trim()) return;
|
||||
const data = {
|
||||
name: formName.trim(),
|
||||
beschreibung: formBeschreibung.trim() || undefined,
|
||||
icon: formIcon.trim() || undefined,
|
||||
};
|
||||
if (editingTyp) {
|
||||
updateMutation.mutate({ id: editingTyp.id, data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (typ: AusruestungTyp) => {
|
||||
setDeletingTyp(typ);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>Ausrüstungstypen</Typography>
|
||||
|
||||
{isLoading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Typen konnten nicht geladen werden.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<Paper variant="outlined">
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Beschreibung</TableCell>
|
||||
<TableCell>Icon</TableCell>
|
||||
<TableCell align="right">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{typen.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
|
||||
Noch keine Typen vorhanden.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{typen.map((typ) => (
|
||||
<TableRow key={typ.id}>
|
||||
<TableCell>{typ.name}</TableCell>
|
||||
<TableCell>{typ.beschreibung || '---'}</TableCell>
|
||||
<TableCell>{typ.icon || '---'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Bearbeiten">
|
||||
<IconButton size="small" onClick={() => openEditDialog(typ)}>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Löschen">
|
||||
<IconButton size="small" color="error" onClick={() => openDeleteDialog(typ)}>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="contained" size="small" startIcon={<AddIcon />} onClick={openAddDialog}>
|
||||
Neuer Typ
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Add/Edit dialog */}
|
||||
<FormDialog
|
||||
open={dialogOpen}
|
||||
onClose={closeDialog}
|
||||
onSubmit={handleSave}
|
||||
title={editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
|
||||
isSubmitting={isSaving}
|
||||
>
|
||||
<TextField
|
||||
label="Name *"
|
||||
fullWidth
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
inputProps={{ maxLength: 100 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Beschreibung"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={formBeschreibung}
|
||||
onChange={(e) => setFormBeschreibung(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Icon (MUI Icon-Name)"
|
||||
fullWidth
|
||||
value={formIcon}
|
||||
onChange={(e) => setFormIcon(e.target.value)}
|
||||
placeholder="z.B. Build, LocalFireDepartment"
|
||||
/>
|
||||
</FormDialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
|
||||
title="Typ löschen"
|
||||
message={<>Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.</>}
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Ausruestung() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
|
||||
const { canManageEquipment, hasPermission } = usePermissions();
|
||||
const canManageTypes = hasPermission('ausruestung:manage_types');
|
||||
|
||||
@@ -568,18 +344,15 @@ function Ausruestung() {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{canManageTypes && (
|
||||
<Tooltip title="Einstellungen">
|
||||
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=ausruestung-typen')}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
|
||||
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab label="Übersicht" />
|
||||
{canManageTypes && <Tab label="Einstellungen" />}
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<>
|
||||
{/* Overdue alert */}
|
||||
{hasOverdue && (
|
||||
@@ -735,11 +508,7 @@ function Ausruestung() {
|
||||
</ChatAwareFab>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 1 && canManageTypes && (
|
||||
<AusruestungTypenSettings />
|
||||
)}
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Settings } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useChat } from '../contexts/ChatContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { nextcloudApi } from '../services/nextcloud';
|
||||
import { notificationsApi } from '../services/notifications';
|
||||
import ChatRoomList from '../components/chat/ChatRoomList';
|
||||
@@ -12,6 +16,8 @@ import ChatMessageView from '../components/chat/ChatMessageView';
|
||||
|
||||
const ChatContent: React.FC = () => {
|
||||
const { rooms, selectedRoomToken, connected } = useChat();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const markedRoomsRef = React.useRef(new Set<string>());
|
||||
|
||||
@@ -44,16 +50,27 @@ const ChatContent: React.FC = () => {
|
||||
}, [selectedRoomToken, queryClient]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 'calc(100vh - 112px)',
|
||||
display: 'flex',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 112px)' }}>
|
||||
{hasPermission('nextcloud:configure') && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 1 }}>
|
||||
<Tooltip title="Einstellungen">
|
||||
<IconButton size="small" onClick={() => navigate('/admin/settings?tab=1&subtab=nextcloud')}>
|
||||
<Settings fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{!connected ? (
|
||||
<Box sx={{ p: 3, display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
@@ -89,6 +106,7 @@ const ChatContent: React.FC = () => {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,15 +20,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Tab,
|
||||
Tabs,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
@@ -38,12 +29,10 @@ import {
|
||||
ContentCopy,
|
||||
Cancel,
|
||||
Edit,
|
||||
Delete,
|
||||
Save,
|
||||
Close,
|
||||
EventBusy,
|
||||
Settings,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
@@ -103,8 +92,6 @@ function FahrzeugBuchungen() {
|
||||
const canCreate = hasPermission('fahrzeugbuchungen:create');
|
||||
const canManage = hasPermission('fahrzeugbuchungen:manage');
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
|
||||
// ── Filters ────────────────────────────────────────────────────────────────
|
||||
const today = new Date();
|
||||
const defaultFrom = format(today, 'yyyy-MM-dd');
|
||||
@@ -120,11 +107,6 @@ function FahrzeugBuchungen() {
|
||||
queryFn: fetchVehicles,
|
||||
});
|
||||
|
||||
const { data: kategorien = [] } = useQuery({
|
||||
queryKey: ['buchungskategorien-all'],
|
||||
queryFn: kategorieApi.getAll,
|
||||
});
|
||||
|
||||
const { data: activeKategorien = [] } = useQuery({
|
||||
queryKey: ['buchungskategorien'],
|
||||
queryFn: kategorieApi.getActive,
|
||||
@@ -191,46 +173,6 @@ function FahrzeugBuchungen() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Einstellungen: Categories management ───────────────────────────────────
|
||||
const [editRowId, setEditRowId] = useState<number | null>(null);
|
||||
const [editRowData, setEditRowData] = useState<Partial<BuchungsKategorie>>({});
|
||||
const [newKatDialog, setNewKatDialog] = useState(false);
|
||||
const [newKatForm, setNewKatForm] = useState({ bezeichnung: '', farbe: '#1976d2' });
|
||||
|
||||
const createKatMutation = useMutation({
|
||||
mutationFn: (data: Omit<BuchungsKategorie, 'id'>) => kategorieApi.create(data),
|
||||
onSuccess: () => {
|
||||
notification.showSuccess('Kategorie erstellt');
|
||||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
|
||||
setNewKatDialog(false);
|
||||
setNewKatForm({ bezeichnung: '', farbe: '#1976d2' });
|
||||
},
|
||||
onError: () => notification.showError('Fehler beim Erstellen'),
|
||||
});
|
||||
|
||||
const updateKatMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<BuchungsKategorie> }) =>
|
||||
kategorieApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
notification.showSuccess('Kategorie aktualisiert');
|
||||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
|
||||
setEditRowId(null);
|
||||
},
|
||||
onError: () => notification.showError('Fehler beim Aktualisieren'),
|
||||
});
|
||||
|
||||
const deleteKatMutation = useMutation({
|
||||
mutationFn: (id: number) => kategorieApi.delete(id),
|
||||
onSuccess: () => {
|
||||
notification.showSuccess('Kategorie deaktiviert');
|
||||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
|
||||
},
|
||||
onError: () => notification.showError('Fehler beim Löschen'),
|
||||
});
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
if (!isFeatureEnabled('fahrzeugbuchungen')) {
|
||||
return <ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." />;
|
||||
@@ -244,23 +186,23 @@ function FahrzeugBuchungen() {
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
Fahrzeugbuchungen
|
||||
</Typography>
|
||||
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
|
||||
iCal abonnieren
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{canManage && (
|
||||
<Tooltip title="Einstellungen">
|
||||
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=fahrzeugbuchungen')}>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
|
||||
iCal abonnieren
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}>
|
||||
<Tab label="Buchungen" />
|
||||
{canManage && <Tab label="Einstellungen" />}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* ── Tab 0: Buchungen ─────────────────────────────────────────────── */}
|
||||
{tabIndex === 0 && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
{/* ── Buchungen ─────────────────────────────────────────────── */}
|
||||
<>
|
||||
{/* Filters */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
|
||||
<GermanDateField
|
||||
@@ -410,173 +352,9 @@ function FahrzeugBuchungen() {
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Tab 1: Einstellungen ─────────────────────────────────────────── */}
|
||||
{tabIndex === 1 && canManage && (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Buchungskategorien</Typography>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => setNewKatDialog(true)}
|
||||
>
|
||||
Neue Kategorie
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Farbe</TableCell>
|
||||
<TableCell>Sortierung</TableCell>
|
||||
<TableCell>Aktiv</TableCell>
|
||||
<TableCell align="right">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{kategorien.map((kat) => {
|
||||
const isEditing = editRowId === kat.id;
|
||||
return (
|
||||
<TableRow key={kat.id}>
|
||||
<TableCell>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
size="small"
|
||||
value={editRowData.bezeichnung ?? kat.bezeichnung}
|
||||
onChange={(e) =>
|
||||
setEditRowData((d) => ({ ...d, bezeichnung: e.target.value }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
kat.bezeichnung
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="color"
|
||||
value={editRowData.farbe ?? kat.farbe}
|
||||
onChange={(e) =>
|
||||
setEditRowData((d) => ({ ...d, farbe: e.target.value }))
|
||||
}
|
||||
style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 1,
|
||||
bgcolor: kat.farbe,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={editRowData.sort_order ?? kat.sort_order}
|
||||
onChange={(e) =>
|
||||
setEditRowData((d) => ({
|
||||
...d,
|
||||
sort_order: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
) : (
|
||||
kat.sort_order
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditing ? (
|
||||
<Checkbox
|
||||
checked={editRowData.aktiv ?? kat.aktiv}
|
||||
onChange={(e) =>
|
||||
setEditRowData((d) => ({ ...d, aktiv: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox checked={kat.aktiv} disabled />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{isEditing ? (
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
updateKatMutation.mutate({ id: kat.id, data: editRowData })
|
||||
}
|
||||
>
|
||||
<Save fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditRowId(null);
|
||||
setEditRowData({});
|
||||
}}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditRowId(kat.id);
|
||||
setEditRowData({
|
||||
bezeichnung: kat.bezeichnung,
|
||||
farbe: kat.farbe,
|
||||
sort_order: kat.sort_order,
|
||||
aktiv: kat.aktiv,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => deleteKatMutation.mutate(kat.id)}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{kategorien.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
|
||||
Keine Kategorien vorhanden
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── FAB ── */}
|
||||
{canCreate && tabIndex === 0 && (
|
||||
{canCreate && (
|
||||
<ChatAwareFab
|
||||
color="primary"
|
||||
aria-label="Buchung erstellen"
|
||||
@@ -655,57 +433,6 @@ function FahrzeugBuchungen() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── New category dialog ── */}
|
||||
<Dialog
|
||||
open={newKatDialog}
|
||||
onClose={() => setNewKatDialog(false)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Neue Kategorie</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Bezeichnung"
|
||||
required
|
||||
value={newKatForm.bezeichnung}
|
||||
onChange={(e) =>
|
||||
setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
Farbe
|
||||
</Typography>
|
||||
<input
|
||||
type="color"
|
||||
value={newKatForm.farbe}
|
||||
onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))}
|
||||
style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setNewKatDialog(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
createKatMutation.mutate({
|
||||
bezeichnung: newKatForm.bezeichnung,
|
||||
farbe: newKatForm.farbe,
|
||||
aktiv: true,
|
||||
sort_order: kategorien.length,
|
||||
})
|
||||
}
|
||||
disabled={!newKatForm.bezeichnung || createKatMutation.isPending}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
@@ -13,41 +13,28 @@ import {
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Add as AddIcon,
|
||||
CheckCircle,
|
||||
Delete as DeleteIcon,
|
||||
DirectionsCar,
|
||||
Edit as EditIcon,
|
||||
Error as ErrorIcon,
|
||||
FileDownload,
|
||||
PauseCircle,
|
||||
School,
|
||||
Search,
|
||||
Settings as SettingsIcon,
|
||||
Warning,
|
||||
ReportProblem,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import { equipmentApi } from '../services/equipment';
|
||||
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
|
||||
import type { VehicleEquipmentWarning } from '../types/equipment.types';
|
||||
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
|
||||
import {
|
||||
@@ -55,11 +42,8 @@ import {
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
} from '../types/vehicle.types';
|
||||
import type { FahrzeugTyp } from '../types/checklist.types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { FormDialog } from '../components/templates';
|
||||
|
||||
// ── Status chip config ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -303,184 +287,11 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings =
|
||||
);
|
||||
};
|
||||
|
||||
// ── Fahrzeugtypen-Verwaltung (Einstellungen Tab) ─────────────────────────────
|
||||
|
||||
function FahrzeugTypenSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const { data: fahrzeugTypen = [], isLoading } = useQuery({
|
||||
queryKey: ['fahrzeug-typen'],
|
||||
queryFn: fahrzeugTypenApi.getAll,
|
||||
});
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FahrzeugTyp | null>(null);
|
||||
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<FahrzeugTyp>) => fahrzeugTypenApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
|
||||
setDialogOpen(false);
|
||||
showSuccess('Fahrzeugtyp erstellt');
|
||||
},
|
||||
onError: () => showError('Fehler beim Erstellen'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<FahrzeugTyp> }) =>
|
||||
fahrzeugTypenApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
|
||||
setDialogOpen(false);
|
||||
showSuccess('Fahrzeugtyp aktualisiert');
|
||||
},
|
||||
onError: () => showError('Fehler beim Aktualisieren'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => fahrzeugTypenApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] });
|
||||
setDeleteError(null);
|
||||
showSuccess('Fahrzeugtyp gelöscht');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.';
|
||||
setDeleteError(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm({ name: '', beschreibung: '', icon: '' });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (t: FahrzeugTyp) => {
|
||||
setEditing(t);
|
||||
setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.name.trim()) return;
|
||||
if (editing) {
|
||||
updateMutation.mutate({ id: editing.id, data: form });
|
||||
} else {
|
||||
createMutation.mutate(form);
|
||||
}
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Fahrzeugtypen
|
||||
</Typography>
|
||||
|
||||
{deleteError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError(null)}>
|
||||
{deleteError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
Neuer Fahrzeugtyp
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Beschreibung</TableCell>
|
||||
<TableCell>Icon</TableCell>
|
||||
<TableCell align="right">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{fahrzeugTypen.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">
|
||||
Keine Fahrzeugtypen vorhanden
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
fahrzeugTypen.map((t) => (
|
||||
<TableRow key={t.id} hover>
|
||||
<TableCell>{t.name}</TableCell>
|
||||
<TableCell>{t.beschreibung ?? '–'}</TableCell>
|
||||
<TableCell>{t.icon ?? '–'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(t)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => deleteMutation.mutate(t.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
|
||||
isSubmitting={isSaving}
|
||||
>
|
||||
<TextField
|
||||
label="Name *"
|
||||
fullWidth
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Beschreibung"
|
||||
fullWidth
|
||||
value={form.beschreibung}
|
||||
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Icon"
|
||||
fullWidth
|
||||
value={form.icon}
|
||||
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
|
||||
placeholder="z.B. fire_truck"
|
||||
/>
|
||||
</FormDialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Fahrzeuge() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
|
||||
const { isAdmin } = usePermissions();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
|
||||
@@ -489,8 +300,6 @@ function Fahrzeuge() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [equipmentWarnings, setEquipmentWarnings] = useState<Map<string, VehicleEquipmentWarning[]>>(new Map());
|
||||
|
||||
const canEditSettings = hasPermission('checklisten:manage_templates');
|
||||
|
||||
const fetchVehicles = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -579,7 +388,14 @@ function Fahrzeuge() {
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{hasPermission('fahrzeuge:configure') && (
|
||||
<Tooltip title="Einstellungen">
|
||||
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=fahrzeuge')}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@@ -588,19 +404,9 @@ function Fahrzeuge() {
|
||||
>
|
||||
Prüfungen CSV
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
|
||||
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab label="Übersicht" />
|
||||
{canEditSettings && <Tab label="Einstellungen" />}
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<>
|
||||
{hasOverdue && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
||||
@@ -670,11 +476,7 @@ function Fahrzeuge() {
|
||||
</ChatAwareFab>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 1 && canEditSettings && (
|
||||
<FahrzeugTypenSettings />
|
||||
)}
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
|
||||
TableHead, TableRow, Paper, Chip, IconButton, Button, TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, CircularProgress, FormControlLabel, Switch,
|
||||
Autocomplete, ToggleButtonGroup, ToggleButton,
|
||||
Box, Tab, Tabs, Typography, Chip, IconButton, Button, TextField,
|
||||
CircularProgress, FormControlLabel, Switch,
|
||||
Autocomplete, ToggleButtonGroup, ToggleButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
// Note: Table/TableBody/etc still needed for IssueSettings tables
|
||||
import {
|
||||
Add as AddIcon, Delete as DeleteIcon,
|
||||
Add as AddIcon,
|
||||
BugReport, FiberNew, HelpOutline,
|
||||
Circle as CircleIcon, Edit as EditIcon,
|
||||
DragIndicator, Check as CheckIcon, Close as CloseIcon,
|
||||
Circle as CircleIcon,
|
||||
ViewList as ViewListIcon, ViewKanban as ViewKanbanIcon,
|
||||
Settings as SettingsIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { DataTable, FormDialog } from '../components/templates';
|
||||
import { DataTable } from '../components/templates';
|
||||
import type { Column } from '../components/templates';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
@@ -211,335 +209,6 @@ function FilterBar({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared color picker helpers ──
|
||||
|
||||
const MUI_CHIP_COLORS = ['default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'] as const;
|
||||
const MUI_THEME_COLORS: Record<string, string> = { default: '#9e9e9e', primary: '#1976d2', secondary: '#9c27b0', error: '#d32f2f', info: '#0288d1', success: '#2e7d32', warning: '#ed6c02', action: '#757575' };
|
||||
const ICON_COLORS = ['action', 'error', 'info', 'success', 'warning', 'primary', 'secondary'] as const;
|
||||
|
||||
function ColorSwatch({ colors, value, onChange }: { colors: readonly string[]; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{colors.map((c) => (
|
||||
<Box
|
||||
key={c}
|
||||
onClick={() => onChange(c)}
|
||||
sx={{
|
||||
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer',
|
||||
bgcolor: MUI_THEME_COLORS[c] ?? c,
|
||||
border: value === c ? '2.5px solid' : '2px solid transparent',
|
||||
borderColor: value === c ? 'text.primary' : 'transparent',
|
||||
'&:hover': { opacity: 0.8 },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HexColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
component="input"
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
||||
sx={{ width: 28, height: 28, border: 'none', borderRadius: 1, cursor: 'pointer', p: 0, bgcolor: 'transparent' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issue Settings (consolidated: Status + Prioritäten + Kategorien) ──
|
||||
|
||||
function IssueSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
// ── Status state ──
|
||||
const [statusCreateOpen, setStatusCreateOpen] = useState(false);
|
||||
const [statusCreateData, setStatusCreateData] = useState<Partial<IssueStatusDef>>({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 });
|
||||
const [statusEditId, setStatusEditId] = useState<number | null>(null);
|
||||
const [statusEditData, setStatusEditData] = useState<Partial<IssueStatusDef>>({});
|
||||
|
||||
// ── Priority state ──
|
||||
const [prioCreateOpen, setPrioCreateOpen] = useState(false);
|
||||
const [prioCreateData, setPrioCreateData] = useState<Partial<IssuePriorityDef>>({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 });
|
||||
const [prioEditId, setPrioEditId] = useState<number | null>(null);
|
||||
const [prioEditData, setPrioEditData] = useState<Partial<IssuePriorityDef>>({});
|
||||
|
||||
// ── Kategorien state ──
|
||||
const [typeCreateOpen, setTypeCreateOpen] = useState(false);
|
||||
const [typeCreateData, setTypeCreateData] = useState<Partial<IssueTyp>>({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true });
|
||||
const [typeEditId, setTypeEditId] = useState<number | null>(null);
|
||||
const [typeEditData, setTypeEditData] = useState<Partial<IssueTyp>>({});
|
||||
|
||||
// ── Queries ──
|
||||
const { data: issueStatuses = [], isLoading: statusLoading } = useQuery({ queryKey: ['issue-statuses'], queryFn: issuesApi.getStatuses });
|
||||
const { data: issuePriorities = [], isLoading: prioLoading } = useQuery({ queryKey: ['issue-priorities'], queryFn: issuesApi.getPriorities });
|
||||
const { data: types = [], isLoading: typesLoading } = useQuery({ queryKey: ['issue-types'], queryFn: issuesApi.getTypes });
|
||||
|
||||
// ── Status mutations ──
|
||||
const createStatusMut = useMutation({
|
||||
mutationFn: (data: Partial<IssueStatusDef>) => issuesApi.createStatus(data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status erstellt'); setStatusCreateOpen(false); setStatusCreateData({ schluessel: '', bezeichnung: '', farbe: 'default', ist_abschluss: false, ist_initial: false, benoetigt_typ_freigabe: false, sort_order: 0 }); },
|
||||
onError: () => showError('Fehler beim Erstellen'),
|
||||
});
|
||||
const updateStatusMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<IssueStatusDef> }) => issuesApi.updateStatus(id, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status aktualisiert'); setStatusEditId(null); },
|
||||
onError: () => showError('Fehler beim Aktualisieren'),
|
||||
});
|
||||
const deleteStatusMut = useMutation({
|
||||
mutationFn: (id: number) => issuesApi.deleteStatus(id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status deaktiviert'); },
|
||||
onError: () => showError('Fehler beim Deaktivieren'),
|
||||
});
|
||||
|
||||
// ── Priority mutations ──
|
||||
const createPrioMut = useMutation({
|
||||
mutationFn: (data: Partial<IssuePriorityDef>) => issuesApi.createPriority(data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität erstellt'); setPrioCreateOpen(false); setPrioCreateData({ schluessel: '', bezeichnung: '', farbe: '#9e9e9e', sort_order: 0 }); },
|
||||
onError: () => showError('Fehler beim Erstellen'),
|
||||
});
|
||||
const updatePrioMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<IssuePriorityDef> }) => issuesApi.updatePriority(id, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität aktualisiert'); setPrioEditId(null); },
|
||||
onError: () => showError('Fehler beim Aktualisieren'),
|
||||
});
|
||||
const deletePrioMut = useMutation({
|
||||
mutationFn: (id: number) => issuesApi.deletePriority(id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität deaktiviert'); },
|
||||
onError: () => showError('Fehler beim Deaktivieren'),
|
||||
});
|
||||
|
||||
// ── Type mutations ──
|
||||
const createTypeMut = useMutation({
|
||||
mutationFn: (data: Partial<IssueTyp>) => issuesApi.createType(data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie erstellt'); setTypeCreateOpen(false); setTypeCreateData({ name: '', icon: 'HelpOutline', farbe: 'action', erlaubt_abgelehnt: true, sort_order: 0, aktiv: true }); },
|
||||
onError: () => showError('Fehler beim Erstellen'),
|
||||
});
|
||||
const updateTypeMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<IssueTyp> }) => issuesApi.updateType(id, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie aktualisiert'); setTypeEditId(null); },
|
||||
onError: () => showError('Fehler beim Aktualisieren'),
|
||||
});
|
||||
const deleteTypeMut = useMutation({
|
||||
mutationFn: (id: number) => issuesApi.deleteType(id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie deaktiviert'); },
|
||||
onError: () => showError('Fehler beim Deaktivieren'),
|
||||
});
|
||||
|
||||
const flatTypes = useMemo(() => {
|
||||
const roots = types.filter(t => !t.parent_id);
|
||||
const result: { type: IssueTyp; indent: boolean }[] = [];
|
||||
for (const root of roots) {
|
||||
result.push({ type: root, indent: false });
|
||||
for (const child of types.filter(t => t.parent_id === root.id)) result.push({ type: child, indent: true });
|
||||
}
|
||||
const listed = new Set(result.map(r => r.type.id));
|
||||
for (const t of types) { if (!listed.has(t.id)) result.push({ type: t, indent: false }); }
|
||||
return result;
|
||||
}, [types]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
|
||||
{/* ──── Section 1: Status ──── */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Status</Typography>
|
||||
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setStatusCreateOpen(true)}>Neuer Status</Button>
|
||||
</Box>
|
||||
{statusLoading ? <CircularProgress /> : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Schlüssel</TableCell>
|
||||
<TableCell>Farbe</TableCell>
|
||||
<TableCell>Abschluss</TableCell>
|
||||
<TableCell>Initial</TableCell>
|
||||
<TableCell>Sort</TableCell>
|
||||
<TableCell>Aktiv</TableCell>
|
||||
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{issueStatuses.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Status vorhanden</TableCell></TableRow>
|
||||
) : issueStatuses.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
{statusEditId === s.id ? (<>
|
||||
<TableCell><TextField size="small" value={statusEditData.bezeichnung ?? s.bezeichnung} onChange={(e) => setStatusEditData({ ...statusEditData, bezeichnung: e.target.value })} /></TableCell>
|
||||
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
|
||||
<TableCell><ColorSwatch colors={MUI_CHIP_COLORS} value={statusEditData.farbe ?? s.farbe} onChange={(v) => setStatusEditData({ ...statusEditData, farbe: v })} /></TableCell>
|
||||
<TableCell><Switch checked={statusEditData.ist_abschluss ?? s.ist_abschluss} onChange={(e) => setStatusEditData({ ...statusEditData, ist_abschluss: e.target.checked })} size="small" /></TableCell>
|
||||
<TableCell><Switch checked={statusEditData.ist_initial ?? s.ist_initial} onChange={(e) => setStatusEditData({ ...statusEditData, ist_initial: e.target.checked })} size="small" /></TableCell>
|
||||
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={statusEditData.sort_order ?? s.sort_order} onChange={(e) => setStatusEditData({ ...statusEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
|
||||
<TableCell><Switch checked={statusEditData.aktiv ?? s.aktiv} onChange={(e) => setStatusEditData({ ...statusEditData, aktiv: e.target.checked })} size="small" /></TableCell>
|
||||
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updateStatusMut.mutate({ id: s.id, data: statusEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setStatusEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box></TableCell>
|
||||
</>) : (<>
|
||||
<TableCell><Chip label={s.bezeichnung} color={s.farbe as any} size="small" /></TableCell>
|
||||
<TableCell><Typography variant="body2" color="text.secondary">{s.schluessel}</Typography></TableCell>
|
||||
<TableCell><Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: MUI_THEME_COLORS[s.farbe] ?? s.farbe }} /></TableCell>
|
||||
<TableCell>{s.ist_abschluss ? '✓' : '-'}</TableCell>
|
||||
<TableCell>{s.ist_initial ? '✓' : '-'}</TableCell>
|
||||
<TableCell>{s.sort_order}</TableCell>
|
||||
<TableCell><Switch checked={s.aktiv} onChange={(e) => updateStatusMut.mutate({ id: s.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
|
||||
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setStatusEditId(s.id); setStatusEditData({ bezeichnung: s.bezeichnung, farbe: s.farbe, ist_abschluss: s.ist_abschluss, ist_initial: s.ist_initial, benoetigt_typ_freigabe: s.benoetigt_typ_freigabe, sort_order: s.sort_order, aktiv: s.aktiv }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deleteStatusMut.mutate(s.id)}><DeleteIcon fontSize="small" /></IconButton></Box></TableCell>
|
||||
</>)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ──── Section 2: Prioritäten ──── */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Prioritäten</Typography>
|
||||
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setPrioCreateOpen(true)}>Neue Priorität</Button>
|
||||
</Box>
|
||||
{prioLoading ? <CircularProgress /> : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Schlüssel</TableCell>
|
||||
<TableCell>Farbe</TableCell>
|
||||
<TableCell>Sort</TableCell>
|
||||
<TableCell>Aktiv</TableCell>
|
||||
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{issuePriorities.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Prioritäten vorhanden</TableCell></TableRow>
|
||||
) : issuePriorities.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
{prioEditId === p.id ? (<>
|
||||
<TableCell><TextField size="small" value={prioEditData.bezeichnung ?? p.bezeichnung} onChange={(e) => setPrioEditData({ ...prioEditData, bezeichnung: e.target.value })} /></TableCell>
|
||||
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
|
||||
<TableCell><HexColorInput value={prioEditData.farbe ?? p.farbe} onChange={(v) => setPrioEditData({ ...prioEditData, farbe: v })} /></TableCell>
|
||||
<TableCell><TextField size="small" type="number" sx={{ width: 60 }} value={prioEditData.sort_order ?? p.sort_order} onChange={(e) => setPrioEditData({ ...prioEditData, sort_order: parseInt(e.target.value) || 0 })} /></TableCell>
|
||||
<TableCell><Switch checked={prioEditData.aktiv ?? p.aktiv} onChange={(e) => setPrioEditData({ ...prioEditData, aktiv: e.target.checked })} size="small" /></TableCell>
|
||||
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updatePrioMut.mutate({ id: p.id, data: prioEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setPrioEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box></TableCell>
|
||||
</>) : (<>
|
||||
<TableCell><Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><CircleIcon sx={{ fontSize: 12, color: p.farbe }} /><Typography variant="body2">{p.bezeichnung}</Typography></Box></TableCell>
|
||||
<TableCell><Typography variant="body2" color="text.secondary">{p.schluessel}</Typography></TableCell>
|
||||
<TableCell><Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: p.farbe, border: '1px solid', borderColor: 'divider' }} /></TableCell>
|
||||
<TableCell>{p.sort_order}</TableCell>
|
||||
<TableCell><Switch checked={p.aktiv} onChange={(e) => updatePrioMut.mutate({ id: p.id, data: { aktiv: e.target.checked } })} size="small" /></TableCell>
|
||||
<TableCell><Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setPrioEditId(p.id); setPrioEditData({ bezeichnung: p.bezeichnung, farbe: p.farbe, sort_order: p.sort_order, aktiv: p.aktiv }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deletePrioMut.mutate(p.id)}><DeleteIcon fontSize="small" /></IconButton></Box></TableCell>
|
||||
</>)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ──── Section 3: Kategorien ──── */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Kategorien</Typography>
|
||||
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={() => setTypeCreateOpen(true)}>Neue Kategorie</Button>
|
||||
</Box>
|
||||
{typesLoading ? <CircularProgress /> : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Icon</TableCell>
|
||||
<TableCell>Farbe</TableCell>
|
||||
<TableCell>Abgelehnt</TableCell>
|
||||
<TableCell>Sort</TableCell>
|
||||
<TableCell>Aktiv</TableCell>
|
||||
<TableCell sx={{ width: 80 }}>Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{flatTypes.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} sx={{ textAlign: 'center', color: 'text.secondary', py: 3 }}>Keine Kategorien vorhanden</TableCell></TableRow>
|
||||
) : flatTypes.map(({ type: t, indent }) => (
|
||||
<TableRow key={t.id} sx={indent ? { bgcolor: 'action.hover' } : undefined}>
|
||||
<TableCell><Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{indent && <DragIndicator fontSize="small" sx={{ opacity: 0.3, ml: 2 }} />}{typeEditId === t.id ? <TextField size="small" value={typeEditData.name || ''} onChange={(e) => setTypeEditData({ ...typeEditData, name: e.target.value })} /> : <Typography variant="body2">{t.name}</Typography>}</Box></TableCell>
|
||||
<TableCell>{typeEditId === t.id ? (<Select size="small" value={typeEditData.icon || 'HelpOutline'} onChange={(e) => setTypeEditData({ ...typeEditData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select>) : <Box sx={{ display: 'inline-flex' }}>{getTypIcon(t.icon, t.farbe)}</Box>}</TableCell>
|
||||
<TableCell>{typeEditId === t.id ? <ColorSwatch colors={ICON_COLORS} value={typeEditData.farbe || 'action'} onChange={(v) => setTypeEditData({ ...typeEditData, farbe: v })} /> : <Box sx={{ width: 16, height: 16, borderRadius: '50%', bgcolor: MUI_THEME_COLORS[t.farbe || 'action'] ?? '#757575' }} />}</TableCell>
|
||||
<TableCell>{typeEditId === t.id ? <Switch checked={typeEditData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeEditData({ ...typeEditData, erlaubt_abgelehnt: e.target.checked })} size="small" /> : (t.erlaubt_abgelehnt ? '✓' : '-')}</TableCell>
|
||||
<TableCell>{typeEditId === t.id ? <TextField size="small" type="number" sx={{ width: 60 }} value={typeEditData.sort_order ?? 0} onChange={(e) => setTypeEditData({ ...typeEditData, sort_order: parseInt(e.target.value) || 0 })} /> : t.sort_order}</TableCell>
|
||||
<TableCell><Switch checked={typeEditId === t.id ? (typeEditData.aktiv ?? t.aktiv) : t.aktiv} onChange={(e) => { if (typeEditId === t.id) setTypeEditData({ ...typeEditData, aktiv: e.target.checked }); else updateTypeMut.mutate({ id: t.id, data: { aktiv: e.target.checked } }); }} size="small" /></TableCell>
|
||||
<TableCell>{typeEditId === t.id ? (<Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" color="primary" onClick={() => updateTypeMut.mutate({ id: t.id, data: typeEditData })}><CheckIcon fontSize="small" /></IconButton><IconButton size="small" onClick={() => setTypeEditId(null)}><CloseIcon fontSize="small" /></IconButton></Box>) : (<Box sx={{ display: 'flex', gap: 0.5 }}><IconButton size="small" onClick={() => { setTypeEditId(t.id); setTypeEditData({ name: t.name, icon: t.icon, farbe: t.farbe, erlaubt_abgelehnt: t.erlaubt_abgelehnt, sort_order: t.sort_order, aktiv: t.aktiv, parent_id: t.parent_id }); }}><EditIcon fontSize="small" /></IconButton><IconButton size="small" color="error" onClick={() => deleteTypeMut.mutate(t.id)}><DeleteIcon fontSize="small" /></IconButton></Box>)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ──── Create Status Dialog ──── */}
|
||||
<FormDialog
|
||||
open={statusCreateOpen}
|
||||
onClose={() => setStatusCreateOpen(false)}
|
||||
onSubmit={() => createStatusMut.mutate(statusCreateData)}
|
||||
title="Neuer Status"
|
||||
submitLabel="Erstellen"
|
||||
isSubmitting={createStatusMut.isPending}
|
||||
>
|
||||
<TextField label="Schlüssel" required fullWidth value={statusCreateData.schluessel || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus />
|
||||
<TextField label="Bezeichnung" required fullWidth value={statusCreateData.bezeichnung || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} />
|
||||
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={MUI_CHIP_COLORS} value={statusCreateData.farbe || 'default'} onChange={(v) => setStatusCreateData({ ...statusCreateData, farbe: v })} /></Box>
|
||||
<TextField label="Sortierung" type="number" value={statusCreateData.sort_order ?? 0} onChange={(e) => setStatusCreateData({ ...statusCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
<FormControlLabel control={<Switch checked={statusCreateData.ist_abschluss ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" />
|
||||
<FormControlLabel control={<Switch checked={statusCreateData.ist_initial ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" />
|
||||
<FormControlLabel control={<Switch checked={statusCreateData.benoetigt_typ_freigabe ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" />
|
||||
</FormDialog>
|
||||
|
||||
{/* ──── Create Priority Dialog ──── */}
|
||||
<FormDialog
|
||||
open={prioCreateOpen}
|
||||
onClose={() => setPrioCreateOpen(false)}
|
||||
onSubmit={() => createPrioMut.mutate(prioCreateData)}
|
||||
title="Neue Priorität"
|
||||
submitLabel="Erstellen"
|
||||
isSubmitting={createPrioMut.isPending}
|
||||
>
|
||||
<TextField label="Schlüssel" required fullWidth value={prioCreateData.schluessel || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus />
|
||||
<TextField label="Bezeichnung" required fullWidth value={prioCreateData.bezeichnung || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} />
|
||||
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><HexColorInput value={prioCreateData.farbe || '#9e9e9e'} onChange={(v) => setPrioCreateData({ ...prioCreateData, farbe: v })} /></Box>
|
||||
<TextField label="Sortierung" type="number" value={prioCreateData.sort_order ?? 0} onChange={(e) => setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</FormDialog>
|
||||
|
||||
{/* ──── Create Kategorie Dialog ──── */}
|
||||
<FormDialog
|
||||
open={typeCreateOpen}
|
||||
onClose={() => setTypeCreateOpen(false)}
|
||||
onSubmit={() => createTypeMut.mutate(typeCreateData)}
|
||||
title="Neue Kategorie"
|
||||
submitLabel="Erstellen"
|
||||
isSubmitting={createTypeMut.isPending}
|
||||
>
|
||||
<TextField label="Name" required fullWidth value={typeCreateData.name || ''} onChange={(e) => setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus />
|
||||
<FormControl fullWidth><InputLabel>Übergeordnete Kategorie</InputLabel><Select value={typeCreateData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setTypeCreateData({ ...typeCreateData, parent_id: e.target.value ? Number(e.target.value) : null })}><MenuItem value="">Keine</MenuItem>{types.filter(t => !t.parent_id).map(t => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl fullWidth><InputLabel>Icon</InputLabel><Select value={typeCreateData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setTypeCreateData({ ...typeCreateData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select></FormControl>
|
||||
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={ICON_COLORS} value={typeCreateData.farbe || 'action'} onChange={(v) => setTypeCreateData({ ...typeCreateData, farbe: v })} /></Box>
|
||||
<FormControlLabel control={<Switch checked={typeCreateData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" />
|
||||
<TextField label="Sortierung" type="number" value={typeCreateData.sort_order ?? 0} onChange={(e) => setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</FormDialog>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// ── Main Page ──
|
||||
|
||||
export default function Issues() {
|
||||
@@ -563,9 +232,8 @@ export default function Issues() {
|
||||
{ label: 'Zugewiesene Issues', key: 'assigned' },
|
||||
];
|
||||
if (canViewAll) t.push({ label: 'Alle Issues', key: 'all' });
|
||||
if (hasEditSettings) t.push({ label: 'Einstellungen', key: 'settings' });
|
||||
return t;
|
||||
}, [canViewAll, hasEditSettings]);
|
||||
}, [canViewAll]);
|
||||
|
||||
const tabParam = parseInt(searchParams.get('tab') || '0', 10);
|
||||
const tab = isNaN(tabParam) || tabParam < 0 || tabParam >= tabs.length ? 0 : tabParam;
|
||||
@@ -649,19 +317,28 @@ export default function Issues() {
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h5">Issues</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={handleViewModeChange}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="list" aria-label="Listenansicht">
|
||||
<ViewListIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="kanban" aria-label="Kanban-Ansicht">
|
||||
<ViewKanbanIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{hasEditSettings && (
|
||||
<Tooltip title="Einstellungen">
|
||||
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=issues')}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={handleViewModeChange}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="list" aria-label="Listenansicht">
|
||||
<ViewListIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="kanban" aria-label="Kanban-Ansicht">
|
||||
<ViewKanbanIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 0 }}>
|
||||
@@ -738,12 +415,6 @@ export default function Issues() {
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
{/* Tab: Einstellungen (conditional) */}
|
||||
{hasEditSettings && (
|
||||
<TabPanel value={tab} index={tabs.findIndex(t => t.key === 'settings')}>
|
||||
<IssueSettings />
|
||||
</TabPanel>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* FAB */}
|
||||
|
||||
@@ -30,14 +30,12 @@ import {
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
@@ -66,7 +64,7 @@ import {
|
||||
ViewDay as ViewDayIcon,
|
||||
ViewWeek as ViewWeekIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ServiceModePage from '../components/shared/ServiceModePage';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
@@ -1570,154 +1568,6 @@ function VeranstaltungFormDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Settings Tab (Kategorien CRUD)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SettingsTabProps {
|
||||
kategorien: VeranstaltungKategorie[];
|
||||
onKategorienChange: (k: VeranstaltungKategorie[]) => void;
|
||||
}
|
||||
|
||||
function SettingsTab({ kategorien, onKategorienChange }: SettingsTabProps) {
|
||||
const notification = useNotification();
|
||||
const [editingKat, setEditingKat] = useState<VeranstaltungKategorie | null>(null);
|
||||
const [newKatOpen, setNewKatOpen] = useState(false);
|
||||
const [newKatForm, setNewKatForm] = useState({ name: '', farbe: '#1976d2', beschreibung: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const reload = async () => {
|
||||
const kat = await eventsApi.getKategorien();
|
||||
onKategorienChange(kat);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newKatForm.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await eventsApi.createKategorie({ name: newKatForm.name.trim(), farbe: newKatForm.farbe, beschreibung: newKatForm.beschreibung || undefined });
|
||||
notification.showSuccess('Kategorie erstellt');
|
||||
setNewKatOpen(false);
|
||||
setNewKatForm({ name: '', farbe: '#1976d2', beschreibung: '' });
|
||||
await reload();
|
||||
} catch { notification.showError('Fehler beim Erstellen'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingKat) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await eventsApi.updateKategorie(editingKat.id, { name: editingKat.name, farbe: editingKat.farbe, beschreibung: editingKat.beschreibung ?? undefined });
|
||||
notification.showSuccess('Kategorie gespeichert');
|
||||
setEditingKat(null);
|
||||
await reload();
|
||||
} catch { notification.showError('Fehler beim Speichern'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await eventsApi.deleteKategorie(id);
|
||||
notification.showSuccess('Kategorie gelöscht');
|
||||
await reload();
|
||||
} catch { notification.showError('Fehler beim Löschen'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>Veranstaltungskategorien</Typography>
|
||||
<Button startIcon={<Add />} variant="contained" size="small" onClick={() => setNewKatOpen(true)}>
|
||||
Neue Kategorie
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Farbe</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Beschreibung</TableCell>
|
||||
<TableCell align="right">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{kategorien.map((k) => (
|
||||
<TableRow key={k.id}>
|
||||
<TableCell>
|
||||
<Box sx={{ width: 24, height: 24, borderRadius: '50%', bgcolor: k.farbe, border: '1px solid', borderColor: 'divider' }} />
|
||||
</TableCell>
|
||||
<TableCell>{k.name}</TableCell>
|
||||
<TableCell>{k.beschreibung ?? '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => setEditingKat({ ...k })}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(k.id)}>
|
||||
<DeleteForeverIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{kategorien.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 3, color: 'text.secondary' }}>
|
||||
Noch keine Kategorien vorhanden
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={Boolean(editingKat)} onClose={() => setEditingKat(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Kategorie bearbeiten</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Name" value={editingKat?.name ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2">Farbe</Typography>
|
||||
<input type="color" value={editingKat?.farbe ?? '#1976d2'} onChange={(e) => setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
|
||||
<Typography variant="body2" color="text.secondary">{editingKat?.farbe}</Typography>
|
||||
</Box>
|
||||
<TextField label="Beschreibung" value={editingKat?.beschreibung ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditingKat(null)}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={handleUpdate} disabled={saving || !editingKat?.name.trim()}>
|
||||
{saving ? <CircularProgress size={20} /> : 'Speichern'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* New category dialog */}
|
||||
<Dialog open={newKatOpen} onClose={() => setNewKatOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Neue Kategorie</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Name" value={newKatForm.name} onChange={(e) => setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2">Farbe</Typography>
|
||||
<input type="color" value={newKatForm.farbe} onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
|
||||
<Typography variant="body2" color="text.secondary">{newKatForm.farbe}</Typography>
|
||||
</Box>
|
||||
<TextField label="Beschreibung" value={newKatForm.beschreibung} onChange={(e) => setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setNewKatOpen(false)}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !newKatForm.name.trim()}>
|
||||
{saving ? <CircularProgress size={20} /> : 'Erstellen'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Main Kalender Page
|
||||
@@ -1732,11 +1582,6 @@ export default function Kalender() {
|
||||
|
||||
const canWriteEvents = hasPermission('kalender:create');
|
||||
|
||||
// ── Tab / search params ───────────────────────────────────────────────────
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = Number(searchParams.get('tab') ?? 0);
|
||||
const setActiveTab = (n: number) => setSearchParams({ tab: String(n) });
|
||||
|
||||
// ── Calendar state ─────────────────────────────────────────────────────────
|
||||
const today = new Date();
|
||||
const [viewMonth, setViewMonth] = useState({
|
||||
@@ -2031,16 +1876,15 @@ export default function Kalender() {
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
Kalender
|
||||
</Typography>
|
||||
{hasPermission('kalender:configure') && (
|
||||
<Tooltip title="Einstellungen">
|
||||
<IconButton onClick={() => navigate('/admin/settings?tab=1&subtab=kalender')}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{canWriteEvents ? (
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab label="Kalender" value={0} />
|
||||
<Tab icon={<SettingsIcon fontSize="small" />} iconPosition="start" label="Einstellungen" value={1} />
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{activeTab === 0 && (
|
||||
<>
|
||||
{/* ── Calendar ───────────────────────────────────────────── */}
|
||||
<Box>
|
||||
@@ -2630,11 +2474,6 @@ export default function Kalender() {
|
||||
</Dialog>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && canWriteEvents && (
|
||||
<SettingsTab kategorien={kategorien} onKategorienChange={setKategorien} />
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -15,17 +15,21 @@ import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { Search as SearchIcon, OpenInNew } from '@mui/icons-material';
|
||||
import { Search as SearchIcon, OpenInNew, Settings } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { bookstackApi } from '../services/bookstack';
|
||||
import { safeOpenUrl } from '../utils/safeOpenUrl';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import type { BookStackPage, BookStackSearchResult } from '../types/bookstack.types';
|
||||
|
||||
export default function Wissen() {
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
|
||||
@@ -89,7 +93,17 @@ export default function Wissen() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', height: 'calc(100vh - 120px)', gap: 2, p: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
|
||||
{hasPermission('wissen:configure') && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 2, pt: 1 }}>
|
||||
<Tooltip title="Einstellungen">
|
||||
<IconButton size="small" onClick={() => navigate('/admin/settings?tab=1&subtab=wissen')}>
|
||||
<Settings fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flex: 1, gap: 2, p: 2, minHeight: 0 }}>
|
||||
{/* Left panel: search + list */}
|
||||
<Paper sx={{ width: '40%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
@@ -274,6 +288,7 @@ export default function Wissen() {
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
18
frontend/src/services/toolConfig.ts
Normal file
18
frontend/src/services/toolConfig.ts
Normal 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),
|
||||
};
|
||||
12
frontend/src/types/toolConfig.types.ts
Normal file
12
frontend/src/types/toolConfig.types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user