From 5add6590e55418896043c6f4cd08993840a865b0 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Wed, 25 Mar 2026 14:26:41 +0100 Subject: [PATCH] refactor external orders --- .../src/controllers/bestellung.controller.ts | 34 ++ .../migrations/061_add_laufende_nummer.sql | 14 + backend/src/routes/bestellung.routes.ts | 14 + backend/src/services/bestellung.service.ts | 23 +- frontend/src/App.tsx | 17 + frontend/src/pages/BestellungDetail.tsx | 115 +++-- frontend/src/pages/BestellungNeu.tsx | 11 +- frontend/src/pages/Bestellungen.tsx | 462 ++++++++++-------- frontend/src/pages/LieferantDetail.tsx | 306 ++++++++++++ frontend/src/types/bestellung.types.ts | 3 + 10 files changed, 740 insertions(+), 259 deletions(-) create mode 100644 backend/src/database/migrations/061_add_laufende_nummer.sql create mode 100644 frontend/src/pages/LieferantDetail.tsx diff --git a/backend/src/controllers/bestellung.controller.ts b/backend/src/controllers/bestellung.controller.ts index cf83dee..285c0f7 100644 --- a/backend/src/controllers/bestellung.controller.ts +++ b/backend/src/controllers/bestellung.controller.ts @@ -21,6 +21,40 @@ class BestellungController { } } + async getVendor(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const vendor = await bestellungService.getVendorById(id); + if (!vendor) { + res.status(404).json({ success: false, message: 'Lieferant nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: vendor }); + } catch (error) { + logger.error('BestellungController.getVendor error', { error }); + res.status(500).json({ success: false, message: 'Lieferant konnte nicht geladen werden' }); + } + } + + async getVendorOrders(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const orders = await bestellungService.getOrders({ lieferant_id: id }); + res.status(200).json({ success: true, data: orders }); + } catch (error) { + logger.error('BestellungController.getVendorOrders error', { error }); + res.status(500).json({ success: false, message: 'Bestellungen konnten nicht geladen werden' }); + } + } + async createVendor(req: Request, res: Response): Promise { const { name } = req.body; if (!name || typeof name !== 'string' || name.trim().length === 0) { diff --git a/backend/src/database/migrations/061_add_laufende_nummer.sql b/backend/src/database/migrations/061_add_laufende_nummer.sql new file mode 100644 index 0000000..39d2ae7 --- /dev/null +++ b/backend/src/database/migrations/061_add_laufende_nummer.sql @@ -0,0 +1,14 @@ +-- Add laufende_nummer (sequential number per year) to bestellungen +ALTER TABLE bestellungen ADD COLUMN laufende_nummer INTEGER; + +-- Backfill existing rows with sequential numbers per year +WITH numbered AS ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY EXTRACT(YEAR FROM erstellt_am) ORDER BY erstellt_am, id + ) AS nr + FROM bestellungen +) +UPDATE bestellungen b SET laufende_nummer = n.nr FROM numbered n WHERE b.id = n.id; + +-- Make NOT NULL after backfill +ALTER TABLE bestellungen ALTER COLUMN laufende_nummer SET NOT NULL; diff --git a/backend/src/routes/bestellung.routes.ts b/backend/src/routes/bestellung.routes.ts index 4f165cd..daa3424 100644 --- a/backend/src/routes/bestellung.routes.ts +++ b/backend/src/routes/bestellung.routes.ts @@ -17,6 +17,20 @@ router.get( bestellungController.listVendors.bind(bestellungController) ); +router.get( + '/vendors/:id', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.getVendor.bind(bestellungController) +); + +router.get( + '/vendors/:id/orders', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.getVendorOrders.bind(bestellungController) +); + router.post( '/vendors', authenticate, diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index 1ef6fea..472b5fb 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -119,13 +119,17 @@ async function getOrders(filters?: { status?: string; lieferant_id?: number; bes l.name AS lieferant_name, COALESCE(u.name, u.preferred_username, u.email) AS besteller_name, COALESCE(pos.total_cost, 0) AS total_cost, - COALESCE(pos.items_count, 0) AS items_count + COALESCE(pos.items_count, 0) AS items_count, + COALESCE(pos.total_received, 0) AS total_received, + COALESCE(pos.total_ordered, 0) AS total_ordered FROM bestellungen b LEFT JOIN lieferanten l ON l.id = b.lieferant_id LEFT JOIN users u ON u.id = b.erstellt_von LEFT JOIN LATERAL ( SELECT SUM(einzelpreis * menge) AS total_cost, - COUNT(*) AS items_count + COUNT(*) AS items_count, + SUM(erhalten_menge) AS total_received, + SUM(menge) AS total_ordered FROM bestellpositionen WHERE bestellung_id = b.id ) pos ON true @@ -178,12 +182,21 @@ async function createOrder(data: { bezeichnung: string; lieferant_id?: number; b const client = await pool.connect(); try { await client.query('BEGIN'); + + // Get next laufende_nummer for the current year + const nrResult = await client.query( + `SELECT COALESCE(MAX(laufende_nummer), 0) + 1 AS next_nr + FROM bestellungen + WHERE EXTRACT(YEAR FROM erstellt_am) = EXTRACT(YEAR FROM NOW())` + ); + const laufendeNummer = nrResult.rows[0].next_nr; + 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, steuersatz, erstellt_von) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, steuersatz, laufende_nummer, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, - [data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, data.steuersatz ?? 20, userId] + [data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, data.steuersatz ?? 20, laufendeNummer, userId] ); const order = result.rows[0]; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f079054..eafd822 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import Wissen from './pages/Wissen'; import Bestellungen from './pages/Bestellungen'; import BestellungDetail from './pages/BestellungDetail'; import BestellungNeu from './pages/BestellungNeu'; +import LieferantDetail from './pages/LieferantDetail'; import Ausruestungsanfrage from './pages/Ausruestungsanfrage'; import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail'; import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; @@ -242,6 +243,22 @@ function App() { } /> + + + + } + /> + + + + } + /> (null); const [deleteFileTarget, setDeleteFileTarget] = useState(null); + const [editMode, setEditMode] = useState(false); + const [reminderForm, setReminderForm] = useState({ faellig_am: '', nachricht: '' }); const [reminderFormOpen, setReminderFormOpen] = useState(false); const [deleteReminderTarget, setDeleteReminderTarget] = useState(null); @@ -124,6 +130,7 @@ export default function BestellungDetail() { const canCreate = hasPermission('bestellungen:create'); const canDelete = hasPermission('bestellungen:delete'); const canManageReminders = hasPermission('bestellungen:manage_reminders'); + const canManageOrders = hasPermission('bestellungen:manage_orders'); const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : []; // All statuses except current, for force override @@ -322,25 +329,19 @@ export default function BestellungDetail() { {/* ── Info Cards ── */} - + Lieferant {bestellung.lieferant_name || '–'} - + Besteller {bestellung.besteller_name || '–'} - - - Budget - {formatCurrency(bestellung.budget)} - - - + Erstellt am {formatDate(bestellung.erstellt_am)} @@ -349,7 +350,7 @@ export default function BestellungDetail() { {/* ── Status Action ── */} - {canCreate && ( + {canManageOrders && ( {validTransitions.length === 1 ? ( + )} + @@ -457,7 +470,7 @@ export default function BestellungDetail() { {positionen.map((p) => - editingItemId === p.id ? ( + editMode && editingItemId === p.id ? ( setEditingItemData((d) => ({ ...d, bezeichnung: e.target.value }))} /> @@ -490,7 +503,7 @@ export default function BestellungDetail() { {formatCurrency(p.einzelpreis)} {formatCurrency((p.einzelpreis ?? 0) * p.menge)} - {canCreate ? ( + {canManageOrders ? ( {(canCreate || canDelete) && ( - {canCreate && startEditItem(p)}>} - {canDelete && setDeleteItemTarget(p.id)}>} + {editMode && canCreate && startEditItem(p)}>} + {editMode && canDelete && setDeleteItemTarget(p.id)}>} )} @@ -514,7 +527,7 @@ export default function BestellungDetail() { )} {/* ── Add Item Row ── */} - {canCreate && ( + {editMode && canCreate && ( setNewItem((f) => ({ ...f, bezeichnung: e.target.value }))} /> @@ -553,7 +566,7 @@ export default function BestellungDetail() { MwSt. - {canCreate ? ( + {editMode && canCreate ? ( - {/* ══════════════════════════════════════════════════════════════════════ */} - {/* Historie */} - {/* ══════════════════════════════════════════════════════════════════════ */} - - - - Historie - - {historie.length === 0 ? ( - Keine Einträge - ) : ( - - {historie.map((h) => ( - - - - {h.aktion} - - {h.erstellt_von_name || 'System'} · {formatDateTime(h.erstellt_am)} - - {h.details && ( - - {Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')} - - )} - - - ))} - - )} - - {/* ── Notizen ── */} {bestellung.notizen && ( @@ -755,6 +736,42 @@ export default function BestellungDetail() { )} + {/* ══════════════════════════════════════════════════════════════════════ */} + {/* Historie */} + {/* ══════════════════════════════════════════════════════════════════════ */} + + }> + + + Historie ({historie.length} Einträge) + + + + {historie.length === 0 ? ( + Keine Einträge + ) : ( + + {historie.map((h) => ( + + + + {h.aktion} + + {h.erstellt_von_name || 'System'} · {formatDateTime(h.erstellt_am)} + + {h.details && ( + + {Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')} + + )} + + + ))} + + )} + + + {/* ══════════════════════════════════════════════════════════════════════ */} {/* Dialogs */} {/* ══════════════════════════════════════════════════════════════════════ */} diff --git a/frontend/src/pages/BestellungNeu.tsx b/frontend/src/pages/BestellungNeu.tsx index 377fbd4..6c62a9a 100644 --- a/frontend/src/pages/BestellungNeu.tsx +++ b/frontend/src/pages/BestellungNeu.tsx @@ -21,7 +21,7 @@ import { useNotification } from '../contexts/NotificationContext'; import { bestellungApi } from '../services/bestellung'; import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types'; -const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', budget: undefined, notizen: '', positionen: [] }; +const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', notizen: '', positionen: [] }; const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' }; const emptyPosition: BestellpositionFormData = { bezeichnung: '', menge: 1, einheit: 'Stk' }; @@ -135,15 +135,6 @@ export default function BestellungNeu() { renderInput={(params) => } /> - setOrderForm((f) => ({ ...f, budget: e.target.value ? Number(e.target.value) : undefined }))} - inputProps={{ min: 0, step: 0.01 }} - /> - = 0 && t < TAB_COUNT) setTab(t); }, [searchParams]); - // ── State ── - const [statusFilter, setStatusFilter] = useState(''); + // ── Filter state ── + const [filterAnchor, setFilterAnchor] = useState(null); - const [vendorDialogOpen, setVendorDialogOpen] = useState(false); - const [vendorForm, setVendorForm] = useState({ ...emptyVendorForm }); - const [editingVendor, setEditingVendor] = useState(null); - - const [deleteVendorTarget, setDeleteVendorTarget] = useState(null); + const [selectedVendors, setSelectedVendors] = useState | null>(null); // null = all + const [selectedOrderers, setSelectedOrderers] = useState | null>(null); + const [selectedStatuses, setSelectedStatuses] = useState>( + () => new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s))) + ); // ── Queries ── const { data: orders = [], isLoading: ordersLoading } = useQuery({ - queryKey: ['bestellungen', statusFilter], - queryFn: () => bestellungApi.getOrders(statusFilter ? { status: statusFilter } : undefined), + queryKey: ['bestellungen'], + queryFn: () => bestellungApi.getOrders(), }); const { data: vendors = [], isLoading: vendorsLoading } = useQuery({ @@ -103,58 +109,105 @@ export default function Bestellungen() { queryFn: bestellungApi.getVendors, }); - // ── Mutations ── - const createVendor = useMutation({ - mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); - showSuccess('Lieferant erstellt'); - closeVendorDialog(); - }, - onError: () => showError('Fehler beim Erstellen des Lieferanten'), - }); + // ── Derive unique filter values from data ── + const uniqueVendors = useMemo(() => { + const map = new Map(); + orders.forEach((o) => { + if (o.lieferant_name) map.set(String(o.lieferant_id ?? o.lieferant_name), o.lieferant_name); + }); + return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1])); + }, [orders]); - const updateVendor = useMutation({ - mutationFn: ({ id, data }: { id: number; data: LieferantFormData }) => bestellungApi.updateVendor(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); - showSuccess('Lieferant aktualisiert'); - closeVendorDialog(); - }, - onError: () => showError('Fehler beim Aktualisieren des Lieferanten'), - }); + const uniqueOrderers = useMemo(() => { + const map = new Map(); + orders.forEach((o) => { + if (o.besteller_name) map.set(o.besteller_id ?? o.besteller_name, o.besteller_name); + }); + return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1])); + }, [orders]); - const deleteVendor = useMutation({ - mutationFn: (id: number) => bestellungApi.deleteVendor(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); - showSuccess('Lieferant gelöscht'); - setDeleteVendorTarget(null); - }, - onError: () => showError('Fehler beim Löschen des Lieferanten'), - }); + // ── Filtered orders ── + const filteredOrders = useMemo(() => { + return orders.filter((o) => { + // Status filter + if (!selectedStatuses.has(o.status)) return false; + // Vendor filter (null = all selected) + if (selectedVendors !== null) { + const key = String(o.lieferant_id ?? o.lieferant_name ?? ''); + if (!selectedVendors.has(key)) return false; + } + // Orderer filter (null = all selected) + if (selectedOrderers !== null) { + const key = o.besteller_id ?? o.besteller_name ?? ''; + if (!selectedOrderers.has(key)) return false; + } + return true; + }); + }, [orders, selectedStatuses, selectedVendors, selectedOrderers]); - // ── Dialog helpers ── + // ── Active filter count ── + const activeFilterCount = useMemo(() => { + let count = 0; + if (selectedStatuses.size !== ALL_STATUSES.length - DEFAULT_EXCLUDED_STATUSES.length) count++; + if (selectedVendors !== null) count++; + if (selectedOrderers !== null) count++; + return count; + }, [selectedStatuses, selectedVendors, selectedOrderers]); - function openEditVendor(v: Lieferant) { - setEditingVendor(v); - setVendorForm({ name: v.name, kontakt_name: v.kontakt_name || '', email: v.email || '', telefon: v.telefon || '', adresse: v.adresse || '', website: v.website || '', notizen: v.notizen || '' }); - setVendorDialogOpen(true); + // ── Filter handlers ── + function resetFilters() { + setSelectedVendors(null); + setSelectedOrderers(null); + setSelectedStatuses(new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s)))); } - function closeVendorDialog() { - setVendorDialogOpen(false); - setEditingVendor(null); - setVendorForm({ ...emptyVendorForm }); + function toggleStatus(s: BestellungStatus) { + setSelectedStatuses((prev) => { + const next = new Set(prev); + if (next.has(s)) next.delete(s); + else next.add(s); + return next; + }); } - function handleVendorSave() { - if (!vendorForm.name.trim()) return; - if (editingVendor) { - updateVendor.mutate({ id: editingVendor.id, data: vendorForm }); - } else { - createVendor.mutate(vendorForm); - } + function toggleVendor(key: string) { + setSelectedVendors((prev) => { + if (prev === null) { + // was "all selected" → deselect this one + const allKeys = new Set(uniqueVendors.map(([k]) => k)); + allKeys.delete(key); + return allKeys; + } + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + // if all are selected again, go back to null + if (next.size === uniqueVendors.length) return null; + return next; + }); + } + + function toggleOrderer(key: string) { + setSelectedOrderers((prev) => { + if (prev === null) { + const allKeys = new Set(uniqueOrderers.map(([k]) => k)); + allKeys.delete(key); + return allKeys; + } + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + if (next.size === uniqueOrderers.length) return null; + return next; + }); + } + + function isVendorSelected(key: string) { + return selectedVendors === null || selectedVendors.has(key); + } + + function isOrdererSelected(key: string) { + return selectedOrderers === null || selectedOrderers.has(key); } // ── Render ── @@ -173,62 +226,153 @@ export default function Bestellungen() { {/* ── Tab 0: Orders ── */} - - Status Filter - - + Filter + + + {activeFilterCount > 0 && ( + + )} + + {filteredOrders.length} von {orders.length} Bestellungen + + {/* Filter Popover */} + setFilterAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + slotProps={{ paper: { sx: { p: 2, maxWidth: 480, maxHeight: '70vh', overflow: 'auto' } } }} + > + + {/* Status */} + + Status + + {ALL_STATUSES.map((s) => ( + toggleStatus(s)} />} + label={BESTELLUNG_STATUS_LABELS[s]} + /> + ))} + + + + + {/* Vendor */} + {uniqueVendors.length > 0 && ( + + Lieferant + + {uniqueVendors.map(([key, label]) => ( + toggleVendor(key)} />} + label={label} + /> + ))} + + + )} + {uniqueVendors.length > 0 && } + + {/* Orderer */} + {uniqueOrderers.length > 0 && ( + + Besteller + + {uniqueOrderers.map(([key, label]) => ( + toggleOrderer(key)} />} + label={label} + /> + ))} + + + )} + + + + + +
+ Kennung Bezeichnung Lieferant Besteller Status Positionen - Gesamtpreis + Gesamtpreis (brutto) + Lieferung Erstellt am {ordersLoading ? ( - Laden... - ) : orders.length === 0 ? ( - Keine Bestellungen vorhanden + Laden... + ) : filteredOrders.length === 0 ? ( + Keine Bestellungen vorhanden ) : ( - orders.map((o) => ( - navigate(`/bestellungen/${o.id}`)} - > - {o.bezeichnung} - {o.lieferant_name || '–'} - {o.besteller_name || '–'} - - - - {o.items_count ?? 0} - {formatCurrency(o.total_cost)} - {formatDate(o.erstellt_am)} - - )) + filteredOrders.map((o) => { + const brutto = calcBrutto(o); + const totalOrdered = o.total_ordered ?? 0; + const totalReceived = o.total_received ?? 0; + const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0; + return ( + navigate(`/bestellungen/${o.id}`)} + > + + {formatKennung(o)} + + {o.bezeichnung} + {o.lieferant_name || '–'} + {o.besteller_name || '–'} + + + + {o.items_count ?? 0} + {formatCurrency(brutto)} + + {totalOrdered > 0 ? ( + + + + {totalReceived}/{totalOrdered} + + + ) : '–'} + + {formatDate(o.erstellt_am)} + + ); + }) )}
@@ -252,34 +396,30 @@ export default function Bestellungen() { E-Mail Telefon Website - Aktionen {vendorsLoading ? ( - Laden... + Laden... ) : vendors.length === 0 ? ( - Keine Lieferanten vorhanden + Keine Lieferanten vorhanden ) : ( vendors.map((v) => ( - + navigate(`/bestellungen/lieferanten/${v.id}`)} + > {v.name} {v.kontakt_name || '–'} - {v.email ? {v.email} : '–'} + {v.email ? e.stopPropagation()}>{v.email} : '–'} {v.telefon || '–'} {v.website ? ( - {v.website} + e.stopPropagation()}>{v.website} ) : '–'} - - - openEditVendor(v)}> - - - setDeleteVendorTarget(v)}> - - )) )} @@ -288,79 +428,11 @@ export default function Bestellungen() {
{hasPermission('bestellungen:manage_vendors') && ( - setVendorDialogOpen(true)} aria-label="Lieferant hinzufügen"> + navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen"> )} - - {/* ── Create/Edit Vendor Dialog ── */} - - {editingVendor ? 'Lieferant bearbeiten' : 'Neuer Lieferant'} - - setVendorForm((f) => ({ ...f, name: e.target.value }))} - /> - setVendorForm((f) => ({ ...f, kontakt_name: e.target.value }))} - /> - setVendorForm((f) => ({ ...f, email: e.target.value }))} - /> - setVendorForm((f) => ({ ...f, telefon: e.target.value }))} - /> - setVendorForm((f) => ({ ...f, adresse: e.target.value }))} - /> - setVendorForm((f) => ({ ...f, website: e.target.value }))} - /> - setVendorForm((f) => ({ ...f, notizen: e.target.value }))} - /> - - - - - - - - {/* ── Delete Vendor Confirm ── */} - setDeleteVendorTarget(null)}> - Lieferant löschen - - - Soll der Lieferant {deleteVendorTarget?.name} wirklich gelöscht werden? - - - - - - - ); } diff --git a/frontend/src/pages/LieferantDetail.tsx b/frontend/src/pages/LieferantDetail.tsx new file mode 100644 index 0000000..354be79 --- /dev/null +++ b/frontend/src/pages/LieferantDetail.tsx @@ -0,0 +1,306 @@ +import { useState, useEffect } from 'react'; +import { + Box, + Typography, + Paper, + Button, + TextField, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Grid, + Card, + CardContent, + Skeleton, +} from '@mui/material'; +import { ArrowBack, Edit as EditIcon, Delete as DeleteIcon, Save as SaveIcon, Close as CloseIcon } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { bestellungApi } from '../services/bestellung'; +import type { LieferantFormData } from '../types/bestellung.types'; + +const emptyForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' }; + +export default function LieferantDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + + const isNew = id === 'neu'; + const vendorId = isNew ? 0 : Number(id); + const canManage = hasPermission('bestellungen:manage_vendors'); + + const [editMode, setEditMode] = useState(isNew); + const [form, setForm] = useState({ ...emptyForm }); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + // ── Query ── + const { data: vendor, isLoading, isError } = useQuery({ + queryKey: ['lieferant', vendorId], + queryFn: () => bestellungApi.getVendor(vendorId), + enabled: !isNew && !!vendorId, + }); + + // Sync form with loaded vendor data + useEffect(() => { + if (vendor) { + setForm({ + name: vendor.name, + kontakt_name: vendor.kontakt_name || '', + email: vendor.email || '', + telefon: vendor.telefon || '', + adresse: vendor.adresse || '', + website: vendor.website || '', + notizen: vendor.notizen || '', + }); + } + }, [vendor]); + + // ── Mutations ── + const createVendor = useMutation({ + mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data), + onSuccess: (created) => { + queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); + showSuccess('Lieferant erstellt'); + navigate(`/bestellungen/lieferanten/${created.id}`, { replace: true }); + }, + onError: () => showError('Fehler beim Erstellen des Lieferanten'), + }); + + const updateVendor = useMutation({ + mutationFn: (data: LieferantFormData) => bestellungApi.updateVendor(vendorId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lieferant', vendorId] }); + queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); + showSuccess('Lieferant aktualisiert'); + setEditMode(false); + }, + onError: () => showError('Fehler beim Aktualisieren des Lieferanten'), + }); + + const deleteVendor = useMutation({ + mutationFn: () => bestellungApi.deleteVendor(vendorId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); + showSuccess('Lieferant gelöscht'); + navigate('/bestellungen?tab=1'); + }, + onError: () => showError('Fehler beim Löschen des Lieferanten'), + }); + + function handleSave() { + if (!form.name.trim()) return; + if (isNew) { + createVendor.mutate(form); + } else { + updateVendor.mutate(form); + } + } + + function handleCancel() { + if (isNew) { + navigate('/bestellungen?tab=1'); + } else if (vendor) { + setForm({ + name: vendor.name, + kontakt_name: vendor.kontakt_name || '', + email: vendor.email || '', + telefon: vendor.telefon || '', + adresse: vendor.adresse || '', + website: vendor.website || '', + notizen: vendor.notizen || '', + }); + setEditMode(false); + } + } + + // ── Loading / Error ── + if (!isNew && isLoading) { + return ( + + + navigate('/bestellungen?tab=1')}> + + + + + + + + + ); + } + + if (!isNew && (isError || !vendor)) { + return ( + + + Lieferant nicht gefunden. + + + + ); + } + + const isSaving = createVendor.isPending || updateVendor.isPending; + + return ( + + {/* ── Header ── */} + + navigate('/bestellungen?tab=1')}> + + + + {isNew ? 'Neuer Lieferant' : vendor!.name} + + {!isNew && canManage && !editMode && ( + <> + + + + )} + {editMode && ( + <> + + + + )} + + + {/* ── Content ── */} + {editMode ? ( + + + setForm((f) => ({ ...f, name: e.target.value }))} + /> + setForm((f) => ({ ...f, kontakt_name: e.target.value }))} + /> + setForm((f) => ({ ...f, email: e.target.value }))} + /> + setForm((f) => ({ ...f, telefon: e.target.value }))} + /> + setForm((f) => ({ ...f, adresse: e.target.value }))} + /> + setForm((f) => ({ ...f, website: e.target.value }))} + /> + setForm((f) => ({ ...f, notizen: e.target.value }))} + /> + + + ) : ( + + + + Name + {vendor!.name} + + + + + Kontakt + {vendor!.kontakt_name || '–'} + + + + + E-Mail + + {vendor!.email ? {vendor!.email} : '–'} + + + + + + Telefon + {vendor!.telefon || '–'} + + + + + Website + + {vendor!.website ? {vendor!.website} : '–'} + + + + + + Adresse + {vendor!.adresse || '–'} + + + {vendor!.notizen && ( + + + Notizen + {vendor!.notizen} + + + )} + + )} + + {/* ── Delete Dialog ── */} + setDeleteDialogOpen(false)}> + Lieferant löschen + + + Soll der Lieferant {vendor?.name} wirklich gelöscht werden? + + + + + + + + + ); +} diff --git a/frontend/src/types/bestellung.types.ts b/frontend/src/types/bestellung.types.ts index 24ad57d..1f8b679 100644 --- a/frontend/src/types/bestellung.types.ts +++ b/frontend/src/types/bestellung.types.ts @@ -67,6 +67,9 @@ export interface Bestellung { // Computed total_cost?: number; items_count?: number; + total_received?: number; + total_ordered?: number; + laufende_nummer?: number; } export interface BestellungFormData {