This commit is contained in:
seb
2026-10-14 14:46:44 +02:00
parent 49f8f2f93f
commit 4188d8f46f
6 changed files with 597 additions and 26 deletions

View File

@@ -30,7 +30,10 @@ import {
parseNum,
} from './manifestConfig';
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
import { getManifestDraftAutomat } from '../../automats/manifestDraftAutomat';
import {
getManifestDraftAutomat,
getManifestEditAutomat,
} from '../../automats/manifestDraftAutomat';
class ManifestForm extends PureComponent {
constructor(props) {
@@ -44,19 +47,29 @@ class ManifestForm extends PureComponent {
: entityAutomat.state.status === 'ready';
this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null;
this.editAutomat = props.mode === 'edit' ? getManifestEditAutomat(props.kind) : null;
const initialDraft = this.draftAutomat?.state;
const initialEdit = (this.editAutomat && String(this.editAutomat.state?.id) === String(props.docId))
? this.editAutomat.state
: null;
const initialDoc = initialDraft || initialEdit;
this.state = {
status: props.mode === 'edit' ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'),
status: props.mode === 'edit' && !initialEdit ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'),
error: null,
parties: initialParties,
partiesLoading: !partiesReady,
date: initialDraft?.date || '',
partyId: initialDraft?.partyId ?? null,
number: initialDraft?.number || '',
externalNumber: initialDraft?.externalNumber || '',
lines: initialDraft?.lines?.length > 0 ? initialDraft.lines : [emptyLine()],
attachments: initialDraft?.attachments || [],
date: initialDoc?.date || '',
partyId: initialDoc?.partyId ?? null,
number: initialDoc?.number || '',
externalNumber: initialDoc?.externalNumber || '',
lines: initialDoc?.lines?.length > 0 ? initialDoc.lines : [emptyLine()],
attachments: initialDoc?.attachments || [],
isDirty: Boolean(initialEdit?.isDirty),
dirtyFields: initialEdit?.dirtyFields || null,
dirtyLineKeys: initialEdit?.dirtyLineKeys || [],
dirtySummary: initialEdit?.dirtySummary || [],
saving: false,
pdfUploading: false,
pdfDialogOpen: false,
@@ -67,6 +80,7 @@ class ManifestForm extends PureComponent {
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleRestore = this.handleRestore.bind(this);
this.handlePartyChange = this.handlePartyChange.bind(this);
this.handleDateChange = this.handleDateChange.bind(this);
this.handleNumberChange = this.handleNumberChange.bind(this);
@@ -145,6 +159,25 @@ class ManifestForm extends PureComponent {
attachments: state.attachments || [],
}));
}
if (this.editAutomat) {
this.unsubEdit = this.editAutomat.subscribe(this, (state) => {
if (String(state.id) === String(this.props.docId)) {
return {
date: state.date || '',
partyId: state.partyId,
externalNumber: state.externalNumber || '',
lines: state.lines?.length > 0 ? state.lines : [emptyLine()],
attachments: state.attachments || [],
isDirty: Boolean(state.isDirty),
dirtyFields: state.dirtyFields,
dirtyLineKeys: state.dirtyLineKeys,
dirtySummary: state.dirtySummary,
};
}
return null;
});
}
}
componentDidUpdate(prevProps, prevState) {
@@ -172,11 +205,42 @@ class ManifestForm extends PureComponent {
});
}
}
if (
this.props.mode === 'edit' &&
this.editAutomat &&
String(this.editAutomat.state.id) === String(this.props.docId)
) {
if (
prevState.date !== this.state.date ||
prevState.partyId !== this.state.partyId ||
prevState.externalNumber !== this.state.externalNumber ||
prevState.lines !== this.state.lines ||
prevState.attachments !== this.state.attachments
) {
this.editAutomat.actions.update({
date: this.state.date,
partyId: this.state.partyId,
externalNumber: this.state.externalNumber,
lines: this.state.lines,
attachments: this.state.attachments,
});
const { isDirty, dirtyFields, dirtyLineKeys, dirtySummary } = this.editAutomat.state;
if (
this.state.isDirty !== isDirty ||
this.state.dirtyFields !== dirtyFields ||
this.state.dirtySummary !== dirtySummary
) {
this.setState({ isDirty, dirtyFields, dirtyLineKeys, dirtySummary });
}
}
}
}
componentWillUnmount() {
this._cancelled = true;
this.unsubDraft?.();
this.unsubEdit?.();
this.unsubParties?.();
this.unsubCompany?.();
if (this._parseAbortController) {
@@ -221,6 +285,29 @@ class ManifestForm extends PureComponent {
return;
}
// If editAutomat already holds this exact document in memory, preserve in-progress edits
if (
this.editAutomat &&
String(this.editAutomat.state.id) === String(docId) &&
this.editAutomat.state.baseline != null
) {
void this.loadPartiesOnly();
this.setState({
status: 'ready',
isDirty: Boolean(this.editAutomat.state.isDirty),
dirtyFields: this.editAutomat.state.dirtyFields,
dirtyLineKeys: this.editAutomat.state.dirtyLineKeys,
dirtySummary: this.editAutomat.state.dirtySummary,
date: this.editAutomat.state.date,
partyId: this.editAutomat.state.partyId,
number: this.editAutomat.state.number,
externalNumber: this.editAutomat.state.externalNumber,
lines: this.editAutomat.state.lines,
attachments: this.editAutomat.state.attachments,
});
return;
}
this.setState({ status: 'loading', error: null, partiesLoading: true });
try {
const [docRes, parties] = await Promise.all([
@@ -240,10 +327,7 @@ class ManifestForm extends PureComponent {
const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles);
this.setState({
status: 'ready',
parties,
partiesLoading: false,
const loadedDoc = {
date: dateInputValue(doc.date),
partyId: doc[cfg.partyField],
number: doc.number ?? '',
@@ -251,6 +335,21 @@ class ManifestForm extends PureComponent {
doc.externalNumber != null ? String(doc.externalNumber) : '',
lines,
attachments,
};
if (this.editAutomat) {
this.editAutomat.actions.loadDoc(docId, loadedDoc);
}
this.setState({
status: 'ready',
parties,
partiesLoading: false,
...loadedDoc,
isDirty: false,
dirtyFields: null,
dirtyLineKeys: [],
dirtySummary: [],
});
} catch (err) {
if (this._cancelled) return;
@@ -267,6 +366,25 @@ class ManifestForm extends PureComponent {
else void this.loadPartiesOnly();
}
handleRestore() {
if (!this.editAutomat) return;
this.editAutomat.actions.restoreBaseline();
const state = this.editAutomat.state;
this.setState({
date: state.date || '',
partyId: state.partyId,
number: state.number || '',
externalNumber: state.externalNumber || '',
lines: state.lines?.length > 0 ? state.lines : [emptyLine()],
attachments: state.attachments || [],
isDirty: false,
dirtyFields: state.dirtyFields,
dirtyLineKeys: state.dirtyLineKeys,
dirtySummary: state.dirtySummary,
});
enqueueSnackbar('Änderungen verworfen und ursprünglicher Stand wiederhergestellt.', { variant: 'info' });
}
handlePartyChange(partyId) {
const { mode } = this.props;
const { requirePartyBeforeLines } = this.cfg;
@@ -736,7 +854,7 @@ class ManifestForm extends PureComponent {
if (this._cancelled) return;
const doc = data[cfg.docField];
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
this.setState({
const savedDoc = {
date: dateInputValue(doc.date),
partyId: doc[cfg.partyField],
number: doc.number ?? '',
@@ -744,6 +862,19 @@ class ManifestForm extends PureComponent {
doc.externalNumber != null ? String(doc.externalNumber) : '',
lines:
apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()],
attachments: this.state.attachments,
};
if (this.editAutomat && String(this.editAutomat.state.id) === String(docId)) {
this.editAutomat.actions.commitSaved(savedDoc);
}
this.setState({
...savedDoc,
isDirty: false,
dirtyFields: null,
dirtyLineKeys: [],
dirtySummary: [],
saving: false,
});
enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
@@ -756,7 +887,7 @@ class ManifestForm extends PureComponent {
renderArticles() {
const cfg = this.cfg;
const { lines, saving } = this.state;
const { lines, saving, dirtyFields, dirtyLineKeys } = this.state;
return (
<ManifestLines
@@ -766,6 +897,8 @@ class ManifestForm extends PureComponent {
saving={saving}
wide={4}
linesColor={cfg.linesColor}
isLinesDirty={Boolean(dirtyFields?.lines)}
dirtyLineKeys={dirtyLineKeys || []}
onLineChange={this.handleLineChange}
onSkuBlur={this.handleSkuBlur}
onAddLine={this.handleAddLine}
@@ -827,6 +960,10 @@ class ManifestForm extends PureComponent {
saving={saving}
pdfUploading={pdfUploading}
lines={lines}
isDirty={this.state.isDirty}
dirtyFields={this.state.dirtyFields}
dirtySummary={this.state.dirtySummary}
onRestore={this.props.mode === 'edit' && this.state.isDirty ? this.handleRestore : undefined}
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
onSave={this.handleSave}
onPartyChange={this.handlePartyChange}

View File

@@ -838,18 +838,29 @@ class ManifestFormLine extends PureComponent {
}
render() {
const { columns = LINE_COLUMNS, saving } = this.props;
const { columns = LINE_COLUMNS, saving, isDirty } = this.props;
const secondLine = this.renderSecondLine();
return (
<Fragment>
{/* Row 1: Editable Line Inputs */}
<TableRow hover sx={{ '& > td': { borderBottom: 'none', p: 0 } }}>
<TableRow
hover
sx={{
'& > td': { borderBottom: 'none', p: 0 },
...(isDirty ? { bgcolor: 'rgba(237, 108, 2, 0.08)' } : {}),
}}
>
{columns.map((col, colIdx) => (
<TableCell
key={col.id}
align={col.align}
sx={{ p: 0, width: col.width, verticalAlign: 'middle' }}
sx={{
p: 0,
width: col.width,
verticalAlign: 'middle',
...(isDirty && colIdx === 0 ? { borderLeft: '3px solid', borderColor: 'warning.main' } : {}),
}}
>
{this.renderField(col, colIdx, columns.length)}
</TableCell>
@@ -868,13 +879,19 @@ class ManifestFormLine extends PureComponent {
{/* Row 2: Matched Supplier Article Details */}
{secondLine && (
<TableRow sx={{ '& > td': { pt: 0.25, pb: 0.75, borderBottom: 'none' } }}>
<TableRow
sx={{
'& > td': { pt: 0.25, pb: 0.75, borderBottom: 'none' },
...(isDirty ? { bgcolor: 'rgba(237, 108, 2, 0.04)' } : {}),
}}
>
<TableCell
colSpan={columns.length + 1}
sx={{
py: 0.4,
px: 1,
bgcolor: 'transparent',
...(isDirty ? { borderLeft: '3px solid', borderColor: 'warning.main' } : {}),
}}
>
{secondLine}

View File

@@ -6,12 +6,14 @@ import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
import UploadFileIcon from '@mui/icons-material/UploadFile';
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 } from './manifestConfig';
@@ -232,10 +234,14 @@ class ManifestHeader extends PureComponent {
onNumberChange,
onExternalNumberChange,
onSave,
onRestore,
actionsColor,
docId,
ledgerSummary,
onOpenBookDialog,
isDirty,
dirtyFields,
dirtySummary,
} = this.props;
const hasArticles = this.hasArticles();
@@ -250,7 +256,7 @@ class ManifestHeader extends PureComponent {
(this.state.createdParty?.id === partyId ? this.state.createdParty : null);
const isDateEmpty = !date || String(date).trim() === '';
const isPartyEmpty = partyId == null || partyId === '';
const isPartyEmpty = kind !== 'quote' && (partyId == null || partyId === '');
return (
<>
@@ -267,7 +273,7 @@ class ManifestHeader extends PureComponent {
<TextField
size="small"
type="date"
label={dateLabel}
label={dirtyFields?.date ? `${dateLabel} *` : dateLabel}
value={date}
error={isDateEmpty}
disabled={saving || pdfUploading}
@@ -279,6 +285,12 @@ class ManifestHeader extends PureComponent {
sx={{
flex: { xs: '1 1 100%', sm: '1 1 150px' },
minWidth: { sm: 130 },
...(dirtyFields?.date && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
<Autocomplete
@@ -328,7 +340,7 @@ class ManifestHeader extends PureComponent {
renderInput={(params) => (
<TextField
{...params}
label={partyLabel}
label={dirtyFields?.partyId ? `${partyLabel} *` : partyLabel}
error={isPartyEmpty}
onKeyDown={this.handlePartyKeyDown}
slotProps={{
@@ -338,6 +350,14 @@ class ManifestHeader extends PureComponent {
autoComplete: 'off',
},
}}
sx={{
...(dirtyFields?.partyId && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
)}
/>
@@ -355,13 +375,19 @@ class ManifestHeader extends PureComponent {
{useExternalNumber ? (
<SingleLineTextEdit
size="small"
label="Referenz"
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 },
...(dirtyFields?.externalNumber && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
) : null}
@@ -424,7 +450,7 @@ class ManifestHeader extends PureComponent {
<Stack spacing={1.5} sx={{ pt: 0.5 }}>
<Button
variant="contained"
color="primary"
color={isDirty ? 'warning' : 'primary'}
disabled={saving}
onClick={onSave}
sx={{
@@ -432,10 +458,61 @@ class ManifestHeader extends PureComponent {
fontWeight: 700,
fontSize: '0.875rem',
whiteSpace: 'nowrap',
...(isDirty && {
boxShadow: (theme) => `0 0 0 2px ${theme.palette.warning.main}`,
}),
}}
>
{saving ? 'Speichern…' : 'Speichern'}
{saving ? 'Speichern…' : isDirty ? 'Speichern *' : 'Speichern'}
</Button>
{isDirty && onRestore && (
<Button
variant="outlined"
color="warning"
size="small"
startIcon={<RestoreIcon />}
disabled={saving}
onClick={onRestore}
sx={{
height: 34,
fontWeight: 700,
fontSize: '0.8rem',
whiteSpace: 'nowrap',
textTransform: 'none',
borderWidth: 1.5,
'&:hover': { borderWidth: 1.5 },
}}
>
Wiederherstellen
</Button>
)}
{isDirty && dirtySummary && dirtySummary.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pt: 0.25 }}>
<Typography variant="caption" sx={{ color: 'warning.dark', fontWeight: 700, fontSize: '0.72rem' }}>
Geänderte Felder:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{dirtySummary.map((item) => (
<Chip
key={item}
size="small"
color="warning"
variant="filled"
label={item}
sx={{
height: 20,
fontSize: '0.68rem',
fontWeight: 700,
bgcolor: 'rgba(237, 108, 2, 0.15)',
color: 'warning.dark',
border: '1px solid',
borderColor: 'warning.main',
}}
/>
))}
</Box>
</Box>
)}
{docId != null && kind === 'delivery' && onOpenBookDialog && (
<Button
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}

View File

@@ -9,6 +9,7 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
@@ -24,6 +25,8 @@ class ManifestLines extends PureComponent {
lines,
saving,
linesColor,
isLinesDirty = false,
dirtyLineKeys = [],
onLineChange,
onAddLine,
onRemoveLine,
@@ -65,7 +68,28 @@ class ManifestLines extends PureComponent {
wide={wide}
minHeight={280}
color={linesColor}
title="Artikel"
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>Artikel</span>
{isLinesDirty && (
<Chip
size="small"
color="warning"
variant="filled"
label="Geändert"
sx={{
height: 20,
fontSize: '0.68rem',
fontWeight: 700,
bgcolor: 'rgba(237, 108, 2, 0.15)',
color: 'warning.dark',
border: '1px solid',
borderColor: 'warning.main',
}}
/>
)}
</Box>
}
sx={{
...(wide >= 4 ? { gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } } : {}),
...(!hasArticles
@@ -115,6 +139,11 @@ class ManifestLines extends PureComponent {
(supplierArticlesLoading && !matched)
: false;
const ledgerLine = line.id != null ? ledgerLinesById[line.id] : null;
const isDirty =
dirtyLineKeys.includes(lineKey) ||
(line.key != null && dirtyLineKeys.includes(line.key)) ||
(line.id != null && dirtyLineKeys.includes(`id-${line.id}`)) ||
dirtyLineKeys.includes(`idx-${index}`);
return (
<ManifestFormLine
@@ -125,6 +154,7 @@ class ManifestLines extends PureComponent {
line={line.key != null ? line : { ...line, key: index }}
columns={columns}
saving={saving}
isDirty={Boolean(isDirty)}
supplierArticle={matched}
loading={Boolean(isLineLoading)}
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}