reroute
This commit is contained in:
484
src/routes/artikel.jsx
Normal file
484
src/routes/artikel.jsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import { Component, createRef } from 'react';
|
||||
import FillSpace from 'dashgui/FillSpace';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { StorageManagerContext, getStorageManager } from '../../api/storageManagerContext';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import {
|
||||
buildSearchUrl,
|
||||
queryFromParams,
|
||||
readArtikelQuery,
|
||||
writeArtikelQuery,
|
||||
} from './artikelQuery';
|
||||
import ResultsPagination from './ResultsPagination';
|
||||
import {
|
||||
buildFilter,
|
||||
formatCategoryList,
|
||||
formatStock,
|
||||
getCategoryDepth,
|
||||
parseFilter,
|
||||
} from './searchFormat';
|
||||
import { preserveSearchFocus } from './searchFocus';
|
||||
import { artikelDetailPath } from './articleRoute';
|
||||
import withSearchParams from './withSearchParams';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
import { BrandLabel } from '../../components/brand';
|
||||
|
||||
const resultRowSx = {
|
||||
alignItems: 'center',
|
||||
py: 1.5,
|
||||
// Keep every row one line so sample height matches real rows.
|
||||
'& .MuiListItemText-root': { minWidth: 0 },
|
||||
'& .MuiListItemText-primary, & .MuiListItemText-secondary': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
};
|
||||
|
||||
function ResultRowMeasureSample() {
|
||||
return (
|
||||
<ListItemButton tabIndex={-1} divider sx={resultRowSx}>
|
||||
<ListItemText
|
||||
primary="Measure"
|
||||
secondary={
|
||||
<Typography component="span" variant="body2" color="text.secondary" noWrap>
|
||||
SKU · Bestand 0 · Verfügbar 0 · Kategorie
|
||||
</Typography>
|
||||
}
|
||||
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}>
|
||||
0,00 €
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
|
||||
function initialSearchState(searchParams) {
|
||||
const query = queryFromParams(searchParams);
|
||||
const stored = readArtikelQuery();
|
||||
const input =
|
||||
query.target.trim().length >= 3
|
||||
? query.target
|
||||
: (stored?.target?.trim().length >= 3 ? stored.target : query.target);
|
||||
|
||||
return {
|
||||
input,
|
||||
error: null,
|
||||
categoryFacets: null,
|
||||
totalCount: null,
|
||||
recordset: null,
|
||||
pageSize: null,
|
||||
};
|
||||
}
|
||||
|
||||
class ArtikelSearchPage extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
get storageManager() {
|
||||
return this.context || getStorageManager();
|
||||
}
|
||||
|
||||
state = initialSearchState(this.props.searchParams);
|
||||
listSlotRef = { current: null };
|
||||
rowMeasureRef = { current: null };
|
||||
paginationRef = createRef();
|
||||
|
||||
setListSlotRef = (node) => {
|
||||
if (this.listSlotRef.current === node) return;
|
||||
this.listSlotRef.current = node;
|
||||
if (!node) return;
|
||||
this.updatePageSize();
|
||||
};
|
||||
|
||||
setRowMeasureRef = (node) => {
|
||||
if (this.rowMeasureRef.current === node) return;
|
||||
this.rowMeasureRef.current = node;
|
||||
if (!node) return;
|
||||
this.updatePageSize();
|
||||
};
|
||||
|
||||
measureRowHeight = () => {
|
||||
// Always use the sample — real rows used to report a different height after paint
|
||||
// (wrapped text), which made resize "fix" a wrong first measure.
|
||||
const sample = this.rowMeasureRef.current;
|
||||
if (!sample) return { height: 0, source: 'none' };
|
||||
return {
|
||||
height: sample.getBoundingClientRect().height,
|
||||
source: 'sample',
|
||||
};
|
||||
};
|
||||
|
||||
updatePageSize = ({ fromResize = false } = {}) => {
|
||||
const slot = this.listSlotRef.current;
|
||||
if (!slot) return;
|
||||
|
||||
// After the first measure, ignore layout churn from painting results.
|
||||
// Only window resize may change pageSize and refetch.
|
||||
if (this.state.pageSize != null && !fromResize) return;
|
||||
|
||||
const slotTop = slot.getBoundingClientRect().top;
|
||||
const available = window.innerHeight - slotTop;
|
||||
const { height: rowHeight, source: rowSource } = this.measureRowHeight();
|
||||
if (available < 1 || rowHeight < 1) return;
|
||||
|
||||
// Outlined Paper around the list is 1px top + 1px bottom — not in row height.
|
||||
const listChrome = 2;
|
||||
const next = Math.max(1, Math.floor((available - listChrome) / rowHeight));
|
||||
|
||||
console.log('[pageSize] measure', {
|
||||
fromResize,
|
||||
prevPageSize: this.state.pageSize,
|
||||
next,
|
||||
available,
|
||||
rowHeight,
|
||||
rowSource,
|
||||
listChrome,
|
||||
slotTop,
|
||||
realRowHeight:
|
||||
slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null,
|
||||
});
|
||||
|
||||
if (next === this.state.pageSize) return;
|
||||
|
||||
this.setState({ pageSize: next }, () => {
|
||||
const target = this.props.searchParams.get('target') ?? '';
|
||||
if (target) this.fetchResults();
|
||||
});
|
||||
};
|
||||
|
||||
handleWindowResize = () => {
|
||||
this.updatePageSize({ fromResize: true });
|
||||
};
|
||||
|
||||
persistQuery = () => {
|
||||
const query = queryFromParams(this.props.searchParams);
|
||||
const target = query.target || this.state.input;
|
||||
writeArtikelQuery({
|
||||
target,
|
||||
filter: query.filter,
|
||||
offset: query.offset,
|
||||
});
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', preserveSearchFocus, true);
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
setLastArticleSku(null);
|
||||
|
||||
// Kick off initial search (empty input = show all products).
|
||||
const { input } = this.state;
|
||||
this.storageManager?.doSearch({
|
||||
target: input,
|
||||
filter: this.props.searchParams.get('filter') || undefined,
|
||||
offset: 0,
|
||||
maxCount: this.state.pageSize ?? 20,
|
||||
onUpdate: (result) => this.setState(result),
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', preserveSearchFocus, true);
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
this.persistQuery();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { searchParams } = this.props;
|
||||
const prev = prevProps.searchParams;
|
||||
const target = searchParams.get('target') ?? '';
|
||||
const prevTarget = prev.get('target') ?? '';
|
||||
|
||||
if (target !== prevTarget && target !== this.state.input) {
|
||||
this.setState({ input: target });
|
||||
}
|
||||
|
||||
if (
|
||||
target !== prevTarget
|
||||
|| searchParams.get('filter') !== prev.get('filter')
|
||||
|| searchParams.get('offset') !== prev.get('offset')
|
||||
) {
|
||||
this.fetchResults();
|
||||
}
|
||||
}
|
||||
|
||||
updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
|
||||
writeArtikelQuery({
|
||||
target: nextTarget,
|
||||
filter: nextFilter,
|
||||
offset: nextOffset,
|
||||
});
|
||||
this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), {
|
||||
replace: true,
|
||||
});
|
||||
};
|
||||
|
||||
handleInputChange = (event) => {
|
||||
const value = event.target.value;
|
||||
this.setState({ input: value });
|
||||
|
||||
this.storageManager?.doSearch({
|
||||
target: value,
|
||||
filter: this.props.searchParams.get('filter') || undefined,
|
||||
offset: 0,
|
||||
maxCount: this.state.pageSize ?? 20,
|
||||
onUpdate: (result) => this.setState(result),
|
||||
});
|
||||
};
|
||||
|
||||
fetchResults = () => {
|
||||
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
||||
const trimmed = target.trim();
|
||||
const { pageSize } = this.state;
|
||||
|
||||
if (pageSize == null) return;
|
||||
|
||||
writeArtikelQuery({ target, filter, offset });
|
||||
|
||||
// Delegate fetch + debounce to the global StorageManager.
|
||||
// The callback pushes results into local component state.
|
||||
this.storageManager?.doSearch({
|
||||
target: trimmed,
|
||||
filter: filter || undefined,
|
||||
offset,
|
||||
maxCount: pageSize,
|
||||
onUpdate: (result) => this.setState(result),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state;
|
||||
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
|
||||
const categories = parseFilter(filterParam);
|
||||
// Drive UI from input so hint vs results switch in one paint (URL lags one tick).
|
||||
const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
|
||||
const rows = recordset ?? [];
|
||||
|
||||
const facetEntries = categoryFacets
|
||||
? Object.entries(categoryFacets)
|
||||
.filter(([, meta]) => meta?.name)
|
||||
.map(([id, { count, name }]) => ({
|
||||
id,
|
||||
count,
|
||||
name,
|
||||
depth: getCategoryDepth(id, categoryFacets),
|
||||
}))
|
||||
.sort((a, b) => Number(a.id) - Number(b.id))
|
||||
: [];
|
||||
|
||||
const safePageSize = pageSize ?? 1;
|
||||
const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize));
|
||||
const currentPage = Math.floor(offset / safePageSize) + 1;
|
||||
const showPagination =
|
||||
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
|
||||
|
||||
const handlePageChange = (_e, page) => {
|
||||
this.updateParams(input, filterParam, (page - 1) * safePageSize);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ mb: 3, maxWidth: 720 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
autoFocus
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
placeholder="Artikel suchen (EAN, Artikelnr., Name, Barcode)…"
|
||||
value={input}
|
||||
onChange={this.handleInputChange}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
spellCheck: false,
|
||||
autoCapitalize: 'off',
|
||||
autoCorrect: 'off',
|
||||
},
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<BackendError
|
||||
error={error}
|
||||
sx={{ mb: 2 }}
|
||||
onRetry={this.fetchResults}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchActive && (
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{facetEntries.length > 0 && (
|
||||
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'auto' }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }}
|
||||
>
|
||||
Kategorien
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{facetEntries.map(({ id, count, name, depth }) => {
|
||||
const selected = categories.includes(id);
|
||||
return (
|
||||
<ListItemButton
|
||||
key={id}
|
||||
selected={selected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
const next = selected ? [] : [id];
|
||||
this.updateParams(input, buildFilter(next), 0);
|
||||
}}
|
||||
sx={{ pl: 2 + depth * 1.5 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={`${name} (${count})`}
|
||||
slotProps={{ primary: { variant: 'body2' } }}
|
||||
/>
|
||||
{selected && (
|
||||
<ClearIcon fontSize="small" color="action" sx={{ ml: 1, flexShrink: 0 }} />
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}>
|
||||
{/* Same column width as the list; invisible, zero layout footprint. */}
|
||||
<Box aria-hidden sx={{ height: 0, overflow: 'hidden', width: '100%' }}>
|
||||
<Box
|
||||
ref={this.setRowMeasureRef}
|
||||
sx={{ visibility: 'hidden', pointerEvents: 'none' }}
|
||||
>
|
||||
<ResultRowMeasureSample />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{searchActive && !error && totalCount === 0 && (
|
||||
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
|
||||
)}
|
||||
|
||||
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
|
||||
<Box ref={this.paginationRef} sx={{ mb: 2 }}>
|
||||
<ResultsPagination
|
||||
invisible={!showPagination}
|
||||
offset={showPagination ? offset : 0}
|
||||
totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
|
||||
pageSize={pageSize ?? 1}
|
||||
pageCount={showPagination ? pageCount : 5}
|
||||
currentPage={showPagination ? currentPage : 1}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<FillSpace
|
||||
aboveRef={this.paginationRef}
|
||||
debug
|
||||
sx={{ overflow: 'auto' }}
|
||||
// Temporary dummy unit so the repeating fill is visible.
|
||||
measureElement={
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: '#dbeafe',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">Einheit</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box ref={this.setListSlotRef}>
|
||||
{rows.length > 0 && (
|
||||
<List component={Paper} variant="outlined" disablePadding>
|
||||
{rows.map((row, index) => (
|
||||
<ListItemButton
|
||||
key={row.id}
|
||||
type="button"
|
||||
divider={index < rows.length - 1}
|
||||
data-result-row
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
this.persistQuery();
|
||||
this.props.navigate(artikelDetailPath(row));
|
||||
}}
|
||||
sx={resultRowSx}
|
||||
>
|
||||
<ListItemText
|
||||
primary={row.name}
|
||||
secondary={
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
|
||||
>
|
||||
{(row.brand?.name || row.brandName) && (
|
||||
<>
|
||||
<BrandLabel
|
||||
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
|
||||
name={row.brand?.name || row.brandName}
|
||||
size={14}
|
||||
spacing={0.5}
|
||||
typographyProps={{
|
||||
variant: 'body2',
|
||||
color: 'text.secondary',
|
||||
noWrap: true,
|
||||
}}
|
||||
/>
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
{row.sku}
|
||||
{' · '}
|
||||
Bestand {formatStock(row.stock)}
|
||||
{' · '}
|
||||
Verfügbar {formatStock(row.available)}
|
||||
{row.categories
|
||||
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
|
||||
: ''}
|
||||
</Typography>
|
||||
}
|
||||
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
|
||||
>
|
||||
{row.price != null ? formatPrice(row.price) : '—'}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</FillSpace>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withSearchParams(ArtikelSearchPage);
|
||||
8
src/routes/artikel/RedirectArtikelKategorien.jsx
Normal file
8
src/routes/artikel/RedirectArtikelKategorien.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { KATEGORIEN_BASE } from './categoryRoute';
|
||||
|
||||
export default function RedirectArtikelKategorien() {
|
||||
const { '*': rest = '' } = useParams();
|
||||
const suffix = rest ? `/${rest}` : '';
|
||||
return <Navigate to={`${KATEGORIEN_BASE}${suffix}`} replace />;
|
||||
}
|
||||
118
src/routes/artikel/_sku.jsx
Normal file
118
src/routes/artikel/_sku.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import { ArticleView } from '../../components/article';
|
||||
import {
|
||||
artikelAutomat,
|
||||
createArticleSelector,
|
||||
getArticle,
|
||||
setLastArticleSku,
|
||||
} from '../../automats';
|
||||
import withSkuRoute from './withSkuRoute';
|
||||
|
||||
/** Full-screen loading state while the shared article cache resolves. */
|
||||
function ArticleLoading() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
class ArtikelSkuPage extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
article: getArticle(props.sku),
|
||||
ready: context.getCategoryState() != null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._mounted = true;
|
||||
this.installEscape();
|
||||
setLastArticleSku(this.props.sku);
|
||||
this.unsub = artikelAutomat.subscribe(this, (state) => ({
|
||||
article: createArticleSelector(this.props.sku)(state),
|
||||
}));
|
||||
void this.ensureArticle();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.sku !== this.props.sku) {
|
||||
setLastArticleSku(this.props.sku);
|
||||
this.setState({ article: getArticle(this.props.sku), error: null });
|
||||
this.unsub?.();
|
||||
this.unsub = artikelAutomat.subscribe(this, (state) => ({
|
||||
article: createArticleSelector(this.props.sku)(state),
|
||||
}));
|
||||
void this.ensureArticle();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._mounted = false;
|
||||
this.removeEscape();
|
||||
this.unsub?.();
|
||||
this.unsub = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates the article fetch to the shared artikelAutomat cache; also waits
|
||||
* for the category tree/company/session the view tiles depend on.
|
||||
*/
|
||||
async ensureArticle() {
|
||||
const sku = this.props.sku;
|
||||
if (!sku) return;
|
||||
try {
|
||||
await Promise.all([
|
||||
artikelAutomat.actions.loadArticle(sku),
|
||||
this.context.categoryTreePromise,
|
||||
this.context.companyPromise,
|
||||
this.context.authMePromise,
|
||||
]);
|
||||
if (this._mounted) this.setState({ ready: true });
|
||||
} catch (error) {
|
||||
if (this._mounted) this.setState({ error });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ error: null });
|
||||
void this.ensureArticle();
|
||||
};
|
||||
|
||||
installEscape() {
|
||||
this._onKeyDown = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
this.props.navigate(-1);
|
||||
};
|
||||
document.addEventListener('keydown', this._onKeyDown);
|
||||
}
|
||||
|
||||
removeEscape() {
|
||||
if (this._onKeyDown) {
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
this._onKeyDown = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { sku } = this.props;
|
||||
const { article, ready, error } = this.state;
|
||||
|
||||
if (error) {
|
||||
return <BackendError error={error} sx={{ mt: 2 }} onRetry={this.handleRetry} />;
|
||||
}
|
||||
if (!article || !ready) return <ArticleLoading />;
|
||||
return <ArticleView sku={sku} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default withSkuRoute(ArtikelSkuPage);
|
||||
555
src/routes/artikel/kategorien.jsx
Normal file
555
src/routes/artikel/kategorien.jsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { Component, PureComponent, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import CategorySortable from './CategorySortable';
|
||||
import CategoryPickerDialog from '../../components/article/CategoryPickerDialog';
|
||||
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
|
||||
import { NewArticleTile } from '../../components/article';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
import {
|
||||
ArticleTile,
|
||||
BackTile,
|
||||
CategoryTile,
|
||||
} from '../../components/tiles';
|
||||
import { TILE_SIZE } from 'dashgui/tiles';
|
||||
import withCategoryRoute from './withCategoryRoute';
|
||||
import { artikelDetailPath, articleSku } from './articleRoute';
|
||||
import {
|
||||
buildCategoryPath,
|
||||
defaultCategoryPath,
|
||||
resolveCategoryIdFromSlugPath,
|
||||
ROOT_CATEGORY_NAME,
|
||||
} from './categoryRoute';
|
||||
import categoryAutomat from '../../automats/categoryAutomat';
|
||||
import posterminalAutomat from '../../automats/posterminalAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
import {
|
||||
categoryDisplayName,
|
||||
getCategoryBreadcrumbs,
|
||||
getCategoryChildren,
|
||||
getCategoryDescendantIds,
|
||||
} from '../../utils/categoryUtils';
|
||||
|
||||
// ─── Helper selectors for this page ────────────────────────────────────────────
|
||||
function selectFromCategory(s) {
|
||||
const { categories, rootCategoryId, renameMode, treeStatus, error } = s;
|
||||
return {
|
||||
categories,
|
||||
rootCategoryId,
|
||||
renameMode,
|
||||
treeStatus,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function selectTerminals(s) {
|
||||
return { terminals: s.terminals };
|
||||
}
|
||||
|
||||
const gridSx = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${TILE_SIZE}px, 1fr))`,
|
||||
gap: 2,
|
||||
};
|
||||
|
||||
class ArtikelKategorienPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const catState = selectFromCategory(categoryAutomat.state);
|
||||
const { categories, rootCategoryId, treeStatus } = catState;
|
||||
|
||||
let resolvedCategoryId = null;
|
||||
if (treeStatus === 'succeeded' && categories && rootCategoryId != null) {
|
||||
resolvedCategoryId = resolveCategoryIdFromSlugPath(
|
||||
categories,
|
||||
rootCategoryId,
|
||||
props.categorySlugPath,
|
||||
);
|
||||
}
|
||||
|
||||
this.handle = categoryAutomat.key(props.categorySlugPath ?? '');
|
||||
const view = this.handle?.state;
|
||||
const catId = view?.data?.categoryId ?? resolvedCategoryId;
|
||||
|
||||
this.state = {
|
||||
drafts: {},
|
||||
...catState,
|
||||
resolvedCategoryId: catId,
|
||||
articles: view?.data?.articles ?? [],
|
||||
childCategories: view?.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
|
||||
loaded: view?.loaded ?? false,
|
||||
loading: view?.loading ?? false,
|
||||
routeError: view?.error ?? null,
|
||||
...selectTerminals(posterminalAutomat.state),
|
||||
companyId: companyAutomat.state.current?.id ?? null,
|
||||
moveCategory: null,
|
||||
};
|
||||
this.gridRef = createRef();
|
||||
this.sortableModule = null;
|
||||
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
|
||||
this.handleDraftChange = this.handleDraftChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setLastArticleSku(null);
|
||||
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
|
||||
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
|
||||
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
|
||||
companyId: s.current?.id ?? null,
|
||||
}));
|
||||
this.ensureTreeLoaded();
|
||||
this.ensurePosterminalsLoaded();
|
||||
this.subscribeViewHandle();
|
||||
this.syncSortable();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (this.state.companyId !== prevState.companyId) {
|
||||
this.ensureTreeLoaded();
|
||||
}
|
||||
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
|
||||
this.syncCategoryFromUrl();
|
||||
}
|
||||
if (
|
||||
this.props.categorySlugPath !== prevProps.categorySlugPath ||
|
||||
this.state.categories !== prevState.categories ||
|
||||
this.state.rootCategoryId !== prevState.rootCategoryId
|
||||
) {
|
||||
this.syncCategoryFromUrl();
|
||||
}
|
||||
if (
|
||||
this.state.renameMode !== prevState.renameMode ||
|
||||
(this.state.renameMode && this.state.resolvedCategoryId !== prevState.resolvedCategoryId)
|
||||
) {
|
||||
this.syncSortable();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unsubCat?.();
|
||||
this._unsubTerm?.();
|
||||
this._unsubCompany?.();
|
||||
this._unsubHandle?.();
|
||||
this.sortableModule?.destroy();
|
||||
}
|
||||
|
||||
subscribeViewHandle() {
|
||||
this._unsubHandle?.();
|
||||
this._unsubHandle = null;
|
||||
if (this.handle) {
|
||||
this._unsubHandle = this.handle.subscribe(this, (item) => {
|
||||
const catId = item.data?.categoryId ?? this.state.resolvedCategoryId;
|
||||
const categories = this.state.categories;
|
||||
return {
|
||||
resolvedCategoryId: catId,
|
||||
articles: item.data?.articles ?? [],
|
||||
childCategories: item.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
|
||||
loaded: item.loaded,
|
||||
loading: item.loading,
|
||||
routeError: item.error,
|
||||
};
|
||||
});
|
||||
void this.handle.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
#ensureModules = () => {
|
||||
const el = this.gridRef.current;
|
||||
if (!el) return;
|
||||
if (!this.sortableModule) {
|
||||
this.sortableModule = new CategorySortable(el, {
|
||||
onReorder: ({ categoryIds }) =>
|
||||
categoryAutomat.actions.reorderCategories({
|
||||
parentId: this.state.resolvedCategoryId,
|
||||
categoryIds,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
syncSortable = () => {
|
||||
this.#ensureModules();
|
||||
if (this.state.renameMode) {
|
||||
this.sortableModule?.destroy();
|
||||
this.sortableModule?.activate();
|
||||
} else {
|
||||
this.sortableModule?.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
ensureTreeLoaded = () => {
|
||||
const { companyId, treeStatus } = this.state;
|
||||
if (!companyId || treeStatus !== 'idle') return;
|
||||
void categoryAutomat.actions.loadTree();
|
||||
};
|
||||
|
||||
ensurePosterminalsLoaded = () => {
|
||||
const { companyId } = this.state;
|
||||
if (!companyId) return;
|
||||
void posterminalAutomat.actions.load();
|
||||
};
|
||||
|
||||
syncCategoryFromUrl = () => {
|
||||
const {
|
||||
categories,
|
||||
rootCategoryId,
|
||||
treeStatus,
|
||||
resolvedCategoryId,
|
||||
} = this.state;
|
||||
const { categorySlugPath, navigate } = this.props;
|
||||
|
||||
if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return;
|
||||
|
||||
const segments = (categorySlugPath ?? '').split('/').filter(Boolean);
|
||||
const hasLegacyHomePrefix = segments[0] === ROOT_CATEGORY_NAME;
|
||||
|
||||
const resolvedId = resolveCategoryIdFromSlugPath(
|
||||
categories,
|
||||
rootCategoryId,
|
||||
categorySlugPath,
|
||||
);
|
||||
|
||||
if (resolvedId == null) {
|
||||
navigate(defaultCategoryPath(), { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLegacyHomePrefix) {
|
||||
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
|
||||
}
|
||||
|
||||
const nextHandle = categoryAutomat.key(categorySlugPath ?? '');
|
||||
if (this.handle !== nextHandle) {
|
||||
this.handle = nextHandle;
|
||||
const view = this.handle.state;
|
||||
const catId = view?.data?.categoryId ?? resolvedId;
|
||||
this.setState({
|
||||
resolvedCategoryId: catId,
|
||||
articles: view?.data?.articles ?? [],
|
||||
childCategories: view?.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
|
||||
loaded: view?.loaded ?? false,
|
||||
loading: view?.loading ?? false,
|
||||
routeError: view?.error ?? null,
|
||||
});
|
||||
this.subscribeViewHandle();
|
||||
} else if (resolvedId !== resolvedCategoryId) {
|
||||
this.setState({
|
||||
resolvedCategoryId: resolvedId,
|
||||
childCategories: getCategoryChildren(categories, resolvedId),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
navigateToCategoryById = (categoryId) => {
|
||||
const { categories, rootCategoryId } = this.state;
|
||||
this.props.navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
|
||||
};
|
||||
|
||||
handleDraftChange(categoryId, value) {
|
||||
this.setState((state) => ({
|
||||
drafts: { ...state.drafts, [categoryId]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
handleRenameSubmit = async (categoryId, name) => {
|
||||
const trimmed = String(name ?? '').trim();
|
||||
if (!trimmed) {
|
||||
enqueueSnackbar('Name ist erforderlich.', { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await categoryAutomat.actions.renameCategory({ categoryId, name: trimmed });
|
||||
enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
};
|
||||
|
||||
handleOpenMoveCategory = (category) => {
|
||||
this.setState({ moveCategory: category });
|
||||
};
|
||||
|
||||
handleCloseMoveCategory = () => {
|
||||
this.setState({ moveCategory: null });
|
||||
};
|
||||
|
||||
handleConfirmMoveCategory = async (targetParentId) => {
|
||||
const { moveCategory } = this.state;
|
||||
if (!moveCategory || targetParentId == null) return;
|
||||
if (String(targetParentId) === String(moveCategory.parentId)) {
|
||||
this.setState({ moveCategory: null });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await categoryAutomat.actions.moveCategory({
|
||||
categoryId: moveCategory.id,
|
||||
parentId: targetParentId,
|
||||
});
|
||||
enqueueSnackbar(`Kategorie „${moveCategory.name}“ verschoben.`, { variant: 'success' });
|
||||
this.setState({ moveCategory: null });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
};
|
||||
|
||||
handleRetryTree = () => {
|
||||
if (this.state.companyId) void categoryAutomat.actions.loadTree();
|
||||
};
|
||||
|
||||
handleRetryArticles = () => {
|
||||
if (this.handle) {
|
||||
void this.handle.reload().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
handleNavigateBack = () => {
|
||||
const { categories, resolvedCategoryId, rootCategoryId } = this.state;
|
||||
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
|
||||
if (breadcrumbs.length < 2) return;
|
||||
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
|
||||
};
|
||||
|
||||
handleArticleClick = (article) => {
|
||||
const path = artikelDetailPath(article);
|
||||
if (path) this.props.navigate(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
companyId,
|
||||
categories,
|
||||
rootCategoryId,
|
||||
treeStatus,
|
||||
error,
|
||||
resolvedCategoryId,
|
||||
articles,
|
||||
loaded,
|
||||
loading,
|
||||
routeError,
|
||||
renameMode,
|
||||
terminals,
|
||||
drafts,
|
||||
moveCategory,
|
||||
} = this.state;
|
||||
|
||||
if (!companyId) {
|
||||
return (
|
||||
<Typography color="text.secondary">
|
||||
Kein Unternehmen ausgewählt.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (treeStatus === 'loading' || treeStatus === 'idle') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (treeStatus === 'failed') {
|
||||
return (
|
||||
<BackendError
|
||||
error={error}
|
||||
onRetry={this.handleRetryTree}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (rootCategoryId == null) {
|
||||
return (
|
||||
<Typography color="error">
|
||||
rootCategoryId fehlt.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
|
||||
const childCategories =
|
||||
this.state.childCategories && this.state.childCategories.length > 0
|
||||
? this.state.childCategories
|
||||
: getCategoryChildren(categories, resolvedCategoryId);
|
||||
const canGoBack =
|
||||
resolvedCategoryId != null &&
|
||||
rootCategoryId != null &&
|
||||
String(resolvedCategoryId) !== String(rootCategoryId) &&
|
||||
categories?.[String(resolvedCategoryId)]?.parentId != null &&
|
||||
categories[String(resolvedCategoryId)].parentId !== 0;
|
||||
|
||||
const showArticlesLoading = loading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CategoryBreadcrumbs
|
||||
crumbs={breadcrumbs}
|
||||
onNavigate={this.navigateToCategoryById}
|
||||
/>
|
||||
|
||||
{routeError && (
|
||||
<BackendError
|
||||
error={routeError}
|
||||
sx={{ mb: 2 }}
|
||||
onRetry={this.handleRetryArticles}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box ref={this.gridRef} sx={gridSx}>
|
||||
{canGoBack && (
|
||||
<BackTile
|
||||
onClick={this.handleNavigateBack}
|
||||
data-id={breadcrumbs.length >= 2 ? String(breadcrumbs[breadcrumbs.length - 2].id) : undefined}
|
||||
className="sortable-item not-sortable"
|
||||
/>
|
||||
)}
|
||||
|
||||
{childCategories.map((item) => {
|
||||
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
|
||||
const draftName =
|
||||
drafts[item.id] !== undefined
|
||||
? drafts[item.id]
|
||||
: label;
|
||||
const editProps = renameMode
|
||||
? {
|
||||
edit: true,
|
||||
'data-id': String(item.id),
|
||||
className: 'sortable-item',
|
||||
label: draftName,
|
||||
terminals,
|
||||
categoryPosterminalIds: item.posterminalIds || [],
|
||||
onTerminalToggle: (terminalId, enabled) => {
|
||||
const action = enabled
|
||||
? posterminalAutomat.actions.enableCategory
|
||||
: posterminalAutomat.actions.disableCategory;
|
||||
void action({ terminalId, categoryId: item.id })
|
||||
.then(() => {
|
||||
categoryAutomat.actions.patchCategoryPosterminalIds({
|
||||
categoryId: item.id,
|
||||
terminalId,
|
||||
enabled,
|
||||
});
|
||||
})
|
||||
.catch(notifyError);
|
||||
},
|
||||
onPlacementClick: () => this.handleOpenMoveCategory(item),
|
||||
onOpen: () => this.navigateToCategoryById(item.id),
|
||||
onPhotoUpload: () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const avifFile = await prepareArticlePhoto(file);
|
||||
await categoryAutomat.actions.uploadCategoryImage({
|
||||
categoryId: item.id,
|
||||
file: avifFile,
|
||||
});
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
onPhotoDelete: () => {
|
||||
void categoryAutomat.actions.deleteCategoryImage(item.id)
|
||||
.catch(notifyError);
|
||||
},
|
||||
nameInput: (
|
||||
<TextField
|
||||
value={draftName}
|
||||
onChange={(e) => this.handleDraftChange(item.id, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void this.handleRenameSubmit(item.id, draftName);
|
||||
}
|
||||
}}
|
||||
variant="standard"
|
||||
size="small"
|
||||
slotProps={{ inputLabel: { shrink: true }, input: { spellCheck: false } }}
|
||||
sx={{
|
||||
width: '100%',
|
||||
'& .MuiInput-underline:before, & .MuiInput-underline:after': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
textAlign: 'center',
|
||||
fontWeight: 600,
|
||||
py: '6px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {
|
||||
label,
|
||||
onClick: () => this.navigateToCategoryById(item.id),
|
||||
};
|
||||
|
||||
return (
|
||||
<CategoryTile
|
||||
key={item.id}
|
||||
thumbHash={item.thumbHash}
|
||||
fullHash={item.fullHash}
|
||||
{...editProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!renameMode && articles.map((item, index) => (
|
||||
<ArticleTile
|
||||
key={item.id}
|
||||
label={item.name != null ? item.name.trim() : undefined}
|
||||
article={item}
|
||||
disabled={!articleSku(item)}
|
||||
onClick={() => this.handleArticleClick(item)}
|
||||
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{renameMode && <NewCategoryTile parentId={resolvedCategoryId} />}
|
||||
{renameMode && loaded && (
|
||||
<NewArticleTile
|
||||
parentId={resolvedCategoryId}
|
||||
onOpenArticle={this.handleArticleClick}
|
||||
sx={{ gridColumnStart: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showArticlesLoading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{moveCategory && (
|
||||
<CategoryPickerDialog
|
||||
open={Boolean(moveCategory)}
|
||||
onClose={this.handleCloseMoveCategory}
|
||||
categories={categories}
|
||||
rootCategoryId={rootCategoryId}
|
||||
title={`„${categoryDisplayName(moveCategory.name, moveCategory.id, rootCategoryId)}“ verschieben nach…`}
|
||||
confirmLabel="Hierher verschieben"
|
||||
mode="single"
|
||||
selectedId={moveCategory.parentId}
|
||||
excludeIds={getCategoryDescendantIds(categories, moveCategory.id)}
|
||||
onSelectCategory={this.handleConfirmMoveCategory}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withCategoryRoute(ArtikelKategorienPage);
|
||||
|
||||
421
src/routes/artikel/lager.jsx
Normal file
421
src/routes/artikel/lager.jsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||
import Sortable from 'sortablejs';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import withNavigate from '../../components/withNavigate';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||
|
||||
function draftsFrom(warehouses) {
|
||||
return Object.fromEntries(warehouses.map((row) => [row.id, row.name]));
|
||||
}
|
||||
|
||||
function getInitialParams(props) {
|
||||
const searchParams =
|
||||
typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
const prefill =
|
||||
props.location?.state?.name ??
|
||||
props.location?.state?.newName ??
|
||||
searchParams?.get('name') ??
|
||||
searchParams?.get('newName') ??
|
||||
'';
|
||||
const returnTo =
|
||||
props.location?.state?.returnTo ??
|
||||
searchParams?.get('returnTo') ??
|
||||
null;
|
||||
return { prefill: prefill.trim(), returnTo };
|
||||
}
|
||||
|
||||
class ArtikelLagerPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { prefill } = getInitialParams(props);
|
||||
const initialWarehouses = warehouseAutomat.state.warehouses || [];
|
||||
this.state = {
|
||||
status: warehouseAutomat.state.status === 'ready' ? 'ready' : 'loading',
|
||||
error: warehouseAutomat.state.error ? backendErrorFrom(warehouseAutomat.state.error) : null,
|
||||
warehouses: initialWarehouses,
|
||||
drafts: draftsFrom(initialWarehouses),
|
||||
newName: prefill,
|
||||
busyKey: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.listRef = createRef();
|
||||
this._sortable = null;
|
||||
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.loadWarehouses = this.loadWarehouses.bind(this);
|
||||
this._initSortable = this._initSortable.bind(this);
|
||||
this._destroySortable = this._destroySortable.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setLastArticleSku(null);
|
||||
const { prefill } = getInitialParams(this.props);
|
||||
if (prefill && !this.state.newName) {
|
||||
this.setState({ newName: prefill });
|
||||
}
|
||||
this._cancelled = false;
|
||||
|
||||
this.unsub = warehouseAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
if (state.status === 'ready') {
|
||||
this.applyWarehouses(state.warehouses);
|
||||
} else if (state.status === 'failed') {
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(state.error) });
|
||||
}
|
||||
});
|
||||
|
||||
void this.loadWarehouses();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.location !== this.props.location) {
|
||||
const { prefill } = getInitialParams(this.props);
|
||||
if (prefill && prefill !== this.state.newName) {
|
||||
this.setState({ newName: prefill });
|
||||
}
|
||||
}
|
||||
|
||||
// Re-initialise Sortable when warehouses change or list mounts
|
||||
if (prevState.warehouses !== this.state.warehouses && this.listRef.current) {
|
||||
this._initSortable();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsub?.();
|
||||
this._destroySortable();
|
||||
}
|
||||
|
||||
_initSortable() {
|
||||
this._destroySortable();
|
||||
const el = this.listRef.current;
|
||||
if (!el) return;
|
||||
|
||||
this._sortable = Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
onEnd: (evt) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (oldIndex === newIndex) return;
|
||||
|
||||
this.setState((prev) => {
|
||||
const next = [...prev.warehouses];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
return { warehouses: next };
|
||||
}, () => {
|
||||
const ids = this.state.warehouses.map((w) => w.id);
|
||||
void this._persistOrder(ids);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_destroySortable() {
|
||||
this._sortable?.destroy();
|
||||
this._sortable = null;
|
||||
}
|
||||
|
||||
async _persistOrder(warehouseIds) {
|
||||
try {
|
||||
await warehouseAutomat.actions.reorder(warehouseIds);
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
// Roll back optimistic reorder
|
||||
void this.refreshFromServer();
|
||||
}
|
||||
}
|
||||
|
||||
applyWarehouses(warehouses) {
|
||||
// Preserve order returned by the server (already sorted by sort/name from backend)
|
||||
this.setState((prev) => {
|
||||
const currentDrafts = prev.drafts || {};
|
||||
const nextDrafts = { ...draftsFrom(warehouses) };
|
||||
// Preserve unsaved in-progress edits for still-existing items if not busy
|
||||
for (const wh of warehouses) {
|
||||
if (prev.busyKey !== wh.id && currentDrafts[wh.id] !== undefined) {
|
||||
nextDrafts[wh.id] = currentDrafts[wh.id];
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
warehouses,
|
||||
drafts: nextDrafts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async loadWarehouses() {
|
||||
if (warehouseAutomat.state.status !== 'ready') {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
}
|
||||
try {
|
||||
const list = await warehouseAutomat.actions.load();
|
||||
if (this._cancelled) return;
|
||||
this.applyWarehouses(list);
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.loadWarehouses();
|
||||
}
|
||||
|
||||
async refreshFromServer() {
|
||||
const list = await warehouseAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
this.applyWarehouses(list);
|
||||
}
|
||||
|
||||
async handleCreate() {
|
||||
const name = this.state.newName.trim();
|
||||
if (!name || this.state.busyKey != null) return;
|
||||
|
||||
const { returnTo } = getInitialParams(this.props);
|
||||
|
||||
this.setState({ busyKey: 'new' });
|
||||
try {
|
||||
const newWarehouse = await warehouseAutomat.actions.create({ name });
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ newName: '', busyKey: null });
|
||||
enqueueSnackbar(`Lager „${newWarehouse.name || name}" angelegt.`, { variant: 'success' });
|
||||
|
||||
if (returnTo && this.props.navigate) {
|
||||
this.props.navigate(returnTo);
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
async handleRename(wh) {
|
||||
const draft = String(this.state.drafts[wh.id] ?? '').trim();
|
||||
if (!draft) {
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [wh.id]: wh.name },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (draft === wh.name || this.state.busyKey != null) return;
|
||||
|
||||
this.setState({ busyKey: wh.id });
|
||||
try {
|
||||
await warehouseAutomat.actions.update(wh.id, { name: draft });
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
enqueueSnackbar('Lager gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState((prev) => ({
|
||||
busyKey: null,
|
||||
drafts: { ...prev.drafts, [wh.id]: wh.name },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(wh) {
|
||||
if (this.state.busyKey != null) return;
|
||||
if (!window.confirm(`Lager „${wh.name}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busyKey: wh.id });
|
||||
try {
|
||||
await warehouseAutomat.actions.remove(wh.id);
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
enqueueSnackbar('Lager gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, warehouses, drafts, newName, busyKey } = this.state;
|
||||
const busy = busyKey != null;
|
||||
|
||||
const { returnTo } = getInitialParams(this.props);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* SortableJS ghost / chosen styling */}
|
||||
<style>{`
|
||||
.sortable-ghost { opacity: 0.35 !important; }
|
||||
.sortable-chosen { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
|
||||
.drag-handle { cursor: grab; touch-action: none; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
`}</style>
|
||||
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={2} minHeight={280} color="#bbf7d0">
|
||||
<Stack spacing={2}>
|
||||
{returnTo ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{ py: 0.5, alignItems: 'center' }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => this.props.navigate?.(returnTo)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{newName
|
||||
? `Lagername „${newName}" übernommen.`
|
||||
: 'Neues Lager anlegen.'}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Reihenfolge per Drag & Drop anpassen.
|
||||
</Typography>
|
||||
|
||||
<List dense disablePadding ref={this.listRef}>
|
||||
{warehouses.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
Noch keine Lager angelegt.
|
||||
</Typography>
|
||||
) : (
|
||||
warehouses.map((wh) => (
|
||||
<ListItem
|
||||
key={wh.id}
|
||||
disableGutters
|
||||
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
|
||||
data-id={wh.id}
|
||||
>
|
||||
<Box
|
||||
className="drag-handle"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'text.disabled',
|
||||
flexShrink: 0,
|
||||
'&:hover': { color: 'text.secondary' },
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</Box>
|
||||
<WarehouseIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label=""
|
||||
value={drafts[wh.id] ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(next) =>
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [wh.id]: next },
|
||||
}))
|
||||
}
|
||||
onBlur={() => void this.handleRename(wh)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.target.blur();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [wh.id]: wh.name },
|
||||
}));
|
||||
event.target.blur();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
aria-label={`„${wh.name}" löschen`}
|
||||
disabled={busy}
|
||||
onClick={() => void this.handleDelete(wh)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Neues Lager"
|
||||
value={newName}
|
||||
disabled={busy}
|
||||
onChange={(next) => this.setState({ newName: next })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void this.handleCreate();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={busy || !newName.trim()}
|
||||
onClick={() => void this.handleCreate()}
|
||||
>
|
||||
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withNavigate(ArtikelLagerPage);
|
||||
293
src/routes/artikel/marken.jsx
Normal file
293
src/routes/artikel/marken.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import CopyrightIcon from '@mui/icons-material/Copyright';
|
||||
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
import brandAutomat from '../../automats/brandAutomat';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { BrandThumb, BrandImageSearchDialog } from '../../components/brand';
|
||||
|
||||
function draftsFrom(brands) {
|
||||
return Object.fromEntries(brands.map((b) => [b.id, b.name]));
|
||||
}
|
||||
|
||||
class ArtikelMarkenPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialBrands = brandAutomat.state.brands || [];
|
||||
this.state = {
|
||||
status: brandAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
|
||||
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
|
||||
brands: initialBrands,
|
||||
drafts: draftsFrom(initialBrands),
|
||||
newName: '',
|
||||
busyKey: null,
|
||||
searchBrand: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.loadBrands = this.loadBrands.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setLastArticleSku(null);
|
||||
this._cancelled = false;
|
||||
|
||||
this.unsub = brandAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
if (state.status === 'succeeded') {
|
||||
this.applyBrands(state.brands);
|
||||
} else if (state.status === 'failed') {
|
||||
this.setState({ status: 'failed', error: backendErrorFrom({ message: state.error }) });
|
||||
}
|
||||
});
|
||||
|
||||
if (brandAutomat.state.status !== 'succeeded') {
|
||||
void this.loadBrands();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
applyBrands(brands) {
|
||||
this.setState((prev) => {
|
||||
const currentDrafts = prev.drafts || {};
|
||||
const nextDrafts = { ...draftsFrom(brands) };
|
||||
for (const b of brands) {
|
||||
if (prev.busyKey !== b.id && currentDrafts[b.id] !== undefined) {
|
||||
nextDrafts[b.id] = currentDrafts[b.id];
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
brands,
|
||||
drafts: nextDrafts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async loadBrands() {
|
||||
if (brandAutomat.state.status !== 'succeeded') {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
}
|
||||
try {
|
||||
const list = await brandAutomat.actions.load();
|
||||
if (this._cancelled) return;
|
||||
this.applyBrands(list);
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.loadBrands();
|
||||
}
|
||||
|
||||
async handleCreate() {
|
||||
const name = this.state.newName.trim();
|
||||
if (!name || this.state.busyKey != null) return;
|
||||
|
||||
this.setState({ busyKey: 'new' });
|
||||
try {
|
||||
const row = await brandAutomat.actions.upsert({ name });
|
||||
if (this._cancelled) return;
|
||||
this.setState({ newName: '', busyKey: null });
|
||||
enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
async handleRename(brand) {
|
||||
const draft = String(this.state.drafts[brand.id] ?? '').trim();
|
||||
if (!draft) {
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [brand.id]: brand.name },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (draft === brand.name || this.state.busyKey != null) return;
|
||||
|
||||
this.setState({ busyKey: brand.id });
|
||||
try {
|
||||
await brandAutomat.actions.upsert({ name: draft });
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
enqueueSnackbar('Marke gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState((prev) => ({
|
||||
busyKey: null,
|
||||
drafts: { ...prev.drafts, [brand.id]: brand.name },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(brand) {
|
||||
if (this.state.busyKey != null) return;
|
||||
if (!window.confirm(`Marke „${brand.name}" wirklich löschen?`)) return;
|
||||
|
||||
this.setState({ busyKey: brand.id });
|
||||
try {
|
||||
await brandAutomat.actions.remove(brand.id);
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
enqueueSnackbar('Marke gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, brands, drafts, newName, busyKey } = this.state;
|
||||
const busy = busyKey != null;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={2} minHeight={280} color="#ddd6fe">
|
||||
<List dense disablePadding>
|
||||
{brands.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
Noch keine Marken angelegt.
|
||||
</Typography>
|
||||
) : (
|
||||
brands.map((b) => (
|
||||
<ListItem
|
||||
key={b.id}
|
||||
disableGutters
|
||||
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
|
||||
>
|
||||
<BrandThumb
|
||||
brand={b}
|
||||
size={22}
|
||||
fallback={<CopyrightIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label=""
|
||||
value={drafts[b.id] ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(next) =>
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [b.id]: next },
|
||||
}))
|
||||
}
|
||||
onBlur={() => void this.handleRename(b)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.target.blur();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [b.id]: b.name },
|
||||
}));
|
||||
event.target.blur();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Tooltip title="Logo suchen">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Logo für „${b.name}" suchen`}
|
||||
disabled={busy}
|
||||
onClick={() => this.setState({ searchBrand: b })}
|
||||
>
|
||||
<ImageSearchIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Löschen">
|
||||
<span>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
aria-label={`„${b.name}" löschen`}
|
||||
disabled={busy}
|
||||
onClick={() => void this.handleDelete(b)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Neue Marke"
|
||||
value={newName}
|
||||
disabled={busy}
|
||||
onChange={(next) => this.setState({ newName: next })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void this.handleCreate();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={busy || !newName.trim()}
|
||||
onClick={() => void this.handleCreate()}
|
||||
>
|
||||
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</PanelTile>
|
||||
|
||||
<BrandImageSearchDialog
|
||||
open={Boolean(this.state.searchBrand)}
|
||||
brand={this.state.searchBrand}
|
||||
onClose={() => this.setState({ searchBrand: null })}
|
||||
/>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ArtikelMarkenPage;
|
||||
594
src/routes/artikel/vorlagen.jsx
Normal file
594
src/routes/artikel/vorlagen.jsx
Normal file
@@ -0,0 +1,594 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import LabelIcon from '@mui/icons-material/Label';
|
||||
import { CanvasEditor } from 'react-canvas-label-editor';
|
||||
import 'react-canvas-label-editor/style.css';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
import { labeltemplate } from '../../api';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
const MIN_HEIGHT_MM = 10;
|
||||
const DEFAULT_HEIGHT_MM = 25;
|
||||
const DEFAULT_DPI = 203;
|
||||
|
||||
function getCanvasWidth(widthClass, dpi) {
|
||||
if (widthClass === '4') {
|
||||
return dpi === 300 ? 1248 : 832;
|
||||
}
|
||||
return dpi === 300 ? 640 : 448;
|
||||
}
|
||||
|
||||
function getMinHeightPx(dpi) {
|
||||
return Math.max(80, Math.round((MIN_HEIGHT_MM * dpi) / 25.4));
|
||||
}
|
||||
|
||||
function mmToPx(mm, dpi) {
|
||||
return Math.round((Number(mm) * dpi) / 25.4);
|
||||
}
|
||||
|
||||
function pxToMm(px, dpi) {
|
||||
return Number(((px / dpi) * 25.4).toFixed(1));
|
||||
}
|
||||
|
||||
class ArtikelVorlagenPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialDpi = DEFAULT_DPI;
|
||||
const initialWidthClass = '2';
|
||||
const initialHeightMm = DEFAULT_HEIGHT_MM;
|
||||
|
||||
this.state = {
|
||||
status: 'loading',
|
||||
error: null,
|
||||
templates: [],
|
||||
// Editor dialog state
|
||||
editorOpen: false,
|
||||
editorLoading: false,
|
||||
editorTemplate: null, // null = new, object = edit
|
||||
editorName: '',
|
||||
editorObjects: [],
|
||||
editorDpi: initialDpi,
|
||||
editorWidthClass: initialWidthClass,
|
||||
editorWidth: getCanvasWidth(initialWidthClass, initialDpi),
|
||||
editorHeightMm: initialHeightMm,
|
||||
editorHeight: mmToPx(initialHeightMm, initialDpi),
|
||||
editorClipboard: null,
|
||||
saving: false,
|
||||
// Delete busy
|
||||
deletingId: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this._currentEditId = null;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleOpenNew = this.handleOpenNew.bind(this);
|
||||
this.handleOpenEdit = this.handleOpenEdit.bind(this);
|
||||
this.handleEditorClose = this.handleEditorClose.bind(this);
|
||||
this.handleEditorSave = this.handleEditorSave.bind(this);
|
||||
this.handleWidthClassChange = this.handleWidthClassChange.bind(this);
|
||||
this.handleDpiChange = this.handleDpiChange.bind(this);
|
||||
this.handleHeightMmChange = this.handleHeightMmChange.bind(this);
|
||||
this.handleHeightMmBlur = this.handleHeightMmBlur.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setLastArticleSku(null);
|
||||
this._cancelled = false;
|
||||
void this.loadTemplates();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
}
|
||||
|
||||
async loadTemplates() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const { labeltemplates } = await labeltemplate.list();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'ready', templates: labeltemplates || [] });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.loadTemplates();
|
||||
}
|
||||
|
||||
handleOpenNew() {
|
||||
this._currentEditId = null;
|
||||
const dpi = DEFAULT_DPI;
|
||||
const widthClass = '2';
|
||||
const width = getCanvasWidth(widthClass, dpi);
|
||||
const heightMm = DEFAULT_HEIGHT_MM;
|
||||
const height = mmToPx(heightMm, dpi);
|
||||
|
||||
this.setState({
|
||||
editorOpen: true,
|
||||
editorLoading: false,
|
||||
editorTemplate: null,
|
||||
editorName: 'Neue Vorlage',
|
||||
editorObjects: [],
|
||||
editorDpi: dpi,
|
||||
editorWidthClass: widthClass,
|
||||
editorWidth: width,
|
||||
editorHeightMm: heightMm,
|
||||
editorHeight: height,
|
||||
});
|
||||
}
|
||||
|
||||
async handleOpenEdit(tmpl) {
|
||||
const fetchId = tmpl.id;
|
||||
this._currentEditId = fetchId;
|
||||
const initialDpi = tmpl.dpi === 300 ? 300 : 203;
|
||||
const initialWidthClass = (tmpl.width || 0) >= 800 ? '4' : '2';
|
||||
const initialWidth = tmpl.width || getCanvasWidth(initialWidthClass, initialDpi);
|
||||
const initialHeight = tmpl.height || mmToPx(DEFAULT_HEIGHT_MM, initialDpi);
|
||||
const initialHeightMm = pxToMm(initialHeight, initialDpi);
|
||||
|
||||
this.setState({
|
||||
editorOpen: true,
|
||||
editorLoading: true,
|
||||
editorTemplate: tmpl,
|
||||
editorName: tmpl.name,
|
||||
editorObjects: [],
|
||||
editorDpi: initialDpi,
|
||||
editorWidthClass: initialWidthClass,
|
||||
editorWidth: initialWidth,
|
||||
editorHeightMm: initialHeightMm,
|
||||
editorHeight: initialHeight,
|
||||
});
|
||||
|
||||
try {
|
||||
const { labeltemplate: full } = await labeltemplate.get(fetchId);
|
||||
if (this._cancelled || this._currentEditId !== fetchId || !this.state.editorOpen) return;
|
||||
const content = full?.content || {};
|
||||
const dpi = full?.dpi === 300 ? 300 : 203;
|
||||
const widthClass = (full?.width || 0) >= 800 ? '4' : '2';
|
||||
const width = full?.width || getCanvasWidth(widthClass, dpi);
|
||||
const height = full?.height || mmToPx(DEFAULT_HEIGHT_MM, dpi);
|
||||
const heightMm = pxToMm(height, dpi);
|
||||
|
||||
this.setState({
|
||||
editorLoading: false,
|
||||
editorTemplate: full,
|
||||
editorName: full.name ?? tmpl.name,
|
||||
editorObjects: content.objects || [],
|
||||
editorDpi: dpi,
|
||||
editorWidthClass: widthClass,
|
||||
editorWidth: width,
|
||||
editorHeightMm: heightMm,
|
||||
editorHeight: height,
|
||||
});
|
||||
} catch (err) {
|
||||
if (this._cancelled || this._currentEditId !== fetchId) return;
|
||||
notifyError(err);
|
||||
this.setState({ editorOpen: false, editorLoading: false, editorTemplate: null });
|
||||
}
|
||||
}
|
||||
|
||||
handleWidthClassChange(event, nextValue) {
|
||||
if (!nextValue) return;
|
||||
const width = getCanvasWidth(nextValue, this.state.editorDpi);
|
||||
this.setState({
|
||||
editorWidthClass: nextValue,
|
||||
editorWidth: width,
|
||||
});
|
||||
}
|
||||
|
||||
handleDpiChange(event, nextValue) {
|
||||
if (!nextValue) return;
|
||||
const dpi = Number(nextValue);
|
||||
const width = getCanvasWidth(this.state.editorWidthClass, dpi);
|
||||
const mm = parseFloat(this.state.editorHeightMm) || DEFAULT_HEIGHT_MM;
|
||||
const minHeightPx = getMinHeightPx(dpi);
|
||||
const height = Math.max(minHeightPx, mmToPx(mm, dpi));
|
||||
this.setState({
|
||||
editorDpi: dpi,
|
||||
editorWidth: width,
|
||||
editorHeight: height,
|
||||
});
|
||||
}
|
||||
|
||||
handleHeightMmChange(event) {
|
||||
const val = event.target.value;
|
||||
const dpi = this.state.editorDpi;
|
||||
const num = parseFloat(val);
|
||||
const minHeightPx = getMinHeightPx(dpi);
|
||||
const height = !isNaN(num) && num > 0
|
||||
? Math.max(minHeightPx, mmToPx(num, dpi))
|
||||
: this.state.editorHeight;
|
||||
this.setState({
|
||||
editorHeightMm: val,
|
||||
editorHeight: height,
|
||||
});
|
||||
}
|
||||
|
||||
handleHeightMmBlur() {
|
||||
const dpi = this.state.editorDpi;
|
||||
const num = parseFloat(this.state.editorHeightMm);
|
||||
const safeMm = !isNaN(num) && num >= MIN_HEIGHT_MM ? num : MIN_HEIGHT_MM;
|
||||
const minHeightPx = getMinHeightPx(dpi);
|
||||
this.setState({
|
||||
editorHeightMm: safeMm,
|
||||
editorHeight: Math.max(minHeightPx, mmToPx(safeMm, dpi)),
|
||||
});
|
||||
}
|
||||
|
||||
handleEditorClose() {
|
||||
if (this.state.saving) return;
|
||||
this._currentEditId = null;
|
||||
this.setState({ editorOpen: false, editorLoading: false, editorTemplate: null });
|
||||
}
|
||||
|
||||
async handleEditorSave() {
|
||||
const {
|
||||
editorTemplate, editorName, editorObjects,
|
||||
editorHeight, editorWidth, editorDpi,
|
||||
} = this.state;
|
||||
const name = editorName.trim();
|
||||
if (!name) return;
|
||||
|
||||
this.setState({ saving: true });
|
||||
const body = {
|
||||
name,
|
||||
width: editorWidth,
|
||||
height: editorHeight,
|
||||
dpi: editorDpi,
|
||||
type: 'zpl',
|
||||
content: { objects: editorObjects },
|
||||
};
|
||||
|
||||
try {
|
||||
if (editorTemplate) {
|
||||
const { labeltemplate: updated } = await labeltemplate.update(editorTemplate.id, body);
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => ({
|
||||
saving: false,
|
||||
editorOpen: false,
|
||||
templates: prev.templates.map((t) => (t.id === updated.id ? updated : t)),
|
||||
}));
|
||||
enqueueSnackbar(`Vorlage „${updated.name}" gespeichert.`, { variant: 'success' });
|
||||
} else {
|
||||
const { labeltemplate: created } = await labeltemplate.create(body);
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => ({
|
||||
saving: false,
|
||||
editorOpen: false,
|
||||
templates: [...prev.templates, created],
|
||||
}));
|
||||
enqueueSnackbar(`Vorlage „${created.name}" angelegt.`, { variant: 'success' });
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ saving: false });
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(tmpl) {
|
||||
if (this.state.deletingId != null) return;
|
||||
if (!window.confirm(`Vorlage „${tmpl.name}" wirklich löschen?`)) return;
|
||||
|
||||
this.setState({ deletingId: tmpl.id });
|
||||
try {
|
||||
await labeltemplate.delete(tmpl.id);
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => ({
|
||||
deletingId: null,
|
||||
templates: prev.templates.filter((t) => t.id !== tmpl.id),
|
||||
}));
|
||||
enqueueSnackbar('Vorlage gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ deletingId: null });
|
||||
}
|
||||
}
|
||||
|
||||
renderEditor() {
|
||||
const {
|
||||
editorOpen, editorLoading, editorTemplate, editorName, editorObjects,
|
||||
editorHeight, editorWidth, editorDpi, editorWidthClass, editorHeightMm,
|
||||
editorClipboard, saving,
|
||||
} = this.state;
|
||||
|
||||
const minHeightPx = getMinHeightPx(editorDpi);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
onClose={this.handleEditorClose}
|
||||
maxWidth={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: '90vw',
|
||||
maxWidth: 1000,
|
||||
bgcolor: 'background.paper',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
{editorTemplate ? `„${editorTemplate.name}" bearbeiten` : 'Neue Vorlage'}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pt: '8px !important' }}>
|
||||
{editorLoading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 10, gap: 2 }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Vorlage wird geladen…
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
size="small"
|
||||
value={editorName}
|
||||
onChange={(e) => this.setState({ editorName: e.target.value })}
|
||||
disabled={saving}
|
||||
sx={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Breite:</Typography>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
value={editorWidthClass}
|
||||
exclusive
|
||||
onChange={this.handleWidthClassChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<ToggleButton value="2">2"</ToggleButton>
|
||||
<ToggleButton value="4">4"</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Auflösung:</Typography>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
value={String(editorDpi)}
|
||||
exclusive
|
||||
onChange={this.handleDpiChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<ToggleButton value="203">203 DPI</ToggleButton>
|
||||
<ToggleButton value="300">300 DPI</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Höhe"
|
||||
size="small"
|
||||
type="number"
|
||||
value={editorHeightMm}
|
||||
onChange={this.handleHeightMmChange}
|
||||
onBlur={this.handleHeightMmBlur}
|
||||
disabled={saving}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">mm</InputAdornment>,
|
||||
}}
|
||||
inputProps={{
|
||||
min: MIN_HEIGHT_MM,
|
||||
max: 300,
|
||||
step: 1,
|
||||
}}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<CanvasEditor
|
||||
width={editorWidth}
|
||||
height={editorHeight}
|
||||
minHeight={minHeightPx}
|
||||
showWidthUI={false}
|
||||
showHeightUI={false}
|
||||
dpi={editorDpi}
|
||||
objects={editorObjects}
|
||||
onChange={(objects) => this.setState({ editorObjects: objects })}
|
||||
clipboard={editorClipboard}
|
||||
onCopy={(obj) => this.setState({ editorClipboard: obj })}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={this.handleEditorClose} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={this.handleEditorSave}
|
||||
disabled={saving || editorLoading || !editorName.trim()}
|
||||
>
|
||||
{saving ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
||||
{saving ? 'Speichert…' : 'Speichern'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, templates, deletingId } = this.state;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={3} color="#dbeafe">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={this.handleOpenNew}
|
||||
>
|
||||
Neue Vorlage
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
py: 6,
|
||||
color: 'text.disabled',
|
||||
}}
|
||||
>
|
||||
<LabelIcon sx={{ fontSize: 48 }} />
|
||||
<Typography variant="body2">Noch keine Vorlagen angelegt.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{templates.map((tmpl) => (
|
||||
<Box
|
||||
key={tmpl.id}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.default',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<Box
|
||||
onClick={() => this.handleOpenEdit(tmpl)}
|
||||
sx={{
|
||||
height: 100,
|
||||
bgcolor: '#f8f8f8',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{tmpl.thumb ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={`data:image/png;base64,${tmpl.thumb}`}
|
||||
alt={tmpl.name}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
imageRendering: 'pixelated',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<LabelIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box sx={{ px: 1.5, py: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ flex: 1, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={tmpl.name}
|
||||
>
|
||||
{tmpl.name}
|
||||
</Typography>
|
||||
<Tooltip title="Bearbeiten">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`„${tmpl.name}" bearbeiten`}
|
||||
onClick={() => this.handleOpenEdit(tmpl)}
|
||||
disabled={deletingId != null}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Löschen">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`„${tmpl.name}" löschen`}
|
||||
onClick={() => void this.handleDelete(tmpl)}
|
||||
disabled={deletingId === tmpl.id}
|
||||
>
|
||||
{deletingId === tmpl.id
|
||||
? <CircularProgress size={16} />
|
||||
: <DeleteOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Dimensions chip */}
|
||||
<Divider />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
>
|
||||
{Number(((tmpl.width / (tmpl.dpi || 203)) * 25.4).toFixed(0))} × {Number(((tmpl.height / (tmpl.dpi || 203)) * 25.4).toFixed(0))} mm ({tmpl.width} × {tmpl.height} px) · {tmpl.dpi || 203} DPI
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
|
||||
{this.renderEditor()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ArtikelVorlagenPage;
|
||||
10
src/routes/auftrag.jsx
Normal file
10
src/routes/auftrag.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { ManifestList } from '../../components/manifest';
|
||||
|
||||
class AuftragAngebotPage extends PureComponent {
|
||||
render() {
|
||||
return <ManifestList kind="quote" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuftragAngebotPage;
|
||||
16
src/routes/auftrag/_quoteId.jsx
Normal file
16
src/routes/auftrag/_quoteId.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { ManifestForm, withManifestId } from '../../components/manifest';
|
||||
|
||||
class AuftragAngebotEditPage extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<ManifestForm
|
||||
kind="quote"
|
||||
mode="edit"
|
||||
docId={this.props.docId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withManifestId('quoteId', AuftragAngebotEditPage);
|
||||
10
src/routes/auftrag/bestellung.jsx
Normal file
10
src/routes/auftrag/bestellung.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { ManifestList } from '../../components/manifest';
|
||||
|
||||
class AuftragBestellungPage extends PureComponent {
|
||||
render() {
|
||||
return <ManifestList kind="order" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuftragBestellungPage;
|
||||
16
src/routes/auftrag/bestellung/_orderId.jsx
Normal file
16
src/routes/auftrag/bestellung/_orderId.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { ManifestForm, withManifestId } from '../../components/manifest';
|
||||
|
||||
class AuftragBestellungEditPage extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<ManifestForm
|
||||
kind="order"
|
||||
mode="edit"
|
||||
docId={this.props.docId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withManifestId('orderId', AuftragBestellungEditPage);
|
||||
10
src/routes/auftrag/bestellung/neu.jsx
Normal file
10
src/routes/auftrag/bestellung/neu.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { ManifestForm } from '../../components/manifest';
|
||||
|
||||
class AuftragBestellungNeuPage extends PureComponent {
|
||||
render() {
|
||||
return <ManifestForm kind="order" mode="create" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuftragBestellungNeuPage;
|
||||
10
src/routes/auftrag/neu.jsx
Normal file
10
src/routes/auftrag/neu.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { ManifestForm } from '../../components/manifest';
|
||||
|
||||
class AuftragAngebotNeuPage extends PureComponent {
|
||||
render() {
|
||||
return <ManifestForm kind="quote" mode="create" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuftragAngebotNeuPage;
|
||||
10
src/routes/infos.jsx
Normal file
10
src/routes/infos.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import Typography from '@mui/material/Typography';
|
||||
import PageLayout from '../../components/PageLayout';
|
||||
|
||||
export default function InfosPage() {
|
||||
return (
|
||||
<PageLayout title="Infos">
|
||||
<Typography color="text.secondary">Infos — Inhalt folgt.</Typography>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
153
src/routes/kunde.jsx
Normal file
153
src/routes/kunde.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import { RecordList } from 'dashgui/lists';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { router } from '../../appRouter';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
function sortEntities(entities) {
|
||||
return [...entities].sort((a, b) => {
|
||||
const byName = String(a.name).localeCompare(String(b.name), 'de');
|
||||
if (byName !== 0) return byName;
|
||||
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '';
|
||||
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(value));
|
||||
}
|
||||
|
||||
class KundeListePage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
status: 'loading',
|
||||
error: null,
|
||||
entities: [],
|
||||
groupsById: {},
|
||||
busyId: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
void this.load();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const [entities, groups] = await Promise.all([
|
||||
this.context.entities ?? this.context.entityPromise,
|
||||
this.context.customergroups ?? this.context.customergroupPromise,
|
||||
]);
|
||||
if (this._cancelled) return;
|
||||
const groupsById = Object.fromEntries(groups.map((row) => [row.id, row]));
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
entities: sortEntities(entities),
|
||||
groupsById,
|
||||
});
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
handleCreate() {
|
||||
void router.navigate('/kunde/neu');
|
||||
}
|
||||
|
||||
handleEdit(entity) {
|
||||
void router.navigate(`/kunde/${entity.id}`);
|
||||
}
|
||||
|
||||
groupName(customergroupId) {
|
||||
return this.state.groupsById[customergroupId]?.name;
|
||||
}
|
||||
|
||||
async handleDelete(entity) {
|
||||
if (this.state.busyId != null) return;
|
||||
const label = [entity.firstName, entity.name].filter(Boolean).join(' ');
|
||||
if (!window.confirm(`Kunde „${label}“ wirklich löschen?`)) return;
|
||||
|
||||
this.setState({ busyId: entity.id });
|
||||
try {
|
||||
await this.context.entity.delete(entity.id);
|
||||
const list = await this.context.updateEntity();
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
entities: sortEntities(list),
|
||||
busyId: null,
|
||||
});
|
||||
enqueueSnackbar('Kunde gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyId: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, entities, groupsById, busyId } = this.state;
|
||||
|
||||
if (status === 'failed') {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'number', label: 'Kundennummer' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'firstName', label: 'Vorname' },
|
||||
{
|
||||
key: 'customergroupId',
|
||||
label: 'Gruppe',
|
||||
render: (row) => this.groupName(row.customergroupId),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Erstellt am',
|
||||
render: (row) => formatDate(row.createdAt),
|
||||
},
|
||||
];
|
||||
|
||||
const label = (row) =>
|
||||
[row.firstName, row.name].filter(Boolean).join(' ') || String(row.number);
|
||||
|
||||
return (
|
||||
<RecordList
|
||||
status={status}
|
||||
error={<BackendError error={error} onRetry={this.handleRetry} />}
|
||||
rows={entities}
|
||||
columns={columns}
|
||||
createLabel="Neuer Kunde"
|
||||
onCreate={this.handleCreate}
|
||||
onEdit={this.handleEdit}
|
||||
onDelete={this.handleDelete}
|
||||
deleteDisabled={busyId != null}
|
||||
emptyMessage="Noch keine Kunden."
|
||||
color="#bfdbfe"
|
||||
getEditLabel={(row) => `„${label(row)}“ bearbeiten`}
|
||||
getDeleteLabel={(row) => `„${label(row)}“ löschen`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default KundeListePage;
|
||||
774
src/routes/kunde/_entityId.jsx
Normal file
774
src/routes/kunde/_entityId.jsx
Normal file
@@ -0,0 +1,774 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import withEntityRoute from './withEntityRoute';
|
||||
|
||||
const ADDRESS_FIELDS = [
|
||||
'addressType',
|
||||
'firstName',
|
||||
'name',
|
||||
'street',
|
||||
'streetNumber',
|
||||
'zip',
|
||||
'city',
|
||||
'county',
|
||||
];
|
||||
|
||||
function emptyAddressDraft() {
|
||||
return {
|
||||
addressType: 'billing',
|
||||
firstName: '',
|
||||
name: '',
|
||||
street: '',
|
||||
streetNumber: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
county: '',
|
||||
};
|
||||
}
|
||||
|
||||
function addressDraftFrom(row) {
|
||||
return {
|
||||
addressType: row.addressType ?? '',
|
||||
firstName: row.firstName ?? '',
|
||||
name: row.name ?? '',
|
||||
street: row.street ?? '',
|
||||
streetNumber: row.streetNumber ?? '',
|
||||
zip: row.zip ?? '',
|
||||
city: row.city ?? '',
|
||||
county: row.county ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function compactAddressBody(draft) {
|
||||
const body = {};
|
||||
for (const key of ADDRESS_FIELDS) {
|
||||
const trimmed = String(draft[key] ?? '').trim();
|
||||
if (key === 'addressType') {
|
||||
body.addressType = trimmed;
|
||||
continue;
|
||||
}
|
||||
body[key] = trimmed === '' ? null : trimmed;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function emptyContactDraft() {
|
||||
return { contactType: 'email', contact: '' };
|
||||
}
|
||||
|
||||
class KundeEditPage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
status: 'loading',
|
||||
error: null,
|
||||
entity: null,
|
||||
groupName: null,
|
||||
name: '',
|
||||
firstName: '',
|
||||
savingEntity: false,
|
||||
addresses: [],
|
||||
addressDrafts: {},
|
||||
newAddress: emptyAddressDraft(),
|
||||
busyAddressKey: null,
|
||||
contacts: [],
|
||||
contactDrafts: {},
|
||||
newContact: emptyContactDraft(),
|
||||
busyContactKey: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleSaveEntity = this.handleSaveEntity.bind(this);
|
||||
this.handleAddAddress = this.handleAddAddress.bind(this);
|
||||
this.handleAddContact = this.handleAddContact.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
void this.load();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.entityId !== this.props.entityId) {
|
||||
void this.load();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
}
|
||||
|
||||
async load() {
|
||||
const { entityId } = this.props;
|
||||
if (entityId == null) {
|
||||
this.setState({
|
||||
status: 'failed',
|
||||
error: backendErrorFrom(new Error('Ungültige Kunden-ID.')),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const [entityRes, addressRes, contactRes, groups] = await Promise.all([
|
||||
this.context.entity.get(entityId),
|
||||
this.context.entity.listAddresses(entityId),
|
||||
this.context.entity.listContacts(entityId),
|
||||
this.context.customergroups ?? this.context.customergroupPromise,
|
||||
]);
|
||||
if (this._cancelled) return;
|
||||
|
||||
requireObject(entityRes, 'GET /api/entity/:id');
|
||||
requireObject(entityRes.entity, 'entity');
|
||||
requireObject(addressRes, 'GET /api/entity/:id/address');
|
||||
requireObject(contactRes, 'GET /api/entity/:id/contact');
|
||||
const addresses = requireArray(addressRes.addresses, 'addresses');
|
||||
const contacts = requireArray(contactRes.contacts, 'contacts');
|
||||
const entity = entityRes.entity;
|
||||
const group = groups.find((g) => g.id === entity.customergroupId);
|
||||
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
entity,
|
||||
groupName: group?.name ?? null,
|
||||
name: entity.name ?? '',
|
||||
firstName: entity.firstName ?? '',
|
||||
addresses,
|
||||
addressDrafts: Object.fromEntries(
|
||||
addresses.map((row) => [row.id, addressDraftFrom(row)]),
|
||||
),
|
||||
contacts,
|
||||
contactDrafts: Object.fromEntries(
|
||||
contacts.map((row) => [
|
||||
row.id,
|
||||
{ contactType: row.contactType, contact: row.contact },
|
||||
]),
|
||||
),
|
||||
newAddress: emptyAddressDraft(),
|
||||
newContact: emptyContactDraft(),
|
||||
});
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async handleSaveEntity() {
|
||||
const { entityId } = this.props;
|
||||
const { name, firstName, savingEntity } = this.state;
|
||||
if (savingEntity || entityId == null) return;
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
notifyError(new Error('Name fehlt.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ savingEntity: true });
|
||||
try {
|
||||
const data = await this.context.entity.update(entityId, {
|
||||
name: trimmedName,
|
||||
firstName: firstName.trim() === '' ? null : firstName.trim(),
|
||||
});
|
||||
requireObject(data, 'PATCH /api/entity/:id');
|
||||
requireObject(data.entity, 'entity');
|
||||
requireDefined(data.entity.id, 'entity.id');
|
||||
await this.context.updateEntity();
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
entity: data.entity,
|
||||
name: data.entity.name ?? '',
|
||||
firstName: data.entity.firstName ?? '',
|
||||
savingEntity: false,
|
||||
});
|
||||
enqueueSnackbar('Kunde gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ savingEntity: false });
|
||||
}
|
||||
}
|
||||
|
||||
setAddressDraft(id, field, value) {
|
||||
this.setState((prev) => ({
|
||||
addressDrafts: {
|
||||
...prev.addressDrafts,
|
||||
[id]: { ...prev.addressDrafts[id], [field]: value },
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async handleSaveAddress(address) {
|
||||
const { entityId } = this.props;
|
||||
const draft = this.state.addressDrafts[address.id];
|
||||
if (!draft || this.state.busyAddressKey != null || entityId == null) return;
|
||||
const body = compactAddressBody(draft);
|
||||
if (!body.addressType) {
|
||||
notifyError(new Error('Adresstyp fehlt.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busyAddressKey: address.id });
|
||||
try {
|
||||
const data = await this.context.entity.updateAddress(
|
||||
entityId,
|
||||
address.id,
|
||||
body,
|
||||
);
|
||||
requireObject(data, 'PATCH /api/entity/:id/address/:id');
|
||||
requireObject(data.address, 'address');
|
||||
const listRes = await this.context.entity.listAddresses(entityId);
|
||||
requireObject(listRes, 'GET /api/entity/:id/address');
|
||||
const addresses = requireArray(listRes.addresses, 'addresses');
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
addresses,
|
||||
addressDrafts: Object.fromEntries(
|
||||
addresses.map((row) => [row.id, addressDraftFrom(row)]),
|
||||
),
|
||||
busyAddressKey: null,
|
||||
});
|
||||
enqueueSnackbar('Adresse gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyAddressKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
async handleAddAddress() {
|
||||
const { entityId } = this.props;
|
||||
const { newAddress, busyAddressKey } = this.state;
|
||||
if (busyAddressKey != null || entityId == null) return;
|
||||
const body = compactAddressBody(newAddress);
|
||||
if (!body.addressType) {
|
||||
notifyError(new Error('Adresstyp fehlt.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busyAddressKey: 'new' });
|
||||
try {
|
||||
const data = await this.context.entity.createAddress(entityId, body);
|
||||
requireObject(data, 'POST /api/entity/:id/address');
|
||||
requireObject(data.address, 'address');
|
||||
requireDefined(data.address.id, 'address.id');
|
||||
const listRes = await this.context.entity.listAddresses(entityId);
|
||||
requireObject(listRes, 'GET /api/entity/:id/address');
|
||||
const addresses = requireArray(listRes.addresses, 'addresses');
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
addresses,
|
||||
addressDrafts: Object.fromEntries(
|
||||
addresses.map((row) => [row.id, addressDraftFrom(row)]),
|
||||
),
|
||||
newAddress: emptyAddressDraft(),
|
||||
busyAddressKey: null,
|
||||
});
|
||||
enqueueSnackbar('Adresse angelegt.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyAddressKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteAddress(address) {
|
||||
const { entityId } = this.props;
|
||||
if (this.state.busyAddressKey != null || entityId == null) return;
|
||||
if (!window.confirm('Adresse wirklich löschen?')) return;
|
||||
|
||||
this.setState({ busyAddressKey: address.id });
|
||||
try {
|
||||
await this.context.entity.deleteAddress(entityId, address.id);
|
||||
const listRes = await this.context.entity.listAddresses(entityId);
|
||||
requireObject(listRes, 'GET /api/entity/:id/address');
|
||||
const addresses = requireArray(listRes.addresses, 'addresses');
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
addresses,
|
||||
addressDrafts: Object.fromEntries(
|
||||
addresses.map((row) => [row.id, addressDraftFrom(row)]),
|
||||
),
|
||||
busyAddressKey: null,
|
||||
});
|
||||
enqueueSnackbar('Adresse gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyAddressKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
setContactDraft(id, field, value) {
|
||||
this.setState((prev) => ({
|
||||
contactDrafts: {
|
||||
...prev.contactDrafts,
|
||||
[id]: { ...prev.contactDrafts[id], [field]: value },
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async handleSaveContact(contact) {
|
||||
const { entityId } = this.props;
|
||||
const draft = this.state.contactDrafts[contact.id];
|
||||
if (!draft || this.state.busyContactKey != null || entityId == null) return;
|
||||
const contactType = String(draft.contactType ?? '').trim();
|
||||
const value = String(draft.contact ?? '').trim();
|
||||
if (!contactType || !value) {
|
||||
notifyError(new Error('Kontakttyp und Wert nötig.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busyContactKey: contact.id });
|
||||
try {
|
||||
const data = await this.context.entity.updateContact(entityId, contact.id, {
|
||||
contactType,
|
||||
contact: value,
|
||||
});
|
||||
requireObject(data, 'PATCH /api/entity/:id/contact/:id');
|
||||
requireObject(data.contact, 'contact');
|
||||
const listRes = await this.context.entity.listContacts(entityId);
|
||||
requireObject(listRes, 'GET /api/entity/:id/contact');
|
||||
const contacts = requireArray(listRes.contacts, 'contacts');
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
contacts,
|
||||
contactDrafts: Object.fromEntries(
|
||||
contacts.map((row) => [
|
||||
row.id,
|
||||
{ contactType: row.contactType, contact: row.contact },
|
||||
]),
|
||||
),
|
||||
busyContactKey: null,
|
||||
});
|
||||
enqueueSnackbar('Kontakt gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyContactKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
async handleAddContact() {
|
||||
const { entityId } = this.props;
|
||||
const { newContact, busyContactKey } = this.state;
|
||||
if (busyContactKey != null || entityId == null) return;
|
||||
const contactType = String(newContact.contactType ?? '').trim();
|
||||
const value = String(newContact.contact ?? '').trim();
|
||||
if (!contactType || !value) {
|
||||
notifyError(new Error('Kontakttyp und Wert nötig.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busyContactKey: 'new' });
|
||||
try {
|
||||
const data = await this.context.entity.createContact(entityId, {
|
||||
contactType,
|
||||
contact: value,
|
||||
});
|
||||
requireObject(data, 'POST /api/entity/:id/contact');
|
||||
requireObject(data.contact, 'contact');
|
||||
requireDefined(data.contact.id, 'contact.id');
|
||||
const listRes = await this.context.entity.listContacts(entityId);
|
||||
requireObject(listRes, 'GET /api/entity/:id/contact');
|
||||
const contacts = requireArray(listRes.contacts, 'contacts');
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
contacts,
|
||||
contactDrafts: Object.fromEntries(
|
||||
contacts.map((row) => [
|
||||
row.id,
|
||||
{ contactType: row.contactType, contact: row.contact },
|
||||
]),
|
||||
),
|
||||
newContact: emptyContactDraft(),
|
||||
busyContactKey: null,
|
||||
});
|
||||
enqueueSnackbar('Kontakt angelegt.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyContactKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteContact(contact) {
|
||||
const { entityId } = this.props;
|
||||
if (this.state.busyContactKey != null || entityId == null) return;
|
||||
if (!window.confirm('Kontakt wirklich löschen?')) return;
|
||||
|
||||
this.setState({ busyContactKey: contact.id });
|
||||
try {
|
||||
await this.context.entity.deleteContact(entityId, contact.id);
|
||||
const listRes = await this.context.entity.listContacts(entityId);
|
||||
requireObject(listRes, 'GET /api/entity/:id/contact');
|
||||
const contacts = requireArray(listRes.contacts, 'contacts');
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
contacts,
|
||||
contactDrafts: Object.fromEntries(
|
||||
contacts.map((row) => [
|
||||
row.id,
|
||||
{ contactType: row.contactType, contact: row.contact },
|
||||
]),
|
||||
),
|
||||
busyContactKey: null,
|
||||
});
|
||||
enqueueSnackbar('Kontakt gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyContactKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
renderAddressFields(draft, onChange, disabled) {
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Adresstyp"
|
||||
required
|
||||
value={draft.addressType}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange('addressType', next)}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Name"
|
||||
value={draft.name}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange('name', next)}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Vorname"
|
||||
value={draft.firstName}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange('firstName', next)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Straße"
|
||||
value={draft.street}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange('street', next)}
|
||||
sx={{ flex: 2 }}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Nr."
|
||||
value={draft.streetNumber}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange('streetNumber', next)}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="PLZ"
|
||||
value={draft.zip}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange('zip', next)}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Ort"
|
||||
value={draft.city}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange('city', next)}
|
||||
sx={{ flex: 2 }}
|
||||
/>
|
||||
</Stack>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Bundesland / County"
|
||||
value={draft.county}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange('county', next)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
status,
|
||||
error,
|
||||
groupName,
|
||||
name,
|
||||
firstName,
|
||||
savingEntity,
|
||||
addresses,
|
||||
addressDrafts,
|
||||
newAddress,
|
||||
busyAddressKey,
|
||||
contacts,
|
||||
contactDrafts,
|
||||
newContact,
|
||||
busyContactKey,
|
||||
} = this.state;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
const addressBusy = busyAddressKey != null;
|
||||
const contactBusy = busyContactKey != null;
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={2} minHeight={200} color="#bfdbfe" title="Stammdaten">
|
||||
<Stack spacing={2}>
|
||||
{groupName != null ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gruppe: {groupName}
|
||||
</Typography>
|
||||
) : null}
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Name"
|
||||
required
|
||||
value={name}
|
||||
disabled={savingEntity}
|
||||
onChange={(next) => this.setState({ name: next })}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Vorname"
|
||||
value={firstName}
|
||||
disabled={savingEntity}
|
||||
onChange={(next) => this.setState({ firstName: next })}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={savingEntity || !name.trim()}
|
||||
onClick={() => void this.handleSaveEntity()}
|
||||
>
|
||||
{savingEntity ? 'Speichern…' : 'Speichern'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={3} minHeight={240} color="#bbf7d0" title="Adressen">
|
||||
<Stack spacing={2}>
|
||||
{addresses.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Noch keine Adressen.
|
||||
</Typography>
|
||||
) : (
|
||||
addresses.map((address) => {
|
||||
const draft = addressDrafts[address.id] ?? addressDraftFrom(address);
|
||||
const busy = busyAddressKey === address.id;
|
||||
return (
|
||||
<Box key={address.id}>
|
||||
{this.renderAddressFields(
|
||||
draft,
|
||||
(field, value) => this.setAddressDraft(address.id, field, value),
|
||||
addressBusy,
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Adresse löschen"
|
||||
disabled={addressBusy}
|
||||
onClick={() => void this.handleDeleteAddress(address)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={addressBusy}
|
||||
onClick={() => void this.handleSaveAddress(address)}
|
||||
>
|
||||
{busy ? '…' : 'Speichern'}
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<Typography variant="subtitle2">Neue Adresse</Typography>
|
||||
{this.renderAddressFields(
|
||||
newAddress,
|
||||
(field, value) =>
|
||||
this.setState((prev) => ({
|
||||
newAddress: { ...prev.newAddress, [field]: value },
|
||||
})),
|
||||
addressBusy,
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={addressBusy || !newAddress.addressType.trim()}
|
||||
onClick={() => void this.handleAddAddress()}
|
||||
>
|
||||
{busyAddressKey === 'new' ? '…' : 'Adresse hinzufügen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={3} minHeight={200} color="#fde68a" title="Kontakte">
|
||||
<Stack spacing={2}>
|
||||
{contacts.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Noch keine Kontakte.
|
||||
</Typography>
|
||||
) : (
|
||||
contacts.map((contact) => {
|
||||
const draft =
|
||||
contactDrafts[contact.id] ?? {
|
||||
contactType: contact.contactType,
|
||||
contact: contact.contact,
|
||||
};
|
||||
const busy = busyContactKey === contact.id;
|
||||
return (
|
||||
<Stack
|
||||
key={contact.id}
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1}
|
||||
sx={{ alignItems: { sm: 'center' } }}
|
||||
>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Typ"
|
||||
fullWidth={false}
|
||||
value={draft.contactType}
|
||||
disabled={contactBusy}
|
||||
onChange={(next) =>
|
||||
this.setContactDraft(contact.id, 'contactType', next)
|
||||
}
|
||||
sx={{ width: 120, flex: '0 0 auto' }}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Kontakt"
|
||||
value={draft.contact}
|
||||
disabled={contactBusy}
|
||||
onChange={(next) =>
|
||||
this.setContactDraft(contact.id, 'contact', next)
|
||||
}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Kontakt löschen"
|
||||
disabled={contactBusy}
|
||||
onClick={() => void this.handleDeleteContact(contact)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={contactBusy}
|
||||
onClick={() => void this.handleSaveContact(contact)}
|
||||
>
|
||||
{busy ? '…' : 'Speichern'}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Typography variant="subtitle2">Neuer Kontakt</Typography>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1}
|
||||
sx={{ alignItems: { sm: 'center' } }}
|
||||
>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Typ"
|
||||
fullWidth={false}
|
||||
value={newContact.contactType}
|
||||
disabled={contactBusy}
|
||||
onChange={(next) =>
|
||||
this.setState((prev) => ({
|
||||
newContact: { ...prev.newContact, contactType: next },
|
||||
}))
|
||||
}
|
||||
sx={{ width: 120, flex: '0 0 auto' }}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Kontakt"
|
||||
value={newContact.contact}
|
||||
disabled={contactBusy}
|
||||
onChange={(next) =>
|
||||
this.setState((prev) => ({
|
||||
newContact: { ...prev.newContact, contact: next },
|
||||
}))
|
||||
}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={
|
||||
contactBusy ||
|
||||
!newContact.contactType.trim() ||
|
||||
!newContact.contact.trim()
|
||||
}
|
||||
onClick={() => void this.handleAddContact()}
|
||||
>
|
||||
{busyContactKey === 'new' ? '…' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withEntityRoute(KundeEditPage);
|
||||
348
src/routes/kunde/gruppen.jsx
Normal file
348
src/routes/kunde/gruppen.jsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import GroupsIcon from '@mui/icons-material/Groups';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
function sortGroups(groups) {
|
||||
return [...groups].sort((a, b) => {
|
||||
const bySort = a.sort - b.sort;
|
||||
if (bySort !== 0) return bySort;
|
||||
const byName = String(a.name).localeCompare(String(b.name), 'de');
|
||||
if (byName !== 0) return byName;
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
function draftsFrom(groups) {
|
||||
return Object.fromEntries(groups.map((row) => [row.id, row.name]));
|
||||
}
|
||||
|
||||
function requireGroupListItem(row, path) {
|
||||
requireObject(row, path);
|
||||
requireDefined(row.id, `${path}.id`);
|
||||
requireDefined(row.name, `${path}.name`);
|
||||
requireDefined(row.sort, `${path}.sort`);
|
||||
requireDefined(row.editable, `${path}.editable`);
|
||||
}
|
||||
|
||||
class KundeGruppenPage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
status: 'loading',
|
||||
error: null,
|
||||
groups: [],
|
||||
drafts: {},
|
||||
newName: '',
|
||||
busyKey: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
void this.load();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
}
|
||||
|
||||
applyGroups(groups) {
|
||||
groups.forEach((row, index) => {
|
||||
requireGroupListItem(row, `customergroups[${index}]`);
|
||||
});
|
||||
const sorted = sortGroups(groups);
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
groups: sorted,
|
||||
drafts: draftsFrom(sorted),
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const list =
|
||||
this.context.customergroups ??
|
||||
(await this.context.customergroupPromise);
|
||||
if (this._cancelled) return;
|
||||
this.applyGroups(list);
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async refreshFromServer() {
|
||||
const list = await this.context.updateCustomergroup();
|
||||
if (this._cancelled) return;
|
||||
this.applyGroups(list);
|
||||
}
|
||||
|
||||
async handleCreate() {
|
||||
const name = this.state.newName.trim();
|
||||
if (!name || this.state.busyKey != null) return;
|
||||
|
||||
this.setState({ busyKey: 'new' });
|
||||
try {
|
||||
const data = await this.context.customergroup.create({ name });
|
||||
requireObject(data, 'POST /api/customergroup');
|
||||
requireObject(data.customergroup, 'customergroup');
|
||||
requireDefined(data.customergroup.id, 'customergroup.id');
|
||||
requireDefined(data.customergroup.sort, 'customergroup.sort');
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ newName: '', busyKey: null });
|
||||
enqueueSnackbar('Kundengruppe angelegt.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
async handleRename(group) {
|
||||
if (!group.editable) return;
|
||||
const draft = String(this.state.drafts[group.id] ?? '').trim();
|
||||
if (!draft) {
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [group.id]: group.name },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (draft === group.name || this.state.busyKey != null) return;
|
||||
|
||||
this.setState({ busyKey: group.id });
|
||||
try {
|
||||
const data = await this.context.customergroup.update(group.id, {
|
||||
name: draft,
|
||||
});
|
||||
requireObject(data, 'PATCH /api/customergroup/:id');
|
||||
requireObject(data.customergroup, 'customergroup');
|
||||
requireDefined(data.customergroup.id, 'customergroup.id');
|
||||
requireDefined(data.customergroup.sort, 'customergroup.sort');
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
enqueueSnackbar('Kundengruppe gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState((prev) => ({
|
||||
busyKey: null,
|
||||
drafts: { ...prev.drafts, [group.id]: group.name },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async handleMove(group, delta) {
|
||||
const { groups, busyKey } = this.state;
|
||||
if (busyKey != null) return;
|
||||
|
||||
const index = groups.findIndex((row) => row.id === group.id);
|
||||
const targetIndex = index + delta;
|
||||
if (index < 0 || targetIndex < 0 || targetIndex >= groups.length) return;
|
||||
|
||||
const next = [...groups];
|
||||
const [row] = next.splice(index, 1);
|
||||
next.splice(targetIndex, 0, row);
|
||||
const customergroupIds = next.map((item) => item.id);
|
||||
|
||||
this.setState({ busyKey: group.id });
|
||||
try {
|
||||
const data = await this.context.customergroup.order({ customergroupIds });
|
||||
requireObject(data, 'PUT /api/customergroup/order');
|
||||
requireArray(data.customergroupIds, 'customergroupIds');
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
try {
|
||||
await this.refreshFromServer();
|
||||
} catch (refreshErr) {
|
||||
notifyError(refreshErr);
|
||||
}
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(group) {
|
||||
if (!group.editable || this.state.busyKey != null) return;
|
||||
if (!window.confirm(`Kundengruppe „${group.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busyKey: group.id });
|
||||
try {
|
||||
await this.context.customergroup.delete(group.id);
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
enqueueSnackbar('Kundengruppe gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, groups, drafts, newName, busyKey } = this.state;
|
||||
const busy = busyKey != null;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={2} minHeight={280} color="#e9d5ff">
|
||||
<Stack spacing={2}>
|
||||
<List dense disablePadding>
|
||||
{groups.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
Noch keine Kundengruppen.
|
||||
</Typography>
|
||||
) : (
|
||||
groups.map((group, index) => {
|
||||
const editable = group.editable === true;
|
||||
return (
|
||||
<ListItem
|
||||
key={group.id}
|
||||
disableGutters
|
||||
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
|
||||
>
|
||||
<GroupsIcon
|
||||
sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label=""
|
||||
value={drafts[group.id] ?? ''}
|
||||
disabled={busy || !editable}
|
||||
onChange={(next) =>
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [group.id]: next },
|
||||
}))
|
||||
}
|
||||
onBlur={() => void this.handleRename(group)}
|
||||
onKeyDown={(event) => {
|
||||
if (!editable) return;
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.target.blur();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.setState((prev) => ({
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[group.id]: group.name,
|
||||
},
|
||||
}));
|
||||
event.target.blur();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`„${group.name}“ nach oben`}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => void this.handleMove(group, -1)}
|
||||
>
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`„${group.name}“ nach unten`}
|
||||
disabled={busy || index >= groups.length - 1}
|
||||
onClick={() => void this.handleMove(group, 1)}
|
||||
>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
aria-label={`„${group.name}“ löschen`}
|
||||
disabled={busy || !editable}
|
||||
onClick={() => void this.handleDelete(group)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Neue Kundengruppe"
|
||||
value={newName}
|
||||
disabled={busy}
|
||||
onChange={(next) => this.setState({ newName: next })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void this.handleCreate();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={busy || !newName.trim()}
|
||||
onClick={() => void this.handleCreate()}
|
||||
>
|
||||
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default KundeGruppenPage;
|
||||
158
src/routes/kunde/neu.jsx
Normal file
158
src/routes/kunde/neu.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { router } from '../../appRouter';
|
||||
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
class KundeNeuPage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
const groups = this.context.customergroups ?? [];
|
||||
this.state = {
|
||||
customergroupId: groups.length > 0 ? groups[0].id : null,
|
||||
number: '',
|
||||
name: '',
|
||||
firstName: '',
|
||||
saving: false,
|
||||
};
|
||||
this.handleGroupChange = this.handleGroupChange.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
void this.loadNumber();
|
||||
}
|
||||
|
||||
async loadNumber() {
|
||||
try {
|
||||
const res = await this.context.entity.nextNumber();
|
||||
requireObject(res, 'GET /api/entity/next-number');
|
||||
requireDefined(res.number, 'number');
|
||||
this.setState({ number: res.number });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
handleGroupChange(event) {
|
||||
const value = event.target.value;
|
||||
this.setState({ customergroupId: value === '' ? null : Number(value) });
|
||||
}
|
||||
|
||||
async handleSave() {
|
||||
if (this.state.saving) return;
|
||||
const { customergroupId, name, firstName } = this.state;
|
||||
const trimmedName = name.trim();
|
||||
const trimmedFirst = firstName.trim();
|
||||
|
||||
if (customergroupId == null) {
|
||||
notifyError(new Error('Kundengruppe fehlt.'));
|
||||
return;
|
||||
}
|
||||
if (!trimmedName) {
|
||||
notifyError(new Error('Name fehlt.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
customergroupId,
|
||||
name: trimmedName,
|
||||
};
|
||||
if (trimmedFirst) body.firstName = trimmedFirst;
|
||||
|
||||
this.setState({ saving: true });
|
||||
try {
|
||||
const data = await this.context.entity.create(body);
|
||||
requireObject(data, 'POST /api/entity');
|
||||
requireObject(data.entity, 'entity');
|
||||
requireDefined(data.entity.id, 'entity.id');
|
||||
await this.context.updateEntity();
|
||||
enqueueSnackbar('Kunde angelegt.', { variant: 'success' });
|
||||
void router.navigate(`/kunde/${data.entity.id}`);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ saving: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { customergroupId, number, name, firstName, saving } = this.state;
|
||||
const groups = this.context.customergroups ?? [];
|
||||
|
||||
const canSave =
|
||||
customergroupId != null && name.trim() !== '' && !saving;
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={2} minHeight={220} color="#bfdbfe" title="Neuer Kunde">
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Kundennummer"
|
||||
value={number}
|
||||
disabled
|
||||
slotProps={{
|
||||
htmlInput: { spellCheck: false, autoComplete: 'off' },
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={customergroupId ?? ''}
|
||||
disabled={saving}
|
||||
onChange={this.handleGroupChange}
|
||||
displayEmpty
|
||||
inputProps={{
|
||||
spellCheck: false,
|
||||
autoComplete: 'off',
|
||||
}}
|
||||
>
|
||||
{groups.map((row) => (
|
||||
<MenuItem key={row.id} value={row.id}>
|
||||
{row.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Name"
|
||||
required
|
||||
value={name}
|
||||
disabled={saving}
|
||||
onChange={(next) => this.setState({ name: next })}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Vorname"
|
||||
value={firstName}
|
||||
disabled={saving}
|
||||
onChange={(next) => this.setState({ firstName: next })}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={!canSave}
|
||||
onClick={() => void this.handleSave()}
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default KundeNeuPage;
|
||||
15
src/routes/lieferung.jsx
Normal file
15
src/routes/lieferung.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { DeliveryList } from '../../components/manifest';
|
||||
import { setLastDeliveryPath } from '../../automats';
|
||||
|
||||
class LieferungListePage extends PureComponent {
|
||||
componentDidMount() {
|
||||
setLastDeliveryPath(null);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <DeliveryList />;
|
||||
}
|
||||
}
|
||||
|
||||
export default LieferungListePage;
|
||||
4
src/routes/lieferung/_deliveryId.jsx
Normal file
4
src/routes/lieferung/_deliveryId.jsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { withManifestId } from '../../components/manifest';
|
||||
import LieferungNeuPage from './neu';
|
||||
|
||||
export default withManifestId('deliveryId', LieferungNeuPage);
|
||||
334
src/routes/lieferung/lieferanten.jsx
Normal file
334
src/routes/lieferung/lieferanten.jsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import StorefrontIcon from '@mui/icons-material/Storefront';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import withNavigate from '../../components/withNavigate';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { setLastDeliveryPath } from '../../automats';
|
||||
import supplierAutomat from '../../automats/supplierAutomat';
|
||||
import { deliveryDraftAutomat } from '../../automats/deliveryEditorAutomat';
|
||||
|
||||
|
||||
function sortSuppliers(suppliers) {
|
||||
return [...suppliers].sort((a, b) =>
|
||||
String(a.name).localeCompare(String(b.name), 'de'),
|
||||
);
|
||||
}
|
||||
|
||||
function draftsFrom(suppliers) {
|
||||
return Object.fromEntries(suppliers.map((row) => [row.id, row.name]));
|
||||
}
|
||||
|
||||
function getInitialParams(props) {
|
||||
const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
const prefill =
|
||||
props.location?.state?.name ??
|
||||
props.location?.state?.newName ??
|
||||
searchParams?.get('name') ??
|
||||
searchParams?.get('newName') ??
|
||||
deliveryDraftAutomat.state.unmatchedSupplierName ??
|
||||
'';
|
||||
const returnTo =
|
||||
props.location?.state?.returnTo ??
|
||||
searchParams?.get('returnTo') ??
|
||||
null;
|
||||
return { prefill: prefill.trim(), returnTo };
|
||||
}
|
||||
|
||||
class LieferungLieferantenPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { prefill } = getInitialParams(props);
|
||||
this.state = {
|
||||
status: 'loading',
|
||||
error: null,
|
||||
suppliers: [],
|
||||
drafts: {},
|
||||
newName: prefill,
|
||||
busyKey: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.loadSuppliers = this.loadSuppliers.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { returnTo, prefill } = getInitialParams(this.props);
|
||||
if (!returnTo) {
|
||||
setLastDeliveryPath(null);
|
||||
}
|
||||
if (prefill && !this.state.newName) {
|
||||
this.setState({ newName: prefill });
|
||||
}
|
||||
this._cancelled = false;
|
||||
void this.loadSuppliers();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.location !== this.props.location) {
|
||||
const { prefill } = getInitialParams(this.props);
|
||||
if (prefill && prefill !== this.state.newName) {
|
||||
this.setState({ newName: prefill });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
}
|
||||
|
||||
applySuppliers(suppliers) {
|
||||
const sorted = sortSuppliers(suppliers);
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
suppliers: sorted,
|
||||
drafts: draftsFrom(sorted),
|
||||
});
|
||||
}
|
||||
|
||||
async loadSuppliers() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const list = await supplierAutomat.actions.load();
|
||||
if (this._cancelled) return;
|
||||
this.applySuppliers(list);
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.loadSuppliers();
|
||||
}
|
||||
|
||||
async refreshFromServer() {
|
||||
const list = await supplierAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
this.applySuppliers(list);
|
||||
}
|
||||
|
||||
async handleCreate() {
|
||||
const name = this.state.newName.trim();
|
||||
if (!name || this.state.busyKey != null) return;
|
||||
|
||||
const { returnTo } = getInitialParams(this.props);
|
||||
|
||||
this.setState({ busyKey: 'new' });
|
||||
try {
|
||||
const newSupplier = await supplierAutomat.actions.create({ name });
|
||||
|
||||
// Assign the new supplier to the active delivery draft & clear unmatched name
|
||||
deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id);
|
||||
deliveryDraftAutomat.actions.setUnmatchedSupplierName(null);
|
||||
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ newName: '', busyKey: null });
|
||||
enqueueSnackbar(`Lieferant „${newSupplier.name || name}“ angelegt.`, { variant: 'success' });
|
||||
|
||||
if (returnTo && this.props.navigate) {
|
||||
this.props.navigate(returnTo);
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async handleRename(supplier) {
|
||||
const draft = String(this.state.drafts[supplier.id] ?? '').trim();
|
||||
if (!draft) {
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [supplier.id]: supplier.name },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (draft === supplier.name || this.state.busyKey != null) return;
|
||||
|
||||
this.setState({ busyKey: supplier.id });
|
||||
try {
|
||||
await supplierAutomat.actions.update(supplier.id, { name: draft });
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
enqueueSnackbar('Lieferant gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState((prev) => ({
|
||||
busyKey: null,
|
||||
drafts: { ...prev.drafts, [supplier.id]: supplier.name },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(supplier) {
|
||||
if (this.state.busyKey != null) return;
|
||||
if (!window.confirm(`Lieferant „${supplier.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busyKey: supplier.id });
|
||||
try {
|
||||
await supplierAutomat.actions.remove(supplier.id);
|
||||
await this.refreshFromServer();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyKey: null });
|
||||
enqueueSnackbar('Lieferant gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, suppliers, drafts, newName, busyKey } = this.state;
|
||||
const busy = busyKey != null;
|
||||
|
||||
const { returnTo } = getInitialParams(this.props);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<BackendError error={error} onRetry={this.handleRetry} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={2} minHeight={280} color="#bbf7d0">
|
||||
<Stack spacing={2}>
|
||||
{returnTo ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{ py: 0.5, alignItems: 'center' }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => this.props.navigate?.(returnTo)}
|
||||
>
|
||||
Zurück zur Lieferung
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{newName
|
||||
? `Lieferantenname „${newName}“ aus Dokument übernommen.`
|
||||
: 'Neuen Lieferanten für die Lieferung anlegen.'}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<List dense disablePadding>
|
||||
{suppliers.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
Noch keine Lieferanten.
|
||||
</Typography>
|
||||
) : (
|
||||
suppliers.map((supplier) => (
|
||||
<ListItem
|
||||
key={supplier.id}
|
||||
disableGutters
|
||||
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
|
||||
>
|
||||
<StorefrontIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label=""
|
||||
value={drafts[supplier.id] ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(next) =>
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [supplier.id]: next },
|
||||
}))
|
||||
}
|
||||
onBlur={() => void this.handleRename(supplier)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.target.blur();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.setState((prev) => ({
|
||||
drafts: { ...prev.drafts, [supplier.id]: supplier.name },
|
||||
}));
|
||||
event.target.blur();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
aria-label={`„${supplier.name}“ löschen`}
|
||||
disabled={busy}
|
||||
onClick={() => void this.handleDelete(supplier)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Neuer Lieferant"
|
||||
value={newName}
|
||||
disabled={busy}
|
||||
onChange={(next) => this.setState({ newName: next })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void this.handleCreate();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={busy || !newName.trim()}
|
||||
onClick={() => void this.handleCreate()}
|
||||
>
|
||||
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withNavigate(LieferungLieferantenPage);
|
||||
|
||||
1602
src/routes/lieferung/neu.jsx
Normal file
1602
src/routes/lieferung/neu.jsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user