diff --git a/src/api/carrier.js b/src/api/carrier.js
new file mode 100644
index 0000000..de7dfbd
--- /dev/null
+++ b/src/api/carrier.js
@@ -0,0 +1,49 @@
+import { http } from './http.js';
+
+/**
+ * Company-scoped carriers / Lieferarten.
+ * Shape: Carrier = { id, name, sort, hasPackages, isReverse }
+ */
+export const carrier = {
+ /** GET /api/carrier → { carriers: Carrier[] } */
+ list() {
+ return http('/api/carrier');
+ },
+
+ /**
+ * POST /api/carrier
+ * Body: { name, sort?, hasPackages?, isReverse? }
+ * → 201 { carrier: Carrier }
+ */
+ create(body) {
+ return http('/api/carrier', { method: 'POST', body });
+ },
+
+ /** GET /api/carrier/:id → 200 { carrier: Carrier } */
+ get(id) {
+ return http(`/api/carrier/${id}`);
+ },
+
+ /**
+ * PATCH /api/carrier/:id
+ * Body: { name?, sort?, hasPackages?, isReverse? }
+ * → 200 { carrier: Carrier }
+ */
+ update(id, body) {
+ return http(`/api/carrier/${id}`, { method: 'PATCH', body });
+ },
+
+ /**
+ * PUT /api/carrier/order
+ * Body: { carrierIds: number[] }
+ * → 200 { carrierIds: number[] }
+ */
+ order(body) {
+ return http('/api/carrier/order', { method: 'PUT', body });
+ },
+
+ /** DELETE /api/carrier/:id → 204 · 409 Conflict if in use */
+ delete(id) {
+ return http(`/api/carrier/${id}`, { method: 'DELETE' });
+ },
+};
diff --git a/src/api/index.js b/src/api/index.js
index a444375..e7a194c 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -32,3 +32,4 @@ export { payment } from './payment.js';
export { account } from './account.js';
export { mcp } from './mcp.js';
export { backup } from './backup.js';
+export { carrier } from './carrier.js';
diff --git a/src/appRouter.jsx b/src/appRouter.jsx
index e16e574..35806fa 100644
--- a/src/appRouter.jsx
+++ b/src/appRouter.jsx
@@ -23,6 +23,7 @@ import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu';
import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId';
import AuftragAusliefernPage from './routes/auftrag/ausliefern';
import AuftragZahlungenPage from './routes/auftrag/zahlungen';
+import AuftragLieferartenPage from './routes/auftrag/lieferarten';
import KundeListePage from './routes/kunde';
import KundeNeuPage from './routes/kunde/neu';
@@ -104,6 +105,7 @@ const routes = [
{ path: 'bestellung/:orderId', element: },
{ path: 'ausliefern', element: },
{ path: 'zahlungen', element: },
+ { path: 'lieferarten', element: },
{ path: ':quoteId', element: },
],
diff --git a/src/automats/carrierAutomat.js b/src/automats/carrierAutomat.js
new file mode 100644
index 0000000..c6a428e
--- /dev/null
+++ b/src/automats/carrierAutomat.js
@@ -0,0 +1,251 @@
+/**
+ * @file carrierAutomat.js
+ * @description Manages cached shipping carriers / Lieferarten (`GET /api/carrier`) for the active company.
+ *
+ * Upstream Triggers:
+ * - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
+ * `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
+ * - `sseAutomat`: Calls `actions.reload()` upon receiving a `'carrier'` SSE event.
+ * - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or mutation actions.
+ */
+
+import { Automat } from 'react-automat';
+import { carrier as carrierApi } from '../api';
+import authAutomat from './authAutomat';
+
+/**
+ * @typedef {Object} Carrier
+ * @property {number} id Carrier identifier.
+ * @property {string} name Carrier name.
+ * @property {number} sort Sort order.
+ * @property {boolean} hasPackages Whether carrier uses package tracking.
+ * @property {boolean} isReverse Whether carrier is used for returns.
+ */
+
+/**
+ * @typedef {Object} CarrierState
+ * @property {Array} carriers Company-scoped carriers.
+ * @property {Array} filteredCarriers Filtered carriers according to active filter query.
+ * @property {string} filter Active search/filter string.
+ * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
+ * @property {Error|null} error Last load error.
+ */
+
+/** @type {CarrierState} */
+const initialState = {
+ carriers: [],
+ filteredCarriers: [],
+ filter: '',
+ status: 'idle',
+ error: null,
+};
+
+let carrierAutomat;
+
+const applyFilter = (carriers, filter) => {
+ const query = String(filter ?? '').trim().toLowerCase();
+ return query ? carriers.filter((c) => String(c.name ?? '').toLowerCase().includes(query)) : carriers;
+};
+
+const updateStateWithCarriers = (carriers, extra = {}) => {
+ const list = Array.isArray(carriers) ? carriers : [];
+ const filteredCarriers = applyFilter(list, carrierAutomat.state.filter);
+ carrierAutomat.setState({ carriers: list, filteredCarriers, ...extra });
+ return list;
+};
+
+/**
+ * Fetches the company-scoped carrier list.
+ *
+ * @returns {Promise>} Carrier list.
+ */
+async function fetchCarriers() {
+ const data = await carrierApi.list();
+ if (Array.isArray(data)) return data;
+ if (data && Array.isArray(data.carriers)) return data.carriers;
+ return [];
+}
+
+/** In-flight `load()` promise so concurrent callers share one request. */
+let inflightLoad = null;
+
+/**
+ * Loads the carrier list once, deduplicating concurrent callers.
+ *
+ * @returns {Promise>} Loaded carriers.
+ */
+function loadCarriers() {
+ if (inflightLoad) return inflightLoad;
+ inflightLoad = (async () => {
+ carrierAutomat.setState({ status: 'loading', error: null });
+ try {
+ const carriers = await fetchCarriers();
+ updateStateWithCarriers(carriers, { status: 'ready', error: null });
+ return carriers;
+ } catch (error) {
+ carrierAutomat.setState({ status: 'failed', error });
+ throw error;
+ } finally {
+ inflightLoad = null;
+ }
+ })();
+ return inflightLoad;
+}
+
+const actions = {
+ /** Sets the active carrier search filter and updates filteredCarriers. */
+ filter(query) {
+ const filter = String(query ?? '');
+ carrierAutomat.setState({
+ filter,
+ filteredCarriers: applyFilter(carrierAutomat.state.carriers, filter),
+ });
+ },
+
+ /**
+ * Loads the carrier list, exposing a loading/failed lifecycle.
+ *
+ * @returns {Promise>} Loaded carriers.
+ */
+ load() {
+ return loadCarriers();
+ },
+
+ /**
+ * Silently re-fetches the carrier list while keeping the current state.
+ *
+ * @returns {Promise>} Fresh carriers.
+ */
+ async reload() {
+ try {
+ const carriers = await fetchCarriers();
+ updateStateWithCarriers(carriers, { status: 'ready', error: null });
+ return carriers;
+ } catch (error) {
+ carrierAutomat.setState({ error });
+ throw error;
+ }
+ },
+
+ /**
+ * Creates a carrier and refreshes the cached list.
+ *
+ * @param {{name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Creation payload.
+ * @returns {Promise} Created carrier.
+ */
+ async create(body) {
+ const data = await carrierApi.create(body);
+ const created = data?.carrier || data;
+ await carrierAutomat.actions.reload();
+ return created;
+ },
+
+ /**
+ * Updates a carrier and refreshes the cached list.
+ *
+ * @param {string|number} id Carrier identifier.
+ * @param {{name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Update payload.
+ * @returns {Promise} Updated carrier.
+ */
+ async update(id, body) {
+ const data = await carrierApi.update(id, body);
+ const updated = data?.carrier || data;
+ await carrierAutomat.actions.reload();
+ return updated;
+ },
+
+ /**
+ * Deletes a carrier and refreshes the cached list.
+ *
+ * @param {string|number} id Carrier identifier.
+ * @returns {Promise}
+ */
+ async remove(id) {
+ await carrierApi.delete(id);
+ await carrierAutomat.actions.reload();
+ },
+
+ /**
+ * Persists a new sort order for all carriers.
+ *
+ * @param {Array} carrierIds IDs in the desired order.
+ * @returns {Promise}
+ */
+ async reorder(carrierIds) {
+ const byId = new Map(carrierAutomat.state.carriers.map((c) => [c.id, c]));
+ const reordered = carrierIds.map((id) => byId.get(id)).filter(Boolean);
+ if (reordered.length === carrierAutomat.state.carriers.length) {
+ updateStateWithCarriers(reordered);
+ }
+ await carrierApi.order({ carrierIds });
+ await carrierAutomat.actions.reload();
+ },
+
+ /**
+ * Replaces the cached list without a round-trip.
+ *
+ * @param {Array} carriers Carriers to cache.
+ * @returns {void}
+ */
+ setCarriers(carriers) {
+ updateStateWithCarriers(carriers, {
+ status: 'ready',
+ error: null,
+ });
+ },
+
+ /**
+ * Forgets the cached list and returns to the initial state.
+ *
+ * @returns {void}
+ */
+ clear() {
+ carrierAutomat.setState({ ...initialState });
+ },
+};
+
+/**
+ * Company-scoped carrier cache. Loads automatically once an authenticated
+ * session with a selected company exists and is cleared when auth is gone.
+ *
+ * @type {Automat}
+ */
+carrierAutomat = new Automat(initialState, actions, { name: 'carrier' });
+
+/**
+ * Derives the cache key from the auth state: the selected company while
+ * authenticated, otherwise `null`.
+ *
+ * @param {import('./authAutomat').AuthState} authState Auth automat state.
+ * @returns {string|null} Cache key.
+ */
+function authKey(authState) {
+ if (authState?.status !== 'authed' || !authState.user) return null;
+ const companyId = authState.user.selectedCompanyId;
+ return companyId == null ? null : String(companyId);
+}
+
+let lastAuthKey;
+
+/**
+ * Loads the carrier list when auth/company becomes available and forgets it
+ * when the session ends.
+ *
+ * @param {import('./authAutomat').AuthState} authState Auth automat state.
+ * @returns {void}
+ */
+function syncWithAuth(authState) {
+ const key = authKey(authState);
+ if (key === lastAuthKey) return;
+ lastAuthKey = key;
+ if (key == null) {
+ carrierAutomat.actions.clear();
+ } else {
+ void carrierAutomat.actions.load().catch(() => {});
+ }
+}
+
+authAutomat.subscribe(syncWithAuth);
+syncWithAuth(authAutomat.state);
+
+export default carrierAutomat;
diff --git a/src/automats/index.js b/src/automats/index.js
index 26d8ac5..d3ebcee 100644
--- a/src/automats/index.js
+++ b/src/automats/index.js
@@ -46,6 +46,7 @@ export {
export { default as supplierAutomat } from './supplierAutomat';
export { default as warehouseAutomat } from './warehouseAutomat';
+export { default as carrierAutomat } from './carrierAutomat';
/** Article search over the derived index (replaces StorageManager.doSearch). */
export {
diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js
index 386f6b7..90fb32b 100644
--- a/src/automats/sseAutomat.js
+++ b/src/automats/sseAutomat.js
@@ -36,6 +36,7 @@ import deliveryAutomat from './deliveryAutomat';
import orderAutomat from './orderAutomat';
import quoteAutomat from './quoteAutomat';
import shipmentAutomat from './shipmentAutomat';
+import carrierAutomat from './carrierAutomat';
/**
@@ -223,6 +224,12 @@ function connect() {
});
});
+ addTrackedListener(es, 'carrier', () => {
+ void carrierAutomat.actions.reload().catch((err) => {
+ console.error('[sse] carrier refresh failed', err);
+ });
+ });
+
addTrackedListener(es, 'attributename', () => {
void attributeAutomat.actions.load().catch((err) => {
console.error('[sse] attributename refresh failed', err);
diff --git a/src/components/carrier/CarrierItem.jsx b/src/components/carrier/CarrierItem.jsx
new file mode 100644
index 0000000..589e35f
--- /dev/null
+++ b/src/components/carrier/CarrierItem.jsx
@@ -0,0 +1,213 @@
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+import ListItem from '@mui/material/ListItem';
+import Switch from '@mui/material/Switch';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import Tooltip from '@mui/material/Tooltip';
+import Typography from '@mui/material/Typography';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
+import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
+import { enqueueSnackbar } from 'notistack';
+import carrierAutomat from '../../automats/carrierAutomat';
+import { notifyError } from '../../utils/notify';
+
+class CarrierItem extends PureComponent {
+ constructor(props) {
+ super(props);
+ const item = props.carrier ?? props.item;
+ this.state = {
+ draft: item?.name ?? '',
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const prevItem = prevProps.carrier ?? prevProps.item;
+ const item = this.props.carrier ?? this.props.item;
+ if (prevItem?.name !== item?.name) {
+ this.setState({ draft: item?.name ?? '' });
+ } else if (prevProps.disabled && !this.props.disabled) {
+ if (this.state.draft !== item?.name) {
+ this.setState({ draft: item?.name ?? '' });
+ }
+ }
+ }
+
+ handleChange = (next) => {
+ this.setState({ draft: next });
+ const item = this.props.carrier ?? this.props.item;
+ this.props.onChange?.(next, item);
+ };
+
+ handleBlur = () => {
+ const item = this.props.carrier ?? this.props.item;
+ if (!item) return;
+ const draft = this.state.draft.trim();
+ if (!draft) {
+ this.setState({ draft: item.name ?? '' });
+ return;
+ }
+ if (draft !== item.name) {
+ void this.updateCarrier(item, { name: draft });
+ }
+ };
+
+ async updateCarrier(item, patch) {
+ try {
+ await carrierAutomat.actions.update(item.id, patch);
+ enqueueSnackbar('Lieferart gespeichert.', { variant: 'success' });
+ } catch (err) {
+ this.setState({ draft: item.name ?? '' });
+ notifyError(err);
+ }
+ }
+
+ handleToggleHasPackages = (e) => {
+ const item = this.props.carrier ?? this.props.item;
+ if (!item) return;
+ const nextVal = e.target.checked;
+ void this.updateCarrier(item, { hasPackages: nextVal });
+ };
+
+ handleToggleIsReverse = (e) => {
+ const item = this.props.carrier ?? this.props.item;
+ if (!item) return;
+ const nextVal = e.target.checked;
+ void this.updateCarrier(item, { isReverse: nextVal });
+ };
+
+ handleKeyDown = (event) => {
+ const item = this.props.carrier ?? this.props.item;
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.target.blur();
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ this.setState({ draft: item?.name ?? '' });
+ event.target.blur();
+ }
+ };
+
+ handleDelete = () => {
+ const item = this.props.carrier ?? this.props.item;
+ if (!item) return;
+ if (!window.confirm(`Lieferart „${item.name}“ wirklich löschen?`)) return;
+ void (async () => {
+ try {
+ await carrierAutomat.actions.remove(item.id);
+ enqueueSnackbar('Lieferart gelöscht.', { variant: 'success' });
+ } catch (err) {
+ notifyError(err);
+ }
+ })();
+ };
+
+ render() {
+ const { disabled, sx } = this.props;
+ const item = this.props.carrier ?? this.props.item;
+ const { draft } = this.state;
+ const name = item?.name ?? '';
+ const hasPackages = Boolean(item?.hasPackages);
+ const isReverse = Boolean(item?.isReverse);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ }
+ label={
+
+ Pakete
+
+ }
+ sx={{ m: 0, gap: 0.5 }}
+ />
+
+
+
+
+ }
+ label={
+
+ Retoure
+
+ }
+ sx={{ m: 0, gap: 0.5 }}
+ />
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default CarrierItem;
diff --git a/src/components/carrier/index.js b/src/components/carrier/index.js
new file mode 100644
index 0000000..ea08121
--- /dev/null
+++ b/src/components/carrier/index.js
@@ -0,0 +1 @@
+export { default as CarrierItem } from './CarrierItem';
diff --git a/src/components/manifest/ShipmentDetailDialog.jsx b/src/components/manifest/ShipmentDetailDialog.jsx
index bb26f4f..bd56183 100644
--- a/src/components/manifest/ShipmentDetailDialog.jsx
+++ b/src/components/manifest/ShipmentDetailDialog.jsx
@@ -31,16 +31,17 @@ import { notifyError } from '../../utils/notify';
import { formatDate } from './manifestConfig';
import orderAutomat from '../../automats/orderAutomat';
import shipmentAutomat from '../../automats/shipmentAutomat';
+import carrierAutomat from '../../automats/carrierAutomat';
import { SURFACE_RAISED, WHITE } from '../../colors';
-const CARRIERS = ['DHL', 'UPS', 'DPD', 'GLS', 'Hermes', 'Spedition', 'Selbstabholung'];
-
// MARK:
class AddPackageDialog extends PureComponent {
constructor(props) {
super(props);
+ const carriers = carrierAutomat.state.carriers || [];
this.state = {
- carrier: 'DHL',
+ carriers,
+ carrierId: carriers[0]?.id ?? '',
trackingCode: '',
type: 'parcel',
state: 'draft',
@@ -49,15 +50,34 @@ class AddPackageDialog extends PureComponent {
this.handleSubmit = this.handleSubmit.bind(this);
}
+ componentDidMount() {
+ this.unsub = carrierAutomat.subscribe(this, (state) => {
+ const carriers = state.carriers || [];
+ const currentId = this.state.carrierId;
+ const valid = carriers.some((c) => c.id === currentId);
+ return {
+ carriers,
+ carrierId: valid ? currentId : (carriers[0]?.id ?? ''),
+ };
+ });
+ if (carrierAutomat.state.status !== 'ready') {
+ void carrierAutomat.actions.load().catch(() => {});
+ }
+ }
+
+ componentWillUnmount() {
+ this.unsub?.();
+ }
+
async handleSubmit() {
const { shipment, onClose, onSuccess } = this.props;
- const { carrier, trackingCode, type, state } = this.state;
- if (!shipment?.id) return;
+ const { carrierId, trackingCode, type, state } = this.state;
+ if (!shipment?.id || !carrierId) return;
this.setState({ saving: true });
try {
await shippingApi.create(shipment.id, {
- carrier: carrier.trim() || undefined,
+ carrierId: Number(carrierId),
trackingCode: trackingCode.trim() || undefined,
type,
state,
@@ -77,7 +97,8 @@ class AddPackageDialog extends PureComponent {
render() {
const { open, onClose, shipment } = this.props;
- const { carrier, trackingCode, type, state, saving } = this.state;
+ const { carriers, carrierId, trackingCode, type, state, saving } = this.state;
+ const canSubmit = Boolean(carrierId) && !saving;
return (
@@ -97,13 +118,21 @@ class AddPackageDialog extends PureComponent {
this.setState({ carrier: e.target.value })}
+ value={carrierId}
+ onChange={(e) => this.setState({ carrierId: e.target.value })}
+ error={carriers.length === 0}
+ helperText={
+ carriers.length === 0
+ ? 'Keine Lieferarten eingerichtet (unter Auftrag → Lieferarten anlegen)'
+ : undefined
+ }
>
- {CARRIERS.map((c) => (
-
+ {carriers.map((c) => (
+
))}
@@ -145,7 +174,7 @@ class AddPackageDialog extends PureComponent {
-
diff --git a/src/nav.js b/src/nav.js
index 57d3dda..4b77d9c 100644
--- a/src/nav.js
+++ b/src/nav.js
@@ -17,6 +17,7 @@ import StorefrontIcon from '@mui/icons-material/Storefront';
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import PaymentsIcon from '@mui/icons-material/Payments';
+import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import LabelIcon from '@mui/icons-material/Label';
import HistoryIcon from '@mui/icons-material/History';
@@ -226,6 +227,13 @@ export const SUB_NAV = {
icon: PaymentsIcon,
help: 'Zahlungseingänge erfassen und Auftragszahlungen überwachen',
},
+ {
+ segment: 'lieferarten',
+ label: 'Lieferarten',
+ title: 'Lieferarten',
+ icon: LocalShippingOutlinedIcon,
+ help: 'Versanddienstleister und Lieferarten (Carriers) verwalten',
+ },
],
'/kunde': [
{
diff --git a/src/routes/auftrag/lieferarten.jsx b/src/routes/auftrag/lieferarten.jsx
new file mode 100644
index 0000000..a47c726
--- /dev/null
+++ b/src/routes/auftrag/lieferarten.jsx
@@ -0,0 +1,270 @@
+import { PureComponent, createRef } from 'react';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import CircularProgress from '@mui/material/CircularProgress';
+import List from '@mui/material/List';
+import Typography from '@mui/material/Typography';
+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 carrierAutomat from '../../automats/carrierAutomat';
+import { CarrierItem } from '../../components/carrier';
+import { GREEN_PANEL } from '../../colors';
+
+class LieferartenSearchField extends PureComponent {
+ constructor(props) {
+ super(props);
+ const initial = carrierAutomat.state.filter || '';
+ this.state = {
+ input: initial,
+ submitting: false,
+ };
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ componentDidMount() {
+ this.unsub = carrierAutomat.subscribe(this, (state) => ({
+ input: state.filter,
+ }));
+ }
+
+ componentWillUnmount() {
+ this.unsub?.();
+ }
+
+ onInputChange(input) {
+ this.setState({ input });
+ carrierAutomat.actions.filter(input);
+ }
+
+ async handleSubmit() {
+ const name = this.state.input.trim();
+ if (!name || this.state.submitting) return;
+
+ this.setState({ submitting: true });
+ try {
+ const row = await carrierAutomat.actions.create({
+ name,
+ hasPackages: true,
+ isReverse: false,
+ });
+ enqueueSnackbar(`Lieferart „${row.name || name}“ angelegt.`, { variant: 'success' });
+ this.setState({ input: '', submitting: false });
+ carrierAutomat.actions.filter('');
+ } catch (err) {
+ this.setState({ submitting: false });
+ notifyError(err);
+ }
+ }
+
+ render() {
+ const { inputRef } = this.props;
+ const { input, submitting } = this.state;
+ const canSubmit = Boolean(input.trim()) && !submitting;
+
+ return (
+
+
+
+ this.onInputChange(next)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ void this.handleSubmit();
+ }
+ }}
+ />
+ void this.handleSubmit()}
+ >
+ Hinzufügen
+
+
+
+
+ );
+ }
+}
+
+class AuftragLieferartenPage extends PureComponent {
+ constructor(props) {
+ super(props);
+ const initialCarriers =
+ carrierAutomat.state.filteredCarriers ?? carrierAutomat.state.carriers ?? [];
+ this.state = {
+ status: carrierAutomat.state.status === 'ready' ? 'ready' : 'loading',
+ error: carrierAutomat.state.error ? backendErrorFrom(carrierAutomat.state.error) : null,
+ carriers: initialCarriers,
+ filter: carrierAutomat.state.filter ?? '',
+ };
+ this.searchRef = createRef();
+ this.listRef = createRef();
+ this._sortable = null;
+ this._cancelled = false;
+ this.handleRetry = this.handleRetry.bind(this);
+ this._initSortable = this._initSortable.bind(this);
+ this._destroySortable = this._destroySortable.bind(this);
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+
+ this.unsub = carrierAutomat.subscribe(this, (state) => ({
+ carriers: state.filteredCarriers ?? state.carriers,
+ filter: state.filter ?? '',
+ status: state.status,
+ error: state.error ? backendErrorFrom(state.error) : null,
+ }));
+
+ if (carrierAutomat.state.status !== 'ready') {
+ void carrierAutomat.actions.load().catch((err) => notifyError(err));
+ } else {
+ this._initSortable();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (
+ prevState.status !== this.state.status ||
+ prevState.carriers !== this.state.carriers ||
+ prevState.filter !== this.state.filter
+ ) {
+ if (this.state.status === 'ready') {
+ this._initSortable();
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ this.unsub?.();
+ this._destroySortable();
+ }
+
+ _initSortable() {
+ this._destroySortable();
+ const el = this.listRef.current;
+ if (!el) return;
+
+ const isFiltered = Boolean(this.state.filter?.trim());
+
+ this._sortable = Sortable.create(el, {
+ handle: '.drag-handle',
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ disabled: isFiltered,
+ onEnd: (evt) => {
+ const { oldIndex, newIndex } = evt;
+ if (oldIndex === newIndex) return;
+
+ this.setState(
+ (prev) => {
+ const next = [...prev.carriers];
+ const [moved] = next.splice(oldIndex, 1);
+ next.splice(newIndex, 0, moved);
+ return { carriers: next };
+ },
+ () => {
+ const ids = this.state.carriers.map((c) => c.id);
+ void this._persistOrder(ids);
+ }
+ );
+ },
+ });
+ }
+
+ _destroySortable() {
+ this._sortable?.destroy();
+ this._sortable = null;
+ }
+
+ async _persistOrder(carrierIds) {
+ try {
+ await carrierAutomat.actions.reorder(carrierIds);
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ void carrierAutomat.actions.reload();
+ }
+ }
+
+ handleRetry() {
+ void carrierAutomat.actions.load().catch((err) => notifyError(err));
+ }
+
+ render() {
+ const { status, error, carriers, filter } = this.state;
+
+ if (status === 'loading' || status === 'idle') {
+ return (
+
+
+
+ );
+ }
+
+ if (status === 'failed') {
+ return ;
+ }
+
+ const trimmedFilter = filter?.trim() ?? '';
+
+ return (
+ <>
+
+
+
+
+ {carriers.length === 0 ? (
+
+ {trimmedFilter
+ ? `Keine Treffer für „${trimmedFilter}“.`
+ : 'Noch keine Lieferarten angelegt.'}
+
+ ) : (
+ carriers.map((c) => (
+
+ ))
+ )}
+
+
+
+ >
+ );
+ }
+}
+
+export { CarrierItem, LieferartenSearchField };
+export default withNavigate(AuftragLieferartenPage);