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:
243
src/components/manifest/Attachment.jsx
Normal file
243
src/components/manifest/Attachment.jsx
Normal 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;
|
||||
41
src/components/manifest/AttachmentList.jsx
Normal file
41
src/components/manifest/AttachmentList.jsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
250
src/components/manifest/TextEditorDialog.jsx
Normal file
250
src/components/manifest/TextEditorDialog.jsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user