update
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import atemschutzService from '../services/atemschutz.service';
|
import atemschutzService from '../services/atemschutz.service';
|
||||||
|
import notificationService from '../services/notification.service';
|
||||||
import { CreateAtemschutzSchema, UpdateAtemschutzSchema } from '../models/atemschutz.model';
|
import { CreateAtemschutzSchema, UpdateAtemschutzSchema } from '../models/atemschutz.model';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
@@ -159,6 +160,49 @@ class AtemschutzController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getExpiring(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const expiring = await atemschutzService.getExpiringCertifications(30);
|
||||||
|
|
||||||
|
// Side-effect: create notifications for expiring certifications (dedup via DB constraint)
|
||||||
|
for (const item of expiring) {
|
||||||
|
if (item.untersuchung_status !== 'ok') {
|
||||||
|
await notificationService.createNotification({
|
||||||
|
user_id: item.user_id,
|
||||||
|
typ: 'atemschutz_expiry',
|
||||||
|
titel: item.untersuchung_status === 'abgelaufen'
|
||||||
|
? 'G26 Untersuchung abgelaufen'
|
||||||
|
: 'G26 Untersuchung läuft bald ab',
|
||||||
|
nachricht: `Ihre G26 Untersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
|
||||||
|
schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung',
|
||||||
|
quell_typ: 'atemschutz_untersuchung',
|
||||||
|
quell_id: item.id,
|
||||||
|
link: '/atemschutz',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.leistungstest_status !== 'ok') {
|
||||||
|
await notificationService.createNotification({
|
||||||
|
user_id: item.user_id,
|
||||||
|
typ: 'atemschutz_expiry',
|
||||||
|
titel: item.leistungstest_status === 'abgelaufen'
|
||||||
|
? 'Leistungstest abgelaufen'
|
||||||
|
: 'Leistungstest läuft bald ab',
|
||||||
|
nachricht: `Ihr Leistungstest ${item.leistungstest_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
|
||||||
|
schwere: item.leistungstest_status === 'abgelaufen' ? 'fehler' : 'warnung',
|
||||||
|
quell_typ: 'atemschutz_leistungstest',
|
||||||
|
quell_id: item.id,
|
||||||
|
link: '/atemschutz',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: expiring });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Atemschutz getExpiring error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ablaufende Zertifizierungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async delete(req: Request, res: Response): Promise<void> {
|
async delete(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import bookingService from '../services/booking.service';
|
import bookingService from '../services/booking.service';
|
||||||
|
import vehicleService from '../services/vehicle.service';
|
||||||
import {
|
import {
|
||||||
CreateBuchungSchema,
|
CreateBuchungSchema,
|
||||||
UpdateBuchungSchema,
|
UpdateBuchungSchema,
|
||||||
@@ -52,12 +53,13 @@ class BookingController {
|
|||||||
res.status(400).json({ success: false, message: 'from und to sind erforderlich' });
|
res.status(400).json({ success: false, message: 'from und to sind erforderlich' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bookings = await bookingService.getBookingsByRange(
|
const fromDate = new Date(from as string);
|
||||||
new Date(from as string),
|
const toDate = new Date(to as string);
|
||||||
new Date(to as string),
|
const [bookings, maintenanceWindows] = await Promise.all([
|
||||||
fahrzeugId as string | undefined
|
bookingService.getBookingsByRange(fromDate, toDate, fahrzeugId as string | undefined),
|
||||||
);
|
vehicleService.getMaintenanceWindows(fromDate, toDate),
|
||||||
res.json({ success: true, data: bookings });
|
]);
|
||||||
|
res.json({ success: true, data: { bookings, maintenanceWindows } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Booking getCalendarRange error', { error });
|
logger.error('Booking getCalendarRange error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
|
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
|
||||||
|
|||||||
@@ -118,6 +118,39 @@ class EventsController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/conflicts?from=<ISO>&to=<ISO>&excludeId=<uuid>
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
checkConflicts = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const fromStr = req.query.from as string | undefined;
|
||||||
|
const toStr = req.query.to as string | undefined;
|
||||||
|
const excludeId = req.query.excludeId as string | undefined;
|
||||||
|
|
||||||
|
if (!fromStr || !toStr) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = new Date(fromStr);
|
||||||
|
const to = new Date(toStr);
|
||||||
|
|
||||||
|
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await eventsService.checkConflicts(from, to, excludeId);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('checkConflicts error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler bei der Konfliktprüfung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// GET /api/events/calendar?from=<ISO>&to=<ISO>
|
// GET /api/events/calendar?from=<ISO>&to=<ISO>
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,8 +21,37 @@ const broadcastSchema = z.object({
|
|||||||
nachricht: z.string().min(1).max(2000),
|
nachricht: z.string().min(1).max(2000),
|
||||||
schwere: z.enum(['info', 'warnung', 'fehler']).default('info'),
|
schwere: z.enum(['info', 'warnung', 'fehler']).default('info'),
|
||||||
targetGroup: z.string().optional(),
|
targetGroup: z.string().optional(),
|
||||||
|
targetDienstgrad: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const broadcastFilterSchema = z.object({
|
||||||
|
targetGroup: z.string().optional(),
|
||||||
|
targetDienstgrad: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildFilteredUserQuery(filters: { targetGroup?: string; targetDienstgrad?: string[] }): { text: string; values: unknown[] } {
|
||||||
|
const conditions: string[] = ['u.is_active = TRUE'];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.targetGroup) {
|
||||||
|
conditions.push(`$${paramIndex} = ANY(u.authentik_groups)`);
|
||||||
|
values.push(filters.targetGroup);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.targetDienstgrad && filters.targetDienstgrad.length > 0) {
|
||||||
|
conditions.push(`mp.dienstgrad = ANY($${paramIndex})`);
|
||||||
|
values.push(filters.targetDienstgrad);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsJoin = filters.targetDienstgrad && filters.targetDienstgrad.length > 0;
|
||||||
|
const text = `SELECT DISTINCT u.id FROM users u${needsJoin ? ' LEFT JOIN member_profiles mp ON mp.user_id = u.id' : ''} WHERE ${conditions.join(' AND ')}`;
|
||||||
|
|
||||||
|
return { text, values };
|
||||||
|
}
|
||||||
|
|
||||||
class ServiceMonitorController {
|
class ServiceMonitorController {
|
||||||
async getAll(_req: Request, res: Response): Promise<void> {
|
async getAll(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -160,21 +189,11 @@ class ServiceMonitorController {
|
|||||||
|
|
||||||
async broadcastNotification(req: Request, res: Response): Promise<void> {
|
async broadcastNotification(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body);
|
const { titel, nachricht, schwere, targetGroup, targetDienstgrad } = broadcastSchema.parse(req.body);
|
||||||
|
|
||||||
let users;
|
const query = buildFilteredUserQuery({ targetGroup, targetDienstgrad });
|
||||||
if (targetGroup) {
|
const result = await pool.query(query.text, query.values);
|
||||||
const result = await pool.query(
|
const users = result.rows;
|
||||||
`SELECT id FROM users WHERE is_active = TRUE AND $1 = ANY(authentik_groups)`,
|
|
||||||
[targetGroup]
|
|
||||||
);
|
|
||||||
users = result.rows;
|
|
||||||
} else {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT id FROM users WHERE is_active = TRUE`
|
|
||||||
);
|
|
||||||
users = result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
@@ -198,6 +217,24 @@ class ServiceMonitorController {
|
|||||||
res.status(500).json({ success: false, message: 'Failed to broadcast notification' });
|
res.status(500).json({ success: false, message: 'Failed to broadcast notification' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async broadcastPreview(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { targetGroup, targetDienstgrad } = broadcastFilterSchema.parse(req.body);
|
||||||
|
|
||||||
|
const query = buildFilteredUserQuery({ targetGroup, targetDienstgrad });
|
||||||
|
const result = await pool.query(query.text, query.values);
|
||||||
|
|
||||||
|
res.json({ success: true, data: { count: result.rows.length } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Failed to preview broadcast', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to preview broadcast' });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ServiceMonitorController();
|
export default new ServiceMonitorController();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import vehicleService from '../services/vehicle.service';
|
import vehicleService from '../services/vehicle.service';
|
||||||
|
import equipmentService from '../services/equipment.service';
|
||||||
import { FahrzeugStatus } from '../models/vehicle.model';
|
import { FahrzeugStatus } from '../models/vehicle.model';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
@@ -140,6 +141,69 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportAlerts(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const escape = (v: unknown): string => {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
const str = String(v);
|
||||||
|
let safe = str.replace(/"/g, '""');
|
||||||
|
if (/^[=+@\-]/.test(safe)) safe = "'" + safe;
|
||||||
|
return `"${safe}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (d: Date | string | null): string => {
|
||||||
|
if (!d) return '';
|
||||||
|
const date = typeof d === 'string' ? new Date(d) : d;
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [vehicleAlerts, equipmentAlerts] = await Promise.all([
|
||||||
|
vehicleService.getUpcomingInspections(365),
|
||||||
|
equipmentService.getUpcomingInspections(365),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const header = 'Typ,Bezeichnung,Kurzname,Prüfungsart,Fällig am,Tage verbleibend';
|
||||||
|
const rows: string[] = [];
|
||||||
|
|
||||||
|
for (const a of vehicleAlerts) {
|
||||||
|
const pruefungsart = a.type === '57a' ? '§57a Überprüfung' : 'Nächste Wartung';
|
||||||
|
rows.push([
|
||||||
|
escape('Fahrzeug'),
|
||||||
|
escape(a.bezeichnung),
|
||||||
|
escape(a.kurzname),
|
||||||
|
escape(pruefungsart),
|
||||||
|
escape(formatDate(a.faelligAm)),
|
||||||
|
escape(a.tage),
|
||||||
|
].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of equipmentAlerts) {
|
||||||
|
rows.push([
|
||||||
|
escape('Ausrüstung'),
|
||||||
|
escape(e.bezeichnung),
|
||||||
|
escape(''),
|
||||||
|
escape('Prüfung'),
|
||||||
|
escape(formatDate(e.naechste_pruefung_am)),
|
||||||
|
escape(e.pruefung_tage_bis_faelligkeit),
|
||||||
|
].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
const csv = '\uFEFF' + header + '\n' + rows.join('\n');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="pruefungen_${dateStr}.csv"`);
|
||||||
|
res.status(200).send(csv);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('exportAlerts error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Prüfungsexport konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getVehicle(req: Request, res: Response): Promise<void> {
|
async getVehicle(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const router = Router();
|
|||||||
|
|
||||||
router.get('/', authenticate, atemschutzController.list.bind(atemschutzController));
|
router.get('/', authenticate, atemschutzController.list.bind(atemschutzController));
|
||||||
router.get('/stats', authenticate, atemschutzController.getStats.bind(atemschutzController));
|
router.get('/stats', authenticate, atemschutzController.getStats.bind(atemschutzController));
|
||||||
|
router.get('/expiring', authenticate, atemschutzController.getExpiring.bind(atemschutzController));
|
||||||
router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController));
|
router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController));
|
||||||
router.get('/user/:userId', authenticate, atemschutzController.getByUserId.bind(atemschutzController));
|
router.get('/user/:userId', authenticate, atemschutzController.getByUserId.bind(atemschutzController));
|
||||||
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
|
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ router.delete(
|
|||||||
*/
|
*/
|
||||||
router.get('/groups', authenticate, eventsController.getAvailableGroups.bind(eventsController));
|
router.get('/groups', authenticate, eventsController.getAvailableGroups.bind(eventsController));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conflict check — must come before /:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/conflicts?from=<ISO>&to=<ISO>&excludeId=<uuid>
|
||||||
|
* Check for overlapping events in the given time range.
|
||||||
|
*/
|
||||||
|
router.get('/conflicts', authenticate, eventsController.checkConflicts.bind(eventsController));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Calendar & upcoming — specific routes must come before /:id
|
// Calendar & upcoming — specific routes must come before /:id
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ router.delete('/services/:id', ...auth, serviceMonitorController.delete.bind(ser
|
|||||||
router.get('/system/health', ...auth, serviceMonitorController.getSystemHealth.bind(serviceMonitorController));
|
router.get('/system/health', ...auth, serviceMonitorController.getSystemHealth.bind(serviceMonitorController));
|
||||||
router.get('/users', ...auth, serviceMonitorController.getUsers.bind(serviceMonitorController));
|
router.get('/users', ...auth, serviceMonitorController.getUsers.bind(serviceMonitorController));
|
||||||
router.post('/notifications/broadcast', ...auth, serviceMonitorController.broadcastNotification.bind(serviceMonitorController));
|
router.post('/notifications/broadcast', ...auth, serviceMonitorController.broadcastNotification.bind(serviceMonitorController));
|
||||||
|
router.post('/notifications/broadcast/preview', ...auth, serviceMonitorController.broadcastPreview.bind(serviceMonitorController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const router = Router();
|
|||||||
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
||||||
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
||||||
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
|
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
|
||||||
|
router.get('/alerts/export', authenticate, requirePermission('vehicles:read'), vehicleController.exportAlerts.bind(vehicleController));
|
||||||
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
||||||
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,45 @@ class AtemschutzService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// EXPIRING CERTIFICATIONS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async getExpiringCertifications(daysAhead = 30): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
at.id, at.user_id, u.email,
|
||||||
|
COALESCE(u.name, u.email) as user_name,
|
||||||
|
at.untersuchung_gueltig_bis,
|
||||||
|
at.leistungstest_gueltig_bis,
|
||||||
|
CASE
|
||||||
|
WHEN at.untersuchung_gueltig_bis < CURRENT_DATE THEN 'abgelaufen'
|
||||||
|
WHEN at.untersuchung_gueltig_bis <= CURRENT_DATE + $1 THEN 'bald_faellig'
|
||||||
|
ELSE 'ok'
|
||||||
|
END as untersuchung_status,
|
||||||
|
CASE
|
||||||
|
WHEN at.leistungstest_gueltig_bis < CURRENT_DATE THEN 'abgelaufen'
|
||||||
|
WHEN at.leistungstest_gueltig_bis <= CURRENT_DATE + $1 THEN 'bald_faellig'
|
||||||
|
ELSE 'ok'
|
||||||
|
END as leistungstest_status
|
||||||
|
FROM atemschutz_traeger at
|
||||||
|
JOIN users u ON u.id = at.user_id
|
||||||
|
WHERE at.einsatzbereit = TRUE
|
||||||
|
AND (
|
||||||
|
at.untersuchung_gueltig_bis <= CURRENT_DATE + $1
|
||||||
|
OR at.leistungstest_gueltig_bis <= CURRENT_DATE + $1
|
||||||
|
)
|
||||||
|
ORDER BY LEAST(at.untersuchung_gueltig_bis, at.leistungstest_gueltig_bis) ASC
|
||||||
|
`, [`${daysAhead} days`]);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AtemschutzService.getExpiringCertifications fehlgeschlagen', { error });
|
||||||
|
throw new Error('Ablaufende Atemschutz-Zertifizierungen konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// DASHBOARD KPI / STATISTIKEN
|
// DASHBOARD KPI / STATISTIKEN
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import pool from '../config/database';
|
import pool from '../config/database';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
import notificationService from './notification.service';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Enums — kept as const objects rather than TypeScript enums so that the
|
// Enums — kept as const objects rather than TypeScript enums so that the
|
||||||
@@ -188,6 +189,9 @@ class AuditService {
|
|||||||
resource_id: entry.resource_id,
|
resource_id: entry.resource_id,
|
||||||
user_id: entry.user_id,
|
user_id: entry.user_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget — never block the audit log write
|
||||||
|
this.alertAdminsIfSensitive(entry).catch(() => {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// GDPR obligation: log the failure so it can be investigated, but
|
// GDPR obligation: log the failure so it can be investigated, but
|
||||||
// NEVER propagate — the main request must complete successfully.
|
// NEVER propagate — the main request must complete successfully.
|
||||||
@@ -203,6 +207,64 @@ class AuditService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Audit alert notifications for admins
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* alertAdminsIfSensitive — checks whether an audit entry represents a
|
||||||
|
* sensitive action and, if so, creates a notification for every active
|
||||||
|
* admin user (except the actor themselves).
|
||||||
|
*
|
||||||
|
* This method MUST NEVER throw. All errors are caught and logged.
|
||||||
|
*/
|
||||||
|
async alertAdminsIfSensitive(entry: AuditLogInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
const sensitiveActions: Record<string, string> = {
|
||||||
|
'PERMISSION_DENIED': 'Zugriff verweigert',
|
||||||
|
'DELETE': 'Datensatz gelöscht',
|
||||||
|
'ROLE_CHANGE': 'Rolle geändert',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUserUpdate =
|
||||||
|
entry.resource_type === AuditResourceType.USER &&
|
||||||
|
entry.action === AuditAction.UPDATE;
|
||||||
|
const isSensitive = sensitiveActions[entry.action] || isUserUpdate;
|
||||||
|
|
||||||
|
if (!isSensitive) return;
|
||||||
|
|
||||||
|
// Get all active admin users
|
||||||
|
const { rows: admins } = await pool.query(
|
||||||
|
"SELECT id FROM users WHERE is_active = TRUE AND 'dashboard_admin' = ANY(authentik_groups)"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (admins.length === 0) return;
|
||||||
|
|
||||||
|
const titel = sensitiveActions[entry.action] || 'Benutzer-Änderung';
|
||||||
|
const nachricht = `${entry.action} auf ${entry.resource_type}${entry.resource_id ? ' ' + entry.resource_id : ''} durch ${entry.user_email ?? 'System'}`;
|
||||||
|
|
||||||
|
for (const admin of admins) {
|
||||||
|
// Don't notify the admin about their own actions
|
||||||
|
if (admin.id === entry.user_id) continue;
|
||||||
|
|
||||||
|
await notificationService.createNotification({
|
||||||
|
user_id: admin.id,
|
||||||
|
typ: 'audit_alert',
|
||||||
|
titel,
|
||||||
|
nachricht,
|
||||||
|
schwere: entry.action === 'PERMISSION_DENIED' ? 'warnung' : 'info',
|
||||||
|
quell_typ: 'audit_alert',
|
||||||
|
quell_id: `${entry.action}_${entry.resource_type}_${entry.resource_id ?? Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('alertAdminsIfSensitive failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
action: entry.action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Query — admin UI
|
// Query — admin UI
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -614,6 +614,48 @@ class EventsService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CONFLICT CHECK
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns events that overlap with the given time range.
|
||||||
|
* Used to warn users about scheduling conflicts before creating/updating events.
|
||||||
|
*/
|
||||||
|
async checkConflicts(
|
||||||
|
datumVon: Date,
|
||||||
|
datumBis: Date,
|
||||||
|
excludeId?: string
|
||||||
|
): Promise<Array<{ id: string; titel: string; datum_von: Date; datum_bis: Date; kategorie_name: string | null }>> {
|
||||||
|
const params: any[] = [datumVon, datumBis];
|
||||||
|
let excludeClause = '';
|
||||||
|
if (excludeId) {
|
||||||
|
excludeClause = ' AND v.id != $3';
|
||||||
|
params.push(excludeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT v.id, v.titel, v.datum_von, v.datum_bis,
|
||||||
|
k.name AS kategorie_name
|
||||||
|
FROM veranstaltungen v
|
||||||
|
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||||
|
WHERE v.abgesagt = FALSE
|
||||||
|
AND ($1::timestamptz, $2::timestamptz) OVERLAPS (v.datum_von, v.datum_bis)
|
||||||
|
${excludeClause}
|
||||||
|
ORDER BY v.datum_von ASC
|
||||||
|
LIMIT 10`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
titel: row.titel,
|
||||||
|
datum_von: new Date(row.datum_von),
|
||||||
|
datum_bis: new Date(row.datum_bis),
|
||||||
|
kategorie_name: row.kategorie_name ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// ICAL EXPORT
|
// ICAL EXPORT
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -406,6 +406,43 @@ class VehicleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// MAINTENANCE WINDOWS (for booking calendar overlay)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async getMaintenanceWindows(
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
bezeichnung: string;
|
||||||
|
kurzname: string | null;
|
||||||
|
status: string;
|
||||||
|
status_bemerkung: string | null;
|
||||||
|
ausser_dienst_von: string;
|
||||||
|
ausser_dienst_bis: string;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, bezeichnung, kurzname, status, status_bemerkung,
|
||||||
|
ausser_dienst_von, ausser_dienst_bis
|
||||||
|
FROM fahrzeuge
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND status IN ('ausser_dienst_wartung', 'ausser_dienst_schaden')
|
||||||
|
AND ausser_dienst_von IS NOT NULL
|
||||||
|
AND ausser_dienst_bis IS NOT NULL
|
||||||
|
AND (ausser_dienst_von, ausser_dienst_bis) OVERLAPS ($1, $2)`,
|
||||||
|
[from, to]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VehicleService.getMaintenanceWindows failed', { error });
|
||||||
|
throw new Error('Failed to fetch maintenance windows');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// DASHBOARD KPI
|
// DASHBOARD KPI
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -11,20 +11,49 @@ import {
|
|||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
OutlinedInput,
|
||||||
|
InputLabel,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import type { SelectChangeEvent } from '@mui/material';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import PeopleIcon from '@mui/icons-material/People';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { adminApi } from '../../services/admin';
|
import { adminApi } from '../../services/admin';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
import type { BroadcastPayload } from '../../types/admin.types';
|
import type { BroadcastPayload } from '../../types/admin.types';
|
||||||
|
|
||||||
|
const DIENSTGRAD_OPTIONS = [
|
||||||
|
'Mitglied',
|
||||||
|
'Maschinist',
|
||||||
|
'Truppführer',
|
||||||
|
'Gruppenführer',
|
||||||
|
'Zugkommandant',
|
||||||
|
'Kommandant',
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_OPTIONS = [
|
||||||
|
'dashboard_admin',
|
||||||
|
'dashboard_kommando',
|
||||||
|
'dashboard_gruppenfuehrer',
|
||||||
|
];
|
||||||
|
|
||||||
function NotificationBroadcastTab() {
|
function NotificationBroadcastTab() {
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const [titel, setTitel] = useState('');
|
const [titel, setTitel] = useState('');
|
||||||
const [nachricht, setNachricht] = useState('');
|
const [nachricht, setNachricht] = useState('');
|
||||||
const [schwere, setSchwere] = useState<'info' | 'warnung' | 'fehler'>('info');
|
const [schwere, setSchwere] = useState<'info' | 'warnung' | 'fehler'>('info');
|
||||||
const [targetGroup, setTargetGroup] = useState('');
|
const [targetGroup, setTargetGroup] = useState('');
|
||||||
|
const [targetDienstgrad, setTargetDienstgrad] = useState<string[]>([]);
|
||||||
|
const [alleBenutzer, setAlleBenutzer] = useState(true);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [previewCount, setPreviewCount] = useState<number | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const broadcastMutation = useMutation({
|
const broadcastMutation = useMutation({
|
||||||
mutationFn: (data: BroadcastPayload) => adminApi.broadcast(data),
|
mutationFn: (data: BroadcastPayload) => adminApi.broadcast(data),
|
||||||
@@ -34,26 +63,74 @@ function NotificationBroadcastTab() {
|
|||||||
setNachricht('');
|
setNachricht('');
|
||||||
setSchwere('info');
|
setSchwere('info');
|
||||||
setTargetGroup('');
|
setTargetGroup('');
|
||||||
|
setTargetDienstgrad([]);
|
||||||
|
setAlleBenutzer(true);
|
||||||
|
setPreviewCount(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showError('Fehler beim Senden der Benachrichtigung');
|
showError('Fehler beim Senden der Benachrichtigung');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fetchPreview = useCallback(() => {
|
||||||
|
if (alleBenutzer) {
|
||||||
|
// For "Alle Benutzer" we still fetch the count (no filters)
|
||||||
|
setPreviewLoading(true);
|
||||||
|
adminApi.broadcastPreview({})
|
||||||
|
.then((result) => setPreviewCount(result.count))
|
||||||
|
.catch(() => setPreviewCount(null))
|
||||||
|
.finally(() => setPreviewLoading(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: { targetGroup?: string; targetDienstgrad?: string[] } = {};
|
||||||
|
if (targetGroup.trim()) payload.targetGroup = targetGroup.trim();
|
||||||
|
if (targetDienstgrad.length > 0) payload.targetDienstgrad = targetDienstgrad;
|
||||||
|
|
||||||
|
setPreviewLoading(true);
|
||||||
|
adminApi.broadcastPreview(payload)
|
||||||
|
.then((result) => setPreviewCount(result.count))
|
||||||
|
.catch(() => setPreviewCount(null))
|
||||||
|
.finally(() => setPreviewLoading(false));
|
||||||
|
}, [alleBenutzer, targetGroup, targetDienstgrad]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(fetchPreview, 300);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [fetchPreview]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
setConfirmOpen(true);
|
setConfirmOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
broadcastMutation.mutate({
|
const payload: BroadcastPayload = { titel, nachricht, schwere };
|
||||||
titel,
|
if (!alleBenutzer) {
|
||||||
nachricht,
|
if (targetGroup.trim()) payload.targetGroup = targetGroup.trim();
|
||||||
schwere,
|
if (targetDienstgrad.length > 0) payload.targetDienstgrad = targetDienstgrad;
|
||||||
...(targetGroup.trim() ? { targetGroup: targetGroup.trim() } : {}),
|
}
|
||||||
});
|
broadcastMutation.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDienstgradChange = (event: SelectChangeEvent<string[]>) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setTargetDienstgrad(typeof value === 'string' ? value.split(',') : value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtersActive = !alleBenutzer && (targetGroup.trim() || targetDienstgrad.length > 0);
|
||||||
|
|
||||||
|
const filterDescription = (() => {
|
||||||
|
if (alleBenutzer) return 'alle aktiven Benutzer';
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (targetGroup.trim()) parts.push(`Gruppe "${targetGroup.trim()}"`);
|
||||||
|
if (targetDienstgrad.length > 0) parts.push(`Dienstgrad: ${targetDienstgrad.join(', ')}`);
|
||||||
|
return parts.length > 0 ? parts.join(' + ') : 'alle aktiven Benutzer';
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 600 }}>
|
<Box sx={{ maxWidth: 600 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>Benachrichtigung senden</Typography>
|
<Typography variant="h6" sx={{ mb: 2 }}>Benachrichtigung senden</Typography>
|
||||||
@@ -91,14 +168,76 @@ function NotificationBroadcastTab() {
|
|||||||
<MenuItem value="fehler">Fehler</MenuItem>
|
<MenuItem value="fehler">Fehler</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={alleBenutzer}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAlleBenutzer(e.target.checked);
|
||||||
|
if (e.target.checked) {
|
||||||
|
setTargetGroup('');
|
||||||
|
setTargetDienstgrad([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Alle Benutzer"
|
||||||
|
sx={{ mb: 2, display: 'block' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!alleBenutzer && (
|
||||||
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
label="Zielgruppe (optional)"
|
select
|
||||||
|
label="Authentik-Gruppe"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={targetGroup}
|
value={targetGroup}
|
||||||
onChange={(e) => setTargetGroup(e.target.value)}
|
onChange={(e) => setTargetGroup(e.target.value)}
|
||||||
helperText="Leer lassen um an alle aktiven Benutzer zu senden"
|
sx={{ mb: 2 }}
|
||||||
sx={{ mb: 3 }}
|
>
|
||||||
/>
|
<MenuItem value="">
|
||||||
|
<em>Keine Einschraenkung</em>
|
||||||
|
</MenuItem>
|
||||||
|
{GROUP_OPTIONS.map((g) => (
|
||||||
|
<MenuItem key={g} value={g}>{g}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||||
|
<InputLabel>Dienstgrad</InputLabel>
|
||||||
|
<Select
|
||||||
|
multiple
|
||||||
|
value={targetDienstgrad}
|
||||||
|
onChange={handleDienstgradChange}
|
||||||
|
input={<OutlinedInput label="Dienstgrad" />}
|
||||||
|
renderValue={(selected) => (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
{selected.map((value) => (
|
||||||
|
<Chip key={value} label={value} size="small" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{DIENSTGRAD_OPTIONS.map((d) => (
|
||||||
|
<MenuItem key={d} value={d}>{d}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<PeopleIcon color="action" fontSize="small" />
|
||||||
|
{previewLoading ? (
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{previewCount !== null
|
||||||
|
? `Wird an ${previewCount} Benutzer gesendet`
|
||||||
|
: 'Empfaengeranzahl wird geladen...'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -113,8 +252,10 @@ function NotificationBroadcastTab() {
|
|||||||
<DialogTitle>Benachrichtigung senden?</DialogTitle>
|
<DialogTitle>Benachrichtigung senden?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
Sind Sie sicher, dass Sie diese Benachrichtigung
|
Sind Sie sicher, dass Sie diese Benachrichtigung an {filterDescription} senden moechten?
|
||||||
{targetGroup.trim() ? ` an die Gruppe "${targetGroup.trim()}"` : ' an alle aktiven Benutzer'} senden moechten?
|
{previewCount !== null && (
|
||||||
|
<> ({previewCount} {previewCount === 1 ? 'Empfaenger' : 'Empfaenger'})</>
|
||||||
|
)}
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { atemschutzApi } from '../../services/atemschutz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invisible component — polls /api/atemschutz/expiring every 10 minutes.
|
||||||
|
* The backend creates notifications as a side-effect when polled.
|
||||||
|
*/
|
||||||
|
const AtemschutzExpiryNotifier: React.FC = () => {
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['atemschutz-expiring'],
|
||||||
|
queryFn: () => atemschutzApi.getExpiring(),
|
||||||
|
refetchInterval: 10 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AtemschutzExpiryNotifier;
|
||||||
@@ -9,6 +9,7 @@ export { default as BookStackSearchWidget } from './BookStackSearchWidget';
|
|||||||
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
|
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
|
||||||
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
|
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
|
||||||
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
|
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
|
||||||
|
export { default as AtemschutzExpiryNotifier } from './AtemschutzExpiryNotifier';
|
||||||
export { default as AdminStatusWidget } from './AdminStatusWidget';
|
export { default as AdminStatusWidget } from './AdminStatusWidget';
|
||||||
export { default as VehicleBookingListWidget } from './VehicleBookingListWidget';
|
export { default as VehicleBookingListWidget } from './VehicleBookingListWidget';
|
||||||
export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
|
export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget
|
|||||||
import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget';
|
import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget';
|
||||||
import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget';
|
import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget';
|
||||||
import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier';
|
import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier';
|
||||||
|
import AtemschutzExpiryNotifier from '../components/dashboard/AtemschutzExpiryNotifier';
|
||||||
import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
|
import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
|
||||||
import AnnouncementBanner from '../components/dashboard/AnnouncementBanner';
|
import AnnouncementBanner from '../components/dashboard/AnnouncementBanner';
|
||||||
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
|
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
|
||||||
@@ -72,6 +73,8 @@ function Dashboard() {
|
|||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
{/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */}
|
{/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */}
|
||||||
<VikunjaOverdueNotifier />
|
<VikunjaOverdueNotifier />
|
||||||
|
{/* Atemschutz — Expiry Notifier (invisible, polling component — outside grid) */}
|
||||||
|
<AtemschutzExpiryNotifier />
|
||||||
<Container maxWidth={false} disableGutters>
|
<Container maxWidth={false} disableGutters>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Warning,
|
Warning,
|
||||||
Block,
|
Block,
|
||||||
|
Build,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
@@ -52,6 +53,7 @@ import type {
|
|||||||
Fahrzeug,
|
Fahrzeug,
|
||||||
CreateBuchungInput,
|
CreateBuchungInput,
|
||||||
BuchungsArt,
|
BuchungsArt,
|
||||||
|
MaintenanceWindow,
|
||||||
} from '../types/booking.types';
|
} from '../types/booking.types';
|
||||||
import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types';
|
import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types';
|
||||||
import {
|
import {
|
||||||
@@ -116,6 +118,7 @@ function FahrzeugBuchungen() {
|
|||||||
// ── Data ──────────────────────────────────────────────────────────────────
|
// ── Data ──────────────────────────────────────────────────────────────────
|
||||||
const [vehicles, setVehicles] = useState<Fahrzeug[]>([]);
|
const [vehicles, setVehicles] = useState<Fahrzeug[]>([]);
|
||||||
const [bookings, setBookings] = useState<FahrzeugBuchungListItem[]>([]);
|
const [bookings, setBookings] = useState<FahrzeugBuchungListItem[]>([]);
|
||||||
|
const [maintenanceWindows, setMaintenanceWindows] = useState<MaintenanceWindow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -123,12 +126,13 @@ function FahrzeugBuchungen() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 });
|
const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 });
|
||||||
const [vehiclesData, bookingsData] = await Promise.all([
|
const [vehiclesData, calendarData] = await Promise.all([
|
||||||
fetchVehicles(),
|
fetchVehicles(),
|
||||||
bookingApi.getCalendarRange(currentWeekStart, end),
|
bookingApi.getCalendarRange(currentWeekStart, end),
|
||||||
]);
|
]);
|
||||||
setVehicles(vehiclesData);
|
setVehicles(vehiclesData);
|
||||||
setBookings(bookingsData);
|
setBookings(calendarData.bookings);
|
||||||
|
setMaintenanceWindows(calendarData.maintenanceWindows);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : 'Fehler beim Laden';
|
const msg = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||||
@@ -160,6 +164,22 @@ function FahrzeugBuchungen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isOutOfService = (vehicle: Fahrzeug, day: Date): boolean => {
|
const isOutOfService = (vehicle: Fahrzeug, day: Date): boolean => {
|
||||||
|
// Check from maintenance windows (server-side filtered)
|
||||||
|
const mw = maintenanceWindows.find((w) => w.id === vehicle.id);
|
||||||
|
if (mw) {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
isWithinInterval(day, {
|
||||||
|
start: parseISO(mw.ausser_dienst_von),
|
||||||
|
end: parseISO(mw.ausser_dienst_bis),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* ignore parse errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to vehicle-level dates
|
||||||
if (!vehicle.ausser_dienst_von || !vehicle.ausser_dienst_bis) return false;
|
if (!vehicle.ausser_dienst_von || !vehicle.ausser_dienst_bis) return false;
|
||||||
try {
|
try {
|
||||||
return isWithinInterval(day, {
|
return isWithinInterval(day, {
|
||||||
@@ -171,6 +191,29 @@ function FahrzeugBuchungen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Get the maintenance tooltip text for an out-of-service cell */
|
||||||
|
const getMaintenanceTooltip = (vehicle: Fahrzeug): string => {
|
||||||
|
const mw = maintenanceWindows.find((w) => w.id === vehicle.id);
|
||||||
|
const statusLabel =
|
||||||
|
(mw?.status ?? vehicle.status) === 'ausser_dienst_wartung'
|
||||||
|
? 'Wartung'
|
||||||
|
: 'Schaden';
|
||||||
|
const bemerkung = mw?.status_bemerkung ?? vehicle.status_bemerkung;
|
||||||
|
const von = mw?.ausser_dienst_von ?? vehicle.ausser_dienst_von;
|
||||||
|
const bis = mw?.ausser_dienst_bis ?? vehicle.ausser_dienst_bis;
|
||||||
|
|
||||||
|
let tooltip = `Außer Dienst (${statusLabel})`;
|
||||||
|
if (bemerkung) tooltip += `: ${bemerkung}`;
|
||||||
|
if (von && bis) {
|
||||||
|
try {
|
||||||
|
tooltip += `\n${format(parseISO(von), 'dd.MM.yyyy')} – ${format(parseISO(bis), 'dd.MM.yyyy')}`;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tooltip;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Create / Edit dialog ──────────────────────────────────────────────────
|
// ── Create / Edit dialog ──────────────────────────────────────────────────
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingBooking, setEditingBooking] =
|
const [editingBooking, setEditingBooking] =
|
||||||
@@ -485,9 +528,15 @@ function FahrzeugBuchungen() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{oos && (
|
{oos && (
|
||||||
<Tooltip title="Fahrzeug außer Dienst">
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<span style={{ whiteSpace: 'pre-line' }}>
|
||||||
|
{getMaintenanceTooltip(vehicle)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Chip
|
<Chip
|
||||||
icon={<Block fontSize="small" />}
|
icon={<Build fontSize="small" />}
|
||||||
label="Außer Dienst"
|
label="Außer Dienst"
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -570,9 +619,14 @@ function FahrzeugBuchungen() {
|
|||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: (theme) => theme.palette.mode === 'dark' ? 'error.700' : 'error.300',
|
borderColor: (theme) => theme.palette.mode === 'dark' ? 'error.700' : 'error.300',
|
||||||
borderRadius: 0.5,
|
borderRadius: 0.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Typography variant="caption">Außer Dienst</Typography>
|
<Build sx={{ fontSize: 10, color: 'error.main' }} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption">Außer Dienst (Wartung/Schaden)</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
|
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
|
||||||
([art, label]) => (
|
([art, label]) => (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardActionArea,
|
CardActionArea,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
DirectionsCar,
|
DirectionsCar,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
|
FileDownload,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
School,
|
School,
|
||||||
Search,
|
Search,
|
||||||
@@ -328,6 +330,24 @@ function Fahrzeuge() {
|
|||||||
fetchWarnings();
|
fetchWarnings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleExportAlerts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const blob = await vehiclesApi.exportAlerts();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
a.download = `pruefungen_${dateStr}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
setError('CSV-Export fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filtered = vehicles.filter((v) => {
|
const filtered = vehicles.filter((v) => {
|
||||||
if (!search.trim()) return true;
|
if (!search.trim()) return true;
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
@@ -366,6 +386,14 @@ function Fahrzeuge() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<FileDownload />}
|
||||||
|
onClick={handleExportAlerts}
|
||||||
|
>
|
||||||
|
Prüfungen CSV
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{hasOverdue && (
|
{hasOverdue && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -60,6 +60,7 @@ import type {
|
|||||||
VeranstaltungKategorie,
|
VeranstaltungKategorie,
|
||||||
GroupInfo,
|
GroupInfo,
|
||||||
CreateVeranstaltungInput,
|
CreateVeranstaltungInput,
|
||||||
|
ConflictEvent,
|
||||||
} from '../types/events.types';
|
} from '../types/events.types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -609,6 +610,46 @@ function EventFormDialog({
|
|||||||
}
|
}
|
||||||
}, [open, editingEvent]);
|
}, [open, editingEvent]);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Conflict detection — debounced check when dates change
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
const [conflicts, setConflicts] = useState<ConflictEvent[]>([]);
|
||||||
|
const conflictTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear conflicts when dialog closes
|
||||||
|
if (!open) {
|
||||||
|
setConflicts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vonDate = new Date(form.datum_von);
|
||||||
|
const bisDate = new Date(form.datum_bis);
|
||||||
|
if (isNaN(vonDate.getTime()) || isNaN(bisDate.getTime()) || bisDate <= vonDate) {
|
||||||
|
setConflicts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflictTimerRef.current) clearTimeout(conflictTimerRef.current);
|
||||||
|
conflictTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await eventsApi.checkConflicts(
|
||||||
|
vonDate.toISOString(),
|
||||||
|
bisDate.toISOString(),
|
||||||
|
editingEvent?.id
|
||||||
|
);
|
||||||
|
setConflicts(result);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — conflict check is advisory only
|
||||||
|
setConflicts([]);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (conflictTimerRef.current) clearTimeout(conflictTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [open, form.datum_von, form.datum_bis, editingEvent]);
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
|
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
|
||||||
if (field === 'kategorie_id' && !editingEvent) {
|
if (field === 'kategorie_id' && !editingEvent) {
|
||||||
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
|
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
|
||||||
@@ -771,6 +812,31 @@ function EventFormDialog({
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Conflict warning */}
|
||||||
|
{conflicts.length > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 0 }}>
|
||||||
|
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
|
||||||
|
Überschneidung mit bestehenden Veranstaltungen:
|
||||||
|
</Typography>
|
||||||
|
{conflicts.map((c) => {
|
||||||
|
const von = new Date(c.datum_von);
|
||||||
|
const bis = new Date(c.datum_bis);
|
||||||
|
const fmtDate = (d: Date) =>
|
||||||
|
`${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
|
||||||
|
const fmtTime = (d: Date) =>
|
||||||
|
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
const range = sameDay(von, bis)
|
||||||
|
? `${fmtDate(von)} ${fmtTime(von)} - ${fmtTime(bis)}`
|
||||||
|
: `${fmtDate(von)} ${fmtTime(von)} - ${fmtDate(bis)} ${fmtTime(bis)}`;
|
||||||
|
return (
|
||||||
|
<Typography key={c.id} variant="body2">
|
||||||
|
{'\u2022'} "{c.titel}" ({range})
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ort */}
|
{/* Ort */}
|
||||||
<TextField
|
<TextField
|
||||||
label="Ort"
|
label="Ort"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import type { MonitoredService, PingResult, PingHistoryEntry, StatusSummary, SystemHealth, UserOverview, BroadcastPayload } from '../types/admin.types';
|
import type { MonitoredService, PingResult, PingHistoryEntry, StatusSummary, SystemHealth, UserOverview, BroadcastPayload, BroadcastPreviewPayload } from '../types/admin.types';
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -26,6 +26,7 @@ export const adminApi = {
|
|||||||
getSystemHealth: () => api.get<ApiResponse<SystemHealth>>('/api/admin/system/health').then(r => r.data.data),
|
getSystemHealth: () => api.get<ApiResponse<SystemHealth>>('/api/admin/system/health').then(r => r.data.data),
|
||||||
getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data),
|
getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data),
|
||||||
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
|
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
|
||||||
|
broadcastPreview: (data: BroadcastPreviewPayload) => api.post<ApiResponse<{ count: number }>>('/api/admin/notifications/broadcast/preview', data).then(r => r.data.data),
|
||||||
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
|
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
|
||||||
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
|
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
|
||||||
fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
|
fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
|
||||||
|
|||||||
@@ -76,4 +76,9 @@ export const atemschutzApi = {
|
|||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
await api.delete(`/api/atemschutz/${id}`);
|
await api.delete(`/api/atemschutz/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getExpiring(): Promise<any[]> {
|
||||||
|
const response = await api.get<{ success: boolean; data: any[] }>('/api/atemschutz/expiring');
|
||||||
|
return response.data?.data ?? [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
FahrzeugBuchung,
|
FahrzeugBuchung,
|
||||||
Fahrzeug,
|
Fahrzeug,
|
||||||
CreateBuchungInput,
|
CreateBuchungInput,
|
||||||
|
MaintenanceWindow,
|
||||||
} from '../types/booking.types';
|
} from '../types/booking.types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -22,15 +23,22 @@ export const bookingApi = {
|
|||||||
// Calendar / listing
|
// Calendar / listing
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
getCalendarRange(from: Date, to: Date, fahrzeugId?: string): Promise<FahrzeugBuchungListItem[]> {
|
getCalendarRange(
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
fahrzeugId?: string
|
||||||
|
): Promise<{ bookings: FahrzeugBuchungListItem[]; maintenanceWindows: MaintenanceWindow[] }> {
|
||||||
return api
|
return api
|
||||||
.get<ApiResponse<FahrzeugBuchungListItem[]>>('/api/bookings/calendar', {
|
.get<ApiResponse<{ bookings: FahrzeugBuchungListItem[]; maintenanceWindows: MaintenanceWindow[] }>>(
|
||||||
|
'/api/bookings/calendar',
|
||||||
|
{
|
||||||
params: {
|
params: {
|
||||||
from: from.toISOString(),
|
from: from.toISOString(),
|
||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
...(fahrzeugId ? { fahrzeugId } : {}),
|
...(fahrzeugId ? { fahrzeugId } : {}),
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
.then((r) => r.data.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
Veranstaltung,
|
Veranstaltung,
|
||||||
GroupInfo,
|
GroupInfo,
|
||||||
CreateVeranstaltungInput,
|
CreateVeranstaltungInput,
|
||||||
|
ConflictEvent,
|
||||||
} from '../types/events.types';
|
} from '../types/events.types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -153,4 +154,13 @@ export const eventsApi = {
|
|||||||
.post<ApiResponse<{ created: number; errors: string[] }>>('/api/events/import', { events })
|
.post<ApiResponse<{ created: number; errors: string[] }>>('/api/events/import', { events })
|
||||||
.then((r) => r.data.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Check for overlapping events in a time range */
|
||||||
|
checkConflicts(from: string, to: string, excludeId?: string): Promise<ConflictEvent[]> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<ConflictEvent[]>>('/api/events/conflicts', {
|
||||||
|
params: { from, to, ...(excludeId ? { excludeId } : {}) },
|
||||||
|
})
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,4 +93,11 @@ export const vehiclesApi = {
|
|||||||
}
|
}
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async exportAlerts(): Promise<Blob> {
|
||||||
|
const response = await api.get('/api/vehicles/alerts/export', {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data as Blob;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,4 +58,10 @@ export interface BroadcastPayload {
|
|||||||
nachricht: string;
|
nachricht: string;
|
||||||
schwere?: 'info' | 'warnung' | 'fehler';
|
schwere?: 'info' | 'warnung' | 'fehler';
|
||||||
targetGroup?: string;
|
targetGroup?: string;
|
||||||
|
targetDienstgrad?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BroadcastPreviewPayload {
|
||||||
|
targetGroup?: string;
|
||||||
|
targetDienstgrad?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,21 @@ export interface Fahrzeug {
|
|||||||
kurzname: string | null;
|
kurzname: string | null;
|
||||||
amtliches_kennzeichen: string | null;
|
amtliches_kennzeichen: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
status_bemerkung?: string | null;
|
||||||
ausser_dienst_von: string | null;
|
ausser_dienst_von: string | null;
|
||||||
ausser_dienst_bis: string | null;
|
ausser_dienst_bis: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MaintenanceWindow {
|
||||||
|
id: string;
|
||||||
|
bezeichnung: string;
|
||||||
|
kurzname: string | null;
|
||||||
|
status: string;
|
||||||
|
status_bemerkung: string | null;
|
||||||
|
ausser_dienst_von: string;
|
||||||
|
ausser_dienst_bis: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateBuchungInput {
|
export interface CreateBuchungInput {
|
||||||
fahrzeugId: string;
|
fahrzeugId: string;
|
||||||
titel: string;
|
titel: string;
|
||||||
|
|||||||
@@ -76,3 +76,11 @@ export interface CreateVeranstaltungInput {
|
|||||||
anmeldung_bis?: string | null;
|
anmeldung_bis?: string | null;
|
||||||
wiederholung?: WiederholungConfig | null;
|
wiederholung?: WiederholungConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConflictEvent {
|
||||||
|
id: string;
|
||||||
|
titel: string;
|
||||||
|
datum_von: string; // ISO
|
||||||
|
datum_bis: string; // ISO
|
||||||
|
kategorie_name: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user