From a537c2f72df5d8569837d7a8a07476a0321a01fb Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 18 Sep 2026 06:44:12 +0200 Subject: [PATCH] subnav --- src/automats/deliveryEditorAutomat.js | 18 +- src/components/AppShell.jsx | 42 +++- src/components/SectionLayout.jsx | 4 +- src/components/manifest/ManifestForm.jsx | 10 +- src/components/manifest/index.js | 2 + src/components/manifest/manifestConfig.js | 229 +++++++++++++++++++++- src/nav.js | 14 +- src/pages/lieferung/neu.jsx | 20 +- 8 files changed, 308 insertions(+), 31 deletions(-) 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} [existingAttachments] + * @returns {Promise>} + */ +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; } diff --git a/src/nav.js b/src/nav.js index 7684b99..0cd9bef 100644 --- a/src/nav.js +++ b/src/nav.js @@ -24,13 +24,13 @@ export const NAV_ITEMS = [ export const SUB_NAV = { '/artikel': [ - { segment: '', icon: ViewListIcon }, - { segment: 'kategorien', label: 'Kategorien', icon: CategoryIcon }, + { segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon }, + { segment: 'kategorien', label: 'Kategorieen', title: 'Kategorien', icon: CategoryIcon }, { segment: 'lager', label: 'Lager', icon: WarehouseIcon }, ], '/lieferung': [ - { segment: '', label: 'Lieferungen', icon: ListAltIcon }, - { segment: 'neu', label: 'Neue Lieferung', icon: AddBoxIcon }, + { segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon }, + { segment: 'neu', label: 'Neu', title: 'Neue Lieferung', icon: AddBoxIcon }, { segment: 'lieferanten', label: 'Lieferanten', icon: StorefrontIcon }, ], '/auftrag': [ @@ -38,9 +38,9 @@ export const SUB_NAV = { { segment: 'bestellung', label: 'Bestellung', icon: ShoppingCartIcon }, ], '/kunde': [ - { segment: '', label: 'Kundenliste', icon: PeopleIcon }, - { segment: 'neu', label: 'Neuer Kunde', icon: AddBoxIcon }, - { segment: 'gruppen', label: 'Kundengruppen', icon: GroupsIcon }, + { segment: '', label: 'Liste', title: 'Kundenliste', icon: PeopleIcon }, + { segment: 'neu', label: 'neu', title: 'Neuer Kunde', icon: AddBoxIcon }, + { segment: 'gruppen', label: 'Gruppen', icon: GroupsIcon }, ], }; diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx index c897a5c..2b62237 100644 --- a/src/pages/lieferung/neu.jsx +++ b/src/pages/lieferung/neu.jsx @@ -13,6 +13,7 @@ import { ManifestLines, DELIVERY_LINE_COLUMNS, buildManifestBody, + restoreAttachmentsFromApi, dateInputValue, emptyLine, lineFromApi, @@ -160,6 +161,11 @@ class LieferungNeuPage extends PureComponent { if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber)); deliveryEditorAutomat.actions.setLineitems(lines); + // Restore attachments with thumbnails + const apiFiles = doc.files ?? []; + const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments); + deliveryEditorAutomat.actions.setAttachments(attachments); + this.setState({ status: 'ready', partiesLoading: false, @@ -573,8 +579,10 @@ class LieferungNeuPage extends PureComponent { textEditorAttachment: attachment, }); } else if (attachment.file instanceof Blob || attachment.file instanceof File) { - const url = URL.createObjectURL(attachment.file); - window.open(url, '_blank'); + if (attachment.file.size > 0) { + const url = URL.createObjectURL(attachment.file); + window.open(url, '_blank'); + } } } @@ -614,13 +622,14 @@ class LieferungNeuPage extends PureComponent { const isCreate = docId == null; 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, @@ -663,6 +672,11 @@ class LieferungNeuPage extends PureComponent { if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber)); deliveryEditorAutomat.actions.setLineitems(lines); + // Restore attachments with thumbnails + const apiFiles = doc.files ?? []; + const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments); + deliveryEditorAutomat.actions.setAttachments(attachments); + enqueueSnackbar(cfg.savedMsg, { variant: 'success' }); this.setState({ saving: false }); }