new features, bookstack
This commit is contained in:
17
.env.example
17
.env.example
@@ -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)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
41
backend/src/controllers/bookstack.controller.ts
Normal file
41
backend/src/controllers/bookstack.controller.ts
Normal 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();
|
||||||
@@ -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();
|
||||||
|
|||||||
10
backend/src/routes/bookstack.routes.ts
Normal file
10
backend/src/routes/bookstack.routes.ts
Normal 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;
|
||||||
@@ -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.
|
||||||
|
|||||||
113
backend/src/services/bookstack.service.ts
Normal file
113
backend/src/services/bookstack.service.ts
Normal 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 };
|
||||||
534
frontend/package-lock.json
generated
534
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
138
frontend/src/components/dashboard/BookStackRecentWidget.tsx
Normal file
138
frontend/src/components/dashboard/BookStackRecentWidget.tsx
Normal 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;
|
||||||
152
frontend/src/components/dashboard/BookStackSearchWidget.tsx
Normal file
152
frontend/src/components/dashboard/BookStackSearchWidget.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
76
frontend/src/contexts/ThemeContext.tsx
Normal file
76
frontend/src/contexts/ThemeContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,12 +207,18 @@ 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);
|
||||||
end.setHours(23, 59, 59, 999);
|
if (iso) {
|
||||||
filters.dateTo = end.toISOString();
|
const end = new Date(iso);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
filters.dateTo = end.toISOString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0];
|
if (selectedArts.length === 1) filters.einsatzArt = selectedArts[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 }}
|
||||||
|
|||||||
@@ -692,18 +692,33 @@ function FahrzeugBuchungen() {
|
|||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{availability !== null && (
|
{/* Availability indicator */}
|
||||||
<Chip
|
{form.fahrzeugId && form.beginn && form.ende ? (
|
||||||
icon={availability ? <CheckCircle /> : <Warning />}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
label={
|
{availability === null ? (
|
||||||
availability
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
? 'Fahrzeug verfügbar'
|
<CircularProgress size={16} />
|
||||||
: 'Konflikt: bereits gebucht'
|
<Typography variant="body2" color="text.secondary">
|
||||||
}
|
Verfügbarkeit wird geprüft...
|
||||||
color={availability ? 'success' : 'error'}
|
</Typography>
|
||||||
size="small"
|
</Box>
|
||||||
sx={{ alignSelf: 'flex-start' }}
|
) : (
|
||||||
/>
|
<Chip
|
||||||
|
icon={availability ? <CheckCircle /> : <Warning />}
|
||||||
|
label={
|
||||||
|
availability
|
||||||
|
? 'Fahrzeug verfügbar'
|
||||||
|
: 'Konflikt: bereits gebucht'
|
||||||
|
}
|
||||||
|
color={availability ? 'success' : 'error'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
@@ -758,25 +773,43 @@ function FahrzeugBuchungen() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||||
<Button
|
<Tooltip
|
||||||
variant="contained"
|
title={
|
||||||
onClick={handleSave}
|
dialogLoading || (form.titel && form.fahrzeugId && form.beginn && form.ende)
|
||||||
disabled={
|
? ''
|
||||||
dialogLoading ||
|
: `Bitte fülle alle Pflichtfelder aus: ${[
|
||||||
!form.titel ||
|
!form.titel && 'Titel',
|
||||||
!form.fahrzeugId ||
|
!form.fahrzeugId && 'Fahrzeug',
|
||||||
!form.beginn ||
|
!form.beginn && 'Beginn',
|
||||||
!form.ende
|
!form.ende && 'Ende',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}`
|
||||||
}
|
}
|
||||||
|
disableHoverListener={!!(form.titel && form.fahrzeugId && form.beginn && form.ende)}
|
||||||
>
|
>
|
||||||
{dialogLoading ? (
|
<span>
|
||||||
<CircularProgress size={20} />
|
<Button
|
||||||
) : editingBooking ? (
|
variant="contained"
|
||||||
'Speichern'
|
onClick={handleSave}
|
||||||
) : (
|
disabled={
|
||||||
'Buchen'
|
dialogLoading ||
|
||||||
)}
|
!form.titel ||
|
||||||
</Button>
|
!form.fahrzeugId ||
|
||||||
|
!form.beginn ||
|
||||||
|
!form.ende
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dialogLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : editingBooking ? (
|
||||||
|
'Speichern'
|
||||||
|
) : (
|
||||||
|
'Buchen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,8 +651,7 @@ const AuditLog: React.FC = () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={de}>
|
<DashboardLayout>
|
||||||
<DashboardLayout>
|
|
||||||
<Container maxWidth="xl">
|
<Container maxWidth="xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Stack
|
<Stack
|
||||||
@@ -726,7 +739,6 @@ const AuditLog: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
</LocalizationProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
24
frontend/src/services/bookstack.ts
Normal file
24
frontend/src/services/bookstack.ts
Normal 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 }));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
36
frontend/src/types/bookstack.types.ts
Normal file
36
frontend/src/types/bookstack.types.ts
Normal 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[];
|
||||||
|
}
|
||||||
60
frontend/src/utils/dateInput.ts
Normal file
60
frontend/src/utils/dateInput.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user