new features, bookstack

This commit is contained in:
Matthias Hochmeister
2026-03-03 21:30:38 +01:00
parent 817329db70
commit d3561c1109
32 changed files with 1923 additions and 207 deletions

View File

@@ -159,6 +159,23 @@ AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
# Used by the backend for Nextcloud integration # Used by the backend for Nextcloud integration
NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
# ============================================================================
# BOOKSTACK CONFIGURATION
# ============================================================================
# BookStack base URL
# The URL of your BookStack instance (without trailing slash)
BOOKSTACK_URL=https://docs.feuerwehr-rems.at
# BookStack API Token ID
# Create via BookStack user profile → API Tokens
BOOKSTACK_TOKEN_ID=your_bookstack_token_id
# BookStack API Token Secret
# Create via BookStack user profile → API Tokens
# WARNING: Keep this secret!
BOOKSTACK_TOKEN_SECRET=your_bookstack_token_secret
# ============================================================================ # ============================================================================
# LOGGING CONFIGURATION (Optional) # LOGGING CONFIGURATION (Optional)
# ============================================================================ # ============================================================================

View File

@@ -81,6 +81,7 @@ import atemschutzRoutes from './routes/atemschutz.routes';
import eventsRoutes from './routes/events.routes'; import eventsRoutes from './routes/events.routes';
import bookingRoutes from './routes/booking.routes'; import bookingRoutes from './routes/booking.routes';
import notificationRoutes from './routes/notification.routes'; import notificationRoutes from './routes/notification.routes';
import bookstackRoutes from './routes/bookstack.routes';
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes); app.use('/api/user', userRoutes);
@@ -95,6 +96,7 @@ app.use('/api/nextcloud/talk', nextcloudRoutes);
app.use('/api/events', eventsRoutes); app.use('/api/events', eventsRoutes);
app.use('/api/bookings', bookingRoutes); app.use('/api/bookings', bookingRoutes);
app.use('/api/notifications', notificationRoutes); app.use('/api/notifications', notificationRoutes);
app.use('/api/bookstack', bookstackRoutes);
// 404 handler // 404 handler
app.use(notFoundHandler); app.use(notFoundHandler);

View File

@@ -33,6 +33,11 @@ interface EnvironmentConfig {
redirectUri: string; redirectUri: string;
}; };
nextcloudUrl: string; nextcloudUrl: string;
bookstack: {
url: string;
tokenId: string;
tokenSecret: string;
};
} }
const environment: EnvironmentConfig = { const environment: EnvironmentConfig = {
@@ -63,6 +68,11 @@ const environment: EnvironmentConfig = {
redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback', redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback',
}, },
nextcloudUrl: process.env.NEXTCLOUD_URL || '', nextcloudUrl: process.env.NEXTCLOUD_URL || '',
bookstack: {
url: process.env.BOOKSTACK_URL || '',
tokenId: process.env.BOOKSTACK_TOKEN_ID || '',
tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '',
},
}; };
export default environment; export default environment;

View File

@@ -0,0 +1,41 @@
import { Request, Response } from 'express';
import bookstackService from '../services/bookstack.service';
import environment from '../config/environment';
import logger from '../utils/logger';
class BookStackController {
async getRecent(_req: Request, res: Response): Promise<void> {
if (!environment.bookstack.url) {
res.status(200).json({ success: true, data: [], configured: false });
return;
}
try {
const pages = await bookstackService.getRecentPages();
res.status(200).json({ success: true, data: pages, configured: true });
} catch (error) {
logger.error('BookStackController.getRecent error', { error });
res.status(500).json({ success: false, message: 'BookStack konnte nicht abgefragt werden' });
}
}
async search(req: Request, res: Response): Promise<void> {
if (!environment.bookstack.url) {
res.status(200).json({ success: true, data: [], configured: false });
return;
}
const query = req.query.query as string | undefined;
if (!query || query.trim().length === 0) {
res.status(400).json({ success: false, message: 'Suchbegriff fehlt' });
return;
}
try {
const results = await bookstackService.searchPages(query.trim());
res.status(200).json({ success: true, data: results, configured: true });
} catch (error) {
logger.error('BookStackController.search error', { error });
res.status(500).json({ success: false, message: 'BookStack-Suche fehlgeschlagen' });
}
}
}
export default new BookStackController();

View File

@@ -325,6 +325,43 @@ class EventsController {
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' }); res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
} }
}; };
// -------------------------------------------------------------------------
// POST /api/events/import
// -------------------------------------------------------------------------
importEvents = async (req: Request, res: Response): Promise<void> => {
try {
const { events } = req.body as { events: unknown[] };
if (!Array.isArray(events) || events.length === 0) {
res.status(400).json({ success: false, message: 'Keine Ereignisse zum Importieren' });
return;
}
const userId = (req.user as any)?.id ?? 'unknown';
const created: number[] = [];
const errors: string[] = [];
for (let i = 0; i < events.length; i++) {
try {
const parsed = CreateVeranstaltungSchema.safeParse(events[i]);
if (!parsed.success) {
errors.push(`Zeile ${i + 2}: ${parsed.error.issues.map((e) => e.message).join(', ')}`);
continue;
}
await eventsService.createEvent(parsed.data, userId);
created.push(i);
} catch (e) {
errors.push(`Zeile ${i + 2}: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`);
}
}
res.status(200).json({
success: true,
data: { created: created.length, errors },
});
} catch (error) {
logger.error('importEvents error', { error });
res.status(500).json({ success: false, message: 'Import fehlgeschlagen' });
}
};
} }
export default new EventsController(); export default new EventsController();

View File

@@ -0,0 +1,10 @@
import { Router } from 'express';
import bookstackController from '../controllers/bookstack.controller';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
router.get('/recent', authenticate, bookstackController.getRecent.bind(bookstackController));
router.get('/search', authenticate, bookstackController.search.bind(bookstackController));
export default router;

View File

@@ -104,6 +104,17 @@ router.get(
// Events CRUD // Events CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* POST /api/events/import
* Bulk import events from CSV data. Requires admin or moderator.
*/
router.post(
'/import',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.importEvents.bind(eventsController)
);
/** /**
* POST /api/events * POST /api/events
* Create a new event. Requires admin or moderator. * Create a new event. Requires admin or moderator.

View File

@@ -0,0 +1,113 @@
import axios from 'axios';
import environment from '../config/environment';
import logger from '../utils/logger';
export interface BookStackPage {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
chapter_id: number;
draft: boolean;
template: boolean;
created_at: string;
updated_at: string;
url: string;
preview_html?: { content: string };
book?: { name: string };
tags?: { name: string; value: string; order: number }[];
createdBy?: { name: string };
updatedBy?: { name: string };
}
export interface BookStackSearchResult {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
url: string;
preview_html: { content: string };
tags: { name: string; value: string; order: number }[];
}
function buildHeaders(): Record<string, string> {
const { bookstack } = environment;
return {
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
'Content-Type': 'application/json',
};
}
async function getRecentPages(): Promise<BookStackPage[]> {
const { bookstack } = environment;
if (!bookstack.url) {
throw new Error('BOOKSTACK_URL is not configured');
}
try {
const response = await axios.get(
`${bookstack.url}/api/pages`,
{
params: { sort: '-updated_at', count: 5 },
headers: buildHeaders(),
},
);
const pages: BookStackPage[] = response.data?.data ?? [];
return pages.map((p) => ({
...p,
url: `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`,
}));
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('BookStack getRecentPages failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
}
logger.error('BookStackService.getRecentPages failed', { error });
throw new Error('Failed to fetch BookStack recent pages');
}
}
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
const { bookstack } = environment;
if (!bookstack.url) {
throw new Error('BOOKSTACK_URL is not configured');
}
try {
const response = await axios.get(
`${bookstack.url}/api/search`,
{
params: { query, count: 8 },
headers: buildHeaders(),
},
);
const results: BookStackSearchResult[] = (response.data?.data ?? [])
.filter((item: any) => item.type === 'page')
.map((item: any) => ({
id: item.id,
name: item.name,
slug: item.slug,
book_id: item.book_id ?? 0,
book_slug: item.book_slug ?? '',
url: `${bookstack.url}/books/${item.book_slug}/page/${item.slug}`,
preview_html: item.preview_html ?? { content: '' },
tags: item.tags ?? [],
}));
return results;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('BookStack searchPages failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
}
logger.error('BookStackService.searchPages failed', { error });
throw new Error('Failed to search BookStack pages');
}
}
export default { getRecentPages, searchPages };

View File

@@ -12,11 +12,16 @@
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.18", "@mui/icons-material": "^5.14.18",
"@mui/material": "^5.14.18", "@mui/material": "^5.14.18",
"@tanstack/react-query": "^5.60.0",
"axios": "^1.6.2", "axios": "^1.6.2",
"date-fns": "^3.6.0",
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.8.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0" "react-router-dom": "^6.20.0",
"recharts": "^2.12.7"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
@@ -53,6 +58,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -345,6 +351,7 @@
"version": "11.14.0", "version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@@ -385,6 +392,7 @@
"version": "11.14.1", "version": "11.14.1",
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@@ -873,6 +881,7 @@
"version": "5.18.0", "version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz",
"integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.9", "@babel/runtime": "^7.23.9",
"@mui/core-downloads-tracker": "^5.18.0", "@mui/core-downloads-tracker": "^5.18.0",
@@ -1400,6 +1409,30 @@
"win32" "win32"
] ]
}, },
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://npm.apple.com/@tanstack/query-core/-/query-core-5.90.20.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.21",
"resolved": "https://npm.apple.com/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1441,6 +1474,60 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://npm.apple.com/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://npm.apple.com/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://npm.apple.com/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://npm.apple.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://npm.apple.com/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://npm.apple.com/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://npm.apple.com/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://npm.apple.com/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://npm.apple.com/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1457,10 +1544,18 @@
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
}, },
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.28", "version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1508,6 +1603,18 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.5", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
@@ -1532,6 +1639,15 @@
"npm": ">=6" "npm": ">=6"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://npm.apple.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -1563,6 +1679,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -1577,6 +1694,18 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1617,6 +1746,26 @@
} }
] ]
}, },
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1641,6 +1790,18 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
}, },
"node_modules/core-js": {
"version": "3.48.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -1656,11 +1817,145 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://npm.apple.com/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://npm.apple.com/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://npm.apple.com/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://npm.apple.com/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://npm.apple.com/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://npm.apple.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://npm.apple.com/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1677,6 +1972,11 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://npm.apple.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1694,6 +1994,13 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/dompurify": {
"version": "2.5.8",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/dompurify/-/dompurify-2.5.8.tgz",
"integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1820,6 +2127,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://npm.apple.com/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/find-root": { "node_modules/find-root": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -1985,6 +2312,20 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2000,6 +2341,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://npm.apple.com/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -2052,6 +2401,34 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jspdf": {
"version": "2.5.2",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/jspdf/-/jspdf-2.5.2.tgz",
"integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.2",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.6",
"core-js": "^3.6.0",
"dompurify": "^2.5.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf-autotable": {
"version": "3.8.4",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/jspdf-autotable/-/jspdf-autotable-3.8.4.tgz",
"integrity": "sha512-rSffGoBsJYX83iTRv8Ft7FhqfgEL2nLpGAIiqruEQQ3e4r0qdLFbPUB7N9HAle0I3XgpisvyW751VHCqKUVOgQ==",
"license": "MIT",
"peerDependencies": {
"jspdf": "^2.5.1"
}
},
"node_modules/jwt-decode": { "node_modules/jwt-decode": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
@@ -2065,6 +2442,11 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://npm.apple.com/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2190,6 +2572,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://npm.apple.com/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"optional": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2243,10 +2631,20 @@
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
}, },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://npm.apple.com/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -2258,6 +2656,7 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -2310,6 +2709,20 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://npm.apple.com/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -2325,6 +2738,47 @@
"react-dom": ">=16.6.0" "react-dom": ">=16.6.0"
} }
}, },
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://npm.apple.com/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://npm.apple.com/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://npm.apple.com/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://npm.apple.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"optional": true
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -2352,6 +2806,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2430,6 +2894,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stylis": { "node_modules/stylis": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
@@ -2446,6 +2920,32 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2489,11 +2989,43 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://npm.apple.com/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

View File

@@ -10,17 +10,19 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.18",
"@mui/material": "^5.14.18",
"@tanstack/react-query": "^5.60.0",
"axios": "^1.6.2",
"date-fns": "^3.6.0",
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.8.4",
"jwt-decode": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"@mui/material": "^5.14.18",
"@mui/icons-material": "^5.14.18",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"axios": "^1.6.2",
"@tanstack/react-query": "^5.60.0",
"jwt-decode": "^4.0.0",
"date-fns": "^3.6.0",
"recharts": "^2.12.7" "recharts": "^2.12.7"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,138 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Divider,
Skeleton,
} from '@mui/material';
import { MenuBook } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import { bookstackApi } from '../../services/bookstack';
import type { BookStackPage } from '../../types/bookstack.types';
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
page,
showDivider,
}) => {
const handleClick = () => {
window.open(page.url, '_blank', 'noopener,noreferrer');
};
const relativeTime = page.updated_at
? formatDistanceToNow(new Date(page.updated_at), { addSuffix: true, locale: de })
: null;
return (
<>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{page.name}
</Typography>
{page.book && (
<Typography variant="body2" color="text.secondary" noWrap sx={{ mt: 0.25 }}>
{page.book.name}
</Typography>
)}
</Box>
{relativeTime && (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
const BookStackRecentWidget: React.FC = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ['bookstack-recent'],
queryFn: () => bookstackApi.getRecent(),
refetchInterval: 5 * 60 * 1000,
retry: 1,
});
const configured = data?.configured ?? true;
const pages = data?.data ?? [];
if (!configured) return null;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
BookStack Neueste Seiten
</Typography>
</Box>
{isLoading && (
<Box>
{[1, 2, 3, 4, 5].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="70%" height={22} />
<Skeleton variant="text" width="50%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
BookStack nicht erreichbar
</Typography>
)}
{!isLoading && !isError && pages.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Seiten gefunden
</Typography>
)}
{!isLoading && !isError && pages.length > 0 && (
<Box>
{pages.map((page, index) => (
<PageRow
key={page.id}
page={page}
showDivider={index < pages.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default BookStackRecentWidget;

View File

@@ -0,0 +1,152 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Divider,
CircularProgress,
InputAdornment,
} from '@mui/material';
import { Search, MenuBook } from '@mui/icons-material';
import { bookstackApi } from '../../services/bookstack';
import type { BookStackSearchResult } from '../../types/bookstack.types';
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim();
}
const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({
result,
showDivider,
}) => {
const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : '';
return (
<>
<Box
onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}
sx={{
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Typography variant="subtitle2" noWrap>
{result.name}
</Typography>
{preview && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 0.25,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{preview}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
const BookStackSearchWidget: React.FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<BookStackSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [configured, setConfigured] = useState(true);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!query.trim()) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
try {
const response = await bookstackApi.search(query.trim());
setConfigured(response.configured);
setResults(response.data);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
if (!configured) return null;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
BookStack Suche
</Typography>
</Box>
<TextField
fullWidth
size="small"
placeholder="Suchbegriff eingeben..."
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{loading ? <CircularProgress size={16} /> : <Search fontSize="small" />}
</InputAdornment>
),
}}
/>
{!loading && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Ergebnisse für {query}"
</Typography>
)}
{results.length > 0 && (
<Box sx={{ mt: 1 }}>
{results.map((result, index) => (
<ResultRow
key={result.id}
result={result}
showDivider={index < results.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default BookStackSearchWidget;

View File

@@ -14,6 +14,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { incidentsApi, EINSATZ_ARTEN, EINSATZ_ART_LABELS, CreateEinsatzPayload } from '../../services/incidents'; import { incidentsApi, EINSATZ_ARTEN, EINSATZ_ART_LABELS, CreateEinsatzPayload } from '../../services/incidents';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { toGermanDateTime, fromGermanDateTime } from '../../utils/dateInput';
interface CreateEinsatzDialogProps { interface CreateEinsatzDialogProps {
open: boolean; open: boolean;
@@ -21,16 +22,16 @@ interface CreateEinsatzDialogProps {
onSuccess: () => void; onSuccess: () => void;
} }
// Default alarm_time = now (rounded to minute) // Default alarm_time = now (rounded to minute) in DD.MM.YYYY HH:MM format
function nowISO(): string { function nowGerman(): string {
const d = new Date(); const d = new Date();
d.setSeconds(0, 0); d.setSeconds(0, 0);
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm return toGermanDateTime(d.toISOString());
} }
const INITIAL_FORM: CreateEinsatzPayload & { alarm_time_local: string } = { const INITIAL_FORM: CreateEinsatzPayload & { alarm_time_local: string } = {
alarm_time: '', alarm_time: '',
alarm_time_local: nowISO(), alarm_time_local: nowGerman(),
einsatz_art: 'Brand', einsatz_art: 'Brand',
einsatz_stichwort: '', einsatz_stichwort: '',
strasse: '', strasse: '',
@@ -47,7 +48,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
onSuccess, onSuccess,
}) => { }) => {
const notification = useNotification(); const notification = useNotification();
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowISO() }); const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -74,8 +75,9 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
setLoading(true); setLoading(true);
try { try {
// Convert local datetime string to UTC ISO string // Convert local datetime string to UTC ISO string
const isoLocal = fromGermanDateTime(form.alarm_time_local);
const payload: CreateEinsatzPayload = { const payload: CreateEinsatzPayload = {
alarm_time: new Date(form.alarm_time_local).toISOString(), alarm_time: isoLocal ? new Date(isoLocal).toISOString() : new Date().toISOString(),
einsatz_art: form.einsatz_art, einsatz_art: form.einsatz_art,
einsatz_stichwort: form.einsatz_stichwort || null, einsatz_stichwort: form.einsatz_stichwort || null,
strasse: form.strasse || null, strasse: form.strasse || null,
@@ -88,7 +90,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
await incidentsApi.create(payload); await incidentsApi.create(payload);
notification.showSuccess('Einsatz erfolgreich angelegt'); notification.showSuccess('Einsatz erfolgreich angelegt');
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() }); setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
onSuccess(); onSuccess();
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Fehler beim Anlegen des Einsatzes'; const msg = err instanceof Error ? err.message : 'Fehler beim Anlegen des Einsatzes';
@@ -101,7 +103,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
const handleClose = () => { const handleClose = () => {
if (loading) return; if (loading) return;
setError(null); setError(null);
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() }); setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
onClose(); onClose();
}; };
@@ -132,7 +134,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
<TextField <TextField
label="Alarmzeit *" label="Alarmzeit *"
name="alarm_time_local" name="alarm_time_local"
type="datetime-local" placeholder="TT.MM.JJJJ HH:MM"
value={form.alarm_time_local} value={form.alarm_time_local}
onChange={handleChange} onChange={handleChange}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -141,7 +143,6 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
helperText="DD.MM.YYYY HH:mm" helperText="DD.MM.YYYY HH:mm"
inputProps={{ inputProps={{
'aria-label': 'Alarmzeit', 'aria-label': 'Alarmzeit',
// HTML datetime-local uses YYYY-MM-DDTHH:mm format
}} }}
/> />
</Grid> </Grid>

View File

@@ -0,0 +1,76 @@
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { lightTheme, darkTheme } from '../theme/theme';
type ThemeMode = 'system' | 'light' | 'dark';
interface ThemeModeContextValue {
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
resolvedMode: 'light' | 'dark';
}
const ThemeModeContext = createContext<ThemeModeContextValue>({
themeMode: 'system',
setThemeMode: () => {},
resolvedMode: 'light',
});
export function useThemeMode(): ThemeModeContextValue {
return useContext(ThemeModeContext);
}
function getSystemPreference(): 'light' | 'dark' {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
const STORAGE_KEY = 'themeMode';
export const ThemeModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored;
}
return 'system';
});
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemPreference(e.matches ? 'dark' : 'light');
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
const setThemeMode = (mode: ThemeMode) => {
setThemeModeState(mode);
localStorage.setItem(STORAGE_KEY, mode);
};
const resolvedMode: 'light' | 'dark' =
themeMode === 'system' ? systemPreference : themeMode;
const theme = resolvedMode === 'dark' ? darkTheme : lightTheme;
const value = useMemo(
() => ({ themeMode, setThemeMode, resolvedMode }),
[themeMode, resolvedMode],
);
return (
<ThemeModeContext.Provider value={value}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</ThemeModeContext.Provider>
);
};

View File

@@ -1,9 +1,8 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { CssBaseline, ThemeProvider } from '@mui/material';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { lightTheme } from './theme/theme'; import { ThemeModeProvider } from './contexts/ThemeContext';
import App from './App'; import App from './App';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -20,12 +19,11 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={lightTheme}> <ThemeModeProvider>
<CssBaseline />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <App />
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeModeProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -45,6 +45,7 @@ import { atemschutzApi } from '../services/atemschutz';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import type { import type {
AtemschutzUebersicht, AtemschutzUebersicht,
AtemschutzStats, AtemschutzStats,
@@ -66,17 +67,6 @@ function formatDate(iso: string | null): string {
}); });
} }
/** Extract YYYY-MM-DD from an ISO timestamp or date string for <input type="date"> */
function toInputDate(iso: string | null | undefined): string {
if (!iso) return '';
// Already DD.MM.YYYY
if (/^\d{2}\.\d{2}\.\d{4}$/.test(iso)) return iso;
// YYYY-MM-DD → DD.MM.YYYY
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return `${m[3]}.${m[2]}.${m[1]}`;
return '';
}
function getDisplayName(item: AtemschutzUebersicht): string { function getDisplayName(item: AtemschutzUebersicht): string {
if (item.user_family_name || item.user_given_name) { if (item.user_family_name || item.user_given_name) {
return [item.user_family_name, item.user_given_name].filter(Boolean).join(', '); return [item.user_family_name, item.user_given_name].filter(Boolean).join(', ');
@@ -240,12 +230,12 @@ function Atemschutz() {
setForm({ setForm({
user_id: item.user_id, user_id: item.user_id,
atemschutz_lehrgang: item.atemschutz_lehrgang, atemschutz_lehrgang: item.atemschutz_lehrgang,
lehrgang_datum: toInputDate(item.lehrgang_datum), lehrgang_datum: toGermanDate(item.lehrgang_datum),
untersuchung_datum: toInputDate(item.untersuchung_datum), untersuchung_datum: toGermanDate(item.untersuchung_datum),
untersuchung_gueltig_bis: toInputDate(item.untersuchung_gueltig_bis), untersuchung_gueltig_bis: toGermanDate(item.untersuchung_gueltig_bis),
untersuchung_ergebnis: item.untersuchung_ergebnis || '', untersuchung_ergebnis: item.untersuchung_ergebnis || '',
leistungstest_datum: toInputDate(item.leistungstest_datum), leistungstest_datum: toGermanDate(item.leistungstest_datum),
leistungstest_gueltig_bis: toInputDate(item.leistungstest_gueltig_bis), leistungstest_gueltig_bis: toGermanDate(item.leistungstest_gueltig_bis),
leistungstest_bestanden: item.leistungstest_bestanden || false, leistungstest_bestanden: item.leistungstest_bestanden || false,
bemerkung: item.bemerkung || '', bemerkung: item.bemerkung || '',
}); });
@@ -267,24 +257,11 @@ function Atemschutz() {
setForm((prev) => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
}; };
/** Normalize dates before submit: parse DD.MM.YYYY or YYYY-MM-DD → YYYY-MM-DD for API */ /** Normalize dates before submit: parse DD.MM.YYYY → YYYY-MM-DD for API */
const normalizeDate = (val: string | undefined): string | undefined => { const normalizeDate = (val: string | undefined): string | undefined => {
if (!val) return undefined; if (!val) return undefined;
// DD.MM.YYYY format (German) const iso = fromGermanDate(val);
const dmy = val.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); return iso || undefined;
if (dmy) {
const y = parseInt(dmy[3], 10);
if (y < 1900 || y > 2100) return undefined;
return `${dmy[3]}-${dmy[2].padStart(2, '0')}-${dmy[1].padStart(2, '0')}`;
}
// YYYY-MM-DD format (ISO)
const iso = val.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) {
const y = parseInt(iso[1], 10);
if (y < 1900 || y > 2100) return undefined;
return val;
}
return undefined;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {

View File

@@ -45,6 +45,7 @@ import {
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment'; import { equipmentApi } from '../services/equipment';
import { fromGermanDate } from '../utils/dateInput';
import { import {
AusruestungDetail, AusruestungDetail,
AusruestungWartungslog, AusruestungWartungslog,
@@ -375,6 +376,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
setSaveError(null); setSaveError(null);
await equipmentApi.addWartungslog(equipmentId, { await equipmentApi.addWartungslog(equipmentId, {
...form, ...form,
datum: fromGermanDate(form.datum) || form.datum,
pruefende_stelle: form.pruefende_stelle || undefined, pruefende_stelle: form.pruefende_stelle || undefined,
ergebnis: form.ergebnis || undefined, ergebnis: form.ergebnis || undefined,
}); });
@@ -459,8 +461,8 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField <TextField
label="Datum *" label="Datum *"
type="date"
fullWidth fullWidth
placeholder="TT.MM.JJJJ"
value={form.datum} value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}

View File

@@ -19,6 +19,7 @@ import {
import { ArrowBack, Save } from '@mui/icons-material'; import { ArrowBack, Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import { equipmentApi } from '../services/equipment'; import { equipmentApi } from '../services/equipment';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import { import {
@@ -71,10 +72,9 @@ const EMPTY_FORM: FormState = {
// -- Helpers ------------------------------------------------------------------ // -- Helpers ------------------------------------------------------------------
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */ /** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'DD.MM.YYYY' */
function toDateInput(iso: string | null | undefined): string { function toDateInput(iso: string | null | undefined): string {
if (!iso) return ''; return toGermanDate(iso);
return iso.slice(0, 10);
} }
// -- Component ---------------------------------------------------------------- // -- Component ----------------------------------------------------------------
@@ -217,8 +217,8 @@ function AusruestungForm() {
fahrzeug_id: form.fahrzeug_id || null, fahrzeug_id: form.fahrzeug_id || null,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined, standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined, pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am || undefined, letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined,
naechste_pruefung_am: form.naechste_pruefung_am || undefined, naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : undefined,
bemerkung: form.bemerkung.trim() || undefined, bemerkung: form.bemerkung.trim() || undefined,
}; };
await equipmentApi.update(id, payload); await equipmentApi.update(id, payload);
@@ -237,8 +237,8 @@ function AusruestungForm() {
fahrzeug_id: form.fahrzeug_id || undefined, fahrzeug_id: form.fahrzeug_id || undefined,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined, standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined, pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am || undefined, letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined,
naechste_pruefung_am: form.naechste_pruefung_am || undefined, naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : undefined,
bemerkung: form.bemerkung.trim() || undefined, bemerkung: form.bemerkung.trim() || undefined,
}; };
const created = await equipmentApi.create(payload); const created = await equipmentApi.create(payload);
@@ -462,8 +462,8 @@ function AusruestungForm() {
<Grid item xs={12} sm={4}> <Grid item xs={12} sm={4}>
<TextField <TextField
label="Letzte Prüfung" label="Letzte Prüfung"
type="date"
fullWidth fullWidth
placeholder="TT.MM.JJJJ"
value={form.letzte_pruefung_am} value={form.letzte_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -472,8 +472,8 @@ function AusruestungForm() {
<Grid item xs={12} sm={4}> <Grid item xs={12} sm={4}>
<TextField <TextField
label="Nächste Prüfung" label="Nächste Prüfung"
type="date"
fullWidth fullWidth
placeholder="TT.MM.JJJJ"
value={form.naechste_pruefung_am} value={form.naechste_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}

View File

@@ -13,6 +13,8 @@ import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard'; import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard'; import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard'; import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
import BookStackRecentWidget from '../components/dashboard/BookStackRecentWidget';
import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget';
function Dashboard() { function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const canViewAtemschutz = user?.groups?.some(g => const canViewAtemschutz = user?.groups?.some(g =>
@@ -104,6 +106,24 @@ function Dashboard() {
</Fade> </Fade>
)} )}
</Box> </Box>
{/* BookStack Recent Pages Widget */}
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box>
<BookStackRecentWidget />
</Box>
</Fade>
</Box>
{/* BookStack Search Widget */}
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box>
<BookStackSearchWidget />
</Box>
</Fade>
</Box>
</Box> </Box>
</Container> </Container>
</DashboardLayout> </DashboardLayout>

View File

@@ -36,6 +36,7 @@ import {
import { format, parseISO } from 'date-fns'; import { format, parseISO } 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 { fromGermanDate } from '../utils/dateInput';
import IncidentStatsChart from '../components/incidents/IncidentStatsChart'; import IncidentStatsChart from '../components/incidents/IncidentStatsChart';
import { import {
incidentsApi, incidentsApi,
@@ -206,13 +207,19 @@ function Einsaetze() {
limit: rowsPerPage, limit: rowsPerPage,
offset: page * rowsPerPage, offset: page * rowsPerPage,
}; };
if (dateFrom) filters.dateFrom = new Date(dateFrom).toISOString(); if (dateFrom) {
const iso = fromGermanDate(dateFrom);
if (iso) filters.dateFrom = new Date(iso).toISOString();
}
if (dateTo) { if (dateTo) {
// Set to end of day for dateTo // Set to end of day for dateTo
const end = new Date(dateTo); const iso = fromGermanDate(dateTo);
if (iso) {
const end = new Date(iso);
end.setHours(23, 59, 59, 999); end.setHours(23, 59, 59, 999);
filters.dateTo = end.toISOString(); filters.dateTo = end.toISOString();
} }
}
if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0]; if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0];
const result = await incidentsApi.getAll(filters as Parameters<typeof incidentsApi.getAll>[0]); const result = await incidentsApi.getAll(filters as Parameters<typeof incidentsApi.getAll>[0]);
@@ -336,7 +343,7 @@ function Einsaetze() {
<Grid item xs={12} sm={4} md={3}> <Grid item xs={12} sm={4} md={3}>
<TextField <TextField
label="Von (Alarmzeit)" label="Von (Alarmzeit)"
type="date" placeholder="TT.MM.JJJJ"
value={dateFrom} value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(0); }} onChange={(e) => { setDateFrom(e.target.value); setPage(0); }}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -348,7 +355,7 @@ function Einsaetze() {
<Grid item xs={12} sm={4} md={3}> <Grid item xs={12} sm={4} md={3}>
<TextField <TextField
label="Bis (Alarmzeit)" label="Bis (Alarmzeit)"
type="date" placeholder="TT.MM.JJJJ"
value={dateTo} value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(0); }} onChange={(e) => { setDateTo(e.target.value); setPage(0); }}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}

View File

@@ -692,7 +692,17 @@ function FahrzeugBuchungen() {
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
{availability !== null && ( {/* Availability indicator */}
{form.fahrzeugId && form.beginn && form.ende ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{availability === null ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Verfügbarkeit wird geprüft...
</Typography>
</Box>
) : (
<Chip <Chip
icon={availability ? <CheckCircle /> : <Warning />} icon={availability ? <CheckCircle /> : <Warning />}
label={ label={
@@ -702,9 +712,14 @@ function FahrzeugBuchungen() {
} }
color={availability ? 'success' : 'error'} color={availability ? 'success' : 'error'}
size="small" size="small"
sx={{ alignSelf: 'flex-start' }}
/> />
)} )}
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung
</Typography>
)}
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<InputLabel>Buchungsart</InputLabel> <InputLabel>Buchungsart</InputLabel>
@@ -758,6 +773,22 @@ function FahrzeugBuchungen() {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button> <Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Tooltip
title={
dialogLoading || (form.titel && form.fahrzeugId && form.beginn && form.ende)
? ''
: `Bitte fülle alle Pflichtfelder aus: ${[
!form.titel && 'Titel',
!form.fahrzeugId && 'Fahrzeug',
!form.beginn && 'Beginn',
!form.ende && 'Ende',
]
.filter(Boolean)
.join(', ')}`
}
disableHoverListener={!!(form.titel && form.fahrzeugId && form.beginn && form.ende)}
>
<span>
<Button <Button
variant="contained" variant="contained"
onClick={handleSave} onClick={handleSave}
@@ -777,6 +808,8 @@ function FahrzeugBuchungen() {
'Buchen' 'Buchen'
)} )}
</Button> </Button>
</span>
</Tooltip>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -56,6 +56,7 @@ import {
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import { fromGermanDate } from '../utils/dateInput';
import { equipmentApi } from '../services/equipment'; import { equipmentApi } from '../services/equipment';
import { import {
FahrzeugDetail, FahrzeugDetail,
@@ -347,6 +348,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
setSaveError(null); setSaveError(null);
await vehiclesApi.addWartungslog(fahrzeugId, { await vehiclesApi.addWartungslog(fahrzeugId, {
...form, ...form,
datum: fromGermanDate(form.datum) || form.datum,
externe_werkstatt: form.externe_werkstatt || undefined, externe_werkstatt: form.externe_werkstatt || undefined,
}); });
setDialogOpen(false); setDialogOpen(false);
@@ -411,8 +413,8 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField <TextField
label="Datum *" label="Datum *"
type="date"
fullWidth fullWidth
placeholder="TT.MM.JJJJ"
value={form.datum} value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}

View File

@@ -18,6 +18,7 @@ import { ArrowBack, Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import { import {
FahrzeugStatus, FahrzeugStatus,
FahrzeugStatusLabel, FahrzeugStatusLabel,
@@ -64,10 +65,9 @@ const EMPTY_FORM: FormState = {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */ /** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'DD.MM.YYYY' */
function toDateInput(iso: string | null | undefined): string { function toDateInput(iso: string | null | undefined): string {
if (!iso) return ''; return toGermanDate(iso);
return iso.slice(0, 10);
} }
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
@@ -169,8 +169,8 @@ function FahrzeugForm() {
status_bemerkung: form.status_bemerkung.trim() || undefined, status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus', standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined, bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined, naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined,
}; };
await vehiclesApi.update(id, payload); await vehiclesApi.update(id, payload);
navigate(`/fahrzeuge/${id}`); navigate(`/fahrzeuge/${id}`);
@@ -188,8 +188,8 @@ function FahrzeugForm() {
status_bemerkung: form.status_bemerkung.trim() || undefined, status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus', standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined, bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined, naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined,
}; };
const newVehicle = await vehiclesApi.create(payload); const newVehicle = await vehiclesApi.create(payload);
navigate(`/fahrzeuge/${newVehicle.id}`); navigate(`/fahrzeuge/${newVehicle.id}`);
@@ -314,8 +314,8 @@ function FahrzeugForm() {
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField <TextField
label="§57a fällig am" label="§57a fällig am"
type="date"
fullWidth fullWidth
placeholder="TT.MM.JJJJ"
value={form.paragraph57a_faellig_am} value={form.paragraph57a_faellig_am}
onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -325,8 +325,8 @@ function FahrzeugForm() {
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField <TextField
label="Nächste Wartung am" label="Nächste Wartung am"
type="date"
fullWidth fullWidth
placeholder="TT.MM.JJJJ"
value={form.naechste_wartung_am} value={form.naechste_wartung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}

View File

@@ -55,8 +55,11 @@ import {
DirectionsCar as CarIcon, DirectionsCar as CarIcon,
Edit as EditIcon, Edit as EditIcon,
Event as EventIcon, Event as EventIcon,
FileDownload as FileDownloadIcon,
FileUpload as FileUploadIcon,
HelpOutline as UnknownIcon, HelpOutline as UnknownIcon,
IosShare, IosShare,
PictureAsPdf as PdfIcon,
Star as StarIcon, Star as StarIcon,
Today as TodayIcon, Today as TodayIcon,
Tune, Tune,
@@ -65,6 +68,7 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { trainingApi } from '../services/training'; import { trainingApi } from '../services/training';
@@ -193,13 +197,12 @@ function formatDateLong(d: Date): string {
return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`; return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`;
} }
function toDatetimeLocal(isoString: string): string {
const d = new Date(isoString);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromDatetimeLocal(value: string): string { function fromDatetimeLocal(value: string): string {
if (!value) return new Date().toISOString();
const dtIso = fromGermanDateTime(value);
if (dtIso) return new Date(dtIso).toISOString();
const dIso = fromGermanDate(value);
if (dIso) return new Date(dIso).toISOString();
return new Date(value).toISOString(); return new Date(value).toISOString();
} }
@@ -609,6 +612,323 @@ function DayPopover({
); );
} }
// ──────────────────────────────────────────────────────────────────────────────
// PDF Export helper
// ──────────────────────────────────────────────────────────────────────────────
async function generatePdf(
year: number,
month: number,
trainingEvents: UebungListItem[],
veranstaltungen: VeranstaltungListItem[],
) {
// Dynamically import jsPDF to avoid bundle bloat if not needed
const { jsPDF } = await import('jspdf');
const autoTable = (await import('jspdf-autotable')).default;
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
const monthLabel = MONTH_LABELS[month];
// Header bar
doc.setFillColor(183, 28, 28); // fire-red
doc.rect(0, 0, 297, 18, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(`Kalender — ${monthLabel} ${year}`, 10, 12);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('Feuerwehr Rems', 250, 12);
// Build combined list (same logic as CombinedListView)
type ListEntry =
| { kind: 'training'; item: UebungListItem }
| { kind: 'event'; item: VeranstaltungListItem };
const combined: ListEntry[] = [
...trainingEvents.map((t): ListEntry => ({ kind: 'training', item: t })),
...veranstaltungen.map((e): ListEntry => ({ kind: 'event', item: e })),
].sort((a, b) => a.item.datum_von.localeCompare(b.item.datum_von));
const formatDateCell = (iso: string) => {
const d = new Date(iso);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
};
const formatTimeCell = (iso: string) => {
const d = new Date(iso);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const rows = combined.map((entry) => {
const item = entry.item;
return [
formatDateCell(item.datum_von),
formatTimeCell(item.datum_von),
item.titel,
entry.kind === 'training'
? (item as UebungListItem).typ
: ((item as VeranstaltungListItem).kategorie_name ?? 'Veranstaltung'),
(item as any).ort ?? '',
];
});
autoTable(doc, {
head: [['Datum', 'Uhrzeit', 'Titel', 'Kategorie/Typ', 'Ort']],
body: rows,
startY: 22,
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
alternateRowStyles: { fillColor: [250, 235, 235] },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 25 },
1: { cellWidth: 18 },
2: { cellWidth: 90 },
3: { cellWidth: 40 },
4: { cellWidth: 60 },
},
});
const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`;
doc.save(filename);
}
// ──────────────────────────────────────────────────────────────────────────────
// CSV Import Dialog
// ──────────────────────────────────────────────────────────────────────────────
const CSV_EXAMPLE = [
'Titel;Datum Von;Datum Bis;Ganztaegig;Ort;Kategorie;Beschreibung',
'Übung Atemschutz;15.03.2026 19:00;15.03.2026 21:00;Nein;Feuerwehrhaus;Übung;Atemschutzübung für alle',
'Tag der offenen Tür;20.04.2026;20.04.2026;Ja;Feuerwehrhaus;Veranstaltung;',
].join('\n');
interface CsvRow {
titel: string;
datum_von: string;
datum_bis: string;
ganztaegig: boolean;
ort: string | null;
beschreibung: string | null;
valid: boolean;
error?: string;
}
function parseCsvRow(line: string, lineNo: number): CsvRow {
const parts = line.split(';');
if (parts.length < 4) {
return { titel: '', datum_von: '', datum_bis: '', ganztaegig: false, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Zu wenige Spalten` };
}
const [titel, rawVon, rawBis, rawGanztaegig, ort, , beschreibung] = parts;
const ganztaegig = rawGanztaegig?.trim().toLowerCase() === 'ja';
const convertDate = (raw: string): string => {
const trimmed = raw.trim();
// DD.MM.YYYY HH:MM
const dtIso = fromGermanDateTime(trimmed);
if (dtIso) return new Date(dtIso).toISOString();
// DD.MM.YYYY
const dIso = fromGermanDate(trimmed);
if (dIso) return new Date(dIso + 'T00:00:00').toISOString();
return '';
};
const datum_von = convertDate(rawVon ?? '');
const datum_bis = convertDate(rawBis ?? '');
if (!titel?.trim()) {
return { titel: '', datum_von, datum_bis, ganztaegig, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Titel fehlt` };
}
if (!datum_von) {
return { titel: titel.trim(), datum_von, datum_bis, ganztaegig, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Datum Von ungültig` };
}
return {
titel: titel.trim(),
datum_von,
datum_bis: datum_bis || datum_von,
ganztaegig,
ort: ort?.trim() || null,
beschreibung: beschreibung?.trim() || null,
valid: true,
};
}
interface CsvImportDialogProps {
open: boolean;
onClose: () => void;
onImported: () => void;
}
function CsvImportDialog({ open, onClose, onImported }: CsvImportDialogProps) {
const [rows, setRows] = useState<CsvRow[]>([]);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ created: number; errors: string[] } | null>(null);
const notification = useNotification();
const fileRef = React.useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
const lines = text.split(/\r?\n/).filter((l) => l.trim());
// Skip header line
const dataLines = lines[0]?.toLowerCase().includes('titel') ? lines.slice(1) : lines;
const parsed = dataLines.map((line, i) => parseCsvRow(line, i + 2));
setRows(parsed);
setResult(null);
};
reader.readAsText(file, 'UTF-8');
};
const downloadExample = () => {
const blob = new Blob(['\uFEFF' + CSV_EXAMPLE], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'kalender_import_beispiel.csv';
a.click();
URL.revokeObjectURL(url);
};
const validRows = rows.filter((r) => r.valid);
const handleImport = async () => {
if (validRows.length === 0) return;
setImporting(true);
try {
const events = validRows.map((r) => ({
titel: r.titel,
datum_von: r.datum_von,
datum_bis: r.datum_bis,
ganztaegig: r.ganztaegig,
ort: r.ort,
beschreibung: r.beschreibung,
zielgruppen: [],
alle_gruppen: true,
anmeldung_erforderlich: false,
}));
const res = await eventsApi.importEvents(events);
setResult(res);
if (res.created > 0) {
notification.showSuccess(`${res.created} Veranstaltung${res.created !== 1 ? 'en' : ''} importiert`);
onImported();
}
} catch {
notification.showError('Import fehlgeschlagen');
} finally {
setImporting(false);
}
};
const handleClose = () => {
setRows([]);
setResult(null);
if (fileRef.current) fileRef.current.value = '';
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>Kalender importieren (CSV)</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<FileDownloadIcon />}
onClick={downloadExample}
sx={{ alignSelf: 'flex-start' }}
>
Beispiel-CSV herunterladen
</Button>
<Box>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
style={{ display: 'none' }}
id="csv-import-input"
/>
<label htmlFor="csv-import-input">
<Button variant="contained" component="span" startIcon={<FileUploadIcon />}>
CSV-Datei auswählen
</Button>
</label>
</Box>
{rows.length > 0 && (
<>
<Typography variant="body2">
{validRows.length} gültige / {rows.length - validRows.length} fehlerhafte Zeilen
</Typography>
{rows.some((r) => !r.valid) && (
<Alert severity="warning" sx={{ whiteSpace: 'pre-line' }}>
{rows.filter((r) => !r.valid).map((r) => r.error).join('\n')}
</Alert>
)}
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Titel</TableCell>
<TableCell>Von</TableCell>
<TableCell>Bis</TableCell>
<TableCell>Ganztägig</TableCell>
<TableCell>Ort</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i} sx={{ bgcolor: row.valid ? undefined : 'error.light' }}>
<TableCell>
<Chip
label={row.valid ? 'OK' : 'Fehler'}
color={row.valid ? 'success' : 'error'}
size="small"
/>
</TableCell>
<TableCell>{row.titel}</TableCell>
<TableCell>{row.datum_von ? new Date(row.datum_von).toLocaleDateString('de-DE') : '—'}</TableCell>
<TableCell>{row.datum_bis ? new Date(row.datum_bis).toLocaleDateString('de-DE') : '—'}</TableCell>
<TableCell>{row.ganztaegig ? 'Ja' : 'Nein'}</TableCell>
<TableCell>{row.ort ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
)}
{result && (
<Alert severity={result.errors.length === 0 ? 'success' : 'warning'}>
{result.created} Veranstaltung{result.created !== 1 ? 'en' : ''} importiert.
{result.errors.length > 0 && ` ${result.errors.length} Fehler.`}
</Alert>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Schließen</Button>
<Button
variant="contained"
onClick={handleImport}
disabled={validRows.length === 0 || importing}
startIcon={importing ? <CircularProgress size={16} /> : <FileUploadIcon />}
>
{validRows.length > 0 ? `${validRows.length} importieren` : 'Importieren'}
</Button>
</DialogActions>
</Dialog>
);
}
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
// Combined List View (training + events sorted by date) // Combined List View (training + events sorted by date)
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
@@ -874,7 +1194,7 @@ function VeranstaltungFormDialog({
wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis) wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis)
? { ? {
typ: wiederholungTyp, typ: wiederholungTyp,
bis: wiederholungBis, bis: fromGermanDate(wiederholungBis) || wiederholungBis,
intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined, intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined,
wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag')
? wiederholungWochentag ? wiederholungWochentag
@@ -956,16 +1276,17 @@ function VeranstaltungFormDialog({
/> />
<TextField <TextField
label="Von" label="Von"
type={form.ganztaegig ? 'date' : 'datetime-local'} placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
value={ value={
form.ganztaegig form.ganztaegig
? toDatetimeLocal(form.datum_von).slice(0, 10) ? toGermanDate(form.datum_von)
: toDatetimeLocal(form.datum_von) : toGermanDateTime(form.datum_von)
} }
onChange={(e) => { onChange={(e) => {
const raw = e.target.value;
const iso = form.ganztaegig const iso = form.ganztaegig
? fromDatetimeLocal(`${e.target.value}T00:00`) ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 00:00` : '')
: fromDatetimeLocal(e.target.value); : fromDatetimeLocal(raw);
handleChange('datum_von', iso); handleChange('datum_von', iso);
}} }}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -973,16 +1294,17 @@ function VeranstaltungFormDialog({
/> />
<TextField <TextField
label="Bis" label="Bis"
type={form.ganztaegig ? 'date' : 'datetime-local'} placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
value={ value={
form.ganztaegig form.ganztaegig
? toDatetimeLocal(form.datum_bis).slice(0, 10) ? toGermanDate(form.datum_bis)
: toDatetimeLocal(form.datum_bis) : toGermanDateTime(form.datum_bis)
} }
onChange={(e) => { onChange={(e) => {
const raw = e.target.value;
const iso = form.ganztaegig const iso = form.ganztaegig
? fromDatetimeLocal(`${e.target.value}T23:59`) ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 23:59` : '')
: fromDatetimeLocal(e.target.value); : fromDatetimeLocal(raw);
handleChange('datum_bis', iso); handleChange('datum_bis', iso);
}} }}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -1039,11 +1361,14 @@ function VeranstaltungFormDialog({
{form.anmeldung_erforderlich && ( {form.anmeldung_erforderlich && (
<TextField <TextField
label="Anmeldeschluss" label="Anmeldeschluss"
type="datetime-local" placeholder="TT.MM.JJJJ HH:MM"
value={form.anmeldung_bis ? toDatetimeLocal(form.anmeldung_bis) : ''} value={form.anmeldung_bis ? toGermanDateTime(form.anmeldung_bis) : ''}
onChange={(e) => onChange={(e) => {
handleChange('anmeldung_bis', e.target.value ? fromDatetimeLocal(e.target.value) : null) const raw = e.target.value;
} if (!raw) { handleChange('anmeldung_bis', null); return; }
const iso = fromGermanDateTime(raw);
if (iso) handleChange('anmeldung_bis', new Date(iso).toISOString());
}}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
fullWidth fullWidth
/> />
@@ -1109,8 +1434,8 @@ function VeranstaltungFormDialog({
<TextField <TextField
label="Wiederholungen bis" label="Wiederholungen bis"
type="date"
size="small" size="small"
placeholder="TT.MM.JJJJ"
value={wiederholungBis} value={wiederholungBis}
onChange={(e) => setWiederholungBis(e.target.value)} onChange={(e) => setWiederholungBis(e.target.value)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -1216,6 +1541,7 @@ export default function Kalender() {
const [icalEventOpen, setIcalEventOpen] = useState(false); const [icalEventOpen, setIcalEventOpen] = useState(false);
const [icalEventUrl, setIcalEventUrl] = useState(''); const [icalEventUrl, setIcalEventUrl] = useState('');
const [icalBookingOpen, setIcalBookingOpen] = useState(false); const [icalBookingOpen, setIcalBookingOpen] = useState(false);
const [csvImportOpen, setCsvImportOpen] = useState(false);
const [icalBookingUrl, setIcalBookingUrl] = useState(''); const [icalBookingUrl, setIcalBookingUrl] = useState('');
// ── Data loading ───────────────────────────────────────────────────────────── // ── Data loading ─────────────────────────────────────────────────────────────
@@ -1405,8 +1731,8 @@ export default function Kalender() {
setBookingForm({ setBookingForm({
...EMPTY_BOOKING_FORM, ...EMPTY_BOOKING_FORM,
fahrzeugId: vehicleId, fahrzeugId: vehicleId,
beginn: fnsFormat(day, "yyyy-MM-dd'T'08:00"), beginn: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'08:00")),
ende: fnsFormat(day, "yyyy-MM-dd'T'17:00"), ende: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'17:00")),
}); });
setBookingDialogError(null); setBookingDialogError(null);
setAvailability(null); setAvailability(null);
@@ -1420,8 +1746,8 @@ export default function Kalender() {
fahrzeugId: detailBooking.fahrzeug_id, fahrzeugId: detailBooking.fahrzeug_id,
titel: detailBooking.titel, titel: detailBooking.titel,
beschreibung: '', beschreibung: '',
beginn: fnsFormat(parseISO(detailBooking.beginn as unknown as string), "yyyy-MM-dd'T'HH:mm"), beginn: toGermanDateTime(fnsFormat(parseISO(detailBooking.beginn as unknown as string), "yyyy-MM-dd'T'HH:mm")),
ende: fnsFormat(parseISO(detailBooking.ende as unknown as string), "yyyy-MM-dd'T'HH:mm"), ende: toGermanDateTime(fnsFormat(parseISO(detailBooking.ende as unknown as string), "yyyy-MM-dd'T'HH:mm")),
buchungsArt: detailBooking.buchungs_art, buchungsArt: detailBooking.buchungs_art,
kontaktPerson: '', kontaktPerson: '',
kontaktTelefon: '', kontaktTelefon: '',
@@ -1440,11 +1766,13 @@ export default function Kalender() {
return; return;
} }
let cancelled = false; let cancelled = false;
const beginnIso = fromGermanDateTime(bookingForm.beginn) || bookingForm.beginn;
const endeIso = fromGermanDateTime(bookingForm.ende) || bookingForm.ende;
bookingApi bookingApi
.checkAvailability( .checkAvailability(
bookingForm.fahrzeugId, bookingForm.fahrzeugId,
new Date(bookingForm.beginn), new Date(beginnIso),
new Date(bookingForm.ende) new Date(endeIso)
) )
.then(({ available }) => { .then(({ available }) => {
if (!cancelled) setAvailability(available); if (!cancelled) setAvailability(available);
@@ -1461,8 +1789,8 @@ export default function Kalender() {
try { try {
const payload: CreateBuchungInput = { const payload: CreateBuchungInput = {
...bookingForm, ...bookingForm,
beginn: new Date(bookingForm.beginn).toISOString(), beginn: (() => { const iso = fromGermanDateTime(bookingForm.beginn); return iso ? new Date(iso).toISOString() : new Date(bookingForm.beginn).toISOString(); })(),
ende: new Date(bookingForm.ende).toISOString(), ende: (() => { const iso = fromGermanDateTime(bookingForm.ende); return iso ? new Date(iso).toISOString() : new Date(bookingForm.ende).toISOString(); })(),
}; };
if (editingBooking) { if (editingBooking) {
await bookingApi.update(editingBooking.id, payload); await bookingApi.update(editingBooking.id, payload);
@@ -1622,6 +1950,35 @@ export default function Kalender() {
</Tooltip> </Tooltip>
)} )}
{/* PDF Export — only in list view */}
{viewMode === 'list' && (
<Tooltip title="Als PDF exportieren">
<IconButton
size="small"
onClick={() => generatePdf(
viewMonth.year,
viewMonth.month,
trainingForMonth,
eventsForMonth,
)}
>
<PdfIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* CSV Import */}
{canWriteEvents && (
<Tooltip title="Kalender importieren (CSV)">
<IconButton
size="small"
onClick={() => setCsvImportOpen(true)}
>
<FileUploadIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* iCal subscribe */} {/* iCal subscribe */}
<Button <Button
startIcon={<IosShare />} startIcon={<IosShare />}
@@ -2166,7 +2523,7 @@ export default function Kalender() {
/> />
<TextField <TextField
fullWidth size="small" label="Beginn" type="datetime-local" required fullWidth size="small" label="Beginn" placeholder="TT.MM.JJJJ HH:MM" required
value={bookingForm.beginn} value={bookingForm.beginn}
onChange={(e) => onChange={(e) =>
setBookingForm((f) => ({ ...f, beginn: e.target.value })) setBookingForm((f) => ({ ...f, beginn: e.target.value }))
@@ -2175,7 +2532,7 @@ export default function Kalender() {
/> />
<TextField <TextField
fullWidth size="small" label="Ende" type="datetime-local" required fullWidth size="small" label="Ende" placeholder="TT.MM.JJJJ HH:MM" required
value={bookingForm.ende} value={bookingForm.ende}
onChange={(e) => onChange={(e) =>
setBookingForm((f) => ({ ...f, ende: e.target.value })) setBookingForm((f) => ({ ...f, ende: e.target.value }))
@@ -2327,6 +2684,16 @@ export default function Kalender() {
)} )}
</Box> </Box>
{/* CSV Import Dialog */}
<CsvImportDialog
open={csvImportOpen}
onClose={() => setCsvImportOpen(false)}
onImported={() => {
setCsvImportOpen(false);
loadCalendarData();
}}
/>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -39,6 +39,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import { import {
MemberWithProfile, MemberWithProfile,
StatusEnum, StatusEnum,
@@ -239,10 +240,11 @@ function MitgliedDetail() {
setFormData({ setFormData({
mitglieds_nr: member.profile.mitglieds_nr ?? undefined, mitglieds_nr: member.profile.mitglieds_nr ?? undefined,
dienstgrad: member.profile.dienstgrad ?? undefined, dienstgrad: member.profile.dienstgrad ?? undefined,
dienstgrad_seit: toGermanDate(member.profile.dienstgrad_seit) || undefined,
funktion: member.profile.funktion, funktion: member.profile.funktion,
status: member.profile.status, status: member.profile.status,
eintrittsdatum: member.profile.eintrittsdatum ?? undefined, eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined,
geburtsdatum: member.profile.geburtsdatum ?? undefined, geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined,
telefon_mobil: member.profile.telefon_mobil ?? undefined, telefon_mobil: member.profile.telefon_mobil ?? undefined,
telefon_privat: member.profile.telefon_privat ?? undefined, telefon_privat: member.profile.telefon_privat ?? undefined,
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined, notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
@@ -263,7 +265,13 @@ function MitgliedDetail() {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
const updated = await membersService.updateMember(userId, formData); const payload: UpdateMemberProfileData = {
...formData,
eintrittsdatum: formData.eintrittsdatum ? fromGermanDate(formData.eintrittsdatum) || undefined : undefined,
geburtsdatum: formData.geburtsdatum ? fromGermanDate(formData.geburtsdatum) || undefined : undefined,
dienstgrad_seit: formData.dienstgrad_seit ? fromGermanDate(formData.dienstgrad_seit) || undefined : undefined,
};
const updated = await membersService.updateMember(userId, payload);
setMember(updated); setMember(updated);
setEditMode(false); setEditMode(false);
} catch { } catch {
@@ -491,9 +499,9 @@ function MitgliedDetail() {
<TextField <TextField
label="Dienstgrad seit" label="Dienstgrad seit"
type="date"
fullWidth fullWidth
size="small" size="small"
placeholder="TT.MM.JJJJ"
value={formData.dienstgrad_seit ?? ''} value={formData.dienstgrad_seit ?? ''}
onChange={(e) => handleFieldChange('dienstgrad_seit', e.target.value || undefined)} onChange={(e) => handleFieldChange('dienstgrad_seit', e.target.value || undefined)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -522,9 +530,9 @@ function MitgliedDetail() {
<TextField <TextField
label="Eintrittsdatum" label="Eintrittsdatum"
type="date"
fullWidth fullWidth
size="small" size="small"
placeholder="TT.MM.JJJJ"
value={formData.eintrittsdatum ?? ''} value={formData.eintrittsdatum ?? ''}
onChange={(e) => handleFieldChange('eintrittsdatum', e.target.value || undefined)} onChange={(e) => handleFieldChange('eintrittsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -532,9 +540,9 @@ function MitgliedDetail() {
<TextField <TextField
label="Geburtsdatum" label="Geburtsdatum"
type="date"
fullWidth fullWidth
size="small" size="small"
placeholder="TT.MM.JJJJ"
value={formData.geburtsdatum ?? ''} value={formData.geburtsdatum ?? ''}
onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)} onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}

View File

@@ -11,20 +11,23 @@ import {
Divider, Divider,
Box, Box,
Button, Button,
ToggleButtonGroup,
ToggleButton,
} from '@mui/material'; } from '@mui/material';
import { Settings as SettingsIcon, Notifications, Palette, Language, Save } from '@mui/icons-material'; import { Settings as SettingsIcon, Notifications, Palette, Language, Save, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { useThemeMode } from '../contexts/ThemeContext';
function Settings() { function Settings() {
const notification = useNotification(); const notification = useNotification();
const { themeMode, setThemeMode } = useThemeMode();
// Settings state // Settings state
const [emailNotifications, setEmailNotifications] = useState(true); const [emailNotifications, setEmailNotifications] = useState(true);
const [alarmNotifications, setAlarmNotifications] = useState(true); const [alarmNotifications, setAlarmNotifications] = useState(true);
const [maintenanceReminders, setMaintenanceReminders] = useState(false); const [maintenanceReminders, setMaintenanceReminders] = useState(false);
const [systemNotifications, setSystemNotifications] = useState(true); const [systemNotifications, setSystemNotifications] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [compactView, setCompactView] = useState(true); const [compactView, setCompactView] = useState(true);
const [animations, setAnimations] = useState(true); const [animations, setAnimations] = useState(true);
@@ -105,18 +108,6 @@ function Settings() {
</Box> </Box>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<FormGroup> <FormGroup>
<FormControlLabel
control={
<Switch
checked={darkMode}
onChange={(e) => {
setDarkMode(e.target.checked);
notification.showInfo('Dunkler Modus wird in einer zukünftigen Version verfügbar sein');
}}
/>
}
label="Dunkler Modus (Vorschau)"
/>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -135,6 +126,30 @@ function Settings() {
} }
label="Animationen" label="Animationen"
/> />
<Box sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
Farbschema
</Typography>
<ToggleButtonGroup
value={themeMode}
exclusive
onChange={(_e, value) => { if (value) setThemeMode(value); }}
size="small"
>
<ToggleButton value="system">
<SettingsBrightness fontSize="small" sx={{ mr: 0.5 }} />
System
</ToggleButton>
<ToggleButton value="light">
<LightMode fontSize="small" sx={{ mr: 0.5 }} />
Hell
</ToggleButton>
<ToggleButton value="dark">
<DarkMode fontSize="small" sx={{ mr: 0.5 }} />
Dunkel
</ToggleButton>
</ToggleButtonGroup>
</Box>
</FormGroup> </FormGroup>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -52,6 +52,7 @@ import {
Delete as DeleteIcon, Delete as DeleteIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { eventsApi } from '../services/events'; import { eventsApi } from '../services/events';
@@ -121,15 +122,15 @@ function formatDateShort(isoString: string): string {
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
} }
/** Convert a Date to datetime-local input value "YYYY-MM-DDTHH:MM" */
function toDatetimeLocal(isoString: string): string {
const d = new Date(isoString);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Convert datetime-local value back to ISO string */ /** Convert datetime-local value back to ISO string */
function fromDatetimeLocal(value: string): string { function fromDatetimeLocal(value: string): string {
if (!value) return new Date().toISOString();
// DD.MM.YYYY HH:MM format
const dtIso = fromGermanDateTime(value);
if (dtIso) return new Date(dtIso).toISOString();
// DD.MM.YYYY format (for ganztaegig)
const dIso = fromGermanDate(value);
if (dIso) return new Date(dIso).toISOString();
return new Date(value).toISOString(); return new Date(value).toISOString();
} }
@@ -720,16 +721,16 @@ function EventFormDialog({
{/* Datum von */} {/* Datum von */}
<TextField <TextField
label="Von" label="Von"
type={form.ganztaegig ? 'date' : 'datetime-local'} placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
value={ value={
form.ganztaegig form.ganztaegig
? toDatetimeLocal(form.datum_von).slice(0, 10) ? toGermanDate(form.datum_von)
: toDatetimeLocal(form.datum_von) : toGermanDateTime(form.datum_von)
} }
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
const iso = form.ganztaegig const iso = form.ganztaegig
? fromDatetimeLocal(`${raw}T00:00`) ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)}T00:00` : '')
: fromDatetimeLocal(raw); : fromDatetimeLocal(raw);
handleChange('datum_von', iso); handleChange('datum_von', iso);
}} }}
@@ -740,16 +741,16 @@ function EventFormDialog({
{/* Datum bis */} {/* Datum bis */}
<TextField <TextField
label="Bis" label="Bis"
type={form.ganztaegig ? 'date' : 'datetime-local'} placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
value={ value={
form.ganztaegig form.ganztaegig
? toDatetimeLocal(form.datum_bis).slice(0, 10) ? toGermanDate(form.datum_bis)
: toDatetimeLocal(form.datum_bis) : toGermanDateTime(form.datum_bis)
} }
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
const iso = form.ganztaegig const iso = form.ganztaegig
? fromDatetimeLocal(`${raw}T23:59`) ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)}T23:59` : '')
: fromDatetimeLocal(raw); : fromDatetimeLocal(raw);
handleChange('datum_bis', iso); handleChange('datum_bis', iso);
}} }}
@@ -827,9 +828,14 @@ function EventFormDialog({
{form.anmeldung_erforderlich && ( {form.anmeldung_erforderlich && (
<TextField <TextField
label="Anmeldeschluss" label="Anmeldeschluss"
type="datetime-local" placeholder="TT.MM.JJJJ HH:MM"
value={form.anmeldung_bis ? toDatetimeLocal(form.anmeldung_bis) : ''} value={form.anmeldung_bis ? toGermanDateTime(form.anmeldung_bis) : ''}
onChange={(e) => handleChange('anmeldung_bis', e.target.value ? fromDatetimeLocal(e.target.value) : null)} onChange={(e) => {
const raw = e.target.value;
if (!raw) { handleChange('anmeldung_bis', null); return; }
const iso = fromGermanDateTime(raw);
if (iso) handleChange('anmeldung_bis', new Date(iso).toISOString());
}}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
fullWidth fullWidth
/> />

View File

@@ -40,10 +40,7 @@ import {
DialogTitle, DialogTitle,
Divider, Divider,
IconButton, IconButton,
MenuItem,
Paper, Paper,
Select,
SelectChangeEvent,
Skeleton, Skeleton,
Stack, Stack,
TextField, TextField,
@@ -57,8 +54,6 @@ import {
GridRenderCellParams, GridRenderCellParams,
GridRowParams, GridRowParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -66,6 +61,7 @@ import DownloadIcon from '@mui/icons-material/Download';
import FilterAltIcon from '@mui/icons-material/FilterAlt'; import FilterAltIcon from '@mui/icons-material/FilterAlt';
import DashboardLayout from '../../components/dashboard/DashboardLayout'; import DashboardLayout from '../../components/dashboard/DashboardLayout';
import { api } from '../../services/api'; import { api } from '../../services/api';
import { fromGermanDate } from '../../utils/dateInput';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types — mirror the backend AuditLogEntry interface // Types — mirror the backend AuditLogEntry interface
@@ -106,8 +102,8 @@ interface AuditFilters {
userId?: string; userId?: string;
action?: AuditAction[]; action?: AuditAction[];
resourceType?: AuditResourceType[]; resourceType?: AuditResourceType[];
dateFrom?: Date | null; dateFrom?: string;
dateTo?: Date | null; dateTo?: string;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -349,17 +345,23 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, onReset })
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{/* Date range */} {/* Date range */}
<DatePicker <TextField
label="Von" label="Von"
value={filters.dateFrom ?? null} size="small"
onChange={(date) => onChange({ ...filters, dateFrom: date })} placeholder="TT.MM.JJJJ"
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }} value={filters.dateFrom ?? ''}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
sx={{ minWidth: 160 }}
InputLabelProps={{ shrink: true }}
/> />
<DatePicker <TextField
label="Bis" label="Bis"
value={filters.dateTo ?? null} size="small"
onChange={(date) => onChange({ ...filters, dateTo: date })} placeholder="TT.MM.JJJJ"
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }} value={filters.dateTo ?? ''}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
sx={{ minWidth: 160 }}
InputLabelProps={{ shrink: true }}
/> />
{/* Action multi-select */} {/* Action multi-select */}
@@ -416,8 +418,8 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, onReset })
const DEFAULT_FILTERS: AuditFilters = { const DEFAULT_FILTERS: AuditFilters = {
action: [], action: [],
resourceType: [], resourceType: [],
dateFrom: null, dateFrom: '',
dateTo: null, dateTo: '',
}; };
const AuditLog: React.FC = () => { const AuditLog: React.FC = () => {
@@ -460,8 +462,14 @@ const AuditLog: React.FC = () => {
pageSize: String(pagination.pageSize), pageSize: String(pagination.pageSize),
}; };
if (f.dateFrom) params.dateFrom = f.dateFrom.toISOString(); if (f.dateFrom) {
if (f.dateTo) params.dateTo = f.dateTo.toISOString(); const iso = fromGermanDate(f.dateFrom);
if (iso) params.dateFrom = new Date(iso).toISOString();
}
if (f.dateTo) {
const iso = fromGermanDate(f.dateTo);
if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString();
}
if (f.action && f.action.length > 0) { if (f.action && f.action.length > 0) {
params.action = f.action.join(','); params.action = f.action.join(',');
} }
@@ -513,8 +521,14 @@ const AuditLog: React.FC = () => {
setExporting(true); setExporting(true);
try { try {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (appliedFilters.dateFrom) params.dateFrom = appliedFilters.dateFrom.toISOString(); if (appliedFilters.dateFrom) {
if (appliedFilters.dateTo) params.dateTo = appliedFilters.dateTo.toISOString(); const iso = fromGermanDate(appliedFilters.dateFrom);
if (iso) params.dateFrom = new Date(iso).toISOString();
}
if (appliedFilters.dateTo) {
const iso = fromGermanDate(appliedFilters.dateTo);
if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString();
}
if (appliedFilters.action && appliedFilters.action.length > 0) { if (appliedFilters.action && appliedFilters.action.length > 0) {
params.action = appliedFilters.action.join(','); params.action = appliedFilters.action.join(',');
} }
@@ -637,7 +651,6 @@ const AuditLog: React.FC = () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
return ( return (
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={de}>
<DashboardLayout> <DashboardLayout>
<Container maxWidth="xl"> <Container maxWidth="xl">
{/* Header */} {/* Header */}
@@ -726,7 +739,6 @@ const AuditLog: React.FC = () => {
/> />
</Container> </Container>
</DashboardLayout> </DashboardLayout>
</LocalizationProvider>
); );
}; };

View File

@@ -0,0 +1,24 @@
import { api } from './api';
import type { BookStackRecentResponse, BookStackSearchResponse } from '../types/bookstack.types';
interface ApiResponse<T> {
success: boolean;
data: T;
configured: boolean;
}
export const bookstackApi = {
getRecent(): Promise<BookStackRecentResponse> {
return api
.get<ApiResponse<BookStackRecentResponse['data']>>('/api/bookstack/recent')
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
},
search(query: string): Promise<BookStackSearchResponse> {
return api
.get<ApiResponse<BookStackSearchResponse['data']>>('/api/bookstack/search', {
params: { query },
})
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
},
};

View File

@@ -146,4 +146,11 @@ export const eventsApi = {
) )
.then((r) => r.data.data); .then((r) => r.data.data);
}, },
/** Bulk-import events from CSV-parsed data */
importEvents(events: CreateVeranstaltungInput[]): Promise<{ created: number; errors: string[] }> {
return api
.post<ApiResponse<{ created: number; errors: string[] }>>('/api/events/import', { events })
.then((r) => r.data.data);
},
}; };

View File

@@ -0,0 +1,36 @@
export interface BookStackPage {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
chapter_id: number;
draft: boolean;
template: boolean;
created_at: string;
updated_at: string;
url: string;
book?: { name: string };
updatedBy?: { name: string };
}
export interface BookStackSearchResult {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
url: string;
preview_html: { content: string };
tags: { name: string; value: string; order: number }[];
}
export interface BookStackRecentResponse {
configured: boolean;
data: BookStackPage[];
}
export interface BookStackSearchResponse {
configured: boolean;
data: BookStackSearchResult[];
}

View File

@@ -0,0 +1,60 @@
/**
* Shared utilities for German date format (DD.MM.YYYY / DD.MM.YYYY HH:MM)
* Used as plain TextField inputs with placeholder instead of type="date"/"datetime-local"
*/
/** ISO/YYYY-MM-DD → DD.MM.YYYY */
export function toGermanDate(iso: string | null | undefined): string {
if (!iso) return '';
// Already DD.MM.YYYY
if (/^\d{2}\.\d{2}\.\d{4}$/.test(iso)) return iso;
// YYYY-MM-DD (possibly with time)
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return `${m[3]}.${m[2]}.${m[1]}`;
return '';
}
/** ISO datetime → DD.MM.YYYY HH:MM */
export function toGermanDateTime(iso: string | null | undefined): string {
if (!iso) return '';
// Already DD.MM.YYYY HH:MM
if (/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$/.test(iso)) return iso;
// YYYY-MM-DDTHH:MM or YYYY-MM-DD HH:MM
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if (m) return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
// Date only → midnight
const d = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (d) return `${d[3]}.${d[2]}.${d[1]} 00:00`;
return '';
}
/** DD.MM.YYYY → YYYY-MM-DD (ISO date string) */
export function fromGermanDate(german: string): string {
const m = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]}`;
}
/** DD.MM.YYYY HH:MM → ISO datetime string (YYYY-MM-DDTHH:MM:00.000Z local) */
export function fromGermanDateTime(german: string): string {
const m = german.match(/^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2})$/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]}T${m[4]}:${m[5]}`;
}
/** Validates DD.MM.YYYY format and that the date is valid */
export function isValidGermanDate(value: string): boolean {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return false;
const [dd, mm, yyyy] = value.split('.').map(Number);
const date = new Date(yyyy, mm - 1, dd);
return date.getFullYear() === yyyy && date.getMonth() === mm - 1 && date.getDate() === dd;
}
/** Validates DD.MM.YYYY HH:MM format */
export function isValidGermanDateTime(value: string): boolean {
if (!/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$/.test(value)) return false;
const [datePart, timePart] = value.split(' ');
const [hh, mm] = timePart.split(':').map(Number);
if (hh > 23 || mm > 59) return false;
return isValidGermanDate(datePart);
}