Files
dashfront/src/components/manifest/DeliveryList.jsx
2026-10-04 21:04:40 +02:00

407 lines
13 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 IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import { RecordList } from 'dashgui/lists';
import deliveryAutomat from '../../automats/deliveryAutomat';
import { delivery } from '../../api';
import { supplierAutomat } from '../../automats';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import BookDeliveryDialog from './BookDeliveryDialog';
import withNavigate from '../withNavigate';
import { formatDate, numberDisplay } from './manifestConfig';
function sortDeliveries(docs) {
if (!Array.isArray(docs)) return [];
return [...docs].sort((a, b) => {
const cmp = String(b.date ?? '').localeCompare(String(a.date ?? ''));
if (cmp !== 0) return cmp;
return Number(b.id ?? 0) - Number(a.id ?? 0);
});
}
function formatFileSize(bytes) {
if (bytes == null || Number.isNaN(bytes)) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export class DeliveryList extends PureComponent {
constructor(props) {
super(props);
this.state = {
status: 'loading',
error: null,
docs: [],
suppliers: supplierAutomat.state?.suppliers || [],
busyId: null,
bookingDoc: null,
bookingOpen: false,
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleEdit = this.handleEdit.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this);
this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this);
this.handleBookSuccess = this.handleBookSuccess.bind(this);
}
componentDidMount() {
this._cancelled = false;
this.unsubSupplier = supplierAutomat.subscribe(this, (state) => ({
suppliers: state.suppliers || [],
}));
void this.load();
}
componentWillUnmount() {
this._cancelled = true;
this.unsubSupplier?.();
}
async fetchDeliveries() {
return deliveryAutomat.actions.ensureLoaded();
}
async load() {
this.setState({ status: 'loading', error: null });
try {
const docs = await this.fetchDeliveries();
if (this._cancelled) return;
this.setState({
status: 'ready',
docs: sortDeliveries(docs),
});
} catch (err) {
if (this._cancelled) return;
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
async handleRetry() {
try {
await deliveryAutomat.actions.reload();
} catch {
// Will be caught in load()
}
void this.load();
}
go(to) {
const { navigate } = this.props;
if (typeof navigate === 'function') {
navigate(to);
return;
}
window.location.assign(to);
}
handleCreate() {
this.go('/lieferung/neu');
}
handleEdit(doc) {
this.go(`/lieferung/${doc.id}`);
}
handleOpenBookDialog(doc) {
this.setState({ bookingDoc: doc, bookingOpen: true });
}
handleCloseBookDialog() {
this.setState({ bookingDoc: null, bookingOpen: false });
}
async handleBookSuccess() {
this.handleCloseBookDialog();
try {
const updated = await deliveryAutomat.actions.reload();
if (this._cancelled) return;
this.setState({ docs: sortDeliveries(updated) });
return;
} catch {
// Fall back to load()
}
void this.load();
}
async handleDelete(doc) {
if (this.state.busyId != null) return;
if (!window.confirm(`Lieferung „${doc.number}“ wirklich löschen?`)) return;
this.setState({ busyId: doc.id });
try {
await delivery.delete(doc.id);
await deliveryAutomat.actions.reload();
if (this._cancelled) return;
await this.load();
enqueueSnackbar('Lieferung gelöscht.', { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ busyId: null });
}
}
getSupplierName(supplierId) {
if (supplierId == null) return null;
const supplier = this.state.suppliers?.find((s) => s.id === supplierId);
return supplier?.name || `Lieferant #${supplierId}`;
}
render() {
const { status, error, docs, busyId, bookingDoc, bookingOpen, suppliers } = this.state;
const columns = [
{
key: 'number',
label: 'Nummer',
render: (row) => (
<Typography
variant="body2"
sx={{ fontWeight: 600, color: 'text.primary', fontFamily: 'monospace' }}
>
{numberDisplay(row.number)}
</Typography>
),
},
{
key: 'createdAt',
label: 'Erstellt',
render: (row) => (
<Typography variant="body2" color="text.secondary">
{row.createdAt ? formatDate(row.createdAt) : '–'}
</Typography>
),
},
{
key: 'date',
label: 'Lieferung',
render: (row) => (
<Typography variant="body2" color="text.primary">
{row.date ? formatDate(row.date) : '–'}
</Typography>
),
},
{
key: 'totalPositions',
label: 'Positionen',
render: (row) => {
const total = row.ledger?.totalPositions ?? row.ledger?.total ?? 0;
return (
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{total}
</Typography>
);
},
},
{
key: 'unbooked',
label: 'Ungebucht',
render: (row) => {
const total = row.ledger?.totalPositions ?? row.ledger?.total ?? 0;
const completely = row.ledger?.completelyOnLedgerPositions ?? row.ledger?.booked ?? 0;
const unbooked = Math.max(0, total - completely);
if (total === 0) {
return (
<Typography variant="body2" color="text.secondary">
0
</Typography>
);
}
if (unbooked === 0) {
return (
<Tooltip title={`Alle ${total} Positionen vollständig ins Lager gebucht`}>
<Chip
size="small"
icon={<CheckCircleOutlineOutlinedIcon sx={{ '&&': { fontSize: 16 } }} />}
label="0"
color="success"
variant="outlined"
onClick={(e) => {
e.stopPropagation();
this.handleOpenBookDialog(row);
}}
sx={{ fontWeight: 600, cursor: 'pointer' }}
/>
</Tooltip>
);
}
return (
<Box
sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}
onClick={(e) => e.stopPropagation()}
>
<Tooltip title={`${unbooked} von ${total} Positionen noch ungebucht`}>
<Chip
size="small"
icon={<Inventory2OutlinedIcon sx={{ '&&': { fontSize: 16 } }} />}
label={String(unbooked)}
color="warning"
variant="outlined"
onClick={() => this.handleOpenBookDialog(row)}
sx={{ fontWeight: 600, cursor: 'pointer' }}
/>
</Tooltip>
<Button
size="small"
variant="contained"
color="warning"
onClick={() => this.handleOpenBookDialog(row)}
sx={{
py: 0.2,
px: 1,
minHeight: 24,
textTransform: 'none',
fontSize: '0.75rem',
fontWeight: 600,
}}
>
Buchen
</Button>
</Box>
);
},
},
{
key: 'supplierId',
label: 'Lieferant',
render: (row) => {
const supplier = suppliers?.find((s) => s.id === row.supplierId);
const name = supplier?.name || (row.supplierId != null ? `Lieferant #${row.supplierId}` : '–');
return (
<Typography variant="body2" color={supplier ? 'text.primary' : 'text.secondary'}>
{name}
</Typography>
);
},
},
{
key: 'externalNumber',
label: 'Referenz',
render: (row) => (
<Typography variant="body2" color="text.secondary">
{numberDisplay(row.externalNumber)}
</Typography>
),
},
{
key: 'files',
label: 'Attachments',
render: (row) => {
const files = Array.isArray(row.files) ? row.files : [];
if (files.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
–
</Typography>
);
}
return (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}
onClick={(e) => e.stopPropagation()}
>
{files.map((file, idx) => {
const docId = file.manifestId ?? row.id;
const fileId = file.fileId ?? file.id;
const isPdf =
file.mimetype === 'application/pdf' ||
file.name?.toLowerCase().endsWith('.pdf');
const isTxt =
file.mimetype?.startsWith('text/') ||
file.name?.toLowerCase().endsWith('.txt') ||
file.name?.toLowerCase().endsWith('.csv');
const icon = isPdf ? (
<PictureAsPdfIcon sx={{ fontSize: 18, color: 'error.main' }} />
) : isTxt ? (
<TextSnippetOutlinedIcon sx={{ fontSize: 18, color: 'primary.main' }} />
) : (
<DescriptionOutlinedIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
);
const title = `${file.name}${file.size ? ` (${formatFileSize(file.size)})` : ''}`;
const fileUrl = `/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`;
return (
<Tooltip key={file.id ?? fileId ?? idx} title={title}>
<IconButton
size="small"
aria-label={title}
onClick={() => window.open(fileUrl, '_blank')}
sx={{
p: 0.5,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
borderRadius: 1,
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main',
},
}}
>
{icon}
</IconButton>
</Tooltip>
);
})}
</Box>
);
},
},
];
return (
<>
<RecordList
status={status}
error={<BackendError error={error} onRetry={this.handleRetry} />}
rows={docs}
columns={columns}
createLabel="Neue Lieferung"
onCreate={this.handleCreate}
onEdit={this.handleEdit}
onDelete={this.handleDelete}
deleteDisabled={busyId != null}
emptyMessage="Noch keine Lieferungen."
color="#fed7aa"
getEditLabel={(row) => `„${row.number}“ bearbeiten`}
getDeleteLabel={(row) => `„${row.number}“ löschen`}
/>
<BookDeliveryDialog
open={bookingOpen}
delivery={bookingDoc}
summary={null}
partyName={bookingDoc ? this.getSupplierName(bookingDoc.supplierId) : null}
onClose={this.handleCloseBookDialog}
onBookSuccess={this.handleBookSuccess}
/>
</>
);
}
}
export default withNavigate(DeliveryList);