From 5032e1593b50eeb9ccf2950836a908e548d31893 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 23 Mar 2026 13:08:19 +0100 Subject: [PATCH] new features --- backend/src/app.ts | 9 + .../src/controllers/bestellung.controller.ts | 466 ++++++++++++ backend/src/controllers/shop.controller.ts | 256 +++++++ .../migrations/038_create_bestellungen.sql | 145 ++++ .../database/migrations/039_create_shop.sql | 84 +++ .../migrations/040_update_permissions.sql | 158 ++++ backend/src/jobs/reminder.job.ts | 75 ++ backend/src/middleware/upload.ts | 52 ++ backend/src/routes/bestellung.routes.ts | 189 +++++ backend/src/routes/booking.routes.ts | 6 +- backend/src/routes/bookstack.routes.ts | 7 +- backend/src/routes/events.routes.ts | 6 +- backend/src/routes/shop.routes.ts | 38 + backend/src/routes/vikunja.routes.ts | 7 +- backend/src/server.ts | 5 + backend/src/services/bestellung.service.ts | 607 ++++++++++++++++ backend/src/services/shop.service.ts | 323 +++++++++ backend/src/services/upload.service.ts | 44 ++ frontend/src/App.tsx | 27 + .../src/components/admin/BestellungenTab.tsx | 167 +++++ .../admin/NotificationBroadcastTab.tsx | 2 - .../components/admin/PermissionMatrixTab.tsx | 66 +- .../components/chat/FileMessageContent.tsx | 2 +- .../dashboard/BestellungenWidget.tsx | 109 +++ .../src/components/dashboard/ShopWidget.tsx | 106 +++ frontend/src/components/dashboard/index.ts | 2 + frontend/src/components/shared/Sidebar.tsx | 19 + frontend/src/constants/widgets.ts | 2 + frontend/src/pages/AdminDashboard.tsx | 7 +- frontend/src/pages/Atemschutz.tsx | 3 +- frontend/src/pages/Ausruestung.tsx | 1 - frontend/src/pages/BestellungDetail.tsx | 686 ++++++++++++++++++ frontend/src/pages/Bestellungen.tsx | 434 +++++++++++ frontend/src/pages/Dashboard.tsx | 26 +- frontend/src/pages/FahrzeugBuchungen.tsx | 1 - frontend/src/pages/Kalender.tsx | 9 - frontend/src/pages/Shop.tsx | 622 ++++++++++++++++ frontend/src/services/bestellung.ts | 117 +++ frontend/src/services/shop.ts | 73 ++ frontend/src/types/bestellung.types.ts | 156 ++++ frontend/src/types/shop.types.ts | 83 +++ 41 files changed, 5157 insertions(+), 40 deletions(-) create mode 100644 backend/src/controllers/bestellung.controller.ts create mode 100644 backend/src/controllers/shop.controller.ts create mode 100644 backend/src/database/migrations/038_create_bestellungen.sql create mode 100644 backend/src/database/migrations/039_create_shop.sql create mode 100644 backend/src/database/migrations/040_update_permissions.sql create mode 100644 backend/src/jobs/reminder.job.ts create mode 100644 backend/src/middleware/upload.ts create mode 100644 backend/src/routes/bestellung.routes.ts create mode 100644 backend/src/routes/shop.routes.ts create mode 100644 backend/src/services/bestellung.service.ts create mode 100644 backend/src/services/shop.service.ts create mode 100644 backend/src/services/upload.service.ts create mode 100644 frontend/src/components/admin/BestellungenTab.tsx create mode 100644 frontend/src/components/dashboard/BestellungenWidget.tsx create mode 100644 frontend/src/components/dashboard/ShopWidget.tsx create mode 100644 frontend/src/pages/BestellungDetail.tsx create mode 100644 frontend/src/pages/Bestellungen.tsx create mode 100644 frontend/src/pages/Shop.tsx create mode 100644 frontend/src/services/bestellung.ts create mode 100644 frontend/src/services/shop.ts create mode 100644 frontend/src/types/bestellung.types.ts create mode 100644 frontend/src/types/shop.types.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index cbdfeeb..435b3c7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,10 +2,12 @@ import express, { Application, Request, Response } from 'express'; import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; +import path from 'path'; import environment from './config/environment'; import logger from './utils/logger'; import { errorHandler, notFoundHandler } from './middleware/error.middleware'; import { requestTimeout } from './middleware/request-timeout.middleware'; +import { authenticate } from './middleware/auth.middleware'; const app: Application = express(); @@ -93,11 +95,13 @@ import bookingRoutes from './routes/booking.routes'; import notificationRoutes from './routes/notification.routes'; import bookstackRoutes from './routes/bookstack.routes'; import vikunjaRoutes from './routes/vikunja.routes'; +import bestellungRoutes from './routes/bestellung.routes'; import configRoutes from './routes/config.routes'; 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'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -114,12 +118,17 @@ app.use('/api/bookings', bookingRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/bookstack', bookstackRoutes); app.use('/api/vikunja', vikunjaRoutes); +app.use('/api/bestellungen', bestellungRoutes); app.use('/api/config', configRoutes); app.use('/api/admin', serviceMonitorRoutes); 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); + +// Static file serving for uploads (authenticated) +app.use('/uploads', authenticate, express.static(path.resolve(__dirname, '../../uploads'))); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/controllers/bestellung.controller.ts b/backend/src/controllers/bestellung.controller.ts new file mode 100644 index 0000000..ec54c81 --- /dev/null +++ b/backend/src/controllers/bestellung.controller.ts @@ -0,0 +1,466 @@ +import { Request, Response } from 'express'; +import bestellungService from '../services/bestellung.service'; +import logger from '../utils/logger'; +import fs from 'fs'; + +// Helper to safely extract a route param as string +const param = (req: Request, key: string): string => req.params[key] as string; + +class BestellungController { + // --------------------------------------------------------------------------- + // Vendors + // --------------------------------------------------------------------------- + + async listVendors(_req: Request, res: Response): Promise { + try { + const vendors = await bestellungService.getVendors(); + res.status(200).json({ success: true, data: vendors }); + } catch (error) { + logger.error('BestellungController.listVendors error', { error }); + res.status(500).json({ success: false, message: 'Lieferanten konnten nicht geladen werden' }); + } + } + + async createVendor(req: Request, res: Response): Promise { + const { name } = req.body; + if (!name || typeof name !== 'string' || name.trim().length === 0) { + res.status(400).json({ success: false, message: 'Name ist erforderlich' }); + return; + } + try { + const vendor = await bestellungService.createVendor(req.body, req.user!.id); + res.status(201).json({ success: true, data: vendor }); + } catch (error) { + logger.error('BestellungController.createVendor error', { error }); + res.status(500).json({ success: false, message: 'Lieferant konnte nicht erstellt werden' }); + } + } + + async updateVendor(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 vendor = await bestellungService.updateVendor(id, req.body, req.user!.id); + if (!vendor) { + res.status(404).json({ success: false, message: 'Lieferant nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: vendor }); + } catch (error) { + logger.error('BestellungController.updateVendor error', { error }); + res.status(500).json({ success: false, message: 'Lieferant konnte nicht aktualisiert werden' }); + } + } + + async deleteVendor(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 deleted = await bestellungService.deleteVendor(id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Lieferant nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Lieferant gelöscht' }); + } catch (error) { + logger.error('BestellungController.deleteVendor error', { error }); + res.status(500).json({ success: false, message: 'Lieferant konnte nicht gelöscht werden' }); + } + } + + // --------------------------------------------------------------------------- + // Orders + // --------------------------------------------------------------------------- + + async listOrders(req: Request, res: Response): Promise { + try { + const filters: { status?: string; lieferant_id?: number; besteller_id?: string } = {}; + if (req.query.status) filters.status = req.query.status as string; + if (req.query.lieferant_id) filters.lieferant_id = parseInt(req.query.lieferant_id as string, 10); + if (req.query.besteller_id) filters.besteller_id = req.query.besteller_id as string; + + const orders = await bestellungService.getOrders(filters); + res.status(200).json({ success: true, data: orders }); + } catch (error) { + logger.error('BestellungController.listOrders error', { error }); + res.status(500).json({ success: false, message: 'Bestellungen konnten nicht geladen werden' }); + } + } + + async getOrder(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 order = await bestellungService.getOrderById(id); + if (!order) { + res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: order }); + } catch (error) { + logger.error('BestellungController.getOrder error', { error }); + res.status(500).json({ success: false, message: 'Bestellung konnte nicht geladen werden' }); + } + } + + async createOrder(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 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' }); + } + } + + async updateOrder(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 order = await bestellungService.updateOrder(id, req.body, req.user!.id); + if (!order) { + res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: order }); + } catch (error) { + logger.error('BestellungController.updateOrder error', { error }); + res.status(500).json({ success: false, message: 'Bestellung konnte nicht aktualisiert werden' }); + } + } + + async deleteOrder(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 deleted = await bestellungService.deleteOrder(id, req.user!.id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Bestellung gelöscht' }); + } catch (error) { + logger.error('BestellungController.deleteOrder error', { error }); + res.status(500).json({ success: false, message: 'Bestellung konnte nicht gelöscht werden' }); + } + } + + async updateStatus(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; + } + const { status } = req.body; + if (!status || typeof status !== 'string') { + res.status(400).json({ success: false, message: 'Status ist erforderlich' }); + return; + } + try { + const order = await bestellungService.updateOrderStatus(id, status, req.user!.id); + if (!order) { + res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: order }); + } catch (error: any) { + if (error.message?.includes('Ungültiger Statusübergang')) { + res.status(400).json({ success: false, message: error.message }); + return; + } + logger.error('BestellungController.updateStatus error', { error }); + res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' }); + } + } + + // --------------------------------------------------------------------------- + // Line Items + // --------------------------------------------------------------------------- + + async addLineItem(req: Request, res: Response): Promise { + const bestellungId = parseInt(param(req, 'id'), 10); + if (isNaN(bestellungId)) { + res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); + return; + } + const { artikel, menge } = req.body; + if (!artikel || typeof artikel !== 'string' || artikel.trim().length === 0) { + res.status(400).json({ success: false, message: 'Artikel ist erforderlich' }); + return; + } + if (menge === undefined || menge === null || menge <= 0) { + res.status(400).json({ success: false, message: 'Menge muss größer als 0 sein' }); + return; + } + try { + const item = await bestellungService.addLineItem(bestellungId, req.body, req.user!.id); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('BestellungController.addLineItem error', { error }); + res.status(500).json({ success: false, message: 'Position konnte nicht hinzugefügt werden' }); + } + } + + async updateLineItem(req: Request, res: Response): Promise { + const itemId = parseInt(param(req, 'itemId'), 10); + if (isNaN(itemId)) { + res.status(400).json({ success: false, message: 'Ungültige Position-ID' }); + return; + } + try { + const item = await bestellungService.updateLineItem(itemId, req.body, req.user!.id); + if (!item) { + res.status(404).json({ success: false, message: 'Position nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('BestellungController.updateLineItem error', { error }); + res.status(500).json({ success: false, message: 'Position konnte nicht aktualisiert werden' }); + } + } + + async deleteLineItem(req: Request, res: Response): Promise { + const itemId = parseInt(param(req, 'itemId'), 10); + if (isNaN(itemId)) { + res.status(400).json({ success: false, message: 'Ungültige Position-ID' }); + return; + } + try { + const deleted = await bestellungService.deleteLineItem(itemId, req.user!.id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Position nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Position gelöscht' }); + } catch (error) { + logger.error('BestellungController.deleteLineItem error', { error }); + res.status(500).json({ success: false, message: 'Position konnte nicht gelöscht werden' }); + } + } + + async updateReceivedQuantity(req: Request, res: Response): Promise { + const itemId = parseInt(param(req, 'itemId'), 10); + if (isNaN(itemId)) { + res.status(400).json({ success: false, message: 'Ungültige Position-ID' }); + return; + } + const { menge } = req.body; + if (menge === undefined || menge === null || menge < 0) { + res.status(400).json({ success: false, message: 'Erhaltene Menge muss >= 0 sein' }); + return; + } + try { + const item = await bestellungService.updateReceivedQuantity(itemId, menge, req.user!.id); + if (!item) { + res.status(404).json({ success: false, message: 'Position nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('BestellungController.updateReceivedQuantity error', { error }); + res.status(500).json({ success: false, message: 'Liefermenge konnte nicht aktualisiert werden' }); + } + } + + // --------------------------------------------------------------------------- + // Files + // --------------------------------------------------------------------------- + + async uploadFile(req: Request, res: Response): Promise { + const bestellungId = parseInt(param(req, 'id'), 10); + if (isNaN(bestellungId)) { + res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); + return; + } + const file = (req as any).file; + if (!file) { + res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); + return; + } + try { + const fileRecord = await bestellungService.addFile(bestellungId, { + dateiname: file.originalname, + dateipfad: file.path, + dateityp: file.mimetype, + dateigroesse: file.size, + }, req.user!.id); + res.status(201).json({ success: true, data: fileRecord }); + } catch (error) { + logger.error('BestellungController.uploadFile error', { error }); + res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' }); + } + } + + async deleteFile(req: Request, res: Response): Promise { + const fileId = parseInt(param(req, 'fileId'), 10); + if (isNaN(fileId)) { + res.status(400).json({ success: false, message: 'Ungültige Datei-ID' }); + return; + } + try { + const result = await bestellungService.deleteFile(fileId, req.user!.id); + if (!result) { + res.status(404).json({ success: false, message: 'Datei nicht gefunden' }); + return; + } + // Remove from disk + try { + if (result.dateipfad && fs.existsSync(result.dateipfad)) { + fs.unlinkSync(result.dateipfad); + } + } catch (err) { + logger.warn('Failed to delete file from disk', { path: result.dateipfad, error: err }); + } + res.status(200).json({ success: true, message: 'Datei gelöscht' }); + } catch (error) { + logger.error('BestellungController.deleteFile error', { error }); + res.status(500).json({ success: false, message: 'Datei konnte nicht gelöscht werden' }); + } + } + + async listFiles(req: Request, res: Response): Promise { + const bestellungId = parseInt(param(req, 'id'), 10); + if (isNaN(bestellungId)) { + res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); + return; + } + try { + const files = await bestellungService.getFilesByOrder(bestellungId); + res.status(200).json({ success: true, data: files }); + } catch (error) { + logger.error('BestellungController.listFiles error', { error }); + res.status(500).json({ success: false, message: 'Dateien konnten nicht geladen werden' }); + } + } + + // --------------------------------------------------------------------------- + // Reminders + // --------------------------------------------------------------------------- + + async addReminder(req: Request, res: Response): Promise { + const bestellungId = parseInt(param(req, 'id'), 10); + if (isNaN(bestellungId)) { + res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); + return; + } + const { titel, faellig_am } = req.body; + if (!titel || typeof titel !== 'string' || titel.trim().length === 0) { + res.status(400).json({ success: false, message: 'Titel ist erforderlich' }); + return; + } + if (!faellig_am) { + res.status(400).json({ success: false, message: 'Fälligkeitsdatum ist erforderlich' }); + return; + } + try { + const reminder = await bestellungService.addReminder(bestellungId, req.body, req.user!.id); + res.status(201).json({ success: true, data: reminder }); + } catch (error) { + logger.error('BestellungController.addReminder error', { error }); + res.status(500).json({ success: false, message: 'Erinnerung konnte nicht erstellt werden' }); + } + } + + async markReminderDone(req: Request, res: Response): Promise { + const remId = parseInt(param(req, 'remId'), 10); + if (isNaN(remId)) { + res.status(400).json({ success: false, message: 'Ungültige Erinnerungs-ID' }); + return; + } + try { + const reminder = await bestellungService.markReminderDone(remId, req.user!.id); + if (!reminder) { + res.status(404).json({ success: false, message: 'Erinnerung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: reminder }); + } catch (error) { + logger.error('BestellungController.markReminderDone error', { error }); + res.status(500).json({ success: false, message: 'Erinnerung konnte nicht als erledigt markiert werden' }); + } + } + + async deleteReminder(req: Request, res: Response): Promise { + const remId = parseInt(param(req, 'remId'), 10); + if (isNaN(remId)) { + res.status(400).json({ success: false, message: 'Ungültige Erinnerungs-ID' }); + return; + } + try { + const deleted = await bestellungService.deleteReminder(remId); + if (!deleted) { + res.status(404).json({ success: false, message: 'Erinnerung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Erinnerung gelöscht' }); + } catch (error) { + logger.error('BestellungController.deleteReminder error', { error }); + res.status(500).json({ success: false, message: 'Erinnerung konnte nicht gelöscht werden' }); + } + } + + // --------------------------------------------------------------------------- + // History + // --------------------------------------------------------------------------- + + async getHistory(req: Request, res: Response): Promise { + const bestellungId = parseInt(param(req, 'id'), 10); + if (isNaN(bestellungId)) { + res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); + return; + } + try { + const history = await bestellungService.getHistory(bestellungId); + res.status(200).json({ success: true, data: history }); + } catch (error) { + logger.error('BestellungController.getHistory error', { error }); + res.status(500).json({ success: false, message: 'Historie konnte nicht geladen werden' }); + } + } + + // --------------------------------------------------------------------------- + // Export (placeholder — returns order detail as JSON for now) + // --------------------------------------------------------------------------- + + async exportOrder(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 order = await bestellungService.getOrderById(id); + if (!order) { + res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: order }); + } catch (error) { + logger.error('BestellungController.exportOrder error', { error }); + res.status(500).json({ success: false, message: 'Export fehlgeschlagen' }); + } + } +} + +export default new BestellungController(); diff --git a/backend/src/controllers/shop.controller.ts b/backend/src/controllers/shop.controller.ts new file mode 100644 index 0000000..796e86d --- /dev/null +++ b/backend/src/controllers/shop.controller.ts @@ -0,0 +1,256 @@ +import { Request, Response } from 'express'; +import shopService from '../services/shop.service'; +import notificationService from '../services/notification.service'; +import logger from '../utils/logger'; + +class ShopController { + // ------------------------------------------------------------------------- + // Catalog Items + // ------------------------------------------------------------------------- + + async getItems(req: Request, res: Response): Promise { + 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 }); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('ShopController.getItems error', { error }); + res.status(500).json({ success: false, message: 'Artikel konnten nicht geladen werden' }); + } + } + + async getItemById(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + const item = await shopService.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 }); + res.status(500).json({ success: false, message: 'Artikel konnte nicht geladen werden' }); + } + } + + async createItem(req: Request, res: Response): Promise { + try { + const { bezeichnung } = req.body; + if (!bezeichnung || bezeichnung.trim().length === 0) { + res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' }); + return; + } + const item = await shopService.createItem(req.body, req.user!.id); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('ShopController.createItem error', { error }); + res.status(500).json({ success: false, message: 'Artikel konnte nicht erstellt werden' }); + } + } + + 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); + 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 }); + res.status(500).json({ success: false, message: 'Artikel konnte nicht aktualisiert werden' }); + } + } + + async deleteItem(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + await shopService.deleteItem(id); + res.status(200).json({ success: true, message: 'Artikel gelöscht' }); + } catch (error) { + logger.error('ShopController.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(); + res.status(200).json({ success: true, data: categories }); + } catch (error) { + logger.error('ShopController.getCategories error', { error }); + res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' }); + } + } + + // ------------------------------------------------------------------------- + // Requests + // ------------------------------------------------------------------------- + + async getRequests(req: Request, res: Response): Promise { + 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 }); + res.status(200).json({ success: true, data: requests }); + } catch (error) { + logger.error('ShopController.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); + res.status(200).json({ success: true, data: requests }); + } catch (error) { + logger.error('ShopController.getMyRequests error', { error }); + res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' }); + } + } + + async getRequestById(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + const request = await shopService.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 }); + res.status(500).json({ success: false, message: 'Anfrage konnte nicht geladen werden' }); + } + } + + async createRequest(req: Request, res: Response): Promise { + try { + const { items, notizen } = req.body as { + items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[]; + notizen?: string; + }; + + if (!items || items.length === 0) { + res.status(400).json({ success: false, message: 'Mindestens eine Position ist erforderlich' }); + return; + } + + for (const item of items) { + if (!item.bezeichnung || item.bezeichnung.trim().length === 0) { + res.status(400).json({ success: false, message: 'Bezeichnung ist für alle Positionen erforderlich' }); + return; + } + if (!item.menge || item.menge < 1) { + res.status(400).json({ success: false, message: 'Menge muss mindestens 1 sein' }); + return; + } + } + + const request = await shopService.createRequest(req.user!.id, items, notizen); + res.status(201).json({ success: true, data: request }); + } catch (error) { + logger.error('ShopController.createRequest error', { error }); + res.status(500).json({ success: false, message: 'Anfrage konnte nicht erstellt werden' }); + } + } + + async updateRequestStatus(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + const { status, admin_notizen } = req.body as { + status?: string; + admin_notizen?: string; + }; + + if (!status) { + res.status(400).json({ success: false, message: 'Status ist erforderlich' }); + return; + } + + const validStatuses = ['offen', 'genehmigt', 'abgelehnt', 'bestellt', 'erledigt']; + if (!validStatuses.includes(status)) { + res.status(400).json({ success: false, message: `Ungültiger Status. Erlaubt: ${validStatuses.join(', ')}` }); + return; + } + + // Fetch request to get anfrager_id for notification + const existing = await shopService.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); + + // Notify requester on status changes + if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) { + 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}.`, + schwere: status === 'abgelehnt' ? 'warnung' : 'info', + link: '/shop', + quell_id: String(id), + quell_typ: 'shop_anfrage', + }); + } + + res.status(200).json({ success: true, data: updated }); + } catch (error) { + logger.error('ShopController.updateRequestStatus error', { error }); + res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' }); + } + } + + async deleteRequest(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + await shopService.deleteRequest(id); + res.status(200).json({ success: true, message: 'Anfrage gelöscht' }); + } catch (error) { + logger.error('ShopController.deleteRequest error', { error }); + res.status(500).json({ success: false, message: 'Anfrage konnte nicht gelöscht werden' }); + } + } + + // ------------------------------------------------------------------------- + // Linking + // ------------------------------------------------------------------------- + + async linkToOrder(req: Request, res: Response): Promise { + try { + const anfrageId = Number(req.params.id); + const { bestellung_id } = req.body as { bestellung_id?: number }; + + if (!bestellung_id) { + res.status(400).json({ success: false, message: 'bestellung_id ist erforderlich' }); + return; + } + + await shopService.linkToOrder(anfrageId, bestellung_id); + res.status(200).json({ success: true, message: 'Verknüpfung erstellt' }); + } catch (error) { + logger.error('ShopController.linkToOrder error', { error }); + res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht erstellt werden' }); + } + } + + async unlinkFromOrder(req: Request, res: Response): Promise { + try { + const anfrageId = Number(req.params.id); + const bestellungId = Number(req.params.bestellungId); + await shopService.unlinkFromOrder(anfrageId, bestellungId); + res.status(200).json({ success: true, message: 'Verknüpfung entfernt' }); + } catch (error) { + logger.error('ShopController.unlinkFromOrder error', { error }); + res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht entfernt werden' }); + } + } +} + +export default new ShopController(); diff --git a/backend/src/database/migrations/038_create_bestellungen.sql b/backend/src/database/migrations/038_create_bestellungen.sql new file mode 100644 index 0000000..0035be5 --- /dev/null +++ b/backend/src/database/migrations/038_create_bestellungen.sql @@ -0,0 +1,145 @@ +-- Migration 038: Bestellungen (Vendor Orders) system +-- Tables for vendors, orders, line items, file attachments, reminders, and audit trail. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Lieferanten (Vendors) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS lieferanten ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + kontakt_name TEXT, + email TEXT, + telefon TEXT, + adresse TEXT, + website TEXT, + notizen TEXT, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_lieferanten_name ON lieferanten(name); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Bestellungen (Orders) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS bestellungen ( + id SERIAL PRIMARY KEY, + bezeichnung TEXT NOT NULL, + lieferant_id INT REFERENCES lieferanten(id) ON DELETE SET NULL, + besteller_id UUID REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'entwurf' + CHECK (status IN ('entwurf','erstellt','bestellt','teillieferung','vollstaendig','abgeschlossen')), + budget NUMERIC(10,2), + notizen TEXT, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + bestellt_am TIMESTAMPTZ, + abgeschlossen_am TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_bestellungen_status ON bestellungen(status); +CREATE INDEX IF NOT EXISTS idx_bestellungen_lieferant ON bestellungen(lieferant_id); +CREATE INDEX IF NOT EXISTS idx_bestellungen_besteller ON bestellungen(besteller_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Bestellpositionen (Order Line Items) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS bestellpositionen ( + id SERIAL PRIMARY KEY, + bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE, + bezeichnung TEXT NOT NULL, + artikelnummer TEXT, + menge NUMERIC NOT NULL DEFAULT 1, + einheit TEXT DEFAULT 'Stk', + einzelpreis NUMERIC(10,2), + erhalten_menge NUMERIC NOT NULL DEFAULT 0, + notizen TEXT, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bestellpositionen_bestellung ON bestellpositionen(bestellung_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Bestellung Dateien (Order File Attachments) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS bestellung_dateien ( + id SERIAL PRIMARY KEY, + bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE, + dateiname TEXT NOT NULL, + dateipfad TEXT NOT NULL, + dateityp TEXT NOT NULL, + dateigroesse INT, + thumbnail_pfad TEXT, + hochgeladen_von UUID REFERENCES users(id) ON DELETE SET NULL, + hochgeladen_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bestellung_dateien_bestellung ON bestellung_dateien(bestellung_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 5. Bestellung Erinnerungen (Order Reminders) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS bestellung_erinnerungen ( + id SERIAL PRIMARY KEY, + bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE, + faellig_am TIMESTAMPTZ NOT NULL, + nachricht TEXT, + erledigt BOOLEAN NOT NULL DEFAULT FALSE, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bestellung_erinnerungen_faellig ON bestellung_erinnerungen(faellig_am) WHERE NOT erledigt; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 6. Bestellung Historie (Audit Trail) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS bestellung_historie ( + id SERIAL PRIMARY KEY, + bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE, + aktion TEXT NOT NULL, + details JSONB, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bestellung_historie_bestellung ON bestellung_historie(bestellung_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 7. Auto-update aktualisiert_am triggers +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE OR REPLACE FUNCTION update_aktualisiert_am() +RETURNS TRIGGER AS $$ +BEGIN + NEW.aktualisiert_am = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_lieferanten_aktualisiert') THEN + CREATE TRIGGER trg_lieferanten_aktualisiert BEFORE UPDATE ON lieferanten + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_bestellungen_aktualisiert') THEN + CREATE TRIGGER trg_bestellungen_aktualisiert BEFORE UPDATE ON bestellungen + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_bestellpositionen_aktualisiert') THEN + CREATE TRIGGER trg_bestellpositionen_aktualisiert BEFORE UPDATE ON bestellpositionen + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + END IF; +END $$; diff --git a/backend/src/database/migrations/039_create_shop.sql b/backend/src/database/migrations/039_create_shop.sql new file mode 100644 index 0000000..596a438 --- /dev/null +++ b/backend/src/database/migrations/039_create_shop.sql @@ -0,0 +1,84 @@ +-- Migration 039: Internal Shop system +-- Tables for catalog items, member requests, request line items, and order linking. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Shop Artikel (Catalog Items) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS shop_artikel ( + id SERIAL PRIMARY KEY, + bezeichnung TEXT NOT NULL, + beschreibung TEXT, + kategorie TEXT, + bild_pfad TEXT, + geschaetzter_preis NUMERIC(10,2), + aktiv BOOLEAN NOT NULL DEFAULT TRUE, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_shop_artikel_kategorie ON shop_artikel(kategorie); +CREATE INDEX IF NOT EXISTS idx_shop_artikel_aktiv ON shop_artikel(aktiv); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Shop Anfragen (Member Requests) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS shop_anfragen ( + id SERIAL PRIMARY KEY, + anfrager_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'offen' + CHECK (status IN ('offen','genehmigt','abgelehnt','bestellt','erledigt')), + notizen TEXT, + admin_notizen TEXT, + bearbeitet_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_shop_anfragen_anfrager ON shop_anfragen(anfrager_id); +CREATE INDEX IF NOT EXISTS idx_shop_anfragen_status ON shop_anfragen(status); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Shop Anfrage Positionen (Request Line Items) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS shop_anfrage_positionen ( + id SERIAL PRIMARY KEY, + anfrage_id INT NOT NULL REFERENCES shop_anfragen(id) ON DELETE CASCADE, + artikel_id INT REFERENCES shop_artikel(id) ON DELETE SET NULL, + bezeichnung TEXT NOT NULL, + menge NUMERIC NOT NULL DEFAULT 1, + notizen TEXT, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_shop_anfrage_positionen_anfrage ON shop_anfrage_positionen(anfrage_id); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Shop Anfrage ↔ Bestellung Link +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS shop_anfrage_bestellung ( + anfrage_id INT NOT NULL REFERENCES shop_anfragen(id) ON DELETE CASCADE, + bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE, + PRIMARY KEY (anfrage_id, bestellung_id) +); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 5. Auto-update triggers +-- ═══════════════════════════════════════════════════════════════════════════ + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_shop_artikel_aktualisiert') THEN + CREATE TRIGGER trg_shop_artikel_aktualisiert BEFORE UPDATE ON shop_artikel + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_shop_anfragen_aktualisiert') THEN + CREATE TRIGGER trg_shop_anfragen_aktualisiert BEFORE UPDATE ON shop_anfragen + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + END IF; +END $$; diff --git a/backend/src/database/migrations/040_update_permissions.sql b/backend/src/database/migrations/040_update_permissions.sql new file mode 100644 index 0000000..394a3da --- /dev/null +++ b/backend/src/database/migrations/040_update_permissions.sql @@ -0,0 +1,158 @@ +-- Migration 040: Permission updates +-- 1. Add bestellungen + shop feature groups and their permissions +-- 2. Simplify calendar permissions from 13 → 4 +-- 3. Migrate existing group_permissions to new calendar scheme + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. New feature groups +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO feature_groups (id, label, sort_order) VALUES + ('bestellungen', 'Bestellungen', 11), + ('shop', 'Shop', 12) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Bestellungen permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('bestellungen:view', 'bestellungen', 'Ansehen', 'Bestellungen einsehen', 1), + ('bestellungen:create', 'bestellungen', 'Erstellen/Bearbeiten', 'Bestellungen erstellen und bearbeiten', 2), + ('bestellungen:delete', 'bestellungen', 'Löschen', 'Bestellungen löschen', 3), + ('bestellungen:manage_vendors', 'bestellungen', 'Lieferanten verwalten','Lieferanten-Datenbank verwalten', 4), + ('bestellungen:export', 'bestellungen', 'PDF Export', 'Bestellungen als PDF exportieren', 5), + ('bestellungen:manage_reminders', 'bestellungen', 'Erinnerungen', 'Erinnerungen für Bestellungen verwalten', 6), + ('bestellungen:widget', 'bestellungen', 'Widget', 'Dashboard-Widget für Bestellungen', 7) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Shop permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('shop:view', 'shop', 'Katalog ansehen', 'Shop-Katalog einsehen', 1), + ('shop:create_request', 'shop', 'Anfrage stellen', 'Bestellanfragen an Admin stellen', 2), + ('shop:manage_catalog', 'shop', 'Katalog verwalten', 'Artikel im Shop-Katalog verwalten', 3), + ('shop:approve_requests', 'shop', 'Anfragen genehmigen', 'Bestellanfragen genehmigen oder ablehnen', 4), + ('shop:link_orders', 'shop', 'Mit Bestellung verknüpfen', 'Anfragen mit Lieferantenbestellungen verknüpfen', 5), + ('shop:widget', 'shop', 'Widget', 'Dashboard-Widget für Shop-Anfragen', 6) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Calendar permission simplification (13 → 4) +-- ═══════════════════════════════════════════════════════════════════════════ +-- New scheme: +-- kalender:view — see events + widgets +-- kalender:create — create/edit/cancel events, mark attendance, manage categories, view reports +-- kalender:view_bookings — see bookings + booking widgets +-- kalender:manage_bookings — create/edit/cancel/delete bookings + +-- 4a. Collect which groups had which old permissions, then map to new ones. +-- We use a temp table so we don't lose data during the transition. + +CREATE TEMP TABLE _cal_migration AS +SELECT DISTINCT authentik_group, + CASE + -- Any group that had kalender:create OR kalender:cancel OR kalender:manage_categories + -- OR kalender:view_reports OR kalender:mark_attendance → gets kalender:create + WHEN permission_id IN ('kalender:create','kalender:cancel','kalender:manage_categories', + 'kalender:view_reports','kalender:mark_attendance') + THEN 'kalender:create' + -- Widget permissions → kalender:view (they already have it if they had widget perms) + WHEN permission_id IN ('kalender:widget_events','kalender:widget_quick_add') + THEN 'kalender:view' + -- Booking-related view widgets → kalender:view_bookings + WHEN permission_id IN ('kalender:widget_bookings') + THEN 'kalender:view_bookings' + -- All booking write ops → kalender:manage_bookings + WHEN permission_id IN ('kalender:create_bookings','kalender:edit_bookings', + 'kalender:cancel_own_bookings','kalender:delete_bookings') + THEN 'kalender:manage_bookings' + ELSE permission_id + END AS new_perm +FROM group_permissions +WHERE permission_id LIKE 'kalender:%'; + +-- 4b. Delete old calendar permissions from group_permissions +DELETE FROM group_permissions WHERE permission_id LIKE 'kalender:%'; + +-- 4c. Delete old calendar permission definitions +DELETE FROM permissions WHERE id LIKE 'kalender:%'; + +-- 4d. Insert new calendar permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('kalender:view', 'kalender', 'Termine ansehen', 'Kalender-Termine und Widgets einsehen', 1), + ('kalender:create', 'kalender', 'Termine verwalten', 'Termine erstellen/bearbeiten/absagen, Kategorien, Berichte', 2), + ('kalender:view_bookings', 'kalender', 'Buchungen ansehen', 'Fahrzeugbuchungen und Buchungs-Widget einsehen', 3), + ('kalender:manage_bookings', 'kalender', 'Buchungen verwalten', 'Buchungen erstellen/bearbeiten/stornieren/löschen', 4) +ON CONFLICT (id) DO NOTHING; + +-- 4e. Re-insert migrated grants (only valid new permissions) +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT DISTINCT authentik_group, new_perm +FROM _cal_migration +WHERE new_perm IN ('kalender:view','kalender:create','kalender:view_bookings','kalender:manage_bookings') +ON CONFLICT DO NOTHING; + +-- Also ensure everyone who had any calendar perm gets kalender:view +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT DISTINCT authentik_group, 'kalender:view' +FROM _cal_migration +ON CONFLICT DO NOTHING; + +-- And everyone who had any booking perm gets kalender:view_bookings +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT DISTINCT authentik_group, 'kalender:view_bookings' +FROM _cal_migration +WHERE new_perm = 'kalender:manage_bookings' +ON CONFLICT DO NOTHING; + +DROP TABLE _cal_migration; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 5. Seed bestellungen + shop permissions for existing groups +-- ═══════════════════════════════════════════════════════════════════════════ +-- kommando gets full access, other groups get view + shop request + +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + -- Kommando: full bestellungen + shop + ('dashboard_kommando', 'bestellungen:view'), + ('dashboard_kommando', 'bestellungen:create'), + ('dashboard_kommando', 'bestellungen:delete'), + ('dashboard_kommando', 'bestellungen:manage_vendors'), + ('dashboard_kommando', 'bestellungen:export'), + ('dashboard_kommando', 'bestellungen:manage_reminders'), + ('dashboard_kommando', 'bestellungen:widget'), + ('dashboard_kommando', 'shop:view'), + ('dashboard_kommando', 'shop:create_request'), + ('dashboard_kommando', 'shop:manage_catalog'), + ('dashboard_kommando', 'shop:approve_requests'), + ('dashboard_kommando', 'shop:link_orders'), + ('dashboard_kommando', 'shop:widget'), + -- Fahrmeister: view orders + shop request + ('dashboard_fahrmeister', 'bestellungen:view'), + ('dashboard_fahrmeister', 'bestellungen:widget'), + ('dashboard_fahrmeister', 'shop:view'), + ('dashboard_fahrmeister', 'shop:create_request'), + -- Zeugmeister: view orders + shop request + ('dashboard_zeugmeister', 'bestellungen:view'), + ('dashboard_zeugmeister', 'bestellungen:widget'), + ('dashboard_zeugmeister', 'shop:view'), + ('dashboard_zeugmeister', 'shop:create_request'), + -- Chargen: view orders + shop request + ('dashboard_chargen', 'bestellungen:view'), + ('dashboard_chargen', 'bestellungen:widget'), + ('dashboard_chargen', 'shop:view'), + ('dashboard_chargen', 'shop:create_request'), + -- Moderator: view orders + shop request + ('dashboard_moderator', 'bestellungen:view'), + ('dashboard_moderator', 'shop:view'), + ('dashboard_moderator', 'shop:create_request'), + -- Atemschutz: shop request only + ('dashboard_atemschutz', 'shop:view'), + ('dashboard_atemschutz', 'shop:create_request'), + -- Mitglied: shop request only + ('dashboard_mitglied', 'shop:view'), + ('dashboard_mitglied', 'shop:create_request') +ON CONFLICT DO NOTHING; diff --git a/backend/src/jobs/reminder.job.ts b/backend/src/jobs/reminder.job.ts new file mode 100644 index 0000000..a1ea9dc --- /dev/null +++ b/backend/src/jobs/reminder.job.ts @@ -0,0 +1,75 @@ +import pool from '../config/database'; +import notificationService from '../services/notification.service'; +import logger from '../utils/logger'; + +const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +let jobInterval: ReturnType | null = null; +let isRunning = false; + +async function runReminderCheck(): Promise { + if (isRunning) { + logger.warn('ReminderJob: previous run still in progress — skipping'); + return; + } + isRunning = true; + try { + // Find due reminders that haven't been processed + const result = await pool.query(` + SELECT e.id, e.bestellung_id, e.nachricht, e.erstellt_von, + b.bezeichnung AS bestellung_bezeichnung, b.besteller_id + FROM bestellung_erinnerungen e + JOIN bestellungen b ON b.id = e.bestellung_id + WHERE e.faellig_am <= NOW() + AND e.erledigt = FALSE + `); + + for (const row of result.rows) { + // Notify the order handler (besteller_id) or the creator + const targetUserId = row.besteller_id || row.erstellt_von; + if (!targetUserId) continue; + + await notificationService.createNotification({ + user_id: targetUserId, + typ: 'bestellung_erinnerung', + titel: 'Bestellungs-Erinnerung', + nachricht: row.nachricht || `Erinnerung für Bestellung "${row.bestellung_bezeichnung}"`, + schwere: 'info', + link: `/bestellungen/${row.bestellung_id}`, + quell_id: `bestellung-erinnerung-${row.id}`, + quell_typ: 'bestellung_erinnerung', + }); + + // Mark as done + await pool.query('UPDATE bestellung_erinnerungen SET erledigt = TRUE WHERE id = $1', [row.id]); + } + + if (result.rows.length > 0) { + logger.info(`ReminderJob: processed ${result.rows.length} reminders`); + } + } catch (error) { + logger.error('ReminderJob: unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + isRunning = false; + } +} + +export function startReminderJob(): void { + if (jobInterval !== null) { + logger.warn('Reminder job already running — skipping duplicate start'); + return; + } + // Run once after short delay, then repeat + setTimeout(() => runReminderCheck(), 45 * 1000); + jobInterval = setInterval(() => runReminderCheck(), INTERVAL_MS); + logger.info('Reminder job scheduled (every 15 minutes)'); +} + +export function stopReminderJob(): void { + if (jobInterval !== null) { + clearInterval(jobInterval); + jobInterval = null; + } + logger.info('Reminder job stopped'); +} diff --git a/backend/src/middleware/upload.ts b/backend/src/middleware/upload.ts new file mode 100644 index 0000000..c76cc96 --- /dev/null +++ b/backend/src/middleware/upload.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import logger from '../utils/logger'; + +const UPLOAD_DIR = path.resolve(__dirname, '../../../uploads/bestellungen'); +const THUMBNAIL_DIR = path.resolve(__dirname, '../../../uploads/bestellungen/thumbnails'); + +// Ensure directories exist +[UPLOAD_DIR, THUMBNAIL_DIR].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + logger.info(`Created upload directory: ${dir}`); + } +}); + +const storage = multer.diskStorage({ + destination(_req: any, _file: any, cb: any) { + cb(null, UPLOAD_DIR); + }, + filename(_req: any, file: any, cb: any) { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const ext = path.extname(file.originalname); + cb(null, `${uniqueSuffix}${ext}`); + }, +}); + +const ALLOWED_TYPES = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'application/pdf', + 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', 'text/csv', +]; + +const multerOptions: any = { + storage, + fileFilter(_req: any, file: any, cb: any) { + if (ALLOWED_TYPES.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`Dateityp ${file.mimetype} ist nicht erlaubt.`)); + } + }, + limits: { fileSize: 20 * 1024 * 1024 }, // 20 MB +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const uploadBestellung: any = multer(multerOptions); + +export { UPLOAD_DIR, THUMBNAIL_DIR }; diff --git a/backend/src/routes/bestellung.routes.ts b/backend/src/routes/bestellung.routes.ts new file mode 100644 index 0000000..5a43a14 --- /dev/null +++ b/backend/src/routes/bestellung.routes.ts @@ -0,0 +1,189 @@ +import { Router } from 'express'; +import bestellungController from '../controllers/bestellung.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; +import { uploadBestellung } from '../middleware/upload'; + +const router = Router(); + +// --------------------------------------------------------------------------- +// Vendors (Lieferanten) +// --------------------------------------------------------------------------- + +router.get( + '/vendors', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.listVendors.bind(bestellungController) +); + +router.post( + '/vendors', + authenticate, + requirePermission('bestellungen:manage_vendors'), + bestellungController.createVendor.bind(bestellungController) +); + +router.patch( + '/vendors/:id', + authenticate, + requirePermission('bestellungen:manage_vendors'), + bestellungController.updateVendor.bind(bestellungController) +); + +router.delete( + '/vendors/:id', + authenticate, + requirePermission('bestellungen:manage_vendors'), + bestellungController.deleteVendor.bind(bestellungController) +); + +// --------------------------------------------------------------------------- +// Orders (Bestellungen) +// --------------------------------------------------------------------------- + +router.get( + '/', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.listOrders.bind(bestellungController) +); + +router.post( + '/', + authenticate, + requirePermission('bestellungen:create'), + bestellungController.createOrder.bind(bestellungController) +); + +// Export must come before /:id to avoid param capture +router.get( + '/export/:id', + authenticate, + requirePermission('bestellungen:export'), + bestellungController.exportOrder.bind(bestellungController) +); + +router.get( + '/:id', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.getOrder.bind(bestellungController) +); + +router.patch( + '/:id', + authenticate, + requirePermission('bestellungen:create'), + bestellungController.updateOrder.bind(bestellungController) +); + +router.delete( + '/:id', + authenticate, + requirePermission('bestellungen:delete'), + bestellungController.deleteOrder.bind(bestellungController) +); + +router.patch( + '/:id/status', + authenticate, + requirePermission('bestellungen:create'), + bestellungController.updateStatus.bind(bestellungController) +); + +// --------------------------------------------------------------------------- +// Line Items (Bestellpositionen) +// --------------------------------------------------------------------------- + +router.post( + '/:id/items', + authenticate, + requirePermission('bestellungen:create'), + bestellungController.addLineItem.bind(bestellungController) +); + +router.patch( + '/items/:itemId', + authenticate, + requirePermission('bestellungen:create'), + bestellungController.updateLineItem.bind(bestellungController) +); + +router.delete( + '/items/:itemId', + authenticate, + requirePermission('bestellungen:delete'), + bestellungController.deleteLineItem.bind(bestellungController) +); + +router.patch( + '/items/:itemId/received', + authenticate, + requirePermission('bestellungen:create'), + bestellungController.updateReceivedQuantity.bind(bestellungController) +); + +// --------------------------------------------------------------------------- +// Files (Bestellung Dateien) +// --------------------------------------------------------------------------- + +router.post( + '/:id/files', + authenticate, + requirePermission('bestellungen:create'), + uploadBestellung.single('datei'), + bestellungController.uploadFile.bind(bestellungController) +); + +router.delete( + '/files/:fileId', + authenticate, + requirePermission('bestellungen:delete'), + bestellungController.deleteFile.bind(bestellungController) +); + +router.get( + '/:id/files', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.listFiles.bind(bestellungController) +); + +// --------------------------------------------------------------------------- +// Reminders (Erinnerungen) +// --------------------------------------------------------------------------- + +router.post( + '/:id/reminders', + authenticate, + requirePermission('bestellungen:manage_reminders'), + bestellungController.addReminder.bind(bestellungController) +); + +router.patch( + '/reminders/:remId', + authenticate, + requirePermission('bestellungen:manage_reminders'), + bestellungController.markReminderDone.bind(bestellungController) +); + +router.delete( + '/reminders/:remId', + authenticate, + requirePermission('bestellungen:manage_reminders'), + bestellungController.deleteReminder.bind(bestellungController) +); + +// --------------------------------------------------------------------------- +// History & Export +// --------------------------------------------------------------------------- + +router.get( + '/:id/history', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.getHistory.bind(bestellungController) +); + +export default router; diff --git a/backend/src/routes/booking.routes.ts b/backend/src/routes/booking.routes.ts index 89cb3bb..1cd7d97 100644 --- a/backend/src/routes/booking.routes.ts +++ b/backend/src/routes/booking.routes.ts @@ -18,15 +18,15 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b // ── Write operations ────────────────────────────────────────────────────────── -router.post('/', authenticate, bookingController.create.bind(bookingController)); -router.patch('/:id', authenticate, requirePermission('kalender:edit_bookings'), bookingController.update.bind(bookingController)); +router.post('/', authenticate, requirePermission('kalender:manage_bookings'), bookingController.create.bind(bookingController)); +router.patch('/:id', authenticate, requirePermission('kalender:manage_bookings'), bookingController.update.bind(bookingController)); // Soft-cancel (sets abgesagt=TRUE) — creator or bookings:write router.delete('/:id', authenticate, bookingController.cancel.bind(bookingController)); router.patch('/:id/cancel', authenticate, bookingController.cancel.bind(bookingController)); // Hard-delete (admin only) -router.delete('/:id/force', authenticate, requirePermission('kalender:delete_bookings'), bookingController.hardDelete.bind(bookingController)); +router.delete('/:id/force', authenticate, requirePermission('kalender:manage_bookings'), bookingController.hardDelete.bind(bookingController)); // ── Single booking read — after specific routes to avoid path conflicts ─────── diff --git a/backend/src/routes/bookstack.routes.ts b/backend/src/routes/bookstack.routes.ts index 9b1112a..98c1e2f 100644 --- a/backend/src/routes/bookstack.routes.ts +++ b/backend/src/routes/bookstack.routes.ts @@ -1,11 +1,12 @@ import { Router } from 'express'; import bookstackController from '../controllers/bookstack.controller'; import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); -router.get('/recent', authenticate, bookstackController.getRecent.bind(bookstackController)); -router.get('/search', authenticate, bookstackController.search.bind(bookstackController)); -router.get('/pages/:id', authenticate, bookstackController.getPage.bind(bookstackController)); +router.get('/recent', authenticate, requirePermission('wissen:view'), bookstackController.getRecent.bind(bookstackController)); +router.get('/search', authenticate, requirePermission('wissen:view'), bookstackController.search.bind(bookstackController)); +router.get('/pages/:id', authenticate, requirePermission('wissen:view'), bookstackController.getPage.bind(bookstackController)); export default router; diff --git a/backend/src/routes/events.routes.ts b/backend/src/routes/events.routes.ts index baf3238..f539c22 100644 --- a/backend/src/routes/events.routes.ts +++ b/backend/src/routes/events.routes.ts @@ -22,7 +22,7 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve router.post( '/kategorien', authenticate, - requirePermission('kalender:manage_categories'), + requirePermission('kalender:create'), eventsController.createKategorie.bind(eventsController) ); @@ -33,7 +33,7 @@ router.post( router.patch( '/kategorien/:id', authenticate, - requirePermission('kalender:manage_categories'), + requirePermission('kalender:create'), eventsController.updateKategorie.bind(eventsController) ); @@ -44,7 +44,7 @@ router.patch( router.delete( '/kategorien/:id', authenticate, - requirePermission('kalender:manage_categories'), + requirePermission('kalender:create'), eventsController.deleteKategorie.bind(eventsController) ); diff --git a/backend/src/routes/shop.routes.ts b/backend/src/routes/shop.routes.ts new file mode 100644 index 0000000..368f417 --- /dev/null +++ b/backend/src/routes/shop.routes.ts @@ -0,0 +1,38 @@ +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)); + +// --------------------------------------------------------------------------- +// 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/routes/vikunja.routes.ts b/backend/src/routes/vikunja.routes.ts index d2df9b9..8b101d1 100644 --- a/backend/src/routes/vikunja.routes.ts +++ b/backend/src/routes/vikunja.routes.ts @@ -1,12 +1,13 @@ import { Router } from 'express'; import vikunjaController from '../controllers/vikunja.controller'; import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); -router.get('/tasks', authenticate, vikunjaController.getMyTasks.bind(vikunjaController)); +router.get('/tasks', authenticate, requirePermission('vikunja:widget_tasks'), vikunjaController.getMyTasks.bind(vikunjaController)); router.get('/overdue', authenticate, vikunjaController.getOverdueTasks.bind(vikunjaController)); -router.get('/projects', authenticate, vikunjaController.getProjects.bind(vikunjaController)); -router.post('/tasks', authenticate, vikunjaController.createTask.bind(vikunjaController)); +router.get('/projects', authenticate, requirePermission('vikunja:create_tasks'), vikunjaController.getProjects.bind(vikunjaController)); +router.post('/tasks', authenticate, requirePermission('vikunja:create_tasks'), vikunjaController.createTask.bind(vikunjaController)); export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index 0e38617..de71d32 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,6 +4,7 @@ import logger from './utils/logger'; import { testConnection, closePool, runMigrations } from './config/database'; import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job'; import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job'; +import { startReminderJob, stopReminderJob } from './jobs/reminder.job'; import { permissionService } from './services/permission.service'; const startServer = async (): Promise => { @@ -28,6 +29,9 @@ const startServer = async (): Promise => { // Start the notification generation job startNotificationJob(); + // Start the order reminder job + startReminderJob(); + // Start the server const server = app.listen(environment.port, () => { logger.info('Server started successfully', { @@ -51,6 +55,7 @@ const startServer = async (): Promise => { // Stop scheduled jobs first stopAuditCleanupJob(); stopNotificationJob(); + stopReminderJob(); server.close(async () => { logger.info('HTTP server closed'); diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts new file mode 100644 index 0000000..aa8faed --- /dev/null +++ b/backend/src/services/bestellung.service.ts @@ -0,0 +1,607 @@ +// ============================================================================= +// Bestellung (Order) Service +// ============================================================================= + +import pool from '../config/database'; +import logger from '../utils/logger'; +import fs from 'fs'; + +// --------------------------------------------------------------------------- +// Vendors (Lieferanten) +// --------------------------------------------------------------------------- + +async function getVendors() { + try { + const result = await pool.query( + `SELECT * FROM lieferanten ORDER BY name` + ); + return result.rows; + } catch (error) { + logger.error('BestellungService.getVendors failed', { error }); + throw new Error('Lieferanten konnten nicht geladen werden'); + } +} + +async function getVendorById(id: number) { + try { + const result = await pool.query( + `SELECT * FROM lieferanten WHERE id = $1`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BestellungService.getVendorById failed', { error, id }); + throw new Error('Lieferant konnte nicht geladen werden'); + } +} + +async function createVendor(data: { name: string; kontakt_person?: string; email?: string; telefon?: string; adresse?: string; notizen?: string }, userId: string) { + try { + const result = await pool.query( + `INSERT INTO lieferanten (name, kontakt_person, email, telefon, adresse, notizen, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [data.name, data.kontakt_person || null, data.email || null, data.telefon || null, data.adresse || null, data.notizen || null, userId] + ); + return result.rows[0]; + } catch (error) { + logger.error('BestellungService.createVendor failed', { error }); + throw new Error('Lieferant konnte nicht erstellt werden'); + } +} + +async function updateVendor(id: number, data: { name?: string; kontakt_person?: string; email?: string; telefon?: string; adresse?: string; notizen?: string }, userId: string) { + try { + const result = await pool.query( + `UPDATE lieferanten + SET name = COALESCE($1, name), + kontakt_person = COALESCE($2, kontakt_person), + email = COALESCE($3, email), + telefon = COALESCE($4, telefon), + adresse = COALESCE($5, adresse), + notizen = COALESCE($6, notizen), + aktualisiert_am = NOW() + WHERE id = $7 + RETURNING *`, + [data.name, data.kontakt_person, data.email, data.telefon, data.adresse, data.notizen, id] + ); + if (result.rows.length === 0) return null; + + await logAction(0, 'Lieferant aktualisiert', `Lieferant "${result.rows[0].name}" bearbeitet`, userId); + return result.rows[0]; + } catch (error) { + logger.error('BestellungService.updateVendor failed', { error, id }); + throw new Error('Lieferant konnte nicht aktualisiert werden'); + } +} + +async function deleteVendor(id: number) { + try { + const result = await pool.query( + `DELETE FROM lieferanten WHERE id = $1 RETURNING id`, + [id] + ); + return (result.rowCount ?? 0) > 0; + } catch (error) { + logger.error('BestellungService.deleteVendor failed', { error, id }); + throw new Error('Lieferant konnte nicht gelöscht werden'); + } +} + +// --------------------------------------------------------------------------- +// Orders (Bestellungen) +// --------------------------------------------------------------------------- + +async function getOrders(filters?: { status?: string; lieferant_id?: number; besteller_id?: string }) { + try { + const conditions: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (filters?.status) { + conditions.push(`b.status = $${paramIndex++}`); + params.push(filters.status); + } + if (filters?.lieferant_id) { + conditions.push(`b.lieferant_id = $${paramIndex++}`); + params.push(filters.lieferant_id); + } + if (filters?.besteller_id) { + conditions.push(`b.erstellt_von = $${paramIndex++}`); + params.push(filters.besteller_id); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await pool.query( + `SELECT b.*, + l.name AS lieferant_name, + u.display_name AS besteller_name, + COALESCE(pos.total_cost, 0) AS total_cost, + COALESCE(pos.items_count, 0) AS items_count + FROM bestellungen b + LEFT JOIN lieferanten l ON l.id = b.lieferant_id + LEFT JOIN users u ON u.id = b.erstellt_von + LEFT JOIN LATERAL ( + SELECT SUM(einzelpreis * menge) AS total_cost, + COUNT(*) AS items_count + FROM bestellpositionen + WHERE bestellung_id = b.id + ) pos ON true + ${whereClause} + ORDER BY b.erstellt_am DESC`, + params + ); + return result.rows; + } catch (error) { + logger.error('BestellungService.getOrders failed', { error }); + throw new Error('Bestellungen konnten nicht geladen werden'); + } +} + +async function getOrderById(id: number) { + try { + const orderResult = await pool.query( + `SELECT b.*, + l.name AS lieferant_name, + u.display_name AS besteller_name + FROM bestellungen b + LEFT JOIN lieferanten l ON l.id = b.lieferant_id + LEFT JOIN users u ON u.id = b.erstellt_von + WHERE b.id = $1`, + [id] + ); + if (orderResult.rows.length === 0) return null; + + const [positionen, dateien, erinnerungen, historie] = await Promise.all([ + pool.query(`SELECT * FROM bestellpositionen WHERE bestellung_id = $1 ORDER BY id`, [id]), + pool.query(`SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, [id]), + pool.query(`SELECT * FROM bestellung_erinnerungen WHERE bestellung_id = $1 ORDER BY faellig_am`, [id]), + pool.query(`SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.benutzer_id WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]), + ]); + + return { + ...orderResult.rows[0], + positionen: positionen.rows, + dateien: dateien.rows, + erinnerungen: erinnerungen.rows, + historie: historie.rows, + }; + } catch (error) { + logger.error('BestellungService.getOrderById failed', { error, id }); + throw new Error('Bestellung konnte nicht geladen werden'); + } +} + +async function createOrder(data: { titel: string; lieferant_id?: number; beschreibung?: string; prioritaet?: string }, userId: string) { + try { + const result = await pool.query( + `INSERT INTO bestellungen (titel, lieferant_id, beschreibung, prioritaet, erstellt_von) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [data.titel, data.lieferant_id || null, data.beschreibung || null, data.prioritaet || 'normal', userId] + ); + const order = result.rows[0]; + await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.titel}" erstellt`, userId); + return order; + } catch (error) { + logger.error('BestellungService.createOrder failed', { error }); + throw new Error('Bestellung konnte nicht erstellt werden'); + } +} + +async function updateOrder(id: number, data: { titel?: string; lieferant_id?: number; beschreibung?: string; prioritaet?: string; status?: string }, userId: string) { + try { + // Check current order for status change detection + const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]); + if (current.rows.length === 0) return null; + + const oldStatus = current.rows[0].status; + const newStatus = data.status || oldStatus; + + let bestellt_am = current.rows[0].bestellt_am; + let abgeschlossen_am = current.rows[0].abgeschlossen_am; + + if (newStatus !== oldStatus) { + if (newStatus === 'bestellt' && !bestellt_am) { + bestellt_am = new Date(); + } + if (newStatus === 'abgeschlossen' && !abgeschlossen_am) { + abgeschlossen_am = new Date(); + } + } + + const result = await pool.query( + `UPDATE bestellungen + SET titel = COALESCE($1, titel), + lieferant_id = COALESCE($2, lieferant_id), + beschreibung = COALESCE($3, beschreibung), + prioritaet = COALESCE($4, prioritaet), + status = COALESCE($5, status), + bestellt_am = $6, + abgeschlossen_am = $7, + aktualisiert_am = NOW() + WHERE id = $8 + RETURNING *`, + [data.titel, data.lieferant_id, data.beschreibung, data.prioritaet, data.status, bestellt_am, abgeschlossen_am, id] + ); + if (result.rows.length === 0) return null; + + const changes: string[] = []; + if (data.titel) changes.push(`Titel geändert`); + if (data.lieferant_id) changes.push(`Lieferant geändert`); + if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus} → ${data.status}`); + if (data.prioritaet) changes.push(`Priorität geändert`); + + await logAction(id, 'Bestellung aktualisiert', changes.join(', ') || 'Bestellung bearbeitet', userId); + return result.rows[0]; + } catch (error) { + logger.error('BestellungService.updateOrder failed', { error, id }); + throw new Error('Bestellung konnte nicht aktualisiert werden'); + } +} + +async function deleteOrder(id: number, _userId: string) { + try { + // Get file paths before deleting + const filesResult = await pool.query( + `SELECT dateipfad FROM bestellung_dateien WHERE bestellung_id = $1`, + [id] + ); + const filePaths = filesResult.rows.map((r: { dateipfad: string }) => r.dateipfad); + + const result = await pool.query( + `DELETE FROM bestellungen WHERE id = $1 RETURNING id`, + [id] + ); + if ((result.rowCount ?? 0) === 0) return false; + + // Remove files from disk + for (const filePath of filePaths) { + try { + if (filePath && fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (err) { + logger.warn('Failed to delete file from disk', { filePath, error: err }); + } + } + + return true; + } catch (error) { + logger.error('BestellungService.deleteOrder failed', { error, id }); + throw new Error('Bestellung konnte nicht gelöscht werden'); + } +} + +const VALID_STATUS_TRANSITIONS: Record = { + entwurf: ['bestellt', 'storniert'], + bestellt: ['teillieferung', 'vollstaendig', 'storniert'], + teillieferung: ['vollstaendig', 'storniert'], + vollstaendig: ['abgeschlossen'], + abgeschlossen: [], + storniert: ['entwurf'], +}; + +async function updateOrderStatus(id: number, status: string, userId: string) { + try { + const current = await pool.query(`SELECT status FROM bestellungen WHERE id = $1`, [id]); + if (current.rows.length === 0) return null; + + const oldStatus = current.rows[0].status; + const allowed = VALID_STATUS_TRANSITIONS[oldStatus] || []; + if (!allowed.includes(status)) { + throw new Error(`Ungültiger Statusübergang: ${oldStatus} → ${status}`); + } + + const updates: string[] = ['status = $1', 'aktualisiert_am = NOW()']; + const params: unknown[] = [status]; + let paramIndex = 2; + + if (status === 'bestellt') { + updates.push(`bestellt_am = COALESCE(bestellt_am, NOW())`); + } + if (status === 'abgeschlossen') { + updates.push(`abgeschlossen_am = COALESCE(abgeschlossen_am, NOW())`); + } + + params.push(id); + const result = await pool.query( + `UPDATE bestellungen SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, + params + ); + + await logAction(id, 'Status geändert', `${oldStatus} → ${status}`, userId); + return result.rows[0]; + } catch (error) { + logger.error('BestellungService.updateOrderStatus failed', { error, id }); + throw error; + } +} + +// --------------------------------------------------------------------------- +// Line Items (Bestellpositionen) +// --------------------------------------------------------------------------- + +async function addLineItem(bestellungId: number, data: { artikel: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) { + try { + const result = await pool.query( + `INSERT INTO bestellpositionen (bestellung_id, artikel, menge, einheit, einzelpreis, notizen) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [bestellungId, data.artikel, data.menge, data.einheit || 'Stück', data.einzelpreis || 0, data.notizen || null] + ); + await logAction(bestellungId, 'Position hinzugefügt', `"${data.artikel}" x${data.menge}`, userId); + return result.rows[0]; + } catch (error) { + logger.error('BestellungService.addLineItem failed', { error, bestellungId }); + throw new Error('Position konnte nicht hinzugefügt werden'); + } +} + +async function updateLineItem(id: number, data: { artikel?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) { + try { + const result = await pool.query( + `UPDATE bestellpositionen + SET artikel = COALESCE($1, artikel), + menge = COALESCE($2, menge), + einheit = COALESCE($3, einheit), + einzelpreis = COALESCE($4, einzelpreis), + notizen = COALESCE($5, notizen) + WHERE id = $6 + RETURNING *`, + [data.artikel, data.menge, data.einheit, data.einzelpreis, data.notizen, id] + ); + if (result.rows.length === 0) return null; + + const item = result.rows[0]; + await logAction(item.bestellung_id, 'Position aktualisiert', `"${item.artikel}" bearbeitet`, userId); + return item; + } catch (error) { + logger.error('BestellungService.updateLineItem failed', { error, id }); + throw new Error('Position konnte nicht aktualisiert werden'); + } +} + +async function deleteLineItem(id: number, userId: string) { + try { + const item = await pool.query(`SELECT * FROM bestellpositionen WHERE id = $1`, [id]); + if (item.rows.length === 0) return false; + + await pool.query(`DELETE FROM bestellpositionen WHERE id = $1`, [id]); + await logAction(item.rows[0].bestellung_id, 'Position entfernt', `"${item.rows[0].artikel}" entfernt`, userId); + return true; + } catch (error) { + logger.error('BestellungService.deleteLineItem failed', { error, id }); + throw new Error('Position konnte nicht gelöscht werden'); + } +} + +async function updateReceivedQuantity(id: number, menge: number, userId: string) { + try { + const result = await pool.query( + `UPDATE bestellpositionen SET erhalten_menge = $1 WHERE id = $2 RETURNING *`, + [menge, id] + ); + if (result.rows.length === 0) return null; + + const item = result.rows[0]; + await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.artikel}": ${menge} von ${item.menge} erhalten`, userId); + + // Check if all items for this order are fully received + const allItems = await pool.query( + `SELECT menge, erhalten_menge FROM bestellpositionen WHERE bestellung_id = $1`, + [item.bestellung_id] + ); + const allReceived = allItems.rows.every((r: { menge: number; erhalten_menge: number }) => r.erhalten_menge >= r.menge); + const someReceived = allItems.rows.some((r: { menge: number; erhalten_menge: number }) => (r.erhalten_menge ?? 0) > 0); + + // Auto-update order status if currently 'bestellt' + const order = await pool.query(`SELECT status FROM bestellungen WHERE id = $1`, [item.bestellung_id]); + if (order.rows.length > 0 && (order.rows[0].status === 'bestellt' || order.rows[0].status === 'teillieferung')) { + if (allReceived) { + await pool.query( + `UPDATE bestellungen SET status = 'vollstaendig', aktualisiert_am = NOW() WHERE id = $1`, + [item.bestellung_id] + ); + await logAction(item.bestellung_id, 'Status geändert', 'Alle Positionen vollständig erhalten → vollstaendig', userId); + } else if (someReceived && order.rows[0].status === 'bestellt') { + await pool.query( + `UPDATE bestellungen SET status = 'teillieferung', aktualisiert_am = NOW() WHERE id = $1`, + [item.bestellung_id] + ); + await logAction(item.bestellung_id, 'Status geändert', 'Teillieferung eingegangen → teillieferung', userId); + } + } + + return item; + } catch (error) { + logger.error('BestellungService.updateReceivedQuantity failed', { error, id }); + throw new Error('Liefermenge konnte nicht aktualisiert werden'); + } +} + +// --------------------------------------------------------------------------- +// Files (Bestellung Dateien) +// --------------------------------------------------------------------------- + +async function addFile(bestellungId: number, fileData: { dateiname: string; dateipfad: string; dateityp: string; dateigroesse: number }, userId: string) { + try { + const result = await pool.query( + `INSERT INTO bestellung_dateien (bestellung_id, dateiname, dateipfad, dateityp, dateigroesse, hochgeladen_von) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [bestellungId, fileData.dateiname, fileData.dateipfad, fileData.dateityp, fileData.dateigroesse, userId] + ); + await logAction(bestellungId, 'Datei hochgeladen', `"${fileData.dateiname}"`, userId); + return result.rows[0]; + } catch (error) { + logger.error('BestellungService.addFile failed', { error, bestellungId }); + throw new Error('Datei konnte nicht gespeichert werden'); + } +} + +async function deleteFile(id: number, userId: string) { + try { + const fileResult = await pool.query( + `SELECT * FROM bestellung_dateien WHERE id = $1`, + [id] + ); + if (fileResult.rows.length === 0) return null; + + const file = fileResult.rows[0]; + await pool.query(`DELETE FROM bestellung_dateien WHERE id = $1`, [id]); + await logAction(file.bestellung_id, 'Datei gelöscht', `"${file.dateiname}"`, userId); + + return { dateipfad: file.dateipfad, dateiname: file.dateiname }; + } catch (error) { + logger.error('BestellungService.deleteFile failed', { error, id }); + throw new Error('Datei konnte nicht gelöscht werden'); + } +} + +async function getFilesByOrder(bestellungId: number) { + try { + const result = await pool.query( + `SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, + [bestellungId] + ); + return result.rows; + } catch (error) { + logger.error('BestellungService.getFilesByOrder failed', { error, bestellungId }); + throw new Error('Dateien konnten nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Reminders (Bestellung Erinnerungen) +// --------------------------------------------------------------------------- + +async function addReminder(bestellungId: number, data: { titel: string; faellig_am: string; notizen?: string }, userId: string) { + try { + const result = await pool.query( + `INSERT INTO bestellung_erinnerungen (bestellung_id, titel, faellig_am, notizen, erstellt_von) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [bestellungId, data.titel, data.faellig_am, data.notizen || null, userId] + ); + await logAction(bestellungId, 'Erinnerung erstellt', `"${data.titel}" fällig am ${data.faellig_am}`, userId); + return result.rows[0]; + } catch (error) { + logger.error('BestellungService.addReminder failed', { error, bestellungId }); + throw new Error('Erinnerung konnte nicht erstellt werden'); + } +} + +async function markReminderDone(id: number, userId: string) { + try { + const result = await pool.query( + `UPDATE bestellung_erinnerungen SET erledigt = TRUE WHERE id = $1 RETURNING *`, + [id] + ); + if (result.rows.length === 0) return null; + + const reminder = result.rows[0]; + await logAction(reminder.bestellung_id, 'Erinnerung erledigt', `"${reminder.titel}"`, userId); + return reminder; + } catch (error) { + logger.error('BestellungService.markReminderDone failed', { error, id }); + throw new Error('Erinnerung konnte nicht als erledigt markiert werden'); + } +} + +async function deleteReminder(id: number) { + try { + const result = await pool.query( + `DELETE FROM bestellung_erinnerungen WHERE id = $1 RETURNING id`, + [id] + ); + return (result.rowCount ?? 0) > 0; + } catch (error) { + logger.error('BestellungService.deleteReminder failed', { error, id }); + throw new Error('Erinnerung konnte nicht gelöscht werden'); + } +} + +async function getDueReminders() { + try { + const result = await pool.query( + `SELECT e.*, b.titel AS bestellung_titel, b.erstellt_von AS besteller_id + FROM bestellung_erinnerungen e + JOIN bestellungen b ON b.id = e.bestellung_id + WHERE e.faellig_am <= NOW() AND e.erledigt = FALSE + ORDER BY e.faellig_am` + ); + return result.rows; + } catch (error) { + logger.error('BestellungService.getDueReminders failed', { error }); + throw new Error('Fällige Erinnerungen konnten nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Audit History (Bestellung Historie) +// --------------------------------------------------------------------------- + +async function logAction(bestellungId: number, aktion: string, details: string, userId: string) { + try { + await pool.query( + `INSERT INTO bestellung_historie (bestellung_id, benutzer_id, aktion, details) + VALUES ($1, $2, $3, $4)`, + [bestellungId, userId, aktion, details] + ); + } catch (error) { + logger.error('BestellungService.logAction failed', { error, bestellungId, aktion }); + // Non-fatal — don't propagate + } +} + +async function getHistory(bestellungId: number) { + try { + const result = await pool.query( + `SELECT h.*, u.display_name AS benutzer_name + FROM bestellung_historie h + LEFT JOIN users u ON u.id = h.benutzer_id + WHERE h.bestellung_id = $1 + ORDER BY h.erstellt_am DESC`, + [bestellungId] + ); + return result.rows; + } catch (error) { + logger.error('BestellungService.getHistory failed', { error, bestellungId }); + throw new Error('Historie konnte nicht geladen werden'); + } +} + +export default { + // Vendors + getVendors, + getVendorById, + createVendor, + updateVendor, + deleteVendor, + // Orders + getOrders, + getOrderById, + createOrder, + updateOrder, + deleteOrder, + updateOrderStatus, + // Line Items + addLineItem, + updateLineItem, + deleteLineItem, + updateReceivedQuantity, + // Files + addFile, + deleteFile, + getFilesByOrder, + // Reminders + addReminder, + markReminderDone, + deleteReminder, + getDueReminders, + // Audit + logAction, + getHistory, +}; diff --git a/backend/src/services/shop.service.ts b/backend/src/services/shop.service.ts new file mode 100644 index 0000000..8daa727 --- /dev/null +++ b/backend/src/services/shop.service.ts @@ -0,0 +1,323 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Catalog Items (shop_artikel) +// --------------------------------------------------------------------------- + +async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filters?.kategorie) { + params.push(filters.kategorie); + conditions.push(`kategorie = $${params.length}`); + } + if (filters?.aktiv !== undefined) { + params.push(filters.aktiv); + conditions.push(`aktiv = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await pool.query( + `SELECT * FROM shop_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]); + return result.rows[0] || null; +} + +async function createItem( + data: { + bezeichnung: string; + beschreibung?: string; + kategorie?: string; + geschaetzte_kosten?: number; + url?: string; + aktiv?: boolean; + }, + userId: string, +) { + const result = await pool.query( + `INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzte_kosten, url, aktiv, erstellt_von) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, true), $7) + RETURNING *`, + [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzte_kosten || null, data.url || null, data.aktiv ?? true, userId], + ); + return result.rows[0]; +} + +async function updateItem( + id: number, + data: { + bezeichnung?: string; + beschreibung?: string; + kategorie?: string; + geschaetzte_kosten?: number; + url?: string; + aktiv?: boolean; + }, + userId: string, +) { + const fields: string[] = []; + const params: unknown[] = []; + + if (data.bezeichnung !== undefined) { + params.push(data.bezeichnung); + fields.push(`bezeichnung = $${params.length}`); + } + if (data.beschreibung !== undefined) { + params.push(data.beschreibung); + fields.push(`beschreibung = $${params.length}`); + } + if (data.kategorie !== undefined) { + params.push(data.kategorie); + fields.push(`kategorie = $${params.length}`); + } + if (data.geschaetzte_kosten !== undefined) { + params.push(data.geschaetzte_kosten); + fields.push(`geschaetzte_kosten = $${params.length}`); + } + if (data.url !== undefined) { + params.push(data.url); + fields.push(`url = $${params.length}`); + } + if (data.aktiv !== undefined) { + params.push(data.aktiv); + fields.push(`aktiv = $${params.length}`); + } + + if (fields.length === 0) { + return getItemById(id); + } + + params.push(userId); + fields.push(`aktualisiert_von = $${params.length}`); + params.push(new Date()); + fields.push(`aktualisiert_am = $${params.length}`); + + params.push(id); + const result = await pool.query( + `UPDATE shop_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]); +} + +async function getCategories() { + const result = await pool.query( + 'SELECT DISTINCT kategorie FROM shop_artikel WHERE kategorie IS NOT NULL ORDER BY kategorie', + ); + return result.rows.map((r: { kategorie: string }) => r.kategorie); +} + +// --------------------------------------------------------------------------- +// Requests (shop_anfragen) +// --------------------------------------------------------------------------- + +async function getRequests(filters?: { status?: string; anfrager_id?: string }) { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filters?.status) { + params.push(filters.status); + conditions.push(`a.status = $${params.length}`); + } + if (filters?.anfrager_id) { + params.push(filters.anfrager_id); + conditions.push(`a.anfrager_id = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await pool.query( + `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 + LEFT JOIN users u ON u.id = a.anfrager_id + LEFT JOIN users u2 ON u2.id = a.bearbeitet_von + ${where} + ORDER BY a.erstellt_am DESC`, + params, + ); + return result.rows; +} + +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 + WHERE a.anfrager_id = $1 + ORDER BY a.erstellt_am DESC`, + [userId], + ); + return result.rows; +} + +async function getRequestById(id: number) { + const reqResult = await pool.query( + `SELECT a.*, + u.vorname || ' ' || u.nachname AS anfrager_name, + u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name + FROM shop_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`, + [id], + ); + if (reqResult.rows.length === 0) return null; + + 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 + WHERE p.anfrage_id = $1 + ORDER BY p.id`, + [id], + ); + + const bestellungen = await pool.query( + `SELECT b.* + FROM shop_anfrage_bestellung ab + JOIN bestellungen b ON b.id = ab.bestellung_id + WHERE ab.anfrage_id = $1`, + [id], + ); + + return { + ...reqResult.rows[0], + positionen: positionen.rows, + bestellungen: bestellungen.rows, + }; +} + +async function createRequest( + userId: string, + items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[], + notizen?: string, +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const anfrageResult = await client.query( + `INSERT INTO shop_anfragen (anfrager_id, notizen) + VALUES ($1, $2) + RETURNING *`, + [userId, notizen || null], + ); + const anfrage = anfrageResult.rows[0]; + + for (const item of items) { + let bezeichnung = item.bezeichnung; + + // 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', + [item.artikel_id], + ); + if (artikelResult.rows.length > 0) { + bezeichnung = artikelResult.rows[0].bezeichnung; + } + } + + await client.query( + `INSERT INTO shop_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], + ); + } + + await client.query('COMMIT'); + return getRequestById(anfrage.id); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('shopService.createRequest failed', { error }); + throw error; + } finally { + client.release(); + } +} + +async function updateRequestStatus( + id: number, + status: string, + adminNotizen?: string, + bearbeitetVon?: string, +) { + const result = await pool.query( + `UPDATE shop_anfragen + SET status = $1, + admin_notizen = COALESCE($2, admin_notizen), + bearbeitet_von = COALESCE($3, bearbeitet_von), + bearbeitet_am = NOW() + WHERE id = $4 + RETURNING *`, + [status, adminNotizen || null, bearbeitetVon || null, id], + ); + return result.rows[0] || null; +} + +async function deleteRequest(id: number) { + await pool.query('DELETE FROM shop_anfragen WHERE id = $1', [id]); +} + +// --------------------------------------------------------------------------- +// Linking (shop_anfrage_bestellung) +// --------------------------------------------------------------------------- + +async function linkToOrder(anfrageId: number, bestellungId: number) { + await pool.query( + `INSERT INTO shop_anfrage_bestellung (anfrage_id, bestellung_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [anfrageId, bestellungId], + ); +} + +async function unlinkFromOrder(anfrageId: number, bestellungId: number) { + await pool.query( + 'DELETE FROM shop_anfrage_bestellung WHERE anfrage_id = $1 AND bestellung_id = $2', + [anfrageId, bestellungId], + ); +} + +async function getLinkedOrders(anfrageId: number) { + const result = await pool.query( + `SELECT b.* + FROM shop_anfrage_bestellung ab + JOIN bestellungen b ON b.id = ab.bestellung_id + WHERE ab.anfrage_id = $1`, + [anfrageId], + ); + return result.rows; +} + +export default { + getItems, + getItemById, + createItem, + updateItem, + deleteItem, + getCategories, + getRequests, + getMyRequests, + getRequestById, + createRequest, + updateRequestStatus, + deleteRequest, + linkToOrder, + unlinkFromOrder, + getLinkedOrders, +}; diff --git a/backend/src/services/upload.service.ts b/backend/src/services/upload.service.ts new file mode 100644 index 0000000..225e560 --- /dev/null +++ b/backend/src/services/upload.service.ts @@ -0,0 +1,44 @@ +import path from 'path'; +import fs from 'fs'; +import logger from '../utils/logger'; +import { THUMBNAIL_DIR } from '../middleware/upload'; + +let sharp: any = null; +try { + sharp = require('sharp'); +} catch { + logger.warn('sharp not installed — thumbnail generation disabled'); +} + +async function generateThumbnail(filePath: string, mimeType: string): Promise { + if (!sharp) return null; + if (!mimeType.startsWith('image/')) return null; + + try { + const ext = path.extname(filePath); + const baseName = path.basename(filePath, ext); + const thumbPath = path.join(THUMBNAIL_DIR, `${baseName}_thumb.webp`); + + await sharp(filePath) + .resize(200, 200, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 70 }) + .toFile(thumbPath); + + return thumbPath; + } catch (error) { + logger.error('Thumbnail generation failed', { filePath, error }); + return null; + } +} + +function deleteFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (error) { + logger.error('File deletion failed', { filePath, error }); + } +} + +export default { generateThumbnail, deleteFile }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ccbafd6..3dbb82f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,9 @@ import UebungDetail from './pages/UebungDetail'; import Veranstaltungen from './pages/Veranstaltungen'; 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 AdminDashboard from './pages/AdminDashboard'; import AdminSettings from './pages/AdminSettings'; import NotFound from './pages/NotFound'; @@ -216,6 +219,30 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> (''); + + const { data: orders, isLoading: ordersLoading } = useQuery({ + queryKey: ['admin-bestellungen', statusFilter], + queryFn: () => bestellungApi.getOrders(statusFilter ? { status: statusFilter } : undefined), + }); + + const { data: requests, isLoading: requestsLoading } = useQuery({ + queryKey: ['admin-shop-requests'], + queryFn: () => shopApi.getRequests({ status: 'offen' }), + }); + + const formatCurrency = (value?: number) => + value != null ? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value) : '–'; + + return ( + + {/* Pending Shop Requests */} + {(requests?.length ?? 0) > 0 && ( + + + Offene Shop-Anfragen ({requests?.length}) + + {requestsLoading ? ( + + ) : ( + + + + + # + Anfrager + Status + Erstellt am + + + + {(requests ?? []).map((req) => ( + navigate('/shop?tab=2')} + > + {req.id} + {req.anfrager_name || '–'} + + + + {new Date(req.erstellt_am).toLocaleDateString('de-AT')} + + ))} + +
+
+ )} +
+ )} + + {/* Orders Overview */} + + + Bestellungen + + Status + + + + {ordersLoading ? ( + + + + ) : ( + + + + + Bezeichnung + Lieferant + Status + Positionen + Gesamt + Erstellt am + + + + {(orders ?? []).length === 0 ? ( + + + Keine Bestellungen + + + ) : ( + (orders ?? []).map((order) => ( + navigate(`/bestellungen/${order.id}`)} + > + {order.bezeichnung} + {order.lieferant_name || '–'} + + + + {order.items_count ?? 0} + {formatCurrency(order.total_cost)} + {new Date(order.erstellt_am).toLocaleDateString('de-AT')} + + )) + )} + +
+
+ )} +
+
+ ); +} + +export default BestellungenTab; diff --git a/frontend/src/components/admin/NotificationBroadcastTab.tsx b/frontend/src/components/admin/NotificationBroadcastTab.tsx index 4dd2ab8..08a6be2 100644 --- a/frontend/src/components/admin/NotificationBroadcastTab.tsx +++ b/frontend/src/components/admin/NotificationBroadcastTab.tsx @@ -124,8 +124,6 @@ function NotificationBroadcastTab() { setTargetDienstgrad(typeof value === 'string' ? value.split(',') : value); }; - const filtersActive = !alleBenutzer && (targetGroup.trim() || targetDienstgrad.length > 0); - const filterDescription = (() => { if (alleBenutzer) return 'alle aktiven Benutzer'; const parts: string[] = []; diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index 797a30d..b023260 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -86,6 +86,38 @@ function buildReverseHierarchy(hierarchy: Record): Record> = { + kalender: { + 'Termine': ['view', 'create'], + 'Buchungen': ['view_bookings', 'manage_bookings'], + }, + bestellungen: { + 'Bestellungen': ['view', 'create', 'delete', 'export'], + 'Lieferanten': ['manage_vendors'], + 'Erinnerungen': ['manage_reminders'], + 'Widget': ['widget'], + }, + shop: { + 'Katalog': ['view', 'manage_catalog'], + 'Anfragen': ['create_request', 'approve_requests', 'link_orders'], + 'Widget': ['widget'], + }, +}; + +function getSubGroupLabel(featureGroupId: string, permId: string): string | null { + const subGroups = PERMISSION_SUB_GROUPS[featureGroupId]; + if (!subGroups) return null; + const action = permId.includes(':') ? permId.split(':')[1] : permId; + for (const [label, actions] of Object.entries(subGroups)) { + if (actions.includes(action)) return label; + } + return null; +} + // ── Component ── function PermissionMatrixTab() { @@ -412,11 +444,29 @@ function PermissionMatrixTab() { - {fgPerms.map((perm: Permission) => { - const depTooltip = getDepTooltip(perm.id); - const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n'); - return ( - + {(() => { + let lastSubGroup: string | null | undefined = undefined; + return fgPerms.map((perm: Permission) => { + const depTooltip = getDepTooltip(perm.id); + const tooltipText = [perm.description, depTooltip].filter(Boolean).join('\n'); + const subGroup = getSubGroupLabel(fg.id, perm.id); + const showSubGroupHeader = subGroup !== lastSubGroup && subGroup !== null; + lastSubGroup = subGroup; + return ( + + {showSubGroupHeader && ( + + + + {subGroup} + + + + )} + {perm.label} @@ -441,8 +491,10 @@ function PermissionMatrixTab() { ); })} - ); - })} + + ); + }); + })()}
diff --git a/frontend/src/components/chat/FileMessageContent.tsx b/frontend/src/components/chat/FileMessageContent.tsx index 33df955..a34ad33 100644 --- a/frontend/src/components/chat/FileMessageContent.tsx +++ b/frontend/src/components/chat/FileMessageContent.tsx @@ -60,7 +60,7 @@ const ContentOverlay: React.FC = ({ open, onClose, mode, co onClose={onClose} maxWidth="lg" fullWidth - slotProps={{ paper: { sx: { bgcolor: mode === 'image' ? 'black' : 'background.paper', m: 1 } } }} + PaperProps={{ sx: { bgcolor: mode === 'image' ? 'black' : 'background.paper', m: 1 } }} > {mode === 'image' && ( diff --git a/frontend/src/components/dashboard/BestellungenWidget.tsx b/frontend/src/components/dashboard/BestellungenWidget.tsx new file mode 100644 index 0000000..684eb9b --- /dev/null +++ b/frontend/src/components/dashboard/BestellungenWidget.tsx @@ -0,0 +1,109 @@ +import { Card, CardContent, Typography, Box, Chip, List, ListItem, ListItemText, Divider, Skeleton } from '@mui/material'; +import { LocalShipping } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { bestellungApi } from '../../services/bestellung'; +import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types'; +import type { BestellungStatus } from '../../types/bestellung.types'; + +function BestellungenWidget() { + const navigate = useNavigate(); + + const { data: orders, isLoading, isError } = useQuery({ + queryKey: ['bestellungen-widget'], + queryFn: () => bestellungApi.getOrders(), + refetchInterval: 5 * 60 * 1000, + retry: 1, + }); + + const openOrders = (orders ?? []).filter( + (o) => !['abgeschlossen'].includes(o.status) + ); + + if (isLoading) { + return ( + + + Bestellungen + + + + + ); + } + + if (isError) { + return ( + + + Bestellungen + + Bestellungen konnten nicht geladen werden. + + + + ); + } + + if (openOrders.length === 0) { + return ( + + + Bestellungen + + + Keine offenen Bestellungen + + + + ); + } + + return ( + + + + Bestellungen + + + + {openOrders.slice(0, 5).map((order, idx) => ( + + {idx > 0 && } + navigate(`/bestellungen/${order.id}`)} + > + + + + + ))} + + {openOrders.length > 5 && ( + navigate('/bestellungen')} + > + Alle {openOrders.length} Bestellungen anzeigen + + )} + + + ); +} + +export default BestellungenWidget; diff --git a/frontend/src/components/dashboard/ShopWidget.tsx b/frontend/src/components/dashboard/ShopWidget.tsx new file mode 100644 index 0000000..12c417b --- /dev/null +++ b/frontend/src/components/dashboard/ShopWidget.tsx @@ -0,0 +1,106 @@ +import { Card, CardContent, Typography, Box, Chip, List, ListItem, ListItemText, Divider, Skeleton } from '@mui/material'; +import { Store } 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'; + +function ShopWidget() { + const navigate = useNavigate(); + + const { data: requests, isLoading, isError } = useQuery({ + queryKey: ['shop-widget-requests'], + queryFn: () => shopApi.getRequests({ status: 'offen' }), + refetchInterval: 5 * 60 * 1000, + retry: 1, + }); + + if (isLoading) { + return ( + + + Shop-Anfragen + + + + ); + } + + if (isError) { + return ( + + + Shop-Anfragen + + Anfragen konnten nicht geladen werden. + + + + ); + } + + const pendingCount = requests?.length ?? 0; + + if (pendingCount === 0) { + return ( + + + Shop-Anfragen + + + Keine offenen Anfragen + + + + ); + } + + return ( + + + + Shop-Anfragen + + + + {(requests ?? []).slice(0, 5).map((req, idx) => ( + + {idx > 0 && } + navigate('/shop?tab=2')} + > + + + + + ))} + + {pendingCount > 5 && ( + navigate('/shop?tab=2')} + > + Alle {pendingCount} Anfragen anzeigen + + )} + + + ); +} + +export default ShopWidget; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 929e468..6794cfe 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -18,3 +18,5 @@ export { default as AnnouncementBanner } from './AnnouncementBanner'; 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'; diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index bac3c67..0357701 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -25,6 +25,8 @@ import { Menu as MenuIcon, ExpandMore, ExpandLess, + LocalShipping, + Store, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; @@ -61,6 +63,7 @@ const adminSubItems: SubItem[] = [ { text: 'Wartung', path: '/admin?tab=5' }, { text: 'FDISK Sync', path: '/admin?tab=6' }, { text: 'Berechtigungen', path: '/admin?tab=7' }, + { text: 'Bestellungen', path: '/admin?tab=8' }, ]; const baseNavigationItems: NavigationItem[] = [ @@ -106,6 +109,22 @@ const baseNavigationItems: NavigationItem[] = [ path: '/wissen', permission: 'wissen:view', }, + { + text: 'Bestellungen', + icon: , + path: '/bestellungen', + subItems: [ + { text: 'Übersicht', path: '/bestellungen?tab=0' }, + { text: 'Lieferanten', path: '/bestellungen?tab=1' }, + ], + permission: 'bestellungen:view', + }, + { + text: 'Shop', + icon: , + path: '/shop', + permission: 'shop:view', + }, ]; const adminItem: NavigationItem = { diff --git a/frontend/src/constants/widgets.ts b/frontend/src/constants/widgets.ts index c64d3bd..94fd3b6 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -13,6 +13,8 @@ export const WIDGETS = [ { key: 'eventQuickAdd', label: 'Termin erstellen', defaultVisible: true }, { 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 }, ] as const; export type WidgetKey = typeof WIDGETS[number]['key']; diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 2a5c370..cafe43a 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -10,6 +10,7 @@ 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 { usePermissionContext } from '../contexts/PermissionContext'; interface TabPanelProps { @@ -23,7 +24,7 @@ function TabPanel({ children, value, index }: TabPanelProps) { return {children}; } -const ADMIN_TAB_COUNT = 8; +const ADMIN_TAB_COUNT = 9; function AdminDashboard() { const navigate = useNavigate(); @@ -57,6 +58,7 @@ function AdminDashboard() { + @@ -84,6 +86,9 @@ function AdminDashboard() { + + + ); } diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx index a43bc06..91d5a34 100644 --- a/frontend/src/pages/Atemschutz.tsx +++ b/frontend/src/pages/Atemschutz.tsx @@ -14,7 +14,6 @@ import { DialogContent, DialogContentText, DialogTitle, - Fab, FormControl, FormControlLabel, Grid, @@ -318,7 +317,7 @@ function Atemschutz() { leistungstest_datum: normalizeDate(form.leistungstest_datum || undefined), leistungstest_gueltig_bis: normalizeDate(form.leistungstest_gueltig_bis || undefined), leistungstest_bestanden: form.leistungstest_bestanden, - bemerkung: form.bemerkung || null, + bemerkung: form.bemerkung || undefined, }; await atemschutzApi.update(editingId, payload); notification.showSuccess('Atemschutzträger erfolgreich aktualisiert.'); diff --git a/frontend/src/pages/Ausruestung.tsx b/frontend/src/pages/Ausruestung.tsx index 951f274..649dab5 100644 --- a/frontend/src/pages/Ausruestung.tsx +++ b/frontend/src/pages/Ausruestung.tsx @@ -9,7 +9,6 @@ import { Chip, CircularProgress, Container, - Fab, FormControl, FormControlLabel, Grid, diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx new file mode 100644 index 0000000..561e59f --- /dev/null +++ b/frontend/src/pages/BestellungDetail.tsx @@ -0,0 +1,686 @@ +import { useState, useRef } from 'react'; +import { + Box, + Typography, + Paper, + Button, + Chip, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Grid, + Card, + CardContent, + LinearProgress, + Checkbox, +} from '@mui/material'; +import { + ArrowBack, + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + Check as CheckIcon, + Close as CloseIcon, + AttachFile, + Alarm, + History, + Upload as UploadIcon, +} from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { bestellungApi } from '../services/bestellung'; +import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; +import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData, Bestellposition } from '../types/bestellung.types'; + +// ── Helpers ── + +const formatCurrency = (value?: number) => + value != null ? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value) : '–'; + +const formatDate = (iso?: string) => + iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '–'; + +const formatDateTime = (iso?: string) => + iso ? new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '–'; + +const formatFileSize = (bytes?: number) => { + if (!bytes) return '–'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +// Status flow +const STATUS_FLOW: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen']; + +function getNextStatus(current: BestellungStatus): BestellungStatus | null { + const idx = STATUS_FLOW.indexOf(current); + return idx >= 0 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null; +} + +// Empty line item form +const emptyItem: BestellpositionFormData = { bezeichnung: '', artikelnummer: '', menge: 1, einheit: 'Stk', einzelpreis: undefined }; + +// ══════════════════════════════════════════════════════════════════════════════ +// Component +// ══════════════════════════════════════════════════════════════════════════════ + +export default function BestellungDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + const fileInputRef = useRef(null); + + const orderId = Number(id); + + // ── State ── + const [newItem, setNewItem] = useState({ ...emptyItem }); + const [editingItemId, setEditingItemId] = useState(null); + const [editingItemData, setEditingItemData] = useState>({}); + const [statusConfirmOpen, setStatusConfirmOpen] = useState(false); + const [deleteItemTarget, setDeleteItemTarget] = useState(null); + const [deleteFileTarget, setDeleteFileTarget] = useState(null); + + const [reminderForm, setReminderForm] = useState({ faellig_am: '', nachricht: '' }); + const [reminderFormOpen, setReminderFormOpen] = useState(false); + const [deleteReminderTarget, setDeleteReminderTarget] = useState(null); + + // ── Query ── + const { data, isLoading, isError } = useQuery({ + queryKey: ['bestellung', orderId], + queryFn: () => bestellungApi.getOrder(orderId), + enabled: !!orderId, + }); + + const bestellung = data?.bestellung; + const positionen = data?.positionen ?? []; + const dateien = data?.dateien ?? []; + const erinnerungen = data?.erinnerungen ?? []; + const historie = data?.historie ?? []; + + const canEdit = hasPermission('bestellungen:edit'); + const nextStatus = bestellung ? getNextStatus(bestellung.status) : null; + + // ── Mutations ── + + const updateStatus = useMutation({ + mutationFn: (status: string) => bestellungApi.updateStatus(orderId, status), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + showSuccess('Status aktualisiert'); + setStatusConfirmOpen(false); + }, + onError: () => showError('Fehler beim Aktualisieren des Status'), + }); + + const addItem = useMutation({ + mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + setNewItem({ ...emptyItem }); + showSuccess('Position hinzugefügt'); + }, + onError: () => showError('Fehler beim Hinzufügen der Position'), + }); + + const updateItem = useMutation({ + mutationFn: ({ itemId, data }: { itemId: number; data: Partial }) => + bestellungApi.updateLineItem(itemId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + setEditingItemId(null); + showSuccess('Position aktualisiert'); + }, + onError: () => showError('Fehler beim Aktualisieren der Position'), + }); + + const deleteItem = useMutation({ + mutationFn: (itemId: number) => bestellungApi.deleteLineItem(itemId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + setDeleteItemTarget(null); + showSuccess('Position gelöscht'); + }, + onError: () => showError('Fehler beim Löschen der Position'), + }); + + const updateReceived = useMutation({ + mutationFn: ({ itemId, menge }: { itemId: number; menge: number }) => + bestellungApi.updateReceivedQty(itemId, menge), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const uploadFile = useMutation({ + mutationFn: (file: File) => bestellungApi.uploadFile(orderId, file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + showSuccess('Datei hochgeladen'); + }, + onError: () => showError('Fehler beim Hochladen der Datei'), + }); + + const deleteFile = useMutation({ + mutationFn: (fileId: number) => bestellungApi.deleteFile(fileId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + setDeleteFileTarget(null); + showSuccess('Datei gelöscht'); + }, + onError: () => showError('Fehler beim Löschen der Datei'), + }); + + const addReminder = useMutation({ + mutationFn: (data: ErinnerungFormData) => bestellungApi.addReminder(orderId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + setReminderForm({ faellig_am: '', nachricht: '' }); + setReminderFormOpen(false); + showSuccess('Erinnerung erstellt'); + }, + onError: () => showError('Fehler beim Erstellen der Erinnerung'), + }); + + const markReminderDone = useMutation({ + mutationFn: (reminderId: number) => bestellungApi.markReminderDone(reminderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const deleteReminder = useMutation({ + mutationFn: (reminderId: number) => bestellungApi.deleteReminder(reminderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + setDeleteReminderTarget(null); + showSuccess('Erinnerung gelöscht'); + }, + onError: () => showError('Fehler beim Löschen'), + }); + + // ── Handlers ── + + function startEditItem(item: Bestellposition) { + setEditingItemId(item.id); + setEditingItemData({ + bezeichnung: item.bezeichnung, + artikelnummer: item.artikelnummer || '', + menge: item.menge, + einheit: item.einheit, + einzelpreis: item.einzelpreis, + }); + } + + function saveEditItem() { + if (editingItemId == null) return; + updateItem.mutate({ itemId: editingItemId, data: editingItemData }); + } + + function handleAddItem() { + if (!newItem.bezeichnung.trim()) return; + addItem.mutate(newItem); + } + + function handleFileSelect(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) uploadFile.mutate(file); + e.target.value = ''; + } + + // Compute totals + const totalCost = positionen.reduce((sum, p) => sum + (p.einzelpreis ?? 0) * p.menge, 0); + const totalReceived = positionen.length > 0 + ? positionen.reduce((sum, p) => sum + p.erhalten_menge, 0) + : 0; + const totalOrdered = positionen.reduce((sum, p) => sum + p.menge, 0); + const receivedPercent = totalOrdered > 0 ? Math.round((totalReceived / totalOrdered) * 100) : 0; + + // ── Loading / Error ── + + if (isLoading) { + return ( + + Laden... + + ); + } + + if (isError || !bestellung) { + return ( + + + Bestellung nicht gefunden. + + + + ); + } + + return ( + + {/* ── Header ── */} + + navigate('/bestellungen')}> + + + {bestellung.bezeichnung} + + + + {/* ── Info Cards ── */} + + + + Lieferant + {bestellung.lieferant_name || '–'} + + + + + Besteller + {bestellung.besteller_name || '–'} + + + + + Budget + {formatCurrency(bestellung.budget)} + + + + + Erstellt am + {formatDate(bestellung.erstellt_am)} + + + + + {/* ── Status Action ── */} + {canEdit && nextStatus && ( + + + + )} + + {/* ── Delivery Progress ── */} + {positionen.length > 0 && ( + + + Lieferfortschritt: {totalReceived} / {totalOrdered} ({receivedPercent}%) + + + + )} + + {/* ══════════════════════════════════════════════════════════════════════ */} + {/* Positionen */} + {/* ══════════════════════════════════════════════════════════════════════ */} + + Positionen + + + + + Bezeichnung + Artikelnr. + Menge + Einheit + Einzelpreis + Gesamt + Erhalten + {canEdit && Aktionen} + + + + {positionen.map((p) => + editingItemId === p.id ? ( + + + setEditingItemData((d) => ({ ...d, bezeichnung: e.target.value }))} /> + + + setEditingItemData((d) => ({ ...d, artikelnummer: e.target.value }))} /> + + + setEditingItemData((d) => ({ ...d, menge: Number(e.target.value) }))} /> + + + setEditingItemData((d) => ({ ...d, einheit: e.target.value }))} /> + + + setEditingItemData((d) => ({ ...d, einzelpreis: e.target.value ? Number(e.target.value) : undefined }))} /> + + {formatCurrency((editingItemData.einzelpreis ?? 0) * (editingItemData.menge ?? 0))} + {p.erhalten_menge} + + + setEditingItemId(null)}> + + + ) : ( + + {p.bezeichnung} + {p.artikelnummer || '–'} + {p.menge} + {p.einheit} + {formatCurrency(p.einzelpreis)} + {formatCurrency((p.einzelpreis ?? 0) * p.menge)} + + {canEdit ? ( + updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} + /> + ) : ( + p.erhalten_menge + )} + + {canEdit && ( + + startEditItem(p)}> + setDeleteItemTarget(p.id)}> + + )} + + ), + )} + + {/* ── Add Item Row ── */} + {canEdit && ( + + + setNewItem((f) => ({ ...f, bezeichnung: e.target.value }))} /> + + + setNewItem((f) => ({ ...f, artikelnummer: e.target.value }))} /> + + + setNewItem((f) => ({ ...f, menge: Number(e.target.value) }))} /> + + + setNewItem((f) => ({ ...f, einheit: e.target.value }))} /> + + + setNewItem((f) => ({ ...f, einzelpreis: e.target.value ? Number(e.target.value) : undefined }))} /> + + {formatCurrency((newItem.einzelpreis ?? 0) * newItem.menge)} + + + + + + + + )} + + {/* ── Totals Row ── */} + {positionen.length > 0 && ( + + Gesamtsumme + {formatCurrency(totalCost)} + + + )} + +
+
+
+ + {/* ══════════════════════════════════════════════════════════════════════ */} + {/* Dateien */} + {/* ══════════════════════════════════════════════════════════════════════ */} + + + + Dateien + {canEdit && ( + <> + + + + )} + + {dateien.length === 0 ? ( + Keine Dateien vorhanden + ) : ( + + {dateien.map((d) => ( + + + {d.thumbnail_pfad && ( + + )} + + {d.dateiname} + + {formatFileSize(d.dateigroesse)} · {formatDate(d.hochgeladen_am)} + + {canEdit && ( + + setDeleteFileTarget(d.id)}> + + + + )} + + + + ))} + + )} + + + {/* ══════════════════════════════════════════════════════════════════════ */} + {/* Erinnerungen */} + {/* ══════════════════════════════════════════════════════════════════════ */} + + + + Erinnerungen + {canEdit && ( + + )} + + {erinnerungen.length === 0 && !reminderFormOpen ? ( + Keine Erinnerungen + ) : ( + + {erinnerungen.map((r) => ( + + markReminderDone.mutate(r.id)} + size="small" + /> + + + {r.nachricht || 'Erinnerung'} + + + Fällig: {formatDate(r.faellig_am)} + + + {canEdit && ( + setDeleteReminderTarget(r.id)}> + + + )} + + ))} + + )} + + {/* Inline Add Reminder Form */} + {reminderFormOpen && ( + + setReminderForm((f) => ({ ...f, faellig_am: e.target.value }))} + /> + setReminderForm((f) => ({ ...f, nachricht: e.target.value }))} + /> + + + + )} + + + {/* ══════════════════════════════════════════════════════════════════════ */} + {/* Historie */} + {/* ══════════════════════════════════════════════════════════════════════ */} + + + + Historie + + {historie.length === 0 ? ( + Keine Einträge + ) : ( + + {historie.map((h) => ( + + + + {h.aktion} + + {h.erstellt_von_name || 'System'} · {formatDateTime(h.erstellt_am)} + + {h.details && ( + + {Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')} + + )} + + + ))} + + )} + + + {/* ── Notizen ── */} + {bestellung.notizen && ( + + Notizen + {bestellung.notizen} + + )} + + {/* ══════════════════════════════════════════════════════════════════════ */} + {/* Dialogs */} + {/* ══════════════════════════════════════════════════════════════════════ */} + + {/* Status Confirmation */} + setStatusConfirmOpen(false)}> + Status ändern + + + Status von {BESTELLUNG_STATUS_LABELS[bestellung.status]} auf{' '} + {nextStatus ? BESTELLUNG_STATUS_LABELS[nextStatus] : ''} ändern? + + + + + + + + + {/* Delete Item Confirmation */} + setDeleteItemTarget(null)}> + Position löschen + + Soll diese Position wirklich gelöscht werden? + + + + + + + + {/* Delete File Confirmation */} + setDeleteFileTarget(null)}> + Datei löschen + + Soll diese Datei wirklich gelöscht werden? + + + + + + + + {/* Delete Reminder Confirmation */} + setDeleteReminderTarget(null)}> + Erinnerung löschen + + Soll diese Erinnerung wirklich gelöscht werden? + + + + + + +
+ ); +} diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx new file mode 100644 index 0000000..8b4bd17 --- /dev/null +++ b/frontend/src/pages/Bestellungen.tsx @@ -0,0 +1,434 @@ +import { useState, useEffect } 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, + Tooltip, + Autocomplete, +} from '@mui/material'; +import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate, 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 { bestellungApi } from '../services/bestellung'; +import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; +import type { BestellungStatus, BestellungFormData, LieferantFormData, Lieferant } from '../types/bestellung.types'; + +// ── Helpers ── + +const formatCurrency = (value?: number) => + value != null ? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value) : '–'; + +const formatDate = (iso?: string) => + iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '–'; + +// ── Tab Panel ── + +interface TabPanelProps { children: React.ReactNode; index: number; value: number } +function TabPanel({ children, value, index }: TabPanelProps) { + if (value !== index) return null; + return {children}; +} + +const TAB_COUNT = 2; + +// ── Status options for filter ── + +const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen']; + +// ── Empty form data ── + +const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', budget: undefined, notizen: '' }; +const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' }; + +// ══════════════════════════════════════════════════════════════════════════════ +// Component +// ══════════════════════════════════════════════════════════════════════════════ + +export default function Bestellungen() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + + // Tab from URL + const [tab, setTab] = useState(() => { + const t = Number(searchParams.get('tab')); + return t >= 0 && t < TAB_COUNT ? t : 0; + }); + useEffect(() => { + const t = Number(searchParams.get('tab')); + if (t >= 0 && t < TAB_COUNT) setTab(t); + }, [searchParams]); + + // ── State ── + const [statusFilter, setStatusFilter] = useState(''); + const [orderDialogOpen, setOrderDialogOpen] = useState(false); + const [orderForm, setOrderForm] = useState({ ...emptyOrderForm }); + + const [vendorDialogOpen, setVendorDialogOpen] = useState(false); + const [vendorForm, setVendorForm] = useState({ ...emptyVendorForm }); + const [editingVendor, setEditingVendor] = useState(null); + + const [deleteVendorTarget, setDeleteVendorTarget] = useState(null); + + // ── Queries ── + const { data: orders = [], isLoading: ordersLoading } = useQuery({ + queryKey: ['bestellungen', statusFilter], + queryFn: () => bestellungApi.getOrders(statusFilter ? { status: statusFilter } : undefined), + }); + + const { data: vendors = [], isLoading: vendorsLoading } = useQuery({ + queryKey: ['lieferanten'], + queryFn: bestellungApi.getVendors, + }); + + // ── Mutations ── + const createOrder = useMutation({ + mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data), + onSuccess: (created) => { + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); + showSuccess('Bestellung erstellt'); + setOrderDialogOpen(false); + setOrderForm({ ...emptyOrderForm }); + navigate(`/bestellungen/${created.id}`); + }, + onError: () => showError('Fehler beim Erstellen der Bestellung'), + }); + + const createVendor = useMutation({ + mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); + showSuccess('Lieferant erstellt'); + closeVendorDialog(); + }, + onError: () => showError('Fehler beim Erstellen des Lieferanten'), + }); + + const updateVendor = useMutation({ + mutationFn: ({ id, data }: { id: number; data: LieferantFormData }) => bestellungApi.updateVendor(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); + showSuccess('Lieferant aktualisiert'); + closeVendorDialog(); + }, + onError: () => showError('Fehler beim Aktualisieren des Lieferanten'), + }); + + const deleteVendor = useMutation({ + mutationFn: (id: number) => bestellungApi.deleteVendor(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); + showSuccess('Lieferant gelöscht'); + setDeleteVendorTarget(null); + }, + onError: () => showError('Fehler beim Löschen des Lieferanten'), + }); + + // ── Dialog helpers ── + + function openEditVendor(v: Lieferant) { + setEditingVendor(v); + setVendorForm({ name: v.name, kontakt_name: v.kontakt_name || '', email: v.email || '', telefon: v.telefon || '', adresse: v.adresse || '', website: v.website || '', notizen: v.notizen || '' }); + setVendorDialogOpen(true); + } + + function closeVendorDialog() { + setVendorDialogOpen(false); + setEditingVendor(null); + setVendorForm({ ...emptyVendorForm }); + } + + function handleVendorSave() { + if (!vendorForm.name.trim()) return; + if (editingVendor) { + updateVendor.mutate({ id: editingVendor.id, data: vendorForm }); + } else { + createVendor.mutate(vendorForm); + } + } + + function handleOrderSave() { + if (!orderForm.bezeichnung.trim()) return; + createOrder.mutate(orderForm); + } + + // ── Render ── + + return ( + + Bestellungen + + + { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }}> + + + + + + {/* ── Tab 0: Orders ── */} + + + + Status Filter + + + + + + + + + Bezeichnung + Lieferant + Besteller + Status + Positionen + Gesamtpreis + Erstellt am + + + + {ordersLoading ? ( + Laden... + ) : orders.length === 0 ? ( + Keine Bestellungen vorhanden + ) : ( + orders.map((o) => ( + navigate(`/bestellungen/${o.id}`)} + > + {o.bezeichnung} + {o.lieferant_name || '–'} + {o.besteller_name || '–'} + + + + {o.items_count ?? 0} + {formatCurrency(o.total_cost)} + {formatDate(o.erstellt_am)} + + )) + )} + +
+
+ + {hasPermission('bestellungen:create') && ( + setOrderDialogOpen(true)} aria-label="Neue Bestellung"> + + + )} +
+ + {/* ── Tab 1: Vendors ── */} + + + {hasPermission('bestellungen:create') && ( + + )} + + + + + + + Name + Kontakt + E-Mail + Telefon + Website + Aktionen + + + + {vendorsLoading ? ( + Laden... + ) : vendors.length === 0 ? ( + Keine Lieferanten vorhanden + ) : ( + vendors.map((v) => ( + + {v.name} + {v.kontakt_name || '–'} + {v.email ? {v.email} : '–'} + {v.telefon || '–'} + + {v.website ? ( + {v.website} + ) : '–'} + + + + openEditVendor(v)}> + + + setDeleteVendorTarget(v)}> + + + + )) + )} + +
+
+
+ + {/* ── Create Order Dialog ── */} + setOrderDialogOpen(false)} maxWidth="sm" fullWidth> + Neue Bestellung + + setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))} + /> + o.name} + value={vendors.find((v) => v.id === orderForm.lieferant_id) || null} + onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))} + renderInput={(params) => } + /> + setOrderForm((f) => ({ ...f, besteller_id: e.target.value }))} + /> + setOrderForm((f) => ({ ...f, budget: e.target.value ? Number(e.target.value) : undefined }))} + inputProps={{ min: 0, step: 0.01 }} + /> + setOrderForm((f) => ({ ...f, notizen: e.target.value }))} + /> + + + + + + + + {/* ── Create/Edit Vendor Dialog ── */} + + {editingVendor ? 'Lieferant bearbeiten' : 'Neuer Lieferant'} + + setVendorForm((f) => ({ ...f, name: e.target.value }))} + /> + setVendorForm((f) => ({ ...f, kontakt_name: e.target.value }))} + /> + setVendorForm((f) => ({ ...f, email: e.target.value }))} + /> + setVendorForm((f) => ({ ...f, telefon: e.target.value }))} + /> + setVendorForm((f) => ({ ...f, adresse: e.target.value }))} + /> + setVendorForm((f) => ({ ...f, website: e.target.value }))} + /> + setVendorForm((f) => ({ ...f, notizen: e.target.value }))} + /> + + + + + + + + {/* ── Delete Vendor Confirm ── */} + setDeleteVendorTarget(null)}> + Lieferant löschen + + + Soll der Lieferant {deleteVendorTarget?.name} wirklich gelöscht werden? + + + + + + + +
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 06c1102..f05b402 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -28,6 +28,8 @@ import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget'; 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 { preferencesApi } from '../services/settings'; import { configApi } from '../services/config'; import { WidgetKey } from '../constants/widgets'; @@ -130,11 +132,27 @@ function Dashboard() { )} + + {hasPermission('bestellungen:widget') && widgetVisible('bestellungen') && ( + + + + + + )} + + {hasPermission('shop:widget') && widgetVisible('shopRequests') && ( + + + + + + )} {/* Kalender Group */} - {hasPermission('kalender:widget_events') && widgetVisible('events') && ( + {hasPermission('kalender:view') && widgetVisible('events') && ( @@ -142,7 +160,7 @@ function Dashboard() { )} - {hasPermission('kalender:widget_bookings') && widgetVisible('vehicleBookingList') && ( + {hasPermission('kalender:view_bookings') && widgetVisible('vehicleBookingList') && ( @@ -150,7 +168,7 @@ function Dashboard() { )} - {hasPermission('kalender:create_bookings') && widgetVisible('vehicleBooking') && ( + {hasPermission('kalender:manage_bookings') && widgetVisible('vehicleBooking') && ( @@ -158,7 +176,7 @@ function Dashboard() { )} - {hasPermission('kalender:widget_quick_add') && widgetVisible('eventQuickAdd') && ( + {hasPermission('kalender:create') && widgetVisible('eventQuickAdd') && ( diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index db367b9..1bb811f 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -22,7 +22,6 @@ import { MenuItem, FormControl, InputLabel, - Fab, CircularProgress, Alert, Popover, diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 62a8952..0e577cb 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -202,15 +202,6 @@ function formatDateLong(d: Date): string { return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`; } -function fromDatetimeLocal(value: string): string { - if (!value) return new Date().toISOString(); - const dtIso = fromGermanDateTime(value); - if (dtIso) return new Date(dtIso).toISOString(); - const dIso = fromGermanDate(value); - if (dIso) return new Date(dIso).toISOString(); - return new Date(value).toISOString(); -} - /** ISO string → YYYY-MM-DDTHH:MM (for type="datetime-local") */ function toDatetimeLocalValue(iso: string | null | undefined): string { if (!iso) return ''; diff --git a/frontend/src/pages/Shop.tsx b/frontend/src/pages/Shop.tsx new file mode 100644 index 0000000..08e1bcd --- /dev/null +++ b/frontend/src/pages/Shop.tsx @@ -0,0 +1,622 @@ +import { useState, useMemo } from 'react'; +import { + Box, Tab, Tabs, Typography, Card, CardContent, CardActions, Grid, Button, Chip, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, + Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, + Badge, MenuItem, Select, FormControl, InputLabel, Autocomplete, Collapse, + Divider, Tooltip, +} from '@mui/material'; +import { + Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ShoppingCart, + Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, + ExpandMore, ExpandLess, +} 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 { 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 { Bestellung } from '../types/bestellung.types'; + +const priceFormat = new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }); + +// ─── Catalog Tab ──────────────────────────────────────────────────────────── + +interface DraftItem { + artikel_id?: number; + bezeichnung: string; + menge: number; + notizen?: string; +} + +function KatalogTab() { + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + const queryClient = useQueryClient(); + + const canManage = hasPermission('shop:manage_catalog'); + const canCreate = hasPermission('shop:create_request'); + + const [filterKategorie, setFilterKategorie] = useState(''); + const [draft, setDraft] = useState([]); + const [customText, setCustomText] = useState(''); + 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 { data: items = [], isLoading } = useQuery({ + queryKey: ['shop', 'items', filterKategorie], + queryFn: () => shopApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined), + }); + + const { data: categories = [] } = useQuery({ + queryKey: ['shop', 'categories'], + queryFn: () => shopApi.getCategories(), + }); + + const createItemMut = useMutation({ + mutationFn: (data: ShopArtikelFormData) => shopApi.createItem(data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); 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); }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + const deleteItemMut = useMutation({ + mutationFn: (id: number) => shopApi.deleteItem(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shop'] }); showSuccess('Artikel gelöscht'); }, + onError: () => showError('Fehler beim Löschen'), + }); + const createRequestMut = useMutation({ + mutationFn: ({ items, notizen }: { items: ShopAnfrageFormItem[]; notizen?: string }) => shopApi.createRequest(items, notizen), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shop'] }); + showSuccess('Anfrage gesendet'); + setDraft([]); + setSubmitOpen(false); + setSubmitNotizen(''); + }, + onError: () => showError('Fehler beim Senden der Anfrage'), + }); + + const addToDraft = (item: ShopArtikel) => { + 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); + return [...prev, { artikel_id: item.id, bezeichnung: item.bezeichnung, menge: 1 }]; + }); + }; + + const addCustomToDraft = () => { + const text = customText.trim(); + if (!text) return; + setDraft(prev => [...prev, { bezeichnung: text, menge: 1 }]); + setCustomText(''); + }; + + const removeDraftItem = (idx: number) => setDraft(prev => prev.filter((_, i) => i !== idx)); + const updateDraftMenge = (idx: number, menge: number) => { + if (menge < 1) return; + setDraft(prev => prev.map((d, i) => i === idx ? { ...d, menge } : d)); + }; + + const openNewArtikel = () => { + setEditArtikel(null); + setArtikelForm({ bezeichnung: '' }); + setArtikelDialogOpen(true); + }; + const openEditArtikel = (a: ShopArtikel) => { + setEditArtikel(a); + setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie, geschaetzter_preis: a.geschaetzter_preis }); + setArtikelDialogOpen(true); + }; + const saveArtikel = () => { + if (!artikelForm.bezeichnung.trim()) return; + if (editArtikel) updateItemMut.mutate({ id: editArtikel.id, data: artikelForm }); + else createItemMut.mutate(artikelForm); + }; + + const handleSubmitRequest = () => { + if (draft.length === 0) return; + createRequestMut.mutate({ + items: draft.map(d => ({ artikel_id: d.artikel_id, bezeichnung: d.bezeichnung, menge: d.menge, notizen: d.notizen })), + notizen: submitNotizen || undefined, + }); + }; + + return ( + + {/* Filter */} + + + Kategorie + + + {canCreate && ( + + + + )} + + + {/* Catalog grid */} + {isLoading ? ( + Lade Katalog... + ) : items.length === 0 ? ( + Keine Artikel vorhanden. + ) : ( + + {items.map(item => ( + + + {item.bild_pfad ? ( + + ) : ( + + + + )} + + {item.bezeichnung} + {item.beschreibung && {item.beschreibung}} + + {item.kategorie && } + {item.geschaetzter_preis != null && ( + ca. {priceFormat.format(item.geschaetzter_preis)} + )} + + + + {canCreate && ( + + )} + {canManage && ( + + openEditArtikel(item)}> + deleteItemMut.mutate(item.id)}> + + )} + + + + ))} + + )} + + {/* Custom item + draft summary */} + {canCreate && draft.length > 0 && ( + + Anfrage-Entwurf + {draft.map((d, idx) => ( + + {d.bezeichnung} + updateDraftMenge(idx, Number(e.target.value))} sx={{ width: 70 }} inputProps={{ min: 1 }} /> + removeDraftItem(idx)}> + + ))} + + + setCustomText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addCustomToDraft(); }} sx={{ flexGrow: 1 }} /> + + + + + )} + + {/* Submit dialog */} + setSubmitOpen(false)} maxWidth="sm" fullWidth> + Anfrage absenden + + Folgende Positionen werden angefragt: + {draft.map((d, idx) => ( + - {d.menge}x {d.bezeichnung} + ))} + setSubmitNotizen(e.target.value)} multiline rows={2} sx={{ mt: 2 }} /> + + + + + + + + {/* Artikel create/edit dialog */} + setArtikelDialogOpen(false)} maxWidth="sm" fullWidth> + {editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'} + + setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} /> + setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} /> + setArtikelForm(f => ({ ...f, kategorie: val || undefined }))} + renderInput={params => } + /> + setArtikelForm(f => ({ ...f, geschaetzter_preis: e.target.value ? Number(e.target.value) : undefined }))} + inputProps={{ min: 0, step: 0.01 }} + /> + + + + + + + + {/* FAB for new catalog item */} + {canManage && ( + + + + )} + + ); +} + +// ─── My Requests Tab ──────────────────────────────────────────────────────── + +function MeineAnfragenTab() { + const [expandedId, setExpandedId] = useState(null); + + const { data: requests = [], isLoading } = useQuery({ + queryKey: ['shop', 'myRequests'], + queryFn: () => shopApi.getMyRequests(), + }); + + const { data: detail } = useQuery({ + queryKey: ['shop', 'request', expandedId], + queryFn: () => shopApi.getRequest(expandedId!), + enabled: expandedId != null, + }); + + if (isLoading) return Lade Anfragen...; + if (requests.length === 0) return Keine Anfragen vorhanden.; + + return ( + + + + + + # + Status + Positionen + Erstellt am + Admin Notizen + + + + {requests.map(r => ( + <> + setExpandedId(prev => prev === r.id ? null : r.id)}> + {expandedId === r.id ? : } + {r.id} + + {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... + )} + + + + + )} + + ))} + +
+
+ ); +} + +// ─── Admin All Requests Tab ───────────────────────────────────────────────── + +function AlleAnfragenTab() { + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + + const [statusFilter, setStatusFilter] = useState(''); + const [expandedId, setExpandedId] = useState(null); + const [actionDialog, setActionDialog] = useState<{ id: number; action: 'genehmigt' | 'abgelehnt' } | null>(null); + const [adminNotizen, setAdminNotizen] = useState(''); + const [linkDialog, setLinkDialog] = useState(null); + const [selectedBestellung, setSelectedBestellung] = useState(null); + + const { data: requests = [], isLoading } = useQuery({ + queryKey: ['shop', 'requests', statusFilter], + queryFn: () => shopApi.getRequests(statusFilter ? { status: statusFilter } : undefined), + }); + + const { data: detail } = useQuery({ + queryKey: ['shop', 'request', expandedId], + queryFn: () => shopApi.getRequest(expandedId!), + enabled: expandedId != null, + }); + + const { data: bestellungen = [] } = useQuery({ + queryKey: ['bestellungen'], + queryFn: () => bestellungApi.getOrders(), + enabled: linkDialog != null, + }); + + const statusMut = useMutation({ + mutationFn: ({ id, status, notes }: { id: number; status: string; notes?: string }) => shopApi.updateRequestStatus(id, status, notes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shop'] }); + showSuccess('Status aktualisiert'); + setActionDialog(null); + setAdminNotizen(''); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const linkMut = useMutation({ + mutationFn: ({ anfrageId, bestellungId }: { anfrageId: number; bestellungId: number }) => shopApi.linkToOrder(anfrageId, bestellungId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shop'] }); + showSuccess('Verknüpfung erstellt'); + setLinkDialog(null); + setSelectedBestellung(null); + }, + onError: () => showError('Fehler beim Verknüpfen'), + }); + + const handleAction = () => { + if (!actionDialog) return; + statusMut.mutate({ id: actionDialog.id, status: actionDialog.action, notes: adminNotizen || undefined }); + }; + + if (isLoading) return Lade Anfragen...; + + return ( + + + Status Filter + + + + {requests.length === 0 ? ( + Keine Anfragen vorhanden. + ) : ( + + + + + + # + Anfrager + Status + Positionen + Erstellt am + Aktionen + + + + {requests.map(r => ( + <> + setExpandedId(prev => prev === r.id ? null : r.id)}> + {expandedId === r.id ? : } + {r.id} + {r.anfrager_name || r.anfrager_id} + + {r.items_count ?? '-'} + {new Date(r.erstellt_am).toLocaleDateString('de-AT')} + e.stopPropagation()}> + + {r.status === 'offen' && ( + <> + + { setActionDialog({ id: r.id, action: 'genehmigt' }); setAdminNotizen(''); }}> + + + + + { setActionDialog({ id: r.id, action: 'abgelehnt' }); setAdminNotizen(''); }}> + + + + + )} + {r.status === 'genehmigt' && ( + + setLinkDialog(r.id)}> + + + + )} + + + + {expandedId === r.id && ( + + + + + {r.notizen && Notizen: {r.notizen}} + {r.admin_notizen && Admin Notizen: {r.admin_notizen}} + {detail && detail.anfrage.id === r.id ? ( + <> + {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... + )} + + + + + )} + + ))} + +
+
+ )} + + {/* Approve/Reject dialog */} + setActionDialog(null)} maxWidth="sm" fullWidth> + {actionDialog?.action === 'genehmigt' ? 'Anfrage genehmigen' : 'Anfrage ablehnen'} + + setAdminNotizen(e.target.value)} + multiline + rows={2} + sx={{ mt: 1 }} + /> + + + + + + + + {/* Link to order dialog */} + { setLinkDialog(null); setSelectedBestellung(null); }} maxWidth="sm" fullWidth> + Mit Bestellung verknüpfen + + `#${o.id} – ${o.bezeichnung}`} + value={selectedBestellung} + onChange={(_, v) => setSelectedBestellung(v)} + renderInput={params => } + /> + + + + + + +
+ ); +} + +// ─── Main Page ────────────────────────────────────────────────────────────── + +export default function Shop() { + 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 tabCount = 1 + (canCreate ? 1 : 0) + (canApprove ? 1 : 0); + + const [activeTab, setActiveTab] = useState(() => { + const t = Number(searchParams.get('tab')); + return t >= 0 && t < tabCount ? t : 0; + }); + + const handleTabChange = (_: React.SyntheticEvent, val: number) => { + setActiveTab(val); + setSearchParams({ tab: String(val) }, { replace: true }); + }; + + const tabIndex = useMemo(() => { + const map: Record = { katalog: 0 }; + let next = 1; + if (canCreate) { map.meine = next; next++; } + if (canApprove) { map.alle = next; } + return map; + }, [canCreate, canApprove]); + + if (!canView) { + return ( + + Keine Berechtigung. + + ); + } + + return ( + + Shop + + + + + {canCreate && } + {canApprove && } + + + + {activeTab === tabIndex.katalog && } + {canCreate && activeTab === tabIndex.meine && } + {canApprove && activeTab === tabIndex.alle && } + + ); +} diff --git a/frontend/src/services/bestellung.ts b/frontend/src/services/bestellung.ts new file mode 100644 index 0000000..ab7abbc --- /dev/null +++ b/frontend/src/services/bestellung.ts @@ -0,0 +1,117 @@ +import { api } from './api'; +import type { + Lieferant, + LieferantFormData, + Bestellung, + BestellungFormData, + BestellungDetailResponse, + Bestellposition, + BestellpositionFormData, + BestellungDatei, + BestellungErinnerung, + ErinnerungFormData, + BestellungHistorie, +} from '../types/bestellung.types'; + +export const bestellungApi = { + // ── Vendors ── + getVendors: async (): Promise => { + const r = await api.get('/api/bestellungen/vendors'); + return r.data.data; + }, + getVendor: async (id: number): Promise => { + const r = await api.get(`/api/bestellungen/vendors/${id}`); + return r.data.data; + }, + createVendor: async (data: LieferantFormData): Promise => { + const r = await api.post('/api/bestellungen/vendors', data); + return r.data.data; + }, + updateVendor: async (id: number, data: LieferantFormData): Promise => { + const r = await api.patch(`/api/bestellungen/vendors/${id}`, data); + return r.data.data; + }, + deleteVendor: async (id: number): Promise => { + await api.delete(`/api/bestellungen/vendors/${id}`); + }, + + // ── Orders ── + getOrders: async (filters?: { status?: string; lieferant_id?: number }): Promise => { + const params = new URLSearchParams(); + if (filters?.status) params.set('status', filters.status); + if (filters?.lieferant_id) params.set('lieferant_id', String(filters.lieferant_id)); + const r = await api.get(`/api/bestellungen?${params.toString()}`); + return r.data.data; + }, + getOrder: async (id: number): Promise => { + const r = await api.get(`/api/bestellungen/${id}`); + return r.data.data; + }, + createOrder: async (data: BestellungFormData): Promise => { + const r = await api.post('/api/bestellungen', data); + return r.data.data; + }, + updateOrder: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/bestellungen/${id}`, data); + return r.data.data; + }, + deleteOrder: async (id: number): Promise => { + await api.delete(`/api/bestellungen/${id}`); + }, + updateStatus: async (id: number, status: string): Promise => { + const r = await api.patch(`/api/bestellungen/${id}/status`, { status }); + return r.data.data; + }, + + // ── Line Items ── + addLineItem: async (orderId: number, data: BestellpositionFormData): Promise => { + const r = await api.post(`/api/bestellungen/${orderId}/items`, data); + return r.data.data; + }, + updateLineItem: async (itemId: number, data: Partial): Promise => { + const r = await api.patch(`/api/bestellungen/items/${itemId}`, data); + return r.data.data; + }, + deleteLineItem: async (itemId: number): Promise => { + await api.delete(`/api/bestellungen/items/${itemId}`); + }, + updateReceivedQty: async (itemId: number, menge: number): Promise => { + const r = await api.patch(`/api/bestellungen/items/${itemId}/received`, { erhalten_menge: menge }); + return r.data.data; + }, + + // ── Files ── + uploadFile: async (orderId: number, file: File): Promise => { + const formData = new FormData(); + formData.append('datei', file); + const r = await api.post(`/api/bestellungen/${orderId}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return r.data.data; + }, + deleteFile: async (fileId: number): Promise => { + await api.delete(`/api/bestellungen/files/${fileId}`); + }, + getFiles: async (orderId: number): Promise => { + const r = await api.get(`/api/bestellungen/${orderId}/files`); + return r.data.data; + }, + + // ── Reminders ── + addReminder: async (orderId: number, data: ErinnerungFormData): Promise => { + const r = await api.post(`/api/bestellungen/${orderId}/reminders`, data); + return r.data.data; + }, + markReminderDone: async (reminderId: number): Promise => { + await api.patch(`/api/bestellungen/reminders/${reminderId}`, { erledigt: true }); + }, + deleteReminder: async (reminderId: number): Promise => { + await api.delete(`/api/bestellungen/reminders/${reminderId}`); + }, + + // ── History ── + getHistory: async (orderId: number): Promise => { + const r = await api.get(`/api/bestellungen/${orderId}/history`); + return r.data.data; + }, +}; diff --git a/frontend/src/services/shop.ts b/frontend/src/services/shop.ts new file mode 100644 index 0000000..1e6737c --- /dev/null +++ b/frontend/src/services/shop.ts @@ -0,0 +1,73 @@ +import { api } from './api'; +import type { + ShopArtikel, + ShopArtikelFormData, + ShopAnfrage, + ShopAnfrageDetailResponse, + ShopAnfrageFormItem, +} 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}`); + }, +}; diff --git a/frontend/src/types/bestellung.types.ts b/frontend/src/types/bestellung.types.ts new file mode 100644 index 0000000..34a997c --- /dev/null +++ b/frontend/src/types/bestellung.types.ts @@ -0,0 +1,156 @@ +// Bestellungen (Vendor Orders) types + +// ── Vendors ── + +export interface Lieferant { + id: number; + name: string; + kontakt_name?: string; + email?: string; + telefon?: string; + adresse?: string; + website?: string; + notizen?: string; + erstellt_von?: string; + erstellt_am: string; + aktualisiert_am: string; +} + +export interface LieferantFormData { + name: string; + kontakt_name?: string; + email?: string; + telefon?: string; + adresse?: string; + website?: string; + notizen?: string; +} + +// ── Orders ── + +export type BestellungStatus = 'entwurf' | 'erstellt' | 'bestellt' | 'teillieferung' | 'vollstaendig' | 'abgeschlossen'; + +export const BESTELLUNG_STATUS_LABELS: Record = { + entwurf: 'Entwurf', + erstellt: 'Erstellt', + bestellt: 'Bestellt', + teillieferung: 'Teillieferung', + vollstaendig: 'Vollständig', + abgeschlossen: 'Abgeschlossen', +}; + +export const BESTELLUNG_STATUS_COLORS: Record = { + entwurf: 'default', + erstellt: 'info', + bestellt: 'primary', + teillieferung: 'warning', + vollstaendig: 'success', + abgeschlossen: 'success', +}; + +export interface Bestellung { + id: number; + bezeichnung: string; + lieferant_id?: number; + lieferant_name?: string; + besteller_id?: string; + besteller_name?: string; + status: BestellungStatus; + budget?: number; + notizen?: string; + erstellt_von?: string; + erstellt_am: string; + aktualisiert_am: string; + bestellt_am?: string; + abgeschlossen_am?: string; + // Computed + total_cost?: number; + items_count?: number; +} + +export interface BestellungFormData { + bezeichnung: string; + lieferant_id?: number; + besteller_id?: string; + status?: BestellungStatus; + budget?: number; + notizen?: string; +} + +// ── Line Items ── + +export interface Bestellposition { + id: number; + bestellung_id: number; + bezeichnung: string; + artikelnummer?: string; + menge: number; + einheit: string; + einzelpreis?: number; + erhalten_menge: number; + notizen?: string; + erstellt_am: string; + aktualisiert_am: string; +} + +export interface BestellpositionFormData { + bezeichnung: string; + artikelnummer?: string; + menge: number; + einheit?: string; + einzelpreis?: number; + notizen?: string; +} + +// ── File Attachments ── + +export interface BestellungDatei { + id: number; + bestellung_id: number; + dateiname: string; + dateipfad: string; + dateityp: string; + dateigroesse?: number; + thumbnail_pfad?: string; + hochgeladen_von?: string; + hochgeladen_am: string; +} + +// ── Reminders ── + +export interface BestellungErinnerung { + id: number; + bestellung_id: number; + faellig_am: string; + nachricht?: string; + erledigt: boolean; + erstellt_von?: string; + erstellt_am: string; +} + +export interface ErinnerungFormData { + faellig_am: string; + nachricht?: string; +} + +// ── Audit Trail ── + +export interface BestellungHistorie { + id: number; + bestellung_id: number; + aktion: string; + details?: Record; + erstellt_von?: string; + erstellt_von_name?: string; + erstellt_am: string; +} + +// ── API Response Types ── + +export interface BestellungDetailResponse { + bestellung: Bestellung; + positionen: Bestellposition[]; + dateien: BestellungDatei[]; + erinnerungen: BestellungErinnerung[]; + historie: BestellungHistorie[]; +} diff --git a/frontend/src/types/shop.types.ts b/frontend/src/types/shop.types.ts new file mode 100644 index 0000000..da0321c --- /dev/null +++ b/frontend/src/types/shop.types.ts @@ -0,0 +1,83 @@ +// Shop (Internal Ordering) types + +// ── Catalog Items ── + +export interface ShopArtikel { + id: number; + bezeichnung: string; + beschreibung?: string; + kategorie?: string; + bild_pfad?: string; + geschaetzter_preis?: number; + aktiv: boolean; + erstellt_von?: string; + erstellt_am: string; + aktualisiert_am: string; +} + +export interface ShopArtikelFormData { + bezeichnung: string; + beschreibung?: string; + kategorie?: string; + geschaetzter_preis?: number; + aktiv?: boolean; +} + +// ── Requests ── + +export type ShopAnfrageStatus = 'offen' | 'genehmigt' | 'abgelehnt' | 'bestellt' | 'erledigt'; + +export const SHOP_STATUS_LABELS: Record = { + offen: 'Offen', + genehmigt: 'Genehmigt', + abgelehnt: 'Abgelehnt', + bestellt: 'Bestellt', + erledigt: 'Erledigt', +}; + +export const SHOP_STATUS_COLORS: Record = { + offen: 'default', + genehmigt: 'info', + abgelehnt: 'error', + bestellt: 'primary', + erledigt: 'success', +}; + +export interface ShopAnfrage { + id: number; + anfrager_id: string; + anfrager_name?: string; + status: ShopAnfrageStatus; + notizen?: string; + admin_notizen?: string; + bearbeitet_von?: string; + bearbeitet_von_name?: string; + erstellt_am: string; + aktualisiert_am: string; + items_count?: number; +} + +export interface ShopAnfragePosition { + id: number; + anfrage_id: number; + artikel_id?: number; + bezeichnung: string; + menge: number; + notizen?: string; + erstellt_am: string; +} + +export interface ShopAnfrageFormItem { + artikel_id?: number; + bezeichnung: string; + menge: number; + notizen?: string; +} + +// ── API Response Types ── + +export interface ShopAnfrageDetailResponse { + anfrage: ShopAnfrage; + positionen: ShopAnfragePosition[]; + linked_bestellungen?: { id: number; bezeichnung: string; status: string }[]; +}