Files
dashboard/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx
Matthias Hochmeister b477e5dbe0 feat: user data purge, breadcrumbs, first-login dialog, widget consolidation, bookkeeping cascade
- Admin can purge all personal data for a user (POST /api/admin/users/:userId/purge-data)
  while keeping the account; clears profile, notifications, bookings, ical tokens, preferences
- Add isNewUser flag to auth callback response; first-login dialog prompts for Standesbuchnummer
- Add PageBreadcrumbs component and apply to 18 sub-pages across the app
- Cascade budget_typ changes from parent pot to all children recursively, converting amounts
  (detailliert→einfach: sum into budget_gesamt; einfach→detailliert: zero all for redistribution)
- Migrate NextcloudTalkWidget to use shared WidgetCard template for consistent header styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:15:28 +02:00

471 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, useEffect } from 'react';
import {
Box, Typography, Paper, Button, IconButton, Chip,
Table, TableBody, TableCell, TableHead, TableRow,
Autocomplete, TextField, Alert, CircularProgress,
Dialog, DialogTitle, DialogContent, DialogActions,
LinearProgress,
} from '@mui/material';
import {
ArrowBack,
Add as AddIcon,
ShoppingCart as ShoppingCartIcon,
} from '@mui/icons-material';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageBreadcrumbs } from '../components/common';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { bestellungApi } from '../services/bestellung';
import { AUSRUESTUNG_STATUS_LABELS } from '../types/ausruestungsanfrage.types';
import type {
AusruestungAnfrage,
AusruestungAnfragePosition,
CreateOrdersRequest,
} from '../types/ausruestungsanfrage.types';
import type { Lieferant } from '../types/bestellung.types';
// ── Helpers ──
function formatOrderId(r: AusruestungAnfrage): string {
if (r.bestell_jahr && r.bestell_nummer) {
return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`;
}
return `#${r.id}`;
}
interface VendorAssignment {
lieferantId: number;
lieferantName: string;
}
interface VendorGroup {
lieferantId: number;
lieferantName: string;
positionen: AusruestungAnfragePosition[];
}
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function AusruestungsanfrageZuBestellung() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const requestId = Number(id);
const canManageVendors = hasPermission('bestellungen:manage_vendors');
// ── Data ──
const { data: detail, isLoading, isError } = useQuery({
queryKey: ['ausruestungsanfrage', requestId],
queryFn: () => ausruestungsanfrageApi.getRequest(requestId),
enabled: !!requestId,
});
const { data: vendors = [] } = useQuery({
queryKey: ['bestellung-vendors'],
queryFn: () => bestellungApi.getVendors(),
});
const { data: catalogItems = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'items-for-edit'],
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
staleTime: 5 * 60 * 1000,
});
const anfrage = detail?.anfrage;
const positionen: AusruestungAnfragePosition[] = detail?.positionen ?? [];
// ── State ──
const [assignments, setAssignments] = useState<Record<number, VendorAssignment | null>>({});
const [orderNames, setOrderNames] = useState<Record<number, string>>({});
// New vendor dialog
const [newVendorDialog, setNewVendorDialog] = useState(false);
const [newVendorName, setNewVendorName] = useState('');
const [newVendorKontakt, setNewVendorKontakt] = useState('');
const [newVendorEmail, setNewVendorEmail] = useState('');
const [newVendorTelefon, setNewVendorTelefon] = useState('');
// Track which position triggered the new-vendor dialog
const [newVendorTargetPosId, setNewVendorTargetPosId] = useState<number | null>(null);
// ── Pre-populate vendor assignments from catalog items' preferred vendor ──
useEffect(() => {
if (positionen.length === 0 || catalogItems.length === 0 || vendors.length === 0) return;
setAssignments(prev => {
const next = { ...prev };
let changed = false;
positionen.forEach(pos => {
// Don't overwrite existing manual assignments
if (next[pos.id] != null) return;
if (!pos.artikel_id) return;
const artikel = catalogItems.find(a => a.id === pos.artikel_id);
if (!artikel?.bevorzugter_lieferant_id) return;
const vendor = vendors.find(v => v.id === artikel.bevorzugter_lieferant_id);
if (!vendor) return;
next[pos.id] = { lieferantId: vendor.id, lieferantName: vendor.name };
changed = true;
});
return changed ? next : prev;
});
}, [positionen, catalogItems, vendors]);
// ── Derived: vendor groups ──
const vendorGroups: VendorGroup[] = useMemo(() => {
const map = new Map<number, VendorGroup>();
positionen.forEach(pos => {
const a = assignments[pos.id];
if (!a) return;
if (!map.has(a.lieferantId)) {
map.set(a.lieferantId, { lieferantId: a.lieferantId, lieferantName: a.lieferantName, positionen: [] });
}
map.get(a.lieferantId)!.positionen.push(pos);
});
return [...map.values()];
}, [assignments, positionen]);
// ── Auto-fill order names when a new vendor group appears ──
useEffect(() => {
if (!anfrage) return;
const label = formatOrderId(anfrage);
vendorGroups.forEach(g => {
setOrderNames(prev => {
if (prev[g.lieferantId]) return prev;
return { ...prev, [g.lieferantId]: `Anfrage ${label} ${g.lieferantName}` };
});
});
}, [vendorGroups, anfrage]);
// ── Derived: progress ──
const assignedCount = positionen.filter(p => assignments[p.id] != null).length;
const skippedCount = positionen.length - assignedCount;
// ── Mutations ──
const createOrdersMut = useMutation({
mutationFn: (payload: CreateOrdersRequest) =>
ausruestungsanfrageApi.createOrders(requestId, payload),
onSuccess: (result) => {
showSuccess(`${result.created_bestellungen.length} Bestellung(en) erfolgreich erstellt`);
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', requestId] });
navigate('/bestellungen');
},
onError: () => showError('Bestellungen konnten nicht erstellt werden'),
});
const createVendorMut = useMutation({
mutationFn: (data: { name: string; kontakt_name?: string; email?: string; telefon?: string }) =>
bestellungApi.createVendor(data),
onSuccess: (newVendor: Lieferant) => {
queryClient.invalidateQueries({ queryKey: ['bestellung-vendors'] });
// Auto-assign to the position that triggered the dialog
if (newVendorTargetPosId != null) {
setAssignments(prev => ({
...prev,
[newVendorTargetPosId]: { lieferantId: newVendor.id, lieferantName: newVendor.name },
}));
}
setNewVendorDialog(false);
setNewVendorName('');
setNewVendorKontakt('');
setNewVendorEmail('');
setNewVendorTelefon('');
setNewVendorTargetPosId(null);
showSuccess(`Lieferant "${newVendor.name}" erstellt`);
},
onError: () => showError('Lieferant konnte nicht erstellt werden'),
});
// ── Handlers ──
const canSubmit = vendorGroups.length > 0 && !createOrdersMut.isPending;
const handleSubmit = () => {
if (!canSubmit) return;
const orders = vendorGroups.map(g => ({
lieferant_id: g.lieferantId,
bezeichnung: orderNames[g.lieferantId] || `Anfrage ${g.lieferantName}`,
positionen: g.positionen.map(p => ({
position_id: p.id,
bezeichnung: p.bezeichnung,
menge: p.menge,
einheit: p.einheit,
notizen: p.notizen,
artikel_id: p.artikel_id,
spezifikationen: p.eigenschaften?.length
? p.eigenschaften.map(e => `${e.eigenschaft_name}: ${e.wert}`)
: undefined,
})),
}));
createOrdersMut.mutate({ orders });
};
const handleCreateVendor = () => {
if (!newVendorName.trim()) return;
createVendorMut.mutate({
name: newVendorName.trim(),
kontakt_name: newVendorKontakt.trim() || undefined,
email: newVendorEmail.trim() || undefined,
telefon: newVendorTelefon.trim() || undefined,
});
};
// ── Loading / error states ──
if (isLoading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (isError || !anfrage) {
return (
<DashboardLayout>
<Alert severity="error" sx={{ m: 3 }}>Anfrage konnte nicht geladen werden.</Alert>
</DashboardLayout>
);
}
// ── Render ──
return (
<DashboardLayout>
<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 ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<IconButton onClick={() => navigate(`/ausruestungsanfrage/${requestId}`)} size="small">
<ArrowBack />
</IconButton>
<Box sx={{ flex: 1 }}>
<Typography variant="h5" fontWeight={600}>
Bestellungen erstellen
</Typography>
<Typography variant="body2" color="text.secondary">
{anfrage.bezeichnung || `Anfrage ${formatOrderId(anfrage)}`}
{' · '}
<Chip
label={AUSRUESTUNG_STATUS_LABELS[anfrage.status]}
size="small"
sx={{ height: 18, fontSize: 11 }}
/>
</Typography>
</Box>
<Chip
label={`${assignedCount} / ${positionen.length} zugewiesen`}
color={assignedCount > 0 ? 'primary' : 'default'}
variant="outlined"
/>
</Box>
{/* ── Progress bar ── */}
<LinearProgress
variant="determinate"
value={positionen.length > 0 ? (assignedCount / positionen.length) * 100 : 0}
sx={{ mb: 3, borderRadius: 2, height: 6 }}
color="primary"
/>
{/* ── Items table ── */}
<Paper sx={{ mb: 3 }}>
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2">Lieferanten zuweisen</Typography>
</Box>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikel</TableCell>
<TableCell align="right" sx={{ width: 80 }}>Menge</TableCell>
<TableCell sx={{ width: 320 }}>Lieferant</TableCell>
</TableRow>
</TableHead>
<TableBody>
{positionen.map(pos => {
const assignment = assignments[pos.id];
const selectedVendor = assignment
? vendors.find(v => v.id === assignment.lieferantId) ?? null
: null;
return (
<TableRow key={pos.id}>
<TableCell>
<Typography variant="body2" fontWeight={500}>{pos.bezeichnung}</Typography>
{pos.notizen && (
<Typography variant="caption" color="text.secondary">{pos.notizen}</Typography>
)}
</TableCell>
<TableCell align="right">
<Typography variant="body2">{pos.menge} {pos.einheit ?? 'Stk'}</Typography>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Autocomplete<Lieferant>
size="small"
sx={{ flex: 1 }}
options={vendors}
getOptionLabel={o => o.name}
value={selectedVendor}
onChange={(_, v) => {
setAssignments(prev => ({
...prev,
[pos.id]: v ? { lieferantId: v.id, lieferantName: v.name } : null,
}));
}}
renderInput={params => (
<TextField
{...params}
placeholder="Lieferant wählen…"
size="small"
/>
)}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
{canManageVendors && (
<IconButton
size="small"
title="Neuen Lieferanten anlegen"
onClick={() => {
setNewVendorTargetPosId(pos.id);
setNewVendorDialog(true);
}}
>
<AddIcon fontSize="small" />
</IconButton>
)}
</Box>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Paper>
{/* ── Preview section ── */}
{vendorGroups.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
Vorschau {vendorGroups.length} Bestellung{vendorGroups.length !== 1 ? 'en' : ''} werden erstellt
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{vendorGroups.map(g => (
<Paper
key={g.lieferantId}
variant="outlined"
sx={{ p: 2, minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}
>
<Typography variant="subtitle2" color="primary" sx={{ mb: 0.5 }}>
{g.lieferantName}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1.5, display: 'block' }}>
{g.positionen.length} Artikel
</Typography>
<Box sx={{ mb: 1.5 }}>
{g.positionen.map(p => (
<Typography key={p.id} variant="body2" sx={{ py: 0.25 }}>
· {p.bezeichnung} ×{p.menge}
</Typography>
))}
</Box>
<TextField
size="small"
fullWidth
label="Bezeichnung der Bestellung"
value={orderNames[g.lieferantId] ?? ''}
onChange={e =>
setOrderNames(prev => ({ ...prev, [g.lieferantId]: e.target.value }))
}
/>
</Paper>
))}
</Box>
</Box>
)}
{/* ── Bottom action bar ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, justifyContent: 'flex-end' }}>
{skippedCount > 0 && assignedCount > 0 && (
<Alert severity="info" sx={{ flex: 1, py: 0.5 }}>
{skippedCount} Artikel {skippedCount === 1 ? 'ist' : 'sind'} auf Lager und {skippedCount === 1 ? 'wird' : 'werden'} nicht bestellt.
</Alert>
)}
{assignedCount === 0 && positionen.length > 0 && (
<Alert severity="info" sx={{ flex: 1, py: 0.5 }}>
Mindestens einem Artikel einen Lieferanten zuweisen, um Bestellungen zu erstellen.
</Alert>
)}
<Button
variant="contained"
size="large"
startIcon={<ShoppingCartIcon />}
disabled={!canSubmit}
onClick={handleSubmit}
>
{createOrdersMut.isPending ? 'Erstelle…' : `${vendorGroups.length || ''} Bestellung${vendorGroups.length !== 1 ? 'en' : ''} erstellen`}
</Button>
</Box>
</Box>
{/* ── New vendor dialog ── */}
<Dialog
open={newVendorDialog}
onClose={() => { setNewVendorDialog(false); setNewVendorTargetPosId(null); }}
maxWidth="sm"
fullWidth
>
<DialogTitle>Neuen Lieferanten anlegen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField
label="Name *"
value={newVendorName}
onChange={e => setNewVendorName(e.target.value)}
autoFocus
fullWidth
/>
<TextField
label="Kontaktperson"
value={newVendorKontakt}
onChange={e => setNewVendorKontakt(e.target.value)}
fullWidth
/>
<TextField
label="E-Mail"
value={newVendorEmail}
onChange={e => setNewVendorEmail(e.target.value)}
fullWidth
type="email"
/>
<TextField
label="Telefon"
value={newVendorTelefon}
onChange={e => setNewVendorTelefon(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setNewVendorDialog(false); setNewVendorTargetPosId(null); }}>
Abbrechen
</Button>
<Button
variant="contained"
disabled={!newVendorName.trim() || createVendorMut.isPending}
onClick={handleCreateVendor}
>
Anlegen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}