u
This commit is contained in:
49
src/api/carrier.js
Normal file
49
src/api/carrier.js
Normal file
@@ -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' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -32,3 +32,4 @@ export { payment } from './payment.js';
|
|||||||
export { account } from './account.js';
|
export { account } from './account.js';
|
||||||
export { mcp } from './mcp.js';
|
export { mcp } from './mcp.js';
|
||||||
export { backup } from './backup.js';
|
export { backup } from './backup.js';
|
||||||
|
export { carrier } from './carrier.js';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu';
|
|||||||
import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId';
|
import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId';
|
||||||
import AuftragAusliefernPage from './routes/auftrag/ausliefern';
|
import AuftragAusliefernPage from './routes/auftrag/ausliefern';
|
||||||
import AuftragZahlungenPage from './routes/auftrag/zahlungen';
|
import AuftragZahlungenPage from './routes/auftrag/zahlungen';
|
||||||
|
import AuftragLieferartenPage from './routes/auftrag/lieferarten';
|
||||||
|
|
||||||
import KundeListePage from './routes/kunde';
|
import KundeListePage from './routes/kunde';
|
||||||
import KundeNeuPage from './routes/kunde/neu';
|
import KundeNeuPage from './routes/kunde/neu';
|
||||||
@@ -104,6 +105,7 @@ const routes = [
|
|||||||
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
|
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
|
||||||
{ path: 'ausliefern', element: <AuftragAusliefernPage /> },
|
{ path: 'ausliefern', element: <AuftragAusliefernPage /> },
|
||||||
{ path: 'zahlungen', element: <AuftragZahlungenPage /> },
|
{ path: 'zahlungen', element: <AuftragZahlungenPage /> },
|
||||||
|
{ path: 'lieferarten', element: <AuftragLieferartenPage /> },
|
||||||
{ path: ':quoteId', element: <AuftragAngebotEditPage /> },
|
{ path: ':quoteId', element: <AuftragAngebotEditPage /> },
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|||||||
251
src/automats/carrierAutomat.js
Normal file
251
src/automats/carrierAutomat.js
Normal file
@@ -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<Carrier>} carriers Company-scoped carriers.
|
||||||
|
* @property {Array<Carrier>} 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<Array<Carrier>>} 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<Array<Carrier>>} 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<Array<Carrier>>} Loaded carriers.
|
||||||
|
*/
|
||||||
|
load() {
|
||||||
|
return loadCarriers();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silently re-fetches the carrier list while keeping the current state.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<Carrier>>} 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<Carrier>} 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<Carrier>} 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<void>}
|
||||||
|
*/
|
||||||
|
async remove(id) {
|
||||||
|
await carrierApi.delete(id);
|
||||||
|
await carrierAutomat.actions.reload();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a new sort order for all carriers.
|
||||||
|
*
|
||||||
|
* @param {Array<string|number>} carrierIds IDs in the desired order.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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<Carrier>} 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<CarrierState>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
@@ -46,6 +46,7 @@ export {
|
|||||||
|
|
||||||
export { default as supplierAutomat } from './supplierAutomat';
|
export { default as supplierAutomat } from './supplierAutomat';
|
||||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||||
|
export { default as carrierAutomat } from './carrierAutomat';
|
||||||
|
|
||||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import deliveryAutomat from './deliveryAutomat';
|
|||||||
import orderAutomat from './orderAutomat';
|
import orderAutomat from './orderAutomat';
|
||||||
import quoteAutomat from './quoteAutomat';
|
import quoteAutomat from './quoteAutomat';
|
||||||
import shipmentAutomat from './shipmentAutomat';
|
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', () => {
|
addTrackedListener(es, 'attributename', () => {
|
||||||
void attributeAutomat.actions.load().catch((err) => {
|
void attributeAutomat.actions.load().catch((err) => {
|
||||||
console.error('[sse] attributename refresh failed', err);
|
console.error('[sse] attributename refresh failed', err);
|
||||||
|
|||||||
213
src/components/carrier/CarrierItem.jsx
Normal file
213
src/components/carrier/CarrierItem.jsx
Normal file
@@ -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 (
|
||||||
|
<ListItem
|
||||||
|
disableGutters
|
||||||
|
data-id={item?.id}
|
||||||
|
sx={{
|
||||||
|
gap: 1,
|
||||||
|
py: 0.75,
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
flexWrap: { xs: 'wrap', sm: 'nowrap' },
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className="drag-handle"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: 'text.disabled',
|
||||||
|
cursor: disabled ? 'default' : 'grab',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
'&:hover': disabled ? undefined : { color: 'text.secondary' },
|
||||||
|
transition: 'color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DragIndicatorIcon sx={{ fontSize: 24, pointerEvents: 'none' }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label=""
|
||||||
|
value={draft}
|
||||||
|
disabled={disabled || !item}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
onBlur={this.handleBlur}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
sx={{ flex: 1, minWidth: 140 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexShrink: 0 }}>
|
||||||
|
<Tooltip title="Paketverfolgung / Tracking für Sendungen aktivieren">
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={hasPackages}
|
||||||
|
disabled={disabled || !item}
|
||||||
|
onChange={this.handleToggleHasPackages}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" sx={{ userSelect: 'none', fontWeight: 500 }}>
|
||||||
|
Pakete
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ m: 0, gap: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Für Retouren / Rücksendungen verwenden">
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={isReverse}
|
||||||
|
disabled={disabled || !item}
|
||||||
|
onChange={this.handleToggleIsReverse}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" sx={{ userSelect: 'none', fontWeight: 500 }}>
|
||||||
|
Retoure
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ m: 0, gap: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label={`„${name}“ löschen`}
|
||||||
|
disabled={disabled || !item}
|
||||||
|
onClick={this.handleDelete}
|
||||||
|
sx={{ width: 40, height: 40, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
|
||||||
|
</IconButton>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CarrierItem;
|
||||||
1
src/components/carrier/index.js
Normal file
1
src/components/carrier/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as CarrierItem } from './CarrierItem';
|
||||||
@@ -31,16 +31,17 @@ import { notifyError } from '../../utils/notify';
|
|||||||
import { formatDate } from './manifestConfig';
|
import { formatDate } from './manifestConfig';
|
||||||
import orderAutomat from '../../automats/orderAutomat';
|
import orderAutomat from '../../automats/orderAutomat';
|
||||||
import shipmentAutomat from '../../automats/shipmentAutomat';
|
import shipmentAutomat from '../../automats/shipmentAutomat';
|
||||||
|
import carrierAutomat from '../../automats/carrierAutomat';
|
||||||
import { SURFACE_RAISED, WHITE } from '../../colors';
|
import { SURFACE_RAISED, WHITE } from '../../colors';
|
||||||
|
|
||||||
const CARRIERS = ['DHL', 'UPS', 'DPD', 'GLS', 'Hermes', 'Spedition', 'Selbstabholung'];
|
|
||||||
|
|
||||||
// MARK: <AddPackageDialog/>
|
// MARK: <AddPackageDialog/>
|
||||||
class AddPackageDialog extends PureComponent {
|
class AddPackageDialog extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
const carriers = carrierAutomat.state.carriers || [];
|
||||||
this.state = {
|
this.state = {
|
||||||
carrier: 'DHL',
|
carriers,
|
||||||
|
carrierId: carriers[0]?.id ?? '',
|
||||||
trackingCode: '',
|
trackingCode: '',
|
||||||
type: 'parcel',
|
type: 'parcel',
|
||||||
state: 'draft',
|
state: 'draft',
|
||||||
@@ -49,15 +50,34 @@ class AddPackageDialog extends PureComponent {
|
|||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
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() {
|
async handleSubmit() {
|
||||||
const { shipment, onClose, onSuccess } = this.props;
|
const { shipment, onClose, onSuccess } = this.props;
|
||||||
const { carrier, trackingCode, type, state } = this.state;
|
const { carrierId, trackingCode, type, state } = this.state;
|
||||||
if (!shipment?.id) return;
|
if (!shipment?.id || !carrierId) return;
|
||||||
|
|
||||||
this.setState({ saving: true });
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
await shippingApi.create(shipment.id, {
|
await shippingApi.create(shipment.id, {
|
||||||
carrier: carrier.trim() || undefined,
|
carrierId: Number(carrierId),
|
||||||
trackingCode: trackingCode.trim() || undefined,
|
trackingCode: trackingCode.trim() || undefined,
|
||||||
type,
|
type,
|
||||||
state,
|
state,
|
||||||
@@ -77,7 +97,8 @@ class AddPackageDialog extends PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { open, onClose, shipment } = this.props;
|
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 (
|
return (
|
||||||
<AppDialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
<AppDialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
@@ -97,13 +118,21 @@ class AddPackageDialog extends PureComponent {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="Versanddienstleister (Carrier)"
|
label="Lieferart (Carrier)"
|
||||||
size="small"
|
size="small"
|
||||||
value={carrier}
|
value={carrierId}
|
||||||
onChange={(e) => this.setState({ carrier: e.target.value })}
|
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) => (
|
||||||
<MenuItem key={c} value={c}>{c}</MenuItem>
|
<MenuItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
@@ -145,7 +174,7 @@ class AddPackageDialog extends PureComponent {
|
|||||||
<Button variant="outlined" onClick={onClose} disabled={saving}>
|
<Button variant="outlined" onClick={onClose} disabled={saving}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={this.handleSubmit} disabled={saving}>
|
<Button variant="contained" onClick={this.handleSubmit} disabled={!canSubmit}>
|
||||||
{saving ? 'Wird gespeichert…' : 'Paket anlegen'}
|
{saving ? 'Wird gespeichert…' : 'Paket anlegen'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import StorefrontIcon from '@mui/icons-material/Storefront';
|
|||||||
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
import PaymentsIcon from '@mui/icons-material/Payments';
|
import PaymentsIcon from '@mui/icons-material/Payments';
|
||||||
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
import LabelIcon from '@mui/icons-material/Label';
|
import LabelIcon from '@mui/icons-material/Label';
|
||||||
|
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
@@ -226,6 +227,13 @@ export const SUB_NAV = {
|
|||||||
icon: PaymentsIcon,
|
icon: PaymentsIcon,
|
||||||
help: 'Zahlungseingänge erfassen und Auftragszahlungen überwachen',
|
help: 'Zahlungseingänge erfassen und Auftragszahlungen überwachen',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
segment: 'lieferarten',
|
||||||
|
label: 'Lieferarten',
|
||||||
|
title: 'Lieferarten',
|
||||||
|
icon: LocalShippingOutlinedIcon,
|
||||||
|
help: 'Versanddienstleister und Lieferarten (Carriers) verwalten',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'/kunde': [
|
'/kunde': [
|
||||||
{
|
{
|
||||||
|
|||||||
270
src/routes/auftrag/lieferarten.jsx
Normal file
270
src/routes/auftrag/lieferarten.jsx
Normal file
@@ -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 (
|
||||||
|
<PanelTileGrid>
|
||||||
|
<Box
|
||||||
|
ref={inputRef}
|
||||||
|
sx={{
|
||||||
|
gridColumn: {
|
||||||
|
xs: '1 / -1',
|
||||||
|
sm: 'span 2',
|
||||||
|
md: 'span 2',
|
||||||
|
},
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
pb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
size="small"
|
||||||
|
search
|
||||||
|
clear
|
||||||
|
placeholder="Lieferart suchen oder neu anlegen..."
|
||||||
|
value={input}
|
||||||
|
disabled={submitting}
|
||||||
|
onChange={(next) => this.onInputChange(next)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void this.handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={() => void this.handleSubmit()}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PanelTileGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed') {
|
||||||
|
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedFilter = filter?.trim() ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LieferartenSearchField inputRef={this.searchRef} />
|
||||||
|
<PanelTileGrid>
|
||||||
|
<PanelTile wide={2} minHeight={0} color={GREEN_PANEL}>
|
||||||
|
<List dense disablePadding ref={this.listRef} sx={{ width: '100%' }}>
|
||||||
|
{carriers.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||||
|
{trimmedFilter
|
||||||
|
? `Keine Treffer für „${trimmedFilter}“.`
|
||||||
|
: 'Noch keine Lieferarten angelegt.'}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
carriers.map((c) => (
|
||||||
|
<CarrierItem
|
||||||
|
key={c.id}
|
||||||
|
carrier={c}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</PanelTile>
|
||||||
|
</PanelTileGrid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CarrierItem, LieferartenSearchField };
|
||||||
|
export default withNavigate(AuftragLieferartenPage);
|
||||||
Reference in New Issue
Block a user