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

@@ -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,