u
This commit is contained in:
1179
package-lock.json
generated
1179
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
|||||||
"pdfconvert-wasm": "^0.3.0",
|
"pdfconvert-wasm": "^0.3.0",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-automat": "file:../automat",
|
"react-automat": "file:../automat",
|
||||||
|
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
"sortablejs": "^1.15.7",
|
"sortablejs": "^1.15.7",
|
||||||
@@ -42,5 +43,9 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"vite": "^8.3.1",
|
"vite": "^8.3.1",
|
||||||
"vite-plugin-pwa": "^1.3.0"
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"canvas@3.2.3": true,
|
||||||
|
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export class ApiClient {
|
|||||||
create: (body) => this.#json('POST', '/api/warehouse', { body }),
|
create: (body) => this.#json('POST', '/api/warehouse', { body }),
|
||||||
/** PATCH /api/warehouse/{id} → 200 { warehouse: Warehouse } */
|
/** PATCH /api/warehouse/{id} → 200 { warehouse: Warehouse } */
|
||||||
update: (id, body) => this.#json('PATCH', `/api/warehouse/${enc(id)}`, { body }),
|
update: (id, body) => this.#json('PATCH', `/api/warehouse/${enc(id)}`, { body }),
|
||||||
|
/** PUT /api/warehouse/order → 200 { warehouseIds: number[] } */
|
||||||
|
order: (body) => this.#json('PUT', '/api/warehouse/order', { body }),
|
||||||
/** DELETE /api/warehouse/{id} → 204 */
|
/** DELETE /api/warehouse/{id} → 204 */
|
||||||
delete: (id) => this.#empty('DELETE', `/api/warehouse/${enc(id)}`),
|
delete: (id) => this.#empty('DELETE', `/api/warehouse/${enc(id)}`),
|
||||||
};
|
};
|
||||||
@@ -353,7 +355,7 @@ export class ApiClient {
|
|||||||
* `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`.
|
* `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`.
|
||||||
*/
|
*/
|
||||||
historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory),
|
historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory),
|
||||||
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */
|
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi, track_stock } */
|
||||||
update: async (sku, body) => {
|
update: async (sku, body) => {
|
||||||
const nameChange = body != null && Object.hasOwn(body, 'name');
|
const nameChange = body != null && Object.hasOwn(body, 'name');
|
||||||
if (nameChange) {
|
if (nameChange) {
|
||||||
@@ -400,6 +402,15 @@ export class ApiClient {
|
|||||||
/** PUT /api/article/{sku}/categories */
|
/** PUT /api/article/{sku}/categories */
|
||||||
setCategories: (sku, body) =>
|
setCategories: (sku, body) =>
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
|
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
|
||||||
|
/** PUT /api/article/{sku}/track_stock */
|
||||||
|
setTrackStock: async (sku, body) => {
|
||||||
|
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
||||||
|
const res = await this.#json('PUT', `/api/article/${enc(sku)}/track_stock`, { body: payload });
|
||||||
|
if (res?.article) {
|
||||||
|
this.setArticle(res.article);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
/** GET /api/article/{sku}/supplier */
|
/** GET /api/article/{sku}/supplier */
|
||||||
getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`),
|
getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`),
|
||||||
/** POST /api/article/{sku}/supplier */
|
/** POST /api/article/{sku}/supplier */
|
||||||
|
|||||||
129
src/api/index.js
129
src/api/index.js
@@ -216,16 +216,20 @@ export const article = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** POST /api/article → 201 { article } */
|
/** POST /api/article → 201 { article } */
|
||||||
create({ parentId, name }) {
|
create({ parentId, name, track_stock }) {
|
||||||
return http('/api/article', {
|
return http('/api/article', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { parentId, name },
|
body: {
|
||||||
|
parentId,
|
||||||
|
name,
|
||||||
|
...(track_stock !== undefined ? { track_stock } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/article/:sku
|
* PATCH /api/article/:sku
|
||||||
* Body: at least one of { name, unitamount, unitSi }
|
* Body: at least one of { name, unitamount, unitSi, track_stock }
|
||||||
* unitSi must exist for selected company → 404 Unit not found
|
* unitSi must exist for selected company → 404 Unit not found
|
||||||
* → 200 { article } (same detail shape as GET)
|
* → 200 { article } (same detail shape as GET)
|
||||||
*/
|
*/
|
||||||
@@ -236,6 +240,19 @@ export const article = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/track_stock
|
||||||
|
* Body: { track_stock: boolean }
|
||||||
|
* → 200 { article }
|
||||||
|
*/
|
||||||
|
setTrackStock(sku, body) {
|
||||||
|
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
||||||
|
return http(`/api/article/${encodeURIComponent(sku)}/track_stock`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/article/:sku → 204
|
* DELETE /api/article/:sku → 204
|
||||||
* Errors: 400 Article has children, 401, 403, 404 Article not found
|
* Errors: 400 Article has children, 401, 403, 404 Article not found
|
||||||
@@ -404,6 +421,56 @@ export const article = {
|
|||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return httpGetBinary(`/api/article/export.zip${qs ? `?${qs}` : ''}`);
|
return httpGetBinary(`/api/article/export.zip${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* POST /api/article/:sku/print
|
||||||
|
* Body: { printerId, templateId, quantity }
|
||||||
|
* Prints the given template to the specified printer for this article.
|
||||||
|
*/
|
||||||
|
printLabel(sku, body) {
|
||||||
|
return http(`/api/article/${encodeURIComponent(sku)}/print`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company-scoped printers (selectedCompanyId from session).
|
||||||
|
* Shape: Printer = { id, address, type, model, serial, sort, desc }
|
||||||
|
*/
|
||||||
|
export const printer = {
|
||||||
|
/** GET /api/printer → { printers: Printer[] } */
|
||||||
|
list() {
|
||||||
|
return http('/api/printer');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GET /api/printer/:id → { printer: Printer } */
|
||||||
|
getById(id) {
|
||||||
|
return http(`/api/printer/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/printer
|
||||||
|
* Body: { address, type, model, serial, sort, desc }
|
||||||
|
* → 201 { printer: Printer }
|
||||||
|
*/
|
||||||
|
create(body) {
|
||||||
|
return http('/api/printer', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/printer/:id
|
||||||
|
* Body: partial printer fields
|
||||||
|
* → 200 { printer: Printer }
|
||||||
|
*/
|
||||||
|
update(id, body) {
|
||||||
|
return http(`/api/printer/${id}`, { method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** DELETE /api/printer/:id → 204 */
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/printer/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -679,7 +746,7 @@ export const warehouse = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/warehouse
|
* POST /api/warehouse
|
||||||
* Body: { name } — empty name ⇒ 400 Name required.
|
* Body: { name, sort? } — empty name ⇒ 400 Name required.
|
||||||
* → 201 { warehouse: Warehouse }
|
* → 201 { warehouse: Warehouse }
|
||||||
*/
|
*/
|
||||||
create(body) {
|
create(body) {
|
||||||
@@ -688,13 +755,22 @@ export const warehouse = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/warehouse/:id
|
* PATCH /api/warehouse/:id
|
||||||
* Body: { name } — empty name ⇒ 400 Name required.
|
* Body: { name?, sort? }
|
||||||
* → 200 { warehouse: Warehouse }
|
* → 200 { warehouse: Warehouse }
|
||||||
*/
|
*/
|
||||||
update(id, body) {
|
update(id, body) {
|
||||||
return http(`/api/warehouse/${id}`, { method: 'PATCH', body });
|
return http(`/api/warehouse/${id}`, { method: 'PATCH', body });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/warehouse/order
|
||||||
|
* Body: { warehouseIds: number[] }
|
||||||
|
* → 200 { warehouseIds: number[] }
|
||||||
|
*/
|
||||||
|
order(body) {
|
||||||
|
return http('/api/warehouse/order', { method: 'PUT', body });
|
||||||
|
},
|
||||||
|
|
||||||
/** DELETE /api/warehouse/:id → 204 */
|
/** DELETE /api/warehouse/:id → 204 */
|
||||||
delete(id) {
|
delete(id) {
|
||||||
return http(`/api/warehouse/${id}`, { method: 'DELETE' });
|
return http(`/api/warehouse/${id}`, { method: 'DELETE' });
|
||||||
@@ -1466,6 +1542,47 @@ export const auth = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company-scoped ZPL label templates (selectedCompanyId from session).
|
||||||
|
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
|
||||||
|
*/
|
||||||
|
export const labeltemplate = {
|
||||||
|
/** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */
|
||||||
|
list() {
|
||||||
|
return http('/api/labeltemplate');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GET /api/labeltemplate/:id → { labeltemplate: LabelTemplate } */
|
||||||
|
get(id) {
|
||||||
|
return http(`/api/labeltemplate/${encodeURIComponent(id)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/labeltemplate
|
||||||
|
* Body: { name, width, height, dpi, type, content }
|
||||||
|
* → 201 { labeltemplate: LabelTemplate }
|
||||||
|
*/
|
||||||
|
create(body) {
|
||||||
|
return http('/api/labeltemplate', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/labeltemplate/:id
|
||||||
|
* Body: partial { name?, width?, height?, dpi?, type?, content? }
|
||||||
|
* → 200 { labeltemplate: LabelTemplate }
|
||||||
|
*/
|
||||||
|
update(id, body) {
|
||||||
|
return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** DELETE /api/labeltemplate/:id → 204 */
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// thumb is returned inline as a base64 string in list/get responses
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
auth,
|
auth,
|
||||||
article,
|
article,
|
||||||
@@ -1476,10 +1593,12 @@ export default {
|
|||||||
customergroup,
|
customergroup,
|
||||||
delivery,
|
delivery,
|
||||||
entity,
|
entity,
|
||||||
|
labeltemplate,
|
||||||
ledger,
|
ledger,
|
||||||
number,
|
number,
|
||||||
order,
|
order,
|
||||||
posterminal,
|
posterminal,
|
||||||
|
printer,
|
||||||
quote,
|
quote,
|
||||||
supplier,
|
supplier,
|
||||||
thumb,
|
thumb,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
|
|||||||
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
|
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
|
||||||
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
|
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
|
||||||
const ArtikelMarkenPage = lazy(() => import('./pages/artikel/marken'));
|
const ArtikelMarkenPage = lazy(() => import('./pages/artikel/marken'));
|
||||||
|
const ArtikelVorlagenPage = lazy(() => import('./pages/artikel/vorlagen'));
|
||||||
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
|
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
|
||||||
const LieferungListePage = lazy(() => import('./pages/lieferung/index'));
|
const LieferungListePage = lazy(() => import('./pages/lieferung/index'));
|
||||||
const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu'));
|
const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu'));
|
||||||
@@ -55,6 +56,7 @@ const routes = [
|
|||||||
{ path: 'kategorien/*', element: <RedirectArtikelKategorien /> },
|
{ path: 'kategorien/*', element: <RedirectArtikelKategorien /> },
|
||||||
{ path: 'lager', element: <ArtikelLagerPage /> },
|
{ path: 'lager', element: <ArtikelLagerPage /> },
|
||||||
{ path: 'marken', element: <ArtikelMarkenPage /> },
|
{ path: 'marken', element: <ArtikelMarkenPage /> },
|
||||||
|
{ path: 'vorlagen', element: <ArtikelVorlagenPage /> },
|
||||||
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
|
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ const actions = {
|
|||||||
await warehouseAutomat.actions.reload();
|
await warehouseAutomat.actions.reload();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a new sort order for all warehouses.
|
||||||
|
*
|
||||||
|
* @param {Array<string|number>} warehouseIds IDs in the desired order.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async reorder(warehouseIds) {
|
||||||
|
await warehouseApi.order({ warehouseIds });
|
||||||
|
await warehouseAutomat.actions.reload();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the cached list without a round-trip.
|
* Replaces the cached list without a round-trip.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ import WarehouseIcon from '@mui/icons-material/Warehouse';
|
|||||||
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
|
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
|
||||||
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
|
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { stock } from '../../api';
|
import { stock, article as articleApi } from '../../api';
|
||||||
|
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||||
import { formatStock } from '../../pages/artikel/searchFormat';
|
import { formatStock } from '../../pages/artikel/searchFormat';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import artikelAutomat from '../../automats/artikelAutomat';
|
import artikelAutomat from '../../automats/artikelAutomat';
|
||||||
@@ -41,9 +43,13 @@ const REASON_PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default class ArticleStockAdjustDialog extends PureComponent {
|
export default class ArticleStockAdjustDialog extends PureComponent {
|
||||||
|
static contextType = StorageManagerContext;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const initialWhId = this.resolveInitialWarehouseId(props);
|
const initialWhId = this.resolveInitialWarehouseId(props);
|
||||||
|
const isTrackStock =
|
||||||
|
props.article?.track_stock !== false && props.article?.track_stock !== 0;
|
||||||
this.state = {
|
this.state = {
|
||||||
mode: 'diff', // 'diff' | 'target'
|
mode: 'diff', // 'diff' | 'target'
|
||||||
warehouseId: initialWhId,
|
warehouseId: initialWhId,
|
||||||
@@ -53,6 +59,8 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
submitting: false,
|
submitting: false,
|
||||||
error: null,
|
error: null,
|
||||||
warehouses: warehouseAutomat.state.warehouses || [],
|
warehouses: warehouseAutomat.state.warehouses || [],
|
||||||
|
trackStock: isTrackStock,
|
||||||
|
initialTrackStock: isTrackStock,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.handleWarehouseChange = this.handleWarehouseChange.bind(this);
|
this.handleWarehouseChange = this.handleWarehouseChange.bind(this);
|
||||||
@@ -60,6 +68,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
this.handleDiffChange = this.handleDiffChange.bind(this);
|
this.handleDiffChange = this.handleDiffChange.bind(this);
|
||||||
this.handleTargetChange = this.handleTargetChange.bind(this);
|
this.handleTargetChange = this.handleTargetChange.bind(this);
|
||||||
this.handleReasonChange = this.handleReasonChange.bind(this);
|
this.handleReasonChange = this.handleReasonChange.bind(this);
|
||||||
|
this.handleTrackStockToggle = this.handleTrackStockToggle.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +91,8 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
if (this.props.open && !prevProps.open) {
|
if (this.props.open && !prevProps.open) {
|
||||||
void warehouseAutomat.actions.load().catch(() => {});
|
void warehouseAutomat.actions.load().catch(() => {});
|
||||||
const initialWhId = this.resolveInitialWarehouseId(this.props);
|
const initialWhId = this.resolveInitialWarehouseId(this.props);
|
||||||
|
const isTrackStock =
|
||||||
|
this.props.article?.track_stock !== false && this.props.article?.track_stock !== 0;
|
||||||
this.setState({
|
this.setState({
|
||||||
mode: 'diff',
|
mode: 'diff',
|
||||||
warehouseId: initialWhId,
|
warehouseId: initialWhId,
|
||||||
@@ -91,6 +102,8 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
submitting: false,
|
submitting: false,
|
||||||
error: null,
|
error: null,
|
||||||
warehouses: warehouseAutomat.state.warehouses || [],
|
warehouses: warehouseAutomat.state.warehouses || [],
|
||||||
|
trackStock: isTrackStock,
|
||||||
|
initialTrackStock: isTrackStock,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,8 +147,15 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
this.setState({ reason: event.target.value });
|
this.setState({ reason: event.target.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleTrackStockToggle(event) {
|
||||||
|
this.setState({ trackStock: event.target.checked, error: null });
|
||||||
|
}
|
||||||
|
|
||||||
calculateAdjustment() {
|
calculateAdjustment() {
|
||||||
const { mode, warehouseId, diffValue, targetValue } = this.state;
|
const { mode, warehouseId, diffValue, targetValue, trackStock } = this.state;
|
||||||
|
if (!trackStock) {
|
||||||
|
return { valid: false, amount: 0, newStock: 0 };
|
||||||
|
}
|
||||||
const currentStock = this.getCurrentStock(warehouseId);
|
const currentStock = this.getCurrentStock(warehouseId);
|
||||||
|
|
||||||
if (mode === 'diff') {
|
if (mode === 'diff') {
|
||||||
@@ -167,43 +187,77 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
|
|
||||||
async handleSubmit(event) {
|
async handleSubmit(event) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
const { article, onClose, onAdjustSuccess } = this.props;
|
const { article, onClose, onAdjustSuccess, onArticleUpdated } = this.props;
|
||||||
const { warehouseId, reason, submitting } = this.state;
|
const { warehouseId, reason, submitting, trackStock, initialTrackStock } = this.state;
|
||||||
|
|
||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
|
|
||||||
if (!warehouseId) {
|
const trackStockChanged = trackStock !== initialTrackStock;
|
||||||
this.setState({ error: 'Bitte wählen Sie ein Lager aus.' });
|
const { valid, amount } = this.calculateAdjustment();
|
||||||
|
const hasAdjustment = trackStock && valid && amount !== 0;
|
||||||
|
|
||||||
|
if (!trackStockChanged && !hasAdjustment) {
|
||||||
|
if (trackStock && (!valid || amount === 0)) {
|
||||||
|
this.setState({ error: 'Bitte geben Sie eine gültige Bestandsänderung ein (ungleich 0).' });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { valid, amount } = this.calculateAdjustment();
|
if (hasAdjustment && !warehouseId) {
|
||||||
if (!valid || amount === 0) {
|
this.setState({ error: 'Bitte wählen Sie ein Lager aus.' });
|
||||||
this.setState({ error: 'Bitte geben Sie eine gültige Bestandsänderung ein (ungleich 0).' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ submitting: true, error: null });
|
this.setState({ submitting: true, error: null });
|
||||||
try {
|
try {
|
||||||
const payload = {
|
let updatedArticle = null;
|
||||||
sku: article.sku,
|
|
||||||
warehouseId: Number(warehouseId),
|
|
||||||
amount,
|
|
||||||
text: reason.trim() || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await stock.adjust(payload);
|
// 1. Update track_stock if changed
|
||||||
|
if (trackStockChanged) {
|
||||||
|
const store = this.context;
|
||||||
|
const setFn = store?.article?.setTrackStock ?? articleApi.setTrackStock;
|
||||||
|
const trackRes = await setFn(article.sku, { track_stock: trackStock });
|
||||||
|
updatedArticle = trackRes?.article || null;
|
||||||
|
}
|
||||||
|
|
||||||
enqueueSnackbar(
|
// 2. Adjust stock if an adjustment was entered and stock is tracked
|
||||||
`Bestand für „${article.name || article.sku}“ erfolgreich angepasst (${amount > 0 ? `+${amount}` : amount}).`,
|
let adjustResult = null;
|
||||||
{ variant: 'success' }
|
if (hasAdjustment) {
|
||||||
);
|
const payload = {
|
||||||
|
sku: article.sku,
|
||||||
|
warehouseId: Number(warehouseId),
|
||||||
|
amount,
|
||||||
|
text: reason.trim() || undefined,
|
||||||
|
};
|
||||||
|
adjustResult = await stock.adjust(payload);
|
||||||
|
}
|
||||||
|
|
||||||
// Reload article to update UI
|
// User feedback
|
||||||
|
if (trackStockChanged && hasAdjustment) {
|
||||||
|
enqueueSnackbar(
|
||||||
|
`Bestandsführung ${trackStock ? 'aktiviert' : 'deaktiviert'} und Bestand angepasst.`,
|
||||||
|
{ variant: 'success' }
|
||||||
|
);
|
||||||
|
} else if (trackStockChanged) {
|
||||||
|
enqueueSnackbar(
|
||||||
|
`Bestandsführung für „${article.name || article.sku}“ ${trackStock ? 'aktiviert' : 'deaktiviert'}.`,
|
||||||
|
{ variant: 'success' }
|
||||||
|
);
|
||||||
|
} else if (hasAdjustment) {
|
||||||
|
enqueueSnackbar(
|
||||||
|
`Bestand für „${article.name || article.sku}“ erfolgreich angepasst (${amount > 0 ? `+${amount}` : amount}).`,
|
||||||
|
{ variant: 'success' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload article in automat cache
|
||||||
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => {});
|
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => {});
|
||||||
|
|
||||||
if (onAdjustSuccess) {
|
if (updatedArticle) {
|
||||||
onAdjustSuccess(result);
|
onArticleUpdated?.(updatedArticle);
|
||||||
|
}
|
||||||
|
if (adjustResult && onAdjustSuccess) {
|
||||||
|
onAdjustSuccess(adjustResult);
|
||||||
}
|
}
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -229,10 +283,16 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
submitting,
|
submitting,
|
||||||
error,
|
error,
|
||||||
warehouses,
|
warehouses,
|
||||||
|
trackStock,
|
||||||
|
initialTrackStock,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
|
const trackStockChanged = trackStock !== initialTrackStock;
|
||||||
const currentStock = this.getCurrentStock(warehouseId);
|
const currentStock = this.getCurrentStock(warehouseId);
|
||||||
const { valid, amount, newStock, isZeroChange } = this.calculateAdjustment();
|
const { valid, amount, newStock, isZeroChange } = this.calculateAdjustment();
|
||||||
|
const hasAdjustment = trackStock && valid && amount !== 0;
|
||||||
|
const canSubmit =
|
||||||
|
!submitting && (trackStockChanged || (hasAdjustment && Boolean(warehouseId)));
|
||||||
|
|
||||||
const selectedWarehouse = warehouses.find((w) => Number(w.id) === Number(warehouseId));
|
const selectedWarehouse = warehouses.find((w) => Number(w.id) === Number(warehouseId));
|
||||||
const unit =
|
const unit =
|
||||||
@@ -247,6 +307,13 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-labelledby="stock-adjust-dialog-title"
|
aria-labelledby="stock-adjust-dialog-title"
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
minHeight: 560,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
id="stock-adjust-dialog-title"
|
id="stock-adjust-dialog-title"
|
||||||
@@ -273,8 +340,8 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent dividers sx={{ pt: 2 }}>
|
<DialogContent dividers sx={{ pt: 2, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5} sx={{ flex: 1 }}>
|
||||||
{/* Article overview */}
|
{/* Article overview */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -292,12 +359,52 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Track Stock Toggle Card */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ pr: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
Bestand verfolgen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{trackStock
|
||||||
|
? 'Lagerbestand wird für diesen Artikel geführt und bei Buchungen aktualisiert.'
|
||||||
|
: 'Keine Bestandsführung. Artikel hat unbegrenzte Verfügbarkeit.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(trackStock)}
|
||||||
|
onChange={this.handleTrackStockToggle}
|
||||||
|
disabled={submitting}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{warehouses.length === 0 ? (
|
{warehouses.length === 0 ? (
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">
|
||||||
Es sind keine Lager vorhanden. Bitte legen Sie zuerst unter „Artikel → Lager“ ein Lager an.
|
Es sind keine Lager vorhanden. Bitte legen Sie zuerst unter „Artikel → Lager“ ein Lager an.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2.5,
|
||||||
|
opacity: trackStock ? 1 : 0.45,
|
||||||
|
pointerEvents: trackStock ? 'auto' : 'none',
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
userSelect: trackStock ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Warehouse selection & Current stock display */}
|
{/* Warehouse selection & Current stock display */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -314,7 +421,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
value={warehouseId ?? ''}
|
value={warehouseId ?? ''}
|
||||||
label="Lager *"
|
label="Lager *"
|
||||||
onChange={this.handleWarehouseChange}
|
onChange={this.handleWarehouseChange}
|
||||||
disabled={submitting}
|
disabled={submitting || !trackStock}
|
||||||
startAdornment={
|
startAdornment={
|
||||||
<WarehouseIcon sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }} />
|
<WarehouseIcon sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }} />
|
||||||
}
|
}
|
||||||
@@ -358,7 +465,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
onChange={this.handleModeChange}
|
onChange={this.handleModeChange}
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={submitting}
|
disabled={submitting || !trackStock}
|
||||||
>
|
>
|
||||||
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
|
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
|
||||||
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
||||||
@@ -380,7 +487,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
type="number"
|
type="number"
|
||||||
value={diffValue}
|
value={diffValue}
|
||||||
onChange={this.handleDiffChange}
|
onChange={this.handleDiffChange}
|
||||||
disabled={submitting}
|
disabled={submitting || !trackStock}
|
||||||
placeholder="z.B. +5 oder -2"
|
placeholder="z.B. +5 oder -2"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
@@ -409,7 +516,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
type="number"
|
type="number"
|
||||||
value={targetValue}
|
value={targetValue}
|
||||||
onChange={this.handleTargetChange}
|
onChange={this.handleTargetChange}
|
||||||
disabled={submitting}
|
disabled={submitting || !trackStock}
|
||||||
placeholder="z.B. 10"
|
placeholder="z.B. 10"
|
||||||
helperText={
|
helperText={
|
||||||
isZeroChange
|
isZeroChange
|
||||||
@@ -459,7 +566,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
label="Grund / Buchungstext"
|
label="Grund / Buchungstext"
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={this.handleReasonChange}
|
onChange={this.handleReasonChange}
|
||||||
disabled={submitting}
|
disabled={submitting || !trackStock}
|
||||||
placeholder="z.B. Inventur, Bruch, Schwund"
|
placeholder="z.B. Inventur, Bruch, Schwund"
|
||||||
helperText="Wird im Journal / Ledger als Transaktionstext gespeichert"
|
helperText="Wird im Journal / Ledger als Transaktionstext gespeichert"
|
||||||
/>
|
/>
|
||||||
@@ -470,14 +577,14 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
label={preset}
|
label={preset}
|
||||||
size="small"
|
size="small"
|
||||||
clickable
|
clickable
|
||||||
disabled={submitting}
|
disabled={submitting || !trackStock}
|
||||||
onClick={() => this.setState({ reason: preset })}
|
onClick={() => this.setState({ reason: preset })}
|
||||||
sx={{ fontSize: '0.75rem' }}
|
sx={{ fontSize: '0.75rem' }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -497,7 +604,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={this.handleSubmit}
|
onClick={this.handleSubmit}
|
||||||
disabled={submitting || !valid || amount === 0 || !warehouseId}
|
disabled={!canSubmit}
|
||||||
startIcon={
|
startIcon={
|
||||||
submitting ? (
|
submitting ? (
|
||||||
<CircularProgress size={16} color="inherit" />
|
<CircularProgress size={16} color="inherit" />
|
||||||
@@ -507,7 +614,11 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
}
|
}
|
||||||
sx={{ minWidth: 150, fontWeight: 600 }}
|
sx={{ minWidth: 150, fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
{submitting ? 'Wird gebucht...' : 'Bestand anpassen'}
|
{submitting
|
||||||
|
? 'Wird gespeichert...'
|
||||||
|
: hasAdjustment
|
||||||
|
? 'Bestand anpassen'
|
||||||
|
: 'Speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ class ArticleTileTab extends PureComponent {
|
|||||||
case ARTICLE_TAB.seo:
|
case ARTICLE_TAB.seo:
|
||||||
return <ArticleSeoTab article={articleData} />;
|
return <ArticleSeoTab article={articleData} />;
|
||||||
case ARTICLE_TAB.label:
|
case ARTICLE_TAB.label:
|
||||||
return <ArticleLabelTab />;
|
return <ArticleLabelTab article={articleData} />;
|
||||||
case ARTICLE_TAB.attributes:
|
case ARTICLE_TAB.attributes:
|
||||||
return <ArticleAttributesTab />;
|
return <ArticleAttributesTab />;
|
||||||
case ARTICLE_TAB.compound:
|
case ARTICLE_TAB.compound:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
import { formatStock } from '../../../pages/artikel/searchFormat';
|
import { formatStock } from '../../../pages/artikel/searchFormat';
|
||||||
import ArticleStockAdjustDialog from '../ArticleStockAdjustDialog';
|
import ArticleStockAdjustDialog from '../ArticleStockAdjustDialog';
|
||||||
@@ -30,6 +31,9 @@ export default class ArticleViewStock extends PureComponent {
|
|||||||
render() {
|
render() {
|
||||||
const { article: articleData } = this.props;
|
const { article: articleData } = this.props;
|
||||||
const { adjustOpen } = this.state;
|
const { adjustOpen } = this.state;
|
||||||
|
const isTrackStock =
|
||||||
|
articleData?.track_stock !== false && articleData?.track_stock !== 0;
|
||||||
|
|
||||||
const rawWarehouses = Array.isArray(articleData?.warehouses)
|
const rawWarehouses = Array.isArray(articleData?.warehouses)
|
||||||
? articleData.warehouses
|
? articleData.warehouses
|
||||||
: [];
|
: [];
|
||||||
@@ -63,149 +67,196 @@ export default class ArticleViewStock extends PureComponent {
|
|||||||
onClick: this.handleOpenAdjust,
|
onClick: this.handleOpenAdjust,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top section: Headline and Total Stock */}
|
{/* Top section: Headline and Total Stock */}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Bestand
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
gap: 0.75,
|
|
||||||
my: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
<Typography
|
||||||
component="div"
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: '2.4rem',
|
textTransform: 'uppercase',
|
||||||
fontWeight: 700,
|
letterSpacing: 0.6,
|
||||||
lineHeight: 1.1,
|
fontWeight: 600,
|
||||||
letterSpacing: '-0.02em',
|
display: 'block',
|
||||||
color: totalStock > 0 ? 'text.primary' : 'text.secondary',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatStock(totalStock)}
|
Bestand
|
||||||
</Typography>
|
</Typography>
|
||||||
{unit ? (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
{unit}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Bottom section: Per-warehouse breakdown */}
|
{isTrackStock ? (
|
||||||
{hasWarehouses ? (
|
<Tooltip title="Klicken, um Bestand anzupassen" arrow placement="top-start">
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
overflowY: 'auto',
|
|
||||||
pr: 0.5,
|
|
||||||
'&::-webkit-scrollbar': { width: 4 },
|
|
||||||
'&::-webkit-scrollbar-thumb': {
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
|
||||||
borderRadius: 2,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{warehouses.map((wh) => (
|
|
||||||
<Box
|
<Box
|
||||||
key={wh.id}
|
onClick={this.handleOpenAdjust}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'baseline',
|
||||||
justifyContent: 'space-between',
|
gap: 0.75,
|
||||||
py: 0.35,
|
my: 0.5,
|
||||||
px: 0.75,
|
cursor: 'pointer',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.55)',
|
px: 0.5,
|
||||||
|
mx: -0.5,
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Typography
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
fontSize: '2.4rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
color: totalStock > 0 ? 'text.primary' : 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatStock(totalStock)}
|
||||||
|
</Typography>
|
||||||
|
{unit ? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{unit}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
onClick={this.handleOpenAdjust}
|
||||||
|
sx={{
|
||||||
|
my: 0.75,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 0.5,
|
||||||
|
mx: -0.5,
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keine Erfassung
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: 'block', mt: 0.25 }}
|
||||||
|
>
|
||||||
|
Bestand wird nicht überwacht
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom section: Per-warehouse breakdown */}
|
||||||
|
{isTrackStock && hasWarehouses ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
pr: 0.5,
|
||||||
|
'&::-webkit-scrollbar': { width: 4 },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
<Box
|
<Box
|
||||||
|
key={wh.id}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
minWidth: 0,
|
justifyContent: 'space-between',
|
||||||
mr: 1,
|
py: 0.35,
|
||||||
|
px: 0.75,
|
||||||
|
borderRadius: 1,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.55)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WarehouseIcon
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 15,
|
display: 'flex',
|
||||||
color: 'text.secondary',
|
alignItems: 'center',
|
||||||
mr: 0.5,
|
minWidth: 0,
|
||||||
flexShrink: 0,
|
mr: 1,
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
noWrap
|
|
||||||
title={wh.name}
|
|
||||||
sx={{
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: '0.82rem',
|
|
||||||
color: 'text.primary',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{wh.name}
|
<WarehouseIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: 'text.secondary',
|
||||||
|
mr: 0.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
noWrap
|
||||||
|
title={wh.name}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wh.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
color:
|
||||||
|
Number(wh.stock) > 0
|
||||||
|
? 'text.primary'
|
||||||
|
: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatStock(wh.stock)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
))}
|
||||||
<Typography
|
</Box>
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.82rem',
|
|
||||||
flexShrink: 0,
|
|
||||||
color:
|
|
||||||
Number(wh.stock) > 0
|
|
||||||
? 'text.primary'
|
|
||||||
: 'text.secondary',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatStock(wh.stock)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
) : null}
|
||||||
) : null}
|
</Box>
|
||||||
</Box>
|
</PanelTile>
|
||||||
</PanelTile>
|
|
||||||
|
|
||||||
<ArticleStockAdjustDialog
|
<ArticleStockAdjustDialog
|
||||||
open={adjustOpen}
|
open={adjustOpen}
|
||||||
onClose={this.handleCloseAdjust}
|
onClose={this.handleCloseAdjust}
|
||||||
article={articleData}
|
article={articleData}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,256 @@
|
|||||||
import { Component } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import PrintIcon from '@mui/icons-material/Print';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
|
import { labeltemplate, printer, article } from '../../../api';
|
||||||
|
import { notifyError } from '../../../utils/notify';
|
||||||
|
import BackendError from '../../BackendError';
|
||||||
|
|
||||||
|
export default class ArticleLabelTab extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
templates: null,
|
||||||
|
printers: null,
|
||||||
|
selectedTemplateId: '',
|
||||||
|
selectedPrinterId: '',
|
||||||
|
quantity: 1,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
this.setState({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const [resTemplates, resPrinters] = await Promise.all([
|
||||||
|
labeltemplate.list(),
|
||||||
|
printer.list(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const templates = resTemplates.labeltemplates || [];
|
||||||
|
const zplPrinters = (resPrinters.printers || []).filter((p) => p.type === 'zpl');
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
templates,
|
||||||
|
printers: zplPrinters,
|
||||||
|
selectedTemplateId: templates.length > 0 ? templates[0].id : '',
|
||||||
|
selectedPrinterId: zplPrinters.length > 0 ? zplPrinters[0].id : '',
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({ error, loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePrint = async () => {
|
||||||
|
const { selectedTemplateId, selectedPrinterId, quantity } = this.state;
|
||||||
|
const { article: articleData } = this.props;
|
||||||
|
|
||||||
|
if (!selectedTemplateId || !selectedPrinterId || quantity < 1) {
|
||||||
|
enqueueSnackbar('Bitte Vorlage, Drucker und Menge auswählen', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await article.printLabel(articleData.sku, {
|
||||||
|
templateId: selectedTemplateId,
|
||||||
|
printerId: selectedPrinterId,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default class ArticleLabelTab extends Component {
|
|
||||||
render() {
|
render() {
|
||||||
|
const {
|
||||||
|
templates,
|
||||||
|
printers,
|
||||||
|
selectedTemplateId,
|
||||||
|
selectedPrinterId,
|
||||||
|
quantity,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<BackendError error={error} onRetry={() => this.loadData()} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templates.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Es sind keine ZPL-Etikettenvorlagen vorhanden.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (printers.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Es sind keine ZPL-Drucker konfiguriert.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
Label
|
<Box>
|
||||||
</Typography>
|
<Grid container spacing={2}>
|
||||||
|
{templates.map((t) => {
|
||||||
|
const isSelected = t.id === selectedTemplateId;
|
||||||
|
return (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => this.setState({ selectedTemplateId: t.id })}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: isSelected ? 'primary.main' : 'divider',
|
||||||
|
bgcolor: isSelected ? 'action.selected' : 'background.paper',
|
||||||
|
boxShadow: isSelected ? 3 : 0,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
gap: 2,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
boxShadow: isSelected ? 3 : 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircleIcon
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: 120,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.thumb ? (
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${t.thumb}`}
|
||||||
|
alt={t.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
Keine Vorschau
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ width: '100%', textAlign: 'center' }}>
|
||||||
|
<Typography variant="subtitle2" noWrap title={t.name}>
|
||||||
|
{t.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t.width}x{t.height} ({t.dpi} dpi)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'flex-start' }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Drucker"
|
||||||
|
value={selectedPrinterId}
|
||||||
|
onChange={(e) => this.setState({ selectedPrinterId: e.target.value })}
|
||||||
|
sx={{ minWidth: 200 }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{printers.map((p) => (
|
||||||
|
<MenuItem key={p.id} value={p.id}>
|
||||||
|
{p.desc ? `${p.desc} (${p.address})` : p.address}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Anzahl"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value, 10);
|
||||||
|
this.setState({ quantity: isNaN(val) ? 1 : Math.max(1, val) });
|
||||||
|
}}
|
||||||
|
inputProps={{ min: 1, max: 9999 }}
|
||||||
|
sx={{ width: 100 }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
startIcon={<PrintIcon />}
|
||||||
|
onClick={this.handlePrint}
|
||||||
|
disabled={!selectedTemplateId || !selectedPrinterId || quantity < 1}
|
||||||
|
sx={{ height: 40 }}
|
||||||
|
>
|
||||||
|
Etiketten drucken
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import AddBoxIcon from '@mui/icons-material/AddBox';
|
|||||||
import StorefrontIcon from '@mui/icons-material/Storefront';
|
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 LabelIcon from '@mui/icons-material/Label';
|
||||||
import { isKategorienPath } from './pages/artikel/categoryRoute';
|
import { isKategorienPath } from './pages/artikel/categoryRoute';
|
||||||
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
|
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ export const SUB_NAV = {
|
|||||||
{ segment: 'kategorien', label: 'Kategorieen', title: 'Kategorien', icon: CategoryIcon },
|
{ segment: 'kategorien', label: 'Kategorieen', title: 'Kategorien', icon: CategoryIcon },
|
||||||
{ segment: 'lager', label: 'Lager', icon: WarehouseIcon },
|
{ segment: 'lager', label: 'Lager', icon: WarehouseIcon },
|
||||||
{ segment: 'marken', label: 'Marken', icon: CopyrightIcon },
|
{ segment: 'marken', label: 'Marken', icon: CopyrightIcon },
|
||||||
|
{ segment: 'vorlagen', label: 'Vorlagen', icon: LabelIcon },
|
||||||
],
|
],
|
||||||
'/lieferung': [
|
'/lieferung': [
|
||||||
{ segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon },
|
{ segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent, createRef } from 'react';
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
@@ -9,7 +9,9 @@ import ListItem from '@mui/material/ListItem';
|
|||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
@@ -20,12 +22,6 @@ import { notifyError } from '../../utils/notify';
|
|||||||
import { setLastArticleSku } from '../../automats';
|
import { setLastArticleSku } from '../../automats';
|
||||||
import warehouseAutomat from '../../automats/warehouseAutomat';
|
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||||
|
|
||||||
function sortWarehouses(warehouses) {
|
|
||||||
return [...warehouses].sort((a, b) =>
|
|
||||||
String(a.name).localeCompare(String(b.name), 'de'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function draftsFrom(warehouses) {
|
function draftsFrom(warehouses) {
|
||||||
return Object.fromEntries(warehouses.map((row) => [row.id, row.name]));
|
return Object.fromEntries(warehouses.map((row) => [row.id, row.name]));
|
||||||
}
|
}
|
||||||
@@ -50,7 +46,7 @@ class ArtikelLagerPage extends PureComponent {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const { prefill } = getInitialParams(props);
|
const { prefill } = getInitialParams(props);
|
||||||
const initialWarehouses = sortWarehouses(warehouseAutomat.state.warehouses || []);
|
const initialWarehouses = warehouseAutomat.state.warehouses || [];
|
||||||
this.state = {
|
this.state = {
|
||||||
status: warehouseAutomat.state.status === 'ready' ? 'ready' : 'loading',
|
status: warehouseAutomat.state.status === 'ready' ? 'ready' : 'loading',
|
||||||
error: warehouseAutomat.state.error ? backendErrorFrom(warehouseAutomat.state.error) : null,
|
error: warehouseAutomat.state.error ? backendErrorFrom(warehouseAutomat.state.error) : null,
|
||||||
@@ -60,9 +56,14 @@ class ArtikelLagerPage extends PureComponent {
|
|||||||
busyKey: null,
|
busyKey: null,
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
|
this.listRef = createRef();
|
||||||
|
this._sortable = null;
|
||||||
|
|
||||||
this.handleCreate = this.handleCreate.bind(this);
|
this.handleCreate = this.handleCreate.bind(this);
|
||||||
this.handleRetry = this.handleRetry.bind(this);
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
this.loadWarehouses = this.loadWarehouses.bind(this);
|
this.loadWarehouses = this.loadWarehouses.bind(this);
|
||||||
|
this._initSortable = this._initSortable.bind(this);
|
||||||
|
this._destroySortable = this._destroySortable.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -85,27 +86,76 @@ class ArtikelLagerPage extends PureComponent {
|
|||||||
void this.loadWarehouses();
|
void this.loadWarehouses();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
if (prevProps.location !== this.props.location) {
|
if (prevProps.location !== this.props.location) {
|
||||||
const { prefill } = getInitialParams(this.props);
|
const { prefill } = getInitialParams(this.props);
|
||||||
if (prefill && prefill !== this.state.newName) {
|
if (prefill && prefill !== this.state.newName) {
|
||||||
this.setState({ newName: prefill });
|
this.setState({ newName: prefill });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-initialise Sortable when warehouses change or list mounts
|
||||||
|
if (prevState.warehouses !== this.state.warehouses && this.listRef.current) {
|
||||||
|
this._initSortable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
this._cancelled = true;
|
||||||
this.unsub?.();
|
this.unsub?.();
|
||||||
|
this._destroySortable();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initSortable() {
|
||||||
|
this._destroySortable();
|
||||||
|
const el = this.listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
this._sortable = Sortable.create(el, {
|
||||||
|
handle: '.drag-handle',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'sortable-ghost',
|
||||||
|
chosenClass: 'sortable-chosen',
|
||||||
|
onEnd: (evt) => {
|
||||||
|
const { oldIndex, newIndex } = evt;
|
||||||
|
if (oldIndex === newIndex) return;
|
||||||
|
|
||||||
|
this.setState((prev) => {
|
||||||
|
const next = [...prev.warehouses];
|
||||||
|
const [moved] = next.splice(oldIndex, 1);
|
||||||
|
next.splice(newIndex, 0, moved);
|
||||||
|
return { warehouses: next };
|
||||||
|
}, () => {
|
||||||
|
const ids = this.state.warehouses.map((w) => w.id);
|
||||||
|
void this._persistOrder(ids);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroySortable() {
|
||||||
|
this._sortable?.destroy();
|
||||||
|
this._sortable = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _persistOrder(warehouseIds) {
|
||||||
|
try {
|
||||||
|
await warehouseAutomat.actions.reorder(warehouseIds);
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
// Roll back optimistic reorder
|
||||||
|
void this.refreshFromServer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyWarehouses(warehouses) {
|
applyWarehouses(warehouses) {
|
||||||
const sorted = sortWarehouses(warehouses);
|
// Preserve order returned by the server (already sorted by sort/name from backend)
|
||||||
this.setState((prev) => {
|
this.setState((prev) => {
|
||||||
const currentDrafts = prev.drafts || {};
|
const currentDrafts = prev.drafts || {};
|
||||||
const nextDrafts = { ...draftsFrom(sorted) };
|
const nextDrafts = { ...draftsFrom(warehouses) };
|
||||||
// Preserve unsaved in-progress edits for still-existing items if not busy
|
// Preserve unsaved in-progress edits for still-existing items if not busy
|
||||||
for (const wh of sorted) {
|
for (const wh of warehouses) {
|
||||||
if (prev.busyKey !== wh.id && currentDrafts[wh.id] !== undefined) {
|
if (prev.busyKey !== wh.id && currentDrafts[wh.id] !== undefined) {
|
||||||
nextDrafts[wh.id] = currentDrafts[wh.id];
|
nextDrafts[wh.id] = currentDrafts[wh.id];
|
||||||
}
|
}
|
||||||
@@ -113,7 +163,7 @@ class ArtikelLagerPage extends PureComponent {
|
|||||||
return {
|
return {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
error: null,
|
error: null,
|
||||||
warehouses: sorted,
|
warehouses,
|
||||||
drafts: nextDrafts,
|
drafts: nextDrafts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -155,7 +205,7 @@ class ArtikelLagerPage extends PureComponent {
|
|||||||
await this.refreshFromServer();
|
await this.refreshFromServer();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({ newName: '', busyKey: null });
|
this.setState({ newName: '', busyKey: null });
|
||||||
enqueueSnackbar(`Lager „${newWarehouse.name || name}“ angelegt.`, { variant: 'success' });
|
enqueueSnackbar(`Lager „${newWarehouse.name || name}" angelegt.`, { variant: 'success' });
|
||||||
|
|
||||||
if (returnTo && this.props.navigate) {
|
if (returnTo && this.props.navigate) {
|
||||||
this.props.navigate(returnTo);
|
this.props.navigate(returnTo);
|
||||||
@@ -196,7 +246,7 @@ class ArtikelLagerPage extends PureComponent {
|
|||||||
|
|
||||||
async handleDelete(wh) {
|
async handleDelete(wh) {
|
||||||
if (this.state.busyKey != null) return;
|
if (this.state.busyKey != null) return;
|
||||||
if (!window.confirm(`Lager „${wh.name}“ wirklich löschen?`)) {
|
if (!window.confirm(`Lager „${wh.name}" wirklich löschen?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,113 +283,137 @@ class ArtikelLagerPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTileGrid>
|
<>
|
||||||
<PanelTile wide={2} minHeight={280} color="#bbf7d0">
|
{/* SortableJS ghost / chosen styling */}
|
||||||
<Stack spacing={2}>
|
<style>{`
|
||||||
{returnTo ? (
|
.sortable-ghost { opacity: 0.35 !important; }
|
||||||
<Alert
|
.sortable-chosen { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
|
||||||
severity="info"
|
.drag-handle { cursor: grab; touch-action: none; }
|
||||||
sx={{ py: 0.5, alignItems: 'center' }}
|
.drag-handle:active { cursor: grabbing; }
|
||||||
action={
|
`}</style>
|
||||||
<Button
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
onClick={() => this.props.navigate?.(returnTo)}
|
|
||||||
>
|
|
||||||
Zurück
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{newName
|
|
||||||
? `Lagername „${newName}“ übernommen.`
|
|
||||||
: 'Neues Lager anlegen.'}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
<PanelTileGrid>
|
||||||
Lager der ausgewählten Firma.
|
<PanelTile wide={2} minHeight={280} color="#bbf7d0">
|
||||||
</Typography>
|
<Stack spacing={2}>
|
||||||
|
{returnTo ? (
|
||||||
<List dense disablePadding>
|
<Alert
|
||||||
{warehouses.length === 0 ? (
|
severity="info"
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
sx={{ py: 0.5, alignItems: 'center' }}
|
||||||
Noch keine Lager angelegt.
|
action={
|
||||||
</Typography>
|
<Button
|
||||||
) : (
|
color="inherit"
|
||||||
warehouses.map((wh) => (
|
|
||||||
<ListItem
|
|
||||||
key={wh.id}
|
|
||||||
disableGutters
|
|
||||||
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<WarehouseIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
|
|
||||||
<SingleLineTextEdit
|
|
||||||
size="small"
|
size="small"
|
||||||
label=""
|
onClick={() => this.props.navigate?.(returnTo)}
|
||||||
value={drafts[wh.id] ?? ''}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(next) =>
|
|
||||||
this.setState((prev) => ({
|
|
||||||
drafts: { ...prev.drafts, [wh.id]: next },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onBlur={() => void this.handleRename(wh)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.target.blur();
|
|
||||||
}
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.setState((prev) => ({
|
|
||||||
drafts: { ...prev.drafts, [wh.id]: wh.name },
|
|
||||||
}));
|
|
||||||
event.target.blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
size="small"
|
|
||||||
aria-label={`„${wh.name}“ löschen`}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void this.handleDelete(wh)}
|
|
||||||
>
|
>
|
||||||
<DeleteOutlinedIcon fontSize="small" />
|
Zurück
|
||||||
</IconButton>
|
</Button>
|
||||||
</ListItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<SingleLineTextEdit
|
|
||||||
size="small"
|
|
||||||
label="Neues Lager"
|
|
||||||
value={newName}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(next) => this.setState({ newName: next })}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
void this.handleCreate();
|
|
||||||
}
|
}
|
||||||
}}
|
>
|
||||||
sx={{ flex: 1 }}
|
{newName
|
||||||
/>
|
? `Lagername „${newName}" übernommen.`
|
||||||
<Button
|
: 'Neues Lager anlegen.'}
|
||||||
variant="contained"
|
</Alert>
|
||||||
size="small"
|
) : null}
|
||||||
disabled={busy || !newName.trim()}
|
|
||||||
onClick={() => void this.handleCreate()}
|
<Typography variant="body2" color="text.secondary">
|
||||||
>
|
Reihenfolge per Drag & Drop anpassen.
|
||||||
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
</Typography>
|
||||||
</Button>
|
|
||||||
</Box>
|
<List dense disablePadding ref={this.listRef}>
|
||||||
</Stack>
|
{warehouses.length === 0 ? (
|
||||||
</PanelTile>
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||||
</PanelTileGrid>
|
Noch keine Lager angelegt.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
warehouses.map((wh) => (
|
||||||
|
<ListItem
|
||||||
|
key={wh.id}
|
||||||
|
disableGutters
|
||||||
|
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
|
||||||
|
data-id={wh.id}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className="drag-handle"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'text.disabled',
|
||||||
|
flexShrink: 0,
|
||||||
|
'&:hover': { color: 'text.secondary' },
|
||||||
|
transition: 'color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DragIndicatorIcon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
<WarehouseIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label=""
|
||||||
|
value={drafts[wh.id] ?? ''}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(next) =>
|
||||||
|
this.setState((prev) => ({
|
||||||
|
drafts: { ...prev.drafts, [wh.id]: next },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBlur={() => void this.handleRename(wh)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.target.blur();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.setState((prev) => ({
|
||||||
|
drafts: { ...prev.drafts, [wh.id]: wh.name },
|
||||||
|
}));
|
||||||
|
event.target.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
aria-label={`„${wh.name}" löschen`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void this.handleDelete(wh)}
|
||||||
|
>
|
||||||
|
<DeleteOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label="Neues Lager"
|
||||||
|
value={newName}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(next) => this.setState({ newName: next })}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void this.handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={busy || !newName.trim()}
|
||||||
|
onClick={() => void this.handleCreate()}
|
||||||
|
>
|
||||||
|
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</PanelTile>
|
||||||
|
</PanelTileGrid>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,10 +181,6 @@ class ArtikelMarkenPage extends PureComponent {
|
|||||||
return (
|
return (
|
||||||
<PanelTileGrid>
|
<PanelTileGrid>
|
||||||
<PanelTile wide={2} minHeight={280} color="#ddd6fe">
|
<PanelTile wide={2} minHeight={280} color="#ddd6fe">
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
||||||
Marken der ausgewählten Firma.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<List dense disablePadding>
|
<List dense disablePadding>
|
||||||
{brands.length === 0 ? (
|
{brands.length === 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||||
|
|||||||
594
src/pages/artikel/vorlagen.jsx
Normal file
594
src/pages/artikel/vorlagen.jsx
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import LabelIcon from '@mui/icons-material/Label';
|
||||||
|
import { CanvasEditor } from 'react-canvas-label-editor';
|
||||||
|
import 'react-canvas-label-editor/style.css';
|
||||||
|
import BackendError from '../../components/BackendError';
|
||||||
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
import { setLastArticleSku } from '../../automats';
|
||||||
|
import { labeltemplate } from '../../api';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
|
const MIN_HEIGHT_MM = 10;
|
||||||
|
const DEFAULT_HEIGHT_MM = 25;
|
||||||
|
const DEFAULT_DPI = 203;
|
||||||
|
|
||||||
|
function getCanvasWidth(widthClass, dpi) {
|
||||||
|
if (widthClass === '4') {
|
||||||
|
return dpi === 300 ? 1248 : 832;
|
||||||
|
}
|
||||||
|
return dpi === 300 ? 640 : 448;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMinHeightPx(dpi) {
|
||||||
|
return Math.max(80, Math.round((MIN_HEIGHT_MM * dpi) / 25.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mmToPx(mm, dpi) {
|
||||||
|
return Math.round((Number(mm) * dpi) / 25.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pxToMm(px, dpi) {
|
||||||
|
return Number(((px / dpi) * 25.4).toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArtikelVorlagenPage extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const initialDpi = DEFAULT_DPI;
|
||||||
|
const initialWidthClass = '2';
|
||||||
|
const initialHeightMm = DEFAULT_HEIGHT_MM;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
status: 'loading',
|
||||||
|
error: null,
|
||||||
|
templates: [],
|
||||||
|
// Editor dialog state
|
||||||
|
editorOpen: false,
|
||||||
|
editorLoading: false,
|
||||||
|
editorTemplate: null, // null = new, object = edit
|
||||||
|
editorName: '',
|
||||||
|
editorObjects: [],
|
||||||
|
editorDpi: initialDpi,
|
||||||
|
editorWidthClass: initialWidthClass,
|
||||||
|
editorWidth: getCanvasWidth(initialWidthClass, initialDpi),
|
||||||
|
editorHeightMm: initialHeightMm,
|
||||||
|
editorHeight: mmToPx(initialHeightMm, initialDpi),
|
||||||
|
editorClipboard: null,
|
||||||
|
saving: false,
|
||||||
|
// Delete busy
|
||||||
|
deletingId: null,
|
||||||
|
};
|
||||||
|
this._cancelled = false;
|
||||||
|
this._currentEditId = null;
|
||||||
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
|
this.handleOpenNew = this.handleOpenNew.bind(this);
|
||||||
|
this.handleOpenEdit = this.handleOpenEdit.bind(this);
|
||||||
|
this.handleEditorClose = this.handleEditorClose.bind(this);
|
||||||
|
this.handleEditorSave = this.handleEditorSave.bind(this);
|
||||||
|
this.handleWidthClassChange = this.handleWidthClassChange.bind(this);
|
||||||
|
this.handleDpiChange = this.handleDpiChange.bind(this);
|
||||||
|
this.handleHeightMmChange = this.handleHeightMmChange.bind(this);
|
||||||
|
this.handleHeightMmBlur = this.handleHeightMmBlur.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
setLastArticleSku(null);
|
||||||
|
this._cancelled = false;
|
||||||
|
void this.loadTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._cancelled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTemplates() {
|
||||||
|
this.setState({ status: 'loading', error: null });
|
||||||
|
try {
|
||||||
|
const { labeltemplates } = await labeltemplate.list();
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ status: 'ready', templates: labeltemplates || [] });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry() {
|
||||||
|
void this.loadTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenNew() {
|
||||||
|
this._currentEditId = null;
|
||||||
|
const dpi = DEFAULT_DPI;
|
||||||
|
const widthClass = '2';
|
||||||
|
const width = getCanvasWidth(widthClass, dpi);
|
||||||
|
const heightMm = DEFAULT_HEIGHT_MM;
|
||||||
|
const height = mmToPx(heightMm, dpi);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
editorOpen: true,
|
||||||
|
editorLoading: false,
|
||||||
|
editorTemplate: null,
|
||||||
|
editorName: 'Neue Vorlage',
|
||||||
|
editorObjects: [],
|
||||||
|
editorDpi: dpi,
|
||||||
|
editorWidthClass: widthClass,
|
||||||
|
editorWidth: width,
|
||||||
|
editorHeightMm: heightMm,
|
||||||
|
editorHeight: height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleOpenEdit(tmpl) {
|
||||||
|
const fetchId = tmpl.id;
|
||||||
|
this._currentEditId = fetchId;
|
||||||
|
const initialDpi = tmpl.dpi === 300 ? 300 : 203;
|
||||||
|
const initialWidthClass = (tmpl.width || 0) >= 800 ? '4' : '2';
|
||||||
|
const initialWidth = tmpl.width || getCanvasWidth(initialWidthClass, initialDpi);
|
||||||
|
const initialHeight = tmpl.height || mmToPx(DEFAULT_HEIGHT_MM, initialDpi);
|
||||||
|
const initialHeightMm = pxToMm(initialHeight, initialDpi);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
editorOpen: true,
|
||||||
|
editorLoading: true,
|
||||||
|
editorTemplate: tmpl,
|
||||||
|
editorName: tmpl.name,
|
||||||
|
editorObjects: [],
|
||||||
|
editorDpi: initialDpi,
|
||||||
|
editorWidthClass: initialWidthClass,
|
||||||
|
editorWidth: initialWidth,
|
||||||
|
editorHeightMm: initialHeightMm,
|
||||||
|
editorHeight: initialHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { labeltemplate: full } = await labeltemplate.get(fetchId);
|
||||||
|
if (this._cancelled || this._currentEditId !== fetchId || !this.state.editorOpen) return;
|
||||||
|
const content = full?.content || {};
|
||||||
|
const dpi = full?.dpi === 300 ? 300 : 203;
|
||||||
|
const widthClass = (full?.width || 0) >= 800 ? '4' : '2';
|
||||||
|
const width = full?.width || getCanvasWidth(widthClass, dpi);
|
||||||
|
const height = full?.height || mmToPx(DEFAULT_HEIGHT_MM, dpi);
|
||||||
|
const heightMm = pxToMm(height, dpi);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
editorLoading: false,
|
||||||
|
editorTemplate: full,
|
||||||
|
editorName: full.name ?? tmpl.name,
|
||||||
|
editorObjects: content.objects || [],
|
||||||
|
editorDpi: dpi,
|
||||||
|
editorWidthClass: widthClass,
|
||||||
|
editorWidth: width,
|
||||||
|
editorHeightMm: heightMm,
|
||||||
|
editorHeight: height,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled || this._currentEditId !== fetchId) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ editorOpen: false, editorLoading: false, editorTemplate: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWidthClassChange(event, nextValue) {
|
||||||
|
if (!nextValue) return;
|
||||||
|
const width = getCanvasWidth(nextValue, this.state.editorDpi);
|
||||||
|
this.setState({
|
||||||
|
editorWidthClass: nextValue,
|
||||||
|
editorWidth: width,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDpiChange(event, nextValue) {
|
||||||
|
if (!nextValue) return;
|
||||||
|
const dpi = Number(nextValue);
|
||||||
|
const width = getCanvasWidth(this.state.editorWidthClass, dpi);
|
||||||
|
const mm = parseFloat(this.state.editorHeightMm) || DEFAULT_HEIGHT_MM;
|
||||||
|
const minHeightPx = getMinHeightPx(dpi);
|
||||||
|
const height = Math.max(minHeightPx, mmToPx(mm, dpi));
|
||||||
|
this.setState({
|
||||||
|
editorDpi: dpi,
|
||||||
|
editorWidth: width,
|
||||||
|
editorHeight: height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHeightMmChange(event) {
|
||||||
|
const val = event.target.value;
|
||||||
|
const dpi = this.state.editorDpi;
|
||||||
|
const num = parseFloat(val);
|
||||||
|
const minHeightPx = getMinHeightPx(dpi);
|
||||||
|
const height = !isNaN(num) && num > 0
|
||||||
|
? Math.max(minHeightPx, mmToPx(num, dpi))
|
||||||
|
: this.state.editorHeight;
|
||||||
|
this.setState({
|
||||||
|
editorHeightMm: val,
|
||||||
|
editorHeight: height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHeightMmBlur() {
|
||||||
|
const dpi = this.state.editorDpi;
|
||||||
|
const num = parseFloat(this.state.editorHeightMm);
|
||||||
|
const safeMm = !isNaN(num) && num >= MIN_HEIGHT_MM ? num : MIN_HEIGHT_MM;
|
||||||
|
const minHeightPx = getMinHeightPx(dpi);
|
||||||
|
this.setState({
|
||||||
|
editorHeightMm: safeMm,
|
||||||
|
editorHeight: Math.max(minHeightPx, mmToPx(safeMm, dpi)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditorClose() {
|
||||||
|
if (this.state.saving) return;
|
||||||
|
this._currentEditId = null;
|
||||||
|
this.setState({ editorOpen: false, editorLoading: false, editorTemplate: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditorSave() {
|
||||||
|
const {
|
||||||
|
editorTemplate, editorName, editorObjects,
|
||||||
|
editorHeight, editorWidth, editorDpi,
|
||||||
|
} = this.state;
|
||||||
|
const name = editorName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
this.setState({ saving: true });
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
width: editorWidth,
|
||||||
|
height: editorHeight,
|
||||||
|
dpi: editorDpi,
|
||||||
|
type: 'zpl',
|
||||||
|
content: { objects: editorObjects },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editorTemplate) {
|
||||||
|
const { labeltemplate: updated } = await labeltemplate.update(editorTemplate.id, body);
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
saving: false,
|
||||||
|
editorOpen: false,
|
||||||
|
templates: prev.templates.map((t) => (t.id === updated.id ? updated : t)),
|
||||||
|
}));
|
||||||
|
enqueueSnackbar(`Vorlage „${updated.name}" gespeichert.`, { variant: 'success' });
|
||||||
|
} else {
|
||||||
|
const { labeltemplate: created } = await labeltemplate.create(body);
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
saving: false,
|
||||||
|
editorOpen: false,
|
||||||
|
templates: [...prev.templates, created],
|
||||||
|
}));
|
||||||
|
enqueueSnackbar(`Vorlage „${created.name}" angelegt.`, { variant: 'success' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ saving: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDelete(tmpl) {
|
||||||
|
if (this.state.deletingId != null) return;
|
||||||
|
if (!window.confirm(`Vorlage „${tmpl.name}" wirklich löschen?`)) return;
|
||||||
|
|
||||||
|
this.setState({ deletingId: tmpl.id });
|
||||||
|
try {
|
||||||
|
await labeltemplate.delete(tmpl.id);
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
deletingId: null,
|
||||||
|
templates: prev.templates.filter((t) => t.id !== tmpl.id),
|
||||||
|
}));
|
||||||
|
enqueueSnackbar('Vorlage gelöscht.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ deletingId: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEditor() {
|
||||||
|
const {
|
||||||
|
editorOpen, editorLoading, editorTemplate, editorName, editorObjects,
|
||||||
|
editorHeight, editorWidth, editorDpi, editorWidthClass, editorHeightMm,
|
||||||
|
editorClipboard, saving,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const minHeightPx = getMinHeightPx(editorDpi);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={editorOpen}
|
||||||
|
onClose={this.handleEditorClose}
|
||||||
|
maxWidth={false}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
width: '90vw',
|
||||||
|
maxWidth: 1000,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>
|
||||||
|
{editorTemplate ? `„${editorTemplate.name}" bearbeiten` : 'Neue Vorlage'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ pt: '8px !important' }}>
|
||||||
|
{editorLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 10, gap: 2 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Vorlage wird geladen…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
size="small"
|
||||||
|
value={editorName}
|
||||||
|
onChange={(e) => this.setState({ editorName: e.target.value })}
|
||||||
|
disabled={saving}
|
||||||
|
sx={{ flex: 1, minWidth: 200 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Breite:</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
value={editorWidthClass}
|
||||||
|
exclusive
|
||||||
|
onChange={this.handleWidthClassChange}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<ToggleButton value="2">2"</ToggleButton>
|
||||||
|
<ToggleButton value="4">4"</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Auflösung:</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
value={String(editorDpi)}
|
||||||
|
exclusive
|
||||||
|
onChange={this.handleDpiChange}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<ToggleButton value="203">203 DPI</ToggleButton>
|
||||||
|
<ToggleButton value="300">300 DPI</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Höhe"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={editorHeightMm}
|
||||||
|
onChange={this.handleHeightMmChange}
|
||||||
|
onBlur={this.handleHeightMmBlur}
|
||||||
|
disabled={saving}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: <InputAdornment position="end">mm</InputAdornment>,
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
min: MIN_HEIGHT_MM,
|
||||||
|
max: 300,
|
||||||
|
step: 1,
|
||||||
|
}}
|
||||||
|
sx={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<CanvasEditor
|
||||||
|
width={editorWidth}
|
||||||
|
height={editorHeight}
|
||||||
|
minHeight={minHeightPx}
|
||||||
|
showWidthUI={false}
|
||||||
|
showHeightUI={false}
|
||||||
|
dpi={editorDpi}
|
||||||
|
objects={editorObjects}
|
||||||
|
onChange={(objects) => this.setState({ editorObjects: objects })}
|
||||||
|
clipboard={editorClipboard}
|
||||||
|
onCopy={(obj) => this.setState({ editorClipboard: obj })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={this.handleEditorClose} disabled={saving}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={this.handleEditorSave}
|
||||||
|
disabled={saving || editorLoading || !editorName.trim()}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
||||||
|
{saving ? 'Speichert…' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { status, error, templates, deletingId } = this.state;
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed') {
|
||||||
|
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PanelTileGrid>
|
||||||
|
<PanelTile wide={3} color="#dbeafe">
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', mb: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={this.handleOpenNew}
|
||||||
|
>
|
||||||
|
Neue Vorlage
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
py: 6,
|
||||||
|
color: 'text.disabled',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LabelIcon sx={{ fontSize: 48 }} />
|
||||||
|
<Typography variant="body2">Noch keine Vorlagen angelegt.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{templates.map((tmpl) => (
|
||||||
|
<Box
|
||||||
|
key={tmpl.id}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<Box
|
||||||
|
onClick={() => this.handleOpenEdit(tmpl)}
|
||||||
|
sx={{
|
||||||
|
height: 100,
|
||||||
|
bgcolor: '#f8f8f8',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tmpl.thumb ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={`data:image/png;base64,${tmpl.thumb}`}
|
||||||
|
alt={tmpl.name}
|
||||||
|
sx={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LabelIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box sx={{ px: 1.5, py: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ flex: 1, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
title={tmpl.name}
|
||||||
|
>
|
||||||
|
{tmpl.name}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Bearbeiten">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`„${tmpl.name}" bearbeiten`}
|
||||||
|
onClick={() => this.handleOpenEdit(tmpl)}
|
||||||
|
disabled={deletingId != null}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Löschen">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`„${tmpl.name}" löschen`}
|
||||||
|
onClick={() => void this.handleDelete(tmpl)}
|
||||||
|
disabled={deletingId === tmpl.id}
|
||||||
|
>
|
||||||
|
{deletingId === tmpl.id
|
||||||
|
? <CircularProgress size={16} />
|
||||||
|
: <DeleteOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dimensions chip */}
|
||||||
|
<Divider />
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.disabled"
|
||||||
|
sx={{ px: 1.5, py: 0.5 }}
|
||||||
|
>
|
||||||
|
{Number(((tmpl.width / (tmpl.dpi || 203)) * 25.4).toFixed(0))} × {Number(((tmpl.height / (tmpl.dpi || 203)) * 25.4).toFixed(0))} mm ({tmpl.width} × {tmpl.height} px) · {tmpl.dpi || 203} DPI
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</PanelTile>
|
||||||
|
</PanelTileGrid>
|
||||||
|
|
||||||
|
{this.renderEditor()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtikelVorlagenPage;
|
||||||
@@ -246,10 +246,6 @@ class LieferungLieferantenPage extends PureComponent {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Lieferanten der ausgewählten Firma.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<List dense disablePadding>
|
<List dense disablePadding>
|
||||||
{suppliers.length === 0 ? (
|
{suppliers.length === 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user