resolve issues with new features
This commit is contained in:
@@ -85,6 +85,7 @@ import bookstackRoutes from './routes/bookstack.routes';
|
|||||||
import vikunjaRoutes from './routes/vikunja.routes';
|
import vikunjaRoutes from './routes/vikunja.routes';
|
||||||
import configRoutes from './routes/config.routes';
|
import configRoutes from './routes/config.routes';
|
||||||
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
||||||
|
import settingsRoutes from './routes/settings.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -103,6 +104,7 @@ app.use('/api/bookstack', bookstackRoutes);
|
|||||||
app.use('/api/vikunja', vikunjaRoutes);
|
app.use('/api/vikunja', vikunjaRoutes);
|
||||||
app.use('/api/config', configRoutes);
|
app.use('/api/config', configRoutes);
|
||||||
app.use('/api/admin', serviceMonitorRoutes);
|
app.use('/api/admin', serviceMonitorRoutes);
|
||||||
|
app.use('/api/admin/settings', settingsRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logger from '../utils/logger';
|
|||||||
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
||||||
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
||||||
import { getUserRole } from '../middleware/rbac.middleware';
|
import { getUserRole } from '../middleware/rbac.middleware';
|
||||||
|
import pool from '../config/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract given_name and family_name from Authentik userinfo.
|
* Extract given_name and family_name from Authentik userinfo.
|
||||||
@@ -372,10 +373,17 @@ class AuthController {
|
|||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
const role = await getUserRole(user.id);
|
const role = await getUserRole(user.id);
|
||||||
|
// Fetch groups from DB so refreshed tokens retain group info
|
||||||
|
const groupsResult = await pool.query(
|
||||||
|
'SELECT authentik_groups FROM users WHERE id = $1',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
const groups: string[] = groupsResult.rows[0]?.authentik_groups ?? [];
|
||||||
const accessToken = tokenService.generateToken({
|
const accessToken = tokenService.generateToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
authentikSub: user.authentik_sub,
|
authentikSub: user.authentik_sub,
|
||||||
|
groups,
|
||||||
role,
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import environment from '../config/environment';
|
import environment from '../config/environment';
|
||||||
|
import settingsService from '../services/settings.service';
|
||||||
|
|
||||||
class ConfigController {
|
class ConfigController {
|
||||||
async getExternalLinks(_req: Request, res: Response): Promise<void> {
|
async getExternalLinks(_req: Request, res: Response): Promise<void> {
|
||||||
const links: Record<string, string> = {};
|
const envLinks: Record<string, string> = {};
|
||||||
if (environment.nextcloudUrl) links.nextcloud = environment.nextcloudUrl;
|
if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl;
|
||||||
if (environment.bookstack.url) links.bookstack = environment.bookstack.url;
|
if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url;
|
||||||
if (environment.vikunja.url) links.vikunja = environment.vikunja.url;
|
if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url;
|
||||||
res.status(200).json({ success: true, data: links });
|
|
||||||
|
const customLinks = await settingsService.getExternalLinks();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...envLinks,
|
||||||
|
customLinks,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,12 @@ class NextcloudController {
|
|||||||
}
|
}
|
||||||
const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword);
|
const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword);
|
||||||
res.status(200).json({ success: true, data: messages });
|
res.status(200).json({ success: true, data: messages });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error('getMessages error', { error });
|
logger.error('getMessages error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' });
|
||||||
}
|
}
|
||||||
@@ -140,7 +145,12 @@ class NextcloudController {
|
|||||||
}
|
}
|
||||||
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword);
|
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword);
|
||||||
res.status(200).json({ success: true, data: null });
|
res.status(200).json({ success: true, data: null });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error('sendMessage error', { error });
|
logger.error('sendMessage error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
|
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
|
||||||
}
|
}
|
||||||
@@ -160,7 +170,12 @@ class NextcloudController {
|
|||||||
}
|
}
|
||||||
await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword);
|
await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword);
|
||||||
res.status(200).json({ success: true, data: null });
|
res.status(200).json({ success: true, data: null });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error('markRoomAsRead error', { error });
|
logger.error('markRoomAsRead error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
|
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
|
||||||
}
|
}
|
||||||
|
|||||||
62
backend/src/controllers/settings.controller.ts
Normal file
62
backend/src/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import settingsService from '../services/settings.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
value: z.any(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const externalLinkSchema = z.array(z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
url: z.string().url().max(500),
|
||||||
|
}));
|
||||||
|
|
||||||
|
class SettingsController {
|
||||||
|
async getAll(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const settings = await settingsService.getAll();
|
||||||
|
res.json({ success: true, data: settings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get settings', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to get settings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const setting = await settingsService.get(req.params.key as string);
|
||||||
|
if (!setting) {
|
||||||
|
res.status(404).json({ success: false, message: 'Setting not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: setting });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get setting', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to get setting' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { value } = updateSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Validate external_links specifically
|
||||||
|
if ((req.params.key as string) === 'external_links') {
|
||||||
|
externalLinkSchema.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await settingsService.set(req.params.key as string, value, req.user!.id);
|
||||||
|
res.json({ success: true, data: setting });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Failed to update setting', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to update setting' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new SettingsController();
|
||||||
12
backend/src/database/migrations/024_create_app_settings.sql
Normal file
12
backend/src/database/migrations/024_create_app_settings.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
|
value JSONB NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed default external links (empty array)
|
||||||
|
INSERT INTO app_settings (key, value) VALUES
|
||||||
|
('external_links', '[]'::jsonb),
|
||||||
|
('refresh_intervals', '{"dashboard": 300000, "admin_services": 15000}'::jsonb)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -124,6 +124,16 @@ export function requirePermission(permission: string) {
|
|||||||
(req as Request & { userRole?: AppRole }).userRole = role;
|
(req as Request & { userRole?: AppRole }).userRole = role;
|
||||||
|
|
||||||
if (!hasPermission(role, permission)) {
|
if (!hasPermission(role, permission)) {
|
||||||
|
// Fallback: dashboard_admin group grants admin:access
|
||||||
|
if (permission === 'admin:access') {
|
||||||
|
const userGroups: string[] = req.user?.groups ?? [];
|
||||||
|
if (userGroups.includes('dashboard_admin')) {
|
||||||
|
(req as Request & { userRole?: AppRole }).userRole = 'admin';
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn('Permission denied', {
|
logger.warn('Permission denied', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
role,
|
role,
|
||||||
|
|||||||
13
backend/src/routes/settings.routes.ts
Normal file
13
backend/src/routes/settings.routes.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import settingsController from '../controllers/settings.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const auth = [authenticate, requirePermission('admin:access')] as const;
|
||||||
|
|
||||||
|
router.get('/', ...auth, settingsController.getAll.bind(settingsController));
|
||||||
|
router.get('/:key', ...auth, settingsController.get.bind(settingsController));
|
||||||
|
router.put('/:key', ...auth, settingsController.update.bind(settingsController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -98,7 +98,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/${p.book_slug}/page/${p.slug}`,
|
url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
@@ -122,7 +122,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
|||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
`${bookstack.url}/api/search`,
|
`${bookstack.url}/api/search`,
|
||||||
{
|
{
|
||||||
params: { query, count: 8 },
|
params: { query, count: 50 },
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -189,7 +189,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/${page.book_slug}/page/${page.slug}`,
|
url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug}/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,
|
||||||
|
|||||||
@@ -200,30 +200,47 @@ async function getMessages(token: string, loginName: string, appPassword: string
|
|||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get(
|
try {
|
||||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
const response = await axios.get(
|
||||||
{
|
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
||||||
params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 },
|
{
|
||||||
headers: {
|
params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 },
|
||||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
headers: {
|
||||||
'OCS-APIRequest': 'true',
|
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||||
'Accept': 'application/json',
|
'OCS-APIRequest': 'true',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const messages: any[] = response.data?.ocs?.data ?? [];
|
const messages: any[] = response.data?.ocs?.data ?? [];
|
||||||
return messages.map((m: any) => ({
|
return messages.map((m: any) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
token: m.token,
|
token: m.token,
|
||||||
actorType: m.actorType,
|
actorType: m.actorType,
|
||||||
actorId: m.actorId,
|
actorId: m.actorId,
|
||||||
actorDisplayName: m.actorDisplayName,
|
actorDisplayName: m.actorDisplayName,
|
||||||
message: m.message,
|
message: m.message,
|
||||||
timestamp: m.timestamp,
|
timestamp: m.timestamp,
|
||||||
messageType: m.messageType ?? '',
|
messageType: m.messageType ?? '',
|
||||||
systemMessage: m.systemMessage ?? '',
|
systemMessage: m.systemMessage ?? '',
|
||||||
}));
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
const err = new Error('Nextcloud authentication invalid');
|
||||||
|
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('NextcloudService.getMessages failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
logger.error('NextcloudService.getMessages failed', { error });
|
||||||
|
throw new Error('Failed to fetch messages');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(token: string, message: string, loginName: string, appPassword: string): Promise<void> {
|
async function sendMessage(token: string, message: string, loginName: string, appPassword: string): Promise<void> {
|
||||||
@@ -232,18 +249,35 @@ async function sendMessage(token: string, message: string, loginName: string, ap
|
|||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.post(
|
try {
|
||||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
await axios.post(
|
||||||
{ message },
|
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
||||||
{
|
{ message },
|
||||||
headers: {
|
{
|
||||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
headers: {
|
||||||
'OCS-APIRequest': 'true',
|
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||||
'Accept': 'application/json',
|
'OCS-APIRequest': 'true',
|
||||||
'Content-Type': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
const err = new Error('Nextcloud authentication invalid');
|
||||||
|
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('NextcloudService.sendMessage failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
logger.error('NextcloudService.sendMessage failed', { error });
|
||||||
|
throw new Error('Failed to send message');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAsRead(token: string, loginName: string, appPassword: string): Promise<void> {
|
async function markAsRead(token: string, loginName: string, appPassword: string): Promise<void> {
|
||||||
@@ -252,16 +286,33 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
|
|||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.delete(
|
try {
|
||||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`,
|
await axios.delete(
|
||||||
{
|
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`,
|
||||||
headers: {
|
{
|
||||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
headers: {
|
||||||
'OCS-APIRequest': 'true',
|
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||||
'Accept': 'application/json',
|
'OCS-APIRequest': 'true',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
const err = new Error('Nextcloud authentication invalid');
|
||||||
|
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('NextcloudService.markAsRead failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
logger.error('NextcloudService.markAsRead failed', { error });
|
||||||
|
throw new Error('Failed to mark conversation as read');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
||||||
|
|||||||
43
backend/src/services/settings.service.ts
Normal file
43
backend/src/services/settings.service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import pool from '../config/database';
|
||||||
|
|
||||||
|
export interface AppSetting {
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsService {
|
||||||
|
async getAll(): Promise<AppSetting[]> {
|
||||||
|
const result = await pool.query('SELECT * FROM app_settings ORDER BY key');
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<AppSetting | null> {
|
||||||
|
const result = await pool.query('SELECT * FROM app_settings WHERE key = $1', [key]);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any, userId: string): Promise<AppSetting> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO app_settings (key, value, updated_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[key, JSON.stringify(value), userId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
const result = await pool.query('DELETE FROM app_settings WHERE key = $1', [key]);
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExternalLinks(): Promise<Array<{name: string; url: string}>> {
|
||||||
|
const setting = await this.get('external_links');
|
||||||
|
return Array.isArray(setting?.value) ? setting.value : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new SettingsService();
|
||||||
@@ -26,6 +26,7 @@ import Veranstaltungen from './pages/Veranstaltungen';
|
|||||||
import VeranstaltungKategorien from './pages/VeranstaltungKategorien';
|
import VeranstaltungKategorien from './pages/VeranstaltungKategorien';
|
||||||
import Wissen from './pages/Wissen';
|
import Wissen from './pages/Wissen';
|
||||||
import AdminDashboard from './pages/AdminDashboard';
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
|
import AdminSettings from './pages/AdminSettings';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -221,6 +222,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminSettings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ChatIcon from '@mui/icons-material/Chat';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Avatar from '@mui/material/Avatar';
|
import Avatar from '@mui/material/Avatar';
|
||||||
import Badge from '@mui/material/Badge';
|
import Badge from '@mui/material/Badge';
|
||||||
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
@@ -39,6 +40,7 @@ const ChatPanelInner: React.FC = () => {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Toolbar />
|
||||||
<IconButton onClick={() => setChatPanelOpen(true)} aria-label="Chat öffnen">
|
<IconButton onClick={() => setChatPanelOpen(true)} aria-label="Chat öffnen">
|
||||||
<ChatIcon />
|
<ChatIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -96,6 +98,7 @@ const ChatPanelInner: React.FC = () => {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Toolbar />
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 1.5,
|
px: 1.5,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const BookStackRecentWidget: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const configured = data?.configured ?? true;
|
const configured = data?.configured ?? true;
|
||||||
const pages = data?.data ?? [];
|
const pages = (data?.data ?? []).slice(0, 5);
|
||||||
|
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { de } from 'date-fns/locale';
|
|||||||
import { nextcloudApi } from '../../services/nextcloud';
|
import { nextcloudApi } from '../../services/nextcloud';
|
||||||
import type { NextcloudConversation } from '../../types/nextcloud.types';
|
import type { NextcloudConversation } from '../../types/nextcloud.types';
|
||||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||||
|
import { useCountUp } from '../../hooks/useCountUp';
|
||||||
|
|
||||||
const POLL_INTERVAL = 2000;
|
const POLL_INTERVAL = 2000;
|
||||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||||
@@ -112,6 +113,7 @@ const NextcloudTalkWidget: React.FC = () => {
|
|||||||
const connected = data?.connected ?? false;
|
const connected = data?.connected ?? false;
|
||||||
const conversations = data?.conversations?.slice(0, 5) ?? [];
|
const conversations = data?.conversations?.slice(0, 5) ?? [];
|
||||||
const totalUnread = data?.totalUnread ?? 0;
|
const totalUnread = data?.totalUnread ?? 0;
|
||||||
|
const animatedUnread = useCountUp(totalUnread);
|
||||||
|
|
||||||
const stopPolling = useCallback(() => {
|
const stopPolling = useCallback(() => {
|
||||||
if (pollIntervalRef.current) {
|
if (pollIntervalRef.current) {
|
||||||
@@ -199,7 +201,7 @@ const NextcloudTalkWidget: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
{connected && totalUnread > 0 && (
|
{connected && totalUnread > 0 && (
|
||||||
<Chip
|
<Chip
|
||||||
label={`${totalUnread} ungelesen`}
|
label={`${animatedUnread} ungelesen`}
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { de } from 'date-fns/locale';
|
|||||||
import { vikunjaApi } from '../../services/vikunja';
|
import { vikunjaApi } from '../../services/vikunja';
|
||||||
import type { VikunjaTask } from '../../types/vikunja.types';
|
import type { VikunjaTask } from '../../types/vikunja.types';
|
||||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||||
|
import { useCountUp } from '../../hooks/useCountUp';
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
|
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
|
||||||
0: { label: 'Keine', color: 'default' },
|
0: { label: 'Keine', color: 'default' },
|
||||||
@@ -95,6 +96,7 @@ const VikunjaMyTasksWidget: React.FC = () => {
|
|||||||
|
|
||||||
const configured = data?.configured ?? true;
|
const configured = data?.configured ?? true;
|
||||||
const tasks = data?.data ?? [];
|
const tasks = data?.data ?? [];
|
||||||
|
const animatedTaskCount = useCountUp(tasks.length);
|
||||||
|
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
return (
|
return (
|
||||||
@@ -129,7 +131,7 @@ const VikunjaMyTasksWidget: React.FC = () => {
|
|||||||
Meine Aufgaben
|
Meine Aufgaben
|
||||||
</Typography>
|
</Typography>
|
||||||
{!isLoading && !isError && tasks.length > 0 && (
|
{!isLoading && !isError && tasks.length > 0 && (
|
||||||
<Chip label={tasks.length} size="small" color="primary" />
|
<Chip label={animatedTaskCount} size="small" color="primary" />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ const IncidentStatsChart: React.FC<IncidentStatsChartProps> = ({ stats, loading
|
|||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
label={({ name, percent }) =>
|
label={({ percent }: { percent: number }) =>
|
||||||
percent > 0.05 ? `${(percent * 100).toFixed(0)}%` : ''
|
percent > 0.05 ? `${(percent * 100).toFixed(0)}%` : ''
|
||||||
}
|
}
|
||||||
labelLine={false}
|
labelLine={false}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { Box, Card, CardContent, Typography, Button } from '@mui/material';
|
import { Box, Card, CardContent, Typography, Button } from '@mui/material';
|
||||||
import { ErrorOutline, Refresh } from '@mui/icons-material';
|
import { ErrorOutline, Refresh } from '@mui/icons-material';
|
||||||
|
|
||||||
|
|||||||
@@ -91,9 +91,12 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const linkEntries = externalLinks
|
const linkEntries = externalLinks
|
||||||
? Object.entries(externalLinks).filter(([, url]) => !!url)
|
? Object.entries(externalLinks).filter(([key, url]) => key !== 'customLinks' && !!url)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const customLinks: Array<{ name: string; url: string }> =
|
||||||
|
externalLinks?.customLinks ?? [];
|
||||||
|
|
||||||
const linkLabels: Record<string, string> = {
|
const linkLabels: Record<string, string> = {
|
||||||
nextcloud: 'Nextcloud Dateien',
|
nextcloud: 'Nextcloud Dateien',
|
||||||
bookstack: 'Wissensdatenbank',
|
bookstack: 'Wissensdatenbank',
|
||||||
@@ -125,7 +128,7 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
{linkEntries.length > 0 && (
|
{(linkEntries.length > 0 || customLinks.length > 0) && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -133,11 +136,11 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
onClick={handleToolsOpen}
|
onClick={handleToolsOpen}
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<Launch />}
|
startIcon={<Launch />}
|
||||||
aria-label="Externe Links"
|
aria-label="FF Rems Tools"
|
||||||
aria-controls="tools-menu"
|
aria-controls="tools-menu"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
Externe Links
|
FF Rems Tools
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
@@ -166,6 +169,15 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
{linkLabels[key] || key}
|
{linkLabels[key] || key}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
{customLinks.length > 0 && linkEntries.length > 0 && <Divider />}
|
||||||
|
{customLinks.map((link, index) => (
|
||||||
|
<MenuItem key={`custom-${index}`} onClick={() => handleOpenExternal(link.url)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Launch fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
{link.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
CalendarMonth,
|
CalendarMonth,
|
||||||
MenuBook,
|
MenuBook,
|
||||||
AdminPanelSettings,
|
AdminPanelSettings,
|
||||||
|
Settings,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
@@ -78,6 +79,12 @@ const adminItem: NavigationItem = {
|
|||||||
path: '/admin',
|
path: '/admin',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adminSettingsItem: NavigationItem = {
|
||||||
|
text: 'Einstellungen',
|
||||||
|
icon: <Settings />,
|
||||||
|
path: '/admin/settings',
|
||||||
|
};
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
mobileOpen: boolean;
|
mobileOpen: boolean;
|
||||||
onMobileClose: () => void;
|
onMobileClose: () => void;
|
||||||
@@ -92,7 +99,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||||
|
|
||||||
const navigationItems = useMemo(() => {
|
const navigationItems = useMemo(() => {
|
||||||
return isAdmin ? [...baseNavigationItems, adminItem] : baseNavigationItems;
|
return isAdmin ? [...baseNavigationItems, adminItem, adminSettingsItem] : baseNavigationItems;
|
||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
const handleNavigation = (path: string) => {
|
const handleNavigation = (path: string) => {
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ function alertTypeLabel(type: InspectionAlertType): string {
|
|||||||
|
|
||||||
type Urgency = 'overdue' | 'urgent' | 'warning';
|
type Urgency = 'overdue' | 'urgent' | 'warning';
|
||||||
|
|
||||||
function getUrgency(tage: number): Urgency {
|
|
||||||
if (tage < 0) return 'overdue';
|
|
||||||
if (tage <= 14) return 'urgent';
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
const URGENCY_CONFIG: Record<Urgency, { severity: 'error' | 'warning'; label: string }> = {
|
const URGENCY_CONFIG: Record<Urgency, { severity: 'error' | 'warning'; label: string }> = {
|
||||||
overdue: { severity: 'error', label: 'Überfällig' },
|
overdue: { severity: 'error', label: 'Überfällig' },
|
||||||
urgent: { severity: 'error', label: 'Dringend (≤ 14 Tage)' },
|
urgent: { severity: 'error', label: 'Dringend (≤ 14 Tage)' },
|
||||||
@@ -96,11 +90,11 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
|||||||
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
|
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
|
||||||
const warning = alerts.filter((a) => a.tage > 14);
|
const warning = alerts.filter((a) => a.tage > 14);
|
||||||
|
|
||||||
const groups: Array<{ urgency: Urgency; items: InspectionAlert[] }> = [
|
const groups: Array<{ urgency: Urgency; items: InspectionAlert[] }> = ([
|
||||||
{ urgency: 'overdue', items: overdue },
|
{ urgency: 'overdue' as Urgency, items: overdue },
|
||||||
{ urgency: 'urgent', items: urgent },
|
{ urgency: 'urgent' as Urgency, items: urgent },
|
||||||
{ urgency: 'warning', items: warning },
|
{ urgency: 'warning' as Urgency, items: warning },
|
||||||
].filter((g) => g.items.length > 0);
|
] as Array<{ urgency: Urgency; items: InspectionAlert[] }>).filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import { AuthContextType, AuthState, User } from '../types/auth.types';
|
import { AuthContextType, AuthState } from '../types/auth.types';
|
||||||
import { authService } from '../services/auth';
|
import { authService } from '../services/auth';
|
||||||
import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage';
|
import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage';
|
||||||
import { useNotification } from './NotificationContext';
|
import { useNotification } from './NotificationContext';
|
||||||
|
|||||||
@@ -22,11 +22,20 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
const [selectedRoomToken, setSelectedRoomToken] = useState<string | null>(null);
|
const [selectedRoomToken, setSelectedRoomToken] = useState<string | null>(null);
|
||||||
const { chatPanelOpen } = useLayout();
|
const { chatPanelOpen } = useLayout();
|
||||||
|
|
||||||
|
const { data: connData } = useQuery({
|
||||||
|
queryKey: ['nextcloud', 'connection'],
|
||||||
|
queryFn: () => nextcloudApi.getConversations(),
|
||||||
|
refetchInterval: chatPanelOpen ? 30000 : 120000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isConnected = connData?.connected ?? false;
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['nextcloud', 'rooms'],
|
queryKey: ['nextcloud', 'rooms'],
|
||||||
queryFn: () => nextcloudApi.getRooms(),
|
queryFn: () => nextcloudApi.getRooms(),
|
||||||
refetchInterval: chatPanelOpen ? 30000 : 120000,
|
refetchInterval: chatPanelOpen ? 30000 : 120000,
|
||||||
enabled: true,
|
enabled: isConnected,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rooms = data?.rooms ?? [];
|
const rooms = data?.rooms ?? [];
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface NotificationProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
|
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [_notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
const [currentNotification, setCurrentNotification] = useState<Notification | null>(null);
|
const [currentNotification, setCurrentNotification] = useState<Notification | null>(null);
|
||||||
|
|
||||||
const addNotification = useCallback((message: string, severity: AlertColor) => {
|
const addNotification = useCallback((message: string, severity: AlertColor) => {
|
||||||
|
|||||||
318
frontend/src/pages/AdminSettings.tsx
Normal file
318
frontend/src/pages/AdminSettings.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Stack,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Delete,
|
||||||
|
Add,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Timer,
|
||||||
|
Info,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { settingsApi } from '../services/settings';
|
||||||
|
|
||||||
|
interface ExternalLink {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshIntervals {
|
||||||
|
dashboardWidgets: number;
|
||||||
|
adminServices: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DASHBOARD_INTERVAL_OPTIONS = [
|
||||||
|
{ value: 30, label: '30 Sekunden' },
|
||||||
|
{ value: 60, label: '1 Minute' },
|
||||||
|
{ value: 300, label: '5 Minuten' },
|
||||||
|
{ value: 600, label: '10 Minuten' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADMIN_INTERVAL_OPTIONS = [
|
||||||
|
{ value: 5, label: '5 Sekunden' },
|
||||||
|
{ value: 15, label: '15 Sekunden' },
|
||||||
|
{ value: 30, label: '30 Sekunden' },
|
||||||
|
{ value: 60, label: '60 Sekunden' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AdminSettings() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||||
|
|
||||||
|
// State for external links
|
||||||
|
const [externalLinks, setExternalLinks] = useState<ExternalLink[]>([]);
|
||||||
|
|
||||||
|
// State for refresh intervals
|
||||||
|
const [refreshIntervals, setRefreshIntervals] = useState<RefreshIntervals>({
|
||||||
|
dashboardWidgets: 60,
|
||||||
|
adminServices: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all settings
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-settings'],
|
||||||
|
queryFn: () => settingsApi.getAll(),
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize state from fetched settings
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
const linksSetting = settings.find((s) => s.key === 'external_links');
|
||||||
|
if (linksSetting?.value) {
|
||||||
|
setExternalLinks(linksSetting.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalsSetting = settings.find((s) => s.key === 'refresh_intervals');
|
||||||
|
if (intervalsSetting?.value) {
|
||||||
|
setRefreshIntervals({
|
||||||
|
dashboardWidgets: intervalsSetting.value.dashboardWidgets ?? 60,
|
||||||
|
adminServices: intervalsSetting.value.adminServices ?? 15,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
// Mutation for saving external links
|
||||||
|
const linksMutation = useMutation({
|
||||||
|
mutationFn: (links: ExternalLink[]) => settingsApi.update('external_links', links),
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccess('Externe Links gespeichert');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['external-links'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Fehler beim Speichern der externen Links');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation for saving refresh intervals
|
||||||
|
const intervalsMutation = useMutation({
|
||||||
|
mutationFn: (intervals: RefreshIntervals) => settingsApi.update('refresh_intervals', intervals),
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccess('Aktualisierungsintervalle gespeichert');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Fehler beim Speichern der Aktualisierungsintervalle');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddLink = () => {
|
||||||
|
setExternalLinks([...externalLinks, { name: '', url: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLink = (index: number) => {
|
||||||
|
setExternalLinks(externalLinks.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkChange = (index: number, field: keyof ExternalLink, value: string) => {
|
||||||
|
const updated = [...externalLinks];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setExternalLinks(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLinks = () => {
|
||||||
|
linksMutation.mutate(externalLinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveIntervals = () => {
|
||||||
|
intervalsMutation.mutate(refreshIntervals);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the most recent updated_at
|
||||||
|
const lastUpdated = settings?.reduce((latest, s) => {
|
||||||
|
if (!latest) return s.updated_at;
|
||||||
|
return new Date(s.updated_at) > new Date(latest) ? s.updated_at : latest;
|
||||||
|
}, '' as string);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||||
|
Admin-Einstellungen
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Section 1: External Links */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<LinkIcon color="primary" sx={{ mr: 2 }} />
|
||||||
|
<Typography variant="h6">FF Rems Tools — Externe Links</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{externalLinks.map((link, index) => (
|
||||||
|
<Box key={index} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={link.name}
|
||||||
|
onChange={(e) => handleLinkChange(index, 'name', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="URL"
|
||||||
|
value={link.url}
|
||||||
|
onChange={(e) => handleLinkChange(index, 'url', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 2 }}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleRemoveLink(index)}
|
||||||
|
aria-label="Link entfernen"
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={handleAddLink}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Link hinzufügen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveLinks}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={linksMutation.isPending}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 2: Refresh Intervals */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Timer color="primary" sx={{ mr: 2 }} />
|
||||||
|
<Typography variant="h6">Aktualisierungsintervalle</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<FormControl size="small" sx={{ maxWidth: 300 }}>
|
||||||
|
<InputLabel>Dashboard Widgets</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={refreshIntervals.dashboardWidgets}
|
||||||
|
label="Dashboard Widgets"
|
||||||
|
onChange={(e) =>
|
||||||
|
setRefreshIntervals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
dashboardWidgets: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{DASHBOARD_INTERVAL_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" sx={{ maxWidth: 300 }}>
|
||||||
|
<InputLabel>Admin Services</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={refreshIntervals.adminServices}
|
||||||
|
label="Admin Services"
|
||||||
|
onChange={(e) =>
|
||||||
|
setRefreshIntervals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
adminServices: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ADMIN_INTERVAL_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveIntervals}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={intervalsMutation.isPending}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 3: Info */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Info color="primary" sx={{ mr: 2 }} />
|
||||||
|
<Typography variant="h6">Info</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{lastUpdated
|
||||||
|
? `Letzte Aktualisierung: ${new Date(lastUpdated).toLocaleString('de-DE')}`
|
||||||
|
: 'Noch keine Einstellungen gespeichert.'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminSettings;
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Paper,
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
IconButton,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ArrowBack,
|
ArrowBack,
|
||||||
@@ -37,7 +36,7 @@ import { de } from 'date-fns/locale';
|
|||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import {
|
import {
|
||||||
incidentsApi,
|
incidentsApi,
|
||||||
EinsatzDetail,
|
EinsatzDetail as EinsatzDetailType,
|
||||||
EinsatzStatus,
|
EinsatzStatus,
|
||||||
EINSATZ_ART_LABELS,
|
EINSATZ_ART_LABELS,
|
||||||
EINSATZ_STATUS_LABELS,
|
EINSATZ_STATUS_LABELS,
|
||||||
@@ -99,7 +98,7 @@ function initials(givenName: string | null, familyName: string | null, name: str
|
|||||||
return '??';
|
return '??';
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayName(p: EinsatzDetail['personal'][0]): string {
|
function displayName(p: EinsatzDetailType['personal'][0]): string {
|
||||||
if (p.given_name && p.family_name) return `${p.given_name} ${p.family_name}`;
|
if (p.given_name && p.family_name) return `${p.given_name} ${p.family_name}`;
|
||||||
if (p.name) return p.name;
|
if (p.name) return p.name;
|
||||||
return p.email;
|
return p.email;
|
||||||
@@ -166,7 +165,7 @@ function EinsatzDetail() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const [einsatz, setEinsatz] = useState<EinsatzDetail | null>(null);
|
const [einsatz, setEinsatz] = useState<EinsatzDetailType | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ function FahrzeugBuchungen() {
|
|||||||
<TableContainer component={Paper} elevation={1}>
|
<TableContainer component={Paper} elevation={1}>
|
||||||
<Table size="small" sx={{ tableLayout: 'fixed' }}>
|
<Table size="small" sx={{ tableLayout: 'fixed' }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow sx={{ bgcolor: 'grey.100' }}>
|
<TableRow sx={{ bgcolor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100' }}>
|
||||||
<TableCell sx={{ width: 160, fontWeight: 700 }}>
|
<TableCell sx={{ width: 160, fontWeight: 700 }}>
|
||||||
Fahrzeug
|
Fahrzeug
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -409,7 +409,7 @@ function FahrzeugBuchungen() {
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: isToday(day) ? 700 : 400,
|
fontWeight: isToday(day) ? 700 : 400,
|
||||||
color: isToday(day) ? 'primary.main' : 'text.primary',
|
color: isToday(day) ? 'primary.main' : 'text.primary',
|
||||||
bgcolor: isToday(day) ? 'primary.50' : undefined,
|
bgcolor: isToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" display="block">
|
<Typography variant="caption" display="block">
|
||||||
@@ -445,10 +445,10 @@ function FahrzeugBuchungen() {
|
|||||||
isFree ? handleCellClick(vehicle.id, day) : undefined
|
isFree ? handleCellClick(vehicle.id, day) : undefined
|
||||||
}
|
}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: isFree ? 'success.50' : undefined,
|
bgcolor: isFree ? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50' : undefined,
|
||||||
cursor: isFree && canCreate ? 'pointer' : 'default',
|
cursor: isFree && canCreate ? 'pointer' : 'default',
|
||||||
'&:hover': isFree && canCreate
|
'&:hover': isFree && canCreate
|
||||||
? { bgcolor: 'success.100' }
|
? { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.800' : 'success.100' }
|
||||||
: {},
|
: {},
|
||||||
p: 0.5,
|
p: 0.5,
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
@@ -511,9 +511,9 @@ function FahrzeugBuchungen() {
|
|||||||
sx={{
|
sx={{
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
bgcolor: 'success.50',
|
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'success.300',
|
borderColor: (theme) => theme.palette.mode === 'dark' ? 'success.700' : 'success.300',
|
||||||
borderRadius: 0.5,
|
borderRadius: 0.5,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import { vehiclesApi } from '../services/vehicles';
|
|||||||
import { fromGermanDate } from '../utils/dateInput';
|
import { fromGermanDate } from '../utils/dateInput';
|
||||||
import { equipmentApi } from '../services/equipment';
|
import { equipmentApi } from '../services/equipment';
|
||||||
import {
|
import {
|
||||||
FahrzeugDetail,
|
FahrzeugDetail as FahrzeugDetailType,
|
||||||
FahrzeugWartungslog,
|
FahrzeugWartungslog,
|
||||||
FahrzeugStatus,
|
FahrzeugStatus,
|
||||||
FahrzeugStatusLabel,
|
FahrzeugStatusLabel,
|
||||||
@@ -121,7 +121,7 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err
|
|||||||
// ── Übersicht Tab ─────────────────────────────────────────────────────────────
|
// ── Übersicht Tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface UebersichtTabProps {
|
interface UebersichtTabProps {
|
||||||
vehicle: FahrzeugDetail;
|
vehicle: FahrzeugDetailType;
|
||||||
onStatusUpdated: () => void;
|
onStatusUpdated: () => void;
|
||||||
canChangeStatus: boolean;
|
canChangeStatus: boolean;
|
||||||
}
|
}
|
||||||
@@ -523,7 +523,7 @@ interface AusruestungTabProps {
|
|||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId }) => {
|
const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId: _vehicleId }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const hasProblems = equipment.some(
|
const hasProblems = equipment.some(
|
||||||
@@ -642,7 +642,7 @@ function FahrzeugDetail() {
|
|||||||
const { isAdmin, canChangeStatus } = usePermissions();
|
const { isAdmin, canChangeStatus } = usePermissions();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
|
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Fab,
|
Fab,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ import {
|
|||||||
Tab,
|
Tab,
|
||||||
Grid,
|
Grid,
|
||||||
TextField,
|
TextField,
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -44,11 +41,9 @@ import {
|
|||||||
MemberWithProfile,
|
MemberWithProfile,
|
||||||
StatusEnum,
|
StatusEnum,
|
||||||
DienstgradEnum,
|
DienstgradEnum,
|
||||||
FunktionEnum,
|
|
||||||
TshirtGroesseEnum,
|
TshirtGroesseEnum,
|
||||||
DIENSTGRAD_VALUES,
|
DIENSTGRAD_VALUES,
|
||||||
STATUS_VALUES,
|
STATUS_VALUES,
|
||||||
FUNKTION_VALUES,
|
|
||||||
TSHIRT_GROESSE_VALUES,
|
TSHIRT_GROESSE_VALUES,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Typography,
|
Typography,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Paper,
|
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Chip,
|
Chip,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionSummary,
|
AccordionSummary,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
@@ -301,7 +300,7 @@ export default function UebungDetail() {
|
|||||||
const rsvpMutation = useMutation({
|
const rsvpMutation = useMutation({
|
||||||
mutationFn: (status: 'zugesagt' | 'abgesagt') =>
|
mutationFn: (status: 'zugesagt' | 'abgesagt') =>
|
||||||
trainingApi.updateRsvp(id!, status),
|
trainingApi.updateRsvp(id!, status),
|
||||||
onSuccess: (_data, status) => {
|
onSuccess: (_data, _status) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['training', 'event', id] });
|
queryClient.invalidateQueries({ queryKey: ['training', 'event', id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['training', 'upcoming'] });
|
queryClient.invalidateQueries({ queryKey: ['training', 'upcoming'] });
|
||||||
setRsvpLoading(null);
|
setRsvpLoading(null);
|
||||||
@@ -342,7 +341,7 @@ export default function UebungDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isPast = new Date(event.datum_von) < new Date();
|
const isPast = new Date(event.datum_von) < new Date();
|
||||||
const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt';
|
// const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
|||||||
@@ -504,13 +504,13 @@ const AuditLog: React.FC = () => {
|
|||||||
|
|
||||||
const handleApplyFilters = () => {
|
const handleApplyFilters = () => {
|
||||||
setApplied(filters);
|
setApplied(filters);
|
||||||
setPaginationModel((prev) => ({ ...prev, page: 0 }));
|
setPaginationModel((prev: any) => ({ ...prev, page: 0 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
setFilters(DEFAULT_FILTERS);
|
setFilters(DEFAULT_FILTERS);
|
||||||
setApplied(DEFAULT_FILTERS);
|
setApplied(DEFAULT_FILTERS);
|
||||||
setPaginationModel((prev) => ({ ...prev, page: 0 }));
|
setPaginationModel((prev: any) => ({ ...prev, page: 0 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -723,7 +723,7 @@ const AuditLog: React.FC = () => {
|
|||||||
noRowsLabel: 'Keine Eintraege gefunden',
|
noRowsLabel: 'Keine Eintraege gefunden',
|
||||||
MuiTablePagination: {
|
MuiTablePagination: {
|
||||||
labelRowsPerPage: 'Eintraege pro Seite:',
|
labelRowsPerPage: 'Eintraege pro Seite:',
|
||||||
labelDisplayedRows: ({ from, to, count }) =>
|
labelDisplayedRows: ({ from, to, count }: { from: any; to: any; count: any }) =>
|
||||||
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`,
|
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
18
frontend/src/services/settings.ts
Normal file
18
frontend/src/services/settings.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
interface AppSetting {
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
getAll: () => api.get<ApiResponse<AppSetting[]>>('/api/admin/settings').then(r => r.data.data),
|
||||||
|
get: (key: string) => api.get<ApiResponse<AppSetting>>(`/api/admin/settings/${key}`).then(r => r.data.data),
|
||||||
|
update: (key: string, value: any) => api.put<ApiResponse<AppSetting>>(`/api/admin/settings/${key}`, { value }).then(r => r.data.data),
|
||||||
|
};
|
||||||
@@ -2,4 +2,5 @@ export interface ExternalLinks {
|
|||||||
nextcloud?: string;
|
nextcloud?: string;
|
||||||
bookstack?: string;
|
bookstack?: string;
|
||||||
vikunja?: string;
|
vikunja?: string;
|
||||||
|
customLinks?: Array<{ name: string; url: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user