diff --git a/backend/src/app.ts b/backend/src/app.ts index 48e4794..56fad3e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -101,7 +101,7 @@ import serviceMonitorRoutes from './routes/serviceMonitor.routes'; 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 ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes'; import issueRoutes from './routes/issue.routes'; app.use('/api/auth', authRoutes); @@ -126,7 +126,7 @@ app.use('/api/admin/settings', settingsRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/banners', bannerRoutes); app.use('/api/permissions', permissionRoutes); -app.use('/api/shop', shopRoutes); +app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes); app.use('/api/issues', issueRoutes); // Static file serving for uploads (authenticated) diff --git a/backend/src/controllers/shop.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts similarity index 74% rename from backend/src/controllers/shop.controller.ts rename to backend/src/controllers/ausruestungsanfrage.controller.ts index 2a26b22..358fe61 100644 --- a/backend/src/controllers/shop.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -1,9 +1,9 @@ import { Request, Response } from 'express'; -import shopService from '../services/shop.service'; +import ausruestungsanfrageService from '../services/ausruestungsanfrage.service'; import notificationService from '../services/notification.service'; import logger from '../utils/logger'; -class ShopController { +class AusruestungsanfrageController { // ------------------------------------------------------------------------- // Catalog Items // ------------------------------------------------------------------------- @@ -12,10 +12,10 @@ class ShopController { try { const kategorie = req.query.kategorie as string | undefined; const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined; - const items = await shopService.getItems({ kategorie, aktiv }); + const items = await ausruestungsanfrageService.getItems({ kategorie, aktiv }); res.status(200).json({ success: true, data: items }); } catch (error) { - logger.error('ShopController.getItems error', { error }); + logger.error('AusruestungsanfrageController.getItems error', { error }); res.status(500).json({ success: false, message: 'Artikel konnten nicht geladen werden' }); } } @@ -23,14 +23,14 @@ class ShopController { async getItemById(req: Request, res: Response): Promise { try { const id = Number(req.params.id); - const item = await shopService.getItemById(id); + const item = await ausruestungsanfrageService.getItemById(id); if (!item) { res.status(404).json({ success: false, message: 'Artikel nicht gefunden' }); return; } res.status(200).json({ success: true, data: item }); } catch (error) { - logger.error('ShopController.getItemById error', { error }); + logger.error('AusruestungsanfrageController.getItemById error', { error }); res.status(500).json({ success: false, message: 'Artikel konnte nicht geladen werden' }); } } @@ -42,10 +42,10 @@ class ShopController { res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' }); return; } - const item = await shopService.createItem(req.body, req.user!.id); + const item = await ausruestungsanfrageService.createItem(req.body, req.user!.id); res.status(201).json({ success: true, data: item }); } catch (error) { - logger.error('ShopController.createItem error', { error }); + logger.error('AusruestungsanfrageController.createItem error', { error }); res.status(500).json({ success: false, message: 'Artikel konnte nicht erstellt werden' }); } } @@ -53,14 +53,14 @@ class ShopController { async updateItem(req: Request, res: Response): Promise { try { const id = Number(req.params.id); - const item = await shopService.updateItem(id, req.body, req.user!.id); + const item = await ausruestungsanfrageService.updateItem(id, req.body, req.user!.id); if (!item) { res.status(404).json({ success: false, message: 'Artikel nicht gefunden' }); return; } res.status(200).json({ success: true, data: item }); } catch (error) { - logger.error('ShopController.updateItem error', { error }); + logger.error('AusruestungsanfrageController.updateItem error', { error }); res.status(500).json({ success: false, message: 'Artikel konnte nicht aktualisiert werden' }); } } @@ -68,20 +68,20 @@ class ShopController { async deleteItem(req: Request, res: Response): Promise { try { const id = Number(req.params.id); - await shopService.deleteItem(id); + await ausruestungsanfrageService.deleteItem(id); res.status(200).json({ success: true, message: 'Artikel gelöscht' }); } catch (error) { - logger.error('ShopController.deleteItem error', { error }); + logger.error('AusruestungsanfrageController.deleteItem error', { error }); res.status(500).json({ success: false, message: 'Artikel konnte nicht gelöscht werden' }); } } async getCategories(_req: Request, res: Response): Promise { try { - const categories = await shopService.getCategories(); + const categories = await ausruestungsanfrageService.getCategories(); res.status(200).json({ success: true, data: categories }); } catch (error) { - logger.error('ShopController.getCategories error', { error }); + logger.error('AusruestungsanfrageController.getCategories error', { error }); res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' }); } } @@ -94,20 +94,20 @@ class ShopController { try { const status = req.query.status as string | undefined; const anfrager_id = req.query.anfrager_id as string | undefined; - const requests = await shopService.getRequests({ status, anfrager_id }); + const requests = await ausruestungsanfrageService.getRequests({ status, anfrager_id }); res.status(200).json({ success: true, data: requests }); } catch (error) { - logger.error('ShopController.getRequests error', { error }); + logger.error('AusruestungsanfrageController.getRequests error', { error }); res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' }); } } async getMyRequests(req: Request, res: Response): Promise { try { - const requests = await shopService.getMyRequests(req.user!.id); + const requests = await ausruestungsanfrageService.getMyRequests(req.user!.id); res.status(200).json({ success: true, data: requests }); } catch (error) { - logger.error('ShopController.getMyRequests error', { error }); + logger.error('AusruestungsanfrageController.getMyRequests error', { error }); res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' }); } } @@ -115,14 +115,14 @@ class ShopController { async getRequestById(req: Request, res: Response): Promise { try { const id = Number(req.params.id); - const request = await shopService.getRequestById(id); + const request = await ausruestungsanfrageService.getRequestById(id); if (!request) { res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' }); return; } res.status(200).json({ success: true, data: request }); } catch (error) { - logger.error('ShopController.getRequestById error', { error }); + logger.error('AusruestungsanfrageController.getRequestById error', { error }); res.status(500).json({ success: false, message: 'Anfrage konnte nicht geladen werden' }); } } @@ -150,10 +150,10 @@ class ShopController { } } - const request = await shopService.createRequest(req.user!.id, items, notizen); + const request = await ausruestungsanfrageService.createRequest(req.user!.id, items, notizen); res.status(201).json({ success: true, data: request }); } catch (error) { - logger.error('ShopController.createRequest error', { error }); + logger.error('AusruestungsanfrageController.createRequest error', { error }); res.status(500).json({ success: false, message: 'Anfrage konnte nicht erstellt werden' }); } } @@ -178,13 +178,13 @@ class ShopController { } // Fetch request to get anfrager_id for notification - const existing = await shopService.getRequestById(id); + const existing = await ausruestungsanfrageService.getRequestById(id); if (!existing) { res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' }); return; } - const updated = await shopService.updateRequestStatus(id, status, admin_notizen, req.user!.id); + const updated = await ausruestungsanfrageService.updateRequestStatus(id, status, admin_notizen, req.user!.id); // Notify requester on status changes if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) { @@ -193,19 +193,19 @@ class ShopController { : `#${id}`; await notificationService.createNotification({ user_id: existing.anfrager_id, - typ: 'shop_anfrage', + typ: 'ausruestung_anfrage', titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`, - nachricht: `Deine Shop-Anfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`, + nachricht: `Deine Ausrüstungsanfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`, schwere: status === 'abgelehnt' ? 'warnung' : 'info', - link: '/shop', + link: '/ausruestungsanfrage', quell_id: String(id), - quell_typ: 'shop_anfrage', + quell_typ: 'ausruestung_anfrage', }); } res.status(200).json({ success: true, data: updated }); } catch (error) { - logger.error('ShopController.updateRequestStatus error', { error }); + logger.error('AusruestungsanfrageController.updateRequestStatus error', { error }); res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' }); } } @@ -213,10 +213,10 @@ class ShopController { async deleteRequest(req: Request, res: Response): Promise { try { const id = Number(req.params.id); - await shopService.deleteRequest(id); + await ausruestungsanfrageService.deleteRequest(id); res.status(200).json({ success: true, message: 'Anfrage gelöscht' }); } catch (error) { - logger.error('ShopController.deleteRequest error', { error }); + logger.error('AusruestungsanfrageController.deleteRequest error', { error }); res.status(500).json({ success: false, message: 'Anfrage konnte nicht gelöscht werden' }); } } @@ -227,10 +227,10 @@ class ShopController { async getOverview(_req: Request, res: Response): Promise { try { - const overview = await shopService.getOverview(); + const overview = await ausruestungsanfrageService.getOverview(); res.status(200).json({ success: true, data: overview }); } catch (error) { - logger.error('ShopController.getOverview error', { error }); + logger.error('AusruestungsanfrageController.getOverview error', { error }); res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' }); } } @@ -249,10 +249,10 @@ class ShopController { return; } - await shopService.linkToOrder(anfrageId, bestellung_id); + await ausruestungsanfrageService.linkToOrder(anfrageId, bestellung_id); res.status(200).json({ success: true, message: 'Verknüpfung erstellt' }); } catch (error) { - logger.error('ShopController.linkToOrder error', { error }); + logger.error('AusruestungsanfrageController.linkToOrder error', { error }); res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht erstellt werden' }); } } @@ -261,13 +261,13 @@ class ShopController { try { const anfrageId = Number(req.params.id); const bestellungId = Number(req.params.bestellungId); - await shopService.unlinkFromOrder(anfrageId, bestellungId); + await ausruestungsanfrageService.unlinkFromOrder(anfrageId, bestellungId); res.status(200).json({ success: true, message: 'Verknüpfung entfernt' }); } catch (error) { - logger.error('ShopController.unlinkFromOrder error', { error }); + logger.error('AusruestungsanfrageController.unlinkFromOrder error', { error }); res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht entfernt werden' }); } } } -export default new ShopController(); +export default new AusruestungsanfrageController(); diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts index ac57e38..302267f 100644 --- a/backend/src/controllers/issue.controller.ts +++ b/backend/src/controllers/issue.controller.ts @@ -67,13 +67,25 @@ class IssueController { return; } try { + const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canManage = permissionService.hasPermission(groups, 'issues:manage'); - if (!canManage) { + + const existing = await issueService.getIssueById(id); + if (!existing) { + res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); + return; + } + + const isOwner = existing.erstellt_von === userId; + if (!canManage && !isOwner) { res.status(403).json({ success: false, message: 'Keine Berechtigung' }); return; } - const issue = await issueService.updateIssue(id, req.body); + + // Owners without manage permission can only change status + const updateData = canManage ? req.body : { status: req.body.status }; + const issue = await issueService.updateIssue(id, updateData); if (!issue) { res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); return; diff --git a/backend/src/controllers/permission.controller.ts b/backend/src/controllers/permission.controller.ts index 195e100..14760c8 100644 --- a/backend/src/controllers/permission.controller.ts +++ b/backend/src/controllers/permission.controller.ts @@ -242,6 +242,64 @@ class PermissionController { res.status(500).json({ success: false, message: 'Fehler beim Laden der Benutzer' }); } } + /** + * GET /api/permissions/debug/:userId + * Returns debug info for a specific user: their groups, resolved permissions, + * and maintenance flags. Admin only. + */ + async debugUser(req: Request, res: Response): Promise { + try { + const userId = req.params.userId as string; + + // Fetch user's Authentik groups from DB + const { pool } = await import('../config/database'); + const userResult = await pool.query( + 'SELECT authentik_groups, email, name FROM users WHERE id = $1', + [userId] + ); + + if (userResult.rows.length === 0) { + res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' }); + return; + } + + const user = userResult.rows[0]; + const groups: string[] = user.authentik_groups ?? []; + const isAdmin = groups.includes('dashboard_admin'); + + // Resolve permissions for those groups + let permissions: string[]; + if (isAdmin) { + const matrix = await permissionService.getMatrix(); + permissions = matrix.permissions.map(p => p.id); + } else { + permissions = permissionService.getEffectivePermissions(groups); + } + + // Maintenance flags + const maintenance = permissionService.getMaintenanceFlags(); + const maintenanceActive = Object.entries(maintenance) + .filter(([, active]) => active) + .map(([featureGroup]) => featureGroup); + + res.json({ + success: true, + data: { + userId, + email: user.email, + name: user.name, + authentikGroups: groups, + isAdmin, + permissions, + maintenance, + maintenanceActiveFeatureGroups: maintenanceActive, + }, + }); + } catch (error) { + logger.error('Failed to debug user permissions', { error, userId: req.params.userId }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Debug-Informationen' }); + } + } } export default new PermissionController(); diff --git a/backend/src/database/migrations/046_rename_shop_to_ausruestungsanfrage.sql b/backend/src/database/migrations/046_rename_shop_to_ausruestungsanfrage.sql new file mode 100644 index 0000000..ac68141 --- /dev/null +++ b/backend/src/database/migrations/046_rename_shop_to_ausruestungsanfrage.sql @@ -0,0 +1,59 @@ +-- Migration 046: Rename Shop → Ausrüstungsanfrage +-- Renames all shop_* tables and updates permission references. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Rename tables +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE IF EXISTS shop_artikel RENAME TO ausruestung_artikel; +ALTER TABLE IF EXISTS shop_anfragen RENAME TO ausruestung_anfragen; +ALTER TABLE IF EXISTS shop_anfrage_positionen RENAME TO ausruestung_anfrage_positionen; +ALTER TABLE IF EXISTS shop_anfrage_bestellung RENAME TO ausruestung_anfrage_bestellung; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Rename indexes +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER INDEX IF EXISTS idx_shop_artikel_kategorie RENAME TO idx_ausruestung_artikel_kategorie; +ALTER INDEX IF EXISTS idx_shop_artikel_aktiv RENAME TO idx_ausruestung_artikel_aktiv; +ALTER INDEX IF EXISTS idx_shop_anfragen_anfrager RENAME TO idx_ausruestung_anfragen_anfrager; +ALTER INDEX IF EXISTS idx_shop_anfragen_status RENAME TO idx_ausruestung_anfragen_status; +ALTER INDEX IF EXISTS idx_shop_anfrage_positionen_anfrage RENAME TO idx_ausruestung_anfrage_positionen_anfrage; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Rename triggers +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TRIGGER IF EXISTS trg_shop_artikel_aktualisiert ON ausruestung_artikel RENAME TO trg_ausruestung_artikel_aktualisiert; +ALTER TRIGGER IF EXISTS trg_shop_anfragen_aktualisiert ON ausruestung_anfragen RENAME TO trg_ausruestung_anfragen_aktualisiert; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Update feature_groups +-- ═══════════════════════════════════════════════════════════════════════════ + +UPDATE feature_groups SET id = 'ausruestungsanfrage' WHERE id = 'shop'; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 5. Update permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +UPDATE permissions SET + id = REPLACE(id, 'shop:', 'ausruestungsanfrage:'), + feature_group_id = 'ausruestungsanfrage' +WHERE feature_group_id = 'shop'; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 6. Update group_permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +UPDATE group_permissions SET + permission_id = REPLACE(permission_id, 'shop:', 'ausruestungsanfrage:') +WHERE permission_id LIKE 'shop:%'; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 7. Update notification quell_typ references +-- ═══════════════════════════════════════════════════════════════════════════ + +UPDATE benachrichtigungen SET quell_typ = 'ausruestung_anfrage' WHERE quell_typ = 'shop_anfrage'; +UPDATE benachrichtigungen SET typ = 'ausruestung_anfrage' WHERE typ = 'shop_anfrage'; +UPDATE benachrichtigungen SET link = '/ausruestungsanfrage' WHERE link = '/shop'; diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index c2d92f7..de77134 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -265,6 +265,41 @@ router.delete( } ); +// --------------------------------------------------------------------------- +// Reset / Truncate endpoints (no olderThanDays, just ?confirm=true) +// --------------------------------------------------------------------------- + +type ResetTarget = 'reset-bestellungen' | 'reset-ausruestung-anfragen' | 'reset-issues' | 'issues-all'; + +const RESET_TARGETS: Record Promise<{ count: number; deleted: boolean }>> = { + 'reset-bestellungen': (c) => cleanupService.resetBestellungenSequence(c), + 'reset-ausruestung-anfragen': (c) => cleanupService.resetAusruestungAnfragenSequence(c), + 'reset-issues': (c) => cleanupService.resetIssuesSequence(c), + 'issues-all': (c) => cleanupService.resetIssuesSequence(c), +}; + +router.delete( + '/cleanup/:resetTarget(reset-bestellungen|reset-ausruestung-anfragen|reset-issues|issues-all)', + authenticate, + requirePermission('admin:write'), + async (req: Request, res: Response): Promise => { + try { + const target = req.params.resetTarget as ResetTarget; + const handler = RESET_TARGETS[target]; + if (!handler) { + res.status(400).json({ success: false, message: `Unknown reset target: ${target}` }); + return; + } + const confirm = req.query.confirm === 'true'; + const result = await handler(confirm); + res.json({ success: true, data: result }); + } catch (error) { + logger.error('Reset failed', { error, target: req.params.resetTarget }); + res.status(500).json({ success: false, message: 'Reset failed' }); + } + } +); + // --------------------------------------------------------------------------- // DELETE /api/admin/users/:userId/sync-data — selective sync data deletion // --------------------------------------------------------------------------- diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts new file mode 100644 index 0000000..c9336a2 --- /dev/null +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import ausruestungsanfrageController from '../controllers/ausruestungsanfrage.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); + +// --------------------------------------------------------------------------- +// Catalog Items +// --------------------------------------------------------------------------- + +router.get('/items', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getItems.bind(ausruestungsanfrageController)); +router.get('/items/:id', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getItemById.bind(ausruestungsanfrageController)); +router.post('/items', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.createItem.bind(ausruestungsanfrageController)); +router.patch('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.updateItem.bind(ausruestungsanfrageController)); +router.delete('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.deleteItem.bind(ausruestungsanfrageController)); + +router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getCategories.bind(ausruestungsanfrageController)); + +// --------------------------------------------------------------------------- +// Overview +// --------------------------------------------------------------------------- + +router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:view_overview'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController)); + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +router.get('/requests', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.getRequests.bind(ausruestungsanfrageController)); +router.get('/requests/my', authenticate, ausruestungsanfrageController.getMyRequests.bind(ausruestungsanfrageController)); +router.get('/requests/:id', authenticate, ausruestungsanfrageController.getRequestById.bind(ausruestungsanfrageController)); +router.post('/requests', authenticate, requirePermission('ausruestungsanfrage:create_request'), ausruestungsanfrageController.createRequest.bind(ausruestungsanfrageController)); +router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController)); +router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController)); + +// --------------------------------------------------------------------------- +// Linking requests to orders +// --------------------------------------------------------------------------- + +router.post('/requests/:id/link', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.linkToOrder.bind(ausruestungsanfrageController)); +router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.unlinkFromOrder.bind(ausruestungsanfrageController)); + +export default router; diff --git a/backend/src/routes/permission.routes.ts b/backend/src/routes/permission.routes.ts index 51c6d29..48f9d94 100644 --- a/backend/src/routes/permission.routes.ts +++ b/backend/src/routes/permission.routes.ts @@ -19,5 +19,6 @@ router.put('/admin/group/:groupName', authenticate, requirePermission('admin:wri router.delete('/admin/group/:groupName', authenticate, requirePermission('admin:write'), permissionController.deleteGroup.bind(permissionController)); router.put('/admin/bulk', authenticate, requirePermission('admin:write'), permissionController.setBulkPermissions.bind(permissionController)); router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController)); +router.get('/debug/:userId', authenticate, requirePermission('admin:write'), permissionController.debugUser.bind(permissionController)); export default router; diff --git a/backend/src/routes/shop.routes.ts b/backend/src/routes/shop.routes.ts deleted file mode 100644 index d82085b..0000000 --- a/backend/src/routes/shop.routes.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Router } from 'express'; -import shopController from '../controllers/shop.controller'; -import { authenticate } from '../middleware/auth.middleware'; -import { requirePermission } from '../middleware/rbac.middleware'; - -const router = Router(); - -// --------------------------------------------------------------------------- -// Catalog Items -// --------------------------------------------------------------------------- - -router.get('/items', authenticate, requirePermission('shop:view'), shopController.getItems.bind(shopController)); -router.get('/items/:id', authenticate, requirePermission('shop:view'), shopController.getItemById.bind(shopController)); -router.post('/items', authenticate, requirePermission('shop:manage_catalog'), shopController.createItem.bind(shopController)); -router.patch('/items/:id', authenticate, requirePermission('shop:manage_catalog'), shopController.updateItem.bind(shopController)); -router.delete('/items/:id', authenticate, requirePermission('shop:manage_catalog'), shopController.deleteItem.bind(shopController)); - -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 -// --------------------------------------------------------------------------- - -router.get('/requests', authenticate, requirePermission('shop:approve_requests'), shopController.getRequests.bind(shopController)); -router.get('/requests/my', authenticate, shopController.getMyRequests.bind(shopController)); -router.get('/requests/:id', authenticate, shopController.getRequestById.bind(shopController)); -router.post('/requests', authenticate, requirePermission('shop:create_request'), shopController.createRequest.bind(shopController)); -router.patch('/requests/:id/status', authenticate, requirePermission('shop:approve_requests'), shopController.updateRequestStatus.bind(shopController)); -router.delete('/requests/:id', authenticate, requirePermission('shop:approve_requests'), shopController.deleteRequest.bind(shopController)); - -// --------------------------------------------------------------------------- -// Linking requests to orders -// --------------------------------------------------------------------------- - -router.post('/requests/:id/link', authenticate, requirePermission('shop:link_orders'), shopController.linkToOrder.bind(shopController)); -router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('shop:link_orders'), shopController.unlinkFromOrder.bind(shopController)); - -export default router; diff --git a/backend/src/services/shop.service.ts b/backend/src/services/ausruestungsanfrage.service.ts similarity index 81% rename from backend/src/services/shop.service.ts rename to backend/src/services/ausruestungsanfrage.service.ts index 62e1541..d55cca7 100644 --- a/backend/src/services/shop.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -2,7 +2,7 @@ import pool from '../config/database'; import logger from '../utils/logger'; // --------------------------------------------------------------------------- -// Catalog Items (shop_artikel) +// Catalog Items (ausruestung_artikel) // --------------------------------------------------------------------------- async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) { @@ -20,14 +20,14 @@ async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) { const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( - `SELECT * FROM shop_artikel ${where} ORDER BY kategorie, bezeichnung`, + `SELECT * FROM ausruestung_artikel ${where} ORDER BY kategorie, bezeichnung`, params, ); return result.rows; } async function getItemById(id: number) { - const result = await pool.query('SELECT * FROM shop_artikel WHERE id = $1', [id]); + const result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]); return result.rows[0] || null; } @@ -42,7 +42,7 @@ async function createItem( userId: string, ) { const result = await pool.query( - `INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von) + `INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von) VALUES ($1, $2, $3, $4, COALESCE($5, true), $6) RETURNING *`, [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId], @@ -94,25 +94,25 @@ async function updateItem( params.push(id); const result = await pool.query( - `UPDATE shop_artikel SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`, + `UPDATE ausruestung_artikel SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`, params, ); return result.rows[0] || null; } async function deleteItem(id: number) { - await pool.query('DELETE FROM shop_artikel WHERE id = $1', [id]); + await pool.query('DELETE FROM ausruestung_artikel WHERE id = $1', [id]); } async function getCategories() { const result = await pool.query( - 'SELECT DISTINCT kategorie FROM shop_artikel WHERE kategorie IS NOT NULL ORDER BY kategorie', + 'SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL ORDER BY kategorie', ); return result.rows.map((r: { kategorie: string }) => r.kategorie); } // --------------------------------------------------------------------------- -// Requests (shop_anfragen) +// Requests (ausruestung_anfragen) // --------------------------------------------------------------------------- async function getRequests(filters?: { status?: string; anfrager_id?: string }) { @@ -133,8 +133,8 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string }) `SELECT a.*, u.vorname || ' ' || u.nachname AS anfrager_name, u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name, - (SELECT COUNT(*)::int FROM shop_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count - FROM shop_anfragen a + (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count + FROM ausruestung_anfragen a LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u2 ON u2.id = a.bearbeitet_von ${where} @@ -147,8 +147,8 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string }) async function getMyRequests(userId: string) { const result = await pool.query( `SELECT a.*, - (SELECT COUNT(*)::int FROM shop_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count - FROM shop_anfragen a + (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count + FROM ausruestung_anfragen a WHERE a.anfrager_id = $1 ORDER BY a.erstellt_am DESC`, [userId], @@ -161,7 +161,7 @@ async function getRequestById(id: number) { `SELECT a.*, u.vorname || ' ' || u.nachname AS anfrager_name, u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name - FROM shop_anfragen a + FROM ausruestung_anfragen a LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u2 ON u2.id = a.bearbeitet_von WHERE a.id = $1`, @@ -171,8 +171,8 @@ async function getRequestById(id: number) { const positionen = await pool.query( `SELECT p.*, sa.bezeichnung AS artikel_bezeichnung, sa.kategorie AS artikel_kategorie - FROM shop_anfrage_positionen p - LEFT JOIN shop_artikel sa ON sa.id = p.artikel_id + FROM ausruestung_anfrage_positionen p + LEFT JOIN ausruestung_artikel sa ON sa.id = p.artikel_id WHERE p.anfrage_id = $1 ORDER BY p.id`, [id], @@ -180,7 +180,7 @@ async function getRequestById(id: number) { const bestellungen = await pool.query( `SELECT b.* - FROM shop_anfrage_bestellung ab + FROM ausruestung_anfrage_bestellung ab JOIN bestellungen b ON b.id = ab.bestellung_id WHERE ab.anfrage_id = $1`, [id], @@ -206,14 +206,14 @@ async function createRequest( const currentYear = new Date().getFullYear(); const maxResult = await client.query( `SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr - FROM shop_anfragen + FROM ausruestung_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, bestell_nummer, bestell_jahr) + `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr) VALUES ($1, $2, $3, $4) RETURNING *`, [userId, notizen || null, nextNr, currentYear], @@ -226,7 +226,7 @@ async function createRequest( // If artikel_id is provided, copy bezeichnung from catalog if (item.artikel_id) { const artikelResult = await client.query( - 'SELECT bezeichnung FROM shop_artikel WHERE id = $1', + 'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1', [item.artikel_id], ); if (artikelResult.rows.length > 0) { @@ -235,7 +235,7 @@ async function createRequest( } await client.query( - `INSERT INTO shop_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen) + `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen) VALUES ($1, $2, $3, $4, $5)`, [anfrage.id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null], ); @@ -245,7 +245,7 @@ async function createRequest( return getRequestById(anfrage.id); } catch (error) { await client.query('ROLLBACK'); - logger.error('shopService.createRequest failed', { error }); + logger.error('ausruestungsanfrageService.createRequest failed', { error }); throw error; } finally { client.release(); @@ -259,7 +259,7 @@ async function updateRequestStatus( bearbeitetVon?: string, ) { const result = await pool.query( - `UPDATE shop_anfragen + `UPDATE ausruestung_anfragen SET status = $1, admin_notizen = COALESCE($2, admin_notizen), bearbeitet_von = COALESCE($3, bearbeitet_von), @@ -272,16 +272,16 @@ async function updateRequestStatus( } async function deleteRequest(id: number) { - await pool.query('DELETE FROM shop_anfragen WHERE id = $1', [id]); + await pool.query('DELETE FROM ausruestung_anfragen WHERE id = $1', [id]); } // --------------------------------------------------------------------------- -// Linking (shop_anfrage_bestellung) +// Linking (ausruestung_anfrage_bestellung) // --------------------------------------------------------------------------- async function linkToOrder(anfrageId: number, bestellungId: number) { await pool.query( - `INSERT INTO shop_anfrage_bestellung (anfrage_id, bestellung_id) + `INSERT INTO ausruestung_anfrage_bestellung (anfrage_id, bestellung_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [anfrageId, bestellungId], @@ -290,7 +290,7 @@ async function linkToOrder(anfrageId: number, bestellungId: number) { async function unlinkFromOrder(anfrageId: number, bestellungId: number) { await pool.query( - 'DELETE FROM shop_anfrage_bestellung WHERE anfrage_id = $1 AND bestellung_id = $2', + 'DELETE FROM ausruestung_anfrage_bestellung WHERE anfrage_id = $1 AND bestellung_id = $2', [anfrageId, bestellungId], ); } @@ -298,7 +298,7 @@ async function unlinkFromOrder(anfrageId: number, bestellungId: number) { async function getLinkedOrders(anfrageId: number) { const result = await pool.query( `SELECT b.* - FROM shop_anfrage_bestellung ab + FROM ausruestung_anfrage_bestellung ab JOIN bestellungen b ON b.id = ab.bestellung_id WHERE ab.anfrage_id = $1`, [anfrageId], @@ -315,8 +315,8 @@ async function getOverview() { `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 + FROM ausruestung_anfrage_positionen p + JOIN ausruestung_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`, @@ -327,10 +327,10 @@ async function getOverview() { 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 + FROM ausruestung_anfragen a LEFT JOIN LATERAL ( SELECT SUM(p.menge) AS total - FROM shop_anfrage_positionen p + FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id ) sub ON true WHERE a.status IN ('offen', 'genehmigt')`, diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index f4b2fee..633fa50 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -197,8 +197,8 @@ async function createOrder(data: { bezeichnung: string; lieferant_id?: number; b } } - await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId); await client.query('COMMIT'); + await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId); return order; } catch (error) { await client.query('ROLLBACK'); diff --git a/backend/src/services/cleanup.service.ts b/backend/src/services/cleanup.service.ts index 031ebd0..6a5177e 100644 --- a/backend/src/services/cleanup.service.ts +++ b/backend/src/services/cleanup.service.ts @@ -126,6 +126,45 @@ class CleanupService { logger.info(`Cleanup: deleted ${rowCount} equipment history entries older than ${olderThanDays} days`); return { count: rowCount ?? 0, deleted: true }; } + + async resetBestellungenSequence(confirm: boolean): Promise { + if (!confirm) { + const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM bestellungen'); + return { count: rows[0].count, deleted: false }; + } + const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM bestellungen'); + const count = rows[0].count; + await pool.query('TRUNCATE bestellungen CASCADE'); + await pool.query('ALTER SEQUENCE bestellungen_id_seq RESTART WITH 1'); + logger.info(`Cleanup: truncated bestellungen (${count} rows) and reset sequence`); + return { count, deleted: true }; + } + + async resetAusruestungAnfragenSequence(confirm: boolean): Promise { + if (!confirm) { + const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM ausruestung_anfragen'); + return { count: rows[0].count, deleted: false }; + } + const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM ausruestung_anfragen'); + const count = rows[0].count; + await pool.query('TRUNCATE ausruestung_anfragen CASCADE'); + await pool.query('ALTER SEQUENCE ausruestung_anfragen_id_seq RESTART WITH 1'); + logger.info(`Cleanup: truncated ausruestung_anfragen (${count} rows) and reset sequence`); + return { count, deleted: true }; + } + + async resetIssuesSequence(confirm: boolean): Promise { + if (!confirm) { + const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues'); + return { count: rows[0].count, deleted: false }; + } + const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues'); + const count = rows[0].count; + await pool.query('TRUNCATE issues CASCADE'); + await pool.query('ALTER SEQUENCE issues_id_seq RESTART WITH 1'); + logger.info(`Cleanup: truncated issues (${count} rows) and reset sequence`); + return { count, deleted: true }; + } } export default new CleanupService(); diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index 6446982..54f8fbf 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -714,7 +714,8 @@ class EventsService { FROM users WHERE authentik_groups IS NOT NULL ) g - WHERE group_name != 'dashboard_admin' + WHERE group_name LIKE 'dashboard_%' + AND group_name != 'dashboard_admin' ORDER BY group_name` ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b802899..4856a9b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,7 +28,7 @@ import VeranstaltungKategorien from './pages/VeranstaltungKategorien'; import Wissen from './pages/Wissen'; import Bestellungen from './pages/Bestellungen'; import BestellungDetail from './pages/BestellungDetail'; -import Shop from './pages/Shop'; +import Ausruestungsanfrage from './pages/Ausruestungsanfrage'; import Issues from './pages/Issues'; import AdminDashboard from './pages/AdminDashboard'; import AdminSettings from './pages/AdminSettings'; @@ -237,10 +237,10 @@ function App() { } /> - + } /> diff --git a/frontend/src/components/admin/BestellungenTab.tsx b/frontend/src/components/admin/BestellungenTab.tsx index c42cc4a..fd6c741 100644 --- a/frontend/src/components/admin/BestellungenTab.tsx +++ b/frontend/src/components/admin/BestellungenTab.tsx @@ -19,11 +19,11 @@ import { import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { bestellungApi } from '../../services/bestellung'; -import { shopApi } from '../../services/shop'; +import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types'; -import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../../types/shop.types'; +import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../../types/ausruestungsanfrage.types'; import type { BestellungStatus } from '../../types/bestellung.types'; -import type { ShopAnfrageStatus } from '../../types/shop.types'; +import type { AusruestungAnfrageStatus } from '../../types/ausruestungsanfrage.types'; function BestellungenTab() { const navigate = useNavigate(); @@ -35,8 +35,8 @@ function BestellungenTab() { }); const { data: requests, isLoading: requestsLoading } = useQuery({ - queryKey: ['admin-shop-requests'], - queryFn: () => shopApi.getRequests({ status: 'offen' }), + queryKey: ['admin-ausruestungsanfrage-requests'], + queryFn: () => ausruestungsanfrageApi.getRequests({ status: 'offen' }), }); const formatCurrency = (value?: number) => @@ -44,11 +44,11 @@ function BestellungenTab() { return ( - {/* Pending Shop Requests */} + {/* Pending Ausrüstungsanfragen */} {(requests?.length ?? 0) > 0 && ( - Offene Shop-Anfragen ({requests?.length}) + Offene Ausrüstungsanfragen ({requests?.length}) {requestsLoading ? ( @@ -69,14 +69,14 @@ function BestellungenTab() { key={req.id} hover sx={{ cursor: 'pointer' }} - onClick={() => navigate('/shop?tab=2')} + onClick={() => navigate('/ausruestungsanfrage?tab=2')} > {req.id} {req.anfrager_name || '–'} diff --git a/frontend/src/components/admin/DataManagementTab.tsx b/frontend/src/components/admin/DataManagementTab.tsx index b3940ad..f9743cd 100644 --- a/frontend/src/components/admin/DataManagementTab.tsx +++ b/frontend/src/components/admin/DataManagementTab.tsx @@ -5,6 +5,7 @@ import { CircularProgress, Divider, } from '@mui/material'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; import { api } from '../../services/api'; import { useNotification } from '../../contexts/NotificationContext'; @@ -25,6 +26,18 @@ const SECTIONS: CleanupSection[] = [ { key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 }, ]; +interface ResetSection { + key: string; + label: string; + description: string; +} + +const RESET_SECTIONS: ResetSection[] = [ + { key: 'reset-bestellungen', label: 'Bestellungen zuruecksetzen', description: 'Alle Bestellungen, Positionen, Dateien, Erinnerungen und Historie loeschen und Nummern zuruecksetzen.' }, + { key: 'reset-ausruestung-anfragen', label: 'Ausruestungsanfragen zuruecksetzen', description: 'Alle Ausruestungsanfragen und zugehoerige Positionen loeschen und Nummern zuruecksetzen.' }, + { key: 'reset-issues', label: 'Issues zuruecksetzen', description: 'Alle Issues und Kommentare loeschen und Nummern zuruecksetzen.' }, +]; + interface SectionState { days: number; previewCount: number | null; @@ -41,6 +54,13 @@ export default function DataManagementTab() { const [confirmDialog, setConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null); const [deleting, setDeleting] = useState(false); + // Reset sections state + const [resetStates, setResetStates] = useState>(() => + Object.fromEntries(RESET_SECTIONS.map(s => [s.key, { previewCount: null, loading: false }])) + ); + const [resetConfirmDialog, setResetConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null); + const [resetDeleting, setResetDeleting] = useState(false); + const updateState = useCallback((key: string, partial: Partial) => { setStates(prev => ({ ...prev, [key]: { ...prev[key], ...partial } })); }, []); @@ -76,6 +96,34 @@ export default function DataManagementTab() { } }, [confirmDialog, states, updateState, showSuccess, showError]); + const handleResetPreview = useCallback(async (key: string) => { + setResetStates(prev => ({ ...prev, [key]: { previewCount: null, loading: true } })); + try { + const res = await api.delete(`/api/admin/cleanup/${key}`); + setResetStates(prev => ({ ...prev, [key]: { previewCount: res.data.data.count, loading: false } })); + } catch { + showError('Vorschau konnte nicht geladen werden'); + setResetStates(prev => ({ ...prev, [key]: { ...prev[key], loading: false } })); + } + }, [showError]); + + const handleResetDelete = useCallback(async () => { + if (!resetConfirmDialog) return; + const { key } = resetConfirmDialog; + setResetDeleting(true); + try { + const res = await api.delete(`/api/admin/cleanup/${key}?confirm=true`); + const deleted = res.data.data.count; + showSuccess(`${deleted} Eintraege geloescht und Nummern zurueckgesetzt`); + setResetStates(prev => ({ ...prev, [key]: { previewCount: null, loading: false } })); + } catch { + showError('Zuruecksetzen fehlgeschlagen'); + } finally { + setResetDeleting(false); + setResetConfirmDialog(null); + } + }, [resetConfirmDialog, showSuccess, showError]); + return ( Datenverwaltung @@ -165,6 +213,81 @@ export default function DataManagementTab() { + + {/* ---- Reset / Truncate sections ---- */} + + Daten zuruecksetzen + + Alle Eintraege loeschen und Nummern (IDs) auf 1 zuruecksetzen. Abhaengige Daten werden ebenfalls geloescht. + + + {RESET_SECTIONS.map((section) => { + const rs = resetStates[section.key]; + return ( + + {section.label} + {section.description} + + + + + {rs?.previewCount !== null && rs?.previewCount !== undefined && ( + <> + 0 ? 'warning' : 'info'} sx={{ py: 0 }}> + {rs.previewCount} {rs.previewCount === 1 ? 'Eintrag' : 'Eintraege'} vorhanden + + + {rs.previewCount > 0 && ( + + )} + + )} + + + ); + })} + + !resetDeleting && setResetConfirmDialog(null)}> + Daten zuruecksetzen? + + + {resetConfirmDialog && ( + <> + {resetConfirmDialog.count} {resetConfirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus {resetConfirmDialog.label} werden + unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht werden. + + )} + + + + + + + ); } diff --git a/frontend/src/components/admin/DebugTab.tsx b/frontend/src/components/admin/DebugTab.tsx index 8e4eb27..d8f421a 100644 --- a/frontend/src/components/admin/DebugTab.tsx +++ b/frontend/src/components/admin/DebugTab.tsx @@ -1,18 +1,25 @@ -import { useState } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { - Box, Paper, Typography, Button, Autocomplete, TextField, - Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, - CircularProgress, + Accordion, AccordionDetails, AccordionSummary, + Box, Button, Card, CardContent, Checkbox, Chip, Paper, Typography, + Autocomplete, TextField, Dialog, DialogTitle, DialogContent, + DialogContentText, DialogActions, CircularProgress, FormControlLabel, + IconButton, Tooltip, } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; -import { useQuery } from '@tanstack/react-query'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import SyncIcon from '@mui/icons-material/Sync'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { adminApi } from '../../services/admin'; import { useNotification } from '../../contexts/NotificationContext'; import type { UserOverview } from '../../types/admin.types'; export default function DebugTab() { + const queryClient = useQueryClient(); const { showSuccess, showError } = useNotification(); + // ── Profile deletion ── const { data: users = [], isLoading: usersLoading } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.getUsers, @@ -38,14 +45,55 @@ export default function DebugTab() { } }; + // ── FDISK Sync ── + const logBoxRef = useRef(null); + const [force, setForce] = useState(false); + + const { data: syncData, isLoading: syncLoading, isError: syncError } = useQuery({ + queryKey: ['admin', 'fdisk-sync', 'logs'], + queryFn: adminApi.fdiskSyncLogs, + refetchInterval: 5000, + }); + + useEffect(() => { + if (logBoxRef.current) { + logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight; + } + }, [syncData?.logs.length]); + + const triggerMutation = useMutation({ + mutationFn: (forceSync: boolean) => adminApi.fdiskSyncTrigger(forceSync), + onSuccess: () => { + showSuccess('Sync gestartet'); + queryClient.invalidateQueries({ queryKey: ['admin', 'fdisk-sync', 'logs'] }); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { status?: number } })?.response?.status === 409 + ? 'Sync läuft bereits' + : 'Sync konnte nicht gestartet werden'; + showError(msg); + }, + }); + + const running = syncData?.running ?? false; + + const copyLogs = useCallback(() => { + const text = (syncData?.logs ?? []).map((e) => e.line).join('\n'); + navigator.clipboard.writeText(text).then( + () => showSuccess('Logs kopiert'), + () => showError('Kopieren fehlgeschlagen'), + ); + }, [syncData?.logs, showSuccess, showError]); + return ( - + Debug-Werkzeuge Werkzeuge fuer Fehlersuche und Datenbereinigung. - + {/* Profile deletion */} + Profildaten loeschen @@ -80,6 +128,111 @@ export default function DebugTab() { + {/* FDISK Sync */} + + }> + FDISK Synchronisation + + + + + + + Synchronisiert Mitgliederdaten und Ausbildungen aus FDISK in die Datenbank. + Läuft automatisch täglich um Mitternacht. + + + + : undefined} + /> + + + setForce(e.target.checked)} />} + label="Alle Mitglieder erzwungen synchronisieren" + /> + + + + + + + Protokoll (letzte 500 Zeilen) + + + + + + + + + {syncLoading && ( + + + + )} + {syncError && ( + + Sync-Dienst nicht erreichbar. Läuft der fdisk-sync Container? + + )} + {!syncLoading && !syncError && ( + + {(syncData?.logs ?? []).length === 0 ? ( + Noch keine Logs vorhanden. + ) : ( + (syncData?.logs ?? []).map((entry, i) => ( + + {entry.line} + + )) + )} + + )} + + + + + + {/* Delete confirmation dialog */} !deleting && setConfirmOpen(false)}> Profildaten loeschen? diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index 258f047..60e4c0e 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -99,7 +99,7 @@ const PERMISSION_SUB_GROUPS: Record> = { 'Erinnerungen': ['manage_reminders'], 'Widget': ['widget'], }, - shop: { + ausruestungsanfrage: { 'Katalog': ['view', 'manage_catalog'], 'Anfragen': ['create_request', 'approve_requests', 'link_orders', 'view_overview', 'order_for_user'], 'Widget': ['widget'], diff --git a/frontend/src/components/dashboard/ShopWidget.tsx b/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx similarity index 67% rename from frontend/src/components/dashboard/ShopWidget.tsx rename to frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx index 12c417b..d506136 100644 --- a/frontend/src/components/dashboard/ShopWidget.tsx +++ b/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx @@ -1,17 +1,17 @@ import { Card, CardContent, Typography, Box, Chip, List, ListItem, ListItemText, Divider, Skeleton } from '@mui/material'; -import { Store } from '@mui/icons-material'; +import { Build } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { shopApi } from '../../services/shop'; -import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../../types/shop.types'; -import type { ShopAnfrageStatus } from '../../types/shop.types'; +import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage'; +import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../../types/ausruestungsanfrage.types'; +import type { AusruestungAnfrageStatus } from '../../types/ausruestungsanfrage.types'; -function ShopWidget() { +function AusruestungsanfrageWidget() { const navigate = useNavigate(); const { data: requests, isLoading, isError } = useQuery({ - queryKey: ['shop-widget-requests'], - queryFn: () => shopApi.getRequests({ status: 'offen' }), + queryKey: ['ausruestungsanfrage-widget-requests'], + queryFn: () => ausruestungsanfrageApi.getRequests({ status: 'offen' }), refetchInterval: 5 * 60 * 1000, retry: 1, }); @@ -20,7 +20,7 @@ function ShopWidget() { return ( - Shop-Anfragen + Ausrüstungsanfragen @@ -31,7 +31,7 @@ function ShopWidget() { return ( - Shop-Anfragen + Ausrüstungsanfragen Anfragen konnten nicht geladen werden. @@ -46,9 +46,9 @@ function ShopWidget() { return ( - Shop-Anfragen + Ausrüstungsanfragen - + Keine offenen Anfragen @@ -60,7 +60,7 @@ function ShopWidget() { - Shop-Anfragen + Ausrüstungsanfragen @@ -70,7 +70,7 @@ function ShopWidget() { navigate('/shop?tab=2')} + onClick={() => navigate('/ausruestungsanfrage?tab=2')} > @@ -93,7 +93,7 @@ function ShopWidget() { variant="caption" color="primary" sx={{ cursor: 'pointer', mt: 1, display: 'block' }} - onClick={() => navigate('/shop?tab=2')} + onClick={() => navigate('/ausruestungsanfrage?tab=2')} > Alle {pendingCount} Anfragen anzeigen @@ -103,4 +103,4 @@ function ShopWidget() { ); } -export default ShopWidget; +export default AusruestungsanfrageWidget; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 6794cfe..513c5ad 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -19,4 +19,4 @@ export { default as BannerWidget } from './BannerWidget'; export { default as LinksWidget } from './LinksWidget'; export { default as WidgetGroup } from './WidgetGroup'; export { default as BestellungenWidget } from './BestellungenWidget'; -export { default as ShopWidget } from './ShopWidget'; +export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget'; diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx index 7593597..f732613 100644 --- a/frontend/src/components/shared/Header.tsx +++ b/frontend/src/components/shared/Header.tsx @@ -4,14 +4,13 @@ import { Toolbar, Typography, IconButton, - Button, - Menu, - MenuItem, Avatar, ListItemIcon, Divider, Box, Tooltip, + Menu, + MenuItem, } from '@mui/material'; import { LocalFireDepartment, @@ -19,14 +18,11 @@ import { Settings, Logout, Menu as MenuIcon, - Launch, Chat, } from '@mui/icons-material'; -import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; import NotificationBell from './NotificationBell'; -import { configApi } from '../../services/config'; import { useLayout } from '../../contexts/LayoutContext'; interface HeaderProps { @@ -38,14 +34,6 @@ function Header({ onMenuClick }: HeaderProps) { const navigate = useNavigate(); const { toggleChatPanel } = useLayout(); const [anchorEl, setAnchorEl] = useState(null); - const [toolsAnchorEl, setToolsAnchorEl] = useState(null); - - const { data: externalLinks } = useQuery({ - queryKey: ['external-links'], - queryFn: () => configApi.getExternalLinks(), - staleTime: 10 * 60 * 1000, - enabled: !!user, - }); const handleMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -55,14 +43,6 @@ function Header({ onMenuClick }: HeaderProps) { setAnchorEl(null); }; - const handleToolsOpen = (event: React.MouseEvent) => { - setToolsAnchorEl(event.currentTarget); - }; - - const handleToolsClose = () => { - setToolsAnchorEl(null); - }; - const handleProfile = () => { handleMenuClose(); navigate('/profile'); @@ -78,11 +58,6 @@ function Header({ onMenuClick }: HeaderProps) { logout(); }; - const handleOpenExternal = (url: string) => { - handleToolsClose(); - window.open(url, '_blank', 'noopener,noreferrer'); - }; - // Get initials for avatar const getInitials = () => { if (!user) return '?'; @@ -90,19 +65,6 @@ function Header({ onMenuClick }: HeaderProps) { return initials || user.name?.[0] || '?'; }; - const linkEntries = externalLinks - ? Object.entries(externalLinks).filter(([key, url]) => key !== 'customLinks' && !!url) - : []; - - const customLinks: Array<{ name: string; url: string }> = - externalLinks?.customLinks ?? []; - - const linkLabels: Record = { - nextcloud: 'Nextcloud Dateien', - bookstack: 'Wissensdatenbank', - vikunja: 'Aufgabenverwaltung', - }; - return ( - {(linkEntries.length > 0 || customLinks.length > 0) && ( - <> - - - - - - - - - {linkEntries.map(([key, url]) => ( - handleOpenExternal(url)}> - - - - {linkLabels[key] || key} - - ))} - {customLinks.length > 0 && linkEntries.length > 0 && } - {customLinks.map((link, index) => ( - handleOpenExternal(link.url)}> - - - - {link.name} - - ))} - - - )} - , path: '/fahrzeuge', - permission: 'fahrzeuge:access', + permission: 'fahrzeuge:view', }, { text: 'Ausrüstung', @@ -123,18 +121,18 @@ const baseNavigationItems: NavigationItem[] = [ permission: 'bestellungen:view', }, { - text: 'Shop', - icon: , - path: '/shop', + text: 'Ausrüstungsanfragen', + icon: , + path: '/ausruestungsanfrage', // subItems computed dynamically in navigationItems useMemo - permission: 'shop:view', + permission: 'ausruestungsanfrage:view', }, { text: 'Issues', icon: , path: '/issues', // subItems computed dynamically in navigationItems useMemo - permission: 'issues:create', + permission: 'issues:view_own', }, ]; @@ -187,24 +185,27 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { permission: 'fahrzeuge:view', }; - // Build Shop sub-items dynamically based on permissions (tab order must match Shop.tsx) - const shopSubItems: SubItem[] = []; - let shopTabIdx = 0; - if (hasPermission('shop:create_request')) { shopSubItems.push({ text: 'Meine Anfragen', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; } - if (hasPermission('shop:approve_requests')) { shopSubItems.push({ text: 'Alle Anfragen', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; } - if (hasPermission('shop:view_overview')) { shopSubItems.push({ text: 'Übersicht', path: `/shop?tab=${shopTabIdx}` }); shopTabIdx++; } - shopSubItems.push({ text: 'Katalog', path: `/shop?tab=${shopTabIdx}` }); + // Build Ausrüstungsanfrage sub-items dynamically based on permissions (tab order must match Ausruestungsanfrage.tsx) + const ausruestungSubItems: SubItem[] = []; + let ausruestungTabIdx = 0; + if (hasPermission('ausruestungsanfrage:create_request')) { ausruestungSubItems.push({ text: 'Meine Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } + if (hasPermission('ausruestungsanfrage:approve_requests')) { ausruestungSubItems.push({ text: 'Alle Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } + if (hasPermission('ausruestungsanfrage:view_overview')) { ausruestungSubItems.push({ text: 'Übersicht', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } + ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); // Build Issues sub-items dynamically (tab order must match Issues.tsx) const issuesSubItems: SubItem[] = [{ text: 'Meine Issues', path: '/issues?tab=0' }]; if (hasPermission('issues:view_all')) { issuesSubItems.push({ text: 'Alle Issues', path: '/issues?tab=1' }); } + if (hasPermission('issues:manage')) { + issuesSubItems.push({ text: 'Erledigte Issues', path: `/issues?tab=${issuesSubItems.length}` }); + } const items = baseNavigationItems .map((item) => { if (item.path === '/fahrzeuge') return fahrzeugeItem; - if (item.path === '/shop') return { ...item, subItems: shopSubItems }; + if (item.path === '/ausruestungsanfrage') return { ...item, subItems: ausruestungSubItems }; if (item.path === '/issues') return { ...item, subItems: issuesSubItems }; return item; }) diff --git a/frontend/src/constants/widgets.ts b/frontend/src/constants/widgets.ts index 94fd3b6..902ef14 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -14,7 +14,7 @@ export const WIDGETS = [ { key: 'adminStatus', label: 'Admin Status', defaultVisible: true }, { key: 'links', label: 'Links', defaultVisible: true }, { key: 'bestellungen', label: 'Bestellungen', defaultVisible: true }, - { key: 'shopRequests', label: 'Shop-Anfragen', defaultVisible: true }, + { key: 'ausruestungsanfragen', label: 'Ausrüstungsanfragen', defaultVisible: true }, ] as const; export type WidgetKey = typeof WIDGETS[number]['key']; diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 9673eef..4aa5d35 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -8,7 +8,6 @@ import UserOverviewTab from '../components/admin/UserOverviewTab'; import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab'; import BannerManagementTab from '../components/admin/BannerManagementTab'; import ServiceModeTab from '../components/admin/ServiceModeTab'; -import FdiskSyncTab from '../components/admin/FdiskSyncTab'; import PermissionMatrixTab from '../components/admin/PermissionMatrixTab'; import BestellungenTab from '../components/admin/BestellungenTab'; import DataManagementTab from '../components/admin/DataManagementTab'; @@ -26,7 +25,7 @@ function TabPanel({ children, value, index }: TabPanelProps) { return {children}; } -const ADMIN_TAB_COUNT = 11; +const ADMIN_TAB_COUNT = 10; function AdminDashboard() { const navigate = useNavigate(); @@ -58,7 +57,6 @@ function AdminDashboard() { - @@ -85,18 +83,15 @@ function AdminDashboard() { - - - - + - + - + diff --git a/frontend/src/pages/Shop.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx similarity index 70% rename from frontend/src/pages/Shop.tsx rename to frontend/src/pages/Ausruestungsanfrage.tsx index 22dcce0..ff1036c 100644 --- a/frontend/src/pages/Shop.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -17,15 +17,15 @@ import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; -import { shopApi } from '../services/shop'; +import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { bestellungApi } from '../services/bestellung'; -import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types'; -import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus, ShopAnfrage, ShopOverview } from '../types/shop.types'; +import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; +import type { AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem, AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage, AusruestungOverview } from '../types/ausruestungsanfrage.types'; import type { Bestellung } from '../types/bestellung.types'; // ─── Helpers ───────────────────────────────────────────────────────────────── -function formatOrderId(r: ShopAnfrage): string { +function formatOrderId(r: AusruestungAnfrage): string { if (r.bestell_jahr && r.bestell_nummer) { return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`; } @@ -46,8 +46,8 @@ function KatalogTab() { const { hasPermission } = usePermissionContext(); const queryClient = useQueryClient(); - const canManage = hasPermission('shop:manage_catalog'); - const canCreate = hasPermission('shop:create_request'); + const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); + const canCreate = hasPermission('ausruestungsanfrage:create_request'); const [filterKategorie, setFilterKategorie] = useState(''); const [draft, setDraft] = useState([]); @@ -55,38 +55,38 @@ function KatalogTab() { const [submitOpen, setSubmitOpen] = useState(false); const [submitNotizen, setSubmitNotizen] = useState(''); const [artikelDialogOpen, setArtikelDialogOpen] = useState(false); - const [editArtikel, setEditArtikel] = useState(null); - const [artikelForm, setArtikelForm] = useState({ bezeichnung: '' }); + const [editArtikel, setEditArtikel] = useState(null); + const [artikelForm, setArtikelForm] = useState({ bezeichnung: '' }); const { data: items = [], isLoading } = useQuery({ - queryKey: ['shop', 'items', filterKategorie], - queryFn: () => shopApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined), + queryKey: ['ausruestungsanfrage', 'items', filterKategorie], + queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined), }); const { data: categories = [] } = useQuery({ - queryKey: ['shop', 'categories'], - queryFn: () => shopApi.getCategories(), + queryKey: ['ausruestungsanfrage', 'categories'], + queryFn: () => ausruestungsanfrageApi.getCategories(), }); const createItemMut = useMutation({ - mutationFn: (data: ShopArtikelFormData) => shopApi.createItem(data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel erstellt'); setArtikelDialogOpen(false); }, + mutationFn: (data: AusruestungArtikelFormData) => ausruestungsanfrageApi.createItem(data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel erstellt'); setArtikelDialogOpen(false); }, onError: () => showError('Fehler beim Erstellen'), }); const updateItemMut = useMutation({ - mutationFn: ({ id, data }: { id: number; data: Partial }) => shopApi.updateItem(id, data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel aktualisiert'); setArtikelDialogOpen(false); }, + mutationFn: ({ id, data }: { id: number; data: Partial }) => ausruestungsanfrageApi.updateItem(id, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel aktualisiert'); setArtikelDialogOpen(false); }, onError: () => showError('Fehler beim Aktualisieren'), }); const deleteItemMut = useMutation({ - mutationFn: (id: number) => shopApi.deleteItem(id), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel gelöscht'); }, + mutationFn: (id: number) => ausruestungsanfrageApi.deleteItem(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel gelöscht'); }, onError: () => showError('Fehler beim Löschen'), }); const createRequestMut = useMutation({ - mutationFn: ({ items, notizen }: { items: ShopAnfrageFormItem[]; notizen?: string }) => shopApi.createRequest(items, notizen), + mutationFn: ({ items, notizen }: { items: AusruestungAnfrageFormItem[]; notizen?: string }) => ausruestungsanfrageApi.createRequest(items, notizen), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['shop'] }); + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Anfrage gesendet'); setDraft([]); setSubmitOpen(false); @@ -95,7 +95,7 @@ function KatalogTab() { onError: () => showError('Fehler beim Senden der Anfrage'), }); - const addToDraft = (item: ShopArtikel) => { + const addToDraft = (item: AusruestungArtikel) => { setDraft(prev => { const existing = prev.find(d => d.artikel_id === item.id); if (existing) return prev.map(d => d.artikel_id === item.id ? { ...d, menge: d.menge + 1 } : d); @@ -121,7 +121,7 @@ function KatalogTab() { setArtikelForm({ bezeichnung: '' }); setArtikelDialogOpen(true); }; - const openEditArtikel = (a: ShopArtikel) => { + const openEditArtikel = (a: AusruestungArtikel) => { setEditArtikel(a); setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie }); setArtikelDialogOpen(true); @@ -279,78 +279,180 @@ function KatalogTab() { function MeineAnfragenTab() { const [expandedId, setExpandedId] = useState(null); + const { hasPermission } = usePermissionContext(); + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + + const canCreate = hasPermission('ausruestungsanfrage:create_request'); + + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [newBezeichnung, setNewBezeichnung] = useState(''); + const [newBemerkung, setNewBemerkung] = useState(''); + const [selectedArtikel, setSelectedArtikel] = useState([]); const { data: requests = [], isLoading } = useQuery({ - queryKey: ['shop', 'myRequests'], - queryFn: () => shopApi.getMyRequests(), + queryKey: ['ausruestungsanfrage', 'myRequests'], + queryFn: () => ausruestungsanfrageApi.getMyRequests(), }); - const { data: detail } = useQuery({ - queryKey: ['shop', 'request', expandedId], - queryFn: () => shopApi.getRequest(expandedId!), + const { data: detail } = useQuery({ + queryKey: ['ausruestungsanfrage', 'request', expandedId], + queryFn: () => ausruestungsanfrageApi.getRequest(expandedId!), enabled: expandedId != null, }); + const { data: catalogItems = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'items-for-create'], + queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }), + enabled: createDialogOpen, + }); + + const createMut = useMutation({ + mutationFn: ({ items, notizen }: { items: AusruestungAnfrageFormItem[]; notizen?: string }) => + ausruestungsanfrageApi.createRequest(items, notizen), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + showSuccess('Anfrage erstellt'); + setCreateDialogOpen(false); + setNewBezeichnung(''); + setNewBemerkung(''); + setSelectedArtikel([]); + }, + onError: () => showError('Fehler beim Erstellen der Anfrage'), + }); + + const handleCreateSubmit = () => { + const items: AusruestungAnfrageFormItem[] = []; + + // Add selected catalog items + for (const a of selectedArtikel) { + items.push({ artikel_id: a.id, bezeichnung: a.bezeichnung, menge: 1 }); + } + + // Add free-text item if provided + const text = newBezeichnung.trim(); + if (text) { + items.push({ bezeichnung: text, menge: 1 }); + } + + if (items.length === 0) return; + + createMut.mutate({ items, notizen: newBemerkung || undefined }); + }; + if (isLoading) return Lade Anfragen...; - if (requests.length === 0) return Keine Anfragen vorhanden.; + if (requests.length === 0 && !canCreate) return Keine Anfragen vorhanden.; return ( - - - - - - Anfrage - Status - Positionen - Erstellt am - Admin Notizen - - - - {requests.map(r => ( - <> - setExpandedId(prev => prev === r.id ? null : r.id)}> - {expandedId === r.id ? : } - {formatOrderId(r)} - - {r.positionen_count ?? r.items_count ?? '-'} - {new Date(r.erstellt_am).toLocaleDateString('de-AT')} - {r.admin_notizen || '-'} + + {requests.length === 0 ? ( + Keine Anfragen vorhanden. + ) : ( + +
+ + + + Anfrage + Status + Positionen + Erstellt am + Admin Notizen - {expandedId === r.id && ( - - - - - {r.notizen && Notizen: {r.notizen}} - {detail ? ( - <> - {detail.positionen.map(p => ( - - {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''} - ))} - {detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && ( - - Verknüpfte Bestellungen: - {detail.linked_bestellungen.map(b => ( - + + + {requests.map(r => ( + <> + setExpandedId(prev => prev === r.id ? null : r.id)}> + {expandedId === r.id ? : } + {formatOrderId(r)} + + {r.positionen_count ?? r.items_count ?? '-'} + {new Date(r.erstellt_am).toLocaleDateString('de-AT')} + {r.admin_notizen || '-'} + + {expandedId === r.id && ( + + + + + {r.notizen && Notizen: {r.notizen}} + {detail ? ( + <> + {detail.positionen.map(p => ( + - {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''} ))} - + {detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && ( + + Verknüpfte Bestellungen: + {detail.linked_bestellungen.map(b => ( + + ))} + + )} + + ) : ( + Lade Details... )} - - ) : ( - Lade Details... - )} - - - - - )} - - ))} - -
-
+
+ + + + )} + + ))} + + + + )} + + {/* Create Request Dialog */} + setCreateDialogOpen(false)} maxWidth="sm" fullWidth> + Neue Ausrüstungsanfrage + + setNewBezeichnung(e.target.value)} + fullWidth + /> + o.bezeichnung} + value={selectedArtikel} + onChange={(_, v) => setSelectedArtikel(v)} + renderInput={params => } + /> + setNewBemerkung(e.target.value)} + multiline + rows={2} + fullWidth + /> + + + + + + + + {/* FAB for creating new request */} + {canCreate && ( + setCreateDialogOpen(true)} aria-label="Neue Anfrage erstellen"> + + + )} + ); } @@ -368,13 +470,13 @@ function AlleAnfragenTab() { const [selectedBestellung, setSelectedBestellung] = useState(null); const { data: requests = [], isLoading } = useQuery({ - queryKey: ['shop', 'requests', statusFilter], - queryFn: () => shopApi.getRequests(statusFilter ? { status: statusFilter } : undefined), + queryKey: ['ausruestungsanfrage', 'requests', statusFilter], + queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined), }); - const { data: detail } = useQuery({ - queryKey: ['shop', 'request', expandedId], - queryFn: () => shopApi.getRequest(expandedId!), + const { data: detail } = useQuery({ + queryKey: ['ausruestungsanfrage', 'request', expandedId], + queryFn: () => ausruestungsanfrageApi.getRequest(expandedId!), enabled: expandedId != null, }); @@ -385,9 +487,9 @@ function AlleAnfragenTab() { }); const statusMut = useMutation({ - mutationFn: ({ id, status, notes }: { id: number; status: string; notes?: string }) => shopApi.updateRequestStatus(id, status, notes), + mutationFn: ({ id, status, notes }: { id: number; status: string; notes?: string }) => ausruestungsanfrageApi.updateRequestStatus(id, status, notes), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['shop'] }); + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Status aktualisiert'); setActionDialog(null); setAdminNotizen(''); @@ -396,9 +498,9 @@ function AlleAnfragenTab() { }); const linkMut = useMutation({ - mutationFn: ({ anfrageId, bestellungId }: { anfrageId: number; bestellungId: number }) => shopApi.linkToOrder(anfrageId, bestellungId), + mutationFn: ({ anfrageId, bestellungId }: { anfrageId: number; bestellungId: number }) => ausruestungsanfrageApi.linkToOrder(anfrageId, bestellungId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['shop'] }); + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Verknüpfung erstellt'); setLinkDialog(null); setSelectedBestellung(null); @@ -419,8 +521,8 @@ function AlleAnfragenTab() { Status Filter @@ -448,7 +550,7 @@ function AlleAnfragenTab() { {expandedId === r.id ? : } {formatOrderId(r)} {r.anfrager_name || r.anfrager_id} - + {r.positionen_count ?? r.items_count ?? '-'} {new Date(r.erstellt_am).toLocaleDateString('de-AT')} e.stopPropagation()}> @@ -570,9 +672,9 @@ function AlleAnfragenTab() { // ─── Overview Tab ──────────────────────────────────────────────────────────── function UebersichtTab() { - const { data: overview, isLoading } = useQuery({ - queryKey: ['shop', 'overview'], - queryFn: () => shopApi.getOverview(), + const { data: overview, isLoading } = useQuery({ + queryKey: ['ausruestungsanfrage', 'overview'], + queryFn: () => ausruestungsanfrageApi.getOverview(), }); if (isLoading) return Lade Übersicht...; @@ -631,14 +733,14 @@ function UebersichtTab() { // ─── Main Page ────────────────────────────────────────────────────────────── -export default function Shop() { +export default function Ausruestungsanfrage() { const [searchParams, setSearchParams] = useSearchParams(); const { hasPermission } = usePermissionContext(); - const canView = hasPermission('shop:view'); - const canCreate = hasPermission('shop:create_request'); - const canApprove = hasPermission('shop:approve_requests'); - const canViewOverview = hasPermission('shop:view_overview'); + const canView = hasPermission('ausruestungsanfrage:view'); + const canCreate = hasPermission('ausruestungsanfrage:create_request'); + const canApprove = hasPermission('ausruestungsanfrage:approve_requests'); + const canViewOverview = hasPermission('ausruestungsanfrage:view_overview'); const tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0) + (canViewOverview ? 1 : 0); @@ -672,7 +774,7 @@ export default function Shop() { return ( - Shop + Ausrüstungsanfragen diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index 561e59f..baa0948 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -100,7 +100,7 @@ export default function BestellungDetail() { const [deleteReminderTarget, setDeleteReminderTarget] = useState(null); // ── Query ── - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ['bestellung', orderId], queryFn: () => bestellungApi.getOrder(orderId), enabled: !!orderId, @@ -263,11 +263,17 @@ export default function BestellungDetail() { } if (isError || !bestellung) { + const is404 = (error as any)?.response?.status === 404 || !bestellung; return ( - Bestellung nicht gefunden. - + + {is404 ? 'Bestellung nicht gefunden.' : 'Fehler beim Laden der Bestellung.'} + + {!is404 && ( + + )} + ); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 6d5d958..49f106a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -29,7 +29,7 @@ import LinksWidget from '../components/dashboard/LinksWidget'; import BannerWidget from '../components/dashboard/BannerWidget'; import WidgetGroup from '../components/dashboard/WidgetGroup'; import BestellungenWidget from '../components/dashboard/BestellungenWidget'; -import ShopWidget from '../components/dashboard/ShopWidget'; +import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget'; import { preferencesApi } from '../services/settings'; import { configApi } from '../services/config'; import { WidgetKey } from '../constants/widgets'; @@ -141,10 +141,10 @@ function Dashboard() { )} - {hasPermission('shop:widget') && widgetVisible('shopRequests') && ( + {hasPermission('ausruestungsanfrage:widget') && widgetVisible('ausruestungsanfragen') && ( - + )} diff --git a/frontend/src/pages/Issues.tsx b/frontend/src/pages/Issues.tsx index e92c912..ed4edea 100644 --- a/frontend/src/pages/Issues.tsx +++ b/frontend/src/pages/Issues.tsx @@ -3,7 +3,7 @@ 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, + InputLabel, Collapse, Divider, CircularProgress, FormControlLabel, Switch, } from '@mui/material'; import { Add as AddIcon, Delete as DeleteIcon, ExpandMore, ExpandLess, @@ -216,7 +216,7 @@ function IssueRow({ )} - {canManage && ( + {(canManage || isOwner) && ( Status @@ -232,19 +232,21 @@ function IssueRow({ Abgelehnt - - Priorität - - + {canManage && ( + + Priorität + + + )} )} @@ -328,9 +330,6 @@ function IssueTable({ issues, canManage, userId }: { issues: Issue[]; canManage: 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(); @@ -341,6 +340,11 @@ export default function Issues() { const canCreate = hasPermission('issues:create'); const userId = user?.id || ''; + const tabParam = parseInt(searchParams.get('tab') || '0', 10); + const maxTab = canManage ? 2 : (canViewAll ? 1 : 0); + const tab = isNaN(tabParam) || tabParam < 0 || tabParam > maxTab ? 0 : tabParam; + + const [showDone, setShowDone] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [form, setForm] = useState({ titel: '', typ: 'bug', prioritaet: 'mittel' }); @@ -365,6 +369,8 @@ export default function Issues() { }; const myIssues = issues.filter((i: Issue) => i.erstellt_von === userId); + const myIssuesFiltered = myIssues.filter((i: Issue) => showDone || (i.status !== 'erledigt' && i.status !== 'abgelehnt')); + const doneIssues = issues.filter((i: Issue) => i.status === 'erledigt' || i.status === 'abgelehnt'); return ( @@ -374,15 +380,21 @@ export default function Issues() { {canViewAll && } + {canManage && } + setShowDone(e.target.checked)} size="small" />} + label="Erledigte anzeigen" + sx={{ mb: 1 }} + /> {isLoading ? ( ) : ( - + )} @@ -397,6 +409,18 @@ export default function Issues() { )} )} + + {canManage && ( + + {isLoading ? ( + + + + ) : ( + + )} + + )} {/* Create Issue Dialog */} diff --git a/frontend/src/services/ausruestungsanfrage.ts b/frontend/src/services/ausruestungsanfrage.ts new file mode 100644 index 0000000..8c31d34 --- /dev/null +++ b/frontend/src/services/ausruestungsanfrage.ts @@ -0,0 +1,80 @@ +import { api } from './api'; +import type { + AusruestungArtikel, + AusruestungArtikelFormData, + AusruestungAnfrage, + AusruestungAnfrageDetailResponse, + AusruestungAnfrageFormItem, + AusruestungOverview, +} from '../types/ausruestungsanfrage.types'; + +export const ausruestungsanfrageApi = { + // ── Catalog Items ── + getItems: async (filters?: { kategorie?: string; aktiv?: boolean }): Promise => { + const params = new URLSearchParams(); + if (filters?.kategorie) params.set('kategorie', filters.kategorie); + if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv)); + const r = await api.get(`/api/ausruestungsanfragen/items?${params.toString()}`); + return r.data.data; + }, + getItem: async (id: number): Promise => { + const r = await api.get(`/api/ausruestungsanfragen/items/${id}`); + return r.data.data; + }, + createItem: async (data: AusruestungArtikelFormData): Promise => { + const r = await api.post('/api/ausruestungsanfragen/items', data); + return r.data.data; + }, + updateItem: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/ausruestungsanfragen/items/${id}`, data); + return r.data.data; + }, + deleteItem: async (id: number): Promise => { + await api.delete(`/api/ausruestungsanfragen/items/${id}`); + }, + getCategories: async (): Promise => { + const r = await api.get('/api/ausruestungsanfragen/categories'); + return r.data.data; + }, + + // ── Requests ── + getRequests: async (filters?: { status?: string }): Promise => { + const params = new URLSearchParams(); + if (filters?.status) params.set('status', filters.status); + const r = await api.get(`/api/ausruestungsanfragen/requests?${params.toString()}`); + return r.data.data; + }, + getMyRequests: async (): Promise => { + const r = await api.get('/api/ausruestungsanfragen/requests/my'); + return r.data.data; + }, + getRequest: async (id: number): Promise => { + const r = await api.get(`/api/ausruestungsanfragen/requests/${id}`); + return r.data.data; + }, + createRequest: async (items: AusruestungAnfrageFormItem[], notizen?: string): Promise => { + const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen }); + return r.data.data; + }, + updateRequestStatus: async (id: number, status: string, admin_notizen?: string): Promise => { + const r = await api.patch(`/api/ausruestungsanfragen/requests/${id}/status`, { status, admin_notizen }); + return r.data.data; + }, + deleteRequest: async (id: number): Promise => { + await api.delete(`/api/ausruestungsanfragen/requests/${id}`); + }, + + // ── Linking ── + linkToOrder: async (anfrageId: number, bestellungId: number): Promise => { + await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/link`, { bestellung_id: bestellungId }); + }, + unlinkFromOrder: async (anfrageId: number, bestellungId: number): Promise => { + await api.delete(`/api/ausruestungsanfragen/requests/${anfrageId}/link/${bestellungId}`); + }, + + // ── Overview ── + getOverview: async (): Promise => { + const r = await api.get('/api/ausruestungsanfragen/overview'); + return r.data.data; + }, +}; diff --git a/frontend/src/services/shop.ts b/frontend/src/services/shop.ts deleted file mode 100644 index 55ed924..0000000 --- a/frontend/src/services/shop.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { api } from './api'; -import type { - ShopArtikel, - ShopArtikelFormData, - ShopAnfrage, - ShopAnfrageDetailResponse, - ShopAnfrageFormItem, - ShopOverview, -} from '../types/shop.types'; - -export const shopApi = { - // ── Catalog Items ── - getItems: async (filters?: { kategorie?: string; aktiv?: boolean }): Promise => { - const params = new URLSearchParams(); - if (filters?.kategorie) params.set('kategorie', filters.kategorie); - if (filters?.aktiv !== undefined) params.set('aktiv', String(filters.aktiv)); - const r = await api.get(`/api/shop/items?${params.toString()}`); - return r.data.data; - }, - getItem: async (id: number): Promise => { - const r = await api.get(`/api/shop/items/${id}`); - return r.data.data; - }, - createItem: async (data: ShopArtikelFormData): Promise => { - const r = await api.post('/api/shop/items', data); - return r.data.data; - }, - updateItem: async (id: number, data: Partial): Promise => { - const r = await api.patch(`/api/shop/items/${id}`, data); - return r.data.data; - }, - deleteItem: async (id: number): Promise => { - await api.delete(`/api/shop/items/${id}`); - }, - getCategories: async (): Promise => { - const r = await api.get('/api/shop/categories'); - return r.data.data; - }, - - // ── Requests ── - getRequests: async (filters?: { status?: string }): Promise => { - const params = new URLSearchParams(); - if (filters?.status) params.set('status', filters.status); - const r = await api.get(`/api/shop/requests?${params.toString()}`); - return r.data.data; - }, - getMyRequests: async (): Promise => { - const r = await api.get('/api/shop/requests/my'); - return r.data.data; - }, - getRequest: async (id: number): Promise => { - const r = await api.get(`/api/shop/requests/${id}`); - return r.data.data; - }, - createRequest: async (items: ShopAnfrageFormItem[], notizen?: string): Promise => { - const r = await api.post('/api/shop/requests', { items, notizen }); - return r.data.data; - }, - updateRequestStatus: async (id: number, status: string, admin_notizen?: string): Promise => { - const r = await api.patch(`/api/shop/requests/${id}/status`, { status, admin_notizen }); - return r.data.data; - }, - deleteRequest: async (id: number): Promise => { - await api.delete(`/api/shop/requests/${id}`); - }, - - // ── Linking ── - linkToOrder: async (anfrageId: number, bestellungId: number): Promise => { - await api.post(`/api/shop/requests/${anfrageId}/link`, { bestellung_id: bestellungId }); - }, - 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/types/shop.types.ts b/frontend/src/types/ausruestungsanfrage.types.ts similarity index 63% rename from frontend/src/types/shop.types.ts rename to frontend/src/types/ausruestungsanfrage.types.ts index 1eab823..aac7777 100644 --- a/frontend/src/types/shop.types.ts +++ b/frontend/src/types/ausruestungsanfrage.types.ts @@ -1,8 +1,8 @@ -// Shop (Internal Ordering) types +// Ausrüstungsanfrage (Equipment Request) types // ── Catalog Items ── -export interface ShopArtikel { +export interface AusruestungArtikel { id: number; bezeichnung: string; beschreibung?: string; @@ -15,7 +15,7 @@ export interface ShopArtikel { aktualisiert_am: string; } -export interface ShopArtikelFormData { +export interface AusruestungArtikelFormData { bezeichnung: string; beschreibung?: string; kategorie?: string; @@ -25,9 +25,9 @@ export interface ShopArtikelFormData { // ── Requests ── -export type ShopAnfrageStatus = 'offen' | 'genehmigt' | 'abgelehnt' | 'bestellt' | 'erledigt'; +export type AusruestungAnfrageStatus = 'offen' | 'genehmigt' | 'abgelehnt' | 'bestellt' | 'erledigt'; -export const SHOP_STATUS_LABELS: Record = { +export const AUSRUESTUNG_STATUS_LABELS: Record = { offen: 'Offen', genehmigt: 'Genehmigt', abgelehnt: 'Abgelehnt', @@ -35,7 +35,7 @@ export const SHOP_STATUS_LABELS: Record = { erledigt: 'Erledigt', }; -export const SHOP_STATUS_COLORS: Record = { +export const AUSRUESTUNG_STATUS_COLORS: Record = { offen: 'default', genehmigt: 'info', abgelehnt: 'error', @@ -43,11 +43,11 @@ export const SHOP_STATUS_COLORS: Record