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

306
package-lock.json generated
View File

@@ -16,8 +16,11 @@
"@mui/material": "^9.4.0",
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1",
"automat": "file:../automat",
"dashgui": "file:../dashgui",
"notistack": "^3.0.2",
"pdfjs-dist": "^6.3.289",
"pdftotext-wasm": "file:../pdftotext-wasm",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-redux": "^9.3.0",
@@ -32,6 +35,17 @@
"vite-plugin-pwa": "^1.3.0"
}
},
"../automat": {
"version": "0.1.0",
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.1.1",
"vite": "^8.3.0"
}
},
"../dashgui": {
"version": "0.1.0",
"devDependencies": {
@@ -54,6 +68,13 @@
"react-dom": "^19.0.0"
}
},
"../pdftotext-wasm": {
"version": "0.1.0",
"license": "GPLv3",
"bin": {
"pdftotext-wasm": "bin.js"
}
},
"node_modules/@apideck/better-ajv-errors": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
@@ -2070,6 +2091,271 @@
}
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.9",
"@napi-rs/canvas-darwin-arm64": "1.0.9",
"@napi-rs/canvas-darwin-x64": "1.0.9",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -3169,6 +3455,10 @@
"node": ">= 4.0.0"
}
},
"node_modules/automat": {
"resolved": "../automat",
"link": true
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -5705,6 +5995,22 @@
"node": ">=8"
}
},
"node_modules/pdfjs-dist": {
"version": "6.3.289",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/pdftotext-wasm": {
"resolved": "../pdftotext-wasm",
"link": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

@@ -22,8 +22,11 @@
"@mui/material": "^9.4.0",
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1",
"notistack": "^3.0.2",
"automat": "file:../automat",
"dashgui": "file:../dashgui",
"notistack": "^3.0.2",
"pdfjs-dist": "^6.3.289",
"pdftotext-wasm": "file:../pdftotext-wasm",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-redux": "^9.3.0",

View File

@@ -0,0 +1,257 @@
import { Automat } from 'automat';
export function createEmptyLineitem(line = {}) {
return {
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: line.name ?? null,
sku: line.sku ?? null,
amount: line.amount ?? null,
pu: line.pu ?? null,
unit: line.unit ?? null,
netprice: line.netprice ?? null,
taxrate: line.taxrate ?? null,
};
}
export const initialDelivery = {
id: null,
deliveryId: null,
date: null,
deliveryEntity: null,
partyId: null,
externalNumber: null,
external_number: null,
number: null,
type: null,
lines: [],
lineitems: [],
notes: [],
attachments: [],
files: [],
};
export const initialState = {
deliveryEntities: [],
...initialDelivery,
};
export const deliveryEditorAutomat = new Automat(initialState, {
setId(id) {
deliveryEditorAutomat.setState({ id, deliveryId: id });
},
setDeliveryEntities(deliveryEntities) {
deliveryEditorAutomat.setState({ deliveryEntities: deliveryEntities ?? [] });
},
setDelivery(delivery = {}) {
const rawLines = delivery.lines ?? delivery.lineitems ?? [];
const lines = Array.isArray(rawLines) ? rawLines : [];
const partyId = delivery.partyId ?? delivery.deliveryEntity ?? null;
const extNum = delivery.externalNumber ?? delivery.external_number ?? null;
const id = delivery.id ?? delivery.deliveryId ?? null;
const notes = Array.isArray(delivery.notes)
? delivery.notes
: delivery.notes ? [delivery.notes] : [];
const rawAttachments = delivery.attachments ?? delivery.files ?? [];
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
deliveryEditorAutomat.setState({
...initialDelivery,
...delivery,
id,
deliveryId: id,
partyId,
deliveryEntity: partyId,
externalNumber: extNum,
external_number: extNum,
lines,
lineitems: lines,
notes,
attachments,
files: attachments,
});
},
setDate(date) {
deliveryEditorAutomat.setState({ date });
},
setDeliveryEntity(entityId) {
deliveryEditorAutomat.setState({ deliveryEntity: entityId, partyId: entityId });
},
setPartyId(partyId) {
deliveryEditorAutomat.setState({ deliveryEntity: partyId, partyId });
},
setExternalNumber(externalNumber) {
deliveryEditorAutomat.setState({
externalNumber,
external_number: externalNumber,
});
},
setNumber(number) {
deliveryEditorAutomat.setState({ number });
},
setType(type) {
deliveryEditorAutomat.setState({ type });
},
setLines(lines) {
const cleanLines = lines ?? [];
deliveryEditorAutomat.setState({ lines: cleanLines, lineitems: cleanLines });
},
setLineitems(lines) {
const cleanLines = lines ?? [];
deliveryEditorAutomat.setState({ lines: cleanLines, lineitems: cleanLines });
},
setNotes(notes) {
const cleanNotes = Array.isArray(notes)
? notes
: notes ? [notes] : [];
deliveryEditorAutomat.setState({ notes: cleanNotes });
},
addLineitem(line = {}) {
const item = createEmptyLineitem(line);
const nextLines = [...(deliveryEditorAutomat.state.lines ?? []), item];
deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines });
},
addLine(line = {}) {
deliveryEditorAutomat.actions.addLineitem(line);
},
updateLineitem(payload) {
const { index, key, ...fields } = payload || {};
const currentLines = deliveryEditorAutomat.state.lines ?? [];
let targetIndex = typeof index === 'number' && !Number.isNaN(index) ? index : -1;
if (targetIndex === -1 && key != null) {
targetIndex = currentLines.findIndex((l) => l.key === key);
}
if (targetIndex >= 0 && targetIndex < currentLines.length) {
const nextLines = [...currentLines];
nextLines[targetIndex] = {
...nextLines[targetIndex],
...fields,
};
deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines });
}
},
updateLine(payload) {
deliveryEditorAutomat.actions.updateLineitem(payload);
},
removeLineitem(payload) {
const currentLines = deliveryEditorAutomat.state.lines ?? [];
let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1;
if (index === -1 && payload != null) {
const key = typeof payload === 'string' ? payload : payload?.key;
if (key != null) {
index = currentLines.findIndex((l) => l.key === key);
}
}
if (index >= 0 && index < currentLines.length) {
const nextLines = currentLines.filter((_, i) => i !== index);
deliveryEditorAutomat.setState({ lines: nextLines, lineitems: nextLines });
}
},
removeLine(payload) {
deliveryEditorAutomat.actions.removeLineitem(payload);
},
addAttachment(attachment) {
const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const item = { ...attachment, id };
const next = [...(deliveryEditorAutomat.state.attachments ?? []), item];
deliveryEditorAutomat.setState({ attachments: next, files: next });
},
setAttachments(attachments) {
const clean = Array.isArray(attachments) ? attachments : [];
deliveryEditorAutomat.setState({ attachments: clean, files: clean });
},
updateAttachment(payload) {
const { id, ...fields } = payload || {};
const current = deliveryEditorAutomat.state.attachments ?? [];
const idx = current.findIndex((a) => a.id === id);
if (idx !== -1) {
const next = [...current];
next[idx] = { ...next[idx], ...fields };
deliveryEditorAutomat.setState({ attachments: next, files: next });
}
},
removeAttachment(idOrIndex) {
const current = deliveryEditorAutomat.state.attachments ?? [];
let next;
if (typeof idOrIndex === 'number') {
next = current.filter((_, idx) => idx !== idOrIndex);
} else {
const id = typeof idOrIndex === 'string' ? idOrIndex : idOrIndex?.id;
next = current.filter((a) => a.id !== id);
}
deliveryEditorAutomat.setState({ attachments: next, files: next });
},
resetDelivery() {
deliveryEditorAutomat.setState({
...initialDelivery,
});
},
resetDeliveryEditor() {
deliveryEditorAutomat.setState({
...initialState,
});
},
});
export const {
setId,
setDeliveryEntities,
setDelivery,
setDate,
setDeliveryEntity,
setPartyId,
setExternalNumber,
setNumber,
setType,
setLines,
setLineitems,
setNotes,
addLineitem,
addLine,
updateLineitem,
updateLine,
removeLineitem,
removeLine,
addAttachment,
setAttachments,
updateAttachment,
removeAttachment,
resetDelivery,
resetDeliveryEditor,
} = deliveryEditorAutomat.actions;
// Selectors
export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id;
export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities;
export const selectDeliveryDate = (state = deliveryEditorAutomat.state) => state.date;
export const selectDeliveryEntity = (state = deliveryEditorAutomat.state) => state.deliveryEntity ?? state.partyId;
export const selectDeliveryExternalNumber = (state = deliveryEditorAutomat.state) => state.externalNumber ?? state.external_number;
export const selectDeliveryNumber = (state = deliveryEditorAutomat.state) => state.number;
export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type;
export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems;
export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes;
export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments;
export default deliveryEditorAutomat;

1
src/automats/index.js Normal file
View File

@@ -0,0 +1 @@
export * from './deliveryEditorAutomat';

View File

@@ -0,0 +1,243 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import SyncIcon from '@mui/icons-material/Sync';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
export class Attachment extends PureComponent {
handleRescan = (e) => {
e?.stopPropagation?.();
const { attachment, onRescan } = this.props;
if (attachment && onRescan) {
onRescan(attachment);
}
};
handleDelete = (e) => {
e?.stopPropagation?.();
const { attachment, onDelete } = this.props;
if (attachment && onDelete) {
onDelete(attachment);
}
};
handleTileClick = (e) => {
const { attachment, onClick } = this.props;
if (onClick && attachment) {
onClick(attachment);
} else if (attachment?.file instanceof Blob || attachment?.file instanceof File) {
const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank');
}
};
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`;
}
render() {
const { attachment, disabled, sx } = this.props;
const thumbnail = attachment?.thumbnail;
const name = attachment?.name || 'Dokument';
const size = attachment?.size;
const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf');
const isText = attachment?.type?.startsWith('text/') || name.toLowerCase().endsWith('.txt');
return (
<Box
component="div"
role="button"
tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : this.handleTileClick}
sx={{
...tileBaseSx,
width: TILE_SIZE,
height: TILE_SIZE,
p: 0,
gap: 0,
position: 'relative',
overflow: 'hidden',
cursor: disabled ? 'default' : 'pointer',
bgcolor: isText ? '#fcfcfc' : '#f8fafc',
border: '1px solid',
borderColor: 'divider',
...(disabled ? { opacity: 0.6, pointerEvents: 'none' } : {}),
...sx,
}}
>
{/* Thumbnail, Text preview, or Fallback Icon */}
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: isText ? '#fafafa' : '#f1f5f9',
}}
>
{thumbnail ? (
<Box
component="img"
src={thumbnail}
alt={name}
sx={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
) : isText ? (
<Box
sx={{
width: '100%',
height: '100%',
p: 1.25,
bgcolor: '#fafafa',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
overflow: 'hidden',
position: 'relative',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<TextSnippetOutlinedIcon sx={{ fontSize: 18, color: 'primary.main' }} />
<Typography
component="span"
sx={{
fontSize: '0.62rem',
fontWeight: 700,
color: 'primary.dark',
bgcolor: 'action.selected',
px: 0.5,
py: 0.1,
borderRadius: 0.5,
letterSpacing: '0.05em',
}}
>
TXT
</Typography>
</Box>
<Typography
component="pre"
sx={{
m: 0,
fontFamily: 'monospace',
fontSize: '0.45rem',
lineHeight: 1.2,
color: 'text.secondary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflow: 'hidden',
opacity: 0.8,
userSelect: 'none',
}}
>
{attachment?.text ? attachment.text.slice(0, 300) : 'Textdatei'}
</Typography>
</Box>
) : (
<PictureAsPdfIcon sx={{ fontSize: 48, color: 'error.main' }} />
)}
</Box>
{/* Action buttons: Rescan (for PDF) and Delete */}
<Box
sx={{
position: 'absolute',
top: 4,
right: 4,
display: 'flex',
gap: 0.5,
zIndex: 2,
}}
onClick={(e) => e.stopPropagation()}
>
{isPdf ? (
<Tooltip title="Neu analysieren">
<span>
<IconButton
size="small"
disabled={disabled}
onClick={this.handleRescan}
sx={{
bgcolor: 'rgba(0, 0, 0, 0.6)',
color: '#fff',
p: 0.5,
backdropFilter: 'blur(2px)',
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.85)' },
}}
>
<SyncIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
) : null}
<Tooltip title="Löschen">
<span>
<IconButton
size="small"
disabled={disabled}
onClick={this.handleDelete}
sx={{
bgcolor: 'rgba(0, 0, 0, 0.6)',
color: '#fff',
p: 0.5,
backdropFilter: 'blur(2px)',
'&:hover': { bgcolor: 'error.main' },
}}
>
<DeleteOutlineOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</Box>
{/* Bottom file metadata overlay */}
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
px: 1,
py: 0.5,
background: 'linear-gradient(transparent, rgba(0,0,0,0.75) 40%, rgba(0,0,0,0.85))',
textAlign: 'left',
pointerEvents: 'none',
}}
>
<Typography
variant="caption"
fontWeight={600}
noWrap
sx={{ color: '#fff', display: 'block', fontSize: '0.72rem', lineHeight: 1.2 }}
>
{name}
</Typography>
{size ? (
<Typography
variant="caption"
noWrap
sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.65rem' }}
>
{this.formatFileSize(size)}
</Typography>
) : null}
</Box>
</Box>
);
}
}
export default Attachment;

View File

@@ -0,0 +1,41 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Attachment from './Attachment';
export class AttachmentList extends PureComponent {
render() {
const { attachments, onRescan, onDelete, onClick, disabled } = this.props;
if (!Array.isArray(attachments) || attachments.length === 0) {
return null;
}
return (
<Box sx={{ mt: 2.5, pt: 1.5, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography
variant="caption"
color="text.secondary"
fontWeight={600}
sx={{ mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
Dateianhänge ({attachments.length})
</Typography>
<Stack direction="row" spacing={1.5} sx={{ flexWrap: 'wrap', gap: 1.5 }}>
{attachments.map((attachment, index) => (
<Attachment
key={attachment.id ?? index}
attachment={attachment}
onRescan={onRescan}
onDelete={onDelete}
onClick={onClick}
disabled={disabled}
/>
))}
</Stack>
</Box>
);
}
}
export default AttachmentList;

View File

@@ -8,6 +8,7 @@ import UploadFileIcon from '@mui/icons-material/UploadFile';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile } from 'dashgui/panels';
import FileDropzone from '../FileDropzone';
import AttachmentList from './AttachmentList';
import { manifestConfig, partyDisplayName } from './manifestConfig';
class ManifestHeader extends PureComponent {
@@ -48,7 +49,12 @@ class ManifestHeader extends PureComponent {
externalNumber,
saving,
pdfUploading,
attachments,
onPdfUpload,
onPdfRescan,
onPdfDelete,
onPdfClick,
onAttachmentClick,
onDateChange,
onNumberChange,
onExternalNumberChange,
@@ -165,6 +171,13 @@ class ManifestHeader extends PureComponent {
</FileDropzone>
) : null}
</Stack>
<AttachmentList
attachments={attachments}
onRescan={onPdfRescan}
onDelete={onPdfDelete}
onClick={onAttachmentClick ?? onPdfClick}
disabled={saving || pdfUploading}
/>
{onPdfUpload ? (
<input
ref={this.fileInputRef}

View File

@@ -0,0 +1,250 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import CloseIcon from '@mui/icons-material/Close';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack';
export class TextEditorDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
content: '',
initialContent: '',
loading: false,
};
this.handleChange = this.handleChange.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleCopy = this.handleCopy.bind(this);
}
componentDidMount() {
this.loadContent();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment || (this.props.open && !prevProps.open)) {
this.loadContent();
}
}
async loadContent() {
const { attachment, open } = this.props;
if (!open || !attachment) return;
if (typeof attachment.text === 'string') {
this.setState({
content: attachment.text,
initialContent: attachment.text,
loading: false,
});
return;
}
if (attachment.file instanceof Blob || attachment.file instanceof File) {
this.setState({ loading: true });
try {
const text = await attachment.file.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
} catch (err) {
console.error('Fehler beim Lesen der Textdatei:', err);
this.setState({ loading: false });
}
}
}
handleChange(e) {
this.setState({ content: e.target.value });
}
handleSave() {
const { onSave, onClose } = this.props;
const { content } = this.state;
onSave?.(content);
enqueueSnackbar('Textdatei gespeichert', { variant: 'success' });
onClose?.();
}
async handleCopy() {
try {
await navigator.clipboard.writeText(this.state.content);
enqueueSnackbar('In die Zwischenablage kopiert', { variant: 'info' });
} catch {
enqueueSnackbar('Kopieren fehlgeschlagen', { variant: 'error' });
}
}
render() {
const { open, onClose, attachment, readOnly = false } = this.props;
if (!open) return null;
const { content, initialContent, loading } = this.state;
const filename = attachment?.name || 'Dokument.txt';
const isDirty = content !== initialContent;
const linesCount = content ? content.split('\n').length : 0;
const charCount = content ? content.length : 0;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="lg"
fullWidth
slotProps={{
paper: {
sx: {
height: '85vh',
maxHeight: 850,
display: 'flex',
flexDirection: 'column',
},
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 1.5,
px: 2.5,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<TextSnippetOutlinedIcon color="primary" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{filename}
</Typography>
<Typography variant="caption" color="text.secondary">
{linesCount} Zeilen · {charCount} Zeichen
</Typography>
</Box>
{isDirty && (
<Chip label="Ungespeichert" size="small" color="warning" sx={{ height: 20, fontSize: '0.7rem' }} />
)}
</Box>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent
sx={{
p: 2,
flex: 1,
display: 'flex',
flexDirection: 'column',
bgcolor: '#fafafa',
}}
>
<TextField
multiline
fullWidth
value={content}
disabled={loading}
onChange={this.handleChange}
inputProps={{
wrap: 'off',
spellCheck: false,
}}
slotProps={{
htmlInput: {
wrap: 'off',
spellCheck: false,
},
input: {
readOnly,
sx: {
height: '100%',
alignItems: 'flex-start',
fontFamily: '"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.72rem',
lineHeight: 1.45,
tabSize: 4,
bgcolor: '#ffffff',
p: 1.5,
'& textarea': {
height: '100% !important',
whiteSpace: 'pre !important',
overflowWrap: 'normal !important',
wordWrap: 'normal !important',
wordBreak: 'normal !important',
overflowX: 'auto !important',
overflowY: 'auto !important',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
},
},
},
}}
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
'& .MuiInputBase-root': {
flex: 1,
},
}}
/>
</DialogContent>
<DialogActions
sx={{
px: 2.5,
py: 1.5,
borderTop: '1px solid',
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
}}
>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button size="small" onClick={onClose}>
{readOnly ? 'Schließen' : 'Abbrechen'}
</Button>
{!readOnly && (
<Button
size="small"
variant="contained"
startIcon={<SaveOutlinedIcon />}
onClick={this.handleSave}
disabled={loading || !isDirty}
>
Speichern
</Button>
)}
</Box>
</DialogActions>
</Dialog>
);
}
}
export default TextEditorDialog;

View File

@@ -3,6 +3,9 @@ export { default as ManifestForm } from './ManifestForm';
export { default as ManifestHeader } from './ManifestHeader';
export { default as ManifestLines } from './ManifestLines';
export { default as ManifestFormLine } from './ManifestFormLine';
export { default as Attachment } from './Attachment';
export { default as AttachmentList } from './AttachmentList';
export { default as TextEditorDialog } from './TextEditorDialog';
export { withManifestId } from './withManifestId';
export {
manifestConfig,

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);

View File

@@ -1,145 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
export function createEmptyLineitem(line = {}) {
return {
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: line.name ?? null,
sku: line.sku ?? null,
amount: line.amount ?? null,
pu: line.pu ?? null,
unit: line.unit ?? null,
netprice: line.netprice ?? null,
taxrate: line.taxrate ?? null,
};
}
const initialDelivery = {
id: null,
date: null,
deliveryEntity: null,
external_number: null,
number: null,
type: null,
lineitems: [],
notes: [],
};
const initialState = {
// system now delivery entity list
deliveryEntities: [],
// delivery being edited
delivery: initialDelivery,
};
const deliveryEditorSlice = createSlice({
name: 'deliveryEditor',
initialState,
reducers: {
setId(state, action) {
state.delivery.id = action.payload;
},
setDeliveryEntities(state, action) {
state.deliveryEntities = action.payload ?? [];
},
setDelivery(state, action) {
state.delivery = {
...initialDelivery,
...action.payload,
};
},
setDate(state, action) {
state.delivery.date = action.payload;
},
setDeliveryEntity(state, action) {
state.delivery.deliveryEntity = action.payload;
},
setExternalNumber(state, action) {
state.delivery.external_number = action.payload;
},
setNumber(state, action) {
state.delivery.number = action.payload;
},
setType(state, action) {
state.delivery.type = action.payload;
},
setLineitems(state, action) {
state.delivery.lineitems = action.payload ?? [];
},
setNotes(state, action) {
state.delivery.notes = Array.isArray(action.payload)
? action.payload
: action.payload ? [action.payload] : [];
},
addLineitem(state, action) {
state.delivery.lineitems.push(createEmptyLineitem(action.payload));
},
updateLineitem(state, action) {
const { index, key, ...fields } = action.payload;
let targetIndex = typeof index === 'number' && !Number.isNaN(index) ? index : -1;
if (targetIndex === -1 && key != null) {
targetIndex = state.delivery.lineitems.findIndex((l) => l.key === key);
}
if (targetIndex >= 0 && targetIndex < state.delivery.lineitems.length) {
state.delivery.lineitems[targetIndex] = {
...state.delivery.lineitems[targetIndex],
...fields,
};
}
},
removeLineitem(state, action) {
const payload = action.payload;
let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1;
if (index === -1 && payload != null) {
const key = typeof payload === 'string' ? payload : payload?.key;
if (key != null) {
index = state.delivery.lineitems.findIndex((l) => l.key === key);
}
}
if (index >= 0 && index < state.delivery.lineitems.length) {
state.delivery.lineitems.splice(index, 1);
}
},
resetDelivery(state) {
state.delivery = { ...initialDelivery };
},
resetDeliveryEditor() {
return {
...initialState,
delivery: { ...initialDelivery },
};
},
},
});
export const {
setId,
setDeliveryEntities,
setDelivery,
setDate,
setDeliveryEntity,
setExternalNumber,
setNumber,
setType,
setLineitems,
setNotes,
addLineitem,
updateLineitem,
removeLineitem,
resetDelivery,
resetDeliveryEditor,
} = deliveryEditorSlice.actions;
// Selectors
export const selectDeliveryId = (state) => state.deliveryEditor.delivery.id;
export const selectDeliveryEntities = (state) => state.deliveryEditor.deliveryEntities;
export const selectDelivery = (state) => state.deliveryEditor.delivery;
export const selectDeliveryDate = (state) => state.deliveryEditor.delivery.date;
export const selectDeliveryEntity = (state) => state.deliveryEditor.delivery.deliveryEntity;
export const selectDeliveryExternalNumber = (state) => state.deliveryEditor.delivery.external_number;
export const selectDeliveryNumber = (state) => state.deliveryEditor.delivery.number;
export const selectDeliveryType = (state) => state.deliveryEditor.delivery.type;
export const selectDeliveryLineitems = (state) => state.deliveryEditor.delivery.lineitems;
export const selectDeliveryNotes = (state) => state.deliveryEditor.delivery.notes;
export default deliveryEditorSlice.reducer;

View File

@@ -5,7 +5,6 @@ import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice';
import posterminalReducer, { posterminalLoadListener } from './posterminalSlice';
import unitReducer, { unitLoadListener } from './unitSlice';
import deliveryEditorReducer from './deliveryEditorSlice';
const listenerMiddleware = createListenerMiddleware();
companyLoadListener(listenerMiddleware);
@@ -53,7 +52,6 @@ export const store = configureStore({
brand: brandReducer,
posterminal: posterminalReducer,
unit: unitReducer,
deliveryEditor: deliveryEditorReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()

7
src/utils/pdfText.js Normal file
View File

@@ -0,0 +1,7 @@
import { pdfToText } from 'pdftotext-wasm';
export async function extractPdfText(pdfSource, options = { layout: true }) {
return pdfToText(pdfSource, options);
}
export { pdfToText };

36
src/utils/pdfThumbnail.js Normal file
View File

@@ -0,0 +1,36 @@
import * as pdfjsLib from 'pdfjs-dist';
// Tells Vite/Webpack to emit the worker script as an asset file locally
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString();
export async function generatePdfThumbnail(pdfSource, targetWidth = 200) {
let loadingTask;
if (pdfSource instanceof File || pdfSource instanceof Blob) {
const arrayBuffer = await pdfSource.arrayBuffer();
loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
} else {
loadingTask = pdfjsLib.getDocument(pdfSource);
}
const pdf = await loadingTask.promise;
const page = await pdf.getPage(1);
const unscaledViewport = page.getViewport({ scale: 1 });
const scale = targetWidth / unscaledViewport.width;
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
await page.render({
canvasContext: context,
viewport: viewport,
}).promise;
return canvas.toDataURL('image/jpeg', 0.85);
}

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import { defineConfig, searchForWorkspaceRoot } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
@@ -90,6 +90,12 @@ const sharedServer = {
port: useHttps ? 4308 : 4209,
https: httpsOptions(),
allowedHosts: true,
fs: {
allow: [
searchForWorkspaceRoot(process.cwd()),
path.resolve(__dirname, '..'),
],
},
proxy: {
'/api': {
target: 'http://localhost:4208',