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 { mcp } from './mcp.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 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: <AuftragBestellungEditPage /> },
|
||||
{ path: 'ausliefern', element: <AuftragAusliefernPage /> },
|
||||
{ path: 'zahlungen', element: <AuftragZahlungenPage /> },
|
||||
{ path: 'lieferarten', element: <AuftragLieferartenPage /> },
|
||||
{ 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 warehouseAutomat } from './warehouseAutomat';
|
||||
export { default as carrierAutomat } from './carrierAutomat';
|
||||
|
||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||
export {
|
||||
|
||||
@@ -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);
|
||||
|
||||
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 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: <AddPackageDialog/>
|
||||
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 (
|
||||
<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 }}>
|
||||
<TextField
|
||||
select
|
||||
label="Versanddienstleister (Carrier)"
|
||||
label="Lieferart (Carrier)"
|
||||
size="small"
|
||||
value={carrier}
|
||||
onChange={(e) => 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) => (
|
||||
<MenuItem key={c} value={c}>{c}</MenuItem>
|
||||
{carriers.map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
@@ -145,7 +174,7 @@ class AddPackageDialog extends PureComponent {
|
||||
<Button variant="outlined" onClick={onClose} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant="contained" onClick={this.handleSubmit} disabled={saving}>
|
||||
<Button variant="contained" onClick={this.handleSubmit} disabled={!canSubmit}>
|
||||
{saving ? 'Wird gespeichert…' : 'Paket anlegen'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -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': [
|
||||
{
|
||||
|
||||
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