diff --git a/backend/src/controllers/bestellung.controller.ts b/backend/src/controllers/bestellung.controller.ts index 4c26fca..cf83dee 100644 --- a/backend/src/controllers/bestellung.controller.ts +++ b/backend/src/controllers/bestellung.controller.ts @@ -187,13 +187,13 @@ class BestellungController { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } - const { status } = req.body; + const { status, force } = 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); + const order = await bestellungService.updateOrderStatus(id, status, req.user!.id, !!force); if (!order) { res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' }); return; diff --git a/backend/src/database/migrations/060_add_steuersatz_to_bestellungen.sql b/backend/src/database/migrations/060_add_steuersatz_to_bestellungen.sql new file mode 100644 index 0000000..2fa9781 --- /dev/null +++ b/backend/src/database/migrations/060_add_steuersatz_to_bestellungen.sql @@ -0,0 +1,3 @@ +-- Add tax rate column to bestellungen +ALTER TABLE bestellungen + ADD COLUMN IF NOT EXISTS steuersatz NUMERIC(5,2) NOT NULL DEFAULT 20.00; diff --git a/backend/src/routes/bestellung.routes.ts b/backend/src/routes/bestellung.routes.ts index 8b86754..4f165cd 100644 --- a/backend/src/routes/bestellung.routes.ts +++ b/backend/src/routes/bestellung.routes.ts @@ -120,7 +120,7 @@ router.delete( router.patch( '/items/:itemId/received', authenticate, - requirePermission('bestellungen:create'), + requirePermission('bestellungen:manage_orders'), bestellungController.updateReceivedQuantity.bind(bestellungController) ); diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index 8ec6495..1ef6fea 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -174,16 +174,16 @@ async function getOrderById(id: number) { } } -async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; notizen?: string; budget?: number; positionen?: Array<{ bezeichnung: string; menge: number; einheit?: string }> }, userId: string) { +async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; notizen?: string; budget?: number; steuersatz?: number; positionen?: Array<{ bezeichnung: string; menge: number; einheit?: string }> }, userId: string) { const client = await pool.connect(); try { await client.query('BEGIN'); const bestellerId = data.besteller_id && data.besteller_id.trim() ? data.besteller_id.trim() : null; const result = await client.query( - `INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, erstellt_von) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, steuersatz, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, - [data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, userId] + [data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, data.steuersatz ?? 20, userId] ); const order = result.rows[0]; @@ -209,7 +209,7 @@ async function createOrder(data: { bezeichnung: string; lieferant_id?: number; b } } -async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_id?: number; notizen?: string; budget?: number; status?: string }, userId: string) { +async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_id?: number; notizen?: string; budget?: number; status?: string; steuersatz?: number }, userId: string) { try { // Check current order for status change detection const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]); @@ -239,10 +239,11 @@ async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_i status = COALESCE($5, status), bestellt_am = $6, abgeschlossen_am = $7, + steuersatz = COALESCE($8, steuersatz), aktualisiert_am = NOW() - WHERE id = $8 + WHERE id = $9 RETURNING *`, - [data.bezeichnung, data.lieferant_id, data.notizen, data.budget, data.status, bestellt_am, abgeschlossen_am, id] + [data.bezeichnung, data.lieferant_id, data.notizen, data.budget, data.status, bestellt_am, abgeschlossen_am, data.steuersatz, id] ); if (result.rows.length === 0) return null; @@ -251,6 +252,7 @@ async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_i if (data.lieferant_id) changes.push(`Lieferant geändert`); if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus} → ${data.status}`); if (data.budget) changes.push(`Budget geändert`); + if (data.steuersatz != null) changes.push(`Steuersatz: ${data.steuersatz}%`); await logAction(id, 'Bestellung aktualisiert', changes.join(', ') || 'Bestellung bearbeitet', userId); return result.rows[0]; @@ -302,15 +304,17 @@ const VALID_STATUS_TRANSITIONS: Record = { abgeschlossen: [], }; -async function updateOrderStatus(id: number, status: string, userId: string) { +async function updateOrderStatus(id: number, status: string, userId: string, force?: boolean) { 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}`); + if (!force) { + 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()']; @@ -330,7 +334,7 @@ async function updateOrderStatus(id: number, status: string, userId: string) { params ); - await logAction(id, 'Status geändert', `${oldStatus} → ${status}`, userId); + await logAction(id, 'Status geändert', `${oldStatus} → ${status}${force ? ' (manuell)' : ''}`, userId); return result.rows[0]; } catch (error) { logger.error('BestellungService.updateOrderStatus failed', { error, id }); diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index eee931b..9961ca1 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -37,6 +37,7 @@ import { History, Upload as UploadIcon, ArrowDropDown, + MoreVert, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -97,7 +98,9 @@ export default function BestellungDetail() { const [editingItemId, setEditingItemId] = useState(null); const [editingItemData, setEditingItemData] = useState>({}); 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); @@ -118,21 +121,36 @@ export default function BestellungDetail() { const erinnerungen = data?.erinnerungen ?? []; const historie = data?.historie ?? []; - const canEdit = hasPermission('bestellungen:create'); + const canCreate = hasPermission('bestellungen:create'); + const canDelete = hasPermission('bestellungen:delete'); + const canManageReminders = hasPermission('bestellungen:manage_reminders'); 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 overrideStatuses = bestellung ? ALL_STATUSES.filter((s) => s !== bestellung.status) : []; + // ── Mutations ── const updateStatus = useMutation({ - mutationFn: (status: string) => bestellungApi.updateStatus(orderId, status), + mutationFn: ({ status, force }: { status: string; force?: boolean }) => bestellungApi.updateStatus(orderId, status, force), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); showSuccess('Status aktualisiert'); setStatusConfirmTarget(null); + setStatusForce(false); }, onError: () => showError('Fehler beim Aktualisieren des Status'), }); + const updateOrder = useMutation({ + mutationFn: (data: Partial<{ steuersatz: number }>) => bestellungApi.updateOrder(orderId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + const addItem = useMutation({ mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data), onSuccess: () => { @@ -252,6 +270,9 @@ export default function BestellungDetail() { // Compute totals (NUMERIC columns come as strings from PostgreSQL — parse to float) const totalCost = positionen.reduce((sum, p) => sum + (parseFloat(String(p.einzelpreis)) || 0) * (parseFloat(String(p.menge)) || 0), 0); + const steuersatz = parseFloat(String(bestellung?.steuersatz ?? 20)); + const taxAmount = totalCost * (steuersatz / 100); + const totalBrutto = totalCost + taxAmount; const totalReceived = positionen.length > 0 ? positionen.reduce((sum, p) => sum + (parseFloat(String(p.erhalten_menge)) || 0), 0) : 0; @@ -328,13 +349,13 @@ export default function BestellungDetail() { {/* ── Status Action ── */} - {canEdit && validTransitions.length > 0 && ( - + {canCreate && ( + {validTransitions.length === 1 ? ( - - ) : ( + ) : validTransitions.length > 1 ? ( <> @@ -570,7 +660,7 @@ export default function BestellungDetail() { markReminderDone.mutate(r.id)} size="small" /> @@ -582,7 +672,7 @@ export default function BestellungDetail() { Fällig: {formatDate(r.faellig_am)} - {canEdit && ( + {canManageReminders && ( setDeleteReminderTarget(r.id)}> @@ -670,17 +760,22 @@ export default function BestellungDetail() { {/* ══════════════════════════════════════════════════════════════════════ */} {/* Status Confirmation */} - setStatusConfirmTarget(null)}> - Status ändern + { setStatusConfirmTarget(null); setStatusForce(false); }}> + Status ändern{statusForce ? ' (manuell)' : ''} Status von {BESTELLUNG_STATUS_LABELS[bestellung.status]} auf{' '} {statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''} ändern? + {statusForce && ( + + Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs. + + )} - - + diff --git a/frontend/src/services/bestellung.ts b/frontend/src/services/bestellung.ts index 1fe163d..b67b9dd 100644 --- a/frontend/src/services/bestellung.ts +++ b/frontend/src/services/bestellung.ts @@ -58,8 +58,8 @@ export const bestellungApi = { 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 }); + updateStatus: async (id: number, status: string, force?: boolean): Promise => { + const r = await api.patch(`/api/bestellungen/${id}/status`, { status, force: force || undefined }); return r.data.data; }, diff --git a/frontend/src/types/bestellung.types.ts b/frontend/src/types/bestellung.types.ts index 1a09b4c..24ad57d 100644 --- a/frontend/src/types/bestellung.types.ts +++ b/frontend/src/types/bestellung.types.ts @@ -57,6 +57,7 @@ export interface Bestellung { besteller_name?: string; status: BestellungStatus; budget?: number; + steuersatz?: number; notizen?: string; erstellt_von?: string; erstellt_am: string; @@ -74,6 +75,7 @@ export interface BestellungFormData { besteller_id?: string; status?: BestellungStatus; budget?: number; + steuersatz?: number; notizen?: string; positionen?: BestellpositionFormData[]; }