This commit is contained in:
seb
2026-10-09 10:42:24 +02:00
parent 1933ac794d
commit 38339b13a2
8 changed files with 353 additions and 37 deletions

View File

@@ -244,11 +244,11 @@ export const article = {
},
/** POST /api/article → 201 { article } */
create({ parentId, name, track_stock }) {
create({ categoryId, name, track_stock }) {
return http('/api/article', {
method: 'POST',
body: {
parentId: Number(parentId),
categoryId: Number(categoryId),
name,
...(track_stock !== undefined ? { track_stock: Boolean(track_stock) } : {}),
},
@@ -2234,4 +2234,3 @@ export default {
globalHistory,
};

View File

@@ -208,7 +208,7 @@ const actions = {
* @param {{ parentId: number|string, name: string }} param0
*/
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const res = await articleApi.create({ categoryId: parentId, name });
const created = res.article;
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
return created;
@@ -223,7 +223,7 @@ const actions = {
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await articleApi.create({ parentId, name });
const res = await articleApi.create({ categoryId: parentId, name });
if (res.article) created.push(res.article);
}
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });

View File

@@ -37,12 +37,16 @@ import supplierAutomat from './supplierAutomat';
* @typedef {Object} SseState
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
* @property {string|null} companyId Company the stream is bound to.
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
*/
const EVENT_HISTORY_LIMIT = 200;
/** @type {SseState} */
const initialState = {
status: 'disconnected',
companyId: null,
events: [],
};
/** Event name prefix for per-article signals. */
@@ -54,9 +58,11 @@ let eventSource = null;
/**
* Handlers for statically named events (`category`, `articles`, …).
* @type {Map<string, Set<(event: MessageEvent) => void>>}
* @type {Map<string, Set<{handler: (event: MessageEvent) => void, detach: (() => void)|null}>>}
*/
const handlers = new Map();
let nextEventId = 0;
const seenEvents = new WeakSet();
/**
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
@@ -65,6 +71,31 @@ const handlers = new Map();
*/
const articleWatchers = new Map();
function recordEvent(event) {
if (seenEvents.has(event)) return;
seenEvents.add(event);
const entry = {
id: ++nextEventId,
type: event.type,
data: typeof event.data === 'string' ? event.data : '',
lastEventId: event.lastEventId ?? '',
time: Date.now(),
};
sseAutomat.setState((state) => ({
events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry],
}));
}
function addTrackedListener(es, type, handler) {
const listener = (event) => {
recordEvent(event);
handler(event);
};
es.addEventListener(type, listener);
return () => es.removeEventListener(type, listener);
}
function disconnect() {
if (!eventSource) return;
eventSource.close();
@@ -80,8 +111,8 @@ function disconnect() {
*/
function attachHandlers(es) {
for (const [event, set] of handlers) {
for (const handler of set) {
es.addEventListener(event, handler);
for (const entry of set) {
entry.detach = addTrackedListener(es, event, entry.handler);
}
}
for (const [sku] of articleWatchers) {
@@ -100,7 +131,8 @@ function attachHandlers(es) {
* @returns {void}
*/
function attachArticleListener(sku) {
eventSource?.addEventListener(`${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
if (!eventSource) return;
addTrackedListener(eventSource, `${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
const set = articleWatchers.get(sku);
if (!set || set.size === 0) return;
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
@@ -121,7 +153,7 @@ function connect() {
const es = new EventSource(url, { withCredentials: true });
// Category tree changed: refresh the tree and every open category view.
es.addEventListener('category', () => {
addTrackedListener(es, 'category', () => {
void categoryAutomat.actions.loadTree().catch((err) => {
console.error('[sse] category tree refresh failed', err);
});
@@ -129,44 +161,47 @@ function connect() {
});
// Article index changed: refresh the index and open category views.
es.addEventListener('articles', () => {
addTrackedListener(es, 'articles', () => {
invalidateAllCategoryViews();
void articleIndexAutomat.actions.reload().catch((err) => {
console.error('[sse] article index refresh failed', err);
});
});
es.addEventListener('warehouse', () => {
addTrackedListener(es, 'warehouse', () => {
void warehouseAutomat.actions.reload().catch((err) => {
console.error('[sse] warehouse refresh failed', err);
});
});
es.addEventListener('attributename', () => {
addTrackedListener(es, 'attributename', () => {
void attributeAutomat.actions.load().catch((err) => {
console.error('[sse] attributename refresh failed', err);
});
});
es.addEventListener('propertyname', () => {
addTrackedListener(es, 'propertyname', () => {
void propertyAutomat.actions.load().catch((err) => {
console.error('[sse] propertyname refresh failed', err);
});
});
es.addEventListener('supplier', () => {
addTrackedListener(es, 'supplier', () => {
void supplierAutomat.actions.reload().catch((err) => {
console.error('[sse] supplier refresh failed', err);
});
});
es.addEventListener('open', () => {
addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected' });
});
addTrackedListener(es, 'message', () => {});
// EventSource reconnects on its own; only surface the state.
es.onerror = () => {
console.error('[sse] event stream error');
recordEvent({ type: 'error', data: '', lastEventId: '' });
sseAutomat.setState({ status: 'error' });
};
@@ -186,6 +221,10 @@ const actions = {
connect();
},
clearEvents() {
sseAutomat.setState({ events: [] });
},
/**
* Registers a handler for a statically named backend event.
*
@@ -202,11 +241,15 @@ const actions = {
set = new Set();
handlers.set(event, set);
}
set.add(handler);
eventSource?.addEventListener(event, handler);
let entry = Array.from(set).find((candidate) => candidate.handler === handler);
if (!entry) {
entry = { handler, detach: null };
set.add(entry);
if (eventSource) entry.detach = addTrackedListener(eventSource, event, handler);
}
return () => {
eventSource?.removeEventListener(event, handler);
set.delete(handler);
entry.detach?.();
set.delete(entry);
if (set.size === 0) handlers.delete(event);
};
},

251
src/devtools/SseMonitor.jsx Normal file
View File

@@ -0,0 +1,251 @@
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Fab from '@mui/material/Fab';
import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import CloseIcon from '@mui/icons-material/Close';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SensorsIcon from '@mui/icons-material/Sensors';
import sseAutomat from '../automats/sseAutomat';
const PANEL_WIDTH = 420;
const PANEL_HEIGHT = 380;
function formatEventData(data) {
if (!data) return '';
try {
return JSON.stringify(JSON.parse(data), null, 2);
} catch {
return data;
}
}
class SseMonitor extends PureComponent {
constructor(props) {
super(props);
this.state = {
status: sseAutomat.state.status,
companyId: sseAutomat.state.companyId,
events: sseAutomat.state.events,
open: false,
position: null,
};
this.panelRef = createRef();
this.handleToggle = this.handleToggle.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handleClear = this.handleClear.bind(this);
this.handleClose = this.handleClose.bind(this);
}
componentDidMount() {
this.unsubscribe = sseAutomat.subscribe(this, (state) => ({
status: state.status,
companyId: state.companyId,
events: state.events,
}));
window.addEventListener('keydown', this.handleKeyDown);
}
componentWillUnmount() {
this.unsubscribe?.();
window.removeEventListener('keydown', this.handleKeyDown);
}
handleKeyDown(event) {
if (!event.altKey || !event.shiftKey || event.key?.toLowerCase() !== 's') return;
event.preventDefault();
this.handleToggle();
}
handleToggle() {
this.setState((state) => ({ open: !state.open }));
}
handleClose() {
this.setState({ open: false });
}
handleClear() {
sseAutomat.actions.clearEvents();
}
handlePointerDown(event) {
if (event.target.closest('button')) return;
const rect = this.panelRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
this.drag = {
pointerId: event.pointerId,
pointerX: event.clientX,
pointerY: event.clientY,
left: rect.left,
top: rect.top,
};
}
handlePointerMove(event) {
if (!this.drag || event.pointerId !== this.drag.pointerId) return;
const maxLeft = Math.max(0, window.innerWidth - PANEL_WIDTH);
const maxTop = Math.max(0, window.innerHeight - 120);
this.setState({
position: {
left: Math.max(0, Math.min(maxLeft, this.drag.left + event.clientX - this.drag.pointerX)),
top: Math.max(0, Math.min(maxTop, this.drag.top + event.clientY - this.drag.pointerY)),
},
});
}
handlePointerUp(event) {
if (this.drag?.pointerId === event.pointerId) this.drag = null;
}
renderEvent(entry) {
const data = formatEventData(entry.data);
return (
<Box
key={entry.id}
sx={{ px: 1.25, py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" spacing={1} alignItems="baseline">
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{new Date(entry.time).toLocaleTimeString()}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, overflowWrap: 'anywhere' }}>
{entry.type}
</Typography>
{entry.lastEventId ? (
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto !important' }}>
#{entry.lastEventId}
</Typography>
) : null}
</Stack>
{data ? (
<Typography
component="pre"
variant="caption"
sx={{
m: 0,
mt: 0.5,
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
color: 'text.secondary',
}}
>
{data}
</Typography>
) : null}
</Box>
);
}
render() {
const { open, position, status, companyId, events } = this.state;
const statusColor =
status === 'connected' ? 'success' : status === 'error' ? 'error' : status === 'connecting' ? 'warning' : 'default';
return (
<>
{!open ? (
<Tooltip title="SSE-Monitor (Alt+Shift+S)" placement="left">
<Fab
size="small"
color="primary"
aria-label="SSE-Monitor umschalten"
aria-pressed={open}
onClick={this.handleToggle}
sx={{
position: 'fixed',
right: 16,
bottom: 16,
zIndex: (theme) => theme.zIndex.tooltip,
}}
>
<SensorsIcon fontSize="small" />
</Fab>
</Tooltip>
) : (
<Paper
ref={this.panelRef}
component="aside"
aria-label="SSE-Monitor"
elevation={12}
sx={{
position: 'fixed',
...(position ? { left: position.left, top: position.top } : { right: 16, bottom: 16 }),
width: `min(${PANEL_WIDTH}px, calc(100vw - 24px))`,
height: `min(${PANEL_HEIGHT}px, calc(100vh - 24px))`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
zIndex: (theme) => theme.zIndex.tooltip,
border: '1px solid',
borderColor: 'divider',
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
onPointerDown={this.handlePointerDown}
onPointerMove={this.handlePointerMove}
onPointerUp={this.handlePointerUp}
onPointerCancel={this.handlePointerUp}
sx={{
px: 1,
py: 0.75,
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'grab',
touchAction: 'none',
userSelect: 'none',
'&:active': { cursor: 'grabbing' },
}}
>
<DragIndicatorIcon fontSize="small" color="action" />
<SensorsIcon fontSize="small" color="primary" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexGrow: 1 }}>
SSE-Monitor
</Typography>
<Chip size="small" color={statusColor} label={status} />
<Tooltip title="Ereignisse löschen">
<IconButton size="small" aria-label="Ereignisse löschen" onClick={this.handleClear}>
<DeleteSweepIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Schließen">
<IconButton size="small" aria-label="Schließen" onClick={this.handleClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ px: 1.25, py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary">
{companyId ? `Company ${companyId}` : 'Keine aktive Company'} · {events.length} Ereignisse
</Typography>
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
{events.length > 0 ? (
[...events].reverse().map((entry) => this.renderEvent(entry))
) : (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
Noch keine SSE-Ereignisse empfangen.
</Typography>
)}
</Box>
</Paper>
)}
</>
);
}
}
export default SseMonitor;

View File

@@ -24,6 +24,9 @@ installCssVars();
const AutomatInspector = import.meta.env.DEV
? lazy(() => import('./devtools/AutomatInspector'))
: null;
const SseMonitor = import.meta.env.DEV
? lazy(() => import('./devtools/SseMonitor'))
: null;
async function clearDevServiceWorker() {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
@@ -63,6 +66,11 @@ createRoot(document.getElementById('root')).render(
</Suspense>
) : null}
</Box>
{SseMonitor ? (
<Suspense fallback={null}>
<SseMonitor />
</Suspense>
) : null}
<PwaUpdateDialog />
</AuthBootstrap>
</SnackbarProvider>

View File

@@ -329,6 +329,10 @@ class ArtikelSearchPage extends Component {
}).catch(() => {});
};
handleClearInput = () => {
this.handleInputChange({ target: { value: '' } });
};
fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
@@ -422,9 +426,8 @@ class ArtikelSearchPage extends Component {
<TextField
sx={{ maxWidth: 520, flexGrow: 1 }}
autoFocus
type="search"
autoComplete="off"
placeholder="Artikel suchen (EAN, Artikelnr., Name, Barcode)…"
placeholder="(EAN, SKU, Name)"
value={input}
onChange={this.handleInputChange}
slotProps={{
@@ -439,21 +442,33 @@ class ArtikelSearchPage extends Component {
<SearchIcon color="action" />
</InputAdornment>
),
endAdornment: input ? (
<InputAdornment position="end">
<IconButton
size="small"
onClick={this.handleClearInput}
onMouseDown={(e) => e.preventDefault()}
title="Suche löschen"
aria-label="Suche löschen"
>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : null,
},
}}
/>
{facetEntries.length > 0 && (
<Button
variant={categoryFilterOpen ? 'contained' : 'outlined'}
color="inherit"
onClick={this.toggleCategoryFilter}
startIcon={<FilterListIcon />}
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Kategorien
</Button>
)}
<Button
variant={categoryFilterOpen ? 'contained' : 'outlined'}
color="inherit"
disabled={facetEntries.length === 0}
onClick={this.toggleCategoryFilter}
startIcon={<FilterListIcon />}
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Kategorien
</Button>
{categories.map((catId) => {
const label = getCategoryBreadcrumbLabel(
catId,

View File

@@ -390,7 +390,7 @@ class LieferungNeuPage extends PureComponent {
// Create article in root category
const articleRes = await articleApi.create({
parentId: rootCategoryId,
categoryId: rootCategoryId,
name: categoryName,
});

View File

@@ -6,7 +6,7 @@
* Everything is created through the existing dashback API:
* - categories: POST /api/category ({ parentId, name })
* - brands: PUT /api/brand ({ name }) → { brand: { id } }
* - articles: POST /api/article ({ parentId, name }) → { article: { sku } }
* - articles: POST /api/article ({ categoryId, name }) → { article: { sku } }
* - price: PUT /api/article/:sku/prices ({ prices: [{ price, tax }] })
* - brand: PUT /api/article/:sku/brand ({ brandId })
* - eans: PUT /api/article/:sku/eans ({ eans })
@@ -142,7 +142,7 @@ async function importRow(row, { ensureCategory, ensureBrand }) {
const brandId = row.brand ? await ensureBrand(row.brand) : null;
// Create the article under the leaf category.
const createRes = await article.create({ parentId: leafCategoryId, name });
const createRes = await article.create({ categoryId: leafCategoryId, name });
requireObject(createRes, 'POST /api/article');
const created = createRes.article;
requireDefined(created, 'article');