diff --git a/src/api/index.js b/src/api/index.js index 8e719e9..eb61375 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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, }; - diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js index dbf41c4..17ea138 100644 --- a/src/automats/categoryAutomat.js +++ b/src/automats/categoryAutomat.js @@ -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 }); diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 32903cd..1fc1560 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -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 void>>} + * @type {Map void, detach: (() => void)|null}>>} */ const handlers = new Map(); +let nextEventId = 0; +const seenEvents = new WeakSet(); /** * Handlers per watched SKU, so one `article-` 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); }; }, diff --git a/src/devtools/SseMonitor.jsx b/src/devtools/SseMonitor.jsx new file mode 100644 index 0000000..d7d0aea --- /dev/null +++ b/src/devtools/SseMonitor.jsx @@ -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 ( + + + + {new Date(entry.time).toLocaleTimeString()} + + + {entry.type} + + {entry.lastEventId ? ( + + #{entry.lastEventId} + + ) : null} + + {data ? ( + + {data} + + ) : null} + + ); + } + + render() { + const { open, position, status, companyId, events } = this.state; + const statusColor = + status === 'connected' ? 'success' : status === 'error' ? 'error' : status === 'connecting' ? 'warning' : 'default'; + + return ( + <> + {!open ? ( + + theme.zIndex.tooltip, + }} + > + + + + ) : ( + theme.zIndex.tooltip, + border: '1px solid', + borderColor: 'divider', + }} + > + + + + + SSE-Monitor + + + + + + + + + + + + + + + + {companyId ? `Company ${companyId}` : 'Keine aktive Company'} · {events.length} Ereignisse + + + + {events.length > 0 ? ( + [...events].reverse().map((entry) => this.renderEvent(entry)) + ) : ( + + Noch keine SSE-Ereignisse empfangen. + + )} + + + )} + + ); + } +} + +export default SseMonitor; diff --git a/src/main.jsx b/src/main.jsx index a8d4a94..01ca9b2 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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( ) : null} + {SseMonitor ? ( + + + + ) : null} diff --git a/src/routes/artikel.jsx b/src/routes/artikel.jsx index 730262d..26f6066 100644 --- a/src/routes/artikel.jsx +++ b/src/routes/artikel.jsx @@ -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 { ), + endAdornment: input ? ( + + e.preventDefault()} + title="Suche löschen" + aria-label="Suche löschen" + > + + + + ) : null, }, }} /> - {facetEntries.length > 0 && ( - - )} + {categories.map((catId) => { const label = getCategoryBreadcrumbLabel( catId, diff --git a/src/routes/lieferung/neu.jsx b/src/routes/lieferung/neu.jsx index 51d21f3..f5d7a41 100644 --- a/src/routes/lieferung/neu.jsx +++ b/src/routes/lieferung/neu.jsx @@ -390,7 +390,7 @@ class LieferungNeuPage extends PureComponent { // Create article in root category const articleRes = await articleApi.create({ - parentId: rootCategoryId, + categoryId: rootCategoryId, name: categoryName, }); diff --git a/src/utils/importArtikel.js b/src/utils/importArtikel.js index 2a18872..25f4c78 100644 --- a/src/utils/importArtikel.js +++ b/src/utils/importArtikel.js @@ -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');