Files
dashfront/src/components/manifest/DeliveryList.jsx
2026-09-26 07:09:36 +02:00

422 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 { StorageManagerContext } from '../../api/storageManagerContext';
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 {
static contextType = StorageManagerContext;
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() {
if (this.context?.deliveries) {
return this.context.deliveries;
}
if (this.context?.deliveryPromise) {
return await this.context.deliveryPromise;
}
const res = await (this.context?.delivery?.list ? this.context.delivery.list() : delivery.list());
return res?.deliveries ?? res ?? [];
}
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() {
if (this.context?.updateDelivery) {
try {
await this.context.updateDelivery();
} 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();
if (this.context?.updateDelivery) {
try {
const updated = await this.context.updateDelivery();
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 (this.context?.delivery?.delete ? this.context.delivery.delete(doc.id) : delivery.delete(doc.id));
if (this.context?.updateDelivery) {
await this.context.updateDelivery();
}
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);