feat: widget icons, dark theme tables, breadcrumb removal, bookkeeping rework, personal equipment pages, PDF/order improvements
This commit is contained in:
@@ -413,10 +413,16 @@ async function getRequestById(id: number) {
|
|||||||
linkedBestellungen = bestellungen.rows;
|
linkedBestellungen = bestellungen.rows;
|
||||||
} catch { /* table may not exist */ }
|
} 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 {
|
return {
|
||||||
anfrage: reqResult.rows[0],
|
anfrage: reqResult.rows[0],
|
||||||
positionen: positionenWithEigenschaften,
|
positionen: positionenWithEigenschaften,
|
||||||
linked_bestellungen: linkedBestellungen,
|
linked_bestellungen: linkedBestellungen,
|
||||||
|
im_haus: imHaus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1019,12 +1019,12 @@ async function stornoTransaktion(id: number, userId: string) {
|
|||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE buchhaltung_transaktionen
|
`UPDATE buchhaltung_transaktionen
|
||||||
SET status = 'storniert'
|
SET status = 'entwurf'
|
||||||
WHERE id = $1 AND status IN ('gebucht', 'freigegeben')
|
WHERE id = $1 AND status IN ('gebucht', 'freigegeben')
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[id, userId]
|
[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;
|
return result.rows[0] || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('BuchhaltungService.stornoTransaktion failed', { error, id });
|
logger.error('BuchhaltungService.stornoTransaktion failed', { error, id });
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import AusruestungForm from './pages/AusruestungForm';
|
|||||||
import AusruestungDetail from './pages/AusruestungDetail';
|
import AusruestungDetail from './pages/AusruestungDetail';
|
||||||
import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
|
import AusruestungEinstellungen from './pages/AusruestungEinstellungen';
|
||||||
import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung';
|
import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung';
|
||||||
|
import PersoenlicheAusruestungNeu from './pages/PersoenlicheAusruestungNeu';
|
||||||
import Atemschutz from './pages/Atemschutz';
|
import Atemschutz from './pages/Atemschutz';
|
||||||
import Mitglieder from './pages/Mitglieder';
|
import Mitglieder from './pages/Mitglieder';
|
||||||
import MitgliedDetail from './pages/MitgliedDetail';
|
import MitgliedDetail from './pages/MitgliedDetail';
|
||||||
@@ -38,6 +39,7 @@ import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail';
|
|||||||
import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung';
|
import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung';
|
||||||
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
|
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
|
||||||
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
||||||
|
import AusruestungsanfrageZuweisung from './pages/AusruestungsanfrageZuweisung';
|
||||||
import Checklisten from './pages/Checklisten';
|
import Checklisten from './pages/Checklisten';
|
||||||
import Buchhaltung from './pages/Buchhaltung';
|
import Buchhaltung from './pages/Buchhaltung';
|
||||||
import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail';
|
import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail';
|
||||||
@@ -193,6 +195,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/persoenliche-ausruestung/neu"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PersoenlicheAusruestungNeu />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/persoenliche-ausruestung"
|
path="/persoenliche-ausruestung"
|
||||||
element={
|
element={
|
||||||
@@ -353,6 +363,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/ausruestungsanfrage/:id/zuweisung"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AusruestungsanfrageZuweisung />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/ausruestungsanfrage/:id"
|
path="/ausruestungsanfrage/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { PageBreadcrumbs } from './PageBreadcrumbs';
|
|
||||||
export type { BreadcrumbItem } from './PageBreadcrumbs';
|
|
||||||
|
|||||||
@@ -25,4 +25,3 @@ export { default as IssueOverviewWidget } from './IssueOverviewWidget';
|
|||||||
export { default as ChecklistWidget } from './ChecklistWidget';
|
export { default as ChecklistWidget } from './ChecklistWidget';
|
||||||
export { default as SortableWidget } from './SortableWidget';
|
export { default as SortableWidget } from './SortableWidget';
|
||||||
export { default as BuchhaltungWidget } from './BuchhaltungWidget';
|
export { default as BuchhaltungWidget } from './BuchhaltungWidget';
|
||||||
export { default as PersoenlicheAusruestungWidget } from './PersoenlicheAusruestungWidget';
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Box, Tab, Tabs } from '@mui/material';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { PageHeader } from './PageHeader';
|
import { PageHeader } from './PageHeader';
|
||||||
import { TabPanel } from './TabPanel';
|
import { TabPanel } from './TabPanel';
|
||||||
import type { BreadcrumbItem } from './PageHeader';
|
|
||||||
|
|
||||||
export interface TabDef {
|
export interface TabDef {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
@@ -14,7 +13,6 @@ export interface TabDef {
|
|||||||
|
|
||||||
export interface DetailLayoutProps {
|
export interface DetailLayoutProps {
|
||||||
title: string;
|
title: string;
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
tabs: TabDef[];
|
tabs: TabDef[];
|
||||||
backTo?: string;
|
backTo?: string;
|
||||||
@@ -25,7 +23,6 @@ export interface DetailLayoutProps {
|
|||||||
/** Detail page layout with PageHeader and tab navigation synced to URL. */
|
/** Detail page layout with PageHeader and tab navigation synced to URL. */
|
||||||
export const DetailLayout: React.FC<DetailLayoutProps> = ({
|
export const DetailLayout: React.FC<DetailLayoutProps> = ({
|
||||||
title,
|
title,
|
||||||
breadcrumbs,
|
|
||||||
actions,
|
actions,
|
||||||
tabs,
|
tabs,
|
||||||
backTo,
|
backTo,
|
||||||
@@ -50,7 +47,7 @@ export const DetailLayout: React.FC<DetailLayoutProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<PageHeader title={title} breadcrumbs={breadcrumbs} actions={actions} backTo={backTo} />
|
<PageHeader title={title} actions={actions} backTo={backTo} />
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
skeleton
|
skeleton
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,31 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Breadcrumbs,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
|
||||||
label: string;
|
|
||||||
href?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PageHeaderProps {
|
export interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
backTo?: string;
|
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<PageHeaderProps> = ({
|
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
breadcrumbs,
|
|
||||||
actions,
|
actions,
|
||||||
backTo,
|
backTo,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -33,27 +25,6 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
|
||||||
<Breadcrumbs sx={{ mb: 1 }}>
|
|
||||||
{breadcrumbs.map((item, i) =>
|
|
||||||
item.href && i < breadcrumbs.length - 1 ? (
|
|
||||||
<Link
|
|
||||||
key={i}
|
|
||||||
to={item.href}
|
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ '&:hover': { textDecoration: 'underline' } }}>
|
|
||||||
{item.label}
|
|
||||||
</Typography>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Typography key={i} variant="body2" color="text.primary">
|
|
||||||
{item.label}
|
|
||||||
</Typography>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Breadcrumbs>
|
|
||||||
)}
|
|
||||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={3}>
|
<Box display="flex" alignItems="center" justifyContent="space-between" mb={3}>
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
{backTo && (
|
{backTo && (
|
||||||
|
|||||||
@@ -59,7 +59,16 @@ export const WidgetCard: React.FC<WidgetCardProps> = ({
|
|||||||
>
|
>
|
||||||
<Box display="flex" alignItems="center" gap={0.75}>
|
<Box display="flex" alignItems="center" gap={0.75}>
|
||||||
{icon && (
|
{icon && (
|
||||||
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& > *': { fontSize: '1.1rem' } }}>
|
<Box sx={{
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
p: 0.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
'& > *': { fontSize: '1rem' },
|
||||||
|
}}>
|
||||||
{icon}
|
{icon}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type { ListCardProps } from './ListCard';
|
|||||||
export { FormCard } from './FormCard';
|
export { FormCard } from './FormCard';
|
||||||
export type { FormCardProps } from './FormCard';
|
export type { FormCardProps } from './FormCard';
|
||||||
export { PageHeader } from './PageHeader';
|
export { PageHeader } from './PageHeader';
|
||||||
export type { PageHeaderProps, BreadcrumbItem } from './PageHeader';
|
export type { PageHeaderProps } from './PageHeader';
|
||||||
export { PageContainer } from './PageContainer';
|
export { PageContainer } from './PageContainer';
|
||||||
export type { PageContainerProps } from './PageContainer';
|
export type { PageContainerProps } from './PageContainer';
|
||||||
export { FormLayout } from './FormLayout';
|
export { FormLayout } from './FormLayout';
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export const WIDGETS = [
|
|||||||
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
|
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
|
||||||
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
|
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
|
||||||
{ key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true },
|
{ key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true },
|
||||||
{ key: 'persoenlicheAusruestung', label: 'Pers. Ausrüstung', defaultVisible: true },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WidgetKey = typeof WIDGETS[number]['key'];
|
export type WidgetKey = typeof WIDGETS[number]['key'];
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { settingsApi } from '../services/settings';
|
import { settingsApi } from '../services/settings';
|
||||||
@@ -294,10 +293,6 @@ function AdminSettings() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Admin', href: '/admin' },
|
|
||||||
{ label: 'Einstellungen' },
|
|
||||||
]} />
|
|
||||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||||
Admin-Einstellungen
|
Admin-Einstellungen
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -779,10 +779,6 @@ function AusruestungDetailPage() {
|
|||||||
<DetailLayout
|
<DetailLayout
|
||||||
title={equipment.bezeichnung}
|
title={equipment.bezeichnung}
|
||||||
backTo="/ausruestung"
|
backTo="/ausruestung"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Ausrüstung', href: '/ausruestung' },
|
|
||||||
{ label: equipment.bezeichnung },
|
|
||||||
]}
|
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={
|
actions={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
|||||||
@@ -304,11 +304,6 @@ function AusruestungForm() {
|
|||||||
<Container maxWidth="md">
|
<Container maxWidth="md">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
|
title={isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Ausrüstung', href: '/ausruestung' },
|
|
||||||
...(isEditMode && id ? [{ label: 'Detail', href: `/ausruestung/${id}` }] : []),
|
|
||||||
{ label: isEditMode ? 'Bearbeiten' : 'Neu' },
|
|
||||||
]}
|
|
||||||
backTo={isEditMode && id ? `/ausruestung/${id}` : '/ausruestung'}
|
backTo={isEditMode && id ? `/ausruestung/${id}` : '/ausruestung'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
@@ -286,10 +285,6 @@ export default function AusruestungsanfrageArtikelDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
|
||||||
{ label: isCreate ? 'Neuer Katalogartikel' : (artikel?.bezeichnung ?? 'Artikel') },
|
|
||||||
]} />
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<IconButton onClick={() => navigate('/ausruestungsanfrage?tab=2')}>
|
<IconButton onClick={() => navigate('/ausruestungsanfrage?tab=2')}>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
||||||
MenuItem, Select, FormControl, InputLabel, Autocomplete,
|
MenuItem, Select, FormControl, InputLabel, Autocomplete,
|
||||||
Checkbox, LinearProgress, Switch, FormControlLabel, Alert,
|
Checkbox, LinearProgress, Switch, FormControlLabel, Alert,
|
||||||
ToggleButton, ToggleButtonGroup, Stack, Divider,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
|
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
|
||||||
@@ -15,13 +14,10 @@ import {
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
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 { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
|
||||||
import type {
|
import type {
|
||||||
AusruestungAnfrage, AusruestungAnfrageDetailResponse,
|
AusruestungAnfrage, AusruestungAnfrageDetailResponse,
|
||||||
@@ -40,223 +36,10 @@ function formatOrderId(r: AusruestungAnfrage): string {
|
|||||||
|
|
||||||
// ── Helpers ──
|
// ── 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[] {
|
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
|
||||||
return positions.filter((p) => p.geliefert && !p.zuweisung_typ);
|
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<Record<number, PositionAssignment>>(() => {
|
|
||||||
const init: Record<number, PositionAssignment> = {};
|
|
||||||
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<PositionAssignment>) => {
|
|
||||||
setAssignments((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[posId]: { ...prev[posId], ...patch },
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipAll = () => {
|
|
||||||
const updated: Record<number, PositionAssignment> = {};
|
|
||||||
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 (
|
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
|
||||||
<DialogTitle>Gegenstände zuweisen</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
||||||
Wähle für jeden gelieferten Gegenstand, wie er erfasst werden soll.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack spacing={3} divider={<Divider />}>
|
|
||||||
{unassigned.map((pos) => {
|
|
||||||
const a = assignments[pos.id] ?? { typ: 'persoenlich' as const };
|
|
||||||
return (
|
|
||||||
<Box key={pos.id}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
|
||||||
<Typography variant="body2" fontWeight={600}>
|
|
||||||
{pos.bezeichnung}
|
|
||||||
</Typography>
|
|
||||||
<Chip label={`${pos.menge}x`} size="small" variant="outlined" />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={a.typ}
|
|
||||||
exclusive
|
|
||||||
size="small"
|
|
||||||
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
|
|
||||||
sx={{ mb: 1.5 }}
|
|
||||||
>
|
|
||||||
<ToggleButton value="ausruestung">Ausrüstung</ToggleButton>
|
|
||||||
<ToggleButton value="persoenlich">Persönlich</ToggleButton>
|
|
||||||
<ToggleButton value="keine">Nicht erfassen</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
|
|
||||||
{a.typ === 'ausruestung' && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
|
||||||
<Autocomplete
|
|
||||||
size="small"
|
|
||||||
options={vehicleOptions}
|
|
||||||
getOptionLabel={(o) => o.name}
|
|
||||||
value={vehicleOptions.find((v) => v.id === a.fahrzeugId) ?? null}
|
|
||||||
onChange={(_e, v) => updateAssignment(pos.id, { fahrzeugId: v?.id })}
|
|
||||||
renderInput={(params) => <TextField {...params} label="Fahrzeug" />}
|
|
||||||
sx={{ minWidth: 200, flex: 1 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="Standort"
|
|
||||||
value={a.standort ?? ''}
|
|
||||||
onChange={(e) => updateAssignment(pos.id, { standort: e.target.value })}
|
|
||||||
sx={{ minWidth: 160, flex: 1 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.typ === 'persoenlich' && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
|
||||||
<Autocomplete
|
|
||||||
size="small"
|
|
||||||
options={memberOptions}
|
|
||||||
getOptionLabel={(o) => 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) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label="Benutzer"
|
|
||||||
placeholder={anfrage.fuer_benutzer_name || anfrage.anfrager_name || ''}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
sx={{ minWidth: 200, flex: 1 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="Größe"
|
|
||||||
value={a.groesse ?? ''}
|
|
||||||
onChange={(e) => updateAssignment(pos.id, { groesse: e.target.value })}
|
|
||||||
sx={{ minWidth: 100 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="Kategorie"
|
|
||||||
value={a.kategorie ?? ''}
|
|
||||||
onChange={(e) => updateAssignment(pos.id, { kategorie: e.target.value })}
|
|
||||||
sx={{ minWidth: 140 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleSkipAll} color="inherit">
|
|
||||||
Alle überspringen
|
|
||||||
</Button>
|
|
||||||
<Box sx={{ flex: 1 }} />
|
|
||||||
<Button onClick={onClose}>Abbrechen</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={submitting}
|
|
||||||
startIcon={<AssignmentIcon />}
|
|
||||||
>
|
|
||||||
Zuweisen
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
// Component
|
// Component
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -282,9 +65,6 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
const [adminNotizen, setAdminNotizen] = useState('');
|
const [adminNotizen, setAdminNotizen] = useState('');
|
||||||
const [statusChangeValue, setStatusChangeValue] = useState('');
|
const [statusChangeValue, setStatusChangeValue] = useState('');
|
||||||
|
|
||||||
// Assignment dialog state
|
|
||||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
|
||||||
|
|
||||||
// Eigenschaften state for edit mode
|
// Eigenschaften state for edit mode
|
||||||
const [editItemEigenschaften, setEditItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
const [editItemEigenschaften, setEditItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
||||||
const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
|
const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
|
||||||
@@ -337,11 +117,11 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
setActionDialog(null);
|
setActionDialog(null);
|
||||||
setAdminNotizen('');
|
setAdminNotizen('');
|
||||||
setStatusChangeValue('');
|
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) {
|
if (variables.status === 'erledigt' && detail) {
|
||||||
const unassigned = getUnassignedPositions(detail.positionen);
|
const unassigned = getUnassignedPositions(detail.positionen);
|
||||||
if (unassigned.length > 0) {
|
if (unassigned.length > 0) {
|
||||||
setAssignmentOpen(true);
|
navigate(`/ausruestungsanfrage/${requestId}/zuweisung`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -427,10 +207,6 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
|
||||||
{ label: anfrage ? `Anfrage ${formatOrderId(anfrage)}` : 'Anfrage' },
|
|
||||||
]} />
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
|
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
|
||||||
@@ -441,10 +217,15 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
{anfrage?.bezeichnung && ` — ${anfrage.bezeichnung}`}
|
{anfrage?.bezeichnung && ` — ${anfrage.bezeichnung}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
{anfrage && (
|
{anfrage && (
|
||||||
<Chip
|
<>
|
||||||
label={AUSRUESTUNG_STATUS_LABELS[anfrage.status]}
|
{detail?.im_haus && (
|
||||||
color={AUSRUESTUNG_STATUS_COLORS[anfrage.status]}
|
<Chip label="Im Haus" color="success" />
|
||||||
/>
|
)}
|
||||||
|
<Chip
|
||||||
|
label={AUSRUESTUNG_STATUS_LABELS[anfrage.status]}
|
||||||
|
color={AUSRUESTUNG_STATUS_COLORS[anfrage.status]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -645,6 +426,9 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
{p.ist_ersatz && (
|
{p.ist_ersatz && (
|
||||||
<Chip label="Ersatzbeschaffung" size="small" color="warning" variant="outlined" />
|
<Chip label="Ersatzbeschaffung" size="small" color="warning" variant="outlined" />
|
||||||
)}
|
)}
|
||||||
|
{p.geliefert && detail?.im_haus && (
|
||||||
|
<Chip label="Im Haus" size="small" color="success" />
|
||||||
|
)}
|
||||||
{p.eigenschaften && p.eigenschaften.length > 0 && p.eigenschaften.map(e => (
|
{p.eigenschaften && p.eigenschaften.length > 0 && p.eigenschaften.map(e => (
|
||||||
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
|
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
|
||||||
))}
|
))}
|
||||||
@@ -743,7 +527,7 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<AssignmentIcon />}
|
startIcon={<AssignmentIcon />}
|
||||||
onClick={() => setAssignmentOpen(true)}
|
onClick={() => navigate(`/ausruestungsanfrage/${requestId}/zuweisung`)}
|
||||||
>
|
>
|
||||||
Zuweisen
|
Zuweisen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -786,20 +570,6 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Assignment dialog */}
|
|
||||||
{detail && anfrage && (
|
|
||||||
<ItemAssignmentDialog
|
|
||||||
open={assignmentOpen}
|
|
||||||
onClose={() => setAssignmentOpen(false)}
|
|
||||||
anfrage={anfrage}
|
|
||||||
positions={detail.positionen}
|
|
||||||
onSuccess={() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
@@ -169,10 +168,6 @@ export default function AusruestungsanfrageNeu() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
|
||||||
{ label: 'Neue Bestellung' },
|
|
||||||
]} />
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
|
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
@@ -237,11 +236,6 @@ export default function AusruestungsanfrageZuBestellung() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Box sx={{ maxWidth: 960, mx: 'auto', px: 2, py: 3 }}>
|
<Box sx={{ maxWidth: 960, mx: 'auto', px: 2, py: 3 }}>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
|
||||||
{ label: anfrage.bezeichnung || `Anfrage ${formatOrderId(anfrage)}`, href: `/ausruestungsanfrage/${requestId}` },
|
|
||||||
{ label: 'Bestellen' },
|
|
||||||
]} />
|
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
|
||||||
|
|||||||
254
frontend/src/pages/AusruestungsanfrageZuweisung.tsx
Normal file
254
frontend/src/pages/AusruestungsanfrageZuweisung.tsx
Normal file
@@ -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<Record<number, PositionAssignment>>({});
|
||||||
|
|
||||||
|
// Initialize assignments when unassigned positions load
|
||||||
|
useMemo(() => {
|
||||||
|
if (unassigned.length > 0 && Object.keys(assignments).length === 0) {
|
||||||
|
const init: Record<number, PositionAssignment> = {};
|
||||||
|
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<PositionAssignment>) => {
|
||||||
|
setAssignments((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[posId]: { ...prev[posId], ...patch },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipAll = () => {
|
||||||
|
const updated: Record<number, PositionAssignment> = {};
|
||||||
|
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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="md">
|
||||||
|
<PageHeader
|
||||||
|
title="Gegenstände zuweisen"
|
||||||
|
subtitle={detail?.anfrage.bezeichnung || undefined}
|
||||||
|
backTo={backPath}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<LinearProgress />
|
||||||
|
) : isError || !detail ? (
|
||||||
|
<Typography color="error">Fehler beim Laden der Anfrage.</Typography>
|
||||||
|
) : unassigned.length === 0 ? (
|
||||||
|
<Typography color="text.secondary">Keine unzugewiesenen Positionen vorhanden.</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Wähle für jeden gelieferten Gegenstand, wie er erfasst werden soll.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={3} divider={<Divider />}>
|
||||||
|
{unassigned.map((pos) => {
|
||||||
|
const a = assignments[pos.id] ?? { typ: 'persoenlich' as const };
|
||||||
|
return (
|
||||||
|
<Box key={pos.id}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{pos.bezeichnung}
|
||||||
|
</Typography>
|
||||||
|
<Chip label={`${pos.menge}x`} size="small" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={a.typ}
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
|
||||||
|
sx={{ mb: 1.5 }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="ausruestung">Ausrüstung</ToggleButton>
|
||||||
|
<ToggleButton value="persoenlich">Persönlich</ToggleButton>
|
||||||
|
<ToggleButton value="keine">Nicht erfassen</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
{a.typ === 'ausruestung' && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<Autocomplete
|
||||||
|
size="small"
|
||||||
|
options={vehicleOptions}
|
||||||
|
getOptionLabel={(o) => o.name}
|
||||||
|
value={vehicleOptions.find((v) => v.id === a.fahrzeugId) ?? null}
|
||||||
|
onChange={(_e, v) => updateAssignment(pos.id, { fahrzeugId: v?.id })}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Fahrzeug" />}
|
||||||
|
sx={{ minWidth: 200, flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Standort"
|
||||||
|
value={a.standort ?? ''}
|
||||||
|
onChange={(e) => updateAssignment(pos.id, { standort: e.target.value })}
|
||||||
|
sx={{ minWidth: 160, flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{a.typ === 'persoenlich' && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<Autocomplete
|
||||||
|
size="small"
|
||||||
|
options={memberOptions}
|
||||||
|
getOptionLabel={(o) => 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) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Benutzer"
|
||||||
|
placeholder={detail.anfrage.fuer_benutzer_name || detail.anfrage.anfrager_name || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
sx={{ minWidth: 200, flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Größe"
|
||||||
|
value={a.groesse ?? ''}
|
||||||
|
onChange={(e) => updateAssignment(pos.id, { groesse: e.target.value })}
|
||||||
|
sx={{ minWidth: 100 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Kategorie"
|
||||||
|
value={a.kategorie ?? ''}
|
||||||
|
onChange={(e) => updateAssignment(pos.id, { kategorie: e.target.value })}
|
||||||
|
sx={{ minWidth: 140 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 4 }}>
|
||||||
|
<Button onClick={handleSkipAll} color="inherit">
|
||||||
|
Alle überspringen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate(backPath)}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
startIcon={<AssignmentIcon />}
|
||||||
|
>
|
||||||
|
Zuweisen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -474,11 +474,20 @@ export default function BestellungDetail() {
|
|||||||
doc.text('Bestellinformationen', 10, curY);
|
doc.text('Bestellinformationen', 10, curY);
|
||||||
curY += 5;
|
curY += 5;
|
||||||
row('Bezeichnung', bestellung.bezeichnung);
|
row('Bezeichnung', bestellung.bezeichnung);
|
||||||
row('Status', BESTELLUNG_STATUS_LABELS[bestellung.status]);
|
|
||||||
row('Erstellt am', formatDate(bestellung.erstellt_am));
|
row('Erstellt am', formatDate(bestellung.erstellt_am));
|
||||||
if (bestellung.bestellt_am) row('Bestelldatum', formatDate(bestellung.bestellt_am));
|
if (bestellung.bestellt_am) row('Bestelldatum', formatDate(bestellung.bestellt_am));
|
||||||
curY += 5;
|
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 ──
|
// ── Line items table ──
|
||||||
const steuersatz = (parseFloat(String(bestellung.steuersatz)) || 20) / 100;
|
const steuersatz = (parseFloat(String(bestellung.steuersatz)) || 20) / 100;
|
||||||
const hasPrices = positionen.some((p) => p.einzelpreis != null && p.einzelpreis > 0);
|
const hasPrices = positionen.some((p) => p.einzelpreis != null && p.einzelpreis > 0);
|
||||||
@@ -619,13 +628,6 @@ export default function BestellungDetail() {
|
|||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.setTextColor(0, 0, 0);
|
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)
|
// Signature line (right)
|
||||||
doc.line(120, curY, 200, curY);
|
doc.line(120, curY, 200, curY);
|
||||||
|
|
||||||
@@ -673,22 +675,20 @@ export default function BestellungDetail() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={bestellung.bezeichnung}
|
title={bestellung.bezeichnung}
|
||||||
backTo="/bestellungen"
|
backTo="/bestellungen"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Bestellungen', href: '/bestellungen' },
|
|
||||||
{ label: bestellung.bezeichnung },
|
|
||||||
]}
|
|
||||||
actions={
|
actions={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
{canExport && !editMode && (
|
{canExport && !editMode && (
|
||||||
<Tooltip title={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung' ? 'Export erst nach Genehmigung verfügbar' : 'PDF Export'}>
|
<Tooltip title={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung' ? 'Export erst nach Genehmigung verfügbar' : 'PDF Export'}>
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<PdfIcon />}
|
||||||
onClick={generateBestellungDetailPdf}
|
onClick={generateBestellungDetailPdf}
|
||||||
color="primary"
|
|
||||||
disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'}
|
disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'}
|
||||||
>
|
>
|
||||||
<PdfIcon />
|
Export
|
||||||
</IconButton>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -77,10 +77,6 @@ export default function BestellungNeu() {
|
|||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Neue Bestellung"
|
title="Neue Bestellung"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Bestellungen', href: '/bestellungen' },
|
|
||||||
{ label: 'Neue Bestellung' },
|
|
||||||
]}
|
|
||||||
backTo="/bestellungen"
|
backTo="/bestellungen"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -244,10 +244,6 @@ function BookingFormPage() {
|
|||||||
<Container maxWidth="md" sx={{ py: 3 }}>
|
<Container maxWidth="md" sx={{ py: 3 }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'}
|
title={isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'}
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Fahrzeugbuchungen', href: '/fahrzeugbuchungen' },
|
|
||||||
{ label: isEdit ? 'Bearbeiten' : 'Neue Buchung' },
|
|
||||||
]}
|
|
||||||
backTo="/fahrzeugbuchungen"
|
backTo="/fahrzeugbuchungen"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1164,7 +1164,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
|
|
||||||
const stornoMut = useMutation({
|
const stornoMut = useMutation({
|
||||||
mutationFn: (id: number) => buchhaltungApi.stornoTransaktion(id),
|
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'),
|
onError: () => showError('Storno fehlgeschlagen'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1264,16 +1264,23 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
return sortDir === 'asc' ? cmp : -cmp;
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
<Tabs value={txSubTab} onChange={(_, v) => setTxSubTab(v)}>
|
<Tabs value={txSubTab} onChange={(_, v) => setTxSubTab(v)}>
|
||||||
<Tab label="Transaktionen" />
|
<Tab label="Offene Buchungen" />
|
||||||
|
<Tab label="Gebuchte Buchungen" />
|
||||||
<Tab label="Wiederkehrende Buchungen" />
|
<Tab label="Wiederkehrende Buchungen" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{txSubTab === 0 && (
|
{(txSubTab === 0 || txSubTab === 1) && (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
|
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
|
||||||
@@ -1286,14 +1293,6 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
|
||||||
<InputLabel>Status</InputLabel>
|
|
||||||
<Select size="small" value={filters.status ?? ''} label="Status"
|
|
||||||
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
|
|
||||||
<MenuItem value=""><em>Alle</em></MenuItem>
|
|
||||||
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 130 }}>
|
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||||
<InputLabel>Typ</InputLabel>
|
<InputLabel>Typ</InputLabel>
|
||||||
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
||||||
@@ -1362,15 +1361,14 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
Betrag
|
Betrag
|
||||||
</TableSortLabel>
|
</TableSortLabel>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
|
||||||
<TableCell>Aktionen</TableCell>
|
<TableCell>Aktionen</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sortedTransaktionen.length === 0 && (
|
{subTabTransaktionen.length === 0 && (
|
||||||
<TableRow><TableCell colSpan={8} align="center"><Typography color="text.secondary">Keine Transaktionen</Typography></TableCell></TableRow>
|
<TableRow><TableCell colSpan={7} align="center"><Typography color="text.secondary">Keine Transaktionen</Typography></TableCell></TableRow>
|
||||||
)}
|
)}
|
||||||
{sortedTransaktionen.map((t: Transaktion) => (
|
{subTabTransaktionen.map((t: Transaktion) => (
|
||||||
<TableRow key={t.id} hover>
|
<TableRow key={t.id} hover>
|
||||||
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
|
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
|
||||||
<TableCell>{fmtDate(t.datum)}</TableCell>
|
<TableCell>{fmtDate(t.datum)}</TableCell>
|
||||||
@@ -1394,44 +1392,35 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
{t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)}
|
{t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusChip status={t.status} labelMap={TRANSAKTION_STATUS_LABELS} colorMap={TRANSAKTION_STATUS_COLORS} />
|
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
{t.status === 'entwurf' && hasPermission('buchhaltung:edit') && (
|
{t.status === 'entwurf' && hasPermission('buchhaltung:edit') && (
|
||||||
<Tooltip title="Buchen">
|
<Tooltip title={!t.konto_id ? 'Kein Konto ausgewählt' : ''}>
|
||||||
<IconButton size="small" color="primary" onClick={() => buchenMut.mutate(t.id)}>
|
<span>
|
||||||
<BookmarkAdd fontSize="small" />
|
<Button size="small" variant="outlined" startIcon={<BookmarkAdd fontSize="small" />} disabled={!t.konto_id} onClick={() => buchenMut.mutate(t.id)}>
|
||||||
</IconButton>
|
Buchen
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{t.status === 'gebucht' && hasPermission('buchhaltung:edit') && (
|
{t.status === 'gebucht' && hasPermission('buchhaltung:edit') && (
|
||||||
<Tooltip title="Freigabe anfordern">
|
<Button size="small" variant="outlined" color="info" startIcon={<HowToReg fontSize="small" />} onClick={() => freigabeMut.mutate(t.id)}>
|
||||||
<IconButton size="small" color="info" onClick={() => freigabeMut.mutate(t.id)}>
|
Freigabe
|
||||||
<HowToReg fontSize="small" />
|
</Button>
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
{t.status === 'freigegeben' && hasPermission('buchhaltung:manage_accounts') && (
|
{t.status === 'freigegeben' && hasPermission('buchhaltung:manage_accounts') && (
|
||||||
<>
|
<>
|
||||||
<Tooltip title="Genehmigen">
|
<Button size="small" variant="outlined" color="success" startIcon={<ThumbUp fontSize="small" />} onClick={() => approveMut.mutate(t.id)}>
|
||||||
<IconButton size="small" color="success" onClick={() => approveMut.mutate(t.id)}>
|
Genehmigen
|
||||||
<ThumbUp fontSize="small" />
|
</Button>
|
||||||
</IconButton>
|
<Button size="small" variant="outlined" color="error" startIcon={<ThumbDown fontSize="small" />} onClick={() => rejectMut.mutate(t.id)}>
|
||||||
</Tooltip>
|
Ablehnen
|
||||||
<Tooltip title="Ablehnen">
|
</Button>
|
||||||
<IconButton size="small" color="error" onClick={() => rejectMut.mutate(t.id)}>
|
|
||||||
<ThumbDown fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && (
|
{(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && (
|
||||||
<Tooltip title="Stornieren">
|
<Button size="small" variant="outlined" color="warning" startIcon={<Cancel fontSize="small" />} onClick={() => stornoMut.mutate(t.id)}>
|
||||||
<IconButton size="small" color="warning" onClick={() => stornoMut.mutate(t.id)}>
|
Stornieren
|
||||||
<Cancel fontSize="small" />
|
</Button>
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
{t.status === 'entwurf' && hasPermission('buchhaltung:delete') && (
|
{t.status === 'entwurf' && hasPermission('buchhaltung:delete') && (
|
||||||
<Tooltip title="Löschen">
|
<Tooltip title="Löschen">
|
||||||
@@ -1483,7 +1472,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{txSubTab === 1 && (
|
{txSubTab === 2 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setWiederkehrendDialog({ open: true })}>Anlegen</Button>}
|
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setWiederkehrendDialog({ open: true })}>Anlegen</Button>}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { ArrowBack } from '@mui/icons-material';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { buchhaltungApi } from '../services/buchhaltung';
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types';
|
import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types';
|
||||||
import type { BankkontoStatementRow } from '../types/buchhaltung.types';
|
import type { BankkontoStatementRow } from '../types/buchhaltung.types';
|
||||||
@@ -62,10 +61,6 @@ export default function BuchhaltungBankkontoDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Buchhaltung', href: '/buchhaltung' },
|
|
||||||
{ label: data?.bankkonto?.bezeichnung || 'Bankkonto' },
|
|
||||||
]} />
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<Tooltip title="Zurueck">
|
<Tooltip title="Zurueck">
|
||||||
<IconButton onClick={() => navigate('/buchhaltung?tab=2')}>
|
<IconButton onClick={() => navigate('/buchhaltung?tab=2')}>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material';
|
import { ArrowBack, KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { buchhaltungApi } from '../services/buchhaltung';
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
|
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
|
||||||
import type { AusgabenTyp, BuchhaltungAudit } from '../types/buchhaltung.types';
|
import type { AusgabenTyp, BuchhaltungAudit } from '../types/buchhaltung.types';
|
||||||
@@ -133,10 +132,6 @@ export default function BuchhaltungKontoDetail() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Box sx={{ p: 3 }}>
|
<Box sx={{ p: 3 }}>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Buchhaltung', href: '/buchhaltung' },
|
|
||||||
{ label: `${konto.kontonummer} — ${konto.bezeichnung}` },
|
|
||||||
]} />
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung')}>
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung')}>
|
||||||
Zurück
|
Zurück
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material';
|
import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { buchhaltungApi } from '../services/buchhaltung';
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types';
|
import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types';
|
||||||
@@ -173,11 +172,6 @@ export default function BuchhaltungKontoManage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Buchhaltung', href: '/buchhaltung' },
|
|
||||||
{ label: `${konto.kontonummer} — ${konto.bezeichnung}`, href: `/buchhaltung/konto/${id}` },
|
|
||||||
{ label: 'Verwalten' },
|
|
||||||
]} />
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung?tab=2')}>
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung?tab=2')}>
|
||||||
Zurück
|
Zurück
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { ArrowBack, CheckCircle, Cancel, RemoveCircle } from '@mui/icons-materia
|
|||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { checklistenApi } from '../services/checklisten';
|
import { checklistenApi } from '../services/checklisten';
|
||||||
@@ -244,10 +243,6 @@ export default function ChecklistAusfuehrung() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Checklisten', href: '/checklisten' },
|
|
||||||
{ label: execution.vorlage_name ?? 'Checkliste' },
|
|
||||||
]} />
|
|
||||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mb: 2 }} size="small">
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mb: 2 }} size="small">
|
||||||
Checklisten
|
Checklisten
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
|
|||||||
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
|
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
|
||||||
import ChecklistWidget from '../components/dashboard/ChecklistWidget';
|
import ChecklistWidget from '../components/dashboard/ChecklistWidget';
|
||||||
import BuchhaltungWidget from '../components/dashboard/BuchhaltungWidget';
|
import BuchhaltungWidget from '../components/dashboard/BuchhaltungWidget';
|
||||||
import PersoenlicheAusruestungWidget from '../components/dashboard/PersoenlicheAusruestungWidget';
|
|
||||||
import { preferencesApi } from '../services/settings';
|
import { preferencesApi } from '../services/settings';
|
||||||
import { configApi } from '../services/config';
|
import { configApi } from '../services/config';
|
||||||
import { WidgetKey } from '../constants/widgets';
|
import { WidgetKey } from '../constants/widgets';
|
||||||
@@ -86,7 +85,7 @@ const BUILTIN_GROUPS: { name: string; title: string }[] = [
|
|||||||
|
|
||||||
// Default widget order per group (used when no preference is set)
|
// Default widget order per group (used when no preference is set)
|
||||||
const DEFAULT_ORDER: Record<string, string[]> = {
|
const DEFAULT_ORDER: Record<string, string[]> = {
|
||||||
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung', 'persoenlicheAusruestung'],
|
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung'],
|
||||||
kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'],
|
kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'],
|
||||||
dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'],
|
dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'],
|
||||||
information: ['links', 'bannerWidget'],
|
information: ['links', 'bannerWidget'],
|
||||||
@@ -139,7 +138,6 @@ function Dashboard() {
|
|||||||
{ key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> },
|
{ key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> },
|
||||||
{ key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> },
|
{ key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> },
|
||||||
{ key: 'buchhaltung', widgetKey: 'buchhaltung', permission: 'buchhaltung:widget', component: <BuchhaltungWidget /> },
|
{ key: 'buchhaltung', widgetKey: 'buchhaltung', permission: 'buchhaltung:widget', component: <BuchhaltungWidget /> },
|
||||||
{ key: 'persoenlicheAusruestung', widgetKey: 'persoenlicheAusruestung', permission: 'persoenliche_ausruestung:view', component: <PersoenlicheAusruestungWidget /> },
|
|
||||||
],
|
],
|
||||||
kalender: [
|
kalender: [
|
||||||
{ key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> },
|
{ key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> },
|
||||||
@@ -176,13 +174,20 @@ function Dashboard() {
|
|||||||
if (preferences?.widgetOrder) {
|
if (preferences?.widgetOrder) {
|
||||||
setLocalOrder((prev) => {
|
setLocalOrder((prev) => {
|
||||||
const merged = { ...prev };
|
const merged = { ...prev };
|
||||||
|
|
||||||
|
// Build a set of all widget keys that are explicitly placed in some saved group
|
||||||
|
const allSavedKeys = new Set<string>();
|
||||||
|
for (const groupKeys of Object.values(preferences.widgetOrder)) {
|
||||||
|
for (const k of (groupKeys as string[])) allSavedKeys.add(k);
|
||||||
|
}
|
||||||
|
|
||||||
for (const group of Object.keys(DEFAULT_ORDER)) {
|
for (const group of Object.keys(DEFAULT_ORDER)) {
|
||||||
if (preferences.widgetOrder[group]) {
|
if (preferences.widgetOrder[group]) {
|
||||||
// Merge: saved order first, then any new widgets not in saved order
|
// Merge: saved order first, then any new widgets not in saved order
|
||||||
const saved = preferences.widgetOrder[group] as string[];
|
const saved = preferences.widgetOrder[group] as string[];
|
||||||
const allKeys = DEFAULT_ORDER[group];
|
const allKeys = DEFAULT_ORDER[group];
|
||||||
const ordered = saved.filter((k: string) => allKeys.includes(k));
|
const ordered = saved.filter((k: string) => allKeys.includes(k));
|
||||||
const remaining = allKeys.filter((k) => !ordered.includes(k));
|
const remaining = allKeys.filter((k) => !allSavedKeys.has(k));
|
||||||
merged[group] = [...ordered, ...remaining];
|
merged[group] = [...ordered, ...remaining];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,10 +284,6 @@ function EinsatzDetail() {
|
|||||||
title={`Einsatz ${einsatz.einsatz_nr}`}
|
title={`Einsatz ${einsatz.einsatz_nr}`}
|
||||||
subtitle={address || undefined}
|
subtitle={address || undefined}
|
||||||
backTo="/einsaetze"
|
backTo="/einsaetze"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Einsätze', href: '/einsaetze' },
|
|
||||||
{ label: `Einsatz ${einsatz.einsatz_nr}` },
|
|
||||||
]}
|
|
||||||
actions={
|
actions={
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<Chip
|
<Chip
|
||||||
|
|||||||
@@ -1006,10 +1006,6 @@ function FahrzeugDetail() {
|
|||||||
<DetailLayout
|
<DetailLayout
|
||||||
title={titleText}
|
title={titleText}
|
||||||
backTo="/fahrzeuge"
|
backTo="/fahrzeuge"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Fahrzeuge', href: '/fahrzeuge' },
|
|
||||||
{ label: titleText },
|
|
||||||
]}
|
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={
|
actions={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
|||||||
@@ -246,11 +246,6 @@ function FahrzeugForm() {
|
|||||||
<Container maxWidth="md">
|
<Container maxWidth="md">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
|
title={isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Fahrzeuge', href: '/fahrzeuge' },
|
|
||||||
...(isEditMode && id ? [{ label: 'Detail', href: `/fahrzeuge/${id}` }] : []),
|
|
||||||
{ label: isEditMode ? 'Bearbeiten' : 'Neu' },
|
|
||||||
]}
|
|
||||||
backTo={isEditMode && id ? `/fahrzeuge/${id}` : '/fahrzeuge'}
|
backTo={isEditMode && id ? `/fahrzeuge/${id}` : '/fahrzeuge'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
@@ -144,10 +143,6 @@ export default function HaushaltsplanDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Haushaltsplan', href: '/haushaltsplan' },
|
|
||||||
{ label: planung.bezeichnung },
|
|
||||||
]} />
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton>
|
<IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton>
|
||||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography>
|
<Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography>
|
||||||
|
|||||||
@@ -264,10 +264,6 @@ export default function IssueDetail() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={`${formatIssueId(issue)} — ${issue.titel}`}
|
title={`${formatIssueId(issue)} — ${issue.titel}`}
|
||||||
backTo="/issues"
|
backTo="/issues"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Issues', href: '/issues' },
|
|
||||||
{ label: `${formatIssueId(issue)} — ${issue.titel}` },
|
|
||||||
]}
|
|
||||||
actions={
|
actions={
|
||||||
<Chip
|
<Chip
|
||||||
label={getStatusLabel(statuses, issue.status)}
|
label={getStatusLabel(statuses, issue.status)}
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ export default function IssueNeu() {
|
|||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Neues Issue"
|
title="Neues Issue"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Issues', href: '/issues' },
|
|
||||||
{ label: 'Neues Issue' },
|
|
||||||
]}
|
|
||||||
backTo="/issues"
|
backTo="/issues"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -162,11 +162,6 @@ export default function LieferantDetail() {
|
|||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={isNew ? 'Neuer Lieferant' : vendor!.name}
|
title={isNew ? 'Neuer Lieferant' : vendor!.name}
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Bestellungen', href: '/bestellungen' },
|
|
||||||
{ label: 'Lieferanten', href: '/bestellungen?tab=1' },
|
|
||||||
{ label: isNew ? 'Neu' : vendor!.name },
|
|
||||||
]}
|
|
||||||
backTo="/bestellungen?tab=1"
|
backTo="/bestellungen?tab=1"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -439,10 +439,6 @@ function MitgliedDetail() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={displayName}
|
title={displayName}
|
||||||
backTo="/mitglieder"
|
backTo="/mitglieder"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: 'Mitglieder', href: '/mitglieder' },
|
|
||||||
{ label: displayName },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Header card */}
|
{/* Header card */}
|
||||||
@@ -820,7 +816,7 @@ function MitgliedDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Uniform sizing */}
|
{/* Uniform sizing + Personal equipment (merged) */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
@@ -858,48 +854,43 @@ function MitgliedDetail() {
|
|||||||
<FieldRow label="Schuhgröße" value={profile?.schuhgroesse ?? null} />
|
<FieldRow label="Schuhgröße" value={profile?.schuhgroesse ?? null} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(hasPermission('persoenliche_ausruestung:view') || hasPermission('persoenliche_ausruestung:view_all')) && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
|
Persönliche Ausrüstung
|
||||||
|
</Typography>
|
||||||
|
{personalEquipmentLoading ? (
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
) : personalEquipment.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
Keine persönlichen Gegenstände erfasst
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
personalEquipment.map((item) => (
|
||||||
|
<Box key={item.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
|
||||||
|
{item.kategorie && (
|
||||||
|
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={ZUSTAND_LABELS[item.zustand]}
|
||||||
|
color={ZUSTAND_COLORS[item.zustand]}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Personal equipment */}
|
|
||||||
{(hasPermission('persoenliche_ausruestung:view') || hasPermission('persoenliche_ausruestung:view_all')) && (
|
|
||||||
<Grid item xs={12} md={6}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
avatar={<SecurityIcon color="primary" />}
|
|
||||||
title="Persönliche Ausrüstung"
|
|
||||||
/>
|
|
||||||
<CardContent>
|
|
||||||
{personalEquipmentLoading ? (
|
|
||||||
<CircularProgress size={24} />
|
|
||||||
) : personalEquipment.length === 0 ? (
|
|
||||||
<Typography color="text.secondary" variant="body2">
|
|
||||||
Keine persönlichen Gegenstände erfasst
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
personalEquipment.map((item) => (
|
|
||||||
<Box key={item.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
|
|
||||||
{item.kategorie && (
|
|
||||||
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Chip
|
|
||||||
label={ZUSTAND_LABELS[item.zustand]}
|
|
||||||
color={ZUSTAND_COLORS[item.zustand]}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Driving licenses */}
|
{/* Driving licenses */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -5,55 +5,42 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Stack,
|
Tab,
|
||||||
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Add as AddIcon } from '@mui/icons-material';
|
import { Add as AddIcon } from '@mui/icons-material';
|
||||||
import CheckroomIcon from '@mui/icons-material/Checkroom';
|
import CheckroomIcon from '@mui/icons-material/Checkroom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { personalEquipmentApi } from '../services/personalEquipment';
|
import { personalEquipmentApi } from '../services/personalEquipment';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
|
||||||
import { membersService } from '../services/members';
|
import { membersService } from '../services/members';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { FormDialog, PageHeader } from '../components/templates';
|
import { PageHeader } from '../components/templates';
|
||||||
|
import { KatalogTab } from '../components/shared/KatalogTab';
|
||||||
import {
|
import {
|
||||||
ZUSTAND_LABELS,
|
ZUSTAND_LABELS,
|
||||||
ZUSTAND_COLORS,
|
ZUSTAND_COLORS,
|
||||||
} from '../types/personalEquipment.types';
|
} from '../types/personalEquipment.types';
|
||||||
import type {
|
import type { PersoenlicheAusruestungZustand } from '../types/personalEquipment.types';
|
||||||
PersoenlicheAusruestungZustand,
|
|
||||||
CreatePersoenlicheAusruestungPayload,
|
|
||||||
} from '../types/personalEquipment.types';
|
|
||||||
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
|
|
||||||
|
|
||||||
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
|
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
|
||||||
|
|
||||||
function PersoenlicheAusruestungPage() {
|
function PersoenlicheAusruestungPage() {
|
||||||
const queryClient = useQueryClient();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
const { showSuccess, showError } = useNotification();
|
|
||||||
|
|
||||||
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
|
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
|
||||||
const canCreate = hasPermission('persoenliche_ausruestung:create');
|
const canCreate = hasPermission('persoenliche_ausruestung:create');
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
const [filterZustand, setFilterZustand] = useState<string>('');
|
const [filterZustand, setFilterZustand] = useState<string>('');
|
||||||
const [filterUser, setFilterUser] = useState<string>('');
|
const [filterUser, setFilterUser] = useState<string>('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formBezeichnung, setFormBezeichnung] = useState<string | AusruestungArtikel | null>(null);
|
|
||||||
const [formKategorie, setFormKategorie] = useState('');
|
|
||||||
const [formUserId, setFormUserId] = useState<{ id: string; name: string } | null>(null);
|
|
||||||
const [formBenutzerName, setFormBenutzerName] = useState('');
|
|
||||||
const [formGroesse, setFormGroesse] = useState('');
|
|
||||||
const [formZustand, setFormZustand] = useState<PersoenlicheAusruestungZustand>('gut');
|
|
||||||
const [formNotizen, setFormNotizen] = useState('');
|
|
||||||
|
|
||||||
// Data queries
|
// Data queries
|
||||||
const { data: items, isLoading } = useQuery({
|
const { data: items, isLoading } = useQuery({
|
||||||
queryKey: ['persoenliche-ausruestung', 'all'],
|
queryKey: ['persoenliche-ausruestung', 'all'],
|
||||||
@@ -61,12 +48,6 @@ function PersoenlicheAusruestungPage() {
|
|||||||
staleTime: 2 * 60 * 1000,
|
staleTime: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: catalogItems } = useQuery({
|
|
||||||
queryKey: ['ausruestungsanfrage-items-catalog'],
|
|
||||||
queryFn: () => ausruestungsanfrageApi.getItems(),
|
|
||||||
staleTime: 10 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: membersList } = useQuery({
|
const { data: membersList } = useQuery({
|
||||||
queryKey: ['members-list-compact'],
|
queryKey: ['members-list-compact'],
|
||||||
queryFn: () => membersService.getMembers({ pageSize: 500 }),
|
queryFn: () => membersService.getMembers({ pageSize: 500 }),
|
||||||
@@ -81,48 +62,6 @@ function PersoenlicheAusruestungPage() {
|
|||||||
}));
|
}));
|
||||||
}, [membersList]);
|
}, [membersList]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: CreatePersoenlicheAusruestungPayload) => personalEquipmentApi.create(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
|
|
||||||
showSuccess('Persönliche Ausrüstung erstellt');
|
|
||||||
setDialogOpen(false);
|
|
||||||
resetForm();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showError('Fehler beim Erstellen');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormBezeichnung(null);
|
|
||||||
setFormKategorie('');
|
|
||||||
setFormUserId(null);
|
|
||||||
setFormBenutzerName('');
|
|
||||||
setFormGroesse('');
|
|
||||||
setFormZustand('gut');
|
|
||||||
setFormNotizen('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
const bezeichnung = typeof formBezeichnung === 'string'
|
|
||||||
? formBezeichnung
|
|
||||||
: formBezeichnung?.bezeichnung ?? '';
|
|
||||||
if (!bezeichnung.trim()) return;
|
|
||||||
|
|
||||||
const payload: CreatePersoenlicheAusruestungPayload = {
|
|
||||||
bezeichnung: bezeichnung.trim(),
|
|
||||||
kategorie: formKategorie || undefined,
|
|
||||||
artikel_id: typeof formBezeichnung === 'object' && formBezeichnung ? formBezeichnung.id : undefined,
|
|
||||||
user_id: formUserId?.id || undefined,
|
|
||||||
benutzer_name: formBenutzerName || undefined,
|
|
||||||
groesse: formGroesse || undefined,
|
|
||||||
zustand: formZustand,
|
|
||||||
notizen: formNotizen || undefined,
|
|
||||||
};
|
|
||||||
createMutation.mutate(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter logic
|
// Filter logic
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = items ?? [];
|
let result = items ?? [];
|
||||||
@@ -148,9 +87,15 @@ function PersoenlicheAusruestungPage() {
|
|||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Persönliche Ausrüstung"
|
title="Persönliche Ausrüstung"
|
||||||
breadcrumbs={[{ label: 'Persönliche Ausrüstung' }]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onChange={(_e, v) => setActiveTab(v)} sx={{ mb: 3 }}>
|
||||||
|
<Tab label="Zuweisungen" />
|
||||||
|
<Tab label="Katalog" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<>
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -293,108 +238,21 @@ function PersoenlicheAusruestungPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 1 && <KatalogTab />}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
{canCreate && (
|
{canCreate && activeTab === 0 && (
|
||||||
<ChatAwareFab
|
<ChatAwareFab
|
||||||
onClick={() => setDialogOpen(true)}
|
onClick={() => navigate('/persoenliche-ausruestung/neu')}
|
||||||
aria-label="Persönliche Ausrüstung hinzufügen"
|
aria-label="Persönliche Ausrüstung hinzufügen"
|
||||||
>
|
>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</ChatAwareFab>
|
</ChatAwareFab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Dialog */}
|
|
||||||
<FormDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={() => { setDialogOpen(false); resetForm(); }}
|
|
||||||
title="Persönliche Ausrüstung hinzufügen"
|
|
||||||
onSubmit={handleCreate}
|
|
||||||
submitLabel="Erstellen"
|
|
||||||
isSubmitting={createMutation.isPending}
|
|
||||||
>
|
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
||||||
<Autocomplete
|
|
||||||
freeSolo
|
|
||||||
options={catalogItems ?? []}
|
|
||||||
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung}
|
|
||||||
value={formBezeichnung}
|
|
||||||
onChange={(_e, v) => {
|
|
||||||
setFormBezeichnung(v);
|
|
||||||
if (v && typeof v !== 'string' && v.kategorie) {
|
|
||||||
setFormKategorie(v.kategorie);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInputChange={(_e, v) => {
|
|
||||||
if (typeof formBezeichnung === 'string' || formBezeichnung === null) {
|
|
||||||
setFormBezeichnung(v);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} label="Bezeichnung" required size="small" />
|
|
||||||
)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{canViewAll && (
|
|
||||||
<Autocomplete
|
|
||||||
options={memberOptions}
|
|
||||||
getOptionLabel={(o) => o.name}
|
|
||||||
value={formUserId}
|
|
||||||
onChange={(_e, v) => setFormUserId(v)}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} label="Benutzer" size="small" />
|
|
||||||
)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!canViewAll && (
|
|
||||||
<TextField
|
|
||||||
label="Benutzer (Name)"
|
|
||||||
size="small"
|
|
||||||
value={formBenutzerName}
|
|
||||||
onChange={(e) => setFormBenutzerName(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Kategorie"
|
|
||||||
size="small"
|
|
||||||
value={formKategorie}
|
|
||||||
onChange={(e) => setFormKategorie(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Größe"
|
|
||||||
size="small"
|
|
||||||
value={formGroesse}
|
|
||||||
onChange={(e) => setFormGroesse(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Zustand"
|
|
||||||
select
|
|
||||||
size="small"
|
|
||||||
value={formZustand}
|
|
||||||
onChange={(e) => setFormZustand(e.target.value as PersoenlicheAusruestungZustand)}
|
|
||||||
>
|
|
||||||
{ZUSTAND_OPTIONS.map(([key, label]) => (
|
|
||||||
<MenuItem key={key} value={key}>{label}</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Notizen"
|
|
||||||
size="small"
|
|
||||||
multiline
|
|
||||||
rows={2}
|
|
||||||
value={formNotizen}
|
|
||||||
onChange={(e) => setFormNotizen(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</FormDialog>
|
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
200
frontend/src/pages/PersoenlicheAusruestungNeu.tsx
Normal file
200
frontend/src/pages/PersoenlicheAusruestungNeu.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { personalEquipmentApi } from '../services/personalEquipment';
|
||||||
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
|
import { membersService } from '../services/members';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { PageHeader } from '../components/templates';
|
||||||
|
import { ZUSTAND_LABELS } from '../types/personalEquipment.types';
|
||||||
|
import type {
|
||||||
|
PersoenlicheAusruestungZustand,
|
||||||
|
CreatePersoenlicheAusruestungPayload,
|
||||||
|
} from '../types/personalEquipment.types';
|
||||||
|
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
|
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
|
||||||
|
|
||||||
|
export default function PersoenlicheAusruestungNeu() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
|
||||||
|
|
||||||
|
const [formBezeichnung, setFormBezeichnung] = useState<string | AusruestungArtikel | null>(null);
|
||||||
|
const [formKategorie, setFormKategorie] = useState('');
|
||||||
|
const [formUserId, setFormUserId] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [formBenutzerName, setFormBenutzerName] = useState('');
|
||||||
|
const [formGroesse, setFormGroesse] = useState('');
|
||||||
|
const [formZustand, setFormZustand] = useState<PersoenlicheAusruestungZustand>('gut');
|
||||||
|
const [formNotizen, setFormNotizen] = useState('');
|
||||||
|
|
||||||
|
const { data: catalogItems } = useQuery({
|
||||||
|
queryKey: ['ausruestungsanfrage-items-catalog'],
|
||||||
|
queryFn: () => ausruestungsanfrageApi.getItems(),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: membersList } = useQuery({
|
||||||
|
queryKey: ['members-list-compact'],
|
||||||
|
queryFn: () => membersService.getMembers({ pageSize: 500 }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
enabled: canViewAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberOptions = useMemo(() => {
|
||||||
|
return (membersList?.items ?? []).map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email,
|
||||||
|
}));
|
||||||
|
}, [membersList]);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreatePersoenlicheAusruestungPayload) => personalEquipmentApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
|
||||||
|
showSuccess('Persönliche Ausrüstung erstellt');
|
||||||
|
navigate('/persoenliche-ausruestung');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Fehler beim Erstellen');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
const bezeichnung = typeof formBezeichnung === 'string'
|
||||||
|
? formBezeichnung
|
||||||
|
: formBezeichnung?.bezeichnung ?? '';
|
||||||
|
if (!bezeichnung.trim()) return;
|
||||||
|
|
||||||
|
const payload: CreatePersoenlicheAusruestungPayload = {
|
||||||
|
bezeichnung: bezeichnung.trim(),
|
||||||
|
kategorie: formKategorie || undefined,
|
||||||
|
artikel_id: typeof formBezeichnung === 'object' && formBezeichnung ? formBezeichnung.id : undefined,
|
||||||
|
user_id: formUserId?.id || undefined,
|
||||||
|
benutzer_name: formBenutzerName || undefined,
|
||||||
|
groesse: formGroesse || undefined,
|
||||||
|
zustand: formZustand,
|
||||||
|
notizen: formNotizen || undefined,
|
||||||
|
};
|
||||||
|
createMutation.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<PageHeader
|
||||||
|
title="Neue Ausrüstung zuweisen"
|
||||||
|
backTo="/persoenliche-ausruestung"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={catalogItems ?? []}
|
||||||
|
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung}
|
||||||
|
value={formBezeichnung}
|
||||||
|
onChange={(_e, v) => {
|
||||||
|
setFormBezeichnung(v);
|
||||||
|
if (v && typeof v !== 'string' && v.kategorie) {
|
||||||
|
setFormKategorie(v.kategorie);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInputChange={(_e, v) => {
|
||||||
|
if (typeof formBezeichnung === 'string' || formBezeichnung === null) {
|
||||||
|
setFormBezeichnung(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Bezeichnung" required size="small" />
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{canViewAll && (
|
||||||
|
<Autocomplete
|
||||||
|
options={memberOptions}
|
||||||
|
getOptionLabel={(o) => o.name}
|
||||||
|
value={formUserId}
|
||||||
|
onChange={(_e, v) => setFormUserId(v)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Benutzer" size="small" />
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canViewAll && (
|
||||||
|
<TextField
|
||||||
|
label="Benutzer (Name)"
|
||||||
|
size="small"
|
||||||
|
value={formBenutzerName}
|
||||||
|
onChange={(e) => setFormBenutzerName(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Kategorie"
|
||||||
|
size="small"
|
||||||
|
value={formKategorie}
|
||||||
|
onChange={(e) => setFormKategorie(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Größe"
|
||||||
|
size="small"
|
||||||
|
value={formGroesse}
|
||||||
|
onChange={(e) => setFormGroesse(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Zustand"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={formZustand}
|
||||||
|
onChange={(e) => setFormZustand(e.target.value as PersoenlicheAusruestungZustand)}
|
||||||
|
>
|
||||||
|
{ZUSTAND_OPTIONS.map(([key, label]) => (
|
||||||
|
<MenuItem key={key} value={key}>{label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Notizen"
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={formNotizen}
|
||||||
|
onChange={(e) => setFormNotizen(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}>
|
||||||
|
<Button onClick={() => navigate('/persoenliche-ausruestung')}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createMutation.isPending || !(typeof formBezeichnung === 'string' ? formBezeichnung.trim() : formBezeichnung?.bezeichnung?.trim())}
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { trainingApi } from '../services/training';
|
import { trainingApi } from '../services/training';
|
||||||
import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types';
|
import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types';
|
||||||
@@ -333,10 +332,6 @@ export default function UebungDetail() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Kalender', href: '/kalender' },
|
|
||||||
{ label: event.titel || 'Übung' },
|
|
||||||
]} />
|
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Button
|
<Button
|
||||||
startIcon={<BackIcon />}
|
startIcon={<BackIcon />}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
Category as CategoryIcon,
|
Category as CategoryIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { PageBreadcrumbs } from '../components/common';
|
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { eventsApi } from '../services/events';
|
import { eventsApi } from '../services/events';
|
||||||
@@ -340,10 +339,6 @@ export default function VeranstaltungKategorien() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg" sx={{ py: 3 }}>
|
<Container maxWidth="lg" sx={{ py: 3 }}>
|
||||||
<PageBreadcrumbs items={[
|
|
||||||
{ label: 'Veranstaltungen', href: '/veranstaltungen' },
|
|
||||||
{ label: 'Kategorien' },
|
|
||||||
]} />
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -295,6 +295,60 @@ const darkThemeOptions: ThemeOptions = {
|
|||||||
main: '#22c55e',
|
main: '#22c55e',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
...lightThemeOptions.components,
|
||||||
|
MuiTableHead: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiTableCell-head': {
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.6875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
borderBottom: '2px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTableRow: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'&.MuiTableRow-hover:hover': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTableCell: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 14,
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
|
||||||
|
transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDivider: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const lightTheme = createTheme(lightThemeOptions);
|
export const lightTheme = createTheme(lightThemeOptions);
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export interface AusruestungAnfrageDetailResponse {
|
|||||||
anfrage: AusruestungAnfrage;
|
anfrage: AusruestungAnfrage;
|
||||||
positionen: AusruestungAnfragePosition[];
|
positionen: AusruestungAnfragePosition[];
|
||||||
linked_bestellungen?: { id: number; bezeichnung: string; status: string }[];
|
linked_bestellungen?: { id: number; bezeichnung: string; status: string }[];
|
||||||
|
im_haus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Overview ──
|
// ── Overview ──
|
||||||
|
|||||||
Reference in New Issue
Block a user