Files
dashfront/src/routes/infos/GlobalHistoryPage.jsx
2026-10-20 21:28:36 +02:00

541 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 Tooltip from '@mui/material/Tooltip';
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 globalHistoryAutomat from '../../automats/globalHistoryAutomat';
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 createDummyTestGlobalHistory() {
return {
id: 999999,
date: '2030-10-30T12:00:00Z',
type: 'Bestellung',
reference: 'BEST-999999',
text: 'Status geändert auf abgeschlossen durch Benutzer',
user: 'Max Mustermann',
};
}
const rowGridSx = {
display: 'grid',
gridTemplateColumns:
'minmax(150px, max-content) minmax(110px, max-content) minmax(140px, max-content) minmax(240px, 1fr)',
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 || createDummyTestGlobalHistory();
return (
<>
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{formatDateTime(d.date)}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.type}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.reference}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.text}</Typography>
</Box>
</>
);
}
class GlobalHistoryRow extends PureComponent {
render() {
const { item, disabled, headerItem } = this.props;
const dummy = headerItem || createDummyTestGlobalHistory();
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="90%" height={20} />
</Box>
<Box sx={{ gridColumn: 2, gridRow: 2 }}>
<Skeleton variant="rounded" width={70} height={22} />
</Box>
<Box sx={{ gridColumn: 3, gridRow: 2 }}>
<Skeleton variant="text" width="80%" height={20} />
</Box>
<Box sx={{ gridColumn: 4, gridRow: 2 }}>
<Skeleton variant="text" width="90%" height={20} />
</Box>
</ListItemButton>
);
}
const userName =
item.user ||
item.employee ||
(item.companyuserId ? `User #${item.companyuserId}` : '—');
const typeStr = item.type || item.entityType || '–';
const refStr = item.reference || item.p1 || '–';
const isArticle = (typeStr === 'article' || typeStr === 'artikel') && refStr && refStr !== '–';
return (
<ListItemButton
divider
disabled={disabled}
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
onClick={
isArticle
? () => {
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
}
: undefined
}
>
{renderInvisibleDummyRow(dummy)}
{/* 1. Datum */}
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
{formatDateTime(item.date || item.createdAt)}
</Typography>
</Box>
{/* 2. Typ */}
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0 }}>
<Chip
size="small"
label={typeStr}
sx={{
height: 22,
fontSize: '0.75rem',
fontWeight: 500,
bgcolor: 'action.hover',
textTransform: 'capitalize',
}}
/>
</Box>
{/* 3. Referenz / Key */}
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0 }}>
{isArticle ? (
<Typography
variant="body2"
component="a"
href={`/artikel/${encodeURIComponent(refStr)}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
}}
sx={{
color: LINK,
fontWeight: 600,
textDecoration: 'none',
...ellipsisSx,
'&:hover': { textDecoration: 'underline' },
}}
>
{refStr}
</Typography>
) : (
<Typography variant="body2" sx={{ fontWeight: 500, fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
{refStr}
</Typography>
)}
</Box>
{/* 4. Meldung / Beschreibung */}
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
<Tooltip title={item.text || item.value || ''} placement="top-start" enterDelay={500}>
<Typography variant="body2" sx={ellipsisSx}>
{item.text || item.value || '–'}
</Typography>
</Tooltip>
</Box>
</ListItemButton>
);
}
}
class GlobalHistorySearchField extends PureComponent {
constructor(props) {
super(props);
const s = globalHistoryAutomat.state;
const stats = globalHistoryAutomat.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 = globalHistoryAutomat.subscribe(this, (state) => ({
input: state.filter ?? '',
seekMonths: state.seekMonths ?? 0,
seekYears: state.seekYears ?? 0,
}));
if (import.meta.env.DEV) {
this.unsubStats = globalHistoryAutomat.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="Referenz, Typ, Meldung oder Benutzer suchen…"
onChange={(next) => globalHistoryAutomat.actions.filter(next)}
/>
</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={() => globalHistoryAutomat.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={() => globalHistoryAutomat.actions.addSeekYear()}
sx={{
minWidth: 78,
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>
<Button
size="small"
variant="text"
color="inherit"
disabled={!monthActive && !yearActive}
onClick={() => globalHistoryAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
>
Reset
</Button>
</Box>
{import.meta.env.DEV && (
<Chip
size="small"
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
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>
);
}
}
export default class GlobalHistoryPage extends PureComponent {
constructor(props) {
super(props);
this.headerItem = createDummyTestGlobalHistory();
this.state = {
items: globalHistoryAutomat.getItems(),
unfilteredTotal: globalHistoryAutomat.state.unfilteredTotal ?? 0,
filterKey: globalHistoryAutomat.getFilterKey?.() ?? '',
status: globalHistoryAutomat.state.status,
error: globalHistoryAutomat.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 = globalHistoryAutomat.subscribe(this, (state) => ({
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: globalHistoryAutomat.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);
globalHistoryAutomat.requireChunk(startChunk, endChunk);
}
handleReload() {
void globalHistoryAutomat.actions.reload().catch(() => {});
}
handleRetry() {
void globalHistoryAutomat.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 (
<>
<GlobalHistorySearchField
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 (
<>
<GlobalHistorySearchField
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={GlobalHistoryRow}
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 }}>Datum</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Typ</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Referenz / Key</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Meldung / Beschreibung</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>
</>
);
}
}