From 8c66492b279d68fb76ed44daca188b89881ea770 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 23 Mar 2026 16:09:42 +0100 Subject: [PATCH] new features --- backend/src/app.ts | 2 + backend/src/controllers/auth.controller.ts | 9 +- .../src/controllers/bestellung.controller.ts | 16 +- .../src/controllers/equipment.controller.ts | 48 ++ backend/src/controllers/issue.controller.ts | 176 +++++++ backend/src/controllers/shop.controller.ts | 19 +- backend/src/controllers/vehicle.controller.ts | 42 ++ .../042_ensure_ganztaegig_column.sql | 4 + .../database/migrations/043_feature_batch.sql | 130 +++++ backend/src/models/equipment.model.ts | 25 +- backend/src/models/vehicle.model.ts | 19 + backend/src/routes/admin.routes.ts | 30 ++ backend/src/routes/equipment.routes.ts | 1 + backend/src/routes/issue.routes.ts | 45 ++ backend/src/routes/shop.routes.ts | 6 + backend/src/routes/vehicle.routes.ts | 1 + backend/src/services/equipment.service.ts | 74 +++ backend/src/services/events.service.ts | 5 +- backend/src/services/issue.service.ts | 162 ++++++ backend/src/services/shop.service.ts | 53 +- backend/src/services/vehicle.service.ts | 64 ++- frontend/src/App.tsx | 9 + frontend/src/components/admin/DebugTab.tsx | 106 ++++ frontend/src/components/shared/Sidebar.tsx | 18 + frontend/src/pages/AdminDashboard.tsx | 7 +- frontend/src/pages/AusruestungDetail.tsx | 108 ++-- frontend/src/pages/Bestellungen.tsx | 2 +- frontend/src/pages/FahrzeugDetail.tsx | 125 +++-- frontend/src/pages/Issues.tsx | 471 ++++++++++++++++++ frontend/src/pages/Kalender.tsx | 80 ++- frontend/src/pages/Shop.tsx | 95 +++- frontend/src/services/admin.ts | 1 + frontend/src/services/equipment.ts | 16 + frontend/src/services/issues.ts | 32 ++ frontend/src/services/shop.ts | 7 + frontend/src/services/vehicles.ts | 12 + frontend/src/types/equipment.types.ts | 25 +- frontend/src/types/issue.types.ts | 39 ++ frontend/src/types/shop.types.ts | 18 + frontend/src/types/vehicle.types.ts | 31 ++ 40 files changed, 2016 insertions(+), 117 deletions(-) create mode 100644 backend/src/controllers/issue.controller.ts create mode 100644 backend/src/database/migrations/042_ensure_ganztaegig_column.sql create mode 100644 backend/src/database/migrations/043_feature_batch.sql create mode 100644 backend/src/routes/issue.routes.ts create mode 100644 backend/src/services/issue.service.ts create mode 100644 frontend/src/components/admin/DebugTab.tsx create mode 100644 frontend/src/pages/Issues.tsx create mode 100644 frontend/src/services/issues.ts create mode 100644 frontend/src/types/issue.types.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 4bfdf4c..48e4794 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -102,6 +102,7 @@ import settingsRoutes from './routes/settings.routes'; import bannerRoutes from './routes/banner.routes'; import permissionRoutes from './routes/permission.routes'; import shopRoutes from './routes/shop.routes'; +import issueRoutes from './routes/issue.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -126,6 +127,7 @@ app.use('/api/settings', settingsRoutes); app.use('/api/banners', bannerRoutes); app.use('/api/permissions', permissionRoutes); app.use('/api/shop', shopRoutes); +app.use('/api/issues', issueRoutes); // Static file serving for uploads (authenticated) const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 59db8ec..fc86d63 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -87,6 +87,7 @@ class AuthController { // Step 2: Get user info from Authentik const userInfo = await authentikService.getUserInfo(tokens.access_token); const groups = userInfo.groups ?? []; + const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_')); // Step 3: Verify ID token if present if (tokens.id_token) { @@ -119,8 +120,8 @@ class AuthController { profile_picture_url: userInfo.picture, }); - await userService.updateGroups(user.id, groups); - logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length }); + await userService.updateGroups(user.id, dashboardGroups); + logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length }); await memberService.ensureProfileExists(user.id); // Audit: first-ever login (user record creation) @@ -168,8 +169,8 @@ class AuthController { }); await userService.updateLastLogin(user.id); - await userService.updateGroups(user.id, groups); - logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length }); + await userService.updateGroups(user.id, dashboardGroups); + logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length }); await memberService.ensureProfileExists(user.id); const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo); diff --git a/backend/src/controllers/bestellung.controller.ts b/backend/src/controllers/bestellung.controller.ts index 2c442ca..4b5f5f2 100644 --- a/backend/src/controllers/bestellung.controller.ts +++ b/backend/src/controllers/bestellung.controller.ts @@ -113,17 +113,29 @@ class BestellungController { } async createOrder(req: Request, res: Response): Promise { - const { bezeichnung } = req.body; + const { bezeichnung, lieferant_id, budget, besteller_id } = req.body; if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) { res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' }); return; } + if (lieferant_id != null && (!Number.isInteger(lieferant_id) || lieferant_id <= 0)) { + res.status(400).json({ success: false, message: 'Ungültige Lieferanten-ID' }); + return; + } + if (budget != null && (typeof budget !== 'number' || budget < 0)) { + res.status(400).json({ success: false, message: 'Budget muss eine positive Zahl sein' }); + return; + } + if (besteller_id != null && besteller_id !== '' && (typeof besteller_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(besteller_id))) { + res.status(400).json({ success: false, message: 'Ungültige Besteller-ID' }); + return; + } try { const order = await bestellungService.createOrder(req.body, req.user!.id); res.status(201).json({ success: true, data: order }); } catch (error) { logger.error('BestellungController.createOrder error', { error }); - res.status(500).json({ success: false, message: 'Bestellung konnte nicht erstellt werden' }); + res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Bestellung konnte nicht erstellt werden' }); } } diff --git a/backend/src/controllers/equipment.controller.ts b/backend/src/controllers/equipment.controller.ts index aa410cf..fffcf51 100644 --- a/backend/src/controllers/equipment.controller.ts +++ b/backend/src/controllers/equipment.controller.ts @@ -81,6 +81,17 @@ const CreateWartungslogSchema = z.object({ (url) => /^https?:\/\//i.test(url), 'Nur http/https URLs erlaubt' ).optional(), + naechste_pruefung_am: isoDate.optional(), +}); + +const UpdateWartungslogSchema = z.object({ + datum: isoDate.optional(), + art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']).optional(), + beschreibung: z.string().min(1).max(2000).optional(), + ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).nullable().optional(), + kosten: z.number().min(0).nullable().optional(), + pruefende_stelle: z.string().max(150).nullable().optional(), + naechste_pruefung_am: isoDate.nullable().optional(), }); // ── Helper ──────────────────────────────────────────────────────────────────── @@ -403,6 +414,43 @@ class EquipmentController { } } + async updateWartung(req: Request, res: Response): Promise { + try { + const { id, wartungId } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' }); + return; + } + const wId = parseInt(wartungId, 10); + if (isNaN(wId)) { + res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' }); + return; + } + const parsed = UpdateWartungslogSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + if (Object.keys(parsed.data).length === 0) { + res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); + return; + } + const entry = await equipmentService.updateWartungslog(id, wId, parsed.data, getUserId(req)); + res.status(200).json({ success: true, data: entry }); + } catch (error: any) { + if (error?.message === 'Wartungseintrag nicht gefunden') { + res.status(404).json({ success: false, message: error.message }); + return; + } + logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId }); + res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' }); + } + } + async uploadWartungFile(req: Request, res: Response): Promise { const { wartungId } = req.params as Record; const id = parseInt(wartungId, 10); diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts new file mode 100644 index 0000000..ac57e38 --- /dev/null +++ b/backend/src/controllers/issue.controller.ts @@ -0,0 +1,176 @@ +import { Request, Response } from 'express'; +import issueService from '../services/issue.service'; +import { permissionService } from '../services/permission.service'; +import logger from '../utils/logger'; + +const param = (req: Request, key: string): string => req.params[key] as string; + +class IssueController { + async getIssues(req: Request, res: Response): Promise { + try { + const userId = req.user!.id; + const groups: string[] = (req.user as any).groups || []; + const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); + const issues = await issueService.getIssues(userId, canViewAll); + res.status(200).json({ success: true, data: issues }); + } catch (error) { + logger.error('IssueController.getIssues error', { error }); + res.status(500).json({ success: false, message: 'Issues konnten nicht geladen werden' }); + } + } + + async getIssue(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const issue = await issueService.getIssueById(id); + if (!issue) { + res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); + return; + } + const userId = req.user!.id; + const groups: string[] = (req.user as any).groups || []; + const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); + if (!canViewAll && issue.erstellt_von !== userId) { + res.status(403).json({ success: false, message: 'Kein Zugriff' }); + return; + } + res.status(200).json({ success: true, data: issue }); + } catch (error) { + logger.error('IssueController.getIssue error', { error }); + res.status(500).json({ success: false, message: 'Issue konnte nicht geladen werden' }); + } + } + + async createIssue(req: Request, res: Response): Promise { + const { titel } = req.body; + if (!titel || typeof titel !== 'string' || titel.trim().length === 0) { + res.status(400).json({ success: false, message: 'Titel ist erforderlich' }); + return; + } + try { + const issue = await issueService.createIssue(req.body, req.user!.id); + res.status(201).json({ success: true, data: issue }); + } catch (error) { + logger.error('IssueController.createIssue error', { error }); + res.status(500).json({ success: false, message: 'Issue konnte nicht erstellt werden' }); + } + } + + async updateIssue(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const groups: string[] = (req.user as any).groups || []; + const canManage = permissionService.hasPermission(groups, 'issues:manage'); + if (!canManage) { + res.status(403).json({ success: false, message: 'Keine Berechtigung' }); + return; + } + const issue = await issueService.updateIssue(id, req.body); + if (!issue) { + res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: issue }); + } catch (error) { + logger.error('IssueController.updateIssue error', { error }); + res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' }); + } + } + + async deleteIssue(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const issue = await issueService.getIssueById(id); + if (!issue) { + res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); + return; + } + const userId = req.user!.id; + const groups: string[] = (req.user as any).groups || []; + const canManage = permissionService.hasPermission(groups, 'issues:manage'); + if (!canManage && issue.erstellt_von !== userId) { + res.status(403).json({ success: false, message: 'Keine Berechtigung' }); + return; + } + await issueService.deleteIssue(id); + res.status(200).json({ success: true, message: 'Issue gelöscht' }); + } catch (error) { + logger.error('IssueController.deleteIssue error', { error }); + res.status(500).json({ success: false, message: 'Issue konnte nicht gelöscht werden' }); + } + } + + async getComments(req: Request, res: Response): Promise { + const issueId = parseInt(param(req, 'id'), 10); + if (isNaN(issueId)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const issue = await issueService.getIssueById(issueId); + if (!issue) { + res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); + return; + } + const userId = req.user!.id; + const groups: string[] = (req.user as any).groups || []; + const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); + if (!canViewAll && issue.erstellt_von !== userId) { + res.status(403).json({ success: false, message: 'Kein Zugriff' }); + return; + } + const comments = await issueService.getComments(issueId); + res.status(200).json({ success: true, data: comments }); + } catch (error) { + logger.error('IssueController.getComments error', { error }); + res.status(500).json({ success: false, message: 'Kommentare konnten nicht geladen werden' }); + } + } + + async addComment(req: Request, res: Response): Promise { + const issueId = parseInt(param(req, 'id'), 10); + if (isNaN(issueId)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + const { inhalt } = req.body; + if (!inhalt || typeof inhalt !== 'string' || inhalt.trim().length === 0) { + res.status(400).json({ success: false, message: 'Kommentar darf nicht leer sein' }); + return; + } + try { + const issue = await issueService.getIssueById(issueId); + if (!issue) { + res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); + return; + } + const userId = req.user!.id; + const groups: string[] = (req.user as any).groups || []; + const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); + const canManage = permissionService.hasPermission(groups, 'issues:manage'); + if (!canViewAll && !canManage && issue.erstellt_von !== userId) { + res.status(403).json({ success: false, message: 'Kein Zugriff' }); + return; + } + const comment = await issueService.addComment(issueId, userId, inhalt.trim()); + res.status(201).json({ success: true, data: comment }); + } catch (error) { + logger.error('IssueController.addComment error', { error }); + res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' }); + } + } +} + +export default new IssueController(); diff --git a/backend/src/controllers/shop.controller.ts b/backend/src/controllers/shop.controller.ts index 796e86d..2a26b22 100644 --- a/backend/src/controllers/shop.controller.ts +++ b/backend/src/controllers/shop.controller.ts @@ -188,11 +188,14 @@ class ShopController { // Notify requester on status changes if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) { + const orderLabel = existing.bestell_jahr && existing.bestell_nummer + ? `${existing.bestell_jahr}/${String(existing.bestell_nummer).padStart(3, '0')}` + : `#${id}`; await notificationService.createNotification({ user_id: existing.anfrager_id, typ: 'shop_anfrage', titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`, - nachricht: `Deine Shop-Anfrage #${id} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`, + nachricht: `Deine Shop-Anfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`, schwere: status === 'abgelehnt' ? 'warnung' : 'info', link: '/shop', quell_id: String(id), @@ -218,6 +221,20 @@ class ShopController { } } + // ------------------------------------------------------------------------- + // Overview + // ------------------------------------------------------------------------- + + async getOverview(_req: Request, res: Response): Promise { + try { + const overview = await shopService.getOverview(); + res.status(200).json({ success: true, data: overview }); + } catch (error) { + logger.error('ShopController.getOverview error', { error }); + res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' }); + } + } + // ------------------------------------------------------------------------- // Linking // ------------------------------------------------------------------------- diff --git a/backend/src/controllers/vehicle.controller.ts b/backend/src/controllers/vehicle.controller.ts index ac95730..f3e2f7e 100644 --- a/backend/src/controllers/vehicle.controller.ts +++ b/backend/src/controllers/vehicle.controller.ts @@ -86,6 +86,8 @@ const UpdateStatusSchema = z.object({ { message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] } ); +const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']); + const CreateWartungslogSchema = z.object({ datum: isoDate, art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(), @@ -94,6 +96,18 @@ const CreateWartungslogSchema = z.object({ kraftstoff_liter: z.number().min(0).optional(), kosten: z.number().min(0).optional(), externe_werkstatt: z.string().max(150).optional(), + ergebnis: ErgebnisEnum.optional(), + naechste_faelligkeit: isoDate.optional(), +}); + +const UpdateWartungslogSchema = z.object({ + datum: isoDate, + art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(), + beschreibung: z.string().min(1).max(2000), + km_stand: z.number().int().min(0).optional(), + externe_werkstatt: z.string().max(150).optional(), + ergebnis: ErgebnisEnum.optional(), + naechste_faelligkeit: isoDate.optional(), }); // ── Helper ──────────────────────────────────────────────────────────────────── @@ -384,6 +398,34 @@ class VehicleController { } } + async updateWartung(req: Request, res: Response): Promise { + try { + const { id, wartungId } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); + return; + } + const parsed = UpdateWartungslogSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + const entry = await vehicleService.updateWartungslog(wartungId, id, parsed.data, getUserId(req)); + res.status(200).json({ success: true, data: entry }); + } catch (error: any) { + if (error?.message === 'Wartungseintrag nicht gefunden') { + res.status(404).json({ success: false, message: 'Wartungseintrag nicht gefunden' }); + return; + } + logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId }); + res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' }); + } + } + async uploadWartungFile(req: Request, res: Response): Promise { const { wartungId } = req.params as Record; const id = parseInt(wartungId, 10); diff --git a/backend/src/database/migrations/042_ensure_ganztaegig_column.sql b/backend/src/database/migrations/042_ensure_ganztaegig_column.sql new file mode 100644 index 0000000..e675075 --- /dev/null +++ b/backend/src/database/migrations/042_ensure_ganztaegig_column.sql @@ -0,0 +1,4 @@ +-- Migration 042: Ensure ganztaegig column exists on fahrzeug_buchungen +-- Fixes case where migration 041 was tracked before this column was added. + +ALTER TABLE fahrzeug_buchungen ADD COLUMN IF NOT EXISTS ganztaegig BOOLEAN DEFAULT FALSE; diff --git a/backend/src/database/migrations/043_feature_batch.sql b/backend/src/database/migrations/043_feature_batch.sql new file mode 100644 index 0000000..a6697e9 --- /dev/null +++ b/backend/src/database/migrations/043_feature_batch.sql @@ -0,0 +1,130 @@ +-- Migration 043: Feature batch +-- 1. Vehicle wartungslog: ergebnis + naechste_faelligkeit +-- 2. Shop anfragen: bestell_nummer + bestell_jahr +-- 3. Issues + issue_kommentare tables +-- 4. New feature group 'issues' + permissions +-- 5. New shop permissions (view_overview, order_for_user) + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Vehicle Wartungslog additions +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE fahrzeug_wartungslog + ADD COLUMN IF NOT EXISTS ergebnis VARCHAR(30) + CHECK (ergebnis IN ('bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden')); + +ALTER TABLE fahrzeug_wartungslog + ADD COLUMN IF NOT EXISTS naechste_faelligkeit DATE; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Shop Anfragen: unique order ID per year +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE shop_anfragen + ADD COLUMN IF NOT EXISTS bestell_nummer INT; + +ALTER TABLE shop_anfragen + ADD COLUMN IF NOT EXISTS bestell_jahr INT DEFAULT EXTRACT(YEAR FROM CURRENT_DATE); + +-- Make bezeichnung optional on shop_anfragen (if it exists) +-- shop_anfragen doesn't have bezeichnung — it's on positionen. No change needed. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Issues tables +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS issues ( + id SERIAL PRIMARY KEY, + titel VARCHAR(500) NOT NULL, + beschreibung TEXT, + typ VARCHAR(50) NOT NULL DEFAULT 'bug' + CHECK (typ IN ('bug', 'feature', 'sonstiges')), + prioritaet VARCHAR(20) NOT NULL DEFAULT 'mittel' + CHECK (prioritaet IN ('niedrig', 'mittel', 'hoch')), + status VARCHAR(20) NOT NULL DEFAULT 'offen' + CHECK (status IN ('offen', 'in_bearbeitung', 'erledigt', 'abgelehnt')), + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + zugewiesen_an UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_issues_erstellt_von ON issues(erstellt_von); +CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status); +CREATE INDEX IF NOT EXISTS idx_issues_typ ON issues(typ); + +CREATE TABLE IF NOT EXISTS issue_kommentare ( + id SERIAL PRIMARY KEY, + issue_id INT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + autor_id UUID REFERENCES users(id) ON DELETE SET NULL, + inhalt TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_issue_kommentare_issue ON issue_kommentare(issue_id); + +-- Auto-update trigger for issues +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_issues_updated') THEN + CREATE TRIGGER trg_issues_updated BEFORE UPDATE ON issues + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + END IF; +END $$; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Issues feature group + permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO feature_groups (id, label, sort_order) VALUES + ('issues', 'Issues', 13) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('issues:create', 'issues', 'Erstellen', 'Issues erstellen', 1), + ('issues:view_own', 'issues', 'Eigene ansehen', 'Eigene Issues einsehen', 2), + ('issues:view_all', 'issues', 'Alle ansehen', 'Alle Issues einsehen', 3), + ('issues:manage', 'issues', 'Verwalten', 'Issues bearbeiten, Status ändern, zuweisen', 4) +ON CONFLICT (id) DO NOTHING; + +-- Seed: all groups get create + view_own; kommando gets all +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kommando: full access + ('dashboard_kommando', 'issues:create'), + ('dashboard_kommando', 'issues:view_own'), + ('dashboard_kommando', 'issues:view_all'), + ('dashboard_kommando', 'issues:manage'), + -- Fahrmeister + ('dashboard_fahrmeister', 'issues:create'), + ('dashboard_fahrmeister', 'issues:view_own'), + -- Zeugmeister + ('dashboard_zeugmeister', 'issues:create'), + ('dashboard_zeugmeister', 'issues:view_own'), + -- Chargen + ('dashboard_chargen', 'issues:create'), + ('dashboard_chargen', 'issues:view_own'), + -- Moderator + ('dashboard_moderator', 'issues:create'), + ('dashboard_moderator', 'issues:view_own'), + -- Atemschutz + ('dashboard_atemschutz', 'issues:create'), + ('dashboard_atemschutz', 'issues:view_own'), + -- Mitglied + ('dashboard_mitglied', 'issues:create'), + ('dashboard_mitglied', 'issues:view_own') +ON CONFLICT DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 5. New shop permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('shop:view_overview', 'shop', 'Übersicht', 'Aggregierte Übersicht aller Anfragen', 7), + ('shop:order_for_user', 'shop', 'Für Benutzer bestellen', 'Anfragen im Namen anderer erstellen', 8) +ON CONFLICT (id) DO NOTHING; + +-- Kommando gets the new permissions +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_kommando', 'shop:view_overview'), + ('dashboard_kommando', 'shop:order_for_user') +ON CONFLICT DO NOTHING; diff --git a/backend/src/models/equipment.model.ts b/backend/src/models/equipment.model.ts index 438ec88..0b86d6e 100644 --- a/backend/src/models/equipment.model.ts +++ b/backend/src/models/equipment.model.ts @@ -167,11 +167,22 @@ export interface UpdateAusruestungData { } export interface CreateAusruestungWartungslogData { - datum: string; - art: AusruestungWartungslogArt; - beschreibung: string; - ergebnis?: string; - kosten?: number; - pruefende_stelle?: string; - dokument_url?: string; + datum: string; + art: AusruestungWartungslogArt; + beschreibung: string; + ergebnis?: string; + kosten?: number; + pruefende_stelle?: string; + dokument_url?: string; + naechste_pruefung_am?: string; +} + +export interface UpdateAusruestungWartungslogData { + datum?: string; + art?: AusruestungWartungslogArt; + beschreibung?: string; + ergebnis?: string | null; + kosten?: number | null; + pruefende_stelle?: string | null; + naechste_pruefung_am?: string | null; } diff --git a/backend/src/models/vehicle.model.ts b/backend/src/models/vehicle.model.ts index ea5fd6b..46c5f10 100644 --- a/backend/src/models/vehicle.model.ts +++ b/backend/src/models/vehicle.model.ts @@ -52,6 +52,11 @@ export interface Fahrzeug { aktiver_lehrgang?: AktiverLehrgang | null; } +export type WartungslogErgebnis = + | 'bestanden' + | 'bestanden_mit_maengeln' + | 'nicht_bestanden'; + export interface FahrzeugWartungslog { id: string; fahrzeug_id: string; @@ -62,6 +67,8 @@ export interface FahrzeugWartungslog { kraftstoff_liter: number | null; kosten: number | null; externe_werkstatt: string | null; + ergebnis: WartungslogErgebnis | null; + naechste_faelligkeit: Date | null; erfasst_von: string | null; created_at: Date; } @@ -174,4 +181,16 @@ export interface CreateWartungslogData { kraftstoff_liter?: number; kosten?: number; externe_werkstatt?: string; + ergebnis?: WartungslogErgebnis; + naechste_faelligkeit?: string; +} + +export interface UpdateWartungslogData { + datum: string; + art?: WartungslogArt; + beschreibung: string; + km_stand?: number; + externe_werkstatt?: string; + ergebnis?: WartungslogErgebnis; + naechste_faelligkeit?: string; } diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index 2fde0a5..c2d92f7 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -325,4 +325,34 @@ router.delete( } ); +// --------------------------------------------------------------------------- +// DELETE /api/admin/debug/user/:userId/profile — delete mitglieder_profile row +// --------------------------------------------------------------------------- + +router.delete( + '/debug/user/:userId/profile', + authenticate, + requirePermission('admin:write'), + async (req: Request, res: Response): Promise => { + try { + const userId = req.params.userId; + const result = await pool.query( + 'DELETE FROM mitglieder_profile WHERE user_id = $1', + [userId] + ); + + if ((result.rowCount ?? 0) === 0) { + res.status(404).json({ success: false, message: 'Kein Profil fuer diesen Benutzer gefunden' }); + return; + } + + logger.info('Admin deleted user profile data', { userId, admin: req.user?.id }); + res.json({ success: true, message: 'Profildaten geloescht' }); + } catch (error) { + logger.error('Failed to delete user profile', { error, userId: req.params.userId }); + res.status(500).json({ success: false, message: 'Fehler beim Loeschen der Profildaten' }); + } + } +); + export default router; diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts index 50eaaa1..cd02c2d 100644 --- a/backend/src/routes/equipment.routes.ts +++ b/backend/src/routes/equipment.routes.ts @@ -23,6 +23,7 @@ router.post('/', authenticate, requirePermission('ausruestung:create') router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController)); router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController)); router.post('/:id/wartung', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.addWartung.bind(equipmentController)); +router.patch('/:id/wartung/:wartungId', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.updateWartung.bind(equipmentController)); router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:manage_maintenance'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController)); // ── Delete — admin only ────────────────────────────────────────────────────── diff --git a/backend/src/routes/issue.routes.ts b/backend/src/routes/issue.routes.ts new file mode 100644 index 0000000..1d142f3 --- /dev/null +++ b/backend/src/routes/issue.routes.ts @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import issueController from '../controllers/issue.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); + +router.get( + '/', + authenticate, + issueController.getIssues.bind(issueController) +); + +router.post( + '/', + authenticate, + requirePermission('issues:create'), + issueController.createIssue.bind(issueController) +); + +router.get( + '/:id/comments', + authenticate, + issueController.getComments.bind(issueController) +); + +router.post( + '/:id/comments', + authenticate, + issueController.addComment.bind(issueController) +); + +router.patch( + '/:id', + authenticate, + issueController.updateIssue.bind(issueController) +); + +router.delete( + '/:id', + authenticate, + issueController.deleteIssue.bind(issueController) +); + +export default router; diff --git a/backend/src/routes/shop.routes.ts b/backend/src/routes/shop.routes.ts index 368f417..d82085b 100644 --- a/backend/src/routes/shop.routes.ts +++ b/backend/src/routes/shop.routes.ts @@ -17,6 +17,12 @@ router.delete('/items/:id', authenticate, requirePermission('shop:manage_catalog router.get('/categories', authenticate, requirePermission('shop:view'), shopController.getCategories.bind(shopController)); +// --------------------------------------------------------------------------- +// Overview +// --------------------------------------------------------------------------- + +router.get('/overview', authenticate, requirePermission('shop:view_overview'), shopController.getOverview.bind(shopController)); + // --------------------------------------------------------------------------- // Requests // --------------------------------------------------------------------------- diff --git a/backend/src/routes/vehicle.routes.ts b/backend/src/routes/vehicle.routes.ts index c8a098a..ecb33ff 100644 --- a/backend/src/routes/vehicle.routes.ts +++ b/backend/src/routes/vehicle.routes.ts @@ -26,6 +26,7 @@ router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehic router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController)); router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.addWartung.bind(vehicleController)); +router.patch('/:id/wartung/:wartungId', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.updateWartung.bind(vehicleController)); router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:manage_maintenance'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController)); export default router; diff --git a/backend/src/services/equipment.service.ts b/backend/src/services/equipment.service.ts index 1655798..90f64cc 100644 --- a/backend/src/services/equipment.service.ts +++ b/backend/src/services/equipment.service.ts @@ -9,6 +9,7 @@ import { CreateAusruestungData, UpdateAusruestungData, CreateAusruestungWartungslogData, + UpdateAusruestungWartungslogData, AusruestungStatus, EquipmentStats, VehicleEquipmentWarning, @@ -330,6 +331,15 @@ class EquipmentService { const entry = result.rows[0] as AusruestungWartungslog; logger.info('Equipment wartungslog entry added', { entryId: entry.id, equipmentId, by: createdBy }); + + // Auto-update next inspection date on the equipment when result is 'bestanden' + if (data.ergebnis === 'bestanden' && data.naechste_pruefung_am) { + await pool.query( + `UPDATE ausruestung SET naechste_pruefung_am = $1, letzte_pruefung_am = $2 WHERE id = $3`, + [data.naechste_pruefung_am, data.datum, equipmentId] + ); + } + return entry; } catch (error) { logger.error('EquipmentService.addWartungslog failed', { error, equipmentId }); @@ -461,6 +471,70 @@ class EquipmentService { } } + // ========================================================================= + // WARTUNGSLOG UPDATE + // ========================================================================= + + async updateWartungslog( + equipmentId: string, + wartungId: number, + data: UpdateAusruestungWartungslogData, + updatedBy: string + ): Promise { + try { + // Verify the wartung entry belongs to this equipment + const check = await pool.query( + `SELECT id FROM ausruestung_wartungslog WHERE id = $1 AND ausruestung_id = $2`, + [wartungId, equipmentId] + ); + if (check.rows.length === 0) { + throw new Error('Wartungseintrag nicht gefunden'); + } + + const fields: string[] = []; + const values: unknown[] = []; + let p = 1; + + const addField = (col: string, value: unknown) => { + fields.push(`${col} = $${p++}`); + values.push(value); + }; + + if (data.datum !== undefined) addField('datum', data.datum); + if (data.art !== undefined) addField('art', data.art); + if (data.beschreibung !== undefined) addField('beschreibung', data.beschreibung); + if (data.ergebnis !== undefined) addField('ergebnis', data.ergebnis); + if (data.kosten !== undefined) addField('kosten', data.kosten); + if (data.pruefende_stelle !== undefined) addField('pruefende_stelle', data.pruefende_stelle); + + if (fields.length === 0) { + throw new Error('No fields to update'); + } + + values.push(wartungId); + const result = await pool.query( + `UPDATE ausruestung_wartungslog SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`, + values + ); + + const entry = result.rows[0] as AusruestungWartungslog; + + // Auto-update next inspection date on the equipment when result is 'bestanden' + if (data.ergebnis === 'bestanden' && data.naechste_pruefung_am) { + await pool.query( + `UPDATE ausruestung SET naechste_pruefung_am = $1, letzte_pruefung_am = $2 WHERE id = $3`, + [data.naechste_pruefung_am, data.datum ?? entry.datum, equipmentId] + ); + } + + logger.info('Equipment wartungslog entry updated', { wartungId, equipmentId, by: updatedBy }); + return entry; + } catch (error) { + logger.error('EquipmentService.updateWartungslog failed', { error, wartungId, equipmentId }); + throw error; + } + } + // ========================================================================= // WARTUNGSLOG FILE UPLOAD // ========================================================================= diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index a55d501..cbaaf4d 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -367,6 +367,7 @@ class EventsService { data.alle_gruppen, data.max_teilnehmer ?? null, data.anmeldung_erforderlich, + data.anmeldung_bis ?? null, userId, ]); } @@ -376,8 +377,8 @@ class EventsService { `INSERT INTO veranstaltungen ( wiederholung_parent_id, titel, beschreibung, ort, ort_url, kategorie_id, datum_von, datum_bis, ganztaegig, zielgruppen, alle_gruppen, - max_teilnehmer, anmeldung_erforderlich, erstellt_von - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + max_teilnehmer, anmeldung_erforderlich, anmeldung_bis, erstellt_von + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, params ); } diff --git a/backend/src/services/issue.service.ts b/backend/src/services/issue.service.ts new file mode 100644 index 0000000..e830c87 --- /dev/null +++ b/backend/src/services/issue.service.ts @@ -0,0 +1,162 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; + +async function getIssues(userId: string, canViewAll: boolean) { + try { + const query = ` + SELECT i.*, + u1.name AS erstellt_von_name, + u2.name AS zugewiesen_an_name + FROM issues i + LEFT JOIN users u1 ON u1.id = i.erstellt_von + LEFT JOIN users u2 ON u2.id = i.zugewiesen_an + ${canViewAll ? '' : 'WHERE i.erstellt_von = $1'} + ORDER BY i.created_at DESC + `; + const result = canViewAll + ? await pool.query(query) + : await pool.query(query, [userId]); + return result.rows; + } catch (error) { + logger.error('IssueService.getIssues failed', { error }); + throw new Error('Issues konnten nicht geladen werden'); + } +} + +async function getIssueById(id: number) { + try { + const result = await pool.query( + `SELECT i.*, + u1.name AS erstellt_von_name, + u2.name AS zugewiesen_an_name + FROM issues i + LEFT JOIN users u1 ON u1.id = i.erstellt_von + LEFT JOIN users u2 ON u2.id = i.zugewiesen_an + WHERE i.id = $1`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.getIssueById failed', { error, id }); + throw new Error('Issue konnte nicht geladen werden'); + } +} + +async function createIssue( + data: { titel: string; beschreibung?: string; typ?: string; prioritaet?: string }, + userId: string +) { + try { + const result = await pool.query( + `INSERT INTO issues (titel, beschreibung, typ, prioritaet, erstellt_von) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + data.titel, + data.beschreibung || null, + data.typ || 'sonstiges', + data.prioritaet || 'mittel', + userId, + ] + ); + return result.rows[0]; + } catch (error) { + logger.error('IssueService.createIssue failed', { error }); + throw new Error('Issue konnte nicht erstellt werden'); + } +} + +async function updateIssue( + id: number, + data: { + titel?: string; + beschreibung?: string; + typ?: string; + prioritaet?: string; + status?: string; + zugewiesen_an?: string | null; + } +) { + try { + const result = await pool.query( + `UPDATE issues + SET titel = COALESCE($1, titel), + beschreibung = COALESCE($2, beschreibung), + typ = COALESCE($3, typ), + prioritaet = COALESCE($4, prioritaet), + status = COALESCE($5, status), + zugewiesen_an = COALESCE($6, zugewiesen_an), + updated_at = NOW() + WHERE id = $7 + RETURNING *`, + [ + data.titel, + data.beschreibung, + data.typ, + data.prioritaet, + data.status, + data.zugewiesen_an, + id, + ] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('IssueService.updateIssue failed', { error, id }); + throw new Error('Issue konnte nicht aktualisiert werden'); + } +} + +async function deleteIssue(id: number) { + try { + const result = await pool.query( + `DELETE FROM issues WHERE id = $1 RETURNING id`, + [id] + ); + return result.rows.length > 0; + } catch (error) { + logger.error('IssueService.deleteIssue failed', { error, id }); + throw new Error('Issue konnte nicht gelöscht werden'); + } +} + +async function getComments(issueId: number) { + try { + const result = await pool.query( + `SELECT c.*, u.name AS autor_name + FROM issue_kommentare c + LEFT JOIN users u ON u.id = c.autor_id + WHERE c.issue_id = $1 + ORDER BY c.created_at ASC`, + [issueId] + ); + return result.rows; + } catch (error) { + logger.error('IssueService.getComments failed', { error, issueId }); + throw new Error('Kommentare konnten nicht geladen werden'); + } +} + +async function addComment(issueId: number, autorId: string, inhalt: string) { + try { + const result = await pool.query( + `INSERT INTO issue_kommentare (issue_id, autor_id, inhalt) + VALUES ($1, $2, $3) + RETURNING *`, + [issueId, autorId, inhalt] + ); + return result.rows[0]; + } catch (error) { + logger.error('IssueService.addComment failed', { error, issueId }); + throw new Error('Kommentar konnte nicht erstellt werden'); + } +} + +export default { + getIssues, + getIssueById, + createIssue, + updateIssue, + deleteIssue, + getComments, + addComment, +}; diff --git a/backend/src/services/shop.service.ts b/backend/src/services/shop.service.ts index 8619d41..62e1541 100644 --- a/backend/src/services/shop.service.ts +++ b/backend/src/services/shop.service.ts @@ -202,11 +202,21 @@ async function createRequest( try { await client.query('BEGIN'); + // Get next bestell_nummer for the current year + const currentYear = new Date().getFullYear(); + const maxResult = await client.query( + `SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr + FROM shop_anfragen + WHERE bestell_jahr = $1`, + [currentYear], + ); + const nextNr = maxResult.rows[0].next_nr; + const anfrageResult = await client.query( - `INSERT INTO shop_anfragen (anfrager_id, notizen) - VALUES ($1, $2) + `INSERT INTO shop_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr) + VALUES ($1, $2, $3, $4) RETURNING *`, - [userId, notizen || null], + [userId, notizen || null, nextNr, currentYear], ); const anfrage = anfrageResult.rows[0]; @@ -296,6 +306,42 @@ async function getLinkedOrders(anfrageId: number) { return result.rows; } +// --------------------------------------------------------------------------- +// Overview (aggregated) +// --------------------------------------------------------------------------- + +async function getOverview() { + const aggregated = await pool.query( + `SELECT p.bezeichnung, + SUM(p.menge)::int AS total_menge, + COUNT(DISTINCT p.anfrage_id)::int AS anfrage_count + FROM shop_anfrage_positionen p + JOIN shop_anfragen a ON a.id = p.anfrage_id + WHERE a.status IN ('offen', 'genehmigt') + GROUP BY p.bezeichnung + ORDER BY total_menge DESC, p.bezeichnung`, + ); + + const counts = await pool.query( + `SELECT + COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count, + COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count, + COALESCE(SUM(sub.total), 0)::int AS total_items + FROM shop_anfragen a + LEFT JOIN LATERAL ( + SELECT SUM(p.menge) AS total + FROM shop_anfrage_positionen p + WHERE p.anfrage_id = a.id + ) sub ON true + WHERE a.status IN ('offen', 'genehmigt')`, + ); + + return { + items: aggregated.rows, + ...counts.rows[0], + }; +} + export default { getItems, getItemById, @@ -312,4 +358,5 @@ export default { linkToOrder, unlinkFromOrder, getLinkedOrders, + getOverview, }; diff --git a/backend/src/services/vehicle.service.ts b/backend/src/services/vehicle.service.ts index f829593..29f90fb 100644 --- a/backend/src/services/vehicle.service.ts +++ b/backend/src/services/vehicle.service.ts @@ -8,6 +8,7 @@ import { CreateFahrzeugData, UpdateFahrzeugData, CreateWartungslogData, + UpdateWartungslogData, FahrzeugStatus, VehicleStats, InspectionAlert, @@ -372,8 +373,9 @@ class VehicleService { const result = await pool.query( `INSERT INTO fahrzeug_wartungslog ( fahrzeug_id, datum, art, beschreibung, - km_stand, kraftstoff_liter, kosten, externe_werkstatt, erfasst_von - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + km_stand, kraftstoff_liter, kosten, externe_werkstatt, + ergebnis, naechste_faelligkeit, erfasst_von + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, [ fahrzeugId, @@ -384,11 +386,21 @@ class VehicleService { data.kraftstoff_liter ?? null, data.kosten ?? null, data.externe_werkstatt ?? null, + data.ergebnis ?? null, + data.naechste_faelligkeit ?? null, createdBy, ] ); const entry = result.rows[0] as FahrzeugWartungslog; + + // Auto-update next service date on the vehicle when result is 'bestanden' + if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) { + await pool.query( + `UPDATE fahrzeuge SET naechste_wartung_am = $1 WHERE id = $2`, + [data.naechste_faelligkeit, fahrzeugId] + ); + } logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy }); return entry; } catch (error) { @@ -397,6 +409,54 @@ class VehicleService { } } + async updateWartungslog( + wartungId: string, + fahrzeugId: string, + data: UpdateWartungslogData, + updatedBy: string + ): Promise { + try { + const result = await pool.query( + `UPDATE fahrzeug_wartungslog + SET datum = $1, art = $2, beschreibung = $3, km_stand = $4, + externe_werkstatt = $5, ergebnis = $6, naechste_faelligkeit = $7 + WHERE id = $8 AND fahrzeug_id = $9 + RETURNING *`, + [ + data.datum, + data.art ?? null, + data.beschreibung, + data.km_stand ?? null, + data.externe_werkstatt ?? null, + data.ergebnis ?? null, + data.naechste_faelligkeit ?? null, + wartungId, + fahrzeugId, + ] + ); + + if (result.rows.length === 0) { + throw new Error('Wartungseintrag nicht gefunden'); + } + + const entry = result.rows[0] as FahrzeugWartungslog; + + // Auto-update next service date on the vehicle when result is 'bestanden' + if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) { + await pool.query( + `UPDATE fahrzeuge SET naechste_wartung_am = $1 WHERE id = $2`, + [data.naechste_faelligkeit, fahrzeugId] + ); + } + + logger.info('Wartungslog entry updated', { wartungId, fahrzeugId, by: updatedBy }); + return entry; + } catch (error) { + logger.error('VehicleService.updateWartungslog failed', { error, wartungId, fahrzeugId }); + throw error; + } + } + async getWartungslogForVehicle(fahrzeugId: string): Promise { try { const result = await pool.query( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3dbb82f..b802899 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import Wissen from './pages/Wissen'; import Bestellungen from './pages/Bestellungen'; import BestellungDetail from './pages/BestellungDetail'; import Shop from './pages/Shop'; +import Issues from './pages/Issues'; import AdminDashboard from './pages/AdminDashboard'; import AdminSettings from './pages/AdminSettings'; import NotFound from './pages/NotFound'; @@ -243,6 +244,14 @@ function App() { } /> + + + + } + /> ({ + queryKey: ['admin', 'users'], + queryFn: adminApi.getUsers, + }); + + const [selectedUser, setSelectedUser] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + + const handleDelete = async () => { + if (!selectedUser) return; + setDeleting(true); + try { + await adminApi.deleteUserProfile(selectedUser.id); + showSuccess(`Profildaten fuer ${selectedUser.name || selectedUser.email} geloescht`); + setSelectedUser(null); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Fehler beim Loeschen'; + showError(msg); + } finally { + setDeleting(false); + setConfirmOpen(false); + } + }; + + return ( + + Debug-Werkzeuge + + Werkzeuge fuer Fehlersuche und Datenbereinigung. + + + + + Profildaten loeschen + + + Loescht die synchronisierten Profildaten (mitglieder_profile) eines Benutzers. + Beim naechsten Login werden die Daten erneut von Authentik und FDISK synchronisiert. + + + + setSelectedUser(v)} + getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`} + isOptionEqualToValue={(a, b) => a.id === b.id} + sx={{ minWidth: 320, flex: 1 }} + renderInput={(params) => ( + + )} + /> + + + + + + !deleting && setConfirmOpen(false)}> + Profildaten loeschen? + + + Profildaten fuer {selectedUser?.name || selectedUser?.email} werden geloescht. + Beim naechsten Login werden die Daten erneut synchronisiert. + + + + + + + + + ); +} diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 71422a7..97146a7 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -27,6 +27,7 @@ import { ExpandLess, LocalShipping, Store, + BugReport, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; @@ -65,6 +66,7 @@ const adminSubItems: SubItem[] = [ { text: 'Berechtigungen', path: '/admin?tab=7' }, { text: 'Bestellungen', path: '/admin?tab=8' }, { text: 'Datenverwaltung', path: '/admin?tab=9' }, + { text: 'Debug', path: '/admin?tab=10' }, ]; const baseNavigationItems: NavigationItem[] = [ @@ -124,8 +126,24 @@ const baseNavigationItems: NavigationItem[] = [ text: 'Shop', icon: , path: '/shop', + subItems: [ + { text: 'Katalog', path: '/shop?tab=0' }, + { text: 'Meine Anfragen', path: '/shop?tab=1' }, + { text: 'Alle Anfragen', path: '/shop?tab=2' }, + { text: 'Übersicht', path: '/shop?tab=3' }, + ], permission: 'shop:view', }, + { + text: 'Issues', + icon: , + path: '/issues', + subItems: [ + { text: 'Meine Issues', path: '/issues?tab=0' }, + { text: 'Alle Issues', path: '/issues?tab=1' }, + ], + permission: 'issues:create', + }, ]; const adminItem: NavigationItem = { diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 2cd2804..9673eef 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -12,6 +12,7 @@ import FdiskSyncTab from '../components/admin/FdiskSyncTab'; import PermissionMatrixTab from '../components/admin/PermissionMatrixTab'; import BestellungenTab from '../components/admin/BestellungenTab'; import DataManagementTab from '../components/admin/DataManagementTab'; +import DebugTab from '../components/admin/DebugTab'; import { usePermissionContext } from '../contexts/PermissionContext'; interface TabPanelProps { @@ -25,7 +26,7 @@ function TabPanel({ children, value, index }: TabPanelProps) { return {children}; } -const ADMIN_TAB_COUNT = 10; +const ADMIN_TAB_COUNT = 11; function AdminDashboard() { const navigate = useNavigate(); @@ -61,6 +62,7 @@ function AdminDashboard() { + @@ -94,6 +96,9 @@ function AdminDashboard() { + + + ); } diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index f0a6bd5..9718f32 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -61,6 +61,7 @@ import { AusruestungStatusLabel, UpdateAusruestungStatusPayload, CreateAusruestungWartungslogPayload, + UpdateAusruestungWartungslogPayload, } from '../types/equipment.types'; import { usePermissions } from '../hooks/usePermissions'; import { useNotification } from '../contexts/NotificationContext'; @@ -422,6 +423,7 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [editingEntry, setEditingEntry] = useState(null); const emptyForm: CreateAusruestungWartungslogPayload = { datum: '', @@ -430,10 +432,33 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd ergebnis: undefined, kosten: undefined, pruefende_stelle: undefined, + naechste_pruefung_am: undefined, }; const [form, setForm] = useState(emptyForm); + const openAddDialog = () => { + setEditingEntry(null); + setForm(emptyForm); + setSaveError(null); + setDialogOpen(true); + }; + + const openEditDialog = (entry: AusruestungWartungslog) => { + setEditingEntry(entry); + setForm({ + datum: entry.datum ? new Date(entry.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '', + art: entry.art, + beschreibung: entry.beschreibung, + ergebnis: entry.ergebnis ?? undefined, + kosten: entry.kosten ?? undefined, + pruefende_stelle: entry.pruefende_stelle ?? undefined, + naechste_pruefung_am: undefined, + }); + setSaveError(null); + setDialogOpen(true); + }; + const handleSubmit = async () => { if (!form.datum || !form.art || !form.beschreibung.trim()) { setSaveError('Datum, Art und Beschreibung sind erforderlich.'); @@ -442,14 +467,33 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd try { setSaving(true); setSaveError(null); - await equipmentApi.addWartungslog(equipmentId, { - ...form, - datum: fromGermanDate(form.datum) || form.datum, - pruefende_stelle: form.pruefende_stelle || undefined, - ergebnis: form.ergebnis || undefined, - }); + const datumIso = fromGermanDate(form.datum) || form.datum; + const naechstePruefungIso = form.naechste_pruefung_am + ? (fromGermanDate(form.naechste_pruefung_am) || form.naechste_pruefung_am) + : undefined; + + if (editingEntry) { + const payload: UpdateAusruestungWartungslogPayload = { + datum: datumIso, + art: form.art, + beschreibung: form.beschreibung, + ergebnis: form.ergebnis || null, + pruefende_stelle: form.pruefende_stelle || null, + naechste_pruefung_am: naechstePruefungIso || null, + }; + await equipmentApi.updateWartungslog(equipmentId, editingEntry.id, payload); + } else { + await equipmentApi.addWartungslog(equipmentId, { + ...form, + datum: datumIso, + pruefende_stelle: form.pruefende_stelle || undefined, + ergebnis: form.ergebnis || undefined, + naechste_pruefung_am: naechstePruefungIso, + }); + } setDialogOpen(false); setForm(emptyForm); + setEditingEntry(null); onAdded(); } catch { setSaveError('Wartungseintrag konnte nicht gespeichert werden.'); @@ -463,6 +507,8 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd (a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime() ); + const showNaechstePruefung = form.ergebnis === 'bestanden'; + return ( {sorted.length === 0 ? ( @@ -496,13 +542,19 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd )} {entry.beschreibung} - - {[ - entry.kosten != null && `${Number(entry.kosten).toFixed(2)} EUR`, - entry.pruefende_stelle && entry.pruefende_stelle, - ].filter(Boolean).join(' · ')} - + {entry.pruefende_stelle && ( + + {entry.pruefende_stelle} + + )} + {canWrite && ( + + openEditDialog(entry)}> + + + + )} ); })} @@ -513,14 +565,14 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }} + onClick={openAddDialog} > )} setDialogOpen(false)} maxWidth="sm" fullWidth> - Wartung / Prüfung eintragen + {editingEntry ? 'Wartungseintrag bearbeiten' : 'Wartung / Prüfung eintragen'} {saveError && {saveError}} @@ -585,21 +637,6 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd - - setForm((f) => ({ - ...f, - kosten: e.target.value ? Number(e.target.value) : undefined, - })) - } - inputProps={{ min: 0, step: 0.01 }} - /> - - = ({ equipmentId, wartungslog, onAdd placeholder="Name der prüfenden Stelle oder Person" /> + {showNaechstePruefung && ( + + setForm((f) => ({ ...f, naechste_pruefung_am: e.target.value }))} + InputLabelProps={{ shrink: true }} + helperText="Wird als nächster Prüftermin übernommen" + /> + + )} diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index 5453f78..d8889fc 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -124,7 +124,7 @@ export default function Bestellungen() { setOrderForm({ ...emptyOrderForm }); navigate(`/bestellungen/${created.id}`); }, - onError: () => showError('Fehler beim Erstellen der Bestellung'), + onError: (error: any) => showError(error?.response?.data?.message || 'Fehler beim Erstellen der Bestellung'), }); const createVendor = useMutation({ diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index bfd0424..dc7d600 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -65,8 +65,12 @@ import { FahrzeugStatus, FahrzeugStatusLabel, CreateWartungslogPayload, + UpdateWartungslogPayload, UpdateStatusPayload, WartungslogArt, + WartungslogErgebnis, + WartungslogErgebnisLabel, + WartungslogErgebnisColor, OverlappingBooking, } from '../types/vehicle.types'; import type { AusruestungListItem } from '../types/equipment.types'; @@ -470,6 +474,7 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [editingWartungId, setEditingWartungId] = useState(null); const emptyForm: CreateWartungslogPayload = { datum: '', @@ -479,10 +484,36 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde kraftstoff_liter: undefined, kosten: undefined, externe_werkstatt: '', + ergebnis: undefined, + naechste_faelligkeit: '', }; const [form, setForm] = useState(emptyForm); + const openCreateDialog = () => { + setEditingWartungId(null); + setForm(emptyForm); + setSaveError(null); + setDialogOpen(true); + }; + + const openEditDialog = (entry: FahrzeugWartungslog) => { + setEditingWartungId(entry.id); + setForm({ + datum: entry.datum ? new Date(entry.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '', + art: entry.art ?? undefined, + beschreibung: entry.beschreibung, + km_stand: entry.km_stand ?? undefined, + kraftstoff_liter: entry.kraftstoff_liter ?? undefined, + kosten: entry.kosten ?? undefined, + externe_werkstatt: entry.externe_werkstatt ?? '', + ergebnis: entry.ergebnis ?? undefined, + naechste_faelligkeit: entry.naechste_faelligkeit ? entry.naechste_faelligkeit.slice(0, 10) : '', + }); + setSaveError(null); + setDialogOpen(true); + }; + const handleSubmit = async () => { if (!form.datum || !form.beschreibung.trim()) { setSaveError('Datum und Beschreibung sind erforderlich.'); @@ -491,13 +522,29 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde try { setSaving(true); setSaveError(null); - await vehiclesApi.addWartungslog(fahrzeugId, { - ...form, - datum: fromGermanDate(form.datum) || form.datum, - externe_werkstatt: form.externe_werkstatt || undefined, - }); + const isoDate = fromGermanDate(form.datum) || form.datum; + if (editingWartungId) { + const payload: UpdateWartungslogPayload = { + datum: isoDate, + art: form.art, + beschreibung: form.beschreibung, + km_stand: form.km_stand, + externe_werkstatt: form.externe_werkstatt || undefined, + ergebnis: form.ergebnis, + naechste_faelligkeit: form.naechste_faelligkeit || undefined, + }; + await vehiclesApi.updateWartungslog(fahrzeugId, editingWartungId, payload); + } else { + await vehiclesApi.addWartungslog(fahrzeugId, { + ...form, + datum: isoDate, + externe_werkstatt: form.externe_werkstatt || undefined, + naechste_faelligkeit: form.naechste_faelligkeit || undefined, + }); + } setDialogOpen(false); setForm(emptyForm); + setEditingWartungId(null); onAdded(); } catch { setSaveError('Wartungseintrag konnte nicht gespeichert werden.'); @@ -521,14 +568,20 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde {fmtDate(entry.datum)} {entry.art && } + {entry.ergebnis && ( + + )} {entry.beschreibung} {[ entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`, - entry.kraftstoff_liter != null && `${Number(entry.kraftstoff_liter).toFixed(1)} L`, - entry.kosten != null && `${Number(entry.kosten).toFixed(2)} €`, entry.externe_werkstatt && entry.externe_werkstatt, + entry.naechste_faelligkeit && `Nächste Fälligkeit: ${fmtDate(entry.naechste_faelligkeit)}`, ].filter(Boolean).join(' · ')} {entry.dokument_url ? ( @@ -568,6 +621,11 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde ) : null} + {canWrite && ( + openEditDialog(entry)} aria-label="Bearbeiten"> + + + )} ); })} @@ -578,14 +636,14 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde { setForm(emptyForm); setDialogOpen(true); }} + onClick={openCreateDialog} > )} setDialogOpen(false)} maxWidth="sm" fullWidth> - Wartung / Service eintragen + {editingWartungId ? 'Wartungseintrag bearbeiten' : 'Wartung / Service eintragen'} {saveError && {saveError}} @@ -624,7 +682,7 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))} /> - + = ({ fahrzeugId, wartungslog, onAdde inputProps={{ min: 0 }} /> - - setForm((f) => ({ ...f, kraftstoff_liter: e.target.value ? Number(e.target.value) : undefined }))} - inputProps={{ min: 0, step: 0.1 }} - /> - - - setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))} - inputProps={{ min: 0, step: 0.01 }} - /> - - + = ({ fahrzeugId, wartungslog, onAdde placeholder="Name der Werkstatt (wenn extern)" /> + + + Ergebnis + + + + + setForm((f) => ({ ...f, naechste_faelligkeit: e.target.value }))} + InputLabelProps={{ shrink: true }} + /> + diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx new file mode 100644 index 0000000..e92c912 --- /dev/null +++ b/frontend/src/pages/Issues.tsx @@ -0,0 +1,471 @@ +import { useState } from 'react'; +import { + Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer, + TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle, + DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, + InputLabel, Collapse, Divider, CircularProgress, +} from '@mui/material'; +import { + Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess, + BugReport, FiberNew, HelpOutline, Send as SendIcon, + Circle as CircleIcon, +} from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import ChatAwareFab from '../components/shared/ChatAwareFab'; +import { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { useAuth } from '../contexts/AuthContext'; +import { issuesApi } from '../services/issues'; +import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types'; + +// ── Helpers ── + +const formatDate = (iso?: string) => + iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'; + +const STATUS_COLORS: Record = { + offen: 'info', + in_bearbeitung: 'warning', + erledigt: 'success', + abgelehnt: 'error', +}; + +const STATUS_LABELS: Record = { + offen: 'Offen', + in_bearbeitung: 'In Bearbeitung', + erledigt: 'Erledigt', + abgelehnt: 'Abgelehnt', +}; + +const TYP_ICONS: Record = { + bug: , + feature: , + sonstiges: , +}; + +const TYP_LABELS: Record = { + bug: 'Bug', + feature: 'Feature', + sonstiges: 'Sonstiges', +}; + +const PRIO_COLORS: Record = { + hoch: '#d32f2f', + mittel: '#ed6c02', + niedrig: '#9e9e9e', +}; + +const PRIO_LABELS: Record = { + hoch: 'Hoch', + mittel: 'Mittel', + niedrig: 'Niedrig', +}; + +// ── Tab Panel ── + +interface TabPanelProps { children: React.ReactNode; index: number; value: number } +function TabPanel({ children, value, index }: TabPanelProps) { + if (value !== index) return null; + return {children}; +} + +// ── Comment Section ── + +function CommentSection({ issueId }: { issueId: number }) { + const queryClient = useQueryClient(); + const { showError } = useNotification(); + const [text, setText] = useState(''); + + const { data: comments = [], isLoading } = useQuery({ + queryKey: ['issues', issueId, 'comments'], + queryFn: () => issuesApi.getComments(issueId), + }); + + const addMut = useMutation({ + mutationFn: (inhalt: string) => issuesApi.addComment(issueId, inhalt), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issues', issueId, 'comments'] }); + setText(''); + }, + onError: () => showError('Kommentar konnte nicht erstellt werden'), + }); + + return ( + + Kommentare + {isLoading ? ( + + ) : comments.length === 0 ? ( + Noch keine Kommentare + ) : ( + comments.map((c: IssueComment) => ( + + + {c.autor_name || 'Unbekannt'} - {formatDate(c.created_at)} + + {c.inhalt} + + )) + )} + + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && text.trim()) { + e.preventDefault(); + addMut.mutate(text.trim()); + } + }} + multiline + maxRows={4} + /> + addMut.mutate(text.trim())} + > + + + + + ); +} + +// ── Issue Row ── + +function IssueRow({ + issue, + canManage, + isOwner, + onDelete, +}: { + issue: Issue; + canManage: boolean; + isOwner: boolean; + onDelete: (id: number) => void; +}) { + const [expanded, setExpanded] = useState(false); + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + + const updateMut = useMutation({ + mutationFn: (data: UpdateIssuePayload) => issuesApi.updateIssue(issue.id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issues'] }); + showSuccess('Issue aktualisiert'); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + return ( + <> + *': { borderBottom: expanded ? 'unset' : undefined } }} + onClick={() => setExpanded(!expanded)} + > + #{issue.id} + + + {TYP_ICONS[issue.typ]} + {issue.titel} + + + + + + + + + {PRIO_LABELS[issue.prioritaet]} + + + + + + {issue.erstellt_von_name || '-'} + {formatDate(issue.created_at)} + + { e.stopPropagation(); setExpanded(!expanded); }}> + {expanded ? : } + + + + + + + + {issue.beschreibung && ( + + {issue.beschreibung} + + )} + {issue.zugewiesen_an_name && ( + + Zugewiesen an: {issue.zugewiesen_an_name} + + )} + + {canManage && ( + + + Status + + + + Priorität + + + + )} + + {(canManage || isOwner) && ( + + )} + + + + + + + + + ); +} + +// ── Issue Table ── + +function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: boolean; userId: string }) { + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + + const deleteMut = useMutation({ + mutationFn: (id: number) => issuesApi.deleteIssue(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issues'] }); + showSuccess('Issue gelöscht'); + }, + onError: () => showError('Fehler beim Löschen'), + }); + + if (issues.length === 0) { + return ( + + Keine Issues vorhanden + + ); + } + + return ( + + + + + ID + Titel + Typ + Priorität + Status + Erstellt von + Erstellt am + + + + + {issues.map((issue) => ( + deleteMut.mutate(id)} + /> + ))} + +
+
+ ); +} + +// ── Main Page ── + +export default function Issues() { + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = parseInt(searchParams.get('tab') || '0', 10); + const tab = isNaN(tabParam) || tabParam < 0 || tabParam > 1 ? 0 : tabParam; + + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + + const canViewAll = hasPermission('issues:view_all'); + const canManage = hasPermission('issues:manage'); + const canCreate = hasPermission('issues:create'); + const userId = user?.id || ''; + + const [createOpen, setCreateOpen] = useState(false); + const [form, setForm] = useState({ titel: '', typ: 'bug', prioritaet: 'mittel' }); + + const { data: issues = [], isLoading } = useQuery({ + queryKey: ['issues'], + queryFn: () => issuesApi.getIssues(), + }); + + const createMut = useMutation({ + mutationFn: (data: CreateIssuePayload) => issuesApi.createIssue(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['issues'] }); + showSuccess('Issue erstellt'); + setCreateOpen(false); + setForm({ titel: '', typ: 'bug', prioritaet: 'mittel' }); + }, + onError: () => showError('Fehler beim Erstellen'), + }); + + const handleTabChange = (_: unknown, newValue: number) => { + setSearchParams({ tab: String(newValue) }); + }; + + const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId); + + return ( + + + Issues + + + + {canViewAll && } + + + + {isLoading ? ( + + + + ) : ( + + )} + + + {canViewAll && ( + + {isLoading ? ( + + + + ) : ( + + )} + + )} + + + {/* Create Issue Dialog */} + setCreateOpen(false)} maxWidth="sm" fullWidth> + Neues Issue erstellen + + setForm({ ...form, titel: e.target.value })} + autoFocus + /> + setForm({ ...form, beschreibung: e.target.value })} + /> + + Typ + + + + Priorität + + + + + + + + + + {/* FAB */} + {canCreate && ( + setCreateOpen(true)} + > + + + )} + + ); +} diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index eb4178f..309f23b 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -1339,11 +1339,20 @@ function VeranstaltungFormDialog({ anmeldung_erforderlich: editingEvent.anmeldung_erforderlich, anmeldung_bis: null, }); - setWiederholungAktiv(false); - setWiederholungTyp('wöchentlich'); - setWiederholungIntervall(1); - setWiederholungBis(''); - setWiederholungWochentag(0); + // Populate recurrence fields if parent event has config (read-only display) + if (editingEvent.wiederholung) { + setWiederholungAktiv(true); + setWiederholungTyp(editingEvent.wiederholung.typ); + setWiederholungIntervall(editingEvent.wiederholung.intervall ?? 1); + setWiederholungBis(editingEvent.wiederholung.bis ?? ''); + setWiederholungWochentag(editingEvent.wiederholung.wochentag ?? 0); + } else { + setWiederholungAktiv(false); + setWiederholungTyp('wöchentlich'); + setWiederholungIntervall(1); + setWiederholungBis(''); + setWiederholungWochentag(0); + } } else { const now = new Date(); now.setMinutes(0, 0, 0); @@ -1358,6 +1367,29 @@ function VeranstaltungFormDialog({ } }, [open, editingEvent]); + // Auto-correct: end date should never be before start date + useEffect(() => { + const von = new Date(form.datum_von); + const bis = new Date(form.datum_bis); + if (!isNaN(von.getTime()) && !isNaN(bis.getTime()) && bis < von) { + // Set datum_bis to datum_von (preserve time offset for non-ganztaegig) + if (form.ganztaegig) { + handleChange('datum_bis', von.toISOString()); + } else { + const adjusted = new Date(von); + adjusted.setHours(adjusted.getHours() + 2); + handleChange('datum_bis', adjusted.toISOString()); + } + } + // Also auto-correct wiederholungBis + if (wiederholungBis) { + const vonDateOnly = form.datum_von.slice(0, 10); + if (wiederholungBis < vonDateOnly) { + setWiederholungBis(vonDateOnly); + } + } + }, [form.datum_von]); // eslint-disable-line react-hooks/exhaustive-deps + const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => { if (field === 'kategorie_id' && !editingEvent) { // Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events) @@ -1600,22 +1632,34 @@ function VeranstaltungFormDialog({ fullWidth /> )} - {/* Wiederholung (only for new events) */} - {!editingEvent && ( + {/* Wiederholung */} + {(!editingEvent || (editingEvent && editingEvent.wiederholung)) && ( <> - setWiederholungAktiv(e.target.checked)} + {editingEvent && editingEvent.wiederholung ? ( + <> + + Wiederholung kann nicht bearbeitet werden + + } + label="Wiederkehrende Veranstaltung" /> - } - label="Wiederkehrende Veranstaltung" - /> + + ) : ( + setWiederholungAktiv(e.target.checked)} + /> + } + label="Wiederkehrende Veranstaltung" + /> + )} {wiederholungAktiv && ( - + Wiederholung setWiederholungBis(e.target.value)} InputLabelProps={{ shrink: true }} fullWidth + disabled={!!editingEvent} helperText="Letztes Datum für Wiederholungen" /> diff --git a/frontend/src/pages/Shop.tsx b/frontend/src/pages/Shop.tsx index 73fe78a..f6b4796 100644 --- a/frontend/src/pages/Shop.tsx +++ b/frontend/src/pages/Shop.tsx @@ -20,9 +20,18 @@ import { usePermissionContext } from '../contexts/PermissionContext'; import { shopApi } from '../services/shop'; import { bestellungApi } from '../services/bestellung'; import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types'; -import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus } from '../types/shop.types'; +import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus, ShopAnfrage, ShopOverview } from '../types/shop.types'; import type { Bestellung } from '../types/bestellung.types'; +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatOrderId(r: ShopAnfrage): string { + if (r.bestell_jahr && r.bestell_nummer) { + return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`; + } + return `#${r.id}`; +} + // ─── Catalog Tab ──────────────────────────────────────────────────────────── interface DraftItem { @@ -291,7 +300,7 @@ function MeineAnfragenTab() { - # + Anfrage Status Positionen Erstellt am @@ -303,9 +312,9 @@ function MeineAnfragenTab() { <> setExpandedId(prev => prev === r.id ? null : r.id)}> {expandedId === r.id ? : } - {r.id} + {formatOrderId(r)} - {r.items_count ?? '-'} + {r.positionen_count ?? r.items_count ?? '-'} {new Date(r.erstellt_am).toLocaleDateString('de-AT')} {r.admin_notizen || '-'} @@ -424,7 +433,7 @@ function AlleAnfragenTab() { - # + Anfrage Anfrager Status Positionen @@ -437,10 +446,10 @@ function AlleAnfragenTab() { <> setExpandedId(prev => prev === r.id ? null : r.id)}> {expandedId === r.id ? : } - {r.id} + {formatOrderId(r)} {r.anfrager_name || r.anfrager_id} - {r.items_count ?? '-'} + {r.positionen_count ?? r.items_count ?? '-'} {new Date(r.erstellt_am).toLocaleDateString('de-AT')} e.stopPropagation()}> @@ -558,6 +567,68 @@ function AlleAnfragenTab() { ); } +// ─── Overview Tab ──────────────────────────────────────────────────────────── + +function UebersichtTab() { + const { data: overview, isLoading } = useQuery({ + queryKey: ['shop', 'overview'], + queryFn: () => shopApi.getOverview(), + }); + + if (isLoading) return Lade Übersicht...; + if (!overview) return Keine Daten verfügbar.; + + return ( + + + + + {overview.pending_count} + Offene Anfragen + + + + + {overview.approved_count} + Genehmigte Anfragen + + + + + {overview.total_items} + Artikel insgesamt + + + + + {overview.items.length === 0 ? ( + Keine offenen/genehmigten Anfragen vorhanden. + ) : ( + + + + + Artikel + Gesamtmenge + Anfragen + + + + {overview.items.map(item => ( + + {item.bezeichnung} + {item.total_menge} + {item.anfrage_count} + + ))} + +
+
+ )} +
+ ); +} + // ─── Main Page ────────────────────────────────────────────────────────────── export default function Shop() { @@ -567,8 +638,9 @@ export default function Shop() { const canView = hasPermission('shop:view'); const canCreate = hasPermission('shop:create_request'); const canApprove = hasPermission('shop:approve_requests'); + const canViewOverview = hasPermission('shop:view_overview'); - const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0); + const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewOverview ? 1 : 0); const [activeTab, setActiveTab] = useState(() => { const t = Number(searchParams.get('tab')); @@ -584,9 +656,10 @@ export default function Shop() { const map: Record = { katalog: 0 }; let next = 1; if (canCreate) { map.meine = next; next++; } - if (canApprove) { map.alle = next; } + if (canApprove) { map.alle = next; next++; } + if (canViewOverview) { map.uebersicht = next; } return map; - }, [canCreate, canApprove]); + }, [canCreate, canApprove, canViewOverview]); if (!canView) { return ( @@ -605,12 +678,14 @@ export default function Shop() { {canCreate && } {canApprove && } + {canViewOverview && }
{activeTab === tabIndex.katalog && } {canCreate && activeTab === tabIndex.meine && } {canApprove && activeTab === tabIndex.alle && } + {canViewOverview && activeTab === tabIndex.uebersicht && } ); } diff --git a/frontend/src/services/admin.ts b/frontend/src/services/admin.ts index 759351d..22bf5d9 100644 --- a/frontend/src/services/admin.ts +++ b/frontend/src/services/admin.ts @@ -30,4 +30,5 @@ export const adminApi = { getPingHistory: (serviceId: string) => api.get>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data), fdiskSyncLogs: () => api.get>('/api/admin/fdisk-sync/logs').then(r => r.data.data), fdiskSyncTrigger: (force = false) => api.post>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data), + deleteUserProfile: (userId: string) => api.delete>(`/api/admin/debug/user/${userId}/profile`).then(r => r.data), }; diff --git a/frontend/src/services/equipment.ts b/frontend/src/services/equipment.ts index b722758..d0e9822 100644 --- a/frontend/src/services/equipment.ts +++ b/frontend/src/services/equipment.ts @@ -10,6 +10,7 @@ import type { UpdateAusruestungPayload, UpdateAusruestungStatusPayload, CreateAusruestungWartungslogPayload, + UpdateAusruestungWartungslogPayload, } from '../types/equipment.types'; async function unwrap( @@ -121,4 +122,19 @@ export const equipmentApi = { ); return response.data.data ?? []; }, + + async updateWartungslog( + equipmentId: string, + wartungId: string, + payload: UpdateAusruestungWartungslogPayload + ): Promise { + const response = await api.patch<{ success: boolean; data: AusruestungWartungslog }>( + `/api/equipment/${equipmentId}/wartung/${wartungId}`, + payload + ); + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; + }, }; diff --git a/frontend/src/services/issues.ts b/frontend/src/services/issues.ts new file mode 100644 index 0000000..175462c --- /dev/null +++ b/frontend/src/services/issues.ts @@ -0,0 +1,32 @@ +import { api } from './api'; +import type { Issue, IssueComment, CreateIssuePayload, UpdateIssuePayload } from '../types/issue.types'; + +export const issuesApi = { + getIssues: async (): Promise => { + const r = await api.get('/api/issues'); + return r.data.data; + }, + getIssue: async (id: number): Promise => { + const r = await api.get(`/api/issues/${id}`); + return r.data.data; + }, + createIssue: async (data: CreateIssuePayload): Promise => { + const r = await api.post('/api/issues', data); + return r.data.data; + }, + updateIssue: async (id: number, data: UpdateIssuePayload): Promise => { + const r = await api.patch(`/api/issues/${id}`, data); + return r.data.data; + }, + deleteIssue: async (id: number): Promise => { + await api.delete(`/api/issues/${id}`); + }, + getComments: async (issueId: number): Promise => { + const r = await api.get(`/api/issues/${issueId}/comments`); + return r.data.data; + }, + addComment: async (issueId: number, inhalt: string): Promise => { + const r = await api.post(`/api/issues/${issueId}/comments`, { inhalt }); + return r.data.data; + }, +}; diff --git a/frontend/src/services/shop.ts b/frontend/src/services/shop.ts index 1e6737c..55ed924 100644 --- a/frontend/src/services/shop.ts +++ b/frontend/src/services/shop.ts @@ -5,6 +5,7 @@ import type { ShopAnfrage, ShopAnfrageDetailResponse, ShopAnfrageFormItem, + ShopOverview, } from '../types/shop.types'; export const shopApi = { @@ -70,4 +71,10 @@ export const shopApi = { unlinkFromOrder: async (anfrageId: number, bestellungId: number): Promise => { await api.delete(`/api/shop/requests/${anfrageId}/link/${bestellungId}`); }, + + // ── Overview ── + getOverview: async (): Promise => { + const r = await api.get('/api/shop/overview'); + return r.data.data; + }, }; diff --git a/frontend/src/services/vehicles.ts b/frontend/src/services/vehicles.ts index 4ba9d80..8337b66 100644 --- a/frontend/src/services/vehicles.ts +++ b/frontend/src/services/vehicles.ts @@ -9,6 +9,7 @@ import type { UpdateFahrzeugPayload, UpdateStatusPayload, CreateWartungslogPayload, + UpdateWartungslogPayload, StatusUpdateResponse, } from '../types/vehicle.types'; @@ -94,6 +95,17 @@ export const vehiclesApi = { return response.data.data; }, + async updateWartungslog(vehicleId: string, wartungId: string, payload: UpdateWartungslogPayload): Promise { + const response = await api.patch<{ success: boolean; data: FahrzeugWartungslog }>( + `/api/vehicles/${vehicleId}/wartung/${wartungId}`, + payload + ); + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; + }, + async exportAlerts(): Promise { const response = await api.get('/api/vehicles/alerts/export', { responseType: 'blob', diff --git a/frontend/src/types/equipment.types.ts b/frontend/src/types/equipment.types.ts index 3353bb7..b556f97 100644 --- a/frontend/src/types/equipment.types.ts +++ b/frontend/src/types/equipment.types.ts @@ -128,11 +128,22 @@ export interface UpdateAusruestungStatusPayload { } export interface CreateAusruestungWartungslogPayload { - datum: string; - art: AusruestungWartungslogArt; - beschreibung: string; - ergebnis?: string; - kosten?: number; - pruefende_stelle?: string; - dokument_url?: string; + datum: string; + art: AusruestungWartungslogArt; + beschreibung: string; + ergebnis?: string; + kosten?: number; + pruefende_stelle?: string; + dokument_url?: string; + naechste_pruefung_am?: string; +} + +export interface UpdateAusruestungWartungslogPayload { + datum?: string; + art?: AusruestungWartungslogArt; + beschreibung?: string; + ergebnis?: string | null; + kosten?: number | null; + pruefende_stelle?: string | null; + naechste_pruefung_am?: string | null; } diff --git a/frontend/src/types/issue.types.ts b/frontend/src/types/issue.types.ts new file mode 100644 index 0000000..78f4031 --- /dev/null +++ b/frontend/src/types/issue.types.ts @@ -0,0 +1,39 @@ +export interface Issue { + id: number; + titel: string; + beschreibung: string | null; + typ: 'bug' | 'feature' | 'sonstiges'; + prioritaet: 'niedrig' | 'mittel' | 'hoch'; + status: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt'; + erstellt_von: string; + erstellt_von_name?: string; + zugewiesen_an: string | null; + zugewiesen_an_name?: string | null; + created_at: string; + updated_at: string; +} + +export interface IssueComment { + id: number; + issue_id: number; + autor_id: string; + autor_name?: string; + inhalt: string; + created_at: string; +} + +export interface CreateIssuePayload { + titel: string; + beschreibung?: string; + typ?: 'bug' | 'feature' | 'sonstiges'; + prioritaet?: 'niedrig' | 'mittel' | 'hoch'; +} + +export interface UpdateIssuePayload { + titel?: string; + beschreibung?: string; + typ?: 'bug' | 'feature' | 'sonstiges'; + prioritaet?: 'niedrig' | 'mittel' | 'hoch'; + status?: 'offen' | 'in_bearbeitung' | 'erledigt' | 'abgelehnt'; + zugewiesen_an?: string | null; +} diff --git a/frontend/src/types/shop.types.ts b/frontend/src/types/shop.types.ts index da0321c..1eab823 100644 --- a/frontend/src/types/shop.types.ts +++ b/frontend/src/types/shop.types.ts @@ -52,8 +52,11 @@ export interface ShopAnfrage { admin_notizen?: string; bearbeitet_von?: string; bearbeitet_von_name?: string; + bestell_nummer?: number; + bestell_jahr?: number; erstellt_am: string; aktualisiert_am: string; + positionen_count?: number; items_count?: number; } @@ -81,3 +84,18 @@ export interface ShopAnfrageDetailResponse { positionen: ShopAnfragePosition[]; linked_bestellungen?: { id: number; bezeichnung: string; status: string }[]; } + +// ── Overview ── + +export interface ShopOverviewItem { + bezeichnung: string; + total_menge: number; + anfrage_count: number; +} + +export interface ShopOverview { + items: ShopOverviewItem[]; + pending_count: number; + approved_count: number; + total_items: number; +} diff --git a/frontend/src/types/vehicle.types.ts b/frontend/src/types/vehicle.types.ts index 3767dee..11209ad 100644 --- a/frontend/src/types/vehicle.types.ts +++ b/frontend/src/types/vehicle.types.ts @@ -48,6 +48,23 @@ export interface FahrzeugListItem { aktiver_lehrgang: AktiverLehrgang | null; } +export type WartungslogErgebnis = + | 'bestanden' + | 'bestanden_mit_maengeln' + | 'nicht_bestanden'; + +export const WartungslogErgebnisLabel: Record = { + bestanden: 'Bestanden', + bestanden_mit_maengeln: 'Bestanden mit Mängeln', + nicht_bestanden: 'Nicht bestanden', +}; + +export const WartungslogErgebnisColor: Record = { + bestanden: 'success', + bestanden_mit_maengeln: 'warning', + nicht_bestanden: 'error', +}; + export interface FahrzeugWartungslog { id: string; fahrzeug_id: string; @@ -58,6 +75,8 @@ export interface FahrzeugWartungslog { kraftstoff_liter: number | null; kosten: number | null; externe_werkstatt: string | null; + ergebnis: WartungslogErgebnis | null; + naechste_faelligkeit: string | null; dokument_url: string | null; erfasst_von: string | null; created_at: string; @@ -160,4 +179,16 @@ export interface CreateWartungslogPayload { kraftstoff_liter?: number; kosten?: number; externe_werkstatt?: string; + ergebnis?: WartungslogErgebnis; + naechste_faelligkeit?: string; +} + +export interface UpdateWartungslogPayload { + datum: string; + art?: WartungslogArt; + beschreibung: string; + km_stand?: number; + externe_werkstatt?: string; + ergebnis?: WartungslogErgebnis; + naechste_faelligkeit?: string; }