This commit is contained in:
seb
2026-10-18 03:14:15 +02:00
parent 0a59a0987a
commit ab9d5fe413
13 changed files with 1018 additions and 271 deletions

View File

@@ -7,6 +7,30 @@ import { http } from './http.js';
*/
export const ledger = {
/**
* GET /api/ledger/history?offset=0&limit=50&search=...&seekMonths=-1
* Append-only ledger audit history with chunk paging, search, and seek filtering
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object, sku?: string, transactionType?: string }} [params]
* → { ledger: LedgerEntry[], history?: LedgerEntry[], total: number, unfilteredTotal?: number }
*/
// #region GET /api/ledger/history — getHistory
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel, sku, transactionType } = {}) {
const params = new URLSearchParams();
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
if (sku != null) params.set('sku', String(sku));
if (transactionType != null) params.set('transaction_type', String(transactionType));
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/ledger/history${qs ? `?${qs}` : ''}`);
},
// #endregion
/**
* GET /api/ledger/manifestarticle/:manifestarticleId
* Query: ?transaction_type=...

View File

@@ -34,6 +34,7 @@ import OAuthAuthorizePage from './routes/oauth/OAuthAuthorizePage';
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
const LedgerHistoryPage = lazy(() => import('./routes/infos/LedgerHistoryPage'));
function LoginRedirect() {
const params = new URLSearchParams(window.location.search);
@@ -72,6 +73,7 @@ const routes = [
{ index: true, element: <ArticleHistoryPage /> },
{ path: 'articlehistory', element: <ArticleHistoryPage /> },
{ path: 'globalhistory', element: <GlobalHistoryPage /> },
{ path: 'ledgerhistory', element: <LedgerHistoryPage /> },
{ path: 'statistics', element: <StatisticsPage /> },
],
},

View File

@@ -391,13 +391,15 @@ export function createChunkListAutomat({
? res.items
: Array.isArray(res?.history)
? res.history
: Array.isArray(res?.rows)
? res.rows
: Array.isArray(res?.entries)
? res.entries
: Array.isArray(res)
? res
: [];
: Array.isArray(res?.ledger)
? res.ledger
: Array.isArray(res?.rows)
? res.rows
: Array.isArray(res?.entries)
? res.entries
: Array.isArray(res)
? res
: [];
const total = Number(res?.total ?? res?.totalRows ?? res?.totalCount ?? records.length);
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
return { records, total, unfilteredTotal };

View File

@@ -191,6 +191,7 @@ export {
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
export { default as globalHistoryAutomat, globalHistoryGridAutomat } from './globalHistoryAutomat';
export { default as ledgerHistoryAutomat, ledgerHistoryGridAutomat } from './ledgerHistoryAutomat';
export { default as chatAutomat } from './chatAutomat';
export {

View File

@@ -0,0 +1,26 @@
/**
* @file ledgerHistoryAutomat.js
* @description Double-buffered chunk list automat (`ledgerHistoryAutomat`) for
* append-only ledger transactions and stock journal with multi-filter caching and LRU eviction.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
* `selectedCompanyId` changes.
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
*/
import { createChunkListAutomat } from './createChunkListAutomat';
import { ledger } from '../api';
export const ledgerHistoryAutomat = createChunkListAutomat({
name: 'ledgerHistory',
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => ledger.getHistory(params),
});
// Alias for consistency with other grid/chunk automats
export const ledgerHistoryGridAutomat = ledgerHistoryAutomat;
export default ledgerHistoryAutomat;

View File

@@ -102,7 +102,7 @@ export default class BookDeliveryDialog extends PureComponent {
const articles = res?.articles || res?.supplier?.articles || [];
const map = buildSupplierArticlesMap(articles);
this.setState({ supplierArticlesMap: map });
}).catch(() => {});
}).catch(() => { });
}
}
@@ -122,7 +122,7 @@ export default class BookDeliveryDialog extends PureComponent {
this.unsubArticle = artikelAutomat.subscribe(() => {
this.forceUpdate();
});
void warehouseAutomat.actions.load().catch(() => {});
void warehouseAutomat.actions.load().catch(() => { });
this.loadSupplierArticlesIfNeeded();
if (this.props.open) {
@@ -225,7 +225,7 @@ export default class BookDeliveryDialog extends PureComponent {
supplierArticlesMap: currentSupplierMap,
},
() => {
void warehouseAutomat.actions.load().catch(() => {});
void warehouseAutomat.actions.load().catch(() => { });
this.loadSupplierArticlesIfNeeded();
if (this.props.summary) {
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);
@@ -535,15 +535,15 @@ export default class BookDeliveryDialog extends PureComponent {
(line.sku ? String(line.sku).trim() : null);
if (targetSku && !cachedArticle) {
void artikelAutomat.actions.loadArticle(targetSku).catch(() => {});
void artikelAutomat.actions.loadArticle(targetSku).catch(() => { });
}
const isTracked =
Boolean(line.trackStock) ||
(cachedArticle?.track_stock !== undefined
? cachedArticle.track_stock !== false &&
cachedArticle.track_stock !== 0 &&
cachedArticle.track_stock !== '0'
cachedArticle.track_stock !== 0 &&
cachedArticle.track_stock !== '0'
: false);
const isEnablingThis = targetSku ? Boolean(enablingTrackStockBySku?.[targetSku]) : false;
@@ -565,8 +565,8 @@ export default class BookDeliveryDialog extends PureComponent {
const warehouseBreakdown =
Array.isArray(cachedArticle?.warehouses) && cachedArticle.warehouses.length > 0
? cachedArticle.warehouses
.map((w) => `${w.name || 'Lager'}: ${formatStock(w.stock ?? 0)}`)
.join(' • ')
.map((w) => `${w.name || 'Lager'}: ${formatStock(w.stock ?? 0)}`)
.join(' • ')
: null;
const eans = line.ean
@@ -791,11 +791,7 @@ export default class BookDeliveryDialog extends PureComponent {
</Alert>
)}
{isFullyBooked ? (
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}>
Alle Positionen dieser Lieferung sind bereits vollständig im Ledger verbucht.
</Alert>
) : isWarehouseMissing ? (
{isFullyBooked ? null : isWarehouseMissing ? (
<Alert severity="warning">
Bitte wählen Sie ein <strong>Ziellager</strong> aus, um die lagergeführten Artikel zu buchen.
</Alert>

View File

@@ -22,6 +22,7 @@ import LabelIcon from '@mui/icons-material/Label';
import HistoryIcon from '@mui/icons-material/History';
import PublicIcon from '@mui/icons-material/Public';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import BarChartIcon from '@mui/icons-material/BarChart';
import { isKategorienPath } from './components/category/categoryRoute';
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
@@ -273,6 +274,13 @@ export const SUB_NAV = {
icon: PublicIcon,
help: 'Globale System- und Änderungshistorie',
},
{
segment: 'ledgerhistory',
label: 'LedgerHistory',
title: 'LedgerHistory',
icon: ReceiptLongIcon,
help: 'Unveränderliches Buchungs- und Bestandsjournal (Audit-Ledger)',
},
{
segment: 'statistics',
label: 'Statistics',

View File

@@ -0,0 +1,629 @@
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton';
import RefreshIcon from '@mui/icons-material/Refresh';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import Chip from '@mui/material/Chip';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../../components/BackendError';
import ledgerHistoryAutomat from '../../automats/ledgerHistoryAutomat';
import { router } from '../../appRouter';
import { backendErrorFrom } from '../../utils/backendError';
import { LINK } from '../../colors';
const CHUNK_SIZE = 50;
function formatDateTime(val) {
if (!val) return '–';
try {
const d = new Date(val);
if (isNaN(d.getTime())) return String(val);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return String(val);
}
}
export function createDummyTestLedgerHistory() {
return {
id: 999999,
date: '2030-10-30T12:00:00Z',
created_at: '2030-10-30T12:00:00Z',
transaction_type: 'ACCEPTANCE',
sku: 'SKU-99999999',
amount: 9999,
transaction_text: 'Wareneingang Lieferschein LS-2030-999999 Musterbuchung',
user: 'Max Mustermann',
};
}
const rowGridSx = {
display: 'grid',
gridTemplateColumns:
'minmax(60px, max-content) minmax(150px, max-content) minmax(110px, max-content) minmax(130px, max-content) minmax(90px, max-content) minmax(220px, 2fr) minmax(120px, max-content)',
gridTemplateRows: '0px 1fr',
rowGap: 0,
columnGap: 2,
alignItems: 'center',
width: '100%',
boxSizing: 'border-box',
};
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const measuringCellSx = {
visibility: 'hidden',
height: 0,
boxSizing: 'border-box',
pointerEvents: 'none',
textTransform: 'none',
letterSpacing: 'normal',
};
const headerCellSx = {
color: 'text.secondary',
typography: 'caption',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
};
function renderInvisibleDummyRow(dummy) {
const d = dummy || createDummyTestLedgerHistory();
return (
<>
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.id}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{formatDateTime(d.created_at || d.date)}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.transaction_type}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.sku}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 5, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">+{d.amount}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 6, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.transaction_text}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 7, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.user}</Typography>
</Box>
</>
);
}
function getTransactionTypeLabel(typeStr) {
const norm = String(typeStr || '').toUpperCase();
if (norm === 'ACCEPTANCE') return 'Annahme';
if (norm === 'WAREHOUSE') return 'Lager';
if (norm === 'ADJUSTMENT' || norm === 'STOCK_ADJUST') return 'Korrektur';
if (norm === 'SALE' || norm === 'OUTBOUND') return 'Verkauf';
if (norm === 'RETURN') return 'Retoure';
if (norm === 'DELIVERY') return 'Lieferung';
if (norm === 'INVENTORY') return 'Inventur';
return typeStr || '–';
}
class LedgerHistoryRow extends PureComponent {
render() {
const { item, disabled, headerItem } = this.props;
const dummy = headerItem || createDummyTestLedgerHistory();
if (!item) {
return (
<ListItemButton
disabled
divider
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2 }}>
<Skeleton variant="text" width="80%" height={20} />
</Box>
<Box sx={{ gridColumn: 2, gridRow: 2 }}>
<Skeleton variant="text" width="90%" height={20} />
</Box>
<Box sx={{ gridColumn: 3, gridRow: 2 }}>
<Skeleton variant="rounded" width={70} height={22} />
</Box>
<Box sx={{ gridColumn: 4, gridRow: 2 }}>
<Skeleton variant="text" width="80%" height={20} />
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2 }}>
<Skeleton variant="text" width="70%" height={20} />
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2 }}>
<Skeleton variant="text" width="90%" height={20} />
</Box>
<Box sx={{ gridColumn: 7, gridRow: 2 }}>
<Skeleton variant="text" width="60%" height={20} />
</Box>
</ListItemButton>
);
}
const userName =
item.user ||
item.employee ||
item.created_by ||
(item.companyuserId ? `User #${item.companyuserId}` : '—');
const typeRaw = item.transaction_type || item.type || '–';
const typeLabel = getTransactionTypeLabel(typeRaw);
const sku = item.sku || null;
const hasSku = Boolean(sku && sku !== '–');
const amt = Number(item.amount);
const hasAmount = item.amount != null && !isNaN(amt);
const isPos = hasAmount && amt > 0;
const isNeg = hasAmount && amt < 0;
const amountStr = hasAmount ? (isPos ? `+${amt}` : `${amt}`) : (item.amount ?? '–');
const bookingText = item.transaction_text || item.text || item.reason || '–';
return (
<ListItemButton
divider
disabled={disabled}
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
onClick={
hasSku
? () => {
void router.navigate(`/artikel/${encodeURIComponent(sku)}`);
}
: undefined
}
>
{renderInvisibleDummyRow(dummy)}
{/* 1. ID */}
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0 }}>
<Typography
variant="body2"
color="text.secondary"
title={item.prev_hash ? `Hash-Version: ${item.hash_version ?? 1}\nVorheriger Hash: ${item.prev_hash}` : undefined}
sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}
>
{item.id}
</Typography>
</Box>
{/* 2. Datum */}
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
{formatDateTime(item.created_at || item.date || item.createdAt)}
</Typography>
</Box>
{/* 3. Typ */}
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0 }}>
<Chip
size="small"
label={typeLabel}
sx={{
height: 22,
fontSize: '0.75rem',
fontWeight: 500,
bgcolor: 'action.hover',
textTransform: 'capitalize',
}}
/>
</Box>
{/* 4. SKU */}
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
{hasSku ? (
<Typography
variant="body2"
component="a"
href={`/artikel/${encodeURIComponent(sku)}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void router.navigate(`/artikel/${encodeURIComponent(sku)}`);
}}
sx={{
color: LINK,
fontWeight: 600,
textDecoration: 'none',
...ellipsisSx,
'&:hover': { textDecoration: 'underline' },
}}
>
{sku}
</Typography>
) : (
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
–
</Typography>
)}
</Box>
{/* 5. Menge / Betrag */}
<Box sx={{ gridColumn: 5, gridRow: 2, minWidth: 0, textAlign: 'right', pr: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
color: isPos ? 'success.main' : isNeg ? 'error.main' : 'text.primary',
...ellipsisSx,
}}
>
{amountStr}
</Typography>
</Box>
{/* 6. Buchungstext */}
<Box sx={{ gridColumn: 6, gridRow: 2, minWidth: 0 }}>
<Typography variant="body2" title={bookingText} sx={ellipsisSx}>
{bookingText}
</Typography>
</Box>
{/* 7. Benutzer */}
<Box sx={{ gridColumn: 7, gridRow: 2, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{userName}
</Typography>
</Box>
</ListItemButton>
);
}
}
class LedgerHistorySearchField extends PureComponent {
constructor(props) {
super(props);
const s = ledgerHistoryAutomat.state;
const stats = ledgerHistoryAutomat.getChunkStats?.();
this.state = {
input: s.filter ?? '',
seekMonths: s.seekMonths ?? 0,
seekYears: s.seekYears ?? 0,
lruChunksCount: stats?.lruChunksCount || 0,
maxLruChunks: stats?.maxLruChunks || 50,
};
}
componentDidMount() {
this.unsub = ledgerHistoryAutomat.subscribe(this, (state) => ({
input: state.filter ?? '',
seekMonths: state.seekMonths ?? 0,
seekYears: state.seekYears ?? 0,
}));
if (import.meta.env.DEV) {
this.unsubStats = ledgerHistoryAutomat.subscribeChunkStats?.((stats) => {
this.setState({
lruChunksCount: stats.lruChunksCount,
maxLruChunks: stats.maxLruChunks,
});
});
}
}
componentWillUnmount() {
this.unsub?.();
this.unsubStats?.();
}
render() {
const { inputRef, onReload, loading } = this.props;
const { input, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
const yearActive = seekYears > 0;
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
return (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 1.25,
pb: 2,
}}
>
{/* Top row: search field */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={input}
placeholder="Artikel-Nr., Buchungstext, Typ oder Benutzer suchen…"
onChange={(next) => ledgerHistoryAutomat.actions.filter(next)}
/>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={onReload}
disabled={loading}
sx={{ height: 40, px: 2, whiteSpace: 'nowrap' }}
>
Aktualisieren
</Button>
</Box>
{/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Button
size="small"
variant={monthActive ? 'contained' : 'outlined'}
color={monthActive ? 'primary' : 'inherit'}
onClick={() => ledgerHistoryAutomat.actions.addSeekMonth()}
sx={{
minWidth: 88,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: monthActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</Button>
<Button
size="small"
variant={yearActive ? 'contained' : 'outlined'}
color={yearActive ? 'primary' : 'inherit'}
onClick={() => ledgerHistoryAutomat.actions.addSeekYear()}
sx={{
minWidth: 80,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: yearActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{yearLabel}
</Button>
{(monthActive || yearActive) && (
<Button
size="small"
variant="text"
color="inherit"
onClick={() => ledgerHistoryAutomat.actions.resetNegativeSeek()}
sx={{
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
color: 'text.secondary',
whiteSpace: 'nowrap',
}}
>
Zurücksetzen
</Button>
)}
</Box>
{/* DEV: Chunk-Cache Debug Monitor trigger */}
{import.meta.env.DEV && (
<Chip
size="small"
variant="outlined"
icon={<ViewModuleIcon sx={{ fontSize: '1rem !important' }} />}
label={
this.state.lruChunksCount > 0
? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})`
: 'Chunk-Cache'
}
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
)}
</Box>
</Box>
</PanelTileGrid>
);
}
}
// MARK: calls getHistory from ./api/ledger.js via ledgerHistoryAutomat
export default class LedgerHistoryPage extends PureComponent {
constructor(props) {
super(props);
this.headerItem = createDummyTestLedgerHistory();
this.state = {
items: ledgerHistoryAutomat.getItems(),
unfilteredTotal: ledgerHistoryAutomat.state.unfilteredTotal ?? 0,
filterKey: ledgerHistoryAutomat.getFilterKey?.() ?? '',
status: ledgerHistoryAutomat.state.status,
error: ledgerHistoryAutomat.state.error,
};
this.searchRef = createRef();
this.handleReload = this.handleReload.bind(this);
this.handleRetry = this.handleRetry.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
}
componentDidMount() {
this.unsub = ledgerHistoryAutomat.subscribe(this, (state) => ({
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: ledgerHistoryAutomat.getFilterKey(state),
status: state.status,
error: state.error,
}));
}
componentWillUnmount() {
this.unsub?.();
}
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const items = this.state.items;
const maxIndex = items ? items.length - 1 : startIndex;
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
ledgerHistoryAutomat.requireChunk(startChunk, endChunk);
}
handleReload() {
void ledgerHistoryAutomat.actions.reload().catch(() => {});
}
handleRetry() {
void ledgerHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
}
render() {
const { items, unfilteredTotal, filterKey, status, error } = this.state;
const loading = status === 'loading';
if (items === null && status === 'failed') {
return (
<>
<LedgerHistorySearchField
inputRef={this.searchRef}
onReload={this.handleReload}
loading={loading}
/>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(error)} onRetry={this.handleRetry} />
</PanelTile>
</PanelTileGrid>
</>
);
}
return (
<>
<LedgerHistorySearchField
inputRef={this.searchRef}
onReload={this.handleReload}
loading={loading}
/>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={LedgerHistoryRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>ID</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Typ</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Artikel-Nr.</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, ...headerCellSx, textAlign: 'right', pr: 1 }}>Menge</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Buchungstext</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, ...headerCellSx }}>Benutzer</Box>
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</>
);
}
}

View File

@@ -1,3 +1,4 @@
export { default as ArticleHistoryPage } from './ArticleHistoryPage';
export { default as GlobalHistoryPage } from './GlobalHistoryPage';
export { default as LedgerHistoryPage } from './LedgerHistoryPage';
export { default as StatisticsPage } from './StatisticsPage';