update
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import bestellungService from '../services/bestellung.service';
|
import bestellungService from '../services/bestellung.service';
|
||||||
|
import { permissionService } from '../services/permission.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
@@ -227,7 +228,34 @@ class BestellungController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
if (!order) {
|
||||||
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
@@ -327,7 +355,11 @@ class BestellungController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.status(200).json({ success: true, data: item });
|
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 });
|
logger.error('BestellungController.updateReceivedQuantity error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Liefermenge konnte nicht aktualisiert werden' });
|
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' });
|
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
|
||||||
return;
|
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 });
|
res.status(200).json({ success: true, data: order });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('BestellungController.exportOrder error', { error });
|
logger.error('BestellungController.exportOrder error', { error });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -102,7 +102,7 @@ router.delete(
|
|||||||
router.patch(
|
router.patch(
|
||||||
'/:id/status',
|
'/:id/status',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('bestellungen:manage_orders'),
|
requirePermission('bestellungen:create'),
|
||||||
bestellungController.updateStatus.bind(bestellungController)
|
bestellungController.updateStatus.bind(bestellungController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import pool from '../config/database';
|
import pool from '../config/database';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import notificationService from './notification.service';
|
||||||
|
import { permissionService } from './permission.service';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Vendors (Lieferanten)
|
// Vendors (Lieferanten)
|
||||||
@@ -318,20 +320,22 @@ async function deleteOrder(id: number, _userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VALID_STATUS_TRANSITIONS: Record<string, string[]> = {
|
const VALID_STATUS_TRANSITIONS: Record<string, string[]> = {
|
||||||
entwurf: ['erstellt', 'bestellt'],
|
entwurf: ['wartet_auf_genehmigung'],
|
||||||
erstellt: ['bestellt'],
|
wartet_auf_genehmigung: ['bereit_zur_bestellung', 'entwurf'],
|
||||||
bestellt: ['teillieferung', 'vollstaendig'],
|
bereit_zur_bestellung: ['bestellt'],
|
||||||
teillieferung: ['vollstaendig'],
|
bestellt: ['teillieferung', 'lieferung_pruefen'],
|
||||||
vollstaendig: ['abgeschlossen'],
|
teillieferung: ['lieferung_pruefen'],
|
||||||
abgeschlossen: [],
|
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 {
|
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;
|
if (current.rows.length === 0) return null;
|
||||||
|
|
||||||
const oldStatus = current.rows[0].status;
|
const order = current.rows[0];
|
||||||
|
const oldStatus = order.status;
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const allowed = VALID_STATUS_TRANSITIONS[oldStatus] || [];
|
const allowed = VALID_STATUS_TRANSITIONS[oldStatus] || [];
|
||||||
if (!allowed.includes(status)) {
|
if (!allowed.includes(status)) {
|
||||||
@@ -349,6 +353,12 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
|
|||||||
if (status === 'abgeschlossen') {
|
if (status === 'abgeschlossen') {
|
||||||
updates.push(`abgeschlossen_am = COALESCE(abgeschlossen_am, NOW())`);
|
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);
|
params.push(id);
|
||||||
const result = await pool.query(
|
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);
|
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];
|
return result.rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('BestellungService.updateOrderStatus failed', { error, id });
|
logger.error('BestellungService.updateOrderStatus failed', { error, id });
|
||||||
@@ -368,13 +425,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
|
|||||||
// Line Items (Bestellpositionen)
|
// 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 {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen)
|
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||||
RETURNING *`,
|
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);
|
await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId);
|
||||||
return result.rows[0];
|
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 {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE bestellpositionen
|
`UPDATE bestellpositionen
|
||||||
@@ -393,10 +450,11 @@ async function updateLineItem(id: number, data: { bezeichnung?: string; artikeln
|
|||||||
menge = COALESCE($3, menge),
|
menge = COALESCE($3, menge),
|
||||||
einheit = COALESCE($4, einheit),
|
einheit = COALESCE($4, einheit),
|
||||||
einzelpreis = COALESCE($5, einzelpreis),
|
einzelpreis = COALESCE($5, einzelpreis),
|
||||||
notizen = COALESCE($6, notizen)
|
notizen = COALESCE($6, notizen),
|
||||||
WHERE id = $7
|
spezifikationen = COALESCE($7::jsonb, spezifikationen)
|
||||||
|
WHERE id = $8
|
||||||
RETURNING *`,
|
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;
|
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) {
|
async function updateReceivedQuantity(id: number, menge: number, userId: string) {
|
||||||
try {
|
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(
|
const result = await pool.query(
|
||||||
`UPDATE bestellpositionen SET erhalten_menge = $1 WHERE id = $2 RETURNING *`,
|
`UPDATE bestellpositionen SET erhalten_menge = $1 WHERE id = $2 RETURNING *`,
|
||||||
[menge, id]
|
[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 (order.rows.length > 0 && (order.rows[0].status === 'bestellt' || order.rows[0].status === 'teillieferung')) {
|
||||||
if (allReceived) {
|
if (allReceived) {
|
||||||
await pool.query(
|
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]
|
[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') {
|
} else if (someReceived && order.rows[0].status === 'bestellt') {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE bestellungen SET status = 'teillieferung', aktualisiert_am = NOW() WHERE id = $1`,
|
`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;
|
return item;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('BestellungService.updateReceivedQuantity failed', { error, id });
|
logger.error('BestellungService.updateReceivedQuantity failed', { error, id });
|
||||||
throw new Error('Liefermenge konnte nicht aktualisiert werden');
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
|
|||||||
'Widget': ['widget'],
|
'Widget': ['widget'],
|
||||||
},
|
},
|
||||||
bestellungen: {
|
bestellungen: {
|
||||||
'Bestellungen': ['view', 'create', 'manage_orders', 'delete', 'export'],
|
'Bestellungen': ['view', 'create', 'approve', 'manage_orders', 'delete', 'export'],
|
||||||
'Lieferanten': ['manage_vendors'],
|
'Lieferanten': ['manage_vendors'],
|
||||||
'Erinnerungen': ['manage_reminders'],
|
'Erinnerungen': ['manage_reminders'],
|
||||||
'Widget': ['widget'],
|
'Widget': ['widget'],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -100,12 +100,13 @@ const formatFileSize = (bytes?: number) => {
|
|||||||
|
|
||||||
// Valid status transitions (must match backend VALID_STATUS_TRANSITIONS)
|
// Valid status transitions (must match backend VALID_STATUS_TRANSITIONS)
|
||||||
const STATUS_TRANSITIONS: Record<BestellungStatus, BestellungStatus[]> = {
|
const STATUS_TRANSITIONS: Record<BestellungStatus, BestellungStatus[]> = {
|
||||||
entwurf: ['erstellt', 'bestellt'],
|
entwurf: ['wartet_auf_genehmigung'],
|
||||||
erstellt: ['bestellt'],
|
wartet_auf_genehmigung: ['bereit_zur_bestellung', 'entwurf'],
|
||||||
bestellt: ['teillieferung', 'vollstaendig'],
|
bereit_zur_bestellung: ['bestellt'],
|
||||||
teillieferung: ['vollstaendig'],
|
bestellt: ['teillieferung', 'lieferung_pruefen'],
|
||||||
vollstaendig: ['abgeschlossen'],
|
teillieferung: ['lieferung_pruefen'],
|
||||||
abgeschlossen: [],
|
lieferung_pruefen: ['abgeschlossen'],
|
||||||
|
abgeschlossen: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Empty line item form
|
// Empty line item form
|
||||||
@@ -129,7 +130,6 @@ export default function BestellungDetail() {
|
|||||||
const [newItem, setNewItem] = useState<BestellpositionFormData>({ ...emptyItem });
|
const [newItem, setNewItem] = useState<BestellpositionFormData>({ ...emptyItem });
|
||||||
const [statusConfirmTarget, setStatusConfirmTarget] = useState<BestellungStatus | null>(null);
|
const [statusConfirmTarget, setStatusConfirmTarget] = useState<BestellungStatus | null>(null);
|
||||||
const [statusForce, setStatusForce] = useState(false);
|
const [statusForce, setStatusForce] = useState(false);
|
||||||
const [statusMenuAnchor, setStatusMenuAnchor] = useState<null | HTMLElement>(null);
|
|
||||||
const [overrideMenuAnchor, setOverrideMenuAnchor] = useState<null | HTMLElement>(null);
|
const [overrideMenuAnchor, setOverrideMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
const [deleteItemTarget, setDeleteItemTarget] = useState<number | null>(null);
|
const [deleteItemTarget, setDeleteItemTarget] = useState<number | null>(null);
|
||||||
const [deleteFileTarget, setDeleteFileTarget] = useState<number | null>(null);
|
const [deleteFileTarget, setDeleteFileTarget] = useState<number | null>(null);
|
||||||
@@ -150,6 +150,7 @@ export default function BestellungDetail() {
|
|||||||
menge: number;
|
menge: number;
|
||||||
einheit: string;
|
einheit: string;
|
||||||
einzelpreis?: number;
|
einzelpreis?: number;
|
||||||
|
spezifikationen: string[];
|
||||||
}>>({});
|
}>>({});
|
||||||
|
|
||||||
const [isSavingAll, setIsSavingAll] = useState(false);
|
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||||
@@ -187,11 +188,12 @@ export default function BestellungDetail() {
|
|||||||
const canDelete = hasPermission('bestellungen:delete');
|
const canDelete = hasPermission('bestellungen:delete');
|
||||||
const canManageReminders = hasPermission('bestellungen:manage_reminders');
|
const canManageReminders = hasPermission('bestellungen:manage_reminders');
|
||||||
const canManageOrders = hasPermission('bestellungen:manage_orders');
|
const canManageOrders = hasPermission('bestellungen:manage_orders');
|
||||||
|
const canApprove = hasPermission('bestellungen:approve');
|
||||||
const canExport = hasPermission('bestellungen:export');
|
const canExport = hasPermission('bestellungen:export');
|
||||||
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
|
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
|
||||||
|
|
||||||
// All statuses except current, for force override
|
// 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) : [];
|
const overrideStatuses = bestellung ? ALL_STATUSES.filter((s) => s !== bestellung.status) : [];
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
@@ -302,6 +304,7 @@ export default function BestellungDetail() {
|
|||||||
menge: parseFloat(String(p.menge)) || 1,
|
menge: parseFloat(String(p.menge)) || 1,
|
||||||
einheit: p.einheit,
|
einheit: p.einheit,
|
||||||
einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined,
|
einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined,
|
||||||
|
spezifikationen: p.spezifikationen || [],
|
||||||
}]))
|
}]))
|
||||||
);
|
);
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
@@ -326,7 +329,10 @@ export default function BestellungDetail() {
|
|||||||
for (const item of positionen) {
|
for (const item of positionen) {
|
||||||
const itemEdit = editItemsData[item.id];
|
const itemEdit = editItemsData[item.id];
|
||||||
if (itemEdit) {
|
if (itemEdit) {
|
||||||
await bestellungApi.updateLineItem(item.id, itemEdit);
|
await bestellungApi.updateLineItem(item.id, {
|
||||||
|
...itemEdit,
|
||||||
|
spezifikationen: itemEdit.spezifikationen,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
|
await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
|
||||||
@@ -455,18 +461,22 @@ export default function BestellungDetail() {
|
|||||||
const totalBrutto = totalNetto * (1 + steuersatz);
|
const totalBrutto = totalNetto * (1 + steuersatz);
|
||||||
|
|
||||||
if (hasPrices) {
|
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 ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined;
|
||||||
const menge = parseFloat(String(p.menge)) || 0;
|
const menge = parseFloat(String(p.menge)) || 0;
|
||||||
const gesamt = ep != null ? ep * menge : undefined;
|
const gesamt = ep != null ? ep * menge : undefined;
|
||||||
return [
|
rows.push([
|
||||||
p.bezeichnung,
|
p.bezeichnung,
|
||||||
p.artikelnummer || '',
|
p.artikelnummer || '',
|
||||||
`${menge} ${p.einheit}`,
|
`${menge} ${p.einheit}`,
|
||||||
ep != null ? formatCurrency(ep) : '–',
|
ep != null ? formatCurrency(ep) : '–',
|
||||||
gesamt != null ? formatCurrency(gesamt) : '–',
|
gesamt != null ? formatCurrency(gesamt) : '–',
|
||||||
];
|
]);
|
||||||
});
|
for (const spec of p.spezifikationen || []) {
|
||||||
|
rows.push([` • ${spec}`, '', '', '', '']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']],
|
head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']],
|
||||||
@@ -484,6 +494,16 @@ export default function BestellungDetail() {
|
|||||||
3: { cellWidth: 30, halign: 'right' },
|
3: { cellWidth: 30, halign: 'right' },
|
||||||
4: { 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: [
|
foot: [
|
||||||
['', '', '', 'Netto:', formatCurrency(totalNetto)],
|
['', '', '', 'Netto:', formatCurrency(totalNetto)],
|
||||||
['', '', '', `Brutto (${bestellung.steuersatz ?? 20}% USt.):`, formatCurrency(totalBrutto)],
|
['', '', '', `Brutto (${bestellung.steuersatz ?? 20}% USt.):`, formatCurrency(totalBrutto)],
|
||||||
@@ -492,10 +512,14 @@ export default function BestellungDetail() {
|
|||||||
didDrawPage: addPdfFooter(doc, settings),
|
didDrawPage: addPdfFooter(doc, settings),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const rows = positionen.map((p) => {
|
const rows: string[][] = [];
|
||||||
|
for (const p of positionen) {
|
||||||
const menge = parseFloat(String(p.menge)) || 0;
|
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, {
|
autoTable(doc, {
|
||||||
head: [['Bezeichnung', 'Art.-Nr.', 'Menge']],
|
head: [['Bezeichnung', 'Art.-Nr.', 'Menge']],
|
||||||
@@ -511,6 +535,16 @@ export default function BestellungDetail() {
|
|||||||
1: { cellWidth: 40 },
|
1: { cellWidth: 40 },
|
||||||
2: { cellWidth: 33, halign: 'right' },
|
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),
|
didDrawPage: addPdfFooter(doc, settings),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -581,10 +615,16 @@ export default function BestellungDetail() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography>
|
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography>
|
||||||
{canExport && !editMode && (
|
{canExport && !editMode && (
|
||||||
<Tooltip title="PDF Export">
|
<Tooltip title={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung' ? 'Export erst nach Genehmigung verfügbar' : 'PDF Export'}>
|
||||||
<IconButton onClick={generateBestellungDetailPdf} color="primary">
|
<span>
|
||||||
<PdfIcon />
|
<IconButton
|
||||||
</IconButton>
|
onClick={generateBestellungDetailPdf}
|
||||||
|
color="primary"
|
||||||
|
disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'}
|
||||||
|
>
|
||||||
|
<PdfIcon />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{canCreate && !editMode && (
|
{canCreate && !editMode && (
|
||||||
@@ -662,41 +702,39 @@ export default function BestellungDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Status Action ── */}
|
{/* ── Status Action ── */}
|
||||||
{canManageOrders && (
|
{(canManageOrders || canCreate || canApprove) && (
|
||||||
<Box sx={{ mb: 3, display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box sx={{ mb: 3, display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
{validTransitions.length === 1 ? (
|
{validTransitions
|
||||||
<Button variant="contained" onClick={() => { setStatusForce(false); setStatusConfirmTarget(validTransitions[0]); }}>
|
.filter((s) => {
|
||||||
Status ändern: {BESTELLUNG_STATUS_LABELS[validTransitions[0]]}
|
// Approve/reject transitions from wartet_auf_genehmigung require canApprove
|
||||||
</Button>
|
if (bestellung.status === 'wartet_auf_genehmigung') {
|
||||||
) : validTransitions.length > 1 ? (
|
return canApprove;
|
||||||
<>
|
}
|
||||||
<Button
|
// Transition to bereit_zur_bestellung from other states also requires canApprove
|
||||||
variant="contained"
|
if (s === 'bereit_zur_bestellung') return canApprove;
|
||||||
endIcon={<ArrowDropDown />}
|
// All other transitions require canCreate or canManageOrders
|
||||||
onClick={(e) => setStatusMenuAnchor(e.currentTarget)}
|
return canCreate || canManageOrders;
|
||||||
>
|
})
|
||||||
Status ändern
|
.map((s) => {
|
||||||
</Button>
|
const isApprove = bestellung.status === 'wartet_auf_genehmigung' && s === 'bereit_zur_bestellung';
|
||||||
<Menu
|
const isReject = bestellung.status === 'wartet_auf_genehmigung' && s === 'entwurf';
|
||||||
anchorEl={statusMenuAnchor}
|
const label = isApprove
|
||||||
open={Boolean(statusMenuAnchor)}
|
? 'Genehmigen'
|
||||||
onClose={() => setStatusMenuAnchor(null)}
|
: isReject
|
||||||
>
|
? 'Ablehnen'
|
||||||
{validTransitions.map((s) => (
|
: `Status: ${BESTELLUNG_STATUS_LABELS[s]}`;
|
||||||
<MenuItem
|
const color = isApprove ? 'success' : isReject ? 'error' : 'primary';
|
||||||
key={s}
|
return (
|
||||||
onClick={() => {
|
<Button
|
||||||
setStatusMenuAnchor(null);
|
key={s}
|
||||||
setStatusForce(false);
|
variant="contained"
|
||||||
setStatusConfirmTarget(s);
|
color={color as 'success' | 'error' | 'primary'}
|
||||||
}}
|
onClick={() => { setStatusForce(false); setStatusConfirmTarget(s); }}
|
||||||
>
|
>
|
||||||
{BESTELLUNG_STATUS_LABELS[s]}
|
{label}
|
||||||
</MenuItem>
|
</Button>
|
||||||
))}
|
);
|
||||||
</Menu>
|
})}
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Manual override menu */}
|
{/* Manual override menu */}
|
||||||
{overrideStatuses.length > 0 && canManageOrders && (
|
{overrideStatuses.length > 0 && canManageOrders && (
|
||||||
@@ -771,29 +809,30 @@ export default function BestellungDetail() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{positionen.map((p) =>
|
{positionen.map((p) =>
|
||||||
editMode ? (
|
editMode ? (
|
||||||
<TableRow key={p.id}>
|
<React.Fragment key={p.id}>
|
||||||
|
<TableRow key={`${p.id}-row`}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TextField size="small" value={editItemsData[p.id]?.bezeichnung ?? p.bezeichnung}
|
<TextField size="small" value={editItemsData[p.id]?.bezeichnung ?? p.bezeichnung}
|
||||||
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 }), 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 } }))} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TextField size="small" value={editItemsData[p.id]?.artikelnummer ?? p.artikelnummer ?? ''}
|
<TextField size="small" value={editItemsData[p.id]?.artikelnummer ?? p.artikelnummer ?? ''}
|
||||||
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 }), 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 } }))} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TextField size="small" type="number" sx={{ width: 80 }}
|
<TextField size="small" type="number" sx={{ width: 80 }}
|
||||||
value={editItemsData[p.id]?.menge ?? p.menge}
|
value={editItemsData[p.id]?.menge ?? p.menge}
|
||||||
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 }), 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) } }))} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TextField size="small" sx={{ width: 80 }}
|
<TextField size="small" sx={{ width: 80 }}
|
||||||
value={editItemsData[p.id]?.einheit ?? p.einheit}
|
value={editItemsData[p.id]?.einheit ?? p.einheit}
|
||||||
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 }), 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 } }))} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TextField size="small" type="number" sx={{ width: 100 }}
|
<TextField size="small" type="number" sx={{ width: 100 }}
|
||||||
value={editItemsData[p.id]?.einzelpreis ?? p.einzelpreis ?? ''}
|
value={editItemsData[p.id]?.einzelpreis ?? p.einzelpreis ?? ''}
|
||||||
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 }), 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 } }))} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{formatCurrency((editItemsData[p.id]?.einzelpreis ?? parseFloat(String(p.einzelpreis ?? 0))) * (editItemsData[p.id]?.menge ?? parseFloat(String(p.menge))))}
|
{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 ? (
|
{canManageOrders ? (
|
||||||
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
|
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
|
||||||
inputProps={{ min: 0, max: p.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) })} />
|
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} />
|
||||||
) : p.erhalten_menge}
|
) : p.erhalten_menge}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -815,9 +855,61 @@ export default function BestellungDetail() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
{/* Specifications editor row */}
|
||||||
|
<TableRow key={`${p.id}-specs`}>
|
||||||
|
<TableCell colSpan={8} sx={{ pt: 0, pb: 1, pl: 4 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{(editItemsData[p.id]?.spezifikationen || []).map((spec, specIdx) => (
|
||||||
|
<Box key={specIdx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={spec}
|
||||||
|
placeholder="Spezifikation"
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
onChange={(e) => 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 } };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<IconButton size="small" color="error" onClick={() => 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 } };
|
||||||
|
})}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setEditItemsData(d => {
|
||||||
|
const cur = d[p.id]?.spezifikationen ? [...d[p.id].spezifikationen] : [];
|
||||||
|
return { ...d, [p.id]: { ...d[p.id], spezifikationen: [...cur, ''] } };
|
||||||
|
})}
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
Spezifikation hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</React.Fragment>
|
||||||
) : (
|
) : (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id}>
|
||||||
<TableCell>{p.bezeichnung}</TableCell>
|
<TableCell>
|
||||||
|
<Box>
|
||||||
|
{p.bezeichnung}
|
||||||
|
{p.spezifikationen && p.spezifikationen.length > 0 && (
|
||||||
|
<Box sx={{ pl: 2, mt: 0.5 }}>
|
||||||
|
{p.spezifikationen.map((spec, i) => (
|
||||||
|
<Typography key={i} variant="caption" color="text.secondary" display="block">• {spec}</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
<TableCell>{p.artikelnummer || '–'}</TableCell>
|
<TableCell>{p.artikelnummer || '–'}</TableCell>
|
||||||
<TableCell align="right">{p.menge}</TableCell>
|
<TableCell align="right">{p.menge}</TableCell>
|
||||||
<TableCell>{p.einheit}</TableCell>
|
<TableCell>{p.einheit}</TableCell>
|
||||||
@@ -831,6 +923,7 @@ export default function BestellungDetail() {
|
|||||||
sx={{ width: 70 }}
|
sx={{ width: 70 }}
|
||||||
value={p.erhalten_menge}
|
value={p.erhalten_menge}
|
||||||
inputProps={{ min: 0, max: p.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) })}
|
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const TAB_COUNT = 2;
|
|||||||
|
|
||||||
// ── Status options ──
|
// ── 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'];
|
const DEFAULT_EXCLUDED_STATUSES: BestellungStatus[] = ['abgeschlossen'];
|
||||||
|
|
||||||
// ── Kennung formatter ──
|
// ── Kennung formatter ──
|
||||||
@@ -262,10 +262,10 @@ export default function Bestellungen() {
|
|||||||
{/* ── Summary Cards ── */}
|
{/* ── Summary Cards ── */}
|
||||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Noch nicht bestellt', count: orders.filter(o => o.status === 'entwurf' || o.status === 'erstellt').length, color: 'text.secondary' },
|
{ label: 'Entwurf / Genehmigung', count: orders.filter(o => o.status === 'entwurf' || o.status === 'wartet_auf_genehmigung').length, color: 'text.secondary' },
|
||||||
{ label: 'Bestellt', count: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' },
|
{ 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: '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' },
|
{ label: 'Gesamt', count: orders.length, color: 'text.primary' },
|
||||||
].map(({ label, count, color }) => (
|
].map(({ label, count, color }) => (
|
||||||
<Grid item xs={6} sm={4} md={2} key={label}>
|
<Grid item xs={6} sm={4} md={2} key={label}>
|
||||||
|
|||||||
@@ -1732,12 +1732,36 @@ export default function Kalender() {
|
|||||||
setCalLoading(true);
|
setCalLoading(true);
|
||||||
setCalError(null);
|
setCalError(null);
|
||||||
try {
|
try {
|
||||||
const firstDay = new Date(viewMonth.year, viewMonth.month, 1);
|
let gridStart: Date;
|
||||||
const dayOfWeek = (firstDay.getDay() + 6) % 7;
|
let gridEnd: Date;
|
||||||
const gridStart = new Date(firstDay);
|
|
||||||
gridStart.setDate(gridStart.getDate() - dayOfWeek);
|
if (viewMode === 'day') {
|
||||||
const gridEnd = new Date(gridStart);
|
// Fetch the full month containing currentDate (plus padding)
|
||||||
gridEnd.setDate(gridStart.getDate() + 41);
|
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([
|
const [trainData, eventData] = await Promise.all([
|
||||||
trainingApi.getCalendarRange(gridStart, gridEnd),
|
trainingApi.getCalendarRange(gridStart, gridEnd),
|
||||||
@@ -1750,7 +1774,7 @@ export default function Kalender() {
|
|||||||
} finally {
|
} finally {
|
||||||
setCalLoading(false);
|
setCalLoading(false);
|
||||||
}
|
}
|
||||||
}, [viewMonth]);
|
}, [viewMonth, viewMode, currentDate, listFrom, listTo]);
|
||||||
|
|
||||||
// Load kategorien + groups once
|
// Load kategorien + groups once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -28,23 +28,25 @@ export interface LieferantFormData {
|
|||||||
|
|
||||||
// ── Orders ──
|
// ── 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<BestellungStatus, string> = {
|
export const BESTELLUNG_STATUS_LABELS: Record<BestellungStatus, string> = {
|
||||||
entwurf: 'Entwurf',
|
entwurf: 'Entwurf',
|
||||||
erstellt: 'Erstellt',
|
wartet_auf_genehmigung: 'Wartet auf Genehmigung',
|
||||||
|
bereit_zur_bestellung: 'Bereit zur Bestellung',
|
||||||
bestellt: 'Bestellt',
|
bestellt: 'Bestellt',
|
||||||
teillieferung: 'Teillieferung',
|
teillieferung: 'Teillieferung',
|
||||||
vollstaendig: 'Vollständig',
|
lieferung_pruefen: 'Lieferung prüfen',
|
||||||
abgeschlossen: 'Abgeschlossen',
|
abgeschlossen: 'Abgeschlossen',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BESTELLUNG_STATUS_COLORS: Record<BestellungStatus, 'default' | 'info' | 'primary' | 'warning' | 'success'> = {
|
export const BESTELLUNG_STATUS_COLORS: Record<BestellungStatus, 'default' | 'info' | 'primary' | 'warning' | 'success' | 'secondary' | 'error'> = {
|
||||||
entwurf: 'default',
|
entwurf: 'default',
|
||||||
erstellt: 'info',
|
wartet_auf_genehmigung: 'warning',
|
||||||
|
bereit_zur_bestellung: 'info',
|
||||||
bestellt: 'primary',
|
bestellt: 'primary',
|
||||||
teillieferung: 'warning',
|
teillieferung: 'warning',
|
||||||
vollstaendig: 'success',
|
lieferung_pruefen: 'secondary',
|
||||||
abgeschlossen: 'success',
|
abgeschlossen: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +72,9 @@ export interface Bestellung {
|
|||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
bestellt_am?: string;
|
bestellt_am?: string;
|
||||||
abgeschlossen_am?: string;
|
abgeschlossen_am?: string;
|
||||||
|
genehmigt_von?: string;
|
||||||
|
genehmigt_am?: string;
|
||||||
|
genehmigt_von_name?: string;
|
||||||
// Computed
|
// Computed
|
||||||
total_cost?: number;
|
total_cost?: number;
|
||||||
items_count?: number;
|
items_count?: number;
|
||||||
@@ -102,6 +107,7 @@ export interface Bestellposition {
|
|||||||
notizen?: string;
|
notizen?: string;
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
|
spezifikationen?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BestellpositionFormData {
|
export interface BestellpositionFormData {
|
||||||
@@ -111,6 +117,7 @@ export interface BestellpositionFormData {
|
|||||||
einheit?: string;
|
einheit?: string;
|
||||||
einzelpreis?: number;
|
einzelpreis?: number;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
|
spezifikationen?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File Attachments ──
|
// ── File Attachments ──
|
||||||
|
|||||||
Reference in New Issue
Block a user