diff --git a/src/automats/deliveryEditorAutomat.js b/src/automats/deliveryEditorAutomat.js
index fa2f6e4..10c7aa2 100644
--- a/src/automats/deliveryEditorAutomat.js
+++ b/src/automats/deliveryEditorAutomat.js
@@ -369,7 +369,7 @@ export const deliveryEditorAutomat = new Automat(initialState, {
updateAttachment(payload) {
const { id, ...fields } = payload || {};
const current = deliveryEditorAutomat.state.attachments ?? [];
- const idx = current.findIndex((a) => a.id === id);
+ const idx = current.findIndex((a) => a.id === id || String(a.id) === String(id));
if (idx !== -1) {
const next = [...current];
next[idx] = { ...next[idx], ...fields };
@@ -377,14 +377,20 @@ export const deliveryEditorAutomat = new Automat(initialState, {
}
},
- removeAttachment(idOrIndex) {
+ removeAttachment(idOrPayload) {
const current = deliveryEditorAutomat.state.attachments ?? [];
+ const targetId =
+ typeof idOrPayload === 'object' && idOrPayload !== null
+ ? (idOrPayload.id ?? idOrPayload.fileId)
+ : idOrPayload;
+ const hasMatch = current.some((a) => a.id === targetId || String(a.id) === String(targetId));
let next;
- if (typeof idOrIndex === 'number') {
- next = current.filter((_, idx) => idx !== idOrIndex);
+ if (hasMatch) {
+ next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
+ } else if (typeof idOrPayload === 'number' && idOrPayload >= 0 && idOrPayload < current.length) {
+ next = current.filter((_, idx) => idx !== idOrPayload);
} else {
- const id = typeof idOrIndex === 'string' ? idOrIndex : idOrIndex?.id;
- next = current.filter((a) => a.id !== id);
+ next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
}
deliveryEditorAutomat.setState({ attachments: next, files: next });
},
diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx
index 669b1d0..39a88f7 100644
--- a/src/components/AppShell.jsx
+++ b/src/components/AppShell.jsx
@@ -4,7 +4,8 @@ import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
-import IconButton from '@mui/material/IconButton';
+import ButtonBase from '@mui/material/ButtonBase';
+import Typography from '@mui/material/Typography';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
@@ -12,7 +13,7 @@ import { artikelQueryPath } from '../pages/artikel/artikelQuery';
import { defaultCategoryPath } from '../pages/artikel/categoryRoute';
const SIDEBAR_WIDTH = 144;
-const SUB_NAV_WIDTH = 80;
+const SUB_NAV_WIDTH = 88;
class AppSidebar extends PureComponent {
render() {
@@ -110,7 +111,7 @@ class AppSubNav extends PureComponent {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
- gap: 1,
+ gap: 1.5,
position: 'sticky',
top: 0,
alignSelf: 'flex-start',
@@ -121,7 +122,7 @@ class AppSubNav extends PureComponent {
const active = segment === activeSub;
return (
- ({
- width: 80,
- height: 80,
- borderRadius: '50%',
+ width: SUB_NAV_WIDTH,
+ py: 1,
+ px: 0.5,
+ borderRadius: 2,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 0.5,
color: active
? theme.palette.primary.main
: theme.palette.text.secondary,
- bgcolor: 'transparent',
+ bgcolor: active ? theme.palette.action.selected : 'transparent',
'&:hover': {
bgcolor: theme.palette.action.hover,
color: theme.palette.primary.main,
},
})}
>
-
-
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
);
})}
diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx
index 8bee2eb..06eaa13 100644
--- a/src/components/SectionLayout.jsx
+++ b/src/components/SectionLayout.jsx
@@ -68,10 +68,10 @@ export default function SectionLayout({ sectionPath }) {
isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc;
const angebotLabel =
- subNav?.find((item) => item.segment === '')?.label ?? 'Angebot';
+ subNav?.find((item) => item.segment === '')?.title ?? subNav?.find((item) => item.segment === '')?.label ?? 'Angebot';
let pageTitle = isDetail
? title
- : (activeSub?.label ?? title);
+ : (activeSub?.title ?? activeSub?.label ?? title);
if (pathname === '/auftrag/neu') pageTitle = 'Neues Angebot';
else if (isAngebotDoc) pageTitle = angebotLabel;
else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung';
diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx
index 29eda31..02d2f50 100644
--- a/src/components/manifest/ManifestForm.jsx
+++ b/src/components/manifest/ManifestForm.jsx
@@ -15,6 +15,7 @@ import ManifestLines from './ManifestLines';
import ManifestPdfDialog from './ManifestPdfDialog';
import {
buildManifestBody,
+ restoreAttachmentsFromApi,
dateInputValue,
emptyLine,
lineFromApi,
@@ -38,6 +39,7 @@ class ManifestForm extends PureComponent {
number: '',
externalNumber: '',
lines: [emptyLine()],
+ attachments: [],
saving: false,
pdfUploading: false,
pdfDialogOpen: false,
@@ -125,6 +127,10 @@ class ManifestForm extends PureComponent {
const lines =
apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
+ // Restore attachments if available
+ const apiFiles = doc.files ?? [];
+ const attachments = await restoreAttachmentsFromApi(apiFiles);
+
this.setState({
status: 'ready',
parties,
@@ -135,6 +141,7 @@ class ManifestForm extends PureComponent {
externalNumber:
doc.externalNumber != null ? String(doc.externalNumber) : '',
lines,
+ attachments,
});
} catch (err) {
if (this._cancelled) return;
@@ -294,13 +301,14 @@ class ManifestForm extends PureComponent {
const requireAtLeastOne = mode === 'create';
let body;
try {
- body = buildManifestBody(
+ body = await buildManifestBody(
{
number: this.state.number,
externalNumber: this.state.externalNumber,
date: this.state.date,
partyId: this.state.partyId,
lines: this.state.lines,
+ attachments: this.state.attachments,
},
{
partyField: cfg.partyField,
diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js
index 60723f6..221c1d6 100644
--- a/src/components/manifest/index.js
+++ b/src/components/manifest/index.js
@@ -14,5 +14,7 @@ export {
emptyLine,
lineFromApi,
buildManifestBody,
+ buildFilesForUpload,
+ restoreAttachmentsFromApi,
dateInputValue,
} from './manifestConfig';
diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js
index 33f321c..54ae567 100644
--- a/src/components/manifest/manifestConfig.js
+++ b/src/components/manifest/manifestConfig.js
@@ -130,10 +130,225 @@ export function buildLines(lines, { requireAtLeastOne }) {
}
/**
- * @param {{ number: string, externalNumber?: string, date: string, partyId: number|null, lines: object[] }} form
+ * Convert File objects to base64 for API upload
+ * @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments
+ * @param {{ onlyNew?: boolean }} [options]
+ * @returns {Promise>}
+ */
+export async function buildFilesForUpload(attachments = [], { onlyNew = false } = {}) {
+ const files = [];
+ for (const attachment of attachments) {
+ if (!attachment) continue;
+ // Skip already persisted attachments in update mode unless modified
+ if (onlyNew && attachment.isPersisted && !attachment.isDirty) {
+ continue;
+ }
+ if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) {
+ continue;
+ }
+
+ let fileToUpload = attachment.file;
+ if (!fileToUpload || fileToUpload.size === 0) {
+ if (attachment.text) {
+ fileToUpload = new File([attachment.text], attachment.name || 'file.txt', {
+ type: 'text/plain;charset=utf-8',
+ });
+ } else {
+ continue;
+ }
+ }
+
+ try {
+ const base64 = await fileToBase64(fileToUpload);
+ const filePayload = {
+ mimetype: attachment.type || fileToUpload.type,
+ name: attachment.name || fileToUpload.name,
+ content: base64,
+ };
+
+ if (attachment.thumbnail) {
+ const thumbBase64 = attachment.thumbnail.includes(',')
+ ? attachment.thumbnail.split(',')[1]
+ : attachment.thumbnail;
+ filePayload.thumbavif = thumbBase64;
+ }
+
+ const meta = {};
+ if (attachment.thumbnail) {
+ meta.thumbnail = attachment.thumbnail;
+ }
+ if (attachment.text) {
+ meta.text = attachment.text;
+ }
+ if (attachment.parentPdfId) {
+ meta.parentPdfId = String(attachment.parentPdfId);
+ }
+ if (Object.keys(meta).length > 0) {
+ filePayload.meta = meta;
+ }
+
+ files.push(filePayload);
+ } catch (err) {
+ console.error('Fehler beim Konvertieren von Datei zu Base64:', err);
+ }
+ }
+ return files;
+}
+
+/**
+ * Convert File to base64 string
+ * @param {File} file
+ * @returns {Promise}
+ */
+function fileToBase64(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result;
+ if (typeof result === 'string') {
+ // Remove data URL prefix (e.g., "data:application/pdf;base64,")
+ const base64 = result.split(',')[1] || result;
+ resolve(base64);
+ } else {
+ reject(new Error('Invalid file reader result'));
+ }
+ };
+ reader.onerror = () => reject(new Error('File reader error'));
+ reader.readAsDataURL(file);
+ });
+}
+
+/**
+ * Convert base64 string to Blob
+ * @param {string} base64
+ * @param {string} mimeType
+ * @returns {Blob}
+ */
+function base64ToBlob(base64, mimeType) {
+ const clean = base64.includes(',') ? base64.split(',')[1] : base64;
+ const byteCharacters = atob(clean);
+ const byteArrays = [];
+ const sliceSize = 512;
+
+ for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
+ const slice = byteCharacters.slice(offset, offset + sliceSize);
+ const byteNumbers = new Array(slice.length);
+ for (let i = 0; i < slice.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ byteArrays.push(byteArray);
+ }
+
+ return new Blob(byteArrays, { type: mimeType });
+}
+
+/**
+ * Restore attachments from API response
+ * @param {Array<{id: number, fileId?: number, manifestId?: number, mimetype: string, name: string, thumbavif?: any, size: number, created?: string, meta?: object}>} apiFiles
+ * @param {Array