This commit is contained in:
seb
2026-09-18 06:44:12 +02:00
parent d5ade4923d
commit a537c2f72d
8 changed files with 308 additions and 31 deletions

View File

@@ -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 (
<IconButton
<ButtonBase
key={segment || 'index'}
type="button"
aria-label={label}
@@ -140,21 +141,42 @@ class AppSubNav extends PureComponent {
navigate(segment ? `${activeNav}/${segment}` : activeNav);
}}
sx={(theme) => ({
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,
},
})}
>
<Icon sx={{ fontSize: 40 }} />
</IconButton>
<Icon sx={{ fontSize: 32 }} />
{label ? (
<Typography
variant="caption"
noWrap
sx={{
fontSize: '0.68rem',
fontWeight: 500,
lineHeight: 1.2,
textAlign: 'center',
maxWidth: '100%',
}}
>
{label}
</Typography>
) : null}
</ButtonBase>
);
})}
</Box>

View File

@@ -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';

View File

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

View File

@@ -14,5 +14,7 @@ export {
emptyLine,
lineFromApi,
buildManifestBody,
buildFilesForUpload,
restoreAttachmentsFromApi,
dateInputValue,
} from './manifestConfig';

View File

@@ -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<Array<{mimetype: string, name: string, content: string, thumbavif?: string, meta?: object}>>}
*/
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<string>}
*/
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<object>} [existingAttachments]
* @returns {Promise<Array<{id: number, fileId: number, manifestId: number, file: File, name: string, type: string, thumbnail: string|null, size: number, text: string|null, parentPdfId: string|null, isPersisted: boolean}>>}
*/
export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachments = []) {
const existingMap = new Map();
for (const existing of existingAttachments) {
if (!existing) continue;
if (existing.id != null) existingMap.set(String(existing.id), existing);
if (existing.fileId != null) existingMap.set(String(existing.fileId), existing);
if (existing.name) existingMap.set(existing.name, existing);
}
const attachments = [];
for (const apiFile of apiFiles) {
try {
let file = null;
let thumbnail = null;
// 1. Try to restore thumbnail from apiFile.thumbavif
if (apiFile.thumbavif) {
try {
if (typeof apiFile.thumbavif === 'string') {
if (apiFile.thumbavif.startsWith('data:')) {
thumbnail = apiFile.thumbavif;
} else {
const thumbBlob = base64ToBlob(apiFile.thumbavif, 'image/avif');
thumbnail = URL.createObjectURL(thumbBlob);
}
} else if (typeof apiFile.thumbavif === 'object') {
const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif;
if (Array.isArray(rawBytes) || rawBytes instanceof Uint8Array) {
const thumbBlob = new Blob([new Uint8Array(rawBytes)], { type: 'image/avif' });
thumbnail = URL.createObjectURL(thumbBlob);
}
}
} catch (thumbErr) {
console.error('Fehler beim Dekodieren von thumbavif:', thumbErr);
}
}
// 2. Check meta.thumbnail if thumbavif wasn't available
if (!thumbnail && apiFile.meta?.thumbnail) {
try {
const metaThumb = apiFile.meta.thumbnail;
if (typeof metaThumb === 'string') {
if (metaThumb.startsWith('data:') || metaThumb.startsWith('blob:')) {
thumbnail = metaThumb;
} else {
const thumbBlob = base64ToBlob(metaThumb, 'image/avif');
thumbnail = URL.createObjectURL(thumbBlob);
}
}
} catch (metaThumbErr) {
console.error('Fehler beim Dekodieren von meta.thumbnail:', metaThumbErr);
}
}
// 3. Fallback to existing matching attachment thumbnail
const matchingExisting =
existingMap.get(String(apiFile.id)) ||
existingMap.get(String(apiFile.fileId)) ||
existingMap.get(apiFile.name);
if (!thumbnail && matchingExisting?.thumbnail) {
thumbnail = matchingExisting.thumbnail;
}
const placeholderName = apiFile.name || 'file';
const placeholderType = apiFile.mimetype || 'application/octet-stream';
file =
matchingExisting?.file instanceof File && matchingExisting.file.size > 0
? matchingExisting.file
: new File([''], placeholderName, { type: placeholderType });
const text = apiFile.meta?.text || matchingExisting?.text || null;
const parentPdfId = apiFile.meta?.parentPdfId || matchingExisting?.parentPdfId || null;
attachments.push({
id: apiFile.id,
fileId: apiFile.fileId ?? apiFile.id,
manifestId: apiFile.manifestId,
isPersisted: true,
file,
name: apiFile.name,
type: apiFile.mimetype,
thumbnail,
size: apiFile.size,
text,
parentPdfId,
uploadedAt: apiFile.created,
});
} catch (err) {
console.error('Fehler beim Wiederherstellen von Datei:', err);
}
}
return attachments;
}
/**
* @param {{ number: string, externalNumber?: string, date: string, partyId: number|null, lines: object[], attachments?: object[] }} form
* @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean, externalNumber?: boolean }} opts
*/
export function buildManifestBody(
export async function buildManifestBody(
form,
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber },
) {
@@ -161,6 +376,16 @@ export function buildManifestBody(
}
if (form.partyId != null) body[partyField] = form.partyId;
else if (!requireAtLeastOne) body[partyField] = null;
// Add files if attachments exist
if (form.attachments && form.attachments.length > 0) {
const isUpdate = !requireAtLeastOne;
const filesToUpload = await buildFilesForUpload(form.attachments, { onlyNew: isUpdate });
if (filesToUpload.length > 0) {
body.files = filesToUpload;
}
}
return body;
}