feat(delivery): replace deliveryEditorSlice with automat and add PDF/text attachment handling

- replace Redux deliveryEditorSlice with deliveryEditorAutomat using automat
- add Attachment and AttachmentList components in Kopfdaten
- extract PDF text via pdftotext-wasm and render PDF thumbnails with pdfjs-dist
- add TextEditorDialog to view and edit extracted text files (preformatted monospace, no wrap)
- configure Vite server.fs.allow for pdftotext.wasm
This commit is contained in:
seb
2026-09-18 01:49:59 +02:00
parent 795043fe99
commit 27f6dfdebf
15 changed files with 1369 additions and 246 deletions

View File

@@ -1,5 +1,4 @@
import { PureComponent } from 'react';
import { legacy_connect as connect } from 'react-redux';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
@@ -18,6 +17,7 @@ import {
emptyLine,
lineFromApi,
manifestConfig,
TextEditorDialog,
} from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
import { loadParties } from '../../components/manifest/manifestConfig';
@@ -25,29 +25,9 @@ import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import {
setId,
setDeliveryEntities,
setDate,
setDeliveryEntity,
setNumber,
setExternalNumber,
setType,
setLineitems,
setNotes,
addLineitem,
updateLineitem,
removeLineitem,
resetDelivery,
selectDeliveryId,
selectDeliveryEntities,
selectDeliveryDate,
selectDeliveryEntity,
selectDeliveryNumber,
selectDeliveryExternalNumber,
selectDeliveryLineitems,
selectDeliveryNotes,
} from '../../store/deliveryEditorSlice';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { deliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
class LieferungNeuPage extends PureComponent {
static contextType = StorageManagerContext;
@@ -56,6 +36,7 @@ class LieferungNeuPage extends PureComponent {
super(props);
this.cfg = manifestConfig('delivery');
this.state = {
...deliveryEditorAutomat.state,
status: 'loadingParties',
error: null,
partiesLoading: true,
@@ -64,6 +45,8 @@ class LieferungNeuPage extends PureComponent {
pdfDialogOpen: false,
pdfDialogData: null,
pdfFileName: '',
textEditorOpen: false,
textEditorAttachment: null,
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
@@ -76,16 +59,22 @@ class LieferungNeuPage extends PureComponent {
this.handleRemoveLine = this.handleRemoveLine.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handlePdfUpload = this.handlePdfUpload.bind(this);
this.handlePdfRescan = this.handlePdfRescan.bind(this);
this.handlePdfDelete = this.handlePdfDelete.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
}
componentDidMount() {
this.unsubscribe = deliveryEditorAutomat.subscribe(this);
this._cancelled = false;
if (this.props.docId != null) {
void this.loadEdit();
} else {
if (this.props.deliveryId != null || this.props.number != null) {
this.props.resetDelivery();
if (this.state.deliveryId != null || this.state.number != null) {
deliveryEditorAutomat.actions.resetDelivery();
}
void this.loadPartiesOnly();
}
@@ -96,7 +85,7 @@ class LieferungNeuPage extends PureComponent {
if (this.props.docId != null) {
void this.loadEdit();
} else {
this.props.resetDelivery();
deliveryEditorAutomat.actions.resetDelivery();
void this.loadPartiesOnly();
}
}
@@ -104,6 +93,7 @@ class LieferungNeuPage extends PureComponent {
componentWillUnmount() {
this._cancelled = true;
this.unsubscribe?.();
}
async loadPartiesOnly() {
@@ -111,7 +101,7 @@ class LieferungNeuPage extends PureComponent {
try {
const parties = await loadParties('delivery', this.context);
if (this._cancelled) return;
this.props.setDeliveryEntities(parties);
deliveryEditorAutomat.actions.setDeliveryEntities(parties);
this.setState({
status: 'ready',
partiesLoading: false,
@@ -146,7 +136,7 @@ class LieferungNeuPage extends PureComponent {
]);
if (this._cancelled) return;
this.props.setDeliveryEntities(parties);
deliveryEditorAutomat.actions.setDeliveryEntities(parties);
requireObject(docRes, cfg.getPathLabel);
requireObject(docRes[cfg.docField], cfg.docField);
@@ -155,12 +145,12 @@ class LieferungNeuPage extends PureComponent {
const lines =
apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
this.props.setId(doc.id ?? docId);
this.props.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) this.props.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) this.props.setNumber(doc.number);
if (doc.externalNumber != null) this.props.setExternalNumber(String(doc.externalNumber));
this.props.setLineitems(lines);
deliveryEditorAutomat.actions.setId(doc.id ?? docId);
deliveryEditorAutomat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) deliveryEditorAutomat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) deliveryEditorAutomat.actions.setNumber(doc.number);
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber));
deliveryEditorAutomat.actions.setLineitems(lines);
this.setState({
status: 'ready',
@@ -182,24 +172,24 @@ class LieferungNeuPage extends PureComponent {
}
handlePartyChange(partyId) {
this.props.setDeliveryEntity(partyId);
deliveryEditorAutomat.actions.setDeliveryEntity(partyId);
}
handleDateChange(date) {
this.props.setDate(date);
deliveryEditorAutomat.actions.setDate(date);
}
handleNumberChange(number) {
this.props.setNumber(number);
deliveryEditorAutomat.actions.setNumber(number);
}
handleExternalNumberChange(externalNumber) {
this.props.setExternalNumber(externalNumber);
deliveryEditorAutomat.actions.setExternalNumber(externalNumber);
}
findLineIndex(key) {
if (typeof key === 'number' && !Number.isNaN(key)) return key;
const lines = this.props.lines;
const lines = this.state.lines;
if (Array.isArray(lines)) {
const idx = lines.findIndex((l) => l.key === key);
if (idx !== -1) return idx;
@@ -210,7 +200,7 @@ class LieferungNeuPage extends PureComponent {
handleLineChange(key, field, value) {
const index = this.findLineIndex(key);
this.props.updateLineitem({
deliveryEditorAutomat.actions.updateLineitem({
index,
key,
[field]: value,
@@ -220,16 +210,105 @@ class LieferungNeuPage extends PureComponent {
}
handleAddLine() {
this.props.addLineitem();
deliveryEditorAutomat.actions.addLineitem();
}
handleRemoveLine(key) {
const index = this.findLineIndex(key);
this.props.removeLineitem(index >= 0 ? index : key);
deliveryEditorAutomat.actions.removeLineitem(index >= 0 ? index : key);
}
async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return;
// 1. Generate thumbnail for PDF
let thumbnail = null;
try {
thumbnail = await generatePdfThumbnail(file);
} catch (err) {
console.error('Fehler beim Erstellen des PDF-Thumbnails:', err);
}
const pdfId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const pdfAttachment = {
id: pdfId,
file,
name: file.name,
size: file.size,
type: 'application/pdf',
thumbnail,
uploadedAt: new Date().toISOString(),
};
deliveryEditorAutomat.actions.addAttachment(pdfAttachment);
// 2. Extract text using pdftotext-wasm to create 2nd file (textfile)
try {
const text = await extractPdfText(file, { layout: true });
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
const txtAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
text,
parentPdfId: pdfId,
uploadedAt: new Date().toISOString(),
};
deliveryEditorAutomat.actions.addAttachment(txtAttachment);
} catch (txtErr) {
console.error('Fehler beim Extrahieren von Text mit pdftotext-wasm:', txtErr);
}
// 3. Run SSE parsing with backend
await this.runPdfParse(file);
}
async handlePdfRescan(attachment) {
if (!attachment?.file || this.state.pdfUploading) return;
// Refresh textfile upon rescan
try {
const text = await extractPdfText(attachment.file, { layout: true });
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
const existingTxt = this.state.attachments?.find(
(a) => a.parentPdfId === attachment.id || a.name === txtName,
);
if (existingTxt) {
deliveryEditorAutomat.actions.updateAttachment({
id: existingTxt.id,
file: txtFile,
size: txtFile.size,
text,
});
} else {
deliveryEditorAutomat.actions.addAttachment({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
text,
parentPdfId: attachment.id,
uploadedAt: new Date().toISOString(),
});
}
} catch (txtErr) {
console.error('Fehler beim Aktualisieren der Textdatei:', txtErr);
}
await this.runPdfParse(attachment.file);
}
handlePdfDelete(attachment) {
if (!attachment?.id) return;
deliveryEditorAutomat.actions.removeAttachment(attachment.id);
}
async runPdfParse(file) {
this.setState({
pdfUploading: true,
pdfDialogOpen: true,
@@ -311,9 +390,9 @@ class LieferungNeuPage extends PureComponent {
const notes = Array.isArray(result?.notes) ? result.notes : [];
// If supplierId is null, try matching supplier by name from loaded delivery entities
if (supplierId == null && result?.basedata?.lieferantName && Array.isArray(this.props.deliveryEntities)) {
if (supplierId == null && result?.basedata?.lieferantName && Array.isArray(this.state.deliveryEntities)) {
const nameToMatch = result.basedata.lieferantName.trim().toLowerCase();
const matched = this.props.deliveryEntities.find(
const matched = this.state.deliveryEntities.find(
(e) =>
e.name &&
(e.name.trim().toLowerCase() === nameToMatch ||
@@ -323,17 +402,17 @@ class LieferungNeuPage extends PureComponent {
if (matched) supplierId = matched.id;
}
if (docDate) this.props.setDate(docDate);
if (supplierId != null) this.props.setDeliveryEntity(supplierId);
if (docDate) deliveryEditorAutomat.actions.setDate(docDate);
if (supplierId != null) deliveryEditorAutomat.actions.setDeliveryEntity(supplierId);
if (extNum != null) {
this.props.setExternalNumber(String(extNum));
deliveryEditorAutomat.actions.setExternalNumber(String(extNum));
}
if (docType) this.props.setType(docType);
if (docType) deliveryEditorAutomat.actions.setType(docType);
if (newLines && newLines.length > 0) {
this.props.setLineitems(newLines);
deliveryEditorAutomat.actions.setLineitems(newLines);
}
if (notes.length > 0) {
this.props.setNotes(notes);
deliveryEditorAutomat.actions.setNotes(notes);
}
break;
}
@@ -375,6 +454,52 @@ class LieferungNeuPage extends PureComponent {
this.setState({ pdfDialogOpen: false });
}
handleAttachmentClick(attachment) {
if (!attachment) return;
const isText =
attachment.type?.startsWith('text/') ||
attachment.name?.toLowerCase().endsWith('.txt') ||
Boolean(attachment.text);
if (isText) {
this.setState({
textEditorOpen: true,
textEditorAttachment: attachment,
});
} else if (attachment.file instanceof Blob || attachment.file instanceof File) {
const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank');
}
}
handleCloseTextEditor() {
this.setState({
textEditorOpen: false,
textEditorAttachment: null,
});
}
handleSaveTextEditor(newText) {
const attachment = this.state.textEditorAttachment;
if (!attachment) return;
const newFile = new File([newText], attachment.name, {
type: 'text/plain;charset=utf-8',
});
deliveryEditorAutomat.actions.updateAttachment({
id: attachment.id,
file: newFile,
size: newFile.size,
text: newText,
});
this.setState({
textEditorOpen: false,
textEditorAttachment: null,
});
}
async handleSave() {
const { docId } = this.props;
const cfg = this.cfg;
@@ -385,11 +510,11 @@ class LieferungNeuPage extends PureComponent {
try {
body = buildManifestBody(
{
number: this.props.number ?? '',
externalNumber: this.props.externalNumber ?? '',
date: this.props.date ?? '',
partyId: this.props.partyId,
lines: this.props.lines,
number: this.state.number ?? '',
externalNumber: this.state.externalNumber ?? '',
date: this.state.date ?? '',
partyId: this.state.partyId,
lines: this.state.lines,
},
{
partyField: cfg.partyField,
@@ -412,7 +537,7 @@ class LieferungNeuPage extends PureComponent {
await this.context[cfg.updateCache]();
if (this._cancelled) return;
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
this.props.resetDelivery();
deliveryEditorAutomat.actions.resetDelivery();
this.props.navigate(cfg.editPath(data[cfg.docField].id));
} else {
const data = await this.context[cfg.api].update(docId, body);
@@ -425,12 +550,12 @@ class LieferungNeuPage extends PureComponent {
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
const lines = apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
this.props.setId(doc.id ?? docId);
this.props.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) this.props.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) this.props.setNumber(doc.number);
if (doc.externalNumber != null) this.props.setExternalNumber(String(doc.externalNumber));
this.props.setLineitems(lines);
deliveryEditorAutomat.actions.setId(doc.id ?? docId);
deliveryEditorAutomat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) deliveryEditorAutomat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) deliveryEditorAutomat.actions.setNumber(doc.number);
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber));
deliveryEditorAutomat.actions.setLineitems(lines);
enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
this.setState({ saving: false });
@@ -453,8 +578,6 @@ class LieferungNeuPage extends PureComponent {
pdfDialogOpen,
pdfDialogData,
pdfFileName,
} = this.state;
const {
deliveryEntities,
date,
partyId,
@@ -462,7 +585,8 @@ class LieferungNeuPage extends PureComponent {
externalNumber,
lines,
notes,
} = this.props;
attachments,
} = this.state;
if (status === 'loadingParties' || status === 'loading') {
return (
@@ -492,7 +616,11 @@ class LieferungNeuPage extends PureComponent {
externalNumber={externalNumber ?? ''}
saving={saving}
pdfUploading={pdfUploading}
attachments={attachments}
onPdfUpload={this.handlePdfUpload}
onPdfRescan={this.handlePdfRescan}
onPdfDelete={this.handlePdfDelete}
onAttachmentClick={this.handleAttachmentClick}
onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange}
onNumberChange={this.handleNumberChange}
@@ -546,39 +674,15 @@ class LieferungNeuPage extends PureComponent {
data={pdfDialogData}
onClose={this.handleClosePdfDialog}
/>
<TextEditorDialog
open={this.state.textEditorOpen}
attachment={this.state.textEditorAttachment}
onClose={this.handleCloseTextEditor}
onSave={this.handleSaveTextEditor}
/>
</>
);
}
}
const mapStateToProps = (state) => ({
deliveryId: selectDeliveryId(state),
deliveryEntities: selectDeliveryEntities(state),
date: selectDeliveryDate(state),
partyId: selectDeliveryEntity(state),
number: selectDeliveryNumber(state),
externalNumber: selectDeliveryExternalNumber(state),
lines: selectDeliveryLineitems(state),
notes: selectDeliveryNotes(state),
});
const mapDispatchToProps = {
setId,
setDeliveryEntities,
setDate,
setDeliveryEntity,
setNumber,
setExternalNumber,
setType,
setLineitems,
setNotes,
addLineitem,
updateLineitem,
removeLineitem,
resetDelivery,
};
export default connect(
mapStateToProps,
mapDispatchToProps,
)(withNavigate(LieferungNeuPage));
export default withNavigate(LieferungNeuPage);