From 4fbea8af81b00497a9d6ad804dbb76b8b789eabb Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 14 Apr 2026 10:35:40 +0200 Subject: [PATCH] feat: widget icons, dark theme tables, breadcrumb removal, bookkeeping rework, personal equipment pages, PDF/order improvements --- .../services/ausruestungsanfrage.service.ts | 6 + backend/src/services/buchhaltung.service.ts | 4 +- frontend/src/App.tsx | 18 ++ frontend/src/components/common/index.ts | 3 +- frontend/src/components/dashboard/index.ts | 1 - .../src/components/templates/DetailLayout.tsx | 5 +- .../src/components/templates/PageHeader.tsx | 33 +-- .../src/components/templates/WidgetCard.tsx | 11 +- frontend/src/components/templates/index.ts | 2 +- frontend/src/constants/widgets.ts | 1 - frontend/src/pages/AdminSettings.tsx | 5 - frontend/src/pages/AusruestungDetail.tsx | 4 - frontend/src/pages/AusruestungForm.tsx | 5 - .../AusruestungsanfrageArtikelDetail.tsx | 5 - .../src/pages/AusruestungsanfrageDetail.tsx | 260 +----------------- frontend/src/pages/AusruestungsanfrageNeu.tsx | 5 - .../pages/AusruestungsanfrageZuBestellung.tsx | 6 - .../pages/AusruestungsanfrageZuweisung.tsx | 254 +++++++++++++++++ frontend/src/pages/BestellungDetail.tsx | 32 +-- frontend/src/pages/BestellungNeu.tsx | 4 - frontend/src/pages/BookingFormPage.tsx | 4 - frontend/src/pages/Buchhaltung.tsx | 77 +++--- .../src/pages/BuchhaltungBankkontoDetail.tsx | 5 - frontend/src/pages/BuchhaltungKontoDetail.tsx | 5 - frontend/src/pages/BuchhaltungKontoManage.tsx | 6 - frontend/src/pages/ChecklistAusfuehrung.tsx | 5 - frontend/src/pages/Dashboard.tsx | 13 +- frontend/src/pages/EinsatzDetail.tsx | 4 - frontend/src/pages/FahrzeugDetail.tsx | 4 - frontend/src/pages/FahrzeugForm.tsx | 5 - frontend/src/pages/HaushaltsplanDetail.tsx | 5 - frontend/src/pages/IssueDetail.tsx | 4 - frontend/src/pages/IssueNeu.tsx | 4 - frontend/src/pages/LieferantDetail.tsx | 5 - frontend/src/pages/MitgliedDetail.tsx | 77 +++--- .../src/pages/PersoenlicheAusruestung.tsx | 186 ++----------- .../src/pages/PersoenlicheAusruestungNeu.tsx | 200 ++++++++++++++ frontend/src/pages/UebungDetail.tsx | 5 - .../src/pages/VeranstaltungKategorien.tsx | 5 - frontend/src/theme/theme.ts | 54 ++++ .../src/types/ausruestungsanfrage.types.ts | 1 + 41 files changed, 679 insertions(+), 659 deletions(-) create mode 100644 frontend/src/pages/AusruestungsanfrageZuweisung.tsx create mode 100644 frontend/src/pages/PersoenlicheAusruestungNeu.tsx diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index 82041b8..68f9847 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -413,10 +413,16 @@ async function getRequestById(id: number) { linkedBestellungen = bestellungen.rows; } catch { /* table may not exist */ } + // Determine im_haus: true if any linked bestellung has status lieferung_pruefen or abgeschlossen + const imHaus = (linkedBestellungen as { status: string }[]).some( + (b) => b.status === 'lieferung_pruefen' || b.status === 'abgeschlossen', + ); + return { anfrage: reqResult.rows[0], positionen: positionenWithEigenschaften, linked_bestellungen: linkedBestellungen, + im_haus: imHaus, }; } diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts index 575479e..b266fcb 100644 --- a/backend/src/services/buchhaltung.service.ts +++ b/backend/src/services/buchhaltung.service.ts @@ -1019,12 +1019,12 @@ async function stornoTransaktion(id: number, userId: string) { try { const result = await pool.query( `UPDATE buchhaltung_transaktionen - SET status = 'storniert' + SET status = 'entwurf' WHERE id = $1 AND status IN ('gebucht', 'freigegeben') RETURNING *`, [id, userId] ); - if (result.rows[0]) await logAudit(id, 'storniert', {}, userId); + if (result.rows[0]) await logAudit(id, 'storniert_zu_entwurf', {}, userId); return result.rows[0] || null; } catch (error) { logger.error('BuchhaltungService.stornoTransaktion failed', { error, id }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a43819..0ffffc7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import AusruestungForm from './pages/AusruestungForm'; import AusruestungDetail from './pages/AusruestungDetail'; import AusruestungEinstellungen from './pages/AusruestungEinstellungen'; import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung'; +import PersoenlicheAusruestungNeu from './pages/PersoenlicheAusruestungNeu'; import Atemschutz from './pages/Atemschutz'; import Mitglieder from './pages/Mitglieder'; import MitgliedDetail from './pages/MitgliedDetail'; @@ -38,6 +39,7 @@ import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail'; import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung'; import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; +import AusruestungsanfrageZuweisung from './pages/AusruestungsanfrageZuweisung'; import Checklisten from './pages/Checklisten'; import Buchhaltung from './pages/Buchhaltung'; import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail'; @@ -193,6 +195,14 @@ function App() { } /> + + + + } + /> } /> + + + + } + /> = ({ title, - breadcrumbs, actions, tabs, backTo, @@ -50,7 +47,7 @@ export const DetailLayout: React.FC = ({ return ( - + {isLoading ? ( skeleton ) : ( diff --git a/frontend/src/components/templates/PageHeader.tsx b/frontend/src/components/templates/PageHeader.tsx index 4cc4eae..f275255 100644 --- a/frontend/src/components/templates/PageHeader.tsx +++ b/frontend/src/components/templates/PageHeader.tsx @@ -1,31 +1,23 @@ import React from 'react'; import { Box, - Breadcrumbs, IconButton, Typography, } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { Link, useNavigate } from 'react-router-dom'; - -export interface BreadcrumbItem { - label: string; - href?: string; -} +import { useNavigate } from 'react-router-dom'; export interface PageHeaderProps { title: string; subtitle?: string; - breadcrumbs?: BreadcrumbItem[]; actions?: React.ReactNode; backTo?: string; } -/** Page title bar with optional breadcrumbs, back button, and action slot. */ +/** Page title bar with optional back button and action slot. */ export const PageHeader: React.FC = ({ title, subtitle, - breadcrumbs, actions, backTo, }) => { @@ -33,27 +25,6 @@ export const PageHeader: React.FC = ({ return ( - {breadcrumbs && breadcrumbs.length > 0 && ( - - {breadcrumbs.map((item, i) => - item.href && i < breadcrumbs.length - 1 ? ( - - - {item.label} - - - ) : ( - - {item.label} - - ) - )} - - )} {backTo && ( diff --git a/frontend/src/components/templates/WidgetCard.tsx b/frontend/src/components/templates/WidgetCard.tsx index ff5c21b..903a731 100644 --- a/frontend/src/components/templates/WidgetCard.tsx +++ b/frontend/src/components/templates/WidgetCard.tsx @@ -59,7 +59,16 @@ export const WidgetCard: React.FC = ({ > {icon && ( - *': { fontSize: '1.1rem' } }}> + *': { fontSize: '1rem' }, + }}> {icon} )} diff --git a/frontend/src/components/templates/index.ts b/frontend/src/components/templates/index.ts index 5d94e84..73bd486 100644 --- a/frontend/src/components/templates/index.ts +++ b/frontend/src/components/templates/index.ts @@ -7,7 +7,7 @@ export type { ListCardProps } from './ListCard'; export { FormCard } from './FormCard'; export type { FormCardProps } from './FormCard'; export { PageHeader } from './PageHeader'; -export type { PageHeaderProps, BreadcrumbItem } from './PageHeader'; +export type { PageHeaderProps } from './PageHeader'; export { PageContainer } from './PageContainer'; export type { PageContainerProps } from './PageContainer'; export { FormLayout } from './FormLayout'; diff --git a/frontend/src/constants/widgets.ts b/frontend/src/constants/widgets.ts index d68b6ab..e40cb01 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -19,7 +19,6 @@ export const WIDGETS = [ { key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true }, { key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true }, { key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true }, - { key: 'persoenlicheAusruestung', label: 'Pers. Ausrüstung', defaultVisible: true }, ] as const; export type WidgetKey = typeof WIDGETS[number]['key']; diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx index ffdb016..aeda6bb 100644 --- a/frontend/src/pages/AdminSettings.tsx +++ b/frontend/src/pages/AdminSettings.tsx @@ -32,7 +32,6 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Navigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { PageBreadcrumbs } from '../components/common'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; import { settingsApi } from '../services/settings'; @@ -294,10 +293,6 @@ function AdminSettings() { return ( - Admin-Einstellungen diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index 9bee17f..97571e6 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -779,10 +779,6 @@ function AusruestungDetailPage() { diff --git a/frontend/src/pages/AusruestungForm.tsx b/frontend/src/pages/AusruestungForm.tsx index 1cf7242..1978ad7 100644 --- a/frontend/src/pages/AusruestungForm.tsx +++ b/frontend/src/pages/AusruestungForm.tsx @@ -304,11 +304,6 @@ function AusruestungForm() { diff --git a/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx b/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx index a2f945a..cd2e98a 100644 --- a/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageArtikelDetail.tsx @@ -11,7 +11,6 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { PageBreadcrumbs } from '../components/common'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; @@ -286,10 +285,6 @@ export default function AusruestungsanfrageArtikelDetail() { return ( - {/* Header */} navigate('/ausruestungsanfrage?tab=2')}> diff --git a/frontend/src/pages/AusruestungsanfrageDetail.tsx b/frontend/src/pages/AusruestungsanfrageDetail.tsx index ca7d4c8..6eb7cc7 100644 --- a/frontend/src/pages/AusruestungsanfrageDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageDetail.tsx @@ -5,7 +5,6 @@ import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, InputLabel, Autocomplete, Checkbox, LinearProgress, Switch, FormControlLabel, Alert, - ToggleButton, ToggleButtonGroup, Stack, Divider, } from '@mui/material'; import { ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, @@ -15,13 +14,10 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { PageBreadcrumbs } from '../components/common'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useAuth } from '../contexts/AuthContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; -import { vehiclesApi } from '../services/vehicles'; -import { membersService } from '../services/members'; import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; import type { AusruestungAnfrage, AusruestungAnfrageDetailResponse, @@ -40,223 +36,10 @@ function formatOrderId(r: AusruestungAnfrage): string { // ── Helpers ── -type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine'; - -interface PositionAssignment { - typ: AssignmentTyp; - fahrzeugId?: string; - standort?: string; - userId?: string; - benutzerName?: string; - groesse?: string; - kategorie?: string; -} - function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] { return positions.filter((p) => p.geliefert && !p.zuweisung_typ); } -// ══════════════════════════════════════════════════════════════════════════════ -// ItemAssignmentDialog -// ══════════════════════════════════════════════════════════════════════════════ - -interface ItemAssignmentDialogProps { - open: boolean; - onClose: () => void; - anfrage: AusruestungAnfrage; - positions: AusruestungAnfragePosition[]; - onSuccess: () => void; -} - -function ItemAssignmentDialog({ open, onClose, anfrage, positions, onSuccess }: ItemAssignmentDialogProps) { - const { showSuccess, showError } = useNotification(); - const unassigned = getUnassignedPositions(positions); - - const [assignments, setAssignments] = useState>(() => { - const init: Record = {}; - for (const p of unassigned) { - init[p.id] = { typ: 'persoenlich' }; - } - return init; - }); - - const { data: vehicleList } = useQuery({ - queryKey: ['vehicles', 'sidebar'], - queryFn: () => vehiclesApi.getAll(), - staleTime: 2 * 60 * 1000, - enabled: open, - }); - - const { data: membersList } = useQuery({ - queryKey: ['members-list-compact'], - queryFn: () => membersService.getMembers({ pageSize: 500 }), - staleTime: 5 * 60 * 1000, - enabled: open, - }); - - const memberOptions = (membersList?.items ?? []).map((m) => ({ - id: m.id, - name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email, - })); - - const vehicleOptions = (vehicleList ?? []).map((v) => ({ - id: v.id, - name: v.bezeichnung ?? v.kurzname, - })); - - const [submitting, setSubmitting] = useState(false); - - const updateAssignment = (posId: number, patch: Partial) => { - setAssignments((prev) => ({ - ...prev, - [posId]: { ...prev[posId], ...patch }, - })); - }; - - const handleSkipAll = () => { - const updated: Record = {}; - for (const p of unassigned) { - updated[p.id] = { typ: 'keine' }; - } - setAssignments(updated); - }; - - const handleSubmit = async () => { - setSubmitting(true); - try { - const payload = Object.entries(assignments).map(([posId, a]) => ({ - positionId: Number(posId), - typ: a.typ, - fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined, - standort: a.typ === 'ausruestung' ? a.standort : undefined, - userId: a.typ === 'persoenlich' ? a.userId : undefined, - benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined, - groesse: a.typ === 'persoenlich' ? a.groesse : undefined, - kategorie: a.typ === 'persoenlich' ? a.kategorie : undefined, - })); - await ausruestungsanfrageApi.assignItems(anfrage.id, payload); - showSuccess('Gegenstände zugewiesen'); - onSuccess(); - onClose(); - } catch { - showError('Fehler beim Zuweisen'); - } finally { - setSubmitting(false); - } - }; - - if (unassigned.length === 0) return null; - - return ( - - Gegenstände zuweisen - - - Wähle für jeden gelieferten Gegenstand, wie er erfasst werden soll. - - - }> - {unassigned.map((pos) => { - const a = assignments[pos.id] ?? { typ: 'persoenlich' as const }; - return ( - - - - {pos.bezeichnung} - - - - - val && updateAssignment(pos.id, { typ: val })} - sx={{ mb: 1.5 }} - > - Ausrüstung - Persönlich - Nicht erfassen - - - {a.typ === 'ausruestung' && ( - - o.name} - value={vehicleOptions.find((v) => v.id === a.fahrzeugId) ?? null} - onChange={(_e, v) => updateAssignment(pos.id, { fahrzeugId: v?.id })} - renderInput={(params) => } - sx={{ minWidth: 200, flex: 1 }} - /> - updateAssignment(pos.id, { standort: e.target.value })} - sx={{ minWidth: 160, flex: 1 }} - /> - - )} - - {a.typ === 'persoenlich' && ( - - o.name} - value={memberOptions.find((m) => m.id === a.userId) ?? null} - onChange={(_e, v) => updateAssignment(pos.id, { userId: v?.id, benutzerName: v?.name })} - renderInput={(params) => ( - - )} - sx={{ minWidth: 200, flex: 1 }} - /> - updateAssignment(pos.id, { groesse: e.target.value })} - sx={{ minWidth: 100 }} - /> - updateAssignment(pos.id, { kategorie: e.target.value })} - sx={{ minWidth: 140 }} - /> - - )} - - ); - })} - - - - - - - - - - ); -} - // ══════════════════════════════════════════════════════════════════════════════ // Component // ══════════════════════════════════════════════════════════════════════════════ @@ -282,9 +65,6 @@ export default function AusruestungsanfrageDetail() { const [adminNotizen, setAdminNotizen] = useState(''); const [statusChangeValue, setStatusChangeValue] = useState(''); - // Assignment dialog state - const [assignmentOpen, setAssignmentOpen] = useState(false); - // Eigenschaften state for edit mode const [editItemEigenschaften, setEditItemEigenschaften] = useState>({}); const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState>>({}); @@ -337,11 +117,11 @@ export default function AusruestungsanfrageDetail() { setActionDialog(null); setAdminNotizen(''); setStatusChangeValue(''); - // Auto-open assignment dialog when status changes to 'erledigt' and unassigned positions exist + // Auto-navigate to assignment page when status changes to 'erledigt' and unassigned positions exist if (variables.status === 'erledigt' && detail) { const unassigned = getUnassignedPositions(detail.positionen); if (unassigned.length > 0) { - setAssignmentOpen(true); + navigate(`/ausruestungsanfrage/${requestId}/zuweisung`); } } }, @@ -427,10 +207,6 @@ export default function AusruestungsanfrageDetail() { return ( - {/* Header */} navigate('/ausruestungsanfrage')}> @@ -441,10 +217,15 @@ export default function AusruestungsanfrageDetail() { {anfrage?.bezeichnung && ` — ${anfrage.bezeichnung}`} {anfrage && ( - + <> + {detail?.im_haus && ( + + )} + + )} @@ -645,6 +426,9 @@ export default function AusruestungsanfrageDetail() { {p.ist_ersatz && ( )} + {p.geliefert && detail?.im_haus && ( + + )} {p.eigenschaften && p.eigenschaften.length > 0 && p.eigenschaften.map(e => ( ))} @@ -743,7 +527,7 @@ export default function AusruestungsanfrageDetail() { @@ -786,20 +570,6 @@ export default function AusruestungsanfrageDetail() { - {/* Assignment dialog */} - {detail && anfrage && ( - setAssignmentOpen(false)} - anfrage={anfrage} - positions={detail.positionen} - onSuccess={() => { - queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); - queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] }); - }} - /> - )} - ); } diff --git a/frontend/src/pages/AusruestungsanfrageNeu.tsx b/frontend/src/pages/AusruestungsanfrageNeu.tsx index dac85ae..5201112 100644 --- a/frontend/src/pages/AusruestungsanfrageNeu.tsx +++ b/frontend/src/pages/AusruestungsanfrageNeu.tsx @@ -7,7 +7,6 @@ import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-mate import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { PageBreadcrumbs } from '../components/common'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; @@ -169,10 +168,6 @@ export default function AusruestungsanfrageNeu() { return ( - {/* Header */} navigate('/ausruestungsanfrage')}> diff --git a/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx b/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx index 44abaeb..97b9fd7 100644 --- a/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx +++ b/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx @@ -14,7 +14,6 @@ import { import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { PageBreadcrumbs } from '../components/common'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; @@ -237,11 +236,6 @@ export default function AusruestungsanfrageZuBestellung() { return ( - {/* ── Header ── */} diff --git a/frontend/src/pages/AusruestungsanfrageZuweisung.tsx b/frontend/src/pages/AusruestungsanfrageZuweisung.tsx new file mode 100644 index 0000000..40cdc97 --- /dev/null +++ b/frontend/src/pages/AusruestungsanfrageZuweisung.tsx @@ -0,0 +1,254 @@ +import { useState, useMemo } from 'react'; +import { + Box, Typography, Container, Button, Chip, + TextField, Autocomplete, ToggleButton, ToggleButtonGroup, + Stack, Divider, LinearProgress, +} from '@mui/material'; +import { Assignment as AssignmentIcon } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageHeader } from '../components/templates'; +import { useNotification } from '../contexts/NotificationContext'; +import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; +import { vehiclesApi } from '../services/vehicles'; +import { membersService } from '../services/members'; +import type { AusruestungAnfragePosition } from '../types/ausruestungsanfrage.types'; + +type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine'; + +interface PositionAssignment { + typ: AssignmentTyp; + fahrzeugId?: string; + standort?: string; + userId?: string; + benutzerName?: string; + groesse?: string; + kategorie?: string; +} + +function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] { + return positions.filter((p) => p.geliefert && !p.zuweisung_typ); +} + +export default function AusruestungsanfrageZuweisung() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { showSuccess, showError } = useNotification(); + const anfrageId = Number(id); + + const { data: detail, isLoading, isError } = useQuery({ + queryKey: ['ausruestungsanfrage', 'request', anfrageId], + queryFn: () => ausruestungsanfrageApi.getRequest(anfrageId), + enabled: !isNaN(anfrageId), + retry: 1, + }); + + const unassigned = useMemo(() => { + if (!detail) return []; + return getUnassignedPositions(detail.positionen); + }, [detail]); + + const [assignments, setAssignments] = useState>({}); + + // Initialize assignments when unassigned positions load + useMemo(() => { + if (unassigned.length > 0 && Object.keys(assignments).length === 0) { + const init: Record = {}; + for (const p of unassigned) { + init[p.id] = { typ: 'persoenlich' }; + } + setAssignments(init); + } + }, [unassigned]); + + const { data: vehicleList } = useQuery({ + queryKey: ['vehicles', 'sidebar'], + queryFn: () => vehiclesApi.getAll(), + staleTime: 2 * 60 * 1000, + }); + + const { data: membersList } = useQuery({ + queryKey: ['members-list-compact'], + queryFn: () => membersService.getMembers({ pageSize: 500 }), + staleTime: 5 * 60 * 1000, + }); + + const memberOptions = (membersList?.items ?? []).map((m) => ({ + id: m.id, + name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email, + })); + + const vehicleOptions = (vehicleList ?? []).map((v) => ({ + id: v.id, + name: v.bezeichnung ?? v.kurzname, + })); + + const [submitting, setSubmitting] = useState(false); + + const updateAssignment = (posId: number, patch: Partial) => { + setAssignments((prev) => ({ + ...prev, + [posId]: { ...prev[posId], ...patch }, + })); + }; + + const handleSkipAll = () => { + const updated: Record = {}; + for (const p of unassigned) { + updated[p.id] = { typ: 'keine' }; + } + setAssignments(updated); + }; + + const handleSubmit = async () => { + if (!detail) return; + setSubmitting(true); + try { + const anfrage = detail.anfrage; + const payload = Object.entries(assignments).map(([posId, a]) => ({ + positionId: Number(posId), + typ: a.typ, + fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined, + standort: a.typ === 'ausruestung' ? a.standort : undefined, + userId: a.typ === 'persoenlich' ? a.userId : undefined, + benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined, + groesse: a.typ === 'persoenlich' ? a.groesse : undefined, + kategorie: a.typ === 'persoenlich' ? a.kategorie : undefined, + })); + await ausruestungsanfrageApi.assignItems(anfrageId, payload); + showSuccess('Gegenstände zugewiesen'); + navigate(`/ausruestungsanfrage/${id}`); + } catch { + showError('Fehler beim Zuweisen'); + } finally { + setSubmitting(false); + } + }; + + const backPath = `/ausruestungsanfrage/${id}`; + + return ( + + + + + {isLoading ? ( + + ) : isError || !detail ? ( + Fehler beim Laden der Anfrage. + ) : unassigned.length === 0 ? ( + Keine unzugewiesenen Positionen vorhanden. + ) : ( + <> + + Wähle für jeden gelieferten Gegenstand, wie er erfasst werden soll. + + + }> + {unassigned.map((pos) => { + const a = assignments[pos.id] ?? { typ: 'persoenlich' as const }; + return ( + + + + {pos.bezeichnung} + + + + + val && updateAssignment(pos.id, { typ: val })} + sx={{ mb: 1.5 }} + > + Ausrüstung + Persönlich + Nicht erfassen + + + {a.typ === 'ausruestung' && ( + + o.name} + value={vehicleOptions.find((v) => v.id === a.fahrzeugId) ?? null} + onChange={(_e, v) => updateAssignment(pos.id, { fahrzeugId: v?.id })} + renderInput={(params) => } + sx={{ minWidth: 200, flex: 1 }} + /> + updateAssignment(pos.id, { standort: e.target.value })} + sx={{ minWidth: 160, flex: 1 }} + /> + + )} + + {a.typ === 'persoenlich' && ( + + o.name} + value={memberOptions.find((m) => m.id === a.userId) ?? null} + onChange={(_e, v) => updateAssignment(pos.id, { userId: v?.id, benutzerName: v?.name })} + renderInput={(params) => ( + + )} + sx={{ minWidth: 200, flex: 1 }} + /> + updateAssignment(pos.id, { groesse: e.target.value })} + sx={{ minWidth: 100 }} + /> + updateAssignment(pos.id, { kategorie: e.target.value })} + sx={{ minWidth: 140 }} + /> + + )} + + ); + })} + + + + + + + + + )} + + + ); +} diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index cf3c2ba..c72c2ee 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -474,11 +474,20 @@ export default function BestellungDetail() { doc.text('Bestellinformationen', 10, curY); curY += 5; row('Bezeichnung', bestellung.bezeichnung); - row('Status', BESTELLUNG_STATUS_LABELS[bestellung.status]); row('Erstellt am', formatDate(bestellung.erstellt_am)); if (bestellung.bestellt_am) row('Bestelldatum', formatDate(bestellung.bestellt_am)); curY += 5; + // ── Place and date ── + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + const pageWidth = doc.internal.pageSize.width; + const dateStr = bestellung.bestellt_am + ? new Date(bestellung.bestellt_am).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + doc.text(`St. Valentin, am ${dateStr}`, pageWidth - 10, curY, { align: 'right' }); + curY += 8; + // ── Line items table ── const steuersatz = (parseFloat(String(bestellung.steuersatz)) || 20) / 100; const hasPrices = positionen.some((p) => p.einzelpreis != null && p.einzelpreis > 0); @@ -619,13 +628,6 @@ export default function BestellungDetail() { doc.setFont('helvetica', 'normal'); doc.setTextColor(0, 0, 0); - // Place and date (right-aligned, above signature line) - const today = new Date(); - const dd = String(today.getDate()).padStart(2, '0'); - const mm = String(today.getMonth() + 1).padStart(2, '0'); - const yyyy = today.getFullYear(); - doc.text(`St. Valentin, am ${dd}.${mm}.${yyyy}`, 200, curY - 6, { align: 'right' }); - // Signature line (right) doc.line(120, curY, 200, curY); @@ -673,22 +675,20 @@ export default function BestellungDetail() { {canExport && !editMode && ( - } onClick={generateBestellungDetailPdf} - color="primary" disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'} > - - + Export + )} diff --git a/frontend/src/pages/BestellungNeu.tsx b/frontend/src/pages/BestellungNeu.tsx index de9c9fd..3c2a75c 100644 --- a/frontend/src/pages/BestellungNeu.tsx +++ b/frontend/src/pages/BestellungNeu.tsx @@ -77,10 +77,6 @@ export default function BestellungNeu() { diff --git a/frontend/src/pages/BookingFormPage.tsx b/frontend/src/pages/BookingFormPage.tsx index 455fcaa..a57fefd 100644 --- a/frontend/src/pages/BookingFormPage.tsx +++ b/frontend/src/pages/BookingFormPage.tsx @@ -244,10 +244,6 @@ function BookingFormPage() { diff --git a/frontend/src/pages/Buchhaltung.tsx b/frontend/src/pages/Buchhaltung.tsx index e225b8c..5f5aae1 100644 --- a/frontend/src/pages/Buchhaltung.tsx +++ b/frontend/src/pages/Buchhaltung.tsx @@ -1164,7 +1164,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { const stornoMut = useMutation({ mutationFn: (id: number) => buchhaltungApi.stornoTransaktion(id), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); showSuccess('Transaktion storniert'); }, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); setTxSubTab(0); showSuccess('Transaktion storniert'); }, onError: () => showError('Storno fehlgeschlagen'), }); @@ -1264,16 +1264,23 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { return sortDir === 'asc' ? cmp : -cmp; }); + const subTabTransaktionen = sortedTransaktionen.filter(t => { + if (txSubTab === 0) return t.status === 'entwurf'; + if (txSubTab === 1) return t.status === 'gebucht' || t.status === 'freigegeben'; + return false; + }); + return ( setTxSubTab(v)}> - + + - {txSubTab === 0 && ( + {(txSubTab === 0 || txSubTab === 1) && ( {/* Filters */} @@ -1286,14 +1293,6 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {haushaltsjahre.map(hj => {hj.bezeichnung})} - - Status - - Typ