- 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>
471 lines
18 KiB
TypeScript
471 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|