Files
dashfront/src/components/manifest/ManifestHeader.jsx
2026-10-28 00:08:45 +01:00

802 lines
28 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, createRef } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile } from 'dashgui/panels';
import { enqueueSnackbar } from 'notistack';
import RestoreIcon from '@mui/icons-material/Restore';
import FileDropzone from '../FileDropzone';
import AttachmentList from './AttachmentList';
import {
hasArticleLines,
manifestConfig,
partyDisplayName,
getLowestSortNonSupplierCustomerGroup,
formatDateTime,
} from './manifestConfig';
import { notifyError, notifyErrorMessage } from '../../utils/notify';
import entityAutomat from '../../automats/entityAutomat';
import companyAutomat from '../../automats/companyAutomat';
import quoteAutomat from '../../automats/quoteAutomat';
import orderAutomat from '../../automats/orderAutomat';
import deliveryAutomat from '../../automats/deliveryAutomat';
import { getNextDocumentNumber, ensureDocumentNumber } from '../../utils/documentNumber';
import {
SURFACE_RAISED,
} from '../../colors';
class ManifestHeader extends PureComponent {
constructor(props) {
super(props);
const selected =
(props.parties || []).find((row) => row.id === props.partyId) ??
(props.kind === 'delivery'
? (companyAutomat.state.suppliers || []).find((row) => row.id === props.partyId)
: (entityAutomat.state.items || []).find((row) => row.id === props.partyId)) ??
null;
this.state = {
partyInput: selected ? partyDisplayName(props.kind, selected) : '',
creatingParty: false,
createdParty: null,
};
this.fileInputRef = createRef();
this.partyInputRef = createRef();
this.focusPartyInput = this.focusPartyInput.bind(this);
this.handlePartyChange = this.handlePartyChange.bind(this);
this.handlePartyInputChange = this.handlePartyInputChange.bind(this);
this.handlePartyKeyDown = this.handlePartyKeyDown.bind(this);
this.handleCreateParty = this.handleCreateParty.bind(this);
this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleFileChange = this.handleFileChange.bind(this);
this.handleFileDrop = this.handleFileDrop.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.partyId !== this.props.partyId || prevProps.parties !== this.props.parties) {
const selected =
(this.props.parties || []).find((row) => row.id === this.props.partyId) ??
(this.state.createdParty?.id === this.props.partyId ? this.state.createdParty : null) ??
(this.props.kind === 'delivery'
? (companyAutomat.state.suppliers || []).find((row) => row.id === this.props.partyId)
: (entityAutomat.state.items || []).find((row) => row.id === this.props.partyId));
if (selected) {
this.setState({ partyInput: partyDisplayName(this.props.kind, selected) });
} else if (this.props.partyId == null) {
this.setState({ partyInput: '', createdParty: null });
}
}
if (!prevProps.partyError && this.props.partyError) {
this.partyInputRef.current?.focus();
}
}
focusPartyInput() {
this.partyInputRef.current?.focus();
}
handlePartyChange(_event, option) {
this.setState({
createdParty: null,
partyInput: option ? partyDisplayName(this.props.kind, option) : '',
});
this.props.onPartyChange(option?.id ?? null);
}
handlePartyInputChange(_event, value, reason) {
if (reason === 'reset') {
const selected =
(this.props.parties || []).find((row) => row.id === this.props.partyId) ??
this.state.createdParty;
this.setState({
partyInput: selected ? partyDisplayName(this.props.kind, selected) : value,
});
return;
}
this.setState({ partyInput: value });
}
handlePartyKeyDown(event) {
if (event.key !== 'Enter') return;
if (event.defaultPrevented) return;
const activeDescendant = event.target?.getAttribute?.('aria-activedescendant');
if (activeDescendant) return;
const raw = this.state.partyInput?.trim();
if (!raw || this.state.creatingParty) return;
const { parties, kind } = this.props;
const lower = raw.toLowerCase();
const existing = (parties || []).find((p) => {
const label = partyDisplayName(kind, p).toLowerCase();
return label === lower || String(p.name ?? '').toLowerCase() === lower;
});
if (existing) {
event.preventDefault();
event.stopPropagation();
this.setState({
createdParty: null,
partyInput: partyDisplayName(kind, existing),
});
this.props.onPartyChange(existing.id);
event.target?.blur?.();
} else {
event.preventDefault();
event.stopPropagation();
void this.handleCreateParty(raw, event.target);
}
}
async handleCreateParty(rawInput, targetEl) {
const raw = String(rawInput ?? this.state.partyInput ?? '').trim();
if (!raw || this.state.creatingParty) return;
this.setState({ creatingParty: true });
try {
const { kind, onPartyChange } = this.props;
if (kind === 'delivery') {
const row = await companyAutomat.actions.createSupplier({ name: raw });
const displayName = row?.name || raw;
enqueueSnackbar(`Lieferant „${displayName}“ angelegt.`, { variant: 'success' });
this.setState({ creatingParty: false, createdParty: row, partyInput: displayName });
onPartyChange?.(row.id);
} else {
await companyAutomat.actions.ensureCustomergroupsLoaded();
const groups = companyAutomat.state.customergroups ?? [];
const targetGroup = getLowestSortNonSupplierCustomerGroup(groups) ?? groups[0];
if (!targetGroup) {
throw new Error('Keine Kundengruppe vorhanden.');
}
let name = raw;
let firstName = null;
if (raw.includes(',')) {
const parts = raw.split(',').map((p) => p.trim());
name = parts[0];
firstName = parts.slice(1).join(' ') || null;
} else if (raw.includes(' ') && !/gmbh|ag|kg|ug|inc|ltd|co\./i.test(raw)) {
const parts = raw.split(' ');
firstName = parts[0];
name = parts.slice(1).join(' ');
}
const body = {
customergroupId: targetGroup.id,
name,
};
if (firstName) body.firstName = firstName;
const row = await entityAutomat.actions.create(body);
const displayName = [row.firstName, row.name].filter(Boolean).join(' ') || row.name || raw;
enqueueSnackbar(`Kunde „${displayName}“ angelegt.`, { variant: 'success' });
this.setState({ creatingParty: false, createdParty: row, partyInput: displayName });
onPartyChange?.(row.id);
}
if (targetEl?.blur) {
targetEl.blur();
} else if (document.activeElement?.blur) {
document.activeElement.blur();
}
} catch (err) {
this.setState({ creatingParty: false });
notifyError(err);
}
}
hasArticles() {
return Boolean(
this.props.hasArticles ??
(Array.isArray(this.props.lines) && hasArticleLines(this.props.lines)),
);
}
handleUploadClick() {
if (this.hasArticles()) {
notifyErrorMessage(
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
);
return;
}
this.fileInputRef.current?.click();
}
handleFileDrop(file) {
if (this.hasArticles()) {
notifyErrorMessage(
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
);
return;
}
this.props.onPdfUpload?.(file);
}
handleFileChange(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
if (this.hasArticles()) {
notifyErrorMessage(
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
);
return;
}
this.props.onPdfUpload?.(file);
}
subscribeListAutomat(kind) {
this.unsubList?.();
this.unsubList = null;
const automat =
kind === 'quote'
? quoteAutomat
: kind === 'order'
? orderAutomat
: kind === 'delivery'
? deliveryAutomat
: null;
if (automat) {
this.unsubList = automat.subscribe(this, () => {
if (!this._cancelled) this.forceUpdate();
});
}
}
componentDidMount() {
this._cancelled = false;
this.unsubNumber = companyAutomat.subscribe(
this,
() => {
this.forceUpdate();
},
['numbers'],
);
this.subscribeListAutomat(this.props.kind);
void companyAutomat.actions.ensureNumbersLoaded().catch(() => {});
if (!this.props.number && this.props.docId == null) {
void ensureDocumentNumber(this.props.kind)
.then(() => {
if (!this._cancelled) this.forceUpdate();
})
.catch(() => {});
}
}
componentDidUpdate(prevProps) {
if (prevProps.kind !== this.props.kind) {
this.subscribeListAutomat(this.props.kind);
}
if (
!this.props.number &&
this.props.docId == null &&
(prevProps.kind !== this.props.kind || prevProps.docId !== this.props.docId)
) {
void ensureDocumentNumber(this.props.kind)
.then(() => {
if (!this._cancelled) this.forceUpdate();
})
.catch(() => {});
}
}
componentWillUnmount() {
this._cancelled = true;
this.unsubNumber?.();
this.unsubList?.();
}
render() {
const {
kind,
headerColor,
dateLabel,
partyLabel,
parties,
partiesLoading,
partyId,
date,
createdAt,
number,
externalNumber,
saving,
pdfUploading,
attachments,
onPdfUpload,
onPdfCreate,
pdfCreating,
onPdfRescan,
onPdfDelete,
onPdfClick,
onAttachmentClick,
onMentionInChat,
onDateChange,
onNumberChange,
onExternalNumberChange,
onSave,
onRestore,
orderCreating,
onCreateOrder,
actionsColor,
partyError,
docId,
ledgerSummary,
onOpenBookDialog,
isDirty,
dirtyFields,
dirtySummary,
} = this.props;
const hasArticles = this.hasArticles();
const isEdit = docId != null;
const previewNumber =
this.props.previewNumber ??
(!number && !isEdit ? getNextDocumentNumber(kind) : null);
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
const hasParty = partyId != null && partyId !== '';
const partyValid = kind !== 'order' || hasParty;
const canSave =
partyValid &&
(isEdit ? Boolean(isDirty) : Boolean(hasArticles || hasAttachments));
// Delivery: number is server-allocated/read-only; the caller reference
// lives in the editable externalNumber field.
const cfg = manifestConfig(kind);
const useExternalNumber = Boolean(cfg.externalNumber);
const selected =
(parties || []).find((row) => row.id === partyId) ??
(this.state.createdParty?.id === partyId ? this.state.createdParty : null);
const isDateEmpty = !date || String(date).trim() === '';
const isPartyEmpty =
(kind !== 'quote' || Boolean(partyError)) && (partyId == null || partyId === '');
return (
<>
{/* Tile 1: 3-wide Tile */}
<PanelTile wide={3} minHeight={160} color={headerColor}>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1.5,
alignItems: 'center',
}}
>
<TextField
size="small"
type="date"
label={dirtyFields?.date ? `${dateLabel} *` : dateLabel}
value={date}
error={isDateEmpty}
disabled={saving || pdfUploading}
onChange={(e) => onDateChange(e.target.value)}
autoComplete="off"
slotProps={{
inputLabel: { shrink: true },
}}
sx={{
flex: { xs: '1 1 100%', sm: '1 1 150px' },
minWidth: { sm: 130 },
...(dirtyFields?.date && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
<Autocomplete
size="small"
autoHighlight
options={parties || []}
value={selected}
loading={Boolean(partiesLoading) || this.state.creatingParty}
loadingText={this.state.creatingParty ? 'Wird angelegt…' : 'Lädt…'}
noOptionsText={
this.state.creatingParty ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<CircularProgress size={16} />
<span>Wird angelegt…</span>
</Box>
) : (
<Box
component="span"
onMouseDown={(e) => {
e.preventDefault();
const raw = this.state.partyInput?.trim();
if (raw) void this.handleCreateParty(raw);
}}
sx={{
cursor: 'pointer',
display: 'block',
width: '100%',
userSelect: 'none',
fontWeight: 600,
color: 'primary.main',
}}
>
Enter zum anlegen
</Box>
)
}
disabled={saving || pdfUploading}
getOptionLabel={(option) => partyDisplayName(kind, option)}
isOptionEqualToValue={(option, value) => option?.id === value?.id}
onChange={this.handlePartyChange}
onInputChange={this.handlePartyInputChange}
onKeyDown={this.handlePartyKeyDown}
sx={{
flex: { xs: '1 1 100%', sm: '2 1 200px' },
minWidth: { sm: 160 },
}}
renderInput={({ inputProps: _unusedInputProps, InputProps: _unusedInputProps2, InputLabelProps: _unusedInputLabelProps, ...params }) => (
<TextField
{...params}
inputRef={this.partyInputRef}
label={partyLabel}
error={isPartyEmpty}
onKeyDown={this.handlePartyKeyDown}
slotProps={{
...params.slotProps,
htmlInput: {
...params.slotProps?.htmlInput,
autoComplete: 'off',
},
}}
sx={{
...(dirtyFields?.partyId && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
)}
/>
<SingleLineTextEdit
size="small"
label="Nummer"
value={number || previewNumber || ''}
disabled
placeholder="–"
title={
!number && previewNumber
? 'Vorschau der nächsten Nummer (wird beim Speichern vergeben)'
: undefined
}
slotProps={{
inputLabel: { shrink: true },
}}
sx={{
flex: { xs: '1 1 100%', sm: '1 1 120px' },
minWidth: { sm: 100 },
'& .MuiInputBase-input': {
fontFamily: 'monospace',
},
...(!number && previewNumber && {
'& .MuiInputBase-input.Mui-disabled': {
fontStyle: 'italic',
fontFamily: 'monospace',
},
}),
}}
/>
{useExternalNumber ? (
<SingleLineTextEdit
size="small"
label={dirtyFields?.externalNumber ? 'Referenz *' : 'Referenz'}
value={externalNumber ?? ''}
disabled={saving || pdfUploading}
onChange={onExternalNumberChange}
sx={{
flex: { xs: '1 1 100%', sm: '1 1 120px' },
minWidth: { sm: 100 },
'& .MuiInputBase-input': {
fontFamily: 'monospace',
},
...(dirtyFields?.externalNumber && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
) : null}
{isEdit && createdAt ? (
<SingleLineTextEdit
size="small"
label="Erstellt am"
value={formatDateTime(createdAt)}
disabled
slotProps={{
inputLabel: { shrink: true },
}}
sx={{
flex: { xs: '1 1 100%', sm: '1 1 150px' },
minWidth: { sm: 130 },
'& .MuiInputBase-input': {
fontVariantNumeric: 'tabular-nums',
},
}}
/>
) : null}
</Box>
<AttachmentList
kind={kind}
docId={docId}
attachments={attachments}
onRescan={onPdfRescan}
onDelete={onPdfDelete}
onClick={onAttachmentClick ?? onPdfClick}
onMentionInChat={onMentionInChat}
disabled={saving || pdfUploading}
hasArticles={hasArticles}
/>
</PanelTile>
{/* Tile 2: 1-wide Action Tile (4th tile in row) with Speichern & Datei hochladen */}
<PanelTile
wide={1}
minHeight={160}
color={actionsColor ?? SURFACE_RAISED}
sx={{
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 1' },
}}
>
<Stack spacing={1.5} sx={{ pt: 0.5 }}>
<Button
variant="contained"
color={canSave ? 'success' : 'inherit'}
disabled={Boolean(saving || orderCreating || !canSave)}
onClick={onSave}
sx={{
height: 40,
fontWeight: 700,
fontSize: '0.875rem',
whiteSpace: 'nowrap',
...(canSave && {
bgcolor: 'success.main',
color: '#fff',
'&:hover': {
bgcolor: 'success.dark',
},
}),
...(!canSave && {
bgcolor: 'action.disabledBackground',
color: 'text.disabled',
boxShadow: 'none',
'&.Mui-disabled': {
bgcolor: 'action.disabledBackground',
color: 'text.disabled',
},
}),
}}
>
{saving ? 'Speichern…' : 'Speichern'}
</Button>
{(() => {
const canRestore = Boolean(onRestore && isDirty && !saving && !orderCreating);
return (
<Button
variant="outlined"
color={canRestore ? 'warning' : 'inherit'}
size="small"
startIcon={<RestoreIcon />}
disabled={!canRestore}
onClick={canRestore ? onRestore : undefined}
title={
canRestore
? 'Änderungen verwerfen und ursprünglichen Stand wiederherstellen'
: 'Keine Änderungen zum Wiederherstellen'
}
sx={{
height: 34,
width: '100%',
fontWeight: 700,
fontSize: '0.8rem',
whiteSpace: 'nowrap',
textTransform: 'none',
borderWidth: 1.5,
'&:hover': { borderWidth: 1.5 },
...(!canRestore && {
borderColor: 'action.disabled',
color: 'text.disabled',
'&.Mui-disabled': {
borderColor: 'action.disabled',
color: 'text.disabled',
borderWidth: 1.5,
},
}),
}}
>
Wiederherstellen
</Button>
);
})()}
{docId != null && kind === 'delivery' && onOpenBookDialog && Boolean(ledgerSummary && (ledgerSummary.totalPositions > 0 || ledgerSummary.lines?.length > 0 || ledgerSummary.hasTrackStock)) && (
<Button
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
color={
ledgerSummary?.status === 'booked'
? 'success'
: ledgerSummary?.status === 'partial'
? 'warning'
: 'primary'
}
startIcon={
ledgerSummary?.status === 'booked' ? (
<CheckCircleOutlineOutlinedIcon />
) : (
<Inventory2OutlinedIcon />
)
}
disabled={saving || pdfUploading}
onClick={onOpenBookDialog}
sx={{
height: 40,
fontWeight: 700,
fontSize: '0.8125rem',
whiteSpace: 'nowrap',
...(ledgerSummary?.status !== 'booked' && {
color: '#ffffff',
'& .MuiButton-startIcon': { color: '#ffffff' },
}),
}}
>
{ledgerSummary?.status === 'booked'
? 'Vollständig gebucht'
: ledgerSummary?.status === 'partial'
? 'Rest verbuchen'
: 'Lieferung verbuchen'}
</Button>
)}
{kind === 'quote' && docId != null && onCreateOrder && (
<Button
type="button"
variant="outlined"
color={partyError && !partyId ? 'warning' : 'primary'}
startIcon={orderCreating ? <CircularProgress size={16} color="inherit" /> : <ShoppingCartIcon />}
disabled={saving || orderCreating || pdfCreating}
onClick={onCreateOrder}
title={!partyId ? 'Kunde für Bestellung erforderlich' : 'Bestellung aus diesem Angebot erstellen'}
sx={{
whiteSpace: 'nowrap',
height: 40,
width: '100%',
px: 1.5,
boxSizing: 'border-box',
fontWeight: 600,
fontSize: '0.875rem',
}}
>
{orderCreating ? 'Wird erstellt…' : 'Bestellung erstellen'}
</Button>
)}
{(kind === 'quote' || kind === 'order' || kind === 'delivery') && (() => {
const canCreatePdf = Boolean(
onPdfCreate &&
docId != null &&
Boolean(number) &&
!saving &&
!pdfCreating &&
!orderCreating
);
return (
<Button
type="button"
variant="outlined"
color={canCreatePdf ? 'primary' : 'inherit'}
startIcon={pdfCreating ? <CircularProgress size={16} color="inherit" /> : <PictureAsPdfIcon />}
disabled={!canCreatePdf}
onClick={canCreatePdf ? onPdfCreate : undefined}
title={
pdfCreating
? 'PDF wird erstellt…'
: canCreatePdf
? 'PDF erstellen'
: docId == null
? 'PDF kann erst nach dem Speichern erstellt werden'
: !number
? 'Dokumentnummer erforderlich für PDF'
: 'PDF erstellen'
}
sx={{
whiteSpace: 'nowrap',
height: 40,
width: '100%',
px: 1.5,
boxSizing: 'border-box',
fontWeight: 600,
fontSize: '0.875rem',
...(!canCreatePdf && {
borderColor: 'action.disabled',
color: 'text.disabled',
'&.Mui-disabled': {
borderColor: 'action.disabled',
color: 'text.disabled',
},
}),
}}
>
{pdfCreating ? 'Wird erstellt…' : 'PDF erstellen'}
</Button>
);
})()}
{onPdfUpload ? (
<FileDropzone
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
disabled={saving || pdfUploading}
onFileDrop={this.handleFileDrop}
sx={{ width: '100%' }}
>
{({ isDragging }) => (
<Button
variant="outlined"
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
disabled={saving || pdfUploading}
onClick={this.handleUploadClick}
title={
hasArticles
? 'Datei-Upload nicht möglich, solange Artikel vorhanden sind'
: 'PDF, TXT oder CSV hochladen'
}
sx={{
whiteSpace: 'nowrap',
height: 40,
width: '100%',
px: 1.5,
boxSizing: 'border-box',
bgcolor: isDragging ? 'action.hover' : 'transparent',
borderColor: isDragging
? 'primary.main'
: hasArticles
? 'action.disabled'
: undefined,
color: hasArticles ? 'text.disabled' : undefined,
opacity: hasArticles ? 0.6 : 1,
cursor: hasArticles ? 'not-allowed' : 'pointer',
transition: 'background-color 0.15s, border-color 0.15s, opacity 0.15s',
}}
>
{pdfUploading
? 'Wird analysiert…'
: isDragging
? 'Datei hier ablegen'
: 'Datei hochladen'}
</Button>
)}
</FileDropzone>
) : null}
</Stack>
</PanelTile>
{onPdfUpload ? (
<input
ref={this.fileInputRef}
type="file"
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
style={{ display: 'none' }}
onChange={this.handleFileChange}
/>
) : null}
</>
);
}
}
export default ManifestHeader;