diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 8ab5273..acb8283 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -146,6 +146,8 @@ export class ApiClient { update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }), /** DELETE /api/entity/{id} → 204 */ 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 } */ listAddresses: (entityId) => 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`), }; + 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 = { /** GET /api/image/{fullHash} → AVIF blob */ get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`), diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 7ab71e8..29b9499 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -297,6 +297,7 @@ export class StorageManager extends ApiClient { this.#loadCustomergroups(), ); const entityCache = createCache('GET /entity', () => this.#loadEntities()); + const numberCache = createCache('GET /number', () => this.#loadNumbers()); const quoteCache = createCache('GET /quote', () => this.#loadQuotes()); const orderCache = createCache('GET /order', () => this.#loadOrders()); @@ -352,6 +353,11 @@ export class StorageManager extends ApiClient { /** Settled GET /entity list, or undefined. */ 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 }, updateQuote: { value: quoteCache.update }, /** Settled GET /quote list, or undefined. */ @@ -506,6 +512,12 @@ export class StorageManager extends ApiClient { 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() { const data = await this.quote.list(); requireObject(data, 'GET /api/quote'); diff --git a/src/api/index.js b/src/api/index.js index 3facb18..69245c1 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -772,6 +772,11 @@ export const entity = { 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[] } */ listAddresses(entityId) { 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 = { async me() { try { @@ -997,6 +1021,7 @@ export default { customergroup, delivery, entity, + number, order, posterminal, quote, diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx index 7afac24..96d8821 100644 --- a/src/components/UserMenu.jsx +++ b/src/components/UserMenu.jsx @@ -9,9 +9,11 @@ import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import LogoutIcon from '@mui/icons-material/Logout'; import BusinessIcon from '@mui/icons-material/Business'; +import SettingsIcon from '@mui/icons-material/Settings'; import { StorageManagerContext } from '../api/storageManagerContext'; import { logout } from '../store/authSlice'; import CompanySettingsDialog from './CompanySettingsDialog'; +import SettingsDialog from './settings/SettingsDialog'; import { notifyError } from '../utils/notify'; class UserMenu extends Component { @@ -22,12 +24,15 @@ class UserMenu extends Component { this.state = { anchorEl: null, settingsOpen: false, + numbersOpen: false, user: null, }; this.handleOpenMenu = this.handleOpenMenu.bind(this); this.handleCloseMenu = this.handleCloseMenu.bind(this); this.handleOpenSettings = this.handleOpenSettings.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); } @@ -60,6 +65,14 @@ class UserMenu extends Component { this.setState({ settingsOpen: false }); } + handleOpenNumbers() { + this.setState({ anchorEl: null, numbersOpen: true }); + } + + handleCloseNumbers() { + this.setState({ numbersOpen: false }); + } + async handleLogout() { this.setState({ anchorEl: null }); try { @@ -71,7 +84,7 @@ class UserMenu extends Component { } render() { - const { user, anchorEl, settingsOpen } = this.state; + const { user, anchorEl, settingsOpen, numbersOpen } = this.state; const open = Boolean(anchorEl); return ( @@ -101,6 +114,12 @@ class UserMenu extends Component { Unternehmen + + + + + Einstellungen + @@ -109,6 +128,7 @@ class UserMenu extends Component { + ); } diff --git a/src/components/settings/NumberSettingsPanel.jsx b/src/components/settings/NumberSettingsPanel.jsx new file mode 100644 index 0000000..271833b --- /dev/null +++ b/src/components/settings/NumberSettingsPanel.jsx @@ -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 ( + + + + ); + } + if (status === 'failed') { + return ; + } + if (numbers.length === 0) { + return ( + + Keine Nummernkreise. + + ); + } + + return ( + + + + + Typ + Präfix + Stellenzahl + Nächste Nummer + + + + + {numbers.map((row) => { + const draft = drafts[row.id]; + if (!draft) return null; + return ( + + + {row.type} + + + this.handleChange(row.id, 'prefix', e.target.value)} + autoComplete="off" + /> + + + this.handleChange(row.id, 'padding', e.target.value)} + /> + + + this.handleChange(row.id, 'next', e.target.value)} + /> + + + + {draft.saveError ? ( + + + {typeof draft.saveError === 'string' + ? draft.saveError + : draft.saveError.message} + + + ) : null} + void this.handleSave(row.id)} + > + + + + + + ); + })} + +
+
+ ); + } +} \ No newline at end of file diff --git a/src/components/settings/SettingsDialog.jsx b/src/components/settings/SettingsDialog.jsx new file mode 100644 index 0000000..e298172 --- /dev/null +++ b/src/components/settings/SettingsDialog.jsx @@ -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: , Panel: SettingsKeysPanel }, + { id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: , 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 ( + + Einstellungen + + {TABS.map((t) => ( + + ))} + + + + {TABS.map((t) => ( + + + + ))} + + + + ); + } +} \ No newline at end of file diff --git a/src/components/settings/SettingsKeysPanel.jsx b/src/components/settings/SettingsKeysPanel.jsx new file mode 100644 index 0000000..7e8be2c --- /dev/null +++ b/src/components/settings/SettingsKeysPanel.jsx @@ -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 ( + + + + ); + } + if (status === 'failed') { + return ; + } + if (settings.length === 0) { + return ( + + Keine Einstellungen. + + ); + } + + return ( + + + + {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 ( + + + {keyLabel(row.key)} + + + this.handleChange(row.key, 'value', e.target.value)} + autoComplete="off" + /> + + + + {draft.saveError ? ( + + + {typeof draft.saveError === 'string' + ? draft.saveError + : draft.saveError.message} + + + ) : null} + void this.handleSave(row.key)} + > + + + + + + ); + })} + +
+
+ ); + } +} \ No newline at end of file diff --git a/src/components/settings/index.js b/src/components/settings/index.js new file mode 100644 index 0000000..df96d02 --- /dev/null +++ b/src/components/settings/index.js @@ -0,0 +1,3 @@ +export { default as SettingsDialog } from './SettingsDialog'; +export { default as SettingsKeysPanel } from './SettingsKeysPanel'; +export { default as NumberSettingsPanel } from './NumberSettingsPanel'; \ No newline at end of file diff --git a/src/pages/kunde/index.jsx b/src/pages/kunde/index.jsx index f50d230..592df04 100644 --- a/src/pages/kunde/index.jsx +++ b/src/pages/kunde/index.jsx @@ -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 { static contextType = StorageManagerContext; @@ -147,9 +152,11 @@ class KundeListePage extends PureComponent { + Kundennummer Name Vorname Gruppe + Erstellt am @@ -161,9 +168,11 @@ class KundeListePage extends PureComponent { sx={{ cursor: 'pointer' }} onClick={() => this.handleEdit(row)} > + {row.number}{row.name}{row.firstName}{this.groupName(row.customergroupId)} + {formatDate(row.createdAt)} e.stopPropagation()}> { - 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 { static contextType = StorageManagerContext; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); + const groups = this.context.customergroups ?? []; this.state = { - status: 'loading', - error: null, - groups: [], - customergroupId: null, + customergroupId: groups.length > 0 ? groups[0].id : null, + number: '', name: '', firstName: '', saving: false, }; - this._cancelled = false; - this.handleRetry = this.handleRetry.bind(this); this.handleGroupChange = this.handleGroupChange.bind(this); this.handleSave = this.handleSave.bind(this); } componentDidMount() { - this._cancelled = false; - void this.loadGroups(); + void this.loadNumber(); } - componentWillUnmount() { - this._cancelled = true; - } - - async loadGroups() { - this.setState({ status: 'loading', error: null }); + async loadNumber() { try { - const list = - this.context.customergroups ?? - (await this.context.customergroupPromise); - if (this._cancelled) return; - this.setState({ - status: 'ready', - groups: sortGroups(list), - }); + 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) { - if (this._cancelled) return; - this.setState({ - status: 'failed', - error: backendErrorFrom(err), - }); + notifyError(err); } } - handleRetry() { - void this.loadGroups(); - } - - handleGroupChange(_event, option) { - this.setState({ customergroupId: option?.id ?? null }); + handleGroupChange(event) { + const value = event.target.value; + this.setState({ customergroupId: value === '' ? null : Number(value) }); } async handleSave() { @@ -109,33 +78,18 @@ class KundeNeuPage extends PureComponent { requireObject(data.entity, 'entity'); requireDefined(data.entity.id, 'entity.id'); await this.context.updateEntity(); - if (this._cancelled) return; enqueueSnackbar('Kunde angelegt.', { variant: 'success' }); void router.navigate(`/kunde/${data.entity.id}`); } catch (err) { - if (this._cancelled) return; notifyError(err); this.setState({ saving: false }); } } render() { - const { - status, - error, - groups, - customergroupId, - name, - firstName, - saving, - } = this.state; + const { customergroupId, number, name, firstName, saving } = this.state; + const groups = this.context.customergroups ?? []; - if (status === 'failed') { - return ; - } - - const selected = - groups.find((row) => row.id === customergroupId) ?? null; const canSave = customergroupId != null && name.trim() !== '' && !saving; @@ -143,31 +97,32 @@ class KundeNeuPage extends PureComponent { - String(option.name ?? '')} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={this.handleGroupChange} - renderInput={(params) => ( - - )} + label="Kundennummer" + value={number} + disabled + slotProps={{ + htmlInput: { spellCheck: false, autoComplete: 'off' }, + }} /> +