From c29b21f714257a8b61bbd3ecc0eb25c9c169809e Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 26 Mar 2026 14:22:35 +0100 Subject: [PATCH] update --- .../src/controllers/bestellung.controller.ts | 41 +++- .../064_add_spezifikationen_and_approval.sql | 63 +++++ backend/src/routes/bestellung.routes.ts | 2 +- backend/src/services/bestellung.service.ts | 113 +++++++-- .../components/admin/PermissionMatrixTab.tsx | 2 +- frontend/src/pages/BestellungDetail.tsx | 217 +++++++++++++----- frontend/src/pages/Bestellungen.tsx | 8 +- frontend/src/pages/Kalender.tsx | 38 ++- frontend/src/types/bestellung.types.ts | 19 +- 9 files changed, 400 insertions(+), 103 deletions(-) create mode 100644 backend/src/database/migrations/064_add_spezifikationen_and_approval.sql diff --git a/backend/src/controllers/bestellung.controller.ts b/backend/src/controllers/bestellung.controller.ts index 285c0f7..110e37a 100644 --- a/backend/src/controllers/bestellung.controller.ts +++ b/backend/src/controllers/bestellung.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import bestellungService from '../services/bestellung.service'; +import { permissionService } from '../services/permission.service'; import logger from '../utils/logger'; import fs from 'fs'; @@ -227,7 +228,34 @@ class BestellungController { return; } try { - const order = await bestellungService.updateOrderStatus(id, status, req.user!.id, !!force); + // For force override, require manage_orders + if (force) { + const canManage = permissionService.hasPermission(req.user!.groups || [], 'bestellungen:manage_orders'); + if (!canManage) { + res.status(403).json({ success: false, message: 'Keine Berechtigung für manuelle Statusänderung' }); + return; + } + } + + // For approval/rejection transitions, require bestellungen:approve + if (status === 'bereit_zur_bestellung' || status === 'entwurf') { + // Check if this is an approval/rejection (from wartet_auf_genehmigung) + const currentOrder = await bestellungService.getOrderById(id); + if (!currentOrder) { + res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); + return; + } + const currentStatus = currentOrder.bestellung.status; + if (currentStatus === 'wartet_auf_genehmigung') { + const canApprove = permissionService.hasPermission(req.user!.groups || [], 'bestellungen:approve'); + if (!canApprove) { + res.status(403).json({ success: false, message: 'Keine Berechtigung zur Genehmigung/Ablehnung von Bestellungen' }); + return; + } + } + } + + const order = await bestellungService.updateOrderStatus(id, status, req.user!.id, !!force, req.user!.id); if (!order) { res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); return; @@ -327,7 +355,11 @@ class BestellungController { return; } res.status(200).json({ success: true, data: item }); - } catch (error) { + } catch (error: any) { + if (error.statusCode === 400) { + res.status(400).json({ success: false, message: error.message }); + return; + } logger.error('BestellungController.updateReceivedQuantity error', { error }); res.status(500).json({ success: false, message: 'Liefermenge konnte nicht aktualisiert werden' }); } @@ -501,6 +533,11 @@ class BestellungController { res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); return; } + const orderStatus = order.bestellung.status; + if (orderStatus === 'entwurf' || orderStatus === 'wartet_auf_genehmigung') { + res.status(403).json({ success: false, message: 'Export nur nach Genehmigung verfügbar' }); + return; + } res.status(200).json({ success: true, data: order }); } catch (error) { logger.error('BestellungController.exportOrder error', { error }); diff --git a/backend/src/database/migrations/064_add_spezifikationen_and_approval.sql b/backend/src/database/migrations/064_add_spezifikationen_and_approval.sql new file mode 100644 index 0000000..2ae8cb4 --- /dev/null +++ b/backend/src/database/migrations/064_add_spezifikationen_and_approval.sql @@ -0,0 +1,63 @@ +-- Migration 064: Add spezifikationen to line items, approval columns, and new status workflow +-- 1. spezifikationen JSONB on bestellpositionen +-- 2. genehmigt_von / genehmigt_am on bestellungen +-- 3. Drop old status CHECK constraint +-- 4. Data migration: old statuses → new statuses +-- 5. Add new status CHECK constraint with approval workflow statuses +-- 6. bestellungen:approve permission + seed for dashboard_kommando + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Spezifikationen on line items +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE bestellpositionen + ADD COLUMN IF NOT EXISTS spezifikationen JSONB DEFAULT '[]'; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Approval columns on bestellungen +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE bestellungen + ADD COLUMN IF NOT EXISTS genehmigt_von UUID REFERENCES users(id), + ADD COLUMN IF NOT EXISTS genehmigt_am TIMESTAMPTZ; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Drop old status CHECK constraint +-- ═══════════════════════════════════════════════════════════════════════════ + +DO $$ BEGIN + ALTER TABLE bestellungen DROP CONSTRAINT IF EXISTS bestellungen_status_check; +EXCEPTION WHEN undefined_object THEN + NULL; +END $$; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 4. Data migration: map old statuses to new ones (before new constraint) +-- ═══════════════════════════════════════════════════════════════════════════ + +UPDATE bestellungen SET status = 'bereit_zur_bestellung' WHERE status = 'erstellt'; +UPDATE bestellungen SET status = 'lieferung_pruefen' WHERE status = 'vollstaendig'; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 5. Add new status CHECK constraint +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE bestellungen + ADD CONSTRAINT bestellungen_status_check + CHECK (status IN ('entwurf','wartet_auf_genehmigung','bereit_zur_bestellung','bestellt','teillieferung','lieferung_pruefen','abgeschlossen')); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 6. Add bestellungen:approve permission +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('bestellungen:approve', 'bestellungen', 'Genehmigen', 'Bestellungen genehmigen', 25) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 7. Seed grant for dashboard_kommando +-- ═══════════════════════════════════════════════════════════════════════════ + +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_kommando', 'bestellungen:approve') +ON CONFLICT DO NOTHING; diff --git a/backend/src/routes/bestellung.routes.ts b/backend/src/routes/bestellung.routes.ts index daa3424..fb30cbb 100644 --- a/backend/src/routes/bestellung.routes.ts +++ b/backend/src/routes/bestellung.routes.ts @@ -102,7 +102,7 @@ router.delete( router.patch( '/:id/status', authenticate, - requirePermission('bestellungen:manage_orders'), + requirePermission('bestellungen:create'), bestellungController.updateStatus.bind(bestellungController) ); diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index 87a3a7b..c924df5 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -5,6 +5,8 @@ import pool from '../config/database'; import logger from '../utils/logger'; import fs from 'fs'; +import notificationService from './notification.service'; +import { permissionService } from './permission.service'; // --------------------------------------------------------------------------- // Vendors (Lieferanten) @@ -318,20 +320,22 @@ async function deleteOrder(id: number, _userId: string) { } const VALID_STATUS_TRANSITIONS: Record = { - entwurf: ['erstellt', 'bestellt'], - erstellt: ['bestellt'], - bestellt: ['teillieferung', 'vollstaendig'], - teillieferung: ['vollstaendig'], - vollstaendig: ['abgeschlossen'], - abgeschlossen: [], + entwurf: ['wartet_auf_genehmigung'], + wartet_auf_genehmigung: ['bereit_zur_bestellung', 'entwurf'], + bereit_zur_bestellung: ['bestellt'], + bestellt: ['teillieferung', 'lieferung_pruefen'], + teillieferung: ['lieferung_pruefen'], + lieferung_pruefen: ['abgeschlossen'], + abgeschlossen: [], }; -async function updateOrderStatus(id: number, status: string, userId: string, force?: boolean) { +async function updateOrderStatus(id: number, status: string, userId: string, force?: boolean, approverUserId?: string) { try { - const current = await pool.query(`SELECT status FROM bestellungen WHERE id = $1`, [id]); + 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 order = current.rows[0]; + const oldStatus = order.status; if (!force) { const allowed = VALID_STATUS_TRANSITIONS[oldStatus] || []; if (!allowed.includes(status)) { @@ -349,6 +353,12 @@ async function updateOrderStatus(id: number, status: string, userId: string, for if (status === 'abgeschlossen') { updates.push(`abgeschlossen_am = COALESCE(abgeschlossen_am, NOW())`); } + if (status === 'bereit_zur_bestellung' && approverUserId) { + updates.push(`genehmigt_von = $${paramIndex}`); + params.push(approverUserId); + paramIndex++; + updates.push(`genehmigt_am = NOW()`); + } params.push(id); const result = await pool.query( @@ -357,6 +367,53 @@ async function updateOrderStatus(id: number, status: string, userId: string, for ); await logAction(id, 'Status geändert', `${oldStatus} → ${status}${force ? ' (manuell)' : ''}`, userId); + + // Notifications based on transition + const orderLabel = order.laufende_nummer ? `#${order.laufende_nummer}` : `#${order.id}`; + const bestellerId = order.besteller_id || order.erstellt_von; + + if (status === 'wartet_auf_genehmigung') { + try { + const approvers = await permissionService.getUsersWithPermission('bestellungen:approve'); + for (const user of approvers) { + await notificationService.createNotification({ + user_id: user.id, + typ: 'bestellung', + titel: 'Bestellung wartet auf Genehmigung', + nachricht: `Bestellung ${orderLabel} wurde zur Genehmigung eingereicht`, + schwere: 'info', + quell_typ: 'bestellung_genehmigung', + quell_id: order.id.toString(), + link: `/bestellungen/${id}`, + }); + } + } catch (err) { + logger.error('Failed to notify approvers', { err, orderId: id }); + } + } else if (status === 'bereit_zur_bestellung') { + await notificationService.createNotification({ + user_id: bestellerId, + typ: 'bestellung', + titel: 'Bestellung genehmigt', + nachricht: `Bestellung ${orderLabel} wurde genehmigt und ist bereit zur Bestellung`, + schwere: 'info', + quell_typ: 'bestellung_genehmigung', + quell_id: order.id.toString(), + link: `/bestellungen/${id}`, + }); + } else if (status === 'entwurf' && oldStatus === 'wartet_auf_genehmigung') { + await notificationService.createNotification({ + user_id: bestellerId, + typ: 'bestellung', + titel: 'Bestellung abgelehnt', + nachricht: `Bestellung ${orderLabel} wurde abgelehnt und zurück in den Entwurf gesetzt`, + schwere: 'warnung', + quell_typ: 'bestellung_genehmigung', + quell_id: order.id.toString(), + link: `/bestellungen/${id}`, + }); + } + return result.rows[0]; } catch (error) { logger.error('BestellungService.updateOrderStatus failed', { error, id }); @@ -368,13 +425,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for // Line Items (Bestellpositionen) // --------------------------------------------------------------------------- -async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) { +async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[] }, userId: string) { try { const result = await pool.query( - `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) RETURNING *`, - [bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null] + [bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null, JSON.stringify(data.spezifikationen || [])] ); await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId); return result.rows[0]; @@ -384,7 +441,7 @@ async function addLineItem(bestellungId: number, data: { bezeichnung: string; ar } } -async function updateLineItem(id: number, data: { bezeichnung?: string; artikelnummer?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) { +async function updateLineItem(id: number, data: { bezeichnung?: string; artikelnummer?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[] }, userId: string) { try { const result = await pool.query( `UPDATE bestellpositionen @@ -393,10 +450,11 @@ async function updateLineItem(id: number, data: { bezeichnung?: string; artikeln menge = COALESCE($3, menge), einheit = COALESCE($4, einheit), einzelpreis = COALESCE($5, einzelpreis), - notizen = COALESCE($6, notizen) - WHERE id = $7 + notizen = COALESCE($6, notizen), + spezifikationen = COALESCE($7::jsonb, spezifikationen) + WHERE id = $8 RETURNING *`, - [data.bezeichnung, data.artikelnummer, data.menge, data.einheit, data.einzelpreis, data.notizen, id] + [data.bezeichnung, data.artikelnummer, data.menge, data.einheit, data.einzelpreis, data.notizen, data.spezifikationen ? JSON.stringify(data.spezifikationen) : null, id] ); if (result.rows.length === 0) return null; @@ -425,6 +483,21 @@ async function deleteLineItem(id: number, userId: string) { async function updateReceivedQuantity(id: number, menge: number, userId: string) { try { + // First check if the item's order is in a valid status for receiving + const itemCheck = await pool.query( + `SELECT bp.bestellung_id, b.status + FROM bestellpositionen bp + JOIN bestellungen b ON b.id = bp.bestellung_id + WHERE bp.id = $1`, + [id] + ); + if (itemCheck.rows.length > 0) { + const orderStatus = itemCheck.rows[0].status; + if (orderStatus !== 'bestellt' && orderStatus !== 'teillieferung') { + throw Object.assign(new Error('Mengenaktualisierung nur bei Status bestellt oder Teillieferung möglich'), { statusCode: 400 }); + } + } + const result = await pool.query( `UPDATE bestellpositionen SET erhalten_menge = $1 WHERE id = $2 RETURNING *`, [menge, id] @@ -447,10 +520,10 @@ async function updateReceivedQuantity(id: number, menge: number, userId: string) 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`, + `UPDATE bestellungen SET status = 'lieferung_pruefen', aktualisiert_am = NOW() WHERE id = $1`, [item.bestellung_id] ); - await logAction(item.bestellung_id, 'Status geändert', 'Alle Positionen vollständig erhalten → vollstaendig', userId); + await logAction(item.bestellung_id, 'Status geändert', 'Alle Positionen vollständig erhalten → lieferung_pruefen', userId); } else if (someReceived && order.rows[0].status === 'bestellt') { await pool.query( `UPDATE bestellungen SET status = 'teillieferung', aktualisiert_am = NOW() WHERE id = $1`, @@ -463,7 +536,7 @@ async function updateReceivedQuantity(id: number, menge: number, userId: string) return item; } catch (error) { logger.error('BestellungService.updateReceivedQuantity failed', { error, id }); - throw new Error('Liefermenge konnte nicht aktualisiert werden'); + throw error; } } diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index 0df72d7..e82387b 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -97,7 +97,7 @@ const PERMISSION_SUB_GROUPS: Record> = { 'Widget': ['widget'], }, bestellungen: { - 'Bestellungen': ['view', 'create', 'manage_orders', 'delete', 'export'], + 'Bestellungen': ['view', 'create', 'approve', 'manage_orders', 'delete', 'export'], 'Lieferanten': ['manage_vendors'], 'Erinnerungen': ['manage_reminders'], 'Widget': ['widget'], diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index db6715e..4a7d17f 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import React, { useState, useRef } from 'react'; import { Box, Typography, @@ -100,12 +100,13 @@ const formatFileSize = (bytes?: number) => { // Valid status transitions (must match backend VALID_STATUS_TRANSITIONS) const STATUS_TRANSITIONS: Record = { - entwurf: ['erstellt', 'bestellt'], - erstellt: ['bestellt'], - bestellt: ['teillieferung', 'vollstaendig'], - teillieferung: ['vollstaendig'], - vollstaendig: ['abgeschlossen'], - abgeschlossen: [], + entwurf: ['wartet_auf_genehmigung'], + wartet_auf_genehmigung: ['bereit_zur_bestellung', 'entwurf'], + bereit_zur_bestellung: ['bestellt'], + bestellt: ['teillieferung', 'lieferung_pruefen'], + teillieferung: ['lieferung_pruefen'], + lieferung_pruefen: ['abgeschlossen'], + abgeschlossen: [], }; // Empty line item form @@ -129,7 +130,6 @@ export default function BestellungDetail() { const [newItem, setNewItem] = useState({ ...emptyItem }); const [statusConfirmTarget, setStatusConfirmTarget] = useState(null); const [statusForce, setStatusForce] = useState(false); - const [statusMenuAnchor, setStatusMenuAnchor] = useState(null); const [overrideMenuAnchor, setOverrideMenuAnchor] = useState(null); const [deleteItemTarget, setDeleteItemTarget] = useState(null); const [deleteFileTarget, setDeleteFileTarget] = useState(null); @@ -150,6 +150,7 @@ export default function BestellungDetail() { menge: number; einheit: string; einzelpreis?: number; + spezifikationen: string[]; }>>({}); const [isSavingAll, setIsSavingAll] = useState(false); @@ -187,11 +188,12 @@ export default function BestellungDetail() { const canDelete = hasPermission('bestellungen:delete'); const canManageReminders = hasPermission('bestellungen:manage_reminders'); const canManageOrders = hasPermission('bestellungen:manage_orders'); + const canApprove = hasPermission('bestellungen:approve'); const canExport = hasPermission('bestellungen:export'); const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : []; // All statuses except current, for force override - const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen']; + const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen']; const overrideStatuses = bestellung ? ALL_STATUSES.filter((s) => s !== bestellung.status) : []; // ── Mutations ── @@ -302,6 +304,7 @@ export default function BestellungDetail() { menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined, + spezifikationen: p.spezifikationen || [], }])) ); setEditMode(true); @@ -326,7 +329,10 @@ export default function BestellungDetail() { for (const item of positionen) { const itemEdit = editItemsData[item.id]; if (itemEdit) { - await bestellungApi.updateLineItem(item.id, itemEdit); + await bestellungApi.updateLineItem(item.id, { + ...itemEdit, + spezifikationen: itemEdit.spezifikationen, + }); } } await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); @@ -455,18 +461,22 @@ export default function BestellungDetail() { const totalBrutto = totalNetto * (1 + steuersatz); if (hasPrices) { - const rows = positionen.map((p) => { + const rows: (string | number)[][] = []; + for (const p of positionen) { const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined; const menge = parseFloat(String(p.menge)) || 0; const gesamt = ep != null ? ep * menge : undefined; - return [ + rows.push([ p.bezeichnung, p.artikelnummer || '', `${menge} ${p.einheit}`, ep != null ? formatCurrency(ep) : '–', gesamt != null ? formatCurrency(gesamt) : '–', - ]; - }); + ]); + for (const spec of p.spezifikationen || []) { + rows.push([` • ${spec}`, '', '', '', '']); + } + } autoTable(doc, { head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']], @@ -484,6 +494,16 @@ export default function BestellungDetail() { 3: { cellWidth: 30, halign: 'right' }, 4: { cellWidth: 30, halign: 'right' }, }, + didParseCell: (data: any) => { + if (data.section === 'body') { + const cell0 = String(data.row.raw[0] ?? ''); + if (cell0.startsWith(' •')) { + data.cell.styles.fontSize = 8; + data.cell.styles.textColor = [100, 100, 100]; + data.cell.styles.fillColor = [255, 255, 255]; + } + } + }, foot: [ ['', '', '', 'Netto:', formatCurrency(totalNetto)], ['', '', '', `Brutto (${bestellung.steuersatz ?? 20}% USt.):`, formatCurrency(totalBrutto)], @@ -492,10 +512,14 @@ export default function BestellungDetail() { didDrawPage: addPdfFooter(doc, settings), }); } else { - const rows = positionen.map((p) => { + const rows: string[][] = []; + for (const p of positionen) { const menge = parseFloat(String(p.menge)) || 0; - return [p.bezeichnung, p.artikelnummer || '', `${menge} ${p.einheit}`]; - }); + rows.push([p.bezeichnung, p.artikelnummer || '', `${menge} ${p.einheit}`]); + for (const spec of p.spezifikationen || []) { + rows.push([` • ${spec}`, '', '']); + } + } autoTable(doc, { head: [['Bezeichnung', 'Art.-Nr.', 'Menge']], @@ -511,6 +535,16 @@ export default function BestellungDetail() { 1: { cellWidth: 40 }, 2: { cellWidth: 33, halign: 'right' }, }, + didParseCell: (data: any) => { + if (data.section === 'body') { + const cell0 = String(data.row.raw[0] ?? ''); + if (cell0.startsWith(' •')) { + data.cell.styles.fontSize = 8; + data.cell.styles.textColor = [100, 100, 100]; + data.cell.styles.fillColor = [255, 255, 255]; + } + } + }, didDrawPage: addPdfFooter(doc, settings), }); } @@ -581,10 +615,16 @@ export default function BestellungDetail() { {bestellung.bezeichnung} {canExport && !editMode && ( - - - - + + + + + + )} {canCreate && !editMode && ( @@ -662,41 +702,39 @@ export default function BestellungDetail() { )} {/* ── Status Action ── */} - {canManageOrders && ( + {(canManageOrders || canCreate || canApprove) && ( - {validTransitions.length === 1 ? ( - - ) : validTransitions.length > 1 ? ( - <> - - setStatusMenuAnchor(null)} - > - {validTransitions.map((s) => ( - { - setStatusMenuAnchor(null); - setStatusForce(false); - setStatusConfirmTarget(s); - }} - > - {BESTELLUNG_STATUS_LABELS[s]} - - ))} - - - ) : null} + {validTransitions + .filter((s) => { + // Approve/reject transitions from wartet_auf_genehmigung require canApprove + if (bestellung.status === 'wartet_auf_genehmigung') { + return canApprove; + } + // Transition to bereit_zur_bestellung from other states also requires canApprove + if (s === 'bereit_zur_bestellung') return canApprove; + // All other transitions require canCreate or canManageOrders + return canCreate || canManageOrders; + }) + .map((s) => { + const isApprove = bestellung.status === 'wartet_auf_genehmigung' && s === 'bereit_zur_bestellung'; + const isReject = bestellung.status === 'wartet_auf_genehmigung' && s === 'entwurf'; + const label = isApprove + ? 'Genehmigen' + : isReject + ? 'Ablehnen' + : `Status: ${BESTELLUNG_STATUS_LABELS[s]}`; + const color = isApprove ? 'success' : isReject ? 'error' : 'primary'; + return ( + + ); + })} {/* Manual override menu */} {overrideStatuses.length > 0 && canManageOrders && ( @@ -771,29 +809,30 @@ export default function BestellungDetail() { {positionen.map((p) => editMode ? ( - + + setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), bezeichnung: e.target.value } }))} /> + onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined, spezifikationen: p.spezifikationen || [] }), bezeichnung: e.target.value } }))} /> setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), artikelnummer: e.target.value } }))} /> + onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined, spezifikationen: p.spezifikationen || [] }), artikelnummer: e.target.value } }))} /> setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), menge: Number(e.target.value) } }))} /> + onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined, spezifikationen: p.spezifikationen || [] }), menge: Number(e.target.value) } }))} /> setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), einheit: e.target.value } }))} /> + onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined, spezifikationen: p.spezifikationen || [] }), einheit: e.target.value } }))} /> setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), einzelpreis: e.target.value ? Number(e.target.value) : undefined } }))} /> + onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined, spezifikationen: p.spezifikationen || [] }), einzelpreis: e.target.value ? Number(e.target.value) : undefined } }))} /> {formatCurrency((editItemsData[p.id]?.einzelpreis ?? parseFloat(String(p.einzelpreis ?? 0))) * (editItemsData[p.id]?.menge ?? parseFloat(String(p.menge))))} @@ -802,6 +841,7 @@ export default function BestellungDetail() { {canManageOrders ? ( updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} /> ) : p.erhalten_menge} @@ -815,9 +855,61 @@ export default function BestellungDetail() { )} + {/* Specifications editor row */} + + + + {(editItemsData[p.id]?.spezifikationen || []).map((spec, specIdx) => ( + + setEditItemsData(d => { + const cur = d[p.id]?.spezifikationen ? [...d[p.id].spezifikationen] : []; + cur[specIdx] = e.target.value; + return { ...d, [p.id]: { ...d[p.id], spezifikationen: cur } }; + })} + /> + setEditItemsData(d => { + const cur = d[p.id]?.spezifikationen ? [...d[p.id].spezifikationen] : []; + cur.splice(specIdx, 1); + return { ...d, [p.id]: { ...d[p.id], spezifikationen: cur } }; + })}> + + + + ))} + + + + + ) : ( - {p.bezeichnung} + + + {p.bezeichnung} + {p.spezifikationen && p.spezifikationen.length > 0 && ( + + {p.spezifikationen.map((spec, i) => ( + • {spec} + ))} + + )} + + {p.artikelnummer || '–'} {p.menge} {p.einheit} @@ -831,6 +923,7 @@ export default function BestellungDetail() { sx={{ width: 70 }} value={p.erhalten_menge} inputProps={{ min: 0, max: p.menge }} + disabled={bestellung.status !== 'bestellt' && bestellung.status !== 'teillieferung'} onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} /> ) : ( diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index 2f8ee9f..046d168 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -59,7 +59,7 @@ const TAB_COUNT = 2; // ── Status options ── -const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen']; +const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen']; const DEFAULT_EXCLUDED_STATUSES: BestellungStatus[] = ['abgeschlossen']; // ── Kennung formatter ── @@ -262,10 +262,10 @@ export default function Bestellungen() { {/* ── Summary Cards ── */} {[ - { label: 'Noch nicht bestellt', count: orders.filter(o => o.status === 'entwurf' || o.status === 'erstellt').length, color: 'text.secondary' }, - { label: 'Bestellt', count: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' }, + { label: 'Entwurf / Genehmigung', count: orders.filter(o => o.status === 'entwurf' || o.status === 'wartet_auf_genehmigung').length, color: 'text.secondary' }, + { label: 'Bereit / Bestellt', count: orders.filter(o => o.status === 'bereit_zur_bestellung' || o.status === 'bestellt').length, color: 'primary.main' }, { label: 'Teillieferung', count: orders.filter(o => o.status === 'teillieferung').length, color: 'warning.main' }, - { label: 'Vollständig', count: orders.filter(o => o.status === 'vollstaendig').length, color: 'success.main' }, + { label: 'Lieferung prüfen', count: orders.filter(o => o.status === 'lieferung_pruefen').length, color: 'secondary.main' }, { label: 'Gesamt', count: orders.length, color: 'text.primary' }, ].map(({ label, count, color }) => ( diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 75f02a6..f003f2b 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -1732,12 +1732,36 @@ export default function Kalender() { setCalLoading(true); setCalError(null); try { - const firstDay = new Date(viewMonth.year, viewMonth.month, 1); - const dayOfWeek = (firstDay.getDay() + 6) % 7; - const gridStart = new Date(firstDay); - gridStart.setDate(gridStart.getDate() - dayOfWeek); - const gridEnd = new Date(gridStart); - gridEnd.setDate(gridStart.getDate() + 41); + let gridStart: Date; + let gridEnd: Date; + + if (viewMode === 'day') { + // Fetch the full month containing currentDate (plus padding) + const monthStart = startOfMonth(currentDate); + const dayOfWeek = (monthStart.getDay() + 6) % 7; + gridStart = subDays(monthStart, dayOfWeek); + gridEnd = addDays(gridStart, 41); + } else if (viewMode === 'week') { + // Fetch the month containing the current week + const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 }); + const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 }); + const monthStart = startOfMonth(weekStart); + const monthEnd = endOfMonth(weekEnd); + gridStart = subDays(monthStart, 7); + gridEnd = addDays(monthEnd, 7); + } else if (viewMode === 'list') { + // Fetch from listFrom to listTo, with padding + gridStart = subDays(parseISO(listFrom), 1); + gridEnd = addDays(parseISO(listTo), 1); + } else { + // Month view: 42-day grid based on viewMonth + const firstDay = new Date(viewMonth.year, viewMonth.month, 1); + const dayOfWeek = (firstDay.getDay() + 6) % 7; + gridStart = new Date(firstDay); + gridStart.setDate(gridStart.getDate() - dayOfWeek); + gridEnd = new Date(gridStart); + gridEnd.setDate(gridStart.getDate() + 41); + } const [trainData, eventData] = await Promise.all([ trainingApi.getCalendarRange(gridStart, gridEnd), @@ -1750,7 +1774,7 @@ export default function Kalender() { } finally { setCalLoading(false); } - }, [viewMonth]); + }, [viewMonth, viewMode, currentDate, listFrom, listTo]); // Load kategorien + groups once useEffect(() => { diff --git a/frontend/src/types/bestellung.types.ts b/frontend/src/types/bestellung.types.ts index e469ffa..7fed6a4 100644 --- a/frontend/src/types/bestellung.types.ts +++ b/frontend/src/types/bestellung.types.ts @@ -28,23 +28,25 @@ export interface LieferantFormData { // ── Orders ── -export type BestellungStatus = 'entwurf' | 'erstellt' | 'bestellt' | 'teillieferung' | 'vollstaendig' | 'abgeschlossen'; +export type BestellungStatus = 'entwurf' | 'wartet_auf_genehmigung' | 'bereit_zur_bestellung' | 'bestellt' | 'teillieferung' | 'lieferung_pruefen' | 'abgeschlossen'; export const BESTELLUNG_STATUS_LABELS: Record = { entwurf: 'Entwurf', - erstellt: 'Erstellt', + wartet_auf_genehmigung: 'Wartet auf Genehmigung', + bereit_zur_bestellung: 'Bereit zur Bestellung', bestellt: 'Bestellt', teillieferung: 'Teillieferung', - vollstaendig: 'Vollständig', + lieferung_pruefen: 'Lieferung prüfen', abgeschlossen: 'Abgeschlossen', }; -export const BESTELLUNG_STATUS_COLORS: Record = { +export const BESTELLUNG_STATUS_COLORS: Record = { entwurf: 'default', - erstellt: 'info', + wartet_auf_genehmigung: 'warning', + bereit_zur_bestellung: 'info', bestellt: 'primary', teillieferung: 'warning', - vollstaendig: 'success', + lieferung_pruefen: 'secondary', abgeschlossen: 'success', }; @@ -70,6 +72,9 @@ export interface Bestellung { aktualisiert_am: string; bestellt_am?: string; abgeschlossen_am?: string; + genehmigt_von?: string; + genehmigt_am?: string; + genehmigt_von_name?: string; // Computed total_cost?: number; items_count?: number; @@ -102,6 +107,7 @@ export interface Bestellposition { notizen?: string; erstellt_am: string; aktualisiert_am: string; + spezifikationen?: string[]; } export interface BestellpositionFormData { @@ -111,6 +117,7 @@ export interface BestellpositionFormData { einheit?: string; einzelpreis?: number; notizen?: string; + spezifikationen?: string[]; } // ── File Attachments ──