u
@@ -49,6 +49,7 @@ import authAutomat from './authAutomat';
|
||||
|
||||
const initialState = {
|
||||
items: [],
|
||||
filter: '',
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
@@ -58,61 +59,83 @@ const initialState = {
|
||||
|
||||
let automat;
|
||||
let inflightLoad = null;
|
||||
const FETCH_PAGE_SIZE = 100;
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads paginated orders, deduplicating concurrent callers.
|
||||
*
|
||||
* @param {{ offset?: number, limit?: number }} [params]
|
||||
* @returns {Promise<Order[]>}
|
||||
*/
|
||||
load({ offset, limit } = {}) {
|
||||
const currentOffset = offset ?? automat.state.offset ?? 0;
|
||||
const currentLimit = limit ?? automat.state.limit ?? 20;
|
||||
// MARK: pages through GET /api/order until every order is collected
|
||||
async function fetchAllOrders() {
|
||||
let offset = 0;
|
||||
let total = Infinity;
|
||||
const collected = [];
|
||||
while (offset === 0 || collected.length < total) {
|
||||
const res = await orderApi.list({ offset, limit: FETCH_PAGE_SIZE });
|
||||
if (offset === 0) total = Number(res?.total ?? res?.orders?.length ?? 0);
|
||||
const page = Array.isArray(res?.orders) ? res.orders : [];
|
||||
collected.push(...page);
|
||||
offset += page.length;
|
||||
if (page.length === 0) break;
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
automat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await orderApi.list({ offset: currentOffset, limit: currentLimit });
|
||||
const orders = res?.orders ?? [];
|
||||
automat.setState({
|
||||
const applyAll = (orders) => ({
|
||||
items: orders,
|
||||
total: Number(res?.total ?? orders.length),
|
||||
offset: Number(res?.offset ?? currentOffset),
|
||||
limit: Number(res?.limit ?? currentLimit),
|
||||
total: orders.length,
|
||||
offset: 0,
|
||||
limit: Math.max(orders.length, 1),
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Client-side filter over number, external reference and (resolved) customer name.
|
||||
* @param {Order[]} items
|
||||
* @param {string} filter
|
||||
* @param {(order: Order) => string} [nameOf] Customer name resolver.
|
||||
*/
|
||||
export function filterOrders(items, filter, nameOf) {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
if (!query) return items;
|
||||
return items.filter((o) =>
|
||||
[o.number, o.externalNumber, nameOf?.(o)]
|
||||
.some((v) => String(v ?? '').toLowerCase().includes(query)));
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/** Sets the active search filter. */
|
||||
filter(query) {
|
||||
automat.setState({ filter: String(query ?? '') });
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads all orders, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Order[]>}
|
||||
*/
|
||||
load() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
automat.setState({ status: 'loading', error: null });
|
||||
inflightLoad = fetchAllOrders()
|
||||
.then((orders) => {
|
||||
automat.setState(applyAll(orders));
|
||||
return orders;
|
||||
} catch (error) {
|
||||
})
|
||||
.catch((error) => {
|
||||
automat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
})
|
||||
.finally(() => { inflightLoad = null; });
|
||||
return inflightLoad;
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the current page of orders without flashing loading state.
|
||||
* Silently re-fetches all orders without flashing loading state.
|
||||
*
|
||||
* @returns {Promise<Order[]>}
|
||||
*/
|
||||
async reload() {
|
||||
const { offset, limit } = automat.state;
|
||||
try {
|
||||
const res = await orderApi.list({ offset, limit });
|
||||
const orders = res?.orders ?? [];
|
||||
automat.setState({
|
||||
items: orders,
|
||||
total: Number(res?.total ?? orders.length),
|
||||
offset: Number(res?.offset ?? offset),
|
||||
limit: Number(res?.limit ?? limit),
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
const orders = await fetchAllOrders();
|
||||
automat.setState(applyAll(orders));
|
||||
return orders;
|
||||
} catch (error) {
|
||||
automat.setState({ error });
|
||||
@@ -120,37 +143,6 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigates to a 1-based page using formula: offset = (page - 1) * limit
|
||||
*
|
||||
* @param {number} page 1-based page number.
|
||||
* @returns {Promise<Order[]>}
|
||||
*/
|
||||
setPage(page) {
|
||||
const limit = automat.state.limit || 20;
|
||||
const offset = Math.max(0, (page - 1) * limit);
|
||||
return actions.load({ offset, limit });
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets pagination offset directly.
|
||||
*
|
||||
* @param {number} offset 0-based offset.
|
||||
* @returns {Promise<Order[]>}
|
||||
*/
|
||||
setOffset(offset) {
|
||||
return actions.load({ offset, limit: automat.state.limit });
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets page limit (records to return) and resets offset to 0.
|
||||
*
|
||||
* @param {number} limit Records per page (1..100).
|
||||
* @returns {Promise<Order[]>}
|
||||
*/
|
||||
setLimit(limit) {
|
||||
return actions.load({ offset: 0, limit });
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensures orders are loaded once the selected company is known.
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
import { Fragment, PureComponent } from 'react';
|
||||
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 Collapse from '@mui/material/Collapse';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableFooter from '@mui/material/TableFooter';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import FillSpace from 'dashgui/FillSpace';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import orderAutomat from '../../automats/orderAutomat';
|
||||
import BackendError from '../BackendError';
|
||||
import orderAutomat, { filterOrders } from '../../automats/orderAutomat';
|
||||
import { order as orderApi } from '../../api';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import withNavigate from '../withNavigate';
|
||||
import ResultsPagination from '../article/ResultsPagination';
|
||||
import {
|
||||
formatDate,
|
||||
loadParties,
|
||||
@@ -36,530 +26,293 @@ import {
|
||||
partyDisplayName,
|
||||
} from './manifestConfig';
|
||||
|
||||
function tintDivider(color, amount = 0.15) {
|
||||
if (typeof color !== 'string') return 'divider';
|
||||
const m = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (!m) return 'divider';
|
||||
|
||||
let hex = m[1];
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
}
|
||||
const num = parseInt(hex, 16);
|
||||
const channels = [16, 8, 0].map((shift) => {
|
||||
const c = (num >> shift) & 0xff;
|
||||
return Math.max(0, Math.round(c * (1 - amount)));
|
||||
});
|
||||
const toHex = (v) => v.toString(16).padStart(2, '0');
|
||||
return `#${channels.map(toHex).join('')}`;
|
||||
}
|
||||
|
||||
function calculateOrderTotal(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) return 0;
|
||||
// MARK: pure helpers
|
||||
function orderGrossTotal(lines) {
|
||||
if (!Array.isArray(lines)) return 0;
|
||||
return lines.reduce((acc, line) => {
|
||||
const amount = Number(line.amount) || 0;
|
||||
const net = Number(line.net) || 0;
|
||||
const rebate = Number(line.rebate) || 0;
|
||||
const lineTotal = amount * net * (1 - rebate / 100);
|
||||
return acc + lineTotal;
|
||||
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);
|
||||
}
|
||||
|
||||
function calculateOrderGrossTotal(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) return 0;
|
||||
return lines.reduce((acc, line) => {
|
||||
const amount = Number(line.amount) || 0;
|
||||
const net = Number(line.net) || 0;
|
||||
const rebate = Number(line.rebate) || 0;
|
||||
const taxrate = Number(line.taxrate) || 0;
|
||||
const lineNet = amount * net * (1 - rebate / 100);
|
||||
const lineGross = lineNet * (1 + taxrate / 100);
|
||||
return acc + lineGross;
|
||||
}, 0);
|
||||
const cfg = manifestConfig('order');
|
||||
|
||||
const rowSx = {
|
||||
alignItems: 'center',
|
||||
height: 64,
|
||||
minHeight: 64,
|
||||
maxHeight: 64,
|
||||
boxSizing: 'border-box',
|
||||
py: 0,
|
||||
gap: 1.5,
|
||||
};
|
||||
|
||||
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||
|
||||
/**
|
||||
* Ambient context for `OrderRow`: FillSpace renders rows as bare elements
|
||||
* (`<Row item index />`), so the list publishes callbacks and the party map
|
||||
* here on every render.
|
||||
*/
|
||||
const rowContext = { partiesById: {}, busyId: null, onOpen: null, onDelete: null };
|
||||
|
||||
// MARK: one row rendered by FillSpace
|
||||
class OrderRow extends PureComponent {
|
||||
partyName(partyId) {
|
||||
if (partyId == null) return '–';
|
||||
const party = rowContext.partiesById[partyId];
|
||||
return partyDisplayName('order', party) || `Kunde #${partyId}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { item: order } = this.props;
|
||||
|
||||
// Data-free probe (FillSpace measures the row height with `item` undefined).
|
||||
if (!order) {
|
||||
return (
|
||||
<ListItemButton disabled divider sx={{ ...rowSx, opacity: 0, pointerEvents: 'none' }}>
|
||||
<Typography variant="body2"> </Typography>
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
|
||||
const { busyId, onOpen, onDelete } = rowContext;
|
||||
const lines = order.lines ?? [];
|
||||
const gross = orderGrossTotal(lines);
|
||||
|
||||
return (
|
||||
<ListItemButton divider sx={rowSx} onClick={() => onOpen?.(order)}>
|
||||
<Box sx={{ width: 120, flexShrink: 0, minWidth: 0 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
|
||||
{order.number}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
|
||||
{numberDisplay(order.externalNumber)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
|
||||
{this.partyName(order.entityId)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
|
||||
{cfg.dateLabel} {formatDate(order.date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${lines.length} Pos.`}
|
||||
sx={{
|
||||
width: 64,
|
||||
flexShrink: 0,
|
||||
height: 24,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
bgcolor: 'action.hover',
|
||||
color: lines.length > 0 ? 'text.primary' : 'text.disabled',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ width: 96, flexShrink: 0, textAlign: 'right', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{gross > 0 ? formatPrice(gross) : '—'}
|
||||
</Typography>
|
||||
|
||||
<IconButton
|
||||
aria-label={`„${order.number}“ löschen`}
|
||||
disabled={busyId != null}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(order);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{ width: 40, height: 40, flexShrink: 0 }}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
|
||||
</IconButton>
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: search + create bar
|
||||
class OrderSearchField extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { input: orderAutomat.state.filter ?? '' };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = orderAutomat.subscribe(this, (state) => ({ input: state.filter }));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { inputRef, onCreate } = this.props;
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<Box
|
||||
ref={inputRef}
|
||||
sx={{
|
||||
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
<SingleLineTextEdit
|
||||
sx={{ flex: 1 }}
|
||||
size="small"
|
||||
search
|
||||
clear
|
||||
value={this.state.input}
|
||||
onChange={(next) => orderAutomat.actions.filter(next)}
|
||||
/>
|
||||
<Button variant="contained" onClick={onCreate}>
|
||||
{cfg.createButton}
|
||||
</Button>
|
||||
</Box>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: list page
|
||||
export class OrderList extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cfg = manifestConfig('order');
|
||||
const orderState = orderAutomat.state;
|
||||
const s = orderAutomat.state;
|
||||
this.state = {
|
||||
status: orderState.status === 'ready' ? 'ready' : 'loading',
|
||||
error: orderState.error,
|
||||
orders: orderState.items ?? [],
|
||||
total: orderState.total ?? 0,
|
||||
offset: orderState.offset ?? 0,
|
||||
limit: orderState.limit ?? 20,
|
||||
status: s.status === 'ready' ? 'ready' : 'loading',
|
||||
error: s.error ? backendErrorFrom(s.error) : null,
|
||||
items: s.items ?? [],
|
||||
filter: s.filter ?? '',
|
||||
partiesById: {},
|
||||
busyId: null,
|
||||
expandedIds: new Set(),
|
||||
};
|
||||
this.searchRef = createRef();
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
this.handleEdit = this.handleEdit.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
this.handlePageChange = this.handlePageChange.bind(this);
|
||||
this.toggleExpand = this.toggleExpand.bind(this);
|
||||
this.nameOf = this.nameOf.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
this.unsubOrder = orderAutomat.subscribe(this, (state) => ({
|
||||
orders: state.items ?? [],
|
||||
total: state.total ?? 0,
|
||||
offset: state.offset ?? 0,
|
||||
limit: state.limit ?? 20,
|
||||
this.unsub = orderAutomat.subscribe(this, (state) => ({
|
||||
items: state.items ?? [],
|
||||
filter: state.filter ?? '',
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
error: state.error ? backendErrorFrom(state.error) : null,
|
||||
}));
|
||||
|
||||
void this.loadPartiesAndOrders();
|
||||
void this.loadAll();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsubOrder?.();
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
async loadPartiesAndOrders() {
|
||||
async loadAll() {
|
||||
try {
|
||||
const parties = await loadParties('order');
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
partiesById: this.cfg.partiesByIdFromList(parties),
|
||||
});
|
||||
if (!this._cancelled) this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
|
||||
} catch {
|
||||
// Non-fatal, party names can fall back to IDs
|
||||
// Non-fatal: names fall back to ids.
|
||||
}
|
||||
|
||||
try {
|
||||
await orderAutomat.actions.ensureLoaded();
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
} catch {
|
||||
// Surfaced through automat state.
|
||||
}
|
||||
}
|
||||
|
||||
async handleRetry() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
await orderAutomat.actions.reload();
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
nameOf(order) {
|
||||
const party = this.state.partiesById[order.entityId];
|
||||
return partyDisplayName('order', party);
|
||||
}
|
||||
|
||||
go(to) {
|
||||
const { navigate } = this.props;
|
||||
if (typeof navigate === 'function') {
|
||||
navigate(to);
|
||||
return;
|
||||
}
|
||||
window.location.assign(to);
|
||||
handleRetry() {
|
||||
void orderAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
|
||||
handleCreate() {
|
||||
this.go(this.cfg.createPath);
|
||||
this.props.navigate(cfg.createPath);
|
||||
}
|
||||
|
||||
handleEdit(order) {
|
||||
this.go(this.cfg.editPath(order.id));
|
||||
handleOpen(order) {
|
||||
this.props.navigate(cfg.editPath(order.id));
|
||||
}
|
||||
|
||||
async handleDelete(order) {
|
||||
if (this.state.busyId != null) return;
|
||||
if (!window.confirm(this.cfg.confirmDelete(order.number))) return;
|
||||
|
||||
if (!window.confirm(cfg.confirmDelete(order.number))) return;
|
||||
this.setState({ busyId: order.id });
|
||||
try {
|
||||
await orderApi.delete(order.id);
|
||||
await orderAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyId: null });
|
||||
enqueueSnackbar(this.cfg.deletedMsg, { variant: 'success' });
|
||||
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyId: null });
|
||||
} finally {
|
||||
if (!this._cancelled) this.setState({ busyId: null });
|
||||
}
|
||||
}
|
||||
|
||||
handlePageChange(page) {
|
||||
// Formula: Next Offset: (page - 1) * limit
|
||||
void orderAutomat.actions.setPage(page);
|
||||
}
|
||||
render() {
|
||||
const { status, error, items, filter, partiesById, busyId } = this.state;
|
||||
|
||||
toggleExpand(orderId) {
|
||||
this.setState((prev) => {
|
||||
const next = new Set(prev.expandedIds);
|
||||
if (next.has(orderId)) {
|
||||
next.delete(orderId);
|
||||
} else {
|
||||
next.add(orderId);
|
||||
}
|
||||
return { expandedIds: next };
|
||||
});
|
||||
}
|
||||
|
||||
partyName(partyId) {
|
||||
if (partyId == null) return '–';
|
||||
const party = this.state.partiesById[partyId];
|
||||
return partyDisplayName('order', party) || `Kunde #${partyId}`;
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
if (status === 'loading' || status === 'idle') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderLineItems(order, borderColor) {
|
||||
const lines = order.lines ?? [];
|
||||
if (lines.length === 0) {
|
||||
return (
|
||||
<Box sx={{ py: 1.5, px: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Positionen vorhanden.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const netTotal = calculateOrderTotal(lines);
|
||||
const grossTotal = calculateOrderGrossTotal(lines);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.45)',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor,
|
||||
}}
|
||||
>
|
||||
<Table size="small" aria-label={`Positionen für Bestellung ${order.number}`}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center" sx={{ fontWeight: 600, fontSize: '0.75rem', width: 44 }}>
|
||||
Pos
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, fontSize: '0.75rem' }}>SKU</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, fontSize: '0.75rem' }}>Artikel</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Menge
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Netto
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Brutto
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Rabatt
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
MwSt.
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Gesamt netto
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Gesamt brutto
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{lines.map((line, idx) => {
|
||||
const amount = Number(line.amount) || 0;
|
||||
const net = Number(line.net) || 0;
|
||||
const taxrate = Number(line.taxrate) || 0;
|
||||
const gross = net * (1 + taxrate / 100);
|
||||
const rebate = Number(line.rebate) || 0;
|
||||
const lineNet = amount * net * (1 - rebate / 100);
|
||||
const lineGross = lineNet * (1 + taxrate / 100);
|
||||
const puText = Number(line.pu) > 1 ? ` (VPE ${line.pu})` : '';
|
||||
|
||||
return (
|
||||
<TableRow key={line.id ?? `line-${idx}`}>
|
||||
<TableCell align="center" sx={{ fontSize: '0.75rem', color: 'text.secondary', fontWeight: 600 }}>
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{line.sku || '–'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem' }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500 }}>
|
||||
{line.name || '–'}
|
||||
</Typography>
|
||||
{line.comment ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{line.comment}
|
||||
</Typography>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{amount}
|
||||
{puText}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatPrice(net)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatPrice(gross)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{rebate > 0 ? `${rebate}%` : '–'}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{line.taxrate != null ? `${line.taxrate}%` : '–'}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
|
||||
{formatPrice(lineNet)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 600, color: 'primary.dark' }}>
|
||||
{formatPrice(lineGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow sx={{ '& > td': { borderTop: '2px solid', borderColor, fontWeight: 700 } }}>
|
||||
<TableCell colSpan={8} align="right" sx={{ fontSize: '0.75rem', fontWeight: 700 }}>
|
||||
Gesamtsumme:
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 700 }}>
|
||||
{formatPrice(netTotal)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 700, color: 'primary.dark' }}>
|
||||
{formatPrice(grossTotal)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, orders, total, offset, limit, busyId, expandedIds } = this.state;
|
||||
const cfg = this.cfg;
|
||||
|
||||
if (status === 'loading') return this.renderLoading();
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<BackendError
|
||||
error={error}
|
||||
onRetry={this.handleRetry}
|
||||
/>
|
||||
);
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
const borderColor = tintDivider(cfg.listColor);
|
||||
rowContext.partiesById = partiesById;
|
||||
rowContext.busyId = busyId;
|
||||
rowContext.onOpen = this.handleOpen;
|
||||
rowContext.onDelete = this.handleDelete;
|
||||
|
||||
// Client Pagination Formula:
|
||||
// Current Page: Math.floor(offset / limit) + 1
|
||||
// Total Pages: Math.ceil(total / limit)
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(total / limit));
|
||||
const showPagination = total > limit;
|
||||
const rows = filterOrders(items, filter, this.nameOf);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OrderSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={3} minHeight={240} color={cfg.listColor}>
|
||||
<Stack spacing={2}>
|
||||
{/* Create Header Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={this.handleCreate}
|
||||
>
|
||||
{cfg.createButton}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Empty State */}
|
||||
{orders.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<PanelTile wide={2} minHeight={0} color={cfg.listColor} sx={{ pb: 0 }}>
|
||||
{items.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ pb: 2 }}>
|
||||
{cfg.emptyList}
|
||||
</Typography>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiTableCell-root': {
|
||||
borderColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox" sx={{ width: 44 }} />
|
||||
<TableCell sx={{ width: 130, fontWeight: 600 }}>Nummer</TableCell>
|
||||
<TableCell sx={{ width: 130, fontWeight: 600 }}>Referenz</TableCell>
|
||||
<TableCell sx={{ width: 130, fontWeight: 600 }}>{cfg.dateLabel}</TableCell>
|
||||
<TableCell sx={{ minWidth: 160, fontWeight: 600 }}>{cfg.partyLabel}</TableCell>
|
||||
<TableCell sx={{ minWidth: 180, fontWeight: 600 }}>Positionen</TableCell>
|
||||
<TableCell padding="checkbox" align="right" sx={{ width: 84 }} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.map((order) => {
|
||||
const isExpanded = expandedIds.has(order.id);
|
||||
const lines = order.lines ?? [];
|
||||
const netTotal = calculateOrderTotal(lines);
|
||||
const grossTotal = calculateOrderGrossTotal(lines);
|
||||
const hasLines = lines.length > 0;
|
||||
|
||||
return (
|
||||
<Fragment key={order.id}>
|
||||
<TableRow
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => this.handleEdit(order)}
|
||||
>
|
||||
{/* Expand / Collapse toggle */}
|
||||
<TableCell
|
||||
padding="checkbox"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (hasLines) this.toggleExpand(order.id);
|
||||
}}
|
||||
>
|
||||
{hasLines ? (
|
||||
<IconButton size="small" aria-label="Positionen ein-/ausblenden">
|
||||
{isExpanded ? (
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
) : null}
|
||||
</TableCell>
|
||||
|
||||
{/* Number */}
|
||||
<TableCell sx={{ fontWeight: 600 }}>{order.number}</TableCell>
|
||||
|
||||
{/* Reference / External Number */}
|
||||
<TableCell>{numberDisplay(order.externalNumber)}</TableCell>
|
||||
|
||||
{/* Date */}
|
||||
<TableCell>{formatDate(order.date)}</TableCell>
|
||||
|
||||
{/* Customer */}
|
||||
<TableCell>{this.partyName(order.entityId)}</TableCell>
|
||||
|
||||
{/* Lines summary */}
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${lines.length} Pos.`}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.75rem',
|
||||
height: 22,
|
||||
}}
|
||||
<FillSpace
|
||||
aboveRef={[this.searchRef]}
|
||||
reserveBottom={32}
|
||||
minPageSize={8}
|
||||
minHeight={0}
|
||||
items={rows}
|
||||
totalCount={items.length}
|
||||
renderItem={OrderRow}
|
||||
/>
|
||||
{grossTotal > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.8rem', lineHeight: 1.2 }}>
|
||||
{formatPrice(grossTotal)} brutto
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{formatPrice(netTotal)} netto
|
||||
</Typography>
|
||||
</Box>
|
||||
) : netTotal > 0 ? (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
{formatPrice(netTotal)}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
{/* Row Action buttons */}
|
||||
<TableCell
|
||||
padding="checkbox"
|
||||
align="right"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`„${order.number}“ bearbeiten`}
|
||||
onClick={() => this.handleEdit(order)}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`„${order.number}“ löschen`}
|
||||
disabled={busyId != null}
|
||||
onClick={() => this.handleDelete(order)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Collapsible Line Details Row */}
|
||||
{hasLines && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
sx={{
|
||||
p: 0,
|
||||
borderBottom: isExpanded ? undefined : 'none',
|
||||
}}
|
||||
>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ py: 1.5, px: 2 }}>
|
||||
{this.renderLineItems(order, borderColor)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{showPagination ? (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<ResultsPagination
|
||||
offset={offset}
|
||||
totalCount={total}
|
||||
pageSize={limit}
|
||||
pageCount={pageCount}
|
||||
currentPage={currentPage}
|
||||
onPageChange={(e, page) => this.handlePageChange(page)}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,7 +645,6 @@ class ArtikelSearchPage extends Component {
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
const next = selected ? [] : [id];
|
||||
this.setState({ categoryFilterOpen: false });
|
||||
this.updateParams(input, buildFilter(next), 0);
|
||||
}}
|
||||
sx={{ pl: 2 + depth * 1.5 }}
|
||||
|
||||
@@ -110,6 +110,7 @@ class ArtikelMarkenPage extends PureComponent {
|
||||
status: brandAutomat.state.status,
|
||||
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
|
||||
brands: brandAutomat.state.filteredBrands ?? brandAutomat.state.brands,
|
||||
totalCount: brandAutomat.state.brands?.length ?? 0,
|
||||
};
|
||||
this.searchRef = createRef();
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
@@ -120,6 +121,7 @@ class ArtikelMarkenPage extends PureComponent {
|
||||
|
||||
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
||||
brands: state.filteredBrands,
|
||||
totalCount: state.brands?.length ?? 0,
|
||||
status: state.status,
|
||||
error: state.error ? backendErrorFrom({ message: state.error }) : null,
|
||||
}));
|
||||
@@ -159,6 +161,7 @@ class ArtikelMarkenPage extends PureComponent {
|
||||
minPageSize={8}
|
||||
minHeight={0}
|
||||
items={brands}
|
||||
totalCount={this.state.totalCount}
|
||||
renderItem={ArtikelMarke}
|
||||
/>
|
||||
</PanelTile>
|
||||
|
||||
@@ -499,6 +499,7 @@ class ArtikelMerkmalePage extends PureComponent {
|
||||
) : (
|
||||
<FillSpace
|
||||
items={filteredAttributes}
|
||||
totalCount={attributes.length}
|
||||
minPageSize={8}
|
||||
maxPageSize={8}
|
||||
listRef={this.attrListRef}
|
||||
@@ -567,6 +568,7 @@ class ArtikelMerkmalePage extends PureComponent {
|
||||
) : (
|
||||
<FillSpace
|
||||
items={filteredValues}
|
||||
totalCount={selectedValues.length}
|
||||
minPageSize={8}
|
||||
maxPageSize={8}
|
||||
listRef={this.valListRef}
|
||||
@@ -629,6 +631,7 @@ class ArtikelMerkmalePage extends PureComponent {
|
||||
) : (
|
||||
<FillSpace
|
||||
items={filteredProperties}
|
||||
totalCount={properties.length}
|
||||
minPageSize={8}
|
||||
maxPageSize={8}
|
||||
listRef={this.propListRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { OrderList } from '../../components/manifest';
|
||||
import OrderList from '../../components/manifest/OrderList';
|
||||
|
||||
class AuftragBestellungPage extends PureComponent {
|
||||
render() {
|
||||
|
||||
@@ -190,7 +190,7 @@ async function main() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const build = spawn('npx', ['vite', 'build'], {
|
||||
cwd: FRONT_ROOT,
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, NODE_ENV: 'production' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
build.stdout.on('data', () => process.stdout.write('[build] '));
|
||||
@@ -208,7 +208,7 @@ async function main() {
|
||||
const server = startProc(
|
||||
'npx',
|
||||
['vite', 'preview', '--port', String(VITE_PORT), '--strictPort'],
|
||||
{ RIG_API_TARGET: `http://localhost:${DUMMY_PORT}` },
|
||||
{ RIG_API_TARGET: `http://localhost:${DUMMY_PORT}`, NODE_ENV: 'production' },
|
||||
'preview',
|
||||
);
|
||||
await waitForPort(VITE_PORT);
|
||||
@@ -248,6 +248,7 @@ async function main() {
|
||||
height: viewport.height,
|
||||
settleMs: SETTLE_MS,
|
||||
dialog: route.dialog,
|
||||
icons: route.icons,
|
||||
});
|
||||
const kb = (buf.length / 1024).toFixed(0);
|
||||
|
||||
|
||||
51
test/cdp.mjs
@@ -161,7 +161,7 @@ async function httpJson(url, options = {}) {
|
||||
* Opens a new page target on the browser, navigates, waits for the app root
|
||||
* to contain content + network to quiet down, captures a PNG.
|
||||
*/
|
||||
export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000, dialog }) {
|
||||
export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000, dialog, icons }) {
|
||||
// Chrome ≥136 requires PUT for /json/new.
|
||||
const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, {
|
||||
method: 'PUT',
|
||||
@@ -257,6 +257,55 @@ export async function capturePage(debugPort, url, file, { width, height, settleM
|
||||
}
|
||||
}
|
||||
|
||||
// Icon sheet: replace the page by a white sheet with every distinct icon
|
||||
// (inline SVG) found on it, drawn in black.
|
||||
if (icons) {
|
||||
await session.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const seen = new Map();
|
||||
for (const svg of document.querySelectorAll('svg')) {
|
||||
const r = svg.getBoundingClientRect();
|
||||
if (r.width > 64 || r.height > 64) continue; // skip illustrations / spinners
|
||||
if (svg.closest('[role="progressbar"]')) continue;
|
||||
const key = svg.getAttribute('data-testid') || svg.innerHTML;
|
||||
if (!svg.innerHTML || seen.has(key)) continue;
|
||||
seen.set(key, svg);
|
||||
}
|
||||
const style = document.createElement('style');
|
||||
style.textContent = \`
|
||||
#icon-sheet{position:absolute;inset:0;background:#fff;color:#000;padding:24px;
|
||||
display:flex;flex-wrap:wrap;gap:20px;align-content:flex-start;font:11px sans-serif}
|
||||
#icon-sheet .cell{width:144px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;word-break:break-all}
|
||||
#icon-sheet svg{width:144px;height:144px;color:#000;fill:#000}
|
||||
#icon-sheet svg *{fill:#000;color:#000}
|
||||
#icon-sheet svg [stroke]:not([stroke=none]){stroke:#000}
|
||||
\`;
|
||||
document.head.appendChild(style);
|
||||
document.getElementById('root').style.display = 'none';
|
||||
const sheet = document.createElement('div');
|
||||
sheet.id = 'icon-sheet';
|
||||
const sorted = [...seen.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])));
|
||||
for (const [key, svg] of sorted) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'cell';
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.removeAttribute('class');
|
||||
clone.removeAttribute('style');
|
||||
clone.removeAttribute('width');
|
||||
clone.removeAttribute('height');
|
||||
cell.appendChild(clone);
|
||||
const label = document.createElement('span');
|
||||
label.textContent = svg.getAttribute('data-testid') || '';
|
||||
cell.appendChild(label);
|
||||
sheet.appendChild(cell);
|
||||
}
|
||||
document.body.style.background = '#fff';
|
||||
document.body.appendChild(sheet);
|
||||
})()`,
|
||||
});
|
||||
await wait(300);
|
||||
}
|
||||
|
||||
// Determinism: hide carets and scrollbars.
|
||||
await session.send('Runtime.evaluate', {
|
||||
expression: `(() => { const s = document.createElement('style');
|
||||
|
||||
@@ -20,6 +20,8 @@ export const ROUTES = [
|
||||
{ name: 'vorlagen', path: '/artikel/vorlagen' },
|
||||
{ name: 'merkmale', path: '/artikel/merkmale' },
|
||||
{ name: 'artikel-detail', path: '/artikel/ART-000001' },
|
||||
// Same page, but the shot is a white sheet with every icon on it in black.
|
||||
{ name: 'artikel-detail-icons', path: '/artikel/ART-000001', icons: true },
|
||||
{ name: 'lieferung', path: '/lieferung' },
|
||||
{ name: 'lieferung-neu', path: '/lieferung/neu' },
|
||||
{ name: 'lieferanten', path: '/lieferung/lieferanten' },
|
||||
|
||||
BIN
test/screenshots/reference/artikel-detail-icons.1280x960.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
test/screenshots/reference/artikel-detail-icons.1920x1200.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 155 KiB |