Files
dashfront/src/components/manifest/QuoteList.jsx
2026-10-21 11:54:46 +02:00

813 lines
29 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 Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
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 DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError';
import SkeleHashImage from '../settings/SkeleHashImage';
import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat';
import numberAutomat from '../../automats/numberAutomat';
import { quote as quoteApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
import withNavigate from '../withNavigate';
import {
formatDate,
loadParties,
manifestConfig,
partyDisplayName,
} from './manifestConfig';
// MARK: pure helpers
function quoteGrossTotal(lines) {
if (!Array.isArray(lines)) return 0;
return lines.reduce((acc, line) => {
const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100);
return acc + net * (1 + (Number(line.taxrate) || 0) / 100);
}, 0);
}
const cfg = manifestConfig('quote');
// Dummy quote generator using the active sequence scheme with max digits for layout testing
export function createDummyTestQuote() {
const maxNumber = getMaxDocumentNumber('quote');
return {
id: 999999,
number: maxNumber,
date: '2030-10-30T12:00:00Z',
customerName: 'Kunde',
positions: 999,
grossTotal: 30000.90,
files: [
{
id: 99991,
manifestId: 999999,
fileId: 99991,
name: `angebot-${maxNumber}.pdf`,
mimetype: 'application/pdf',
size: 2701,
thumbx: 141,
thumby: 200,
skeledash: '6w5VujjmxhSpoO8VSwebe2yXU3Tj4108PysbXPqoHmyYcs9oXH8z6npbrjwK90StUBOXY0LLPiDgZy9qjYjkFDVKSjUp3dcWQ',
},
{
id: 99992,
manifestId: 999999,
fileId: 99992,
name: 'datenblatt-muster.pdf',
mimetype: 'application/pdf',
size: 15420,
thumbx: 141,
thumby: 200,
skeledash: '6w5VujjmxhSpoO8VSwebe2yXU3Tj4108PysbXPqoHmyYcs9oXH8z6npbrjwK90StUBOXY0LLPiDgZy9qjYjkFDVKSjUp3dcWQ',
},
],
};
}
const rowGridSx = {
display: 'grid',
gridTemplateColumns: 'max-content max-content 1fr max-content max-content max-content auto',
gridTemplateRows: '0px 1fr',
rowGap: 0,
columnGap: 1.5,
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 || createDummyTestQuote();
return (
<>
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body1" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>{d.number}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.date ? formatDate(d.date) : '–'}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body1">{d.customerName}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Chip size="small" label={d.positions} sx={{ height: 24, '& .MuiChip-label': { px: 0.5 } }} />
</Box>
<Box aria-hidden sx={{ gridColumn: 5, gridRow: 1, display: 'flex', gap: '3px', alignItems: 'center', whiteSpace: 'nowrap', ...measuringCellSx }}>
{(d.files || []).map((file, i) => (
<Box key={file.id ?? file.fileId ?? i} sx={{ width: 15, height: 20, flexShrink: 0 }} />
))}
</Box>
<Box aria-hidden sx={{ gridColumn: 6, gridRow: 1, whiteSpace: 'nowrap', display: 'flex', justifyContent: 'flex-end', ...measuringCellSx }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{formatPrice(d.grossTotal)}
</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 7, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<IconButton sx={{ width: 40, height: 40 }} />
</Box>
</>
);
}
/**
* Ambient context for `QuoteRow`: FillSpace renders rows as bare elements
* (`<Row item index />`), so the list publishes callbacks and the dummy quote here on every render.
*/
const rowContext = {
partiesById: {},
busyId: null,
onOpen: null,
onDelete: null,
headerItem: null,
};
// MARK: <QuoteRow/>
class QuoteRow extends PureComponent {
partyName(partyId) {
if (partyId == null) return '–';
const party = rowContext.partiesById[partyId];
return partyDisplayName('quote', party) || `Kunde #${partyId}`;
}
render() {
const { item: quote, disabled } = this.props;
const dummy = this.props.headerItem || rowContext.headerItem;
// Data-free probe (FillSpace measures the row height with `item` undefined).
if (!quote) {
if (disabled) {
return (
<ListItemButton disabled divider sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</ListItemButton>
);
}
return (
<ListItemButton disabled divider sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, 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="text" width="90%" height={20} /></Box>
<Box sx={{ gridColumn: 3, gridRow: 2 }}><Skeleton variant="text" width="60%" height={20} /></Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="rounded" width={28} height={20} /></Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', alignItems: 'center', gap: '3px' }}><Skeleton variant="rounded" width={15} height={20} /></Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'flex-end' }}><Skeleton variant="text" width="80%" height={20} /></Box>
<Box sx={{ gridColumn: 7, gridRow: 2 }} />
</ListItemButton>
);
}
const { busyId, onOpen, onDelete } = rowContext;
const posCount = quote.positions != null ? quote.positions : (quote.lines?.length ?? 0);
const gross = quote.grossTotal != null ? Number(quote.grossTotal) : quoteGrossTotal(quote.lines);
const customer = quote.customerName || this.partyName(quote.entityId);
const quantityInfo = quote.totalQuantity != null ? ` · ${quote.totalQuantity} Stück` : '';
const files = Array.isArray(quote.files) ? quote.files : [];
return (
<ListItemButton
divider
sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}
onClick={disabled ? undefined : () => onOpen?.(quote)}
disabled={disabled}
>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 600, fontFamily: 'monospace', ...ellipsisSx }}>
{quote.number}
</Typography>
</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{quote.date ? formatDate(quote.date) : '–'}
</Typography>
</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
{customer}
</Typography>
</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Chip
size="small"
label={posCount}
title={`${posCount} Positionen${quantityInfo}`}
sx={{
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
bgcolor: 'action.hover',
color: posCount > 0 ? 'text.primary' : 'text.disabled',
'& .MuiChip-label': { px: 0.5 },
}}
/>
</Box>
{/* Files / Attachments column after Pos.: space for 2 per row, 2 lines max (up to 4 thumbnails) */}
<Box
sx={{
gridColumn: 5,
gridRow: 2,
display: 'flex',
flexWrap: 'wrap',
alignContent: 'center',
gap: '3px',
maxHeight: 44,
overflow: 'hidden',
width: 0,
minWidth: '100%',
}}
>
{files.slice(0, 4).map((file, index) => {
const fileId = file.fileId ?? file.id;
const title = file.name ? `${file.name} (Öffnen)` : 'Datei öffnen';
return (
<Tooltip key={file.id ?? fileId ?? index} title={title}>
<Box
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
if (quote.id != null && fileId != null) {
window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank');
} else {
onOpen?.(quote);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
if (quote.id != null && fileId != null) {
window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank');
}
}
}}
sx={{
width: 15,
height: 20,
flexShrink: 0,
borderRadius: 0.5,
overflow: 'hidden',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease',
'&:hover': {
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
borderColor: 'primary.main',
transform: 'scale(1.3)',
zIndex: 3,
},
}}
>
{file.skeledash ? (
<SkeleHashImage hash={file.skeledash} />
) : (
<PictureAsPdfIcon sx={{ fontSize: 11, color: 'error.main' }} />
)}
</Box>
</Tooltip>
);
})}
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums', textAlign: 'right', whiteSpace: 'nowrap' }}
>
{gross > 0 ? formatPrice(gross) : '—'}
</Typography>
</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<IconButton
aria-label={`„${quote.number}“ löschen`}
disabled={busyId != null || disabled}
onClick={(e) => {
e.stopPropagation();
onDelete?.(quote);
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{ width: 40, height: 40 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
</Box>
</ListItemButton>
);
}
}
// MARK: <QuoteSearchField/>
class QuoteSearchField extends PureComponent {
constructor(props) {
super(props);
const s = quoteAutomat.state;
const stats = quoteAutomat.getChunkStats?.();
this.state = {
input: s.filter ?? '',
seekMonths: s.seekMonths ?? 0,
seekYears: s.seekYears ?? 0,
negativeSeekMonths: s.negativeSeekMonths ?? 0,
lruChunksCount: stats?.lruChunksCount || 0,
maxLruChunks: stats?.maxLruChunks || 50,
filterCachesCount: stats?.filterCaches?.length || 1,
};
}
componentDidMount() {
this.unsub = quoteAutomat.subscribe(this, (state) => ({
input: state.filter ?? '',
seekMonths: state.seekMonths ?? 0,
seekYears: state.seekYears ?? 0,
negativeSeekMonths: state.negativeSeekMonths ?? 0,
}));
if (import.meta.env.DEV) {
this.unsubStats = quoteAutomat.subscribeChunkStats?.((stats) => {
this.setState({
lruChunksCount: stats.lruChunksCount,
maxLruChunks: stats.maxLruChunks,
filterCachesCount: stats.filterCaches?.length || 1,
});
});
}
}
componentWillUnmount() {
this.unsub?.();
this.unsubStats?.();
}
render() {
const {
inputRef,
onCreate,
customerFilterOpen,
onToggleCustomerFilter,
selectedCustomer,
onClearCustomerFilter,
} = 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 & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={input}
onChange={(next) => quoteAutomat.actions.filter(next)}
/>
<PartyFilterButton
title="Kunden"
open={customerFilterOpen}
onClick={onToggleCustomerFilter}
/>
{selectedCustomer && (
<PartyFilterChip
label={partyDisplayName('quote', selectedCustomer)}
onDelete={onClearCustomerFilter}
/>
)}
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
</Box>
{/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) & Chunk-Cache Monitor */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{/* Negativeseek controls (-1 Month, -1 Year, Reset) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<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={() => quoteAutomat.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={() => quoteAutomat.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={() => quoteAutomat.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 && (
<Tooltip title={`Chunk-Cache-Monitor öffnen (Alt+Shift+C) — ${this.state.lruChunksCount || 0}/${this.state.maxLruChunks || 50} Chunks im RAM über ${this.state.filterCachesCount || 1} Filter`}>
<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', { detail: { target: 'quote' } }))}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
</Tooltip>
)}
</Box>
</Box>
</Box>
</PanelTileGrid>
);
}
}
// MARK: <QuoteList/>
export class QuoteList extends PureComponent {
constructor(props) {
super(props);
this.headerItem = createDummyTestQuote();
this.state = {
items: quoteAutomat.getItems(),
unfilteredTotal: quoteAutomat.state.unfilteredTotal ?? 0,
filterKey: quoteAutomat.getFilterKey?.() ?? '',
entityId: quoteAutomat.state.entityId ?? null,
customerFilterOpen: false,
partiesById: {},
partiesList: [],
busyId: null,
};
this.searchRef = createRef();
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this);
this.handleSelectCustomer = this.handleSelectCustomer.bind(this);
}
componentDidMount() {
this._cancelled = false;
this.unsub = quoteAutomat.subscribe(this, (state) => ({
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: quoteAutomat.getFilterKey(state),
entityId: state.entityId ?? null,
}));
this.unsubNumber = numberAutomat.subscribe(this, () => {
this.headerItem = createDummyTestQuote();
this.forceUpdate();
});
void numberAutomat.actions.ensureLoaded().catch(() => { });
void this.loadParties();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
this.unsubNumber?.();
}
handleToggleCustomerFilter() {
this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen }));
}
handleSelectCustomer(entityId) {
quoteAutomat.actions.setEntityId(entityId);
}
async loadParties() {
try {
const parties = await loadParties('quote');
if (!this._cancelled) {
this.setState({
partiesById: cfg.partiesByIdFromList(parties),
partiesList: parties || [],
});
}
} catch {
// Non-fatal: names fall back to ids or customerName.
}
}
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);
quoteAutomat.requireChunk(startChunk, endChunk);
}
handleRetry() {
void quoteAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => { });
}
handleCreate() {
this.props.navigate(cfg.createPath);
}
handleOpen(quote) {
if (quote.id === 999999) {
enqueueSnackbar(`Test-Angebot „${quote.number}“ angeklickt.`, { variant: 'info' });
return;
}
this.props.navigate(cfg.editPath(quote.id));
}
async handleDelete(quote) {
if (quote.id === 999999) {
enqueueSnackbar('Test-Angebot kann nicht gelöscht werden.', { variant: 'info' });
return;
}
if (this.state.busyId != null) return;
if (!window.confirm(cfg.confirmDelete(quote.number))) return;
this.setState({ busyId: quote.id });
try {
await quoteApi.delete(quote.id);
await quoteAutomat.actions.reload();
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
if (!this._cancelled) this.setState({ busyId: null });
}
}
render() {
const {
items,
unfilteredTotal,
filterKey,
entityId,
customerFilterOpen,
partiesById,
partiesList,
busyId,
} = this.state;
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpen;
rowContext.onDelete = this.handleDelete;
rowContext.headerItem = this.headerItem;
const customerCounts = {};
if (Array.isArray(items)) {
for (const q of items) {
if (q?.entityId) {
customerCounts[q.entityId] = (customerCounts[q.entityId] || 0) + 1;
}
}
}
const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null;
const searchField = (
<QuoteSearchField
inputRef={this.searchRef}
onCreate={this.handleCreate}
customerFilterOpen={customerFilterOpen}
onToggleCustomerFilter={this.handleToggleCustomerFilter}
selectedCustomer={selectedCustomer}
onClearCustomerFilter={() => this.handleSelectCustomer(null)}
/>
);
if (items === null && quoteAutomat.state.status === 'failed') {
return (
<>
{searchField}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(quoteAutomat.state.error)} onRetry={this.handleRetry} />
</PanelTile>
</PanelTileGrid>
</>
);
}
return (
<>
{searchField}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'stretch' }}>
{customerFilterOpen && (
<PartyFilterPanel
title="Kunden"
parties={partiesList}
selectedId={entityId}
onSelect={this.handleSelectCustomer}
onClose={this.handleToggleCustomerFilter}
counts={customerCounts}
searchPlaceholder="Kunde suchen…"
/>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} 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={QuoteRow}
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 }}>Nr.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, textAlign: 'left', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...headerCellSx }}></Box>
<Box sx={{ gridColumn: 6, gridRow: 2, textAlign: 'right', ...headerCellSx }}>Gesamt</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, ...headerCellSx }} />
</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>
</Box>
</Box>
</>
);
}
}
export default withNavigate(QuoteList);