Files
dashfront/src/components/manifest/ShipmentDetailDialog.jsx
2026-10-17 05:11:07 +02:00

529 lines
20 KiB
JavaScript
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 { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import CloseIcon from '@mui/icons-material/Close';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { enqueueSnackbar } from 'notistack';
import AppDialog from '../AppDialog';
import { shipment as shipmentApi, shipping as shippingApi } from '../../api';
import { notifyError } from '../../utils/notify';
import { formatDate } from './manifestConfig';
import orderAutomat from '../../automats/orderAutomat';
import shipmentAutomat from '../../automats/shipmentAutomat';
import carrierAutomat from '../../automats/carrierAutomat';
import { SURFACE_RAISED, WHITE } from '../../colors';
// MARK: <AddPackageDialog/>
class AddPackageDialog extends PureComponent {
constructor(props) {
super(props);
const carriers = carrierAutomat.state.carriers || [];
this.state = {
carriers,
carrierId: carriers[0]?.id ?? '',
trackingCode: '',
type: 'parcel',
state: 'draft',
saving: false,
};
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount() {
this.unsub = carrierAutomat.subscribe(this, (state) => {
const carriers = state.carriers || [];
const currentId = this.state.carrierId;
const valid = carriers.some((c) => c.id === currentId);
return {
carriers,
carrierId: valid ? currentId : (carriers[0]?.id ?? ''),
};
});
if (carrierAutomat.state.status !== 'ready') {
void carrierAutomat.actions.load().catch(() => {});
}
}
componentWillUnmount() {
this.unsub?.();
}
async handleSubmit() {
const { shipment, onClose, onSuccess } = this.props;
const { carrierId, trackingCode, type, state } = this.state;
if (!shipment?.id || !carrierId) return;
this.setState({ saving: true });
try {
await shippingApi.create(shipment.id, {
carrierId: Number(carrierId),
trackingCode: trackingCode.trim() || undefined,
type,
state,
});
enqueueSnackbar('Paket angelegt.', { variant: 'success' });
await Promise.all([
orderAutomat.actions.reload(),
shipmentAutomat.actions.reload(),
]);
onSuccess?.();
onClose();
} catch (err) {
notifyError(err);
this.setState({ saving: false });
}
}
render() {
const { open, onClose, shipment } = this.props;
const { carriers, carrierId, trackingCode, type, state, saving } = this.state;
const canSubmit = Boolean(carrierId) && !saving;
return (
<AppDialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Paket zu Lieferschein hinzufügen
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Lieferschein: <strong>{shipment?.number}</strong>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
<TextField
select
label="Lieferart (Carrier)"
size="small"
value={carrierId}
onChange={(e) => this.setState({ carrierId: e.target.value })}
error={carriers.length === 0}
helperText={
carriers.length === 0
? 'Keine Lieferarten eingerichtet (unter Auftrag → Lieferarten anlegen)'
: undefined
}
>
{carriers.map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</TextField>
<TextField
label="Tracking-Code / Sendungsnummer"
size="small"
value={trackingCode}
onChange={(e) => this.setState({ trackingCode: e.target.value })}
placeholder="z.B. 00340434234234234"
/>
<TextField
select
label="Paketart"
size="small"
value={type}
onChange={(e) => this.setState({ type: e.target.value })}
>
<MenuItem value="parcel">Paket (Parcel)</MenuItem>
<MenuItem value="pallet">Palette (Pallet)</MenuItem>
<MenuItem value="letter">Brief / Dokument</MenuItem>
</TextField>
<TextField
select
label="Status"
size="small"
value={state}
onChange={(e) => this.setState({ state: e.target.value })}
helperText={state === 'shipped' ? 'Bucht bei Auswahl direkt das Lager ab.' : undefined}
>
<MenuItem value="draft">Entwurf (draft)</MenuItem>
<MenuItem value="packed">Gepackt (packed)</MenuItem>
<MenuItem value="shipped">Versendet (shipped — Lagerabbuchung)</MenuItem>
</TextField>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1.5 }}>
<Button variant="outlined" onClick={onClose} disabled={saving}>
Abbrechen
</Button>
<Button variant="contained" onClick={this.handleSubmit} disabled={!canSubmit}>
{saving ? 'Wird gespeichert…' : 'Paket anlegen'}
</Button>
</Box>
</Box>
</AppDialog>
);
}
}
/**
* Modal to view and manage an existing Lieferschein (Shipment) directly from the Order page.
*/
export default class ShipmentDetailDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
shipment: props.shipment || null,
loading: false,
error: null,
addPackageDialogOpen: false,
busyAction: false,
};
this.handleRefresh = this.handleRefresh.bind(this);
this.handleDeletePackage = this.handleDeletePackage.bind(this);
this.handleMarkPackageShipped = this.handleMarkPackageShipped.bind(this);
this.handleStateChange = this.handleStateChange.bind(this);
}
componentDidMount() {
if (this.props.open && this.props.shipment?.id) {
void this.handleRefresh();
}
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.setState({ shipment: this.props.shipment, error: null });
if (this.props.shipment?.id) {
void this.handleRefresh();
}
} else if (prevProps.shipment?.id !== this.props.shipment?.id && this.props.shipment?.id) {
this.setState({ shipment: this.props.shipment });
void this.handleRefresh();
}
}
// MARK: calls shipmentApi.get from ./api/index.js
// #region GET /api/shipment/:id — refresh details
async handleRefresh() {
const shipmentId = this.props.shipment?.id;
if (!shipmentId) return;
this.setState({ loading: true, error: null });
try {
const res = await shipmentApi.get(shipmentId);
const enriched = res?.shipment || res;
this.setState({ shipment: enriched, loading: false });
} catch (err) {
notifyError(err);
this.setState({ error: err?.message || String(err), loading: false });
}
}
// #endregion
async handleDeletePackage(pkg) {
if (!window.confirm(`Paket „#${pkg.number || pkg.id}“ wirklich löschen?`)) return;
this.setState({ busyAction: true });
try {
await shippingApi.delete(pkg.id);
enqueueSnackbar('Paket gelöscht.', { variant: 'success' });
await Promise.all([
orderAutomat.actions.reload(),
shipmentAutomat.actions.reload(),
]);
this.props.onChanged?.();
await this.handleRefresh();
} catch (err) {
notifyError(err);
} finally {
this.setState({ busyAction: false });
}
}
async handleMarkPackageShipped(pkg) {
this.setState({ busyAction: true });
try {
await shippingApi.update(pkg.id, { state: 'shipped' });
enqueueSnackbar('Paket als versendet markiert (Lagerbestand abgebucht).', { variant: 'success' });
await Promise.all([
orderAutomat.actions.reload(),
shipmentAutomat.actions.reload(),
]);
this.props.onChanged?.();
await this.handleRefresh();
} catch (err) {
notifyError(err);
} finally {
this.setState({ busyAction: false });
}
}
async handleStateChange(newState) {
const shipmentId = this.state.shipment?.id;
if (!shipmentId) return;
this.setState({ busyAction: true });
try {
await shipmentApi.update(shipmentId, { state: newState });
this.setState((prev) => ({
shipment: { ...prev.shipment, state: newState },
}));
const label = newState === 'delivered' ? 'Versendet' : newState === 'returned' ? 'Retoure' : 'Neu';
enqueueSnackbar(`Lieferstatus geändert: ${label}`, { variant: 'success' });
await Promise.all([
orderAutomat.actions.reload(),
shipmentAutomat.actions.reload(),
]);
this.props.onChanged?.();
} catch (err) {
notifyError(err);
} finally {
this.setState({ busyAction: false });
}
}
render() {
const { open, onClose } = this.props;
const { shipment, loading, error, addPackageDialogOpen, busyAction } = this.state;
if (!shipment) return null;
const shLines = Array.isArray(shipment.lines) ? shipment.lines : [];
const shPackages = Array.isArray(shipment.shippings) ? shipment.shippings : [];
return (
<AppDialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<LocalShippingIcon color="primary" sx={{ fontSize: 28 }} />
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{shipment.number || 'Lieferschein'}
</Typography>
<Typography variant="caption" color="text.secondary">
{shipment.date ? `Datum: ${formatDate(shipment.date)}` : ''}
{shipment.externalNumber ? ` • Ref: ${shipment.externalNumber}` : ''}
</Typography>
</Box>
</Stack>
<IconButton
aria-label="close"
onClick={onClose}
size="small"
sx={{ color: (theme) => theme.palette.grey[500] }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{error && (
<Alert severity="error" onClose={() => this.setState({ error: null })}>
{error}
</Alert>
)}
{/* Shipped Article Positions */}
<Box>
<TableContainer sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1.5, bgcolor: WHITE }}>
<Table size="small">
<TableHead sx={{ bgcolor: SURFACE_RAISED }}>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.75 }}>SKU</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.75 }}>Bezeichnung</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.75 }}>Menge</TableCell>
</TableRow>
</TableHead>
<TableBody>
{shLines.length === 0 ? (
<TableRow>
<TableCell colSpan={3} align="center" sx={{ py: 2, color: 'text.secondary' }}>
{loading ? 'Lädt Positionen…' : 'Keine Positionen auf diesem Lieferschein.'}
</TableCell>
</TableRow>
) : (
shLines.map((line, idx) => (
<TableRow key={line.id || idx}>
<TableCell sx={{ py: 0.75, fontWeight: 500 }}>{line.sku}</TableCell>
<TableCell sx={{ py: 0.75 }}>{line.name || '–'}</TableCell>
<TableCell align="right" sx={{ py: 0.75, fontWeight: 700 }}>
{line.amount}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
{/* Shipping Packages / Tracking */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', mb: shPackages.length > 0 ? 1 : 0 }}>
<Button
size="small"
variant="outlined"
startIcon={<AddIcon />}
onClick={() => this.setState({ addPackageDialogOpen: true })}
disabled={busyAction}
sx={{ textTransform: 'none', fontWeight: 600, height: 28, fontSize: '0.75rem' }}
>
Paket hinzufügen
</Button>
</Box>
{shPackages.length > 0 ? (
<Stack spacing={1}>
{shPackages.map((pkg) => {
const isShipped = pkg.state === 'shipped';
return (
<Box
key={pkg.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1.5,
bgcolor: WHITE,
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Chip size="small" label={`#${pkg.number || pkg.id}`} sx={{ fontWeight: 700 }} />
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pkg.carrier || 'DHL'} ({pkg.type === 'pallet' ? 'Palette' : 'Paket'})
</Typography>
{pkg.trackingCode ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'primary.main' }}>
{pkg.trackingCode}
</Typography>
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(pkg.trackingCode);
enqueueSnackbar('Tracking-Code kopiert!', { variant: 'info' });
}}
sx={{ p: 0.25 }}
>
<ContentCopyIcon sx={{ fontSize: 13 }} />
</IconButton>
</Box>
) : (
<Typography variant="caption" color="text.secondary">
Ohne Tracking
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isShipped ? (
<Chip
size="small"
color="success"
icon={<CheckCircleOutlineOutlinedIcon />}
label="Versendet (Lager abgebucht)"
sx={{ fontWeight: 600 }}
/>
) : (
<>
<Button
size="small"
variant="contained"
color="success"
disabled={busyAction}
onClick={() => this.handleMarkPackageShipped(pkg)}
sx={{ textTransform: 'none', py: 0.25, px: 1, height: 26, fontSize: '0.75rem' }}
>
Als versendet buchen
</Button>
<IconButton
size="small"
color="error"
disabled={busyAction}
onClick={() => this.handleDeletePackage(pkg)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</>
)}
</Box>
</Box>
);
})}
</Stack>
) : null}
</Box>
</DialogContent>
<DialogActions sx={{ p: 2, justifyContent: 'flex-end' }}>
{(!shipment.state || shipment.state === 'new') && (
<Button
variant="contained"
color="primary"
disabled={busyAction}
onClick={() => this.handleStateChange('delivered')}
sx={{ textTransform: 'none', fontWeight: 700 }}
>
{busyAction ? 'Wird versendet…' : 'Versenden'}
</Button>
)}
{shipment.state === 'delivered' && (
<Button
variant="contained"
color="warning"
disabled={busyAction}
onClick={() => this.handleStateChange('returned')}
sx={{ textTransform: 'none', fontWeight: 700 }}
>
{busyAction ? 'Wird gebucht…' : 'Retoure'}
</Button>
)}
{shipment.state === 'returned' && (
<Chip
label="Retoure"
color="error"
variant="outlined"
sx={{ fontWeight: 700, height: 28, fontSize: '0.8rem' }}
/>
)}
</DialogActions>
{addPackageDialogOpen && (
<AddPackageDialog
open={addPackageDialogOpen}
shipment={shipment}
onClose={() => this.setState({ addPackageDialogOpen: false })}
onSuccess={async () => {
this.props.onChanged?.();
await this.handleRefresh();
}}
/>
)}
</AppDialog>
);
}
}