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