diff --git a/.env.example b/.env.example index 70e00a1..7d5a2e2 100644 --- a/.env.example +++ b/.env.example @@ -159,6 +159,23 @@ AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback # Used by the backend for Nextcloud integration 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) # ============================================================================ diff --git a/backend/src/app.ts b/backend/src/app.ts index 430926b..f308222 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -81,6 +81,7 @@ import atemschutzRoutes from './routes/atemschutz.routes'; import eventsRoutes from './routes/events.routes'; import bookingRoutes from './routes/booking.routes'; import notificationRoutes from './routes/notification.routes'; +import bookstackRoutes from './routes/bookstack.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -95,6 +96,7 @@ app.use('/api/nextcloud/talk', nextcloudRoutes); app.use('/api/events', eventsRoutes); app.use('/api/bookings', bookingRoutes); app.use('/api/notifications', notificationRoutes); +app.use('/api/bookstack', bookstackRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts index fec1b61..b617f6c 100644 --- a/backend/src/config/environment.ts +++ b/backend/src/config/environment.ts @@ -33,6 +33,11 @@ interface EnvironmentConfig { redirectUri: string; }; nextcloudUrl: string; + bookstack: { + url: string; + tokenId: string; + tokenSecret: string; + }; } const environment: EnvironmentConfig = { @@ -63,6 +68,11 @@ const environment: EnvironmentConfig = { redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback', }, 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; diff --git a/backend/src/controllers/bookstack.controller.ts b/backend/src/controllers/bookstack.controller.ts new file mode 100644 index 0000000..9d2c9b7 --- /dev/null +++ b/backend/src/controllers/bookstack.controller.ts @@ -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 { + 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 { + 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(); diff --git a/backend/src/controllers/events.controller.ts b/backend/src/controllers/events.controller.ts index 7dfc4ce..9ad1fdc 100644 --- a/backend/src/controllers/events.controller.ts +++ b/backend/src/controllers/events.controller.ts @@ -325,6 +325,43 @@ class EventsController { res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' }); } }; + // ------------------------------------------------------------------------- + // POST /api/events/import + // ------------------------------------------------------------------------- + importEvents = async (req: Request, res: Response): Promise => { + 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(); diff --git a/backend/src/routes/bookstack.routes.ts b/backend/src/routes/bookstack.routes.ts new file mode 100644 index 0000000..881a629 --- /dev/null +++ b/backend/src/routes/bookstack.routes.ts @@ -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; diff --git a/backend/src/routes/events.routes.ts b/backend/src/routes/events.routes.ts index 7d0414e..9f9d26a 100644 --- a/backend/src/routes/events.routes.ts +++ b/backend/src/routes/events.routes.ts @@ -104,6 +104,17 @@ router.get( // 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 * Create a new event. Requires admin or moderator. diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts new file mode 100644 index 0000000..5fdc693 --- /dev/null +++ b/backend/src/services/bookstack.service.ts @@ -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 { + const { bookstack } = environment; + return { + 'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`, + 'Content-Type': 'application/json', + }; +} + +async function getRecentPages(): Promise { + 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 { + 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 }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd31cc0..e451456 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,11 +12,16 @@ "@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-dom": "^18.2.0", - "react-router-dom": "^6.20.0" + "react-router-dom": "^6.20.0", + "recharts": "^2.12.7" }, "devDependencies": { "@types/react": "^18.2.37", @@ -53,6 +58,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -345,6 +351,7 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -385,6 +392,7 @@ "version": "11.14.1", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -873,6 +881,7 @@ "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/core-downloads-tracker": "^5.18.0", @@ -1400,6 +1409,30 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1441,6 +1474,60 @@ "@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": { "version": "1.0.8", "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", "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": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1508,6 +1603,18 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "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": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", @@ -1532,6 +1639,15 @@ "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": { "version": "2.10.0", "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" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1577,6 +1694,18 @@ "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": { "version": "1.0.2", "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": { "version": "2.1.1", "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", "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": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -1656,11 +1817,145 @@ "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": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "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": { "version": "4.4.3", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1694,6 +1994,13 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1820,6 +2127,26 @@ "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": { "version": "1.1.0", "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", "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2000,6 +2341,14 @@ "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": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2052,6 +2401,34 @@ "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": { "version": "4.0.0", "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", "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": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2190,6 +2572,12 @@ "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": { "version": "1.1.1", "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", "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": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2258,6 +2656,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2310,6 +2709,20 @@ "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": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -2325,6 +2738,47 @@ "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": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2352,6 +2806,16 @@ "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": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2430,6 +2894,16 @@ "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": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -2446,6 +2920,32 @@ "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": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2489,11 +2989,43 @@ "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": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -2563,4 +3095,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/package.json b/frontend/package.json index 93728bf..328b615 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,17 +10,19 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "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-dom": "^18.2.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" }, "devDependencies": { diff --git a/frontend/src/components/dashboard/BookStackRecentWidget.tsx b/frontend/src/components/dashboard/BookStackRecentWidget.tsx new file mode 100644 index 0000000..e4678e0 --- /dev/null +++ b/frontend/src/components/dashboard/BookStackRecentWidget.tsx @@ -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 ( + <> + + + + {page.name} + + {page.book && ( + + {page.book.name} + + )} + + {relativeTime && ( + + {relativeTime} + + )} + + {showDivider && } + + ); +}; + +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 ( + + + + + + BookStack — Neueste Seiten + + + + {isLoading && ( + + {[1, 2, 3, 4, 5].map((n) => ( + + + + + ))} + + )} + + {isError && ( + + BookStack nicht erreichbar + + )} + + {!isLoading && !isError && pages.length === 0 && ( + + Keine Seiten gefunden + + )} + + {!isLoading && !isError && pages.length > 0 && ( + + {pages.map((page, index) => ( + + ))} + + )} + + + ); +}; + +export default BookStackRecentWidget; diff --git a/frontend/src/components/dashboard/BookStackSearchWidget.tsx b/frontend/src/components/dashboard/BookStackSearchWidget.tsx new file mode 100644 index 0000000..3ace48a --- /dev/null +++ b/frontend/src/components/dashboard/BookStackSearchWidget.tsx @@ -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 ( + <> + 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' }, + }} + > + + {result.name} + + {preview && ( + + {preview} + + )} + + {showDivider && } + + ); +}; + +const BookStackSearchWidget: React.FC = () => { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [configured, setConfigured] = useState(true); + const debounceRef = useRef | 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 ( + + + + + + BookStack — Suche + + + + setQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + {loading ? : } + + ), + }} + /> + + {!loading && query.trim() && results.length === 0 && ( + + Keine Ergebnisse für „{query}" + + )} + + {results.length > 0 && ( + + {results.map((result, index) => ( + + ))} + + )} + + + ); +}; + +export default BookStackSearchWidget; diff --git a/frontend/src/components/incidents/CreateEinsatzDialog.tsx b/frontend/src/components/incidents/CreateEinsatzDialog.tsx index e60ec6b..5ee501d 100644 --- a/frontend/src/components/incidents/CreateEinsatzDialog.tsx +++ b/frontend/src/components/incidents/CreateEinsatzDialog.tsx @@ -14,6 +14,7 @@ import { } from '@mui/material'; import { incidentsApi, EINSATZ_ARTEN, EINSATZ_ART_LABELS, CreateEinsatzPayload } from '../../services/incidents'; import { useNotification } from '../../contexts/NotificationContext'; +import { toGermanDateTime, fromGermanDateTime } from '../../utils/dateInput'; interface CreateEinsatzDialogProps { open: boolean; @@ -21,16 +22,16 @@ interface CreateEinsatzDialogProps { onSuccess: () => void; } -// Default alarm_time = now (rounded to minute) -function nowISO(): string { +// Default alarm_time = now (rounded to minute) in DD.MM.YYYY HH:MM format +function nowGerman(): string { const d = new Date(); 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 } = { alarm_time: '', - alarm_time_local: nowISO(), + alarm_time_local: nowGerman(), einsatz_art: 'Brand', einsatz_stichwort: '', strasse: '', @@ -47,7 +48,7 @@ const CreateEinsatzDialog: React.FC = ({ onSuccess, }) => { 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 [error, setError] = useState(null); @@ -74,8 +75,9 @@ const CreateEinsatzDialog: React.FC = ({ setLoading(true); try { // Convert local datetime string to UTC ISO string + const isoLocal = fromGermanDateTime(form.alarm_time_local); 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_stichwort: form.einsatz_stichwort || null, strasse: form.strasse || null, @@ -88,7 +90,7 @@ const CreateEinsatzDialog: React.FC = ({ await incidentsApi.create(payload); notification.showSuccess('Einsatz erfolgreich angelegt'); - setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() }); + setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() }); onSuccess(); } catch (err) { const msg = err instanceof Error ? err.message : 'Fehler beim Anlegen des Einsatzes'; @@ -101,7 +103,7 @@ const CreateEinsatzDialog: React.FC = ({ const handleClose = () => { if (loading) return; setError(null); - setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() }); + setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() }); onClose(); }; @@ -132,7 +134,7 @@ const CreateEinsatzDialog: React.FC = ({ = ({ helperText="DD.MM.YYYY HH:mm" inputProps={{ 'aria-label': 'Alarmzeit', - // HTML datetime-local uses YYYY-MM-DDTHH:mm format }} /> diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..6a10076 --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -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({ + 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(() => { + 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 ( + + + + {children} + + + ); +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 75c6a1e..8b71964 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; -import { CssBaseline, ThemeProvider } from '@mui/material'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { lightTheme } from './theme/theme'; +import { ThemeModeProvider } from './contexts/ThemeContext'; import App from './App'; const queryClient = new QueryClient({ @@ -20,12 +19,11 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById('root')!).render( - - + - + , ); diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx index a4643ad..b29f51d 100644 --- a/frontend/src/pages/Atemschutz.tsx +++ b/frontend/src/pages/Atemschutz.tsx @@ -45,6 +45,7 @@ import { atemschutzApi } from '../services/atemschutz'; import { membersService } from '../services/members'; import { useNotification } from '../contexts/NotificationContext'; import { useAuth } from '../contexts/AuthContext'; +import { toGermanDate, fromGermanDate } from '../utils/dateInput'; import type { AtemschutzUebersicht, AtemschutzStats, @@ -66,17 +67,6 @@ function formatDate(iso: string | null): string { }); } -/** Extract YYYY-MM-DD from an ISO timestamp or date string for */ -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 { if (item.user_family_name || item.user_given_name) { return [item.user_family_name, item.user_given_name].filter(Boolean).join(', '); @@ -240,12 +230,12 @@ function Atemschutz() { setForm({ user_id: item.user_id, atemschutz_lehrgang: item.atemschutz_lehrgang, - lehrgang_datum: toInputDate(item.lehrgang_datum), - untersuchung_datum: toInputDate(item.untersuchung_datum), - untersuchung_gueltig_bis: toInputDate(item.untersuchung_gueltig_bis), + lehrgang_datum: toGermanDate(item.lehrgang_datum), + untersuchung_datum: toGermanDate(item.untersuchung_datum), + untersuchung_gueltig_bis: toGermanDate(item.untersuchung_gueltig_bis), untersuchung_ergebnis: item.untersuchung_ergebnis || '', - leistungstest_datum: toInputDate(item.leistungstest_datum), - leistungstest_gueltig_bis: toInputDate(item.leistungstest_gueltig_bis), + leistungstest_datum: toGermanDate(item.leistungstest_datum), + leistungstest_gueltig_bis: toGermanDate(item.leistungstest_gueltig_bis), leistungstest_bestanden: item.leistungstest_bestanden || false, bemerkung: item.bemerkung || '', }); @@ -267,24 +257,11 @@ function Atemschutz() { 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 => { if (!val) return undefined; - // DD.MM.YYYY format (German) - const dmy = val.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); - 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 iso = fromGermanDate(val); + return iso || undefined; }; const handleSubmit = async () => { diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index 451a773..b92ac18 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -45,6 +45,7 @@ import { import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { equipmentApi } from '../services/equipment'; +import { fromGermanDate } from '../utils/dateInput'; import { AusruestungDetail, AusruestungWartungslog, @@ -375,6 +376,7 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd setSaveError(null); await equipmentApi.addWartungslog(equipmentId, { ...form, + datum: fromGermanDate(form.datum) || form.datum, pruefende_stelle: form.pruefende_stelle || undefined, ergebnis: form.ergebnis || undefined, }); @@ -459,8 +461,8 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd setForm((f) => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} diff --git a/frontend/src/pages/AusruestungForm.tsx b/frontend/src/pages/AusruestungForm.tsx index 323ed84..b8e0bf0 100644 --- a/frontend/src/pages/AusruestungForm.tsx +++ b/frontend/src/pages/AusruestungForm.tsx @@ -19,6 +19,7 @@ import { import { ArrowBack, Save } from '@mui/icons-material'; import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { toGermanDate, fromGermanDate } from '../utils/dateInput'; import { equipmentApi } from '../services/equipment'; import { vehiclesApi } from '../services/vehicles'; import { @@ -71,10 +72,9 @@ const EMPTY_FORM: FormState = { // -- 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 { - if (!iso) return ''; - return iso.slice(0, 10); + return toGermanDate(iso); } // -- Component ---------------------------------------------------------------- @@ -217,8 +217,8 @@ function AusruestungForm() { fahrzeug_id: form.fahrzeug_id || null, standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined, pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined, - letzte_pruefung_am: form.letzte_pruefung_am || undefined, - naechste_pruefung_am: form.naechste_pruefung_am || undefined, + letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined, + naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : undefined, bemerkung: form.bemerkung.trim() || undefined, }; await equipmentApi.update(id, payload); @@ -237,8 +237,8 @@ function AusruestungForm() { fahrzeug_id: form.fahrzeug_id || undefined, standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined, pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined, - letzte_pruefung_am: form.letzte_pruefung_am || undefined, - naechste_pruefung_am: form.naechste_pruefung_am || undefined, + letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined, + naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : undefined, bemerkung: form.bemerkung.trim() || undefined, }; const created = await equipmentApi.create(payload); @@ -462,8 +462,8 @@ function AusruestungForm() { setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))} InputLabelProps={{ shrink: true }} @@ -472,8 +472,8 @@ function AusruestungForm() { setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))} InputLabelProps={{ shrink: true }} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 481e846..c0c3cbf 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,6 +13,8 @@ import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget'; import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard'; import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard'; import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard'; +import BookStackRecentWidget from '../components/dashboard/BookStackRecentWidget'; +import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget'; function Dashboard() { const { user } = useAuth(); const canViewAtemschutz = user?.groups?.some(g => @@ -104,6 +106,24 @@ function Dashboard() { )} + + {/* BookStack Recent Pages Widget */} + + + + + + + + + {/* BookStack Search Widget */} + + + + + + + diff --git a/frontend/src/pages/Einsaetze.tsx b/frontend/src/pages/Einsaetze.tsx index 6d7b13a..b070dcd 100644 --- a/frontend/src/pages/Einsaetze.tsx +++ b/frontend/src/pages/Einsaetze.tsx @@ -36,6 +36,7 @@ import { import { format, parseISO } from 'date-fns'; import { de } from 'date-fns/locale'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { fromGermanDate } from '../utils/dateInput'; import IncidentStatsChart from '../components/incidents/IncidentStatsChart'; import { incidentsApi, @@ -206,12 +207,18 @@ function Einsaetze() { limit: 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) { // Set to end of day for dateTo - const end = new Date(dateTo); - end.setHours(23, 59, 59, 999); - filters.dateTo = end.toISOString(); + const iso = fromGermanDate(dateTo); + if (iso) { + const end = new Date(iso); + end.setHours(23, 59, 59, 999); + filters.dateTo = end.toISOString(); + } } if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0]; @@ -336,7 +343,7 @@ function Einsaetze() { { setDateFrom(e.target.value); setPage(0); }} InputLabelProps={{ shrink: true }} @@ -348,7 +355,7 @@ function Einsaetze() { { setDateTo(e.target.value); setPage(0); }} InputLabelProps={{ shrink: true }} diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index 84e109d..f61e325 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -692,18 +692,33 @@ function FahrzeugBuchungen() { InputLabelProps={{ shrink: true }} /> - {availability !== null && ( - : } - label={ - availability - ? 'Fahrzeug verfügbar' - : 'Konflikt: bereits gebucht' - } - color={availability ? 'success' : 'error'} - size="small" - sx={{ alignSelf: 'flex-start' }} - /> + {/* Availability indicator */} + {form.fahrzeugId && form.beginn && form.ende ? ( + + {availability === null ? ( + + + + Verfügbarkeit wird geprüft... + + + ) : ( + : } + label={ + availability + ? 'Fahrzeug verfügbar' + : 'Konflikt: bereits gebucht' + } + color={availability ? 'success' : 'error'} + size="small" + /> + )} + + ) : ( + + Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung + )} @@ -758,25 +773,43 @@ function FahrzeugBuchungen() { - + + + + diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index fe5f9b9..e87cbdd 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -56,6 +56,7 @@ import { import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { vehiclesApi } from '../services/vehicles'; +import { fromGermanDate } from '../utils/dateInput'; import { equipmentApi } from '../services/equipment'; import { FahrzeugDetail, @@ -347,6 +348,7 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde setSaveError(null); await vehiclesApi.addWartungslog(fahrzeugId, { ...form, + datum: fromGermanDate(form.datum) || form.datum, externe_werkstatt: form.externe_werkstatt || undefined, }); setDialogOpen(false); @@ -411,8 +413,8 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde setForm((f) => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} diff --git a/frontend/src/pages/FahrzeugForm.tsx b/frontend/src/pages/FahrzeugForm.tsx index d2827da..15097c7 100644 --- a/frontend/src/pages/FahrzeugForm.tsx +++ b/frontend/src/pages/FahrzeugForm.tsx @@ -18,6 +18,7 @@ import { ArrowBack, Save } from '@mui/icons-material'; import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { vehiclesApi } from '../services/vehicles'; +import { toGermanDate, fromGermanDate } from '../utils/dateInput'; import { FahrzeugStatus, FahrzeugStatusLabel, @@ -64,10 +65,9 @@ const EMPTY_FORM: FormState = { // ── 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 { - if (!iso) return ''; - return iso.slice(0, 10); + return toGermanDate(iso); } // ── Component ───────────────────────────────────────────────────────────────── @@ -169,8 +169,8 @@ function FahrzeugForm() { status_bemerkung: form.status_bemerkung.trim() || undefined, standort: form.standort.trim() || 'Feuerwehrhaus', bild_url: form.bild_url.trim() || undefined, - paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, - naechste_wartung_am: form.naechste_wartung_am || undefined, + paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined, + naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined, }; await vehiclesApi.update(id, payload); navigate(`/fahrzeuge/${id}`); @@ -188,8 +188,8 @@ function FahrzeugForm() { status_bemerkung: form.status_bemerkung.trim() || undefined, standort: form.standort.trim() || 'Feuerwehrhaus', bild_url: form.bild_url.trim() || undefined, - paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, - naechste_wartung_am: form.naechste_wartung_am || undefined, + paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined, + naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined, }; const newVehicle = await vehiclesApi.create(payload); navigate(`/fahrzeuge/${newVehicle.id}`); @@ -314,8 +314,8 @@ function FahrzeugForm() { setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))} InputLabelProps={{ shrink: true }} @@ -325,8 +325,8 @@ function FahrzeugForm() { setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))} InputLabelProps={{ shrink: true }} diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index dbc2c14..dcf1807 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -55,8 +55,11 @@ import { DirectionsCar as CarIcon, Edit as EditIcon, Event as EventIcon, + FileDownload as FileDownloadIcon, + FileUpload as FileUploadIcon, HelpOutline as UnknownIcon, IosShare, + PictureAsPdf as PdfIcon, Star as StarIcon, Today as TodayIcon, Tune, @@ -65,6 +68,7 @@ import { } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput'; import { useAuth } from '../contexts/AuthContext'; import { useNotification } from '../contexts/NotificationContext'; 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()}`; } -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 { + 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(); } @@ -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([]); + const [importing, setImporting] = useState(false); + const [result, setResult] = useState<{ created: number; errors: string[] } | null>(null); + const notification = useNotification(); + const fileRef = React.useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( + + Kalender importieren (CSV) + + + + + + + + + + {rows.length > 0 && ( + <> + + {validRows.length} gültige / {rows.length - validRows.length} fehlerhafte Zeilen + + {rows.some((r) => !r.valid) && ( + + {rows.filter((r) => !r.valid).map((r) => r.error).join('\n')} + + )} + + + + + Status + Titel + Von + Bis + Ganztägig + Ort + + + + {rows.map((row, i) => ( + + + + + {row.titel} + {row.datum_von ? new Date(row.datum_von).toLocaleDateString('de-DE') : '—'} + {row.datum_bis ? new Date(row.datum_bis).toLocaleDateString('de-DE') : '—'} + {row.ganztaegig ? 'Ja' : 'Nein'} + {row.ort ?? '—'} + + ))} + +
+
+ + )} + + {result && ( + + {result.created} Veranstaltung{result.created !== 1 ? 'en' : ''} importiert. + {result.errors.length > 0 && ` ${result.errors.length} Fehler.`} + + )} +
+
+ + + + +
+ ); +} + // ────────────────────────────────────────────────────────────────────────────── // Combined List View (training + events sorted by date) // ────────────────────────────────────────────────────────────────────────────── @@ -874,7 +1194,7 @@ function VeranstaltungFormDialog({ wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis) ? { typ: wiederholungTyp, - bis: wiederholungBis, + bis: fromGermanDate(wiederholungBis) || wiederholungBis, intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined, wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') ? wiederholungWochentag @@ -956,16 +1276,17 @@ function VeranstaltungFormDialog({ /> { + const raw = e.target.value; const iso = form.ganztaegig - ? fromDatetimeLocal(`${e.target.value}T00:00`) - : fromDatetimeLocal(e.target.value); + ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 00:00` : '') + : fromDatetimeLocal(raw); handleChange('datum_von', iso); }} InputLabelProps={{ shrink: true }} @@ -973,16 +1294,17 @@ function VeranstaltungFormDialog({ /> { + const raw = e.target.value; const iso = form.ganztaegig - ? fromDatetimeLocal(`${e.target.value}T23:59`) - : fromDatetimeLocal(e.target.value); + ? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 23:59` : '') + : fromDatetimeLocal(raw); handleChange('datum_bis', iso); }} InputLabelProps={{ shrink: true }} @@ -1039,11 +1361,14 @@ function VeranstaltungFormDialog({ {form.anmeldung_erforderlich && ( - handleChange('anmeldung_bis', e.target.value ? fromDatetimeLocal(e.target.value) : null) - } + placeholder="TT.MM.JJJJ HH:MM" + value={form.anmeldung_bis ? toGermanDateTime(form.anmeldung_bis) : ''} + 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 }} fullWidth /> @@ -1109,8 +1434,8 @@ function VeranstaltungFormDialog({ setWiederholungBis(e.target.value)} InputLabelProps={{ shrink: true }} @@ -1216,6 +1541,7 @@ export default function Kalender() { const [icalEventOpen, setIcalEventOpen] = useState(false); const [icalEventUrl, setIcalEventUrl] = useState(''); const [icalBookingOpen, setIcalBookingOpen] = useState(false); + const [csvImportOpen, setCsvImportOpen] = useState(false); const [icalBookingUrl, setIcalBookingUrl] = useState(''); // ── Data loading ───────────────────────────────────────────────────────────── @@ -1405,8 +1731,8 @@ export default function Kalender() { setBookingForm({ ...EMPTY_BOOKING_FORM, fahrzeugId: vehicleId, - beginn: fnsFormat(day, "yyyy-MM-dd'T'08:00"), - ende: fnsFormat(day, "yyyy-MM-dd'T'17:00"), + beginn: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'08:00")), + ende: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'17:00")), }); setBookingDialogError(null); setAvailability(null); @@ -1420,8 +1746,8 @@ export default function Kalender() { fahrzeugId: detailBooking.fahrzeug_id, titel: detailBooking.titel, beschreibung: '', - beginn: 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"), + beginn: toGermanDateTime(fnsFormat(parseISO(detailBooking.beginn 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, kontaktPerson: '', kontaktTelefon: '', @@ -1440,11 +1766,13 @@ export default function Kalender() { return; } let cancelled = false; + const beginnIso = fromGermanDateTime(bookingForm.beginn) || bookingForm.beginn; + const endeIso = fromGermanDateTime(bookingForm.ende) || bookingForm.ende; bookingApi .checkAvailability( bookingForm.fahrzeugId, - new Date(bookingForm.beginn), - new Date(bookingForm.ende) + new Date(beginnIso), + new Date(endeIso) ) .then(({ available }) => { if (!cancelled) setAvailability(available); @@ -1461,8 +1789,8 @@ export default function Kalender() { try { const payload: CreateBuchungInput = { ...bookingForm, - beginn: new Date(bookingForm.beginn).toISOString(), - ende: new Date(bookingForm.ende).toISOString(), + beginn: (() => { const iso = fromGermanDateTime(bookingForm.beginn); return iso ? new Date(iso).toISOString() : new Date(bookingForm.beginn).toISOString(); })(), + ende: (() => { const iso = fromGermanDateTime(bookingForm.ende); return iso ? new Date(iso).toISOString() : new Date(bookingForm.ende).toISOString(); })(), }; if (editingBooking) { await bookingApi.update(editingBooking.id, payload); @@ -1622,6 +1950,35 @@ export default function Kalender() { )} + {/* PDF Export — only in list view */} + {viewMode === 'list' && ( + + generatePdf( + viewMonth.year, + viewMonth.month, + trainingForMonth, + eventsForMonth, + )} + > + + + + )} + + {/* CSV Import */} + {canWriteEvents && ( + + setCsvImportOpen(true)} + > + + + + )} + {/* iCal subscribe */}