This commit is contained in:
seb
2026-10-14 23:24:45 +02:00
parent eb4654dfbf
commit e2a2d02cb4
8 changed files with 170 additions and 71 deletions

View File

@@ -84,6 +84,19 @@ export const delivery = {
},
// #endregion
/**
* DELETE /api/delivery/:id/file/:fileId
* Deletes delivery file attachment.
* @param {number|string} id Delivery ID
* @param {number|string} fileId File ID
* → 204 void
*/
// #region DELETE /api/delivery/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/delivery/parse — multipart/form-data, field "file"
* Accepts PDF, TXT or CSV document (max 50 MB)

View File

@@ -62,4 +62,17 @@ export const order = {
return http(`/api/order/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* DELETE /api/order/:id/file/:fileId
* Deletes order file attachment.
* @param {number|string} id Order ID
* @param {number|string} fileId File ID
* → 204 void
*/
// #region DELETE /api/order/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
return http(`/api/order/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
};

View File

@@ -363,7 +363,7 @@ export function getManifestDirtyDetails(current, baseline) {
const count = dirtyLineKeys.length;
summary.push(count > 0 ? `${count} Artikelzeile${count === 1 ? '' : 'n'}` : 'Artikelzeilen');
}
if (fields.attachments) summary.push('Dateien');
if (fields.attachments && current.kind !== 'quote') summary.push('Dateien');
const isDirty = summary.length > 0;
@@ -455,6 +455,16 @@ export function createManifestEditAutomat({ kind, name }) {
});
},
setAttachments(attachments) {
const list = Array.isArray(attachments) ? attachments : [];
const base = automat.state.baseline;
const nextBaseline = base ? { ...base, attachments: [...list] } : null;
automat.setState({
attachments: list,
baseline: nextBaseline,
});
},
restoreBaseline() {
if (!automat.state.baseline) return;
const base = automat.state.baseline;

View File

@@ -66,19 +66,37 @@ class AppSidebar extends PureComponent {
<Box
component="nav"
sx={(theme) => ({
width: SIDEBAR_WIDTH,
width: { xs: '100%', lg: SIDEBAR_WIDTH },
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'stretch' },
backgroundColor: theme.nav.backgroundColor || NAV_SURFACE,
background: theme.nav.background,
position: 'sticky',
top: 0,
zIndex: theme.zIndex.appBar || 1100,
alignSelf: 'flex-start',
height: 'var(--df-vh, 100vh)',
height: { xs: 52, sm: 56, lg: 'var(--df-vh, 100vh)' },
px: { xs: 1, sm: 1.5, lg: 0 },
boxSizing: 'border-box',
boxShadow: { xs: '0 2px 8px rgba(0,0,0,0.18)', lg: 'none' },
})}
>
<List disablePadding sx={{ flexGrow: 1 }}>
<List
disablePadding
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: 'center',
height: { xs: '100%', lg: 'auto' },
overflowX: { xs: 'auto', lg: 'visible' },
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
gap: { xs: 0.5, sm: 1, lg: 0 },
}}
>
{NAV_ITEMS.map(({ path, label, help, icon: Icon }) => {
const active = path === activeNav;
@@ -110,12 +128,21 @@ class AppSidebar extends PureComponent {
}
}}
sx={(theme) => ({
borderRadius: 0,
px: 1.5,
py: 1.25,
borderLeft: '3px solid transparent',
borderRadius: { xs: 1, lg: 0 },
width: { xs: 'auto', lg: '100%' },
height: { xs: '100%', lg: 'auto' },
px: { xs: 1.25, sm: 1.5 },
py: { xs: 0.75, sm: 1, lg: 1.25 },
borderLeft: { xs: 'none', lg: '3px solid transparent' },
borderBottom: { xs: '3px solid transparent', lg: 'none' },
color: theme.nav.item,
'& .MuiListItemIcon-root': { color: 'inherit' },
whiteSpace: 'nowrap',
flexShrink: 0,
'& .MuiListItemIcon-root': {
color: 'inherit',
minWidth: { xs: 'auto', lg: 36 },
mr: { xs: 0.75, lg: 0 },
},
'&:hover': {
bgcolor: theme.nav.itemHover,
color: theme.nav.itemHoverText,
@@ -124,7 +151,8 @@ class AppSidebar extends PureComponent {
bgcolor: theme.nav.itemActive,
color: theme.nav.itemActiveText,
fontWeight: 600,
borderLeftColor: theme.nav.itemActiveAccent,
borderLeftColor: { xs: 'transparent', lg: theme.nav.itemActiveAccent },
borderBottomColor: { xs: theme.nav.itemActiveAccent, lg: 'transparent' },
'&:hover': {
bgcolor: theme.nav.itemActive,
color: theme.nav.itemActiveText,
@@ -132,18 +160,28 @@ class AppSidebar extends PureComponent {
},
})}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<ListItemIcon>
<HelpBadge title={label} help={help} placement="right">
<Icon />
</HelpBadge>
</ListItemIcon>
<ListItemText primary={label} />
<ListItemText
primary={label}
primaryTypographyProps={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
fontWeight: active ? 600 : 500,
noWrap: true,
}}
sx={{ m: 0 }}
/>
</ListItemButton>
);
})}
</List>
<UserMenu />
<Box sx={{ ml: { xs: 'auto', lg: 0 }, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
<UserMenu />
</Box>
</Box>
);
}
@@ -187,10 +225,11 @@ class AppSubNav extends PureComponent {
return (
<Box
sx={{
width: SUB_NAV_WIDTH,
width: { xs: 0, lg: SUB_NAV_WIDTH },
display: { xs: 'none', lg: 'block' },
flexShrink: 0,
ml: 3,
mr: 2,
ml: { xs: 0, lg: 3 },
mr: { xs: 0, lg: 2 },
}}
/>
);
@@ -201,19 +240,23 @@ class AppSubNav extends PureComponent {
component="nav"
aria-label="Unternavigation"
sx={{
width: SUB_NAV_WIDTH,
width: { xs: 60, sm: 72, md: SUB_NAV_WIDTH },
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
position: 'sticky',
top: 0,
top: { xs: 52, sm: 56, lg: 0 },
alignSelf: 'flex-start',
height: 'var(--df-vh, 100vh)',
py: 4,
ml: 3,
mr: 2,
height: {
xs: 'calc(var(--df-vh, 100vh) - 52px)',
sm: 'calc(var(--df-vh, 100vh) - 56px)',
lg: 'var(--df-vh, 100vh)',
},
py: { xs: 2, lg: 4 },
ml: { xs: 1, sm: 2, lg: 3 },
mr: { xs: 1, sm: 1.5, lg: 2 },
}}
>
{subNav.map(({ segment, label, title, help, icon: Icon }) => {
@@ -247,7 +290,7 @@ class AppSubNav extends PureComponent {
navigate(segment ? `${activeNav}/${segment}` : activeNav);
}}
sx={(theme) => ({
width: SUB_NAV_WIDTH,
width: { xs: 60, sm: 72, md: SUB_NAV_WIDTH },
py: 1,
px: 0.5,
borderRadius: 2,
@@ -271,14 +314,14 @@ class AppSubNav extends PureComponent {
})}
>
<HelpBadge title={title || label} help={help} placement="right">
<Icon sx={{ fontSize: 32 }} />
<Icon sx={{ fontSize: { xs: 24, sm: 28, md: 32 } }} />
</HelpBadge>
{label ? (
<Typography
variant="caption"
noWrap
sx={{
fontSize: '0.68rem',
fontSize: { xs: '0.62rem', sm: '0.68rem' },
fontWeight: 500,
lineHeight: 1.2,
textAlign: 'center',
@@ -303,26 +346,43 @@ export default function AppShell() {
const activeSub = activeNav ? resolveActiveSub(pathname, activeNav) : null;
return (
<Box sx={{ display: 'flex', minHeight: 'var(--df-vh, 100vh)', bgcolor: 'background.default' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', lg: 'row' },
minHeight: 'var(--df-vh, 100vh)',
bgcolor: 'background.default',
}}
>
<AppSidebar activeNav={activeNav} navigate={navigate} />
<AppSubNav
activeNav={activeNav}
activeSub={activeSub}
navigate={navigate}
/>
<Box
component="main"
sx={(theme) => ({
flexGrow: 1,
sx={{
display: 'flex',
flexDirection: 'column',
py: 4,
pr: 3,
bgcolor: theme.nav.mainBackground,
flexDirection: 'row',
flexGrow: 1,
minWidth: 0,
})}
}}
>
<Outlet />
<AppSubNav
activeNav={activeNav}
activeSub={activeSub}
navigate={navigate}
/>
<Box
component="main"
sx={(theme) => ({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
py: { xs: 2, md: 3, lg: 4 },
pr: { xs: 1.5, sm: 2, lg: 3 },
pl: { xs: 1.5, sm: 2, lg: 0 },
bgcolor: theme.nav.mainBackground,
minWidth: 0,
})}
>
<Outlet />
</Box>
</Box>
<ChatFab />
<ChatDrawer />

View File

@@ -110,9 +110,21 @@ class UserMenu extends PureComponent {
return `data:${mime};base64,${pic}`;
})();
const isTop = Boolean(
anchorEl && typeof anchorEl.getBoundingClientRect === 'function' && anchorEl.getBoundingClientRect().top < 200
);
return (
<>
<Box sx={{ px: 1.5, py: 1.5, alignSelf: 'flex-start' }}>
<Box
sx={{
px: { xs: 0.5, sm: 1, lg: 1.5 },
py: { xs: 0.5, lg: 1.5 },
alignSelf: { xs: 'center', lg: 'flex-start' },
display: 'flex',
alignItems: 'center',
}}
>
<IconButton
onClick={this.handleOpenMenu}
onContextMenu={this.handleContextMenu}
@@ -138,8 +150,14 @@ class UserMenu extends PureComponent {
anchorEl={anchorEl}
open={open}
onClose={this.handleCloseMenu}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
anchorOrigin={{
vertical: isTop ? 'bottom' : 'top',
horizontal: isTop ? 'right' : 'left',
}}
transformOrigin={{
vertical: isTop ? 'top' : 'bottom',
horizontal: isTop ? 'right' : 'left',
}}
>
{pwaUpdateAvailable && (
<MenuItem onClick={this.handleOpenPwaUpdate} sx={{ bgcolor: 'action.hover' }}>

View File

@@ -73,7 +73,7 @@ export default class ChatFab extends PureComponent {
sx={(theme) => ({
position: 'fixed',
bottom: { xs: 20, sm: 28 },
left: { xs: 20, sm: 164 },
left: { xs: 20, lg: 164 },
zIndex: theme.zIndex.speedDial || 1050,
})}
>

View File

@@ -220,15 +220,13 @@ class ManifestForm extends PureComponent {
prevState.date !== this.state.date ||
prevState.partyId !== this.state.partyId ||
prevState.externalNumber !== this.state.externalNumber ||
prevState.lines !== this.state.lines ||
prevState.attachments !== this.state.attachments
prevState.lines !== this.state.lines
) {
this.editAutomat.actions.update({
date: this.state.date,
partyId: this.state.partyId,
externalNumber: this.state.externalNumber,
lines: this.state.lines,
attachments: this.state.attachments,
});
const { isDirty, dirtyFields, dirtyLineKeys, dirtyLineFields, dirtySummary } = this.editAutomat.state;
if (
@@ -809,18 +807,13 @@ class ManifestForm extends PureComponent {
this.setState({ pdfDialogOpen: false, pdfUploading: false });
}
async handleCreatePdf() {
async handleCreatePdf(e) {
e?.preventDefault?.();
e?.stopPropagation?.();
const { docId } = this.props;
if (this.state.pdfCreating || this.state.saving) return;
if (docId == null) return;
if (this.state.isDirty) {
await this.handleSave();
if (this.state.isDirty) {
return;
}
}
this.setState({ pdfCreating: true });
try {
const res = await quoteApi.createPdf(docId);
@@ -834,20 +827,11 @@ class ManifestForm extends PureComponent {
);
this.setState({ attachments });
if (this.editAutomat) {
this.editAutomat.actions.updateDraft({ attachments });
this.editAutomat.actions.setAttachments?.(attachments);
}
}
enqueueSnackbar('Angebot-PDF erfolgreich erstellt.', { variant: 'success' });
const fileUrl =
res.fileUrl ||
(res.attachment?.fileId
? `/api/quote/${docId}/file/${res.attachment.fileId}`
: null);
if (fileUrl) {
window.open(fileUrl, '_blank');
}
} catch (err) {
if (!this._cancelled) {
notifyError(err);
@@ -861,23 +845,23 @@ class ManifestForm extends PureComponent {
async handleAttachmentDelete(attachment) {
if (!attachment) return;
const targetId = attachment.id ?? attachment.fileId;
const fileId = attachment.fileId ?? attachment.id;
const { docId } = this.props;
const nextAttachments = (this.state.attachments || []).filter(
(a) => (a.id ?? a.fileId) !== targetId && a !== attachment,
(a) => (a.fileId ?? a.id) !== fileId && a !== attachment,
);
this.setState({ attachments: nextAttachments });
if (this.editAutomat) {
this.editAutomat.actions.updateDraft({ attachments: nextAttachments });
this.editAutomat.actions.setAttachments?.(nextAttachments);
}
if (docId != null && targetId != null && attachment.isPersisted) {
if (docId != null && fileId != null && attachment.isPersisted) {
try {
const api = apiFor(this.cfg);
if (typeof api.deleteFile === 'function') {
await api.deleteFile(docId, targetId);
await api.deleteFile(docId, fileId);
enqueueSnackbar('Anhang gelöscht.', { variant: 'success' });
}
} catch (err) {

View File

@@ -525,6 +525,7 @@ class ManifestHeader extends PureComponent {
)}
{kind === 'quote' && onPdfCreate && docId != null && Boolean(number) && (
<Button
type="button"
variant="outlined"
startIcon={pdfCreating ? <CircularProgress size={16} color="inherit" /> : <PictureAsPdfIcon />}
disabled={saving || pdfCreating}