This commit is contained in:
seb
2026-09-21 20:21:57 +02:00
parent 2046f96768
commit 536e7b1e2a
20 changed files with 3047 additions and 672 deletions

View File

@@ -6,7 +6,7 @@ import {
buildCategoryPath,
defaultCategoryPath,
} from '../../pages/artikel/categoryRoute';
import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
import ArticleTileTab from './ArticleTileTab';
import ArticlePhotoTile from './ArticlePhotoTile';
@@ -25,18 +25,11 @@ class ArticleView extends PureComponent {
constructor(props, context) {
super(props, context);
const sku = props.sku;
const currentArticle = articleAutomat.state[sku] ?? null;
this.state = { article: currentArticle };
this.state = { article: getArticle(props.sku) };
this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
if (!currentArticle) {
articleAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
this.ensureArticle(props.sku);
}
componentDidMount() {
@@ -45,23 +38,24 @@ class ArticleView extends PureComponent {
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
const currentArticle = articleAutomat.state[this.props.sku] ?? null;
this.setState({ article: currentArticle });
if (!currentArticle) {
articleAutomat.actions.loadArticle(this.props.sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
this.setState({ article: getArticle(this.props.sku) });
this.ensureArticle(this.props.sku);
this.subscribeToArticle();
}
}
/** Loads through the shared cache; resolves instantly when already cached. */
ensureArticle(sku) {
if (!sku) return;
artikelAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
subscribeToArticle() {
if (this.unsub) this.unsub();
const selector = createArticleSelector(this.props.sku);
this.unsub = articleAutomat.subscribe(this, (state) => ({
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: selector(state),
}));
}

View File

@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import DeleteIcon from '@mui/icons-material/Delete';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { artikelAutomat } from '../../../automats';
import { notifyError } from '../../../utils/notify';
export default class ArticleSettingsTab extends Component {
@@ -24,6 +25,7 @@ export default class ArticleSettingsTab extends Component {
this.setState({ busy: true });
try {
await this.context.article.delete(articleData.sku);
artikelAutomat.actions.clearArticle(articleData.sku);
onArticleDeleted?.(articleData);
} catch (err) {
notifyError(err);

View File

@@ -33,10 +33,11 @@ class ManifestLines extends PureComponent {
onSearchArticle,
onRemoveSupplierArticle,
busySupplierArticleLineKeys = {},
wide = 3,
} = this.props;
return (
<PanelTile wide={3} minHeight={280} color={linesColor} title="Artikel">
<PanelTile wide={wide} minHeight={280} color={linesColor} title="Artikel">
<Stack spacing={1.5}>
<TableContainer>
<Table size="small" stickyHeader>

View File

@@ -0,0 +1,296 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import VerticalAlignTopIcon from '@mui/icons-material/VerticalAlignTop';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
/**
* Inline PDF viewer rendered as a panel tile, mirroring TextEditorPanel's
* "window" behaviour: it can sit above, right of, or below the article list,
* and stays sticky when docked to the right. Mount it only while a PDF
* attachment is selected; unmounting closes it.
*/
export class PdfViewerPanel extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
url: null,
loading: false,
error: null,
};
this.handleOpenExternal = this.handleOpenExternal.bind(this);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadUrl();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.revokeUrl();
this.loadUrl();
}
}
componentWillUnmount() {
this.revokeUrl();
}
revokeUrl() {
if (this._objectUrl) {
URL.revokeObjectURL(this._objectUrl);
this._objectUrl = null;
}
}
async loadUrl() {
const { attachment, docId } = this.props;
if (!attachment) return;
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
this._objectUrl = URL.createObjectURL(attachment.file);
this.setState({ url: this._objectUrl, loading: false, error: null });
return;
}
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true, error: null });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
this._objectUrl = URL.createObjectURL(blob);
this.setState({ url: this._objectUrl, loading: false, error: null });
return;
} catch (err) {
console.error('Fehler beim Laden der PDF-Datei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false, error: 'Fehler beim Laden der Datei' });
return;
}
}
this.setState({ url: null, loading: false, error: null });
}
handleOpenExternal() {
const { url } = this.state;
if (!url) return;
window.open(url, '_blank');
}
handleDownload() {
const { attachment } = this.props;
const { url } = this.state;
if (!url) return;
const link = document.createElement('a');
link.href = url;
link.download = attachment?.name || 'Dokument.pdf';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
render() {
const {
onClose,
attachment,
wide = 3,
position = 'top',
onPositionChange,
height = '60vh',
} = this.props;
if (!attachment) return null;
const { url, loading, error } = this.state;
const filename = attachment?.name || 'Dokument.pdf';
return (
<PanelTile
wide={wide}
minHeight={320}
color="#f8fafc"
sx={{ height, minHeight: 320 }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
mb: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<PictureAsPdfIcon color="error" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{filename}
</Typography>
<Typography variant="caption" color="text.secondary">
PDF-Vorschau
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Tooltip title="Über der Artikelliste">
<IconButton
size="small"
color={position === 'top' ? 'primary' : 'default'}
aria-label="Über der Artikelliste"
onClick={() => onPositionChange?.('top')}
>
<VerticalAlignTopIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Rechts neben der Artikelliste">
<IconButton
size="small"
color={position === 'right' ? 'primary' : 'default'}
aria-label="Rechts neben der Artikelliste"
onClick={() => onPositionChange?.('right')}
>
<ViewSidebarIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Unter der Artikelliste">
<IconButton
size="small"
color={position === 'below' ? 'primary' : 'default'}
aria-label="Unter der Artikelliste"
onClick={() => onPositionChange?.('below')}
>
<VerticalAlignBottomIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Box
sx={{
position: 'relative',
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: '#ffffff',
overflow: 'hidden',
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(248, 250, 252, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
{error ? (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
}}
>
<Typography color="text.secondary">{error}</Typography>
</Box>
) : (
url && (
<Box
component="iframe"
title={filename}
src={url}
sx={{ flex: 1, width: '100%', border: 'none' }}
/>
)
)}
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 1,
mt: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<OpenInNewIcon />}
onClick={this.handleOpenExternal}
disabled={loading || !url}
>
In neuem Tab
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !url}
>
Download
</Button>
</Box>
<Button size="small" onClick={onClose}>
Schließen
</Button>
</Box>
</Box>
</PanelTile>
);
}
}
export default PdfViewerPanel;

View File

@@ -1,340 +0,0 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
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 DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
export class TextEditorDialog extends PureComponent {
static contextType = StorageManagerContext;
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);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadContent();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment || (this.props.open && !prevProps.open)) {
if (this.props.open) {
this.loadContent();
} else {
this.setState({ content: '', initialContent: '', loading: false });
}
}
}
async loadContent() {
const { attachment, open, docId, onContentLoad } = 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) &&
attachment.file.size > 0
) {
this.setState({ loading: true });
try {
const text = await attachment.file.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
return;
} catch (err) {
console.error('Fehler beim Lesen der Textdatei:', err);
this.setState({ loading: false });
}
}
// Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId})
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
const text = await blob.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
onContentLoad?.(attachment, text, blob);
return;
} catch (err) {
console.error('Fehler beim Laden der Textdatei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false });
return;
}
}
this.setState({
content: '',
initialContent: '',
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' });
}
}
handleDownload() {
const { attachment } = this.props;
const { content } = this.state;
if (!content) return;
const filename = attachment?.name || 'Dokument.txt';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
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',
position: 'relative',
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(250, 250, 250, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
<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',
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !content}
>
Download
</Button>
</Box>
<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

@@ -0,0 +1,381 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
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 Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import VerticalAlignTopIcon from '@mui/icons-material/VerticalAlignTop';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
/**
* Inline text-file viewer/editor rendered as a full-width panel tile below the
* manifest header ("Kopfdaten"). Mount it only while a text attachment is
* selected; unmounting closes it.
*/
export class TextEditorPanel extends PureComponent {
static contextType = StorageManagerContext;
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);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadContent();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.loadContent();
}
}
async loadContent() {
const { attachment, docId, onContentLoad } = this.props;
if (!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) &&
attachment.file.size > 0
) {
this.setState({ loading: true });
try {
const text = await attachment.file.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
return;
} catch (err) {
console.error('Fehler beim Lesen der Textdatei:', err);
this.setState({ loading: false });
}
}
// Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId})
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
const text = await blob.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
onContentLoad?.(attachment, text, blob);
return;
} catch (err) {
console.error('Fehler beim Laden der Textdatei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false });
return;
}
}
this.setState({
content: '',
initialContent: '',
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' });
}
}
handleDownload() {
const { attachment } = this.props;
const { content } = this.state;
if (!content) return;
const filename = attachment?.name || 'Dokument.txt';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
render() {
const {
onClose,
attachment,
readOnly = false,
wide = 3,
position = 'top',
onPositionChange,
height = '60vh',
} = this.props;
if (!attachment) 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 (
<PanelTile
wide={wide}
minHeight={320}
color="#f8fafc"
sx={{ height, minHeight: 320 }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
mb: 1.5,
flexShrink: 0,
}}
>
<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>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Tooltip title="Über der Artikelliste">
<IconButton
size="small"
color={position === 'top' ? 'primary' : 'default'}
aria-label="Über der Artikelliste"
onClick={() => onPositionChange?.('top')}
>
<VerticalAlignTopIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Rechts neben der Artikelliste">
<IconButton
size="small"
color={position === 'right' ? 'primary' : 'default'}
aria-label="Rechts neben der Artikelliste"
onClick={() => onPositionChange?.('right')}
>
<ViewSidebarIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Unter der Artikelliste">
<IconButton
size="small"
color={position === 'below' ? 'primary' : 'default'}
aria-label="Unter der Artikelliste"
onClick={() => onPositionChange?.('below')}
>
<VerticalAlignBottomIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Box
sx={{
position: 'relative',
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(248, 250, 252, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
<TextField
multiline
fullWidth
value={content}
disabled={loading}
onChange={this.handleChange}
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,
},
}}
/>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 1,
mt: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !content}
>
Download
</Button>
</Box>
<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>
</Box>
</Box>
</PanelTile>
);
}
}
export default TextEditorPanel;

View File

@@ -5,7 +5,8 @@ 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 { default as TextEditorPanel } from './TextEditorPanel';
export { default as PdfViewerPanel } from './PdfViewerPanel';
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
export { withManifestId } from './withManifestId';
export {