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

@@ -369,7 +369,7 @@ export const deliveryEditorAutomat = new Automat(initialState, {
updateAttachment(payload) { updateAttachment(payload) {
const { id, ...fields } = payload || {}; const { id, ...fields } = payload || {};
const current = deliveryEditorAutomat.state.attachments ?? []; 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) { if (idx !== -1) {
const next = [...current]; const next = [...current];
next[idx] = { ...next[idx], ...fields }; 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 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; let next;
if (typeof idOrIndex === 'number') { if (hasMatch) {
next = current.filter((_, idx) => idx !== idOrIndex); 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 { } else {
const id = typeof idOrIndex === 'string' ? idOrIndex : idOrIndex?.id; next = current.filter((a) => a.id !== targetId && String(a.id) !== String(targetId));
next = current.filter((a) => a.id !== id);
} }
deliveryEditorAutomat.setState({ attachments: next, files: next }); deliveryEditorAutomat.setState({ attachments: next, files: next });
}, },

View File

@@ -4,7 +4,8 @@ import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; 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 { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; 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'; import { defaultCategoryPath } from '../pages/artikel/categoryRoute';
const SIDEBAR_WIDTH = 144; const SIDEBAR_WIDTH = 144;
const SUB_NAV_WIDTH = 80; const SUB_NAV_WIDTH = 88;
class AppSidebar extends PureComponent { class AppSidebar extends PureComponent {
render() { render() {
@@ -110,7 +111,7 @@ class AppSubNav extends PureComponent {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: 1, gap: 1.5,
position: 'sticky', position: 'sticky',
top: 0, top: 0,
alignSelf: 'flex-start', alignSelf: 'flex-start',
@@ -121,7 +122,7 @@ class AppSubNav extends PureComponent {
const active = segment === activeSub; const active = segment === activeSub;
return ( return (
<IconButton <ButtonBase
key={segment || 'index'} key={segment || 'index'}
type="button" type="button"
aria-label={label} aria-label={label}
@@ -140,21 +141,42 @@ class AppSubNav extends PureComponent {
navigate(segment ? `${activeNav}/${segment}` : activeNav); navigate(segment ? `${activeNav}/${segment}` : activeNav);
}} }}
sx={(theme) => ({ sx={(theme) => ({
width: 80, width: SUB_NAV_WIDTH,
height: 80, py: 1,
borderRadius: '50%', px: 0.5,
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
color: active color: active
? theme.palette.primary.main ? theme.palette.primary.main
: theme.palette.text.secondary, : theme.palette.text.secondary,
bgcolor: 'transparent', bgcolor: active ? theme.palette.action.selected : 'transparent',
'&:hover': { '&:hover': {
bgcolor: theme.palette.action.hover, bgcolor: theme.palette.action.hover,
color: theme.palette.primary.main, color: theme.palette.primary.main,
}, },
})} })}
> >
<Icon sx={{ fontSize: 40 }} /> <Icon sx={{ fontSize: 32 }} />
</IconButton> {label ? (
<Typography
variant="caption"
noWrap
sx={{
fontSize: '0.68rem',
fontWeight: 500,
lineHeight: 1.2,
textAlign: 'center',
maxWidth: '100%',
}}
>
{label}
</Typography>
) : null}
</ButtonBase>
); );
})} })}
</Box> </Box>

View File

@@ -68,10 +68,10 @@ export default function SectionLayout({ sectionPath }) {
isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc; isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc;
const angebotLabel = 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 let pageTitle = isDetail
? title ? title
: (activeSub?.label ?? title); : (activeSub?.title ?? activeSub?.label ?? title);
if (pathname === '/auftrag/neu') pageTitle = 'Neues Angebot'; if (pathname === '/auftrag/neu') pageTitle = 'Neues Angebot';
else if (isAngebotDoc) pageTitle = angebotLabel; else if (isAngebotDoc) pageTitle = angebotLabel;
else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung'; else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung';

View File

@@ -15,6 +15,7 @@ import ManifestLines from './ManifestLines';
import ManifestPdfDialog from './ManifestPdfDialog'; import ManifestPdfDialog from './ManifestPdfDialog';
import { import {
buildManifestBody, buildManifestBody,
restoreAttachmentsFromApi,
dateInputValue, dateInputValue,
emptyLine, emptyLine,
lineFromApi, lineFromApi,
@@ -38,6 +39,7 @@ class ManifestForm extends PureComponent {
number: '', number: '',
externalNumber: '', externalNumber: '',
lines: [emptyLine()], lines: [emptyLine()],
attachments: [],
saving: false, saving: false,
pdfUploading: false, pdfUploading: false,
pdfDialogOpen: false, pdfDialogOpen: false,
@@ -125,6 +127,10 @@ class ManifestForm extends PureComponent {
const lines = const lines =
apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()]; apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
// Restore attachments if available
const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles);
this.setState({ this.setState({
status: 'ready', status: 'ready',
parties, parties,
@@ -135,6 +141,7 @@ class ManifestForm extends PureComponent {
externalNumber: externalNumber:
doc.externalNumber != null ? String(doc.externalNumber) : '', doc.externalNumber != null ? String(doc.externalNumber) : '',
lines, lines,
attachments,
}); });
} catch (err) { } catch (err) {
if (this._cancelled) return; if (this._cancelled) return;
@@ -294,13 +301,14 @@ class ManifestForm extends PureComponent {
const requireAtLeastOne = mode === 'create'; const requireAtLeastOne = mode === 'create';
let body; let body;
try { try {
body = buildManifestBody( body = await buildManifestBody(
{ {
number: this.state.number, number: this.state.number,
externalNumber: this.state.externalNumber, externalNumber: this.state.externalNumber,
date: this.state.date, date: this.state.date,
partyId: this.state.partyId, partyId: this.state.partyId,
lines: this.state.lines, lines: this.state.lines,
attachments: this.state.attachments,
}, },
{ {
partyField: cfg.partyField, partyField: cfg.partyField,

View File

@@ -14,5 +14,7 @@ export {
emptyLine, emptyLine,
lineFromApi, lineFromApi,
buildManifestBody, buildManifestBody,
buildFilesForUpload,
restoreAttachmentsFromApi,
dateInputValue, dateInputValue,
} from './manifestConfig'; } 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 * @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean, externalNumber?: boolean }} opts
*/ */
export function buildManifestBody( export async function buildManifestBody(
form, form,
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber }, { partyField, requireAtLeastOne, externalNumber: useExternalNumber },
) { ) {
@@ -161,6 +376,16 @@ export function buildManifestBody(
} }
if (form.partyId != null) body[partyField] = form.partyId; if (form.partyId != null) body[partyField] = form.partyId;
else if (!requireAtLeastOne) body[partyField] = null; 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; return body;
} }

View File

@@ -24,13 +24,13 @@ export const NAV_ITEMS = [
export const SUB_NAV = { export const SUB_NAV = {
'/artikel': [ '/artikel': [
{ segment: '', icon: ViewListIcon }, { segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon },
{ segment: 'kategorien', label: 'Kategorien', icon: CategoryIcon }, { segment: 'kategorien', label: 'Kategorieen', title: 'Kategorien', icon: CategoryIcon },
{ segment: 'lager', label: 'Lager', icon: WarehouseIcon }, { segment: 'lager', label: 'Lager', icon: WarehouseIcon },
], ],
'/lieferung': [ '/lieferung': [
{ segment: '', label: 'Lieferungen', icon: ListAltIcon }, { segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon },
{ segment: 'neu', label: 'Neue Lieferung', icon: AddBoxIcon }, { segment: 'neu', label: 'Neu', title: 'Neue Lieferung', icon: AddBoxIcon },
{ segment: 'lieferanten', label: 'Lieferanten', icon: StorefrontIcon }, { segment: 'lieferanten', label: 'Lieferanten', icon: StorefrontIcon },
], ],
'/auftrag': [ '/auftrag': [
@@ -38,9 +38,9 @@ export const SUB_NAV = {
{ segment: 'bestellung', label: 'Bestellung', icon: ShoppingCartIcon }, { segment: 'bestellung', label: 'Bestellung', icon: ShoppingCartIcon },
], ],
'/kunde': [ '/kunde': [
{ segment: '', label: 'Kundenliste', icon: PeopleIcon }, { segment: '', label: 'Liste', title: 'Kundenliste', icon: PeopleIcon },
{ segment: 'neu', label: 'Neuer Kunde', icon: AddBoxIcon }, { segment: 'neu', label: 'neu', title: 'Neuer Kunde', icon: AddBoxIcon },
{ segment: 'gruppen', label: 'Kundengruppen', icon: GroupsIcon }, { segment: 'gruppen', label: 'Gruppen', icon: GroupsIcon },
], ],
}; };

View File

@@ -13,6 +13,7 @@ import {
ManifestLines, ManifestLines,
DELIVERY_LINE_COLUMNS, DELIVERY_LINE_COLUMNS,
buildManifestBody, buildManifestBody,
restoreAttachmentsFromApi,
dateInputValue, dateInputValue,
emptyLine, emptyLine,
lineFromApi, lineFromApi,
@@ -160,6 +161,11 @@ class LieferungNeuPage extends PureComponent {
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber)); if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber));
deliveryEditorAutomat.actions.setLineitems(lines); 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({ this.setState({
status: 'ready', status: 'ready',
partiesLoading: false, partiesLoading: false,
@@ -573,10 +579,12 @@ class LieferungNeuPage extends PureComponent {
textEditorAttachment: attachment, textEditorAttachment: attachment,
}); });
} else if (attachment.file instanceof Blob || attachment.file instanceof File) { } else if (attachment.file instanceof Blob || attachment.file instanceof File) {
if (attachment.file.size > 0) {
const url = URL.createObjectURL(attachment.file); const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank'); window.open(url, '_blank');
} }
} }
}
handleCloseTextEditor() { handleCloseTextEditor() {
this.setState({ this.setState({
@@ -614,13 +622,14 @@ class LieferungNeuPage extends PureComponent {
const isCreate = docId == null; const isCreate = docId == null;
let body; let body;
try { try {
body = buildManifestBody( body = await buildManifestBody(
{ {
number: this.state.number ?? '', number: this.state.number ?? '',
externalNumber: this.state.externalNumber ?? '', externalNumber: this.state.externalNumber ?? '',
date: this.state.date ?? '', date: this.state.date ?? '',
partyId: this.state.partyId, partyId: this.state.partyId,
lines: this.state.lines, lines: this.state.lines,
attachments: this.state.attachments,
}, },
{ {
partyField: cfg.partyField, partyField: cfg.partyField,
@@ -663,6 +672,11 @@ class LieferungNeuPage extends PureComponent {
if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber)); if (doc.externalNumber != null) deliveryEditorAutomat.actions.setExternalNumber(String(doc.externalNumber));
deliveryEditorAutomat.actions.setLineitems(lines); 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' }); enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
this.setState({ saving: false }); this.setState({ saving: false });
} }