Compare commits

...

2 Commits

Author SHA1 Message Date
seb
bcd61fcf5e u 2026-10-15 00:45:05 +02:00
seb
6e0b72b34b u 2026-10-15 00:42:46 +02:00
7 changed files with 160 additions and 0 deletions

View File

@@ -28,4 +28,10 @@ export const shipment = {
return http(`/api/shipment/${id}`, { method: 'DELETE' });
},
// #endregion
// #region DELETE /api/shipment/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
return http(`/api/shipment/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
};

View File

@@ -1,3 +1,5 @@
import { isTouchDevice } from '../../utils/touch';
const FOCUSABLE =
'a, button, input, textarea, select, [role="button"], [role="menuitem"], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
@@ -9,6 +11,7 @@ const FOCUSABLE =
* focus afterwards, unless the user just selected some text.
*/
export function preserveSearchFocus(e) {
if (isTouchDevice()) return;
if (e.target.closest(FOCUSABLE)) return;
const selection = window.getSelection?.();
if (selection && !selection.isCollapsed) return;

View File

@@ -8,6 +8,7 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import SyncIcon from '@mui/icons-material/Sync';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
import SkeleHashImage from '../settings/SkeleHashImage';
import { generateTextThumbnail } from '../../utils/textThumbnail';
import { notifyErrorMessage } from '../../utils/notify';
import {
@@ -164,6 +165,11 @@ export class Attachment extends PureComponent {
display: 'block',
}}
/>
) : attachment?.skeledash ? (
<SkeleHashImage
hash={attachment.skeledash}
sx={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : isText ? (
<Box
sx={{

View File

@@ -9,12 +9,14 @@ import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError';
import SkeleHashImage from '../settings/SkeleHashImage';
import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat';
import { quote as quoteApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
@@ -99,6 +101,9 @@ class QuoteRow extends PureComponent {
<Box sx={{ width: 40, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Skeleton variant="rounded" width={28} height={20} />
</Box>
<Box sx={{ width: 76, flexShrink: 0, display: 'flex', alignItems: 'center', gap: '3px' }}>
<Skeleton variant="rounded" width={15} height={20} />
</Box>
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', justifyContent: 'flex-end' }}>
<Skeleton variant="text" width={60} height={20} />
</Box>
@@ -112,6 +117,7 @@ class QuoteRow extends PureComponent {
const gross = quote.grossTotal != null ? Number(quote.grossTotal) : quoteGrossTotal(quote.lines);
const customer = quote.customerName || this.partyName(quote.entityId);
const quantityInfo = quote.totalQuantity != null ? ` · ${quote.totalQuantity} Stück` : '';
const files = Array.isArray(quote.files) ? quote.files : [];
return (
<ListItemButton divider sx={rowSx} onClick={() => onOpen?.(quote)}>
@@ -153,6 +159,78 @@ class QuoteRow extends PureComponent {
}}
/>
{/* Files / Attachments column after Pos.: space for 4 per row, 2 lines max */}
<Box
sx={{
width: 76,
flexShrink: 0,
display: 'flex',
flexWrap: 'wrap',
alignContent: 'center',
gap: '3px',
maxHeight: 44,
overflow: 'hidden',
}}
>
{files.slice(0, 8).map((file, index) => {
const fileId = file.fileId ?? file.id;
const title = file.name ? `${file.name} (Öffnen)` : 'Datei öffnen';
return (
<Tooltip key={file.id ?? fileId ?? index} title={title}>
<Box
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
if (quote.id != null && fileId != null) {
window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank');
} else {
onOpen?.(quote);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
if (quote.id != null && fileId != null) {
window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank');
}
}
}}
sx={{
width: 15,
height: 20,
flexShrink: 0,
borderRadius: 0.5,
overflow: 'hidden',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease',
'&:hover': {
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
borderColor: 'primary.main',
transform: 'scale(1.3)',
zIndex: 3,
},
}}
>
{file.skeledash ? (
<SkeleHashImage hash={file.skeledash} />
) : (
<PictureAsPdfIcon sx={{ fontSize: 11, color: 'error.main' }} />
)}
</Box>
</Tooltip>
);
})}
</Box>
<Typography
variant="body2"
sx={{ width: 96, flexShrink: 0, textAlign: 'right', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
@@ -519,6 +597,7 @@ export class QuoteList extends PureComponent {
<Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box>
<Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box>
<Box sx={{ width: 76, flexShrink: 0, textAlign: 'center' }}>Dateien</Box>
<Box sx={{ width: 96, flexShrink: 0, textAlign: 'right' }}>Gesamt</Box>
<Box sx={{ width: 40, flexShrink: 0 }} />
</Box>

View File

@@ -8,6 +8,11 @@ import AuthBootstrap from './components/AuthBootstrap';
import App from './App';
import { getActiveTheme } from './theme';
import { installCssVars } from './colors';
import { disableTouchAutofocus } from './utils/touch';
// Globally suppress automatic focus on touch devices to avoid virtual keyboard popups
disableTouchAutofocus();
// Side-effect imports: each module registers its automat and subscribes to
// authAutomat, so the stores must be evaluated before the session bootstraps.
import './automats';

View File

@@ -15,6 +15,8 @@ import {
NAV_ITEM_ACTIVE_TEXT,
} from '../../colors';
import { isTouchDevice } from '../../utils/touch';
const nav = {
background: NAV_BACKGROUND,
backgroundColor: NAV_SURFACE,
@@ -33,6 +35,7 @@ const nav = {
export const defaultThemeId = 'default';
export function createDefaultTheme() {
const isTouch = isTouchDevice();
return createTheme({
palette: {
mode: 'light',
@@ -47,11 +50,13 @@ export function createDefaultTheme() {
MuiModal: {
defaultProps: {
disableScrollLock: true,
disableAutoFocus: isTouch,
},
},
MuiDialog: {
defaultProps: {
disableScrollLock: true,
disableAutoFocus: isTouch,
},
},
MuiMenu: {
@@ -91,6 +96,7 @@ export function createDefaultTheme() {
defaultProps: {
transitionDuration: 0,
disableScrollLock: true,
disableAutoFocus: isTouch,
},
},
},

55
src/utils/touch.js Normal file
View File

@@ -0,0 +1,55 @@
/**
* Detects if the primary pointing device or environment is a touch device
* (e.g. tablet, phone, POS touchscreen terminal).
* Returns false on desktop devices with mice/trackpads even if they have touchscreens.
*/
export function isTouchDevice() {
if (typeof window === 'undefined') return false;
if (window.matchMedia) {
if (window.matchMedia('(pointer: coarse)').matches) {
return true;
}
if (window.matchMedia('(pointer: fine)').matches) {
return false;
}
}
return 'ontouchstart' in window || (navigator.maxTouchPoints > 0);
}
/**
* Globally disables autofocus on touch devices to prevent virtual keyboard popups.
* Physical taps on inputs still focus and open the keyboard normally.
*/
export function disableTouchAutofocus() {
if (!isTouchDevice()) return;
// 1. Intercept programmatic focus called during mount/render on autofocus elements
const originalFocus = HTMLElement.prototype.focus;
HTMLElement.prototype.focus = function (options) {
if (this.hasAttribute?.('autofocus') || this.autofocus) {
this.removeAttribute?.('autofocus');
try {
this.autofocus = false;
} catch {
// ignore
}
return;
}
const stack = new Error().stack || '';
if (stack.includes('commitMount') || stack.includes('commitHostUpdate')) {
return;
}
return originalFocus.call(this, options);
};
// 2. Prevent browser from honoring native autofocus attribute on dynamically inserted elements
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (name, value) {
if (typeof name === 'string' && name.toLowerCase() === 'autofocus') {
return;
}
return originalSetAttribute.call(this, name, value);
};
}