diff --git a/package-lock.json b/package-lock.json
index 1836879..f8ced51 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,8 @@
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@shopify/draggable": "^1.2.1",
+ "ag-grid-community": "^36.2.0",
+ "ag-grid-react": "^36.2.0",
"dashgui": "file:../dashgui",
"jsbarcode": "^3.12.3",
"notistack": "^3.0.2",
@@ -24,6 +26,7 @@
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.4",
+ "react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"
},
@@ -79,6 +82,34 @@
"react-dom": "^19.0.0"
}
},
+ "../react-virtual-aggridautomat": {
+ "version": "0.0.0",
+ "license": "0BSD",
+ "dependencies": {
+ "@types/prismjs": "^1.26.6",
+ "ag-grid-community": "^36.2.0",
+ "ag-grid-react": "^36.2.0",
+ "marked": "^18.0.14",
+ "prismjs": "^1.30.0",
+ "react": "^19.2.8",
+ "react-automat": "github:sebseb7/react-automat",
+ "react-dom": "^19.2.8"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@types/node": "^24.13.3",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.7",
+ "@vitejs/plugin-react": "^6.1.1",
+ "eslint": "^10.10.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.6",
+ "globals": "^17.12.0",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.69.0",
+ "vite": "^8.3.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -3848,6 +3879,42 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/ag-charts-types": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.2.0.tgz",
+ "integrity": "sha512-4pDV6/4zbzPN7t607Gr1o6DO9RhmPafQGr+hIpTJBaerbaMoC+WOO3v/wkxFbvCNrHKOexEKSfNTH7PaAR4UzA==",
+ "license": "MIT"
+ },
+ "node_modules/ag-grid-community": {
+ "version": "36.2.0",
+ "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.2.0.tgz",
+ "integrity": "sha512-tK0Toj7t0fV/OMLPnDc0xlYKAqDbnCT3iJodYSn2TJ541caU8jPdYwWhMdW+AP2+pUNwFiJVgc04eJNfvBPsWw==",
+ "license": "MIT",
+ "dependencies": {
+ "ag-charts-types": "14.2.0",
+ "ag-stack": "36.2.0"
+ }
+ },
+ "node_modules/ag-grid-react": {
+ "version": "36.2.0",
+ "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.2.0.tgz",
+ "integrity": "sha512-7dsaR4Ga8/bJAOov7XJqxsDc1JAjUU5Bmh9+s4U9+/TKIdSsaVo53SuZb2yEFSB1elv3eh6aZP8a1fqGZehXwA==",
+ "license": "MIT",
+ "dependencies": {
+ "ag-grid-community": "36.2.0",
+ "prop-types": "^15.8.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/ag-stack": {
+ "version": "36.2.0",
+ "resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.2.0.tgz",
+ "integrity": "sha512-f3XT2htFs9dUPcHni2z9ak69QNta5ODEpooHuuNvbjN98JkokpEqRQ9YLQHQJFdCcgi9Ybs/j3zkkvmNRamIPg==",
+ "license": "MIT"
+ },
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -7412,6 +7479,10 @@
"react-dom": ">=16.6.0"
}
},
+ "node_modules/react-virtual-aggridautomat": {
+ "resolved": "../react-virtual-aggridautomat",
+ "link": true
+ },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
diff --git a/package.json b/package.json
index e246e56..4286774 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,8 @@
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@shopify/draggable": "^1.2.1",
+ "ag-grid-community": "^36.2.0",
+ "ag-grid-react": "^36.2.0",
"dashgui": "file:../dashgui",
"jsbarcode": "^3.12.3",
"notistack": "^3.0.2",
@@ -31,6 +33,7 @@
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.4",
+ "react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"
},
diff --git a/src/api/index.js b/src/api/index.js
index 7b73a52..5531e57 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -215,6 +215,32 @@ export const article = {
return http(`/api/article/${encodeURIComponent(sku)}`);
},
+ /**
+ * GET /api/article/history
+ * History of all articles with paging/sorting/filtering
+ */
+ getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
+ const params = new URLSearchParams();
+ if (startRow != null) params.set('startRow', String(startRow));
+ if (endRow != null) params.set('endRow', String(endRow));
+ if (offset != null) params.set('offset', String(offset));
+ if (limit != null) params.set('limit', String(limit));
+ if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
+ if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
+ const qs = params.toString();
+ return http(`/api/article/history${qs ? `?${qs}` : ''}`);
+ },
+
+ /**
+ * GET /api/article/:sku/history
+ */
+ getBySkuHistory(sku, { limit } = {}) {
+ const params = new URLSearchParams();
+ if (limit != null) params.set('limit', String(limit));
+ const qs = params.toString();
+ return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
+ },
+
/** POST /api/article → 201 { article } */
create({ parentId, name, track_stock }) {
return http('/api/article', {
@@ -1797,6 +1823,24 @@ export const labeltemplate = {
// thumb is returned inline as a base64 string in list/get responses
};
+export const globalHistory = {
+ /**
+ * GET /api/history
+ * Global system/entity history with paging/sorting/filtering
+ */
+ list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
+ const params = new URLSearchParams();
+ if (startRow != null) params.set('startRow', String(startRow));
+ if (endRow != null) params.set('endRow', String(endRow));
+ if (offset != null) params.set('offset', String(offset));
+ if (limit != null) params.set('limit', String(limit));
+ if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
+ if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
+ const qs = params.toString();
+ return http(`/api/history${qs ? `?${qs}` : ''}`);
+ },
+};
+
export default {
auth,
article,
@@ -1822,4 +1866,5 @@ export default {
unit,
warehouse,
stock,
+ globalHistory,
};
diff --git a/src/appRouter.jsx b/src/appRouter.jsx
index dc7727b..71785dc 100644
--- a/src/appRouter.jsx
+++ b/src/appRouter.jsx
@@ -24,7 +24,11 @@ import KundeListePage from './routes/kunde';
import KundeNeuPage from './routes/kunde/neu';
import KundeGruppenPage from './routes/kunde/gruppen';
import KundeEditPage from './routes/kunde/_entityId';
-import InfosPage from './routes/infos';
+import {
+ ArticleHistoryPage,
+ GlobalHistoryPage,
+ StatisticsPage,
+} from './routes/infos';
const routes = [
{
@@ -42,7 +46,16 @@ const routes = [
{ path: ':deliveryId', element: },
],
},
- { path: 'infos', element: },
+ {
+ path: 'infos',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'articlehistory', element: },
+ { path: 'globalhistory', element: },
+ { path: 'statistics', element: },
+ ],
+ },
{
path: 'kategorien/*',
element: ,
diff --git a/src/automats/articleHistoryAutomat.js b/src/automats/articleHistoryAutomat.js
new file mode 100644
index 0000000..a51c956
--- /dev/null
+++ b/src/automats/articleHistoryAutomat.js
@@ -0,0 +1,34 @@
+import { createGridAutomat } from 'react-virtual-aggridautomat';
+import { article } from '../api';
+import authAutomat from './authAutomat';
+
+export const articleHistoryGridAutomat = createGridAutomat({
+ name: 'articleHistory',
+ blockSize: 50,
+ fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
+ try {
+ const res = await article.getHistory({
+ startRow,
+ endRow,
+ sortModel,
+ filterModel,
+ });
+ const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
+ const total = res?.total ?? res?.totalRows ?? res?.totalCount;
+ const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
+ return { rows, lastRow };
+ } catch (err) {
+ console.warn('Failed to fetch article history (backend route pending):', err);
+ return { rows: [], lastRow: 0 };
+ }
+ },
+});
+
+// Reload on company switch or auth change
+authAutomat.subscribe((auth) => {
+ if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
+ articleHistoryGridAutomat.actions.reload();
+ }
+});
+
+export default articleHistoryGridAutomat;
diff --git a/src/automats/globalHistoryAutomat.js b/src/automats/globalHistoryAutomat.js
new file mode 100644
index 0000000..d1b716c
--- /dev/null
+++ b/src/automats/globalHistoryAutomat.js
@@ -0,0 +1,34 @@
+import { createGridAutomat } from 'react-virtual-aggridautomat';
+import { globalHistory } from '../api';
+import authAutomat from './authAutomat';
+
+export const globalHistoryGridAutomat = createGridAutomat({
+ name: 'globalHistory',
+ blockSize: 50,
+ fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
+ try {
+ const res = await globalHistory.list({
+ startRow,
+ endRow,
+ sortModel,
+ filterModel,
+ });
+ const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
+ const total = res?.total ?? res?.totalRows ?? res?.totalCount;
+ const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
+ return { rows, lastRow };
+ } catch (err) {
+ console.warn('Failed to fetch global history (backend route pending):', err);
+ return { rows: [], lastRow: 0 };
+ }
+ },
+});
+
+// Reload on company switch or auth change
+authAutomat.subscribe((auth) => {
+ if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
+ globalHistoryGridAutomat.actions.reload();
+ }
+});
+
+export default globalHistoryGridAutomat;
diff --git a/src/automats/index.js b/src/automats/index.js
index 248ee70..e69455e 100644
--- a/src/automats/index.js
+++ b/src/automats/index.js
@@ -70,3 +70,6 @@ export {
invalidateAllCategoryViews,
} from './categoryAutomat';
+export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
+export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
+
diff --git a/src/components/article/ArticlePhoto.jsx b/src/components/article/ArticlePhoto.jsx
index 3f9be99..187a813 100644
--- a/src/components/article/ArticlePhoto.jsx
+++ b/src/components/article/ArticlePhoto.jsx
@@ -65,13 +65,13 @@ class ArticlePhoto extends Component {
fill || objectFit === 'cover'
? { position: 'absolute', inset: 0 }
: {
- position: 'absolute',
- maxWidth: '100%',
- maxHeight: '100%',
- aspectRatio,
- width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
- height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
- }
+ position: 'absolute',
+ maxWidth: '100%',
+ maxHeight: '100%',
+ aspectRatio,
+ width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
+ height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
+ }
}
>
@@ -87,21 +87,21 @@ class ArticlePhoto extends Component {
sx={
fill || objectFit === 'cover'
? {
- display: 'block',
- width: '100%',
- height: '100%',
- objectFit: 'cover',
- opacity: thumbLoaded ? 1 : 0,
- }
+ display: 'block',
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ opacity: thumbLoaded ? 1 : 0,
+ }
: {
- display: 'block',
- maxWidth: '100%',
- maxHeight: '100%',
- width: 'auto',
- height: 'auto',
- objectFit: 'contain',
- opacity: thumbLoaded ? 1 : 0,
- }
+ display: 'block',
+ maxWidth: '100%',
+ maxHeight: '100%',
+ width: 'auto',
+ height: 'auto',
+ objectFit: 'contain',
+ opacity: thumbLoaded ? 1 : 0,
+ }
}
/>
) : null}
diff --git a/src/components/manifest/ArticleSearchDialog.jsx b/src/components/manifest/ArticleSearchDialog.jsx
index e9f3628..f3b65a2 100644
--- a/src/components/manifest/ArticleSearchDialog.jsx
+++ b/src/components/manifest/ArticleSearchDialog.jsx
@@ -121,13 +121,13 @@ export default class ArticleSearchDialog extends PureComponent {
const trimmed = query.trim().toLowerCase();
const filtered = trimmed
? bookableArticles.filter((a) => {
- const skuMatch = a.sku?.toLowerCase().includes(trimmed);
- const nameMatch = a.name?.toLowerCase().includes(trimmed);
- const eanMatch = Array.isArray(a.eans)
- ? a.eans.some((e) => String(e).toLowerCase().includes(trimmed))
- : false;
- return skuMatch || nameMatch || eanMatch;
- })
+ const skuMatch = a.sku?.toLowerCase().includes(trimmed);
+ const nameMatch = a.name?.toLowerCase().includes(trimmed);
+ const eanMatch = Array.isArray(a.eans)
+ ? a.eans.some((e) => String(e).toLowerCase().includes(trimmed))
+ : false;
+ return skuMatch || nameMatch || eanMatch;
+ })
: bookableArticles.slice(0, 50);
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
@@ -270,8 +270,6 @@ export default class ArticleSearchDialog extends PureComponent {
) : (
diff --git a/src/components/manifest/ManifestFormLine.jsx b/src/components/manifest/ManifestFormLine.jsx
index fd120f6..61d41f4 100644
--- a/src/components/manifest/ManifestFormLine.jsx
+++ b/src/components/manifest/ManifestFormLine.jsx
@@ -67,7 +67,7 @@ class ManifestFormLine extends PureComponent {
const current = getArticle(sku);
this.setState({ cachedArticle: current });
if (!current) {
- artikelAutomat.actions.loadArticle(sku).catch(() => {});
+ artikelAutomat.actions.loadArticle(sku).catch(() => { });
}
this.unsubArticle = artikelAutomat.subscribe(this, (state) => ({
cachedArticle: createArticleSelector(sku)(state),
@@ -118,8 +118,8 @@ class ManifestFormLine extends PureComponent {
isBooked
? `Vollständig im Lager gebucht: ${ledgerLine.bookedAmount} Stück (Klicken für Details)`
: isPartial
- ? `Teilweise im Lager gebucht: ${ledgerLine.bookedAmount} von ${ledgerLine.targetAmount} Stück (Klicken zum Einbuchen dieser Position)`
- : `Noch nicht im Lager gebucht (${ledgerLine.targetAmount} Stück – Klicken zum Einbuchen dieser Position)`
+ ? `Teilweise im Lager gebucht: ${ledgerLine.bookedAmount} von ${ledgerLine.targetAmount} Stück (Klicken zum Einbuchen dieser Position)`
+ : `Noch nicht im Lager gebucht (${ledgerLine.targetAmount} Stück – Klicken zum Einbuchen dieser Position)`
}
>
{
- e.stopPropagation();
- onBookLine(ledgerLine);
- }
+ e.stopPropagation();
+ onBookLine(ledgerLine);
+ }
: undefined
}
icon={
@@ -144,8 +144,8 @@ class ManifestFormLine extends PureComponent {
isBooked
? `Lager: ${ledgerLine.bookedAmount} Stk. gebucht`
: isPartial
- ? `Lager: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
- : 'Lager: offen'
+ ? `Lager: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
+ : 'Lager: offen'
}
color={isBooked ? 'success' : isPartial ? 'warning' : 'default'}
variant="outlined"
@@ -157,12 +157,12 @@ class ManifestFormLine extends PureComponent {
bgcolor: isBooked ? 'success.50' : undefined,
'&:hover': isClickable
? {
- bgcolor: isBooked
- ? 'success.100'
- : isPartial
+ bgcolor: isBooked
+ ? 'success.100'
+ : isPartial
? 'warning.100'
: 'action.hover',
- }
+ }
: undefined,
}}
/>
@@ -239,10 +239,10 @@ class ManifestFormLine extends PureComponent {
MozAppearance: 'textfield',
},
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
- {
- WebkitAppearance: 'none',
- margin: 0,
- },
+ {
+ WebkitAppearance: 'none',
+ margin: 0,
+ },
}}
/>
);
@@ -282,9 +282,9 @@ class ManifestFormLine extends PureComponent {
const vkNetto = vkPrice?.net;
const vkBrutto = vkPrice
? priceBrutto(vkPrice) ??
- (vkPrice.net != null && vkPrice.taxrate != null
- ? vkPrice.net * (1 + vkPrice.taxrate / 100)
- : null)
+ (vkPrice.net != null && vkPrice.taxrate != null
+ ? vkPrice.net * (1 + vkPrice.taxrate / 100)
+ : null)
: null;
return (
@@ -335,8 +335,6 @@ class ManifestFormLine extends PureComponent {
) : (
diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx
index 846255b..04a03f1 100644
--- a/src/components/tiles/CategoryTile.jsx
+++ b/src/components/tiles/CategoryTile.jsx
@@ -66,8 +66,8 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount, onPlacementClick
color: isDeleteMode
? 'error.main'
: isTerminalMode || isPhotoMode
- ? 'primary.main'
- : 'common.white',
+ ? 'primary.main'
+ : 'common.white',
}}
>
{isTerminalMode ? (
@@ -114,10 +114,10 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
px: 1,
...(isActionTab
? {
- '&:hover': {
- bgcolor: settingsTab === -3 ? 'rgba(211, 47, 47, 0.12)' : 'grey.400',
- },
- }
+ '&:hover': {
+ bgcolor: settingsTab === -3 ? 'rgba(211, 47, 47, 0.12)' : 'grey.400',
+ },
+ }
: {}),
};
diff --git a/src/index.css b/src/index.css
index edb809c..213716a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,3 +1,6 @@
+@import 'ag-grid-community/styles/ag-grid.css';
+@import 'ag-grid-community/styles/ag-theme-alpine.css';
+
html,
body,
#root {
diff --git a/src/nav.js b/src/nav.js
index 7626d4d..c8ddd6b 100644
--- a/src/nav.js
+++ b/src/nav.js
@@ -17,6 +17,9 @@ import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import LabelIcon from '@mui/icons-material/Label';
import TuneIcon from '@mui/icons-material/Tune';
+import HistoryIcon from '@mui/icons-material/History';
+import PublicIcon from '@mui/icons-material/Public';
+import BarChartIcon from '@mui/icons-material/BarChart';
import { isKategorienPath } from './components/category/categoryRoute';
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
@@ -229,6 +232,29 @@ export const SUB_NAV = {
help: 'Kundengruppen für Staffelpreise und Rabatte verwalten',
},
],
+ '/infos': [
+ {
+ segment: '',
+ label: 'ArticleHistory',
+ title: 'ArticleHistory',
+ icon: HistoryIcon,
+ help: 'Historie aller Artikeländerungen und Ereignisse',
+ },
+ {
+ segment: 'globalhistory',
+ label: 'GlobalHistory',
+ title: 'GlobalHistory',
+ icon: PublicIcon,
+ help: 'Globale System- und Änderungshistorie',
+ },
+ {
+ segment: 'statistics',
+ label: 'Statistics',
+ title: 'Statistics',
+ icon: BarChartIcon,
+ help: 'Statistische Auswertungen und Kennzahlen',
+ },
+ ],
};
export function isNavItemActive(pathname, navPath) {
@@ -281,6 +307,11 @@ export function resolveActiveSub(pathname, sectionPath) {
return rest;
}
+ // Treat /infos/articlehistory as segment ''
+ if (sectionPath === '/infos' && rest === 'articlehistory') {
+ return '';
+ }
+
const active = subNav.find((item) => {
if (item.segment === 'kategorien') {
return isKategorienPath(pathname);
diff --git a/src/routes/infos.jsx b/src/routes/infos.jsx
deleted file mode 100644
index 490a315..0000000
--- a/src/routes/infos.jsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import Typography from '@mui/material/Typography';
-import PageLayout from '../components/PageLayout';
-
-export default function InfosPage() {
- return (
-
- Infos — Inhalt folgt.
-
- );
-}
diff --git a/src/routes/infos/ArticleHistoryPage.jsx b/src/routes/infos/ArticleHistoryPage.jsx
new file mode 100644
index 0000000..34e2ce3
--- /dev/null
+++ b/src/routes/infos/ArticleHistoryPage.jsx
@@ -0,0 +1,152 @@
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Typography from '@mui/material/Typography';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import CircularProgress from '@mui/material/CircularProgress';
+import { PanelTile } from 'dashgui/panels';
+import { AutomatGrid } from 'react-virtual-aggridautomat';
+import { articleHistoryGridAutomat } from '../../automats';
+import { router } from '../../appRouter';
+
+const COLUMN_DEFS = [
+ {
+ headerName: 'ID',
+ field: 'id',
+ maxWidth: 90,
+ sortable: true,
+ filter: 'agNumberColumnFilter',
+ cellStyle: { fontVariantNumeric: 'tabular-nums' },
+ },
+ {
+ headerName: 'Datum',
+ field: 'date',
+ minWidth: 170,
+ maxWidth: 200,
+ sortable: true,
+ valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''),
+ cellStyle: { fontVariantNumeric: 'tabular-nums' },
+ },
+ {
+ headerName: 'Artikel-Nr.',
+ field: 'sku',
+ minWidth: 130,
+ maxWidth: 160,
+ sortable: true,
+ filter: 'agTextColumnFilter',
+ cellRenderer: (p) => {
+ if (!p.value) return '';
+ return (
+ {
+ e.preventDefault();
+ void router.navigate(`/artikel/${encodeURIComponent(p.value)}`);
+ }}
+ style={{ color: '#2563eb', fontWeight: 600, textDecoration: 'none' }}
+ >
+ {p.value}
+
+ );
+ },
+ },
+ {
+ headerName: 'Artikelname',
+ field: 'name',
+ minWidth: 180,
+ flex: 1,
+ sortable: true,
+ filter: 'agTextColumnFilter',
+ },
+ {
+ headerName: 'Änderung / Ereignis',
+ field: 'text',
+ minWidth: 300,
+ flex: 2,
+ sortable: true,
+ filter: 'agTextColumnFilter',
+ cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' },
+ autoHeight: true,
+ },
+ {
+ headerName: 'Benutzer',
+ field: 'user',
+ maxWidth: 160,
+ sortable: true,
+ filter: 'agTextColumnFilter',
+ valueGetter: (p) =>
+ p.data?.user ||
+ p.data?.employee ||
+ p.data?.companyuser ||
+ (p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'),
+ },
+];
+
+export default class ArticleHistoryPage extends PureComponent {
+ constructor(props) {
+ super(props);
+ const s = articleHistoryGridAutomat.state;
+ this.state = {
+ loading: s.loading,
+ error: s.error,
+ totalRows: s.totalRows,
+ };
+ this.handleReload = this.handleReload.bind(this);
+ }
+
+ componentDidMount() {
+ this.unsub = articleHistoryGridAutomat.subscribe(this, (state) => ({
+ loading: state.loading,
+ error: state.error,
+ totalRows: state.totalRows,
+ }));
+ }
+
+ componentWillUnmount() {
+ this.unsub?.();
+ }
+
+ handleReload() {
+ articleHistoryGridAutomat.actions.reload();
+ }
+
+ render() {
+ const { loading, totalRows } = this.state;
+
+ const actions = (
+
+ {loading && }
+ {totalRows >= 0 && (
+
+ {totalRows} Einträge
+
+ )}
+ }
+ onClick={this.handleReload}
+ disabled={loading}
+ >
+ Aktualisieren
+
+
+ );
+
+ return (
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/routes/infos/GlobalHistoryPage.jsx b/src/routes/infos/GlobalHistoryPage.jsx
new file mode 100644
index 0000000..2798eea
--- /dev/null
+++ b/src/routes/infos/GlobalHistoryPage.jsx
@@ -0,0 +1,137 @@
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Typography from '@mui/material/Typography';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import CircularProgress from '@mui/material/CircularProgress';
+import { PanelTile } from 'dashgui/panels';
+import { AutomatGrid } from 'react-virtual-aggridautomat';
+import { globalHistoryGridAutomat } from '../../automats';
+
+const COLUMN_DEFS = [
+ {
+ headerName: 'ID',
+ field: 'id',
+ maxWidth: 90,
+ sortable: true,
+ filter: 'agNumberColumnFilter',
+ cellStyle: { fontVariantNumeric: 'tabular-nums' },
+ },
+ {
+ headerName: 'Datum',
+ field: 'date',
+ minWidth: 170,
+ maxWidth: 200,
+ sortable: true,
+ valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''),
+ cellStyle: { fontVariantNumeric: 'tabular-nums' },
+ },
+ {
+ headerName: 'Typ',
+ field: 'type',
+ minWidth: 120,
+ maxWidth: 160,
+ sortable: true,
+ filter: 'agTextColumnFilter',
+ },
+ {
+ headerName: 'Referenz / Key',
+ field: 'reference',
+ minWidth: 140,
+ maxWidth: 200,
+ sortable: true,
+ filter: 'agTextColumnFilter',
+ valueGetter: (p) => p.data?.reference || p.data?.p1 || '',
+ },
+ {
+ headerName: 'Meldung / Beschreibung',
+ field: 'text',
+ minWidth: 300,
+ flex: 2,
+ sortable: true,
+ filter: 'agTextColumnFilter',
+ valueGetter: (p) => p.data?.text || p.data?.value || '',
+ cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' },
+ autoHeight: true,
+ },
+ {
+ headerName: 'Benutzer',
+ field: 'user',
+ maxWidth: 160,
+ sortable: true,
+ filter: 'agTextColumnFilter',
+ valueGetter: (p) =>
+ p.data?.user ||
+ p.data?.employee ||
+ (p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'),
+ },
+];
+
+export default class GlobalHistoryPage extends PureComponent {
+ constructor(props) {
+ super(props);
+ const s = globalHistoryGridAutomat.state;
+ this.state = {
+ loading: s.loading,
+ error: s.error,
+ totalRows: s.totalRows,
+ };
+ this.handleReload = this.handleReload.bind(this);
+ }
+
+ componentDidMount() {
+ this.unsub = globalHistoryGridAutomat.subscribe(this, (state) => ({
+ loading: state.loading,
+ error: state.error,
+ totalRows: state.totalRows,
+ }));
+ }
+
+ componentWillUnmount() {
+ this.unsub?.();
+ }
+
+ handleReload() {
+ globalHistoryGridAutomat.actions.reload();
+ }
+
+ render() {
+ const { loading, totalRows } = this.state;
+
+ const actions = (
+
+ {loading && }
+ {totalRows >= 0 && (
+
+ {totalRows} Einträge
+
+ )}
+ }
+ onClick={this.handleReload}
+ disabled={loading}
+ >
+ Aktualisieren
+
+
+ );
+
+ return (
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/routes/infos/StatisticsPage.jsx b/src/routes/infos/StatisticsPage.jsx
new file mode 100644
index 0000000..474dd36
--- /dev/null
+++ b/src/routes/infos/StatisticsPage.jsx
@@ -0,0 +1,36 @@
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import BarChartIcon from '@mui/icons-material/BarChart';
+import { PanelTile } from 'dashgui/panels';
+
+export default class StatisticsPage extends PureComponent {
+ render() {
+ return (
+
+
+
+
+ Statistik & Auswertungen
+
+
+ Hier entstehen demnächst grafische Auswertungen, Umsatzstatistiken und
+ Bestandskennzahlen.
+
+
+
+ );
+ }
+}
diff --git a/src/routes/infos/index.js b/src/routes/infos/index.js
new file mode 100644
index 0000000..04a5fba
--- /dev/null
+++ b/src/routes/infos/index.js
@@ -0,0 +1,3 @@
+export { default as ArticleHistoryPage } from './ArticleHistoryPage';
+export { default as GlobalHistoryPage } from './GlobalHistoryPage';
+export { default as StatisticsPage } from './StatisticsPage';
diff --git a/vite.config.js b/vite.config.js
index 02dc563..20cc135 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -79,12 +79,14 @@ const sharedDeps = [
'@emotion/styled',
'@mui/material',
'@mui/icons-material',
+ 'react-automat',
];
const sharedResolve = {
dedupe: sharedDeps,
alias: {
'node:module': path.resolve(__dirname, 'src/utils/emptyNodeModule.js'),
+ 'react-automat': path.resolve(__dirname, '../automat'),
},
};