Add Kundennummer/Nummernkreise settings and settings dialog.

Add number & setting API endpoints, StorageManager caches, and a Settings
dialog (tabs for settings keys and number ranges) reachable from the user
menu. Show Kundennummer and creation date in the Kunde list, and refactor
Neuer Kunde to show an auto-generated number with group select.
This commit is contained in:
seb
2026-08-08 09:03:44 +02:00
parent 354f7e7466
commit 87acdd929e
10 changed files with 731 additions and 89 deletions

View File

@@ -146,6 +146,8 @@ export class ApiClient {
update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }), update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }),
/** DELETE /api/entity/{id} → 204 */ /** DELETE /api/entity/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`), delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`),
/** GET /api/entity/next-number → { number: string } */
nextNumber: () => this.#json('GET', '/api/entity/next-number'),
/** GET /api/entity/{entityId}/address → { addresses } */ /** GET /api/entity/{entityId}/address → { addresses } */
listAddresses: (entityId) => listAddresses: (entityId) =>
this.#json('GET', `/api/entity/${enc(entityId)}/address`), this.#json('GET', `/api/entity/${enc(entityId)}/address`),
@@ -287,6 +289,23 @@ export class ApiClient {
setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`), setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`),
}; };
this.number = {
/** GET /api/number → { numbers: [{ id, type, prefix, padding, next }] } */
list: () => this.#json('GET', '/api/number'),
/** PATCH /api/number/{id} — body: { prefix?, padding?, next? } (only present keys patched) */
update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }),
};
this.setting = {
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
list: (query) => this.#json('GET', '/api/company/setting', { query }),
/** GET /api/company/setting/{key} → { setting: { key, value, frontend } } */
get: (key) => this.#json('GET', `/api/company/setting/${enc(key)}`),
/** PUT /api/company/setting/{key} body { value, frontend } → { setting: { key, value, frontend } } */
upsert: (key, body) =>
this.#json('PUT', `/api/company/setting/${enc(key)}`, { body }),
};
this.image = { this.image = {
/** GET /api/image/{fullHash} → AVIF blob */ /** GET /api/image/{fullHash} → AVIF blob */
get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`), get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`),

View File

@@ -297,6 +297,7 @@ export class StorageManager extends ApiClient {
this.#loadCustomergroups(), this.#loadCustomergroups(),
); );
const entityCache = createCache('GET /entity', () => this.#loadEntities()); const entityCache = createCache('GET /entity', () => this.#loadEntities());
const numberCache = createCache('GET /number', () => this.#loadNumbers());
const quoteCache = createCache('GET /quote', () => this.#loadQuotes()); const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
const orderCache = createCache('GET /order', () => this.#loadOrders()); const orderCache = createCache('GET /order', () => this.#loadOrders());
@@ -352,6 +353,11 @@ export class StorageManager extends ApiClient {
/** Settled GET /entity list, or undefined. */ /** Settled GET /entity list, or undefined. */
entities: { get: () => entityCache.value }, entities: { get: () => entityCache.value },
numberPromise: { get: () => numberCache.promise },
updateNumber: { value: numberCache.update },
/** Settled GET /number list, or undefined. */
numbers: { get: () => numberCache.value },
quotePromise: { get: () => quoteCache.promise }, quotePromise: { get: () => quoteCache.promise },
updateQuote: { value: quoteCache.update }, updateQuote: { value: quoteCache.update },
/** Settled GET /quote list, or undefined. */ /** Settled GET /quote list, or undefined. */
@@ -506,6 +512,12 @@ export class StorageManager extends ApiClient {
return requireArray(data.entities, 'entities'); return requireArray(data.entities, 'entities');
} }
async #loadNumbers() {
const data = await this.number.list();
requireObject(data, 'GET /api/number');
return requireArray(data.numbers, 'numbers');
}
async #loadQuotes() { async #loadQuotes() {
const data = await this.quote.list(); const data = await this.quote.list();
requireObject(data, 'GET /api/quote'); requireObject(data, 'GET /api/quote');

View File

@@ -772,6 +772,11 @@ export const entity = {
return http(`/api/entity/${id}`, { method: 'DELETE' }); return http(`/api/entity/${id}`, { method: 'DELETE' });
}, },
/** GET /api/entity/next-number → { number: string } */
nextNumber() {
return http('/api/entity/next-number');
},
/** GET /api/entity/:entityId/address → { addresses: EntityAddress[] } */ /** GET /api/entity/:entityId/address → { addresses: EntityAddress[] } */
listAddresses(entityId) { listAddresses(entityId) {
return http(`/api/entity/${entityId}/address`); return http(`/api/entity/${entityId}/address`);
@@ -936,6 +941,25 @@ export const posterminal = {
}, },
}; };
export const number = {
/**
* GET /api/number → { numbers: [{ id, type, prefix, padding, next }] }
* Lists the number sequences for the selected company.
*/
list() {
return http('/api/number');
},
/**
* PATCH /api/number/:id
* Body: at least one of { prefix, padding, next } — only present keys are patched.
* → 200 { number: { id, type, prefix, padding, next } }
*/
update(id, body) {
return http(`/api/number/${id}`, { method: 'PATCH', body });
},
};
export const auth = { export const auth = {
async me() { async me() {
try { try {
@@ -997,6 +1021,7 @@ export default {
customergroup, customergroup,
delivery, delivery,
entity, entity,
number,
order, order,
posterminal, posterminal,
quote, quote,

View File

@@ -9,9 +9,11 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutIcon from '@mui/icons-material/Logout';
import BusinessIcon from '@mui/icons-material/Business'; import BusinessIcon from '@mui/icons-material/Business';
import SettingsIcon from '@mui/icons-material/Settings';
import { StorageManagerContext } from '../api/storageManagerContext'; import { StorageManagerContext } from '../api/storageManagerContext';
import { logout } from '../store/authSlice'; import { logout } from '../store/authSlice';
import CompanySettingsDialog from './CompanySettingsDialog'; import CompanySettingsDialog from './CompanySettingsDialog';
import SettingsDialog from './settings/SettingsDialog';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
class UserMenu extends Component { class UserMenu extends Component {
@@ -22,12 +24,15 @@ class UserMenu extends Component {
this.state = { this.state = {
anchorEl: null, anchorEl: null,
settingsOpen: false, settingsOpen: false,
numbersOpen: false,
user: null, user: null,
}; };
this.handleOpenMenu = this.handleOpenMenu.bind(this); this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this); this.handleCloseMenu = this.handleCloseMenu.bind(this);
this.handleOpenSettings = this.handleOpenSettings.bind(this); this.handleOpenSettings = this.handleOpenSettings.bind(this);
this.handleCloseSettings = this.handleCloseSettings.bind(this); this.handleCloseSettings = this.handleCloseSettings.bind(this);
this.handleOpenNumbers = this.handleOpenNumbers.bind(this);
this.handleCloseNumbers = this.handleCloseNumbers.bind(this);
this.handleLogout = this.handleLogout.bind(this); this.handleLogout = this.handleLogout.bind(this);
} }
@@ -60,6 +65,14 @@ class UserMenu extends Component {
this.setState({ settingsOpen: false }); this.setState({ settingsOpen: false });
} }
handleOpenNumbers() {
this.setState({ anchorEl: null, numbersOpen: true });
}
handleCloseNumbers() {
this.setState({ numbersOpen: false });
}
async handleLogout() { async handleLogout() {
this.setState({ anchorEl: null }); this.setState({ anchorEl: null });
try { try {
@@ -71,7 +84,7 @@ class UserMenu extends Component {
} }
render() { render() {
const { user, anchorEl, settingsOpen } = this.state; const { user, anchorEl, settingsOpen, numbersOpen } = this.state;
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
return ( return (
@@ -101,6 +114,12 @@ class UserMenu extends Component {
</ListItemIcon> </ListItemIcon>
<ListItemText>Unternehmen</ListItemText> <ListItemText>Unternehmen</ListItemText>
</MenuItem> </MenuItem>
<MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenNumbers}>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Einstellungen</ListItemText>
</MenuItem>
<MenuItem onClick={this.handleLogout}> <MenuItem onClick={this.handleLogout}>
<ListItemIcon> <ListItemIcon>
<LogoutIcon fontSize="small" /> <LogoutIcon fontSize="small" />
@@ -109,6 +128,7 @@ class UserMenu extends Component {
</MenuItem> </MenuItem>
</Menu> </Menu>
<CompanySettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} /> <CompanySettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
<SettingsDialog open={numbersOpen} onClose={this.handleCloseNumbers} />
</> </>
); );
} }

View File

@@ -0,0 +1,286 @@
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
function parsePadding(value) {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
}
function parseNext(value) {
const n = Number(value);
return Number.isInteger(n) && n >= 0 ? n : null;
}
export default class NumberSettingsPanel extends Component {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
status: 'loading',
error: null,
numbers: [],
// id → { prefix, padding, next, busy, saveError }
drafts: {},
};
this.handleRetry = this.handleRetry.bind(this);
this.handleSave = this.handleSave.bind(this);
}
componentDidMount() {
void this.load();
}
async load() {
this.setState({ status: 'loading', error: null });
try {
const numbers = requireArray(
await (this.context.numbers ?? this.context.numberPromise),
'numbers',
);
const drafts = Object.fromEntries(
numbers.map((row) => [
row.id,
{
prefix: row.prefix ?? '',
padding: row.padding != null ? String(row.padding) : '',
next: row.next != null ? String(row.next) : '',
busy: false,
saveError: null,
},
]),
);
this.setState({ status: 'ready', numbers, drafts });
} catch (err) {
notifyError(err);
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
handleRetry() {
void this.load();
}
handleChange(id, field, value) {
this.setState((prev) => ({
drafts: {
...prev.drafts,
[id]: { ...prev.drafts[id], [field]: value, saveError: null },
},
}));
}
async handleSave(id) {
const draft = this.state.drafts[id];
if (!draft || draft.busy) return;
const row = this.state.numbers.find((r) => r.id === id);
if (!row) return;
const body = {};
let invalid = false;
let errorMessage = null;
if (draft.prefix !== (row.prefix ?? '')) {
body.prefix = draft.prefix;
}
if (draft.padding !== (row.padding != null ? String(row.padding) : '')) {
const padding = parsePadding(draft.padding);
if (padding == null) {
invalid = true;
errorMessage = 'Stellenzahl muss eine positive ganze Zahl sein.';
} else {
body.padding = padding;
}
}
if (draft.next !== (row.next != null ? String(row.next) : '')) {
const next = parseNext(draft.next);
if (next == null) {
invalid = true;
errorMessage = 'Nächste Nummer muss eine nicht-negative ganze Zahl sein.';
} else {
body.next = next;
}
}
if (Object.keys(body).length === 0) {
return;
}
if (invalid) {
this.setState((prev) => ({
drafts: {
...prev.drafts,
[id]: { ...prev.drafts[id], saveError: errorMessage },
},
}));
return;
}
this.setState((prev) => ({
drafts: {
...prev.drafts,
[id]: { ...prev.drafts[id], busy: true, saveError: null },
},
}));
try {
const data = await this.context.number.update(id, body);
requireObject(data, 'PATCH /api/number');
requireObject(data.number, 'number');
const updated = requireDefined(data.number, 'number');
const numbers = this.state.numbers.map((n) => (n.id === id ? { ...n, ...body } : n));
await this.context.updateNumber();
this.setState({
numbers,
drafts: {
...this.state.drafts,
[id]: {
prefix: updated.prefix ?? '',
padding: updated.padding != null ? String(updated.padding) : '',
next: updated.next != null ? String(updated.next) : '',
busy: false,
saveError: null,
},
},
});
enqueueSnackbar('Nummernkreis gespeichert.', { variant: 'success' });
} catch (err) {
if (this._unmounted) return;
this.setState((prev) => ({
drafts: {
...prev.drafts,
[id]: { ...prev.drafts[id], busy: false, saveError: backendErrorFrom(err) },
},
}));
}
}
componentWillUnmount() {
this._unmounted = true;
}
render() {
const { status, error, numbers, drafts } = 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} />;
}
if (numbers.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
Keine Nummernkreise.
</Typography>
);
}
return (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Typ</TableCell>
<TableCell>Präfix</TableCell>
<TableCell>Stellenzahl</TableCell>
<TableCell>Nächste Nummer</TableCell>
<TableCell align="right" />
</TableRow>
</TableHead>
<TableBody>
{numbers.map((row) => {
const draft = drafts[row.id];
if (!draft) return null;
return (
<TableRow key={row.id} hover>
<TableCell>
<Typography variant="body2">{row.type}</Typography>
</TableCell>
<TableCell>
<TextField
size="small"
value={draft.prefix}
disabled={draft.busy}
onChange={(e) => this.handleChange(row.id, 'prefix', e.target.value)}
autoComplete="off"
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={draft.padding}
disabled={draft.busy}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
onChange={(e) => this.handleChange(row.id, 'padding', e.target.value)}
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={draft.next}
disabled={draft.busy}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
onChange={(e) => this.handleChange(row.id, 'next', e.target.value)}
/>
</TableCell>
<TableCell align="right">
<Stack
direction="row"
spacing={0}
justifyContent="flex-end"
alignItems="center"
>
{draft.saveError ? (
<Box sx={{ maxWidth: 220, mr: 1 }}>
<Alert severity="error" sx={{ py: 0 }}>
{typeof draft.saveError === 'string'
? draft.saveError
: draft.saveError.message}
</Alert>
</Box>
) : null}
<IconButton
size="small"
aria-label="Speichern"
color="primary"
disabled={draft.busy}
onClick={() => void this.handleSave(row.id)}
>
<SaveOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
}

View File

@@ -0,0 +1,76 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import NumbersIcon from '@mui/icons-material/Numbers';
import TuneIcon from '@mui/icons-material/Tune';
import NumberSettingsPanel from './NumberSettingsPanel';
import SettingsKeysPanel from './SettingsKeysPanel';
const TAB_KEYS = {
keys: 'keys',
numbers: 'numbers',
};
const TABS = [
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
];
export default class SettingsDialog extends Component {
constructor(props) {
super(props);
this.state = { tab: TAB_KEYS.keys };
this.handleTabChange = this.handleTabChange.bind(this);
}
componentDidUpdate(prevProps) {
const { open } = this.props;
if (!open && prevProps.open) {
this.setState({ tab: TAB_KEYS.keys });
}
}
handleTabChange(_event, value) {
this.setState({ tab: value });
}
render() {
const { open, onClose } = this.props;
const { tab } = this.state;
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Einstellungen</DialogTitle>
<Tabs
value={tab}
onChange={this.handleTabChange}
variant="fullWidth"
sx={{ px: 3 }}
>
{TABS.map((t) => (
<Tab key={t.id} icon={t.icon} iconPosition="start" label={t.label} value={t.id} />
))}
</Tabs>
<DialogContent dividers>
<Box sx={{ display: 'grid' }}>
{TABS.map((t) => (
<Box
key={t.id}
sx={{
gridArea: '1 / 1',
visibility: t.id === tab ? undefined : 'hidden',
}}
>
<t.Panel />
</Box>
))}
</Box>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -0,0 +1,237 @@
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
/**
* Settings that may be set via PUT /api/company/setting/{key}.
* Only these keys are accepted by the backend.
*/
const SETTABLE_KEYS = [
'ui.theme',
'keys.tavily',
'keys.openrouter',
'labelprinter.addr',
'labelprinter.type',
];
const KEY_LABELS = {
'ui.theme': 'UI-Theme',
'keys.tavily': 'Tavily API-Key',
'keys.openrouter': 'OpenRouter API-Key',
'labelprinter.addr': 'Labeldrucker-Adresse',
'labelprinter.type': 'Labeldrucker-Typ',
};
function keyLabel(key) {
return KEY_LABELS[key] ?? key;
}
export default class SettingsKeysPanel extends Component {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
status: 'loading',
error: null,
settings: [],
// key → { value, busy, saveError }
drafts: {},
};
this.handleRetry = this.handleRetry.bind(this);
this.handleSave = this.handleSave.bind(this);
}
componentDidMount() {
void this.load();
}
async load() {
this.setState({ status: 'loading', error: null });
try {
const data = await this.context.setting.list();
requireObject(data, 'GET /api/company/setting');
const settings = requireArray(data.settings, 'settings');
const drafts = Object.fromEntries(
settings.map((s) => [
s.key,
{
value: s.value ?? '',
busy: false,
saveError: null,
},
]),
);
this.setState({ status: 'ready', settings, drafts });
} catch (err) {
notifyError(err);
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
handleRetry() {
void this.load();
}
handleChange(key, field, value) {
this.setState((prev) => ({
drafts: {
...prev.drafts,
[key]: { ...prev.drafts[key], [field]: value, saveError: null },
},
}));
}
async handleSave(key) {
const draft = this.state.drafts[key];
if (!draft || draft.busy) return;
const row = this.state.settings.find((s) => s.key === key);
if (!row) return;
const body = {};
if (draft.value !== (row.value ?? '')) {
body.value = draft.value;
}
if (Object.keys(body).length === 0) {
return;
}
this.setState((prev) => ({
drafts: {
...prev.drafts,
[key]: { ...prev.drafts[key], busy: true, saveError: null },
},
}));
try {
const data = await this.context.setting.upsert(key, body);
requireObject(data, 'PUT /api/company/setting/:key');
requireObject(data.setting, 'setting');
const updated = requireDefined(data.setting, 'setting');
const settings = this.state.settings.map((s) =>
s.key === key ? { ...s, ...body } : s,
);
this.setState({
settings,
drafts: {
...this.state.drafts,
[key]: {
value: updated.value ?? '',
busy: false,
saveError: null,
},
},
});
enqueueSnackbar('Einstellung gespeichert.', { variant: 'success' });
} catch (err) {
if (this._unmounted) return;
this.setState((prev) => ({
drafts: {
...prev.drafts,
[key]: { ...prev.drafts[key], busy: false, saveError: backendErrorFrom(err) },
},
}));
}
}
componentWillUnmount() {
this._unmounted = true;
}
render() {
const { status, error, settings, drafts } = 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} />;
}
if (settings.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
Keine Einstellungen.
</Typography>
);
}
return (
<TableContainer>
<Table size="small">
<TableBody>
{SETTABLE_KEYS.map((key) => {
const row = settings.find((s) => s.key === key);
const draft = row ? drafts[row.key] : null;
if (!row || !draft) return null;
return (
<TableRow key={row.key} hover>
<TableCell>
<Typography variant="body2">{keyLabel(row.key)}</Typography>
</TableCell>
<TableCell>
<TextField
size="small"
value={draft.value}
disabled={draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
autoComplete="off"
/>
</TableCell>
<TableCell align="right">
<Stack
direction="row"
spacing={0}
justifyContent="flex-end"
alignItems="center"
>
{draft.saveError ? (
<Box sx={{ maxWidth: 220, mr: 1 }}>
<Alert severity="error" sx={{ py: 0 }}>
{typeof draft.saveError === 'string'
? draft.saveError
: draft.saveError.message}
</Alert>
</Box>
) : null}
<IconButton
size="small"
aria-label="Speichern"
color="primary"
disabled={draft.busy}
onClick={() => void this.handleSave(row.key)}
>
<SaveOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
}

View File

@@ -0,0 +1,3 @@
export { default as SettingsDialog } from './SettingsDialog';
export { default as SettingsKeysPanel } from './SettingsKeysPanel';
export { default as NumberSettingsPanel } from './NumberSettingsPanel';

View File

@@ -29,6 +29,11 @@ function sortEntities(entities) {
}); });
} }
function formatDate(value) {
if (!value) return '';
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(value));
}
class KundeListePage extends PureComponent { class KundeListePage extends PureComponent {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
@@ -147,9 +152,11 @@ class KundeListePage extends PureComponent {
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Kundennummer</TableCell>
<TableCell>Name</TableCell> <TableCell>Name</TableCell>
<TableCell>Vorname</TableCell> <TableCell>Vorname</TableCell>
<TableCell>Gruppe</TableCell> <TableCell>Gruppe</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell padding="checkbox" /> <TableCell padding="checkbox" />
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -161,9 +168,11 @@ class KundeListePage extends PureComponent {
sx={{ cursor: 'pointer' }} sx={{ cursor: 'pointer' }}
onClick={() => this.handleEdit(row)} onClick={() => this.handleEdit(row)}
> >
<TableCell>{row.number}</TableCell>
<TableCell>{row.name}</TableCell> <TableCell>{row.name}</TableCell>
<TableCell>{row.firstName}</TableCell> <TableCell>{row.firstName}</TableCell>
<TableCell>{this.groupName(row.customergroupId)}</TableCell> <TableCell>{this.groupName(row.customergroupId)}</TableCell>
<TableCell>{formatDate(row.createdAt)}</TableCell>
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}> <TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
<IconButton <IconButton
size="small" size="small"

View File

@@ -1,84 +1,53 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; 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 Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import SingleLineTextEdit from '../../components/SingleLineTextEdit'; import SingleLineTextEdit from '../../components/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from '../../components/panels'; import { PanelTile, PanelTileGrid } from '../../components/panels';
import { StorageManagerContext } from '../../api/storageManagerContext'; import { StorageManagerContext } from '../../api/storageManagerContext';
import { router } from '../../appRouter'; import { router } from '../../appRouter';
import { requireDefined, requireObject } from '../../utils/apiShape'; import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; 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;
});
}
class KundeNeuPage extends PureComponent { class KundeNeuPage extends PureComponent {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
constructor(props) { constructor(props, context) {
super(props); super(props, context);
const groups = this.context.customergroups ?? [];
this.state = { this.state = {
status: 'loading', customergroupId: groups.length > 0 ? groups[0].id : null,
error: null, number: '',
groups: [],
customergroupId: null,
name: '', name: '',
firstName: '', firstName: '',
saving: false, saving: false,
}; };
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleGroupChange = this.handleGroupChange.bind(this); this.handleGroupChange = this.handleGroupChange.bind(this);
this.handleSave = this.handleSave.bind(this); this.handleSave = this.handleSave.bind(this);
} }
componentDidMount() { componentDidMount() {
this._cancelled = false; void this.loadNumber();
void this.loadGroups();
} }
componentWillUnmount() { async loadNumber() {
this._cancelled = true;
}
async loadGroups() {
this.setState({ status: 'loading', error: null });
try { try {
const list = const res = await this.context.entity.nextNumber();
this.context.customergroups ?? requireObject(res, 'GET /api/entity/next-number');
(await this.context.customergroupPromise); requireDefined(res.number, 'number');
if (this._cancelled) return; this.setState({ number: res.number });
this.setState({
status: 'ready',
groups: sortGroups(list),
});
} catch (err) { } catch (err) {
if (this._cancelled) return; notifyError(err);
this.setState({
status: 'failed',
error: backendErrorFrom(err),
});
} }
} }
handleRetry() { handleGroupChange(event) {
void this.loadGroups(); const value = event.target.value;
} this.setState({ customergroupId: value === '' ? null : Number(value) });
handleGroupChange(_event, option) {
this.setState({ customergroupId: option?.id ?? null });
} }
async handleSave() { async handleSave() {
@@ -109,33 +78,18 @@ class KundeNeuPage extends PureComponent {
requireObject(data.entity, 'entity'); requireObject(data.entity, 'entity');
requireDefined(data.entity.id, 'entity.id'); requireDefined(data.entity.id, 'entity.id');
await this.context.updateEntity(); await this.context.updateEntity();
if (this._cancelled) return;
enqueueSnackbar('Kunde angelegt.', { variant: 'success' }); enqueueSnackbar('Kunde angelegt.', { variant: 'success' });
void router.navigate(`/kunde/${data.entity.id}`); void router.navigate(`/kunde/${data.entity.id}`);
} catch (err) { } catch (err) {
if (this._cancelled) return;
notifyError(err); notifyError(err);
this.setState({ saving: false }); this.setState({ saving: false });
} }
} }
render() { render() {
const { const { customergroupId, number, name, firstName, saving } = this.state;
status, const groups = this.context.customergroups ?? [];
error,
groups,
customergroupId,
name,
firstName,
saving,
} = this.state;
if (status === 'failed') {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
const selected =
groups.find((row) => row.id === customergroupId) ?? null;
const canSave = const canSave =
customergroupId != null && name.trim() !== '' && !saving; customergroupId != null && name.trim() !== '' && !saving;
@@ -143,31 +97,32 @@ class KundeNeuPage extends PureComponent {
<PanelTileGrid> <PanelTileGrid>
<PanelTile wide={2} minHeight={220} color="#bfdbfe" title="Neuer Kunde"> <PanelTile wide={2} minHeight={220} color="#bfdbfe" title="Neuer Kunde">
<Stack spacing={2}> <Stack spacing={2}>
<Autocomplete <TextField
size="small" size="small"
options={groups} label="Kundennummer"
value={selected} value={number}
loading={status === 'loading'} disabled
disabled={saving || status === 'loading'} slotProps={{
getOptionLabel={(option) => String(option.name ?? '')} htmlInput: { spellCheck: false, autoComplete: 'off' },
isOptionEqualToValue={(option, value) => option.id === value.id} }}
onChange={this.handleGroupChange}
renderInput={(params) => (
<TextField
{...params}
label="Kundengruppe"
required
slotProps={{
...params.slotProps,
htmlInput: {
...params.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 <SingleLineTextEdit
size="small" size="small"
label="Name" label="Name"