diff --git a/src/api/index.js b/src/api/index.js
index 02d843e..f09659c 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -17,6 +17,7 @@ export { posterminal } from './posterminal.js';
export { printer } from './printer.js';
export { propertyname } from './propertyname.js';
export { quote } from './quote.js';
+export { retoure } from './retoure.js';
export { setting } from './setting.js';
export { serper } from './serper.js';
export { supplier } from './supplier.js';
diff --git a/src/api/payment.js b/src/api/payment.js
index ccb16a9..5c933ba 100644
--- a/src/api/payment.js
+++ b/src/api/payment.js
@@ -11,8 +11,17 @@ export const payment = {
// #endregion
// #region POST /api/order/:id/payment — create
- create(orderId, body) {
- return http(`/api/order/${orderId}/payment`, { method: 'POST', body });
+ create(orderIdOrBody, maybeBody) {
+ if (typeof orderIdOrBody === 'object' && orderIdOrBody !== null) {
+ return http('/api/payment', { method: 'POST', body: orderIdOrBody });
+ }
+ return http(`/api/order/${orderIdOrBody}/payment`, { method: 'POST', body: maybeBody });
+ },
+ // #endregion
+
+ // #region POST /api/payment — register
+ register(body) {
+ return http('/api/payment', { method: 'POST', body });
},
// #endregion
diff --git a/src/api/retoure.js b/src/api/retoure.js
new file mode 100644
index 0000000..bd8ac6c
--- /dev/null
+++ b/src/api/retoure.js
@@ -0,0 +1,187 @@
+import { http } from './http.js';
+
+/**
+ * Normalizes a retouresummary record from either snake_case (raw view) or camelCase.
+ * Fields: manifest_id, retoure_number, retoure_date, entity_id, customer_name,
+ * positions, gross_total, paid_total, ordered_quantity, shipped_quantity, is_paid, is_shipped, created_at
+ */
+export function normalizeRetoureSummary(row) {
+ if (!row) return null;
+ const id = row.manifest_id ?? row.id;
+ const num = row.retoure_number ?? row.number ?? (id ? `RET-${id}` : '');
+ const dt = row.retoure_date ?? row.date ?? row.created_at ?? row.createdAt;
+ const entityId = row.entity_id ?? row.entityId ?? null;
+ const customerName = row.customer_name ?? row.customerName ?? '';
+ const positions = Number(row.positions) || 0;
+ const grossTotal = row.gross_total != null ? Number(row.gross_total) : (row.grossTotal != null ? Number(row.grossTotal) : 0);
+ const paidTotal = row.paid_total != null ? Number(row.paid_total) : (row.paidTotal != null ? Number(row.paidTotal) : 0);
+ const orderedQuantity = row.ordered_quantity != null ? Number(row.ordered_quantity) : (row.orderedQuantity != null ? Number(row.orderedQuantity) : 0);
+ const shippedQuantity = row.shipped_quantity != null ? Number(row.shipped_quantity) : (row.shippedQuantity != null ? Number(row.shippedQuantity) : 0);
+ const isPaid = Boolean(row.is_paid ?? row.isPaid);
+ const isShipped = Boolean(row.is_shipped ?? row.isShipped);
+ const createdAt = row.created_at ?? row.createdAt ?? null;
+
+ return {
+ id,
+ manifest_id: id,
+ number: num,
+ retoure_number: num,
+ date: dt,
+ retoure_date: dt,
+ entityId,
+ entity_id: entityId,
+ customerName,
+ customer_name: customerName,
+ positions,
+ grossTotal,
+ gross_total: grossTotal,
+ paidTotal,
+ paid_total: paidTotal,
+ orderedQuantity,
+ ordered_quantity: orderedQuantity,
+ shippedQuantity,
+ shipped_quantity: shippedQuantity,
+ isPaid,
+ is_paid: isPaid,
+ isShipped,
+ is_shipped: isShipped,
+ createdAt,
+ created_at: createdAt,
+ lines: row.lines ?? [],
+ files: row.files ?? [],
+ shipments: row.shipments ?? [],
+ payments: row.payments ?? [],
+ };
+}
+
+/**
+ * Customer Returns / Retouren (manifest type = retoure).
+ */
+export const retoure = {
+ /**
+ * GET /api/retoure
+ * Query params: q, from, to, isPaid, isShipped, page, pageSize, offset, limit, entityId
+ * → { retoures: RetoureSummary[], total: number, unfilteredTotal: number }
+ */
+ // #region GET /api/retoure — list
+ async list({
+ q,
+ search,
+ from,
+ to,
+ isPaid,
+ isShipped,
+ onlyUnpaid,
+ onlyUndelivered,
+ page,
+ pageSize,
+ offset,
+ limit,
+ entityId,
+ seekMonths,
+ } = {}) {
+ const params = new URLSearchParams();
+ const query = q || search;
+ if (query && String(query).trim()) {
+ params.set('q', String(query).trim());
+ params.set('search', String(query).trim());
+ }
+ if (from) params.set('from', String(from));
+ if (to) params.set('to', String(to));
+ if (isPaid !== undefined && isPaid !== null) {
+ params.set('isPaid', String(isPaid));
+ } else if (onlyUnpaid === true || onlyUnpaid === 'true') {
+ params.set('isPaid', 'false');
+ }
+ if (isShipped !== undefined && isShipped !== null) {
+ params.set('isShipped', String(isShipped));
+ } else if (onlyUndelivered === true || onlyUndelivered === 'true') {
+ params.set('isShipped', 'false');
+ }
+ if (page != null) params.set('page', String(page));
+ if (pageSize != null) params.set('pageSize', String(pageSize));
+ if (offset != null) params.set('offset', String(offset));
+ if (limit != null) params.set('limit', String(limit));
+ if (entityId != null) params.set('entityId', String(entityId));
+ if (seekMonths != null && Number(seekMonths) < 0) {
+ params.set('seekMonths', String(Math.floor(Number(seekMonths))));
+ }
+
+ const qs = params.toString();
+ const res = await http(`/api/retoure${qs ? `?${qs}` : ''}`);
+ const rawList = res?.retoures ?? res?.summaries ?? res?.items ?? (Array.isArray(res) ? res : []);
+ const items = rawList.map(normalizeRetoureSummary);
+ const total = Number(res?.total ?? res?.count ?? items.length);
+ const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
+
+ return {
+ retoures: items,
+ summaries: items,
+ items,
+ total,
+ unfilteredTotal,
+ raw: res,
+ };
+ },
+ // #endregion
+
+ /** GET /api/retoure/:id → { retoure: Retoure } */
+ // #region GET /api/retoure/:id — get
+ async get(id) {
+ const res = await http(`/api/retoure/${id}`);
+ if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
+ return { retoure: res };
+ }
+ return res;
+ },
+ // #endregion
+
+ /**
+ * POST /api/retoure
+ * Body: { number?, date, entityId?, externalNumber?, lines }
+ * → 201 { retoure: Retoure }
+ */
+ // #region POST /api/retoure — create
+ async create(body) {
+ const res = await http('/api/retoure', { method: 'POST', body });
+ if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
+ return { retoure: res };
+ }
+ return res;
+ },
+ // #endregion
+
+ /**
+ * PUT /api/retoure/:id
+ * Body: { number?, date?, entityId?, externalNumber?, lines? }
+ * → 200 { retoure: Retoure }
+ */
+ // #region PUT /api/retoure/:id — update
+ async update(id, body) {
+ const res = await http(`/api/retoure/${id}`, { method: 'PUT', body });
+ if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
+ return { retoure: res };
+ }
+ return res;
+ },
+ // #endregion
+
+ /** DELETE /api/retoure/:id → 204 */
+ // #region DELETE /api/retoure/:id — delete
+ delete(id) {
+ return http(`/api/retoure/${id}`, { method: 'DELETE' });
+ },
+ // #endregion
+
+ /**
+ * POST /api/retoure/:id/shipment
+ * Creates return shipment.
+ * Body: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }>, shipping?: { carrierId, type, state, warehouseId } }
+ * → 201 { shipment: Shipment }
+ */
+ // #region POST /api/retoure/:id/shipment — createShipment
+ createShipment(id, body = {}) {
+ return http(`/api/retoure/${id}/shipment`, { method: 'POST', body });
+ },
+ // #endregion
+};
diff --git a/src/appRouter.jsx b/src/appRouter.jsx
index db972a9..fd6faf7 100644
--- a/src/appRouter.jsx
+++ b/src/appRouter.jsx
@@ -22,6 +22,9 @@ import AuftragBestellungPage from './routes/auftrag/bestellung';
import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu';
import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId';
import AuftragAusliefernPage from './routes/auftrag/ausliefern';
+import AuftragRetourePage from './routes/auftrag/retoure';
+import AuftragRetoureNeuPage from './routes/auftrag/retoure/neu';
+import AuftragRetoureEditPage from './routes/auftrag/retoure/_retoureId';
import AuftragZahlungenPage from './routes/auftrag/zahlungen';
import AuftragVersandartenPage from './routes/auftrag/versandarten';
@@ -109,6 +112,9 @@ const routes = [
{ path: 'bestellung/neu', element: },
{ path: 'bestellung/:orderId', element: },
{ path: 'ausliefern', element: },
+ { path: 'retoure', element: },
+ { path: 'retoure/neu', element: },
+ { path: 'retoure/:retoureId', element: },
{ path: 'zahlungen', element: },
{ path: 'versandarten', element: },
{ path: 'lieferarten', element: },
diff --git a/src/automats/index.js b/src/automats/index.js
index f9ccf1e..5b67392 100644
--- a/src/automats/index.js
+++ b/src/automats/index.js
@@ -98,6 +98,20 @@ export {
subscribeChunkStats,
} from './orderAutomat';
export { default as entityAutomat } from './entityAutomat';
+export {
+ default as retoureAutomat,
+ selectRetoures,
+ selectRetoureTotal,
+ selectRetoureUnfilteredTotal,
+ isRetourePaid,
+ isRetoureShipped,
+ isRetoureDelivered,
+ getRetoureDate,
+ getItems as getRetoureItems,
+ requireChunk as requireRetoureChunk,
+ getChunkStats as getRetoureChunkStats,
+ subscribeChunkStats as subscribeRetoureChunkStats,
+} from './retoureAutomat';
export { default as customergroupAutomat } from './customergroupAutomat';
export { default as numberAutomat } from './numberAutomat';
export { default as accountAutomat } from './accountAutomat';
diff --git a/src/automats/manifestDraftAutomat.js b/src/automats/manifestDraftAutomat.js
index 49e01e2..fb1ac76 100644
--- a/src/automats/manifestDraftAutomat.js
+++ b/src/automats/manifestDraftAutomat.js
@@ -224,14 +224,21 @@ export const orderDraftAutomat = createManifestDraftAutomat({
name: 'order-draft',
});
+/** Shared singleton automat for retoure draft. */
+export const retoureDraftAutomat = createManifestDraftAutomat({
+ kind: 'retoure',
+ name: 'retoure-draft',
+});
+
/**
* Returns the draft automat for a given manifest kind.
- * @param {'quote'|'order'} kind
+ * @param {'quote'|'order'|'retoure'} kind
* @returns {Automat|null}
*/
export function getManifestDraftAutomat(kind) {
if (kind === 'quote') return quoteDraftAutomat;
if (kind === 'order') return orderDraftAutomat;
+ if (kind === 'retoure') return retoureDraftAutomat;
return null;
}
@@ -454,10 +461,10 @@ export function createManifestEditAutomat({ kind, name }) {
shipments: doc.shipments || [],
payments: doc.payments || [],
shippings: doc.shippings || [],
- orderTotal: doc.orderTotal ?? null,
- paidTotal: doc.paidTotal ?? null,
- paid: Boolean(doc.paid),
- shipped: Boolean(doc.shipped),
+ orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? null,
+ paidTotal: doc.paidTotal ?? doc.paid_total ?? null,
+ paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid),
+ shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_shipped),
delivered: Boolean(doc.delivered),
baseline,
isDirty: false,
@@ -605,13 +612,20 @@ export const orderEditAutomat = createManifestEditAutomat({
name: 'order-edit',
});
+/** Shared singleton automat for the single last edited retoure. */
+export const retoureEditAutomat = createManifestEditAutomat({
+ kind: 'retoure',
+ name: 'retoure-edit',
+});
+
/**
* Returns the edit automat for a given manifest kind.
- * @param {'quote'|'order'} kind
+ * @param {'quote'|'order'|'retoure'} kind
* @returns {Automat|null}
*/
export function getManifestEditAutomat(kind) {
if (kind === 'quote') return quoteEditAutomat;
if (kind === 'order') return orderEditAutomat;
+ if (kind === 'retoure') return retoureEditAutomat;
return null;
}
diff --git a/src/automats/retoureAutomat.js b/src/automats/retoureAutomat.js
new file mode 100644
index 0000000..302a9a8
--- /dev/null
+++ b/src/automats/retoureAutomat.js
@@ -0,0 +1,1005 @@
+/**
+ * @file retoureAutomat.js
+ * @description Manages cached and chunk-paginated customer returns / Retouren (`GET /api/retoure`).
+ *
+ * Implements a multi-filter chunk cache:
+ * - Each filter combination has its own isolated cache entry (total, items array, loadedChunks).
+ * - Total chunk capacity across all filter combinations is bounded by a global LRU (Least Recently Used) limit of 50 chunks.
+ * - When filter settings change, previously loaded chunks remain cached in memory up to the 50-chunk limit.
+ * - Switching back to a previously used filter is instant with zero network requests if its chunks are in RAM.
+ *
+ * Upstream Triggers:
+ * - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
+ * - `sseAutomat`: Dispatches `'retoure'` events to `reload()`.
+ * - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`.
+ */
+
+import { Automat } from 'react-automat';
+import { retoure as retoureApi, normalizeRetoureSummary } from '../api/retoure';
+import authAutomat from './authAutomat';
+
+/**
+ * @typedef {Object} RetoureLine
+ * @property {number} id
+ * @property {string|null} sku
+ * @property {string|null} name
+ * @property {number} pu
+ * @property {number} amount
+ * @property {number} net
+ * @property {number} rebate
+ * @property {number} taxrate
+ * @property {string|null} comment
+ */
+
+/**
+ * @typedef {Object} Retoure
+ * @property {number} id Retoure/manifest id.
+ * @property {string} number Retoure number (e.g. RET-00001).
+ * @property {string} date Retoure date (ISO).
+ * @property {number|null} entityId Customer id.
+ * @property {string|null} customerName Customer display name.
+ * @property {number} positions Positions count.
+ * @property {number} grossTotal Gross sum.
+ * @property {number} paidTotal Paid sum (refunded).
+ * @property {number} orderedQuantity Ordered/returned quantity.
+ * @property {number} shippedQuantity Shipped/received quantity.
+ * @property {boolean} isPaid Paid / refunded status.
+ * @property {boolean} isShipped Shipped / received status.
+ * @property {string|null} createdAt Creation timestamp.
+ * @property {RetoureLine[]} lines Line items.
+ */
+
+export const CHUNK_SIZE = 50;
+export const MAX_LRU_CHUNKS = 50;
+const PREFETCH_CHUNKS = 1;
+const CHUNK_DEBOUNCE_MS = 130;
+
+const initialState = {
+ items: null,
+ total: 0,
+ unfilteredTotal: 0,
+ filter: '',
+ onlyUnpaid: false,
+ onlyUndelivered: false,
+ entityId: null,
+ seekMonths: 0,
+ seekYears: 0,
+ negativeSeekMonths: 0,
+ status: 'idle',
+ error: null,
+};
+
+let automat;
+let queryEpoch = 0;
+let filterDebounceTimer = null;
+let chunkDebounceTimer = null;
+let currentEventTick = 0;
+let lastRequireChunkTick = -1;
+
+const pendingTargetChunks = new Set();
+let pendingChunkPromise = null;
+let pendingChunkResolve = null;
+let pendingChunkReject = null;
+
+// Multi-Filter Cache & Global LRU Management
+const filterCaches = new Map();
+const lruChunks = new Map();
+const inflightChunks = new Map();
+
+// Dev / Observability: Chunk event history and subscriber registry
+const MAX_CHUNK_EVENTS = 150;
+let nextEventId = 0;
+const chunkEvents = [];
+const chunkStatsListeners = new Set();
+
+export function makeLruKey(filterKey, chunkIndex) {
+ return `${filterKey}:::${chunkIndex}`;
+}
+
+export function parseLruKey(key) {
+ const [filterKey, chunkStr] = key.split(':::');
+ return { filterKey, chunkIndex: Number(chunkStr) };
+}
+
+// MARK: pure filter helpers
+export function getFilterParams(state = automat?.state || initialState) {
+ const filter = String(state.filter ?? '').trim();
+ const onlyUnpaid = Boolean(state.onlyUnpaid);
+ const onlyUndelivered = Boolean(state.onlyUndelivered);
+ const entityId = state.entityId != null && state.entityId !== '' ? Number(state.entityId) : null;
+ const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0)
+ ? Number(state.negativeSeekMonths)
+ : 0;
+ return { filter, onlyUnpaid, onlyUndelivered, entityId, negativeSeekMonths };
+}
+
+export function getFilterKey(state = automat?.state || initialState) {
+ const { filter, onlyUnpaid, onlyUndelivered, entityId, negativeSeekMonths } = getFilterParams(state);
+ return JSON.stringify({ f: filter, u: onlyUnpaid, d: onlyUndelivered, e: entityId, s: negativeSeekMonths });
+}
+
+export function formatFilterLabel(filterKeyOrState) {
+ let f, u, d, e, s;
+ if (typeof filterKeyOrState === 'string') {
+ try {
+ const parsed = JSON.parse(filterKeyOrState);
+ f = parsed.f;
+ u = parsed.u;
+ d = parsed.d;
+ e = parsed.e;
+ s = parsed.s;
+ } catch {
+ return filterKeyOrState;
+ }
+ } else if (filterKeyOrState && typeof filterKeyOrState === 'object') {
+ if ('f' in filterKeyOrState) {
+ f = filterKeyOrState.f;
+ u = filterKeyOrState.u;
+ d = filterKeyOrState.d;
+ e = filterKeyOrState.e;
+ s = filterKeyOrState.s;
+ } else {
+ const params = getFilterParams(filterKeyOrState);
+ f = params.filter;
+ u = params.onlyUnpaid;
+ d = params.onlyUndelivered;
+ e = params.entityId;
+ s = params.negativeSeekMonths;
+ }
+ }
+
+ const parts = [];
+ if (f) parts.push(`Suche: „${f}“`);
+ if (e != null) parts.push(`Kunde: #${e}`);
+ if (u) parts.push('Nur unerstattete');
+ if (d) parts.push('Nur offene');
+ if (s < 0) parts.push(`Seek: ${s}M`);
+ if (parts.length === 0) return 'Standard (Alle Retouren)';
+ return parts.join(' · ');
+}
+
+function countNonNullItems(items) {
+ if (!Array.isArray(items)) return 0;
+ let count = 0;
+ for (let i = 0; i < items.length; i++) {
+ if (items[i] != null) count++;
+ }
+ return count;
+}
+
+function getOrCreateFilterCache(filterKey, filterState = automat?.state || initialState) {
+ let entry = filterCaches.get(filterKey);
+ if (!entry) {
+ const params = getFilterParams(filterState);
+ entry = {
+ key: filterKey,
+ label: formatFilterLabel(params),
+ params,
+ total: 0,
+ unfilteredTotal: automat?.state?.unfilteredTotal || 0,
+ items: null,
+ loadedChunks: new Set(),
+ status: 'idle',
+ error: null,
+ lastAccessed: Date.now(),
+ epoch: queryEpoch,
+ };
+ filterCaches.set(filterKey, entry);
+ }
+ return entry;
+}
+
+function touchChunkLRU(filterKey, chunkIndex) {
+ const key = makeLruKey(filterKey, chunkIndex);
+ const existing = lruChunks.get(key);
+ if (existing) {
+ lruChunks.delete(key);
+ lruChunks.set(key, { ...existing, accessedAt: Date.now() });
+ }
+}
+
+function registerChunkLRU(filterKey, chunkIndex, itemCount = 0) {
+ const key = makeLruKey(filterKey, chunkIndex);
+ if (lruChunks.has(key)) {
+ lruChunks.delete(key);
+ }
+ const now = Date.now();
+ lruChunks.set(key, {
+ filterKey,
+ chunkIndex,
+ loadedAt: now,
+ accessedAt: now,
+ itemCount,
+ });
+
+ while (lruChunks.size > MAX_LRU_CHUNKS) {
+ const oldestKey = lruChunks.keys().next().value;
+ const oldestInfo = lruChunks.get(oldestKey);
+ lruChunks.delete(oldestKey);
+ if (oldestInfo) {
+ evictChunkFromMemory(oldestInfo.filterKey, oldestInfo.chunkIndex, 'lru');
+ }
+ }
+}
+
+function evictChunkFromMemory(filterKey, chunkIndex, reason = 'manual') {
+ const entry = filterCaches.get(filterKey);
+ if (!entry) return;
+
+ if (entry.loadedChunks.has(chunkIndex)) {
+ entry.loadedChunks.delete(chunkIndex);
+ if (Array.isArray(entry.items)) {
+ const offset = chunkIndex * CHUNK_SIZE;
+ for (let i = offset; i < offset + CHUNK_SIZE && i < entry.items.length; i++) {
+ entry.items[i] = null;
+ }
+ }
+
+ const currentKey = getFilterKey(automat.state);
+ if (filterKey === currentKey) {
+ automat.setState({ items: [...entry.items] });
+ }
+
+ recordChunkEvent({
+ type: reason === 'lru' ? 'lru_evict' : 'evict',
+ chunkIndex,
+ filterKey,
+ message: `Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`,
+ });
+ notifyChunkStats();
+ }
+}
+
+function recordChunkEvent(entry) {
+ const event = {
+ id: ++nextEventId,
+ time: Date.now(),
+ epoch: queryEpoch,
+ filterKey: entry.filterKey ?? getFilterKey(automat?.state),
+ ...entry,
+ };
+ chunkEvents.push(event);
+ if (chunkEvents.length > MAX_CHUNK_EVENTS) {
+ chunkEvents.shift();
+ }
+ notifyChunkStats();
+}
+
+function notifyChunkStats() {
+ if (chunkStatsListeners.size === 0) return;
+ const stats = getChunkStats();
+ for (const listener of chunkStatsListeners) {
+ try {
+ listener(stats);
+ } catch (e) {
+ console.error('[retoureAutomat] chunkStatsListener error', e);
+ }
+ }
+}
+
+export function getChunkStats() {
+ const currentKey = getFilterKey(automat?.state);
+ const activeEntry = filterCaches.get(currentKey);
+
+ const total = activeEntry?.total ?? automat?.state?.total ?? 0;
+ const activeLoaded = activeEntry?.loadedChunks ?? new Set();
+ const chunkCount = total > 0 ? Math.ceil(total / CHUNK_SIZE) : (activeLoaded.size > 0 ? Math.max(...activeLoaded) + 1 : 0);
+ const items = activeEntry?.items ?? automat?.state?.items;
+ const loadedItemsCount = countNonNullItems(items);
+
+ // Summarize all cached filter combinations
+ const cachedFiltersSummary = Array.from(filterCaches.values()).map((entry) => ({
+ key: entry.key,
+ label: entry.label,
+ params: entry.params,
+ total: entry.total,
+ loadedChunksCount: entry.loadedChunks.size,
+ loadedChunks: Array.from(entry.loadedChunks).sort((a, b) => a - b),
+ chunkCount: entry.total > 0 ? Math.ceil(entry.total / CHUNK_SIZE) : (entry.loadedChunks.size > 0 ? Math.max(...entry.loadedChunks) + 1 : 0),
+ loadedItemsCount: countNonNullItems(entry.items),
+ lastAccessed: entry.lastAccessed,
+ isActive: entry.key === currentKey,
+ status: entry.status,
+ items: entry.items,
+ }));
+
+ // LRU queue list from oldest (1) to newest (50)
+ const lruEntries = Array.from(lruChunks.entries()).map(([key, info], idx) => ({
+ lruKey: key,
+ filterKey: info.filterKey,
+ filterLabel: filterCaches.get(info.filterKey)?.label || info.filterKey,
+ chunkIndex: info.chunkIndex,
+ loadedAt: info.loadedAt,
+ accessedAt: info.accessedAt,
+ itemCount: info.itemCount,
+ lruRank: idx + 1,
+ }));
+
+ // Active filter's inflight chunks
+ const activeInflight = [];
+ for (const key of inflightChunks.keys()) {
+ if (key.startsWith(`${currentKey}:::`)) {
+ const parsed = parseLruKey(key);
+ activeInflight.push(parsed.chunkIndex);
+ }
+ }
+
+ return {
+ automatName: 'retoure',
+ chunkSize: CHUNK_SIZE,
+ maxLruChunks: MAX_LRU_CHUNKS,
+ lruChunksCount: lruChunks.size,
+ total,
+ chunkCount,
+ loadedChunks: Array.from(activeLoaded).sort((a, b) => a - b),
+ inflightChunks: activeInflight.sort((a, b) => a - b),
+ pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b),
+ loadedItemsCount,
+ status: automat?.state?.status ?? 'idle',
+ queryEpoch,
+ filter: automat?.state?.filter ?? '',
+ onlyUnpaid: automat?.state?.onlyUnpaid ?? false,
+ onlyUndelivered: automat?.state?.onlyUndelivered ?? false,
+ items,
+ activeFilterKey: currentKey,
+ activeFilterLabel: formatFilterLabel(currentKey),
+ filterCaches: cachedFiltersSummary,
+ lruEntries,
+ events: [...chunkEvents],
+ };
+}
+
+export function subscribeChunkStats(listener) {
+ chunkStatsListeners.add(listener);
+ try {
+ listener(getChunkStats());
+ } catch (e) {
+ console.error(e);
+ }
+ return () => {
+ chunkStatsListeners.delete(listener);
+ };
+}
+
+function clearChunkDebounce() {
+ if (chunkDebounceTimer) {
+ clearTimeout(chunkDebounceTimer);
+ chunkDebounceTimer = null;
+ }
+ if (pendingTargetChunks.size > 0) {
+ pendingTargetChunks.clear();
+ notifyChunkStats();
+ }
+ pendingChunkPromise = null;
+ pendingChunkResolve = null;
+ pendingChunkReject = null;
+}
+
+function _executeRequiredChunks(chunks, targetEpoch, prefetch = true, targetFilterKey = getFilterKey(automat.state)) {
+ if (targetEpoch !== queryEpoch) {
+ return Promise.resolve(automat.state.items);
+ }
+
+ const chunkList = Array.from(chunks).sort((a, b) => a - b);
+ if (chunkList.length === 0) {
+ return Promise.resolve(automat.state.items);
+ }
+
+ const promises = [];
+ for (const c of chunkList) {
+ promises.push(actions.loadChunk(c, targetEpoch, targetFilterKey));
+ }
+
+ return Promise.all(promises).then(() => {
+ if (targetEpoch !== queryEpoch) return automat.state.items;
+ if (prefetch) {
+ const maxChunk = chunkList[chunkList.length - 1];
+ const entry = filterCaches.get(targetFilterKey);
+ const total = entry?.total || automat.state.total || 0;
+ for (let c = maxChunk + 1; c <= maxChunk + PREFETCH_CHUNKS; c++) {
+ if (total > 0 && c * CHUNK_SIZE >= total) {
+ break;
+ }
+ void actions.loadChunk(c, targetEpoch, targetFilterKey);
+ }
+ }
+ return automat.state.items;
+ });
+}
+
+// MARK: pure retoure helpers
+export function isRetourePaid(retoure) {
+ if (!retoure) return false;
+ return Boolean(retoure.isPaid ?? retoure.is_paid ?? retoure.paid);
+}
+
+export function isRetoureShipped(retoure) {
+ if (!retoure) return false;
+ return Boolean(retoure.isShipped ?? retoure.is_shipped ?? retoure.delivered);
+}
+
+export function isRetoureDelivered(retoure) {
+ return isRetoureShipped(retoure);
+}
+
+export function getRetoureDate(retoure) {
+ if (!retoure) return null;
+ const raw = retoure.retoureDate ?? retoure.retoure_date ?? retoure.date ?? retoure.createdAt ?? retoure.created_at;
+ if (!raw) return null;
+ const d = new Date(raw);
+ return Number.isNaN(d.getTime()) ? null : d;
+}
+
+function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
+ const page = Math.floor(offset / limit) + 1;
+ const params = {
+ offset,
+ limit,
+ page,
+ pageSize: limit,
+ };
+ const trimmed = String(filterParams.filter ?? '').trim();
+ if (trimmed) {
+ params.q = trimmed;
+ params.search = trimmed;
+ }
+ if (filterParams.onlyUnpaid) {
+ params.isPaid = false;
+ }
+ if (filterParams.onlyUndelivered) {
+ params.isShipped = false;
+ }
+ if (filterParams.entityId != null) {
+ params.entityId = filterParams.entityId;
+ }
+ if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
+ params.seekMonths = Number(filterParams.negativeSeekMonths);
+ }
+ return params;
+}
+
+async function fetchRetouresPageFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
+ const params = buildQueryParamsFor(filterParams, offset, limit);
+ const res = await retoureApi.list(params);
+ const rawList = res?.retoures ?? res?.summaries ?? res?.items ?? (Array.isArray(res) ? res : []);
+ const items = rawList.map(normalizeRetoureSummary);
+ const total = Number(res?.total ?? items.length);
+ const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
+ return { items, total, unfilteredTotal };
+}
+
+function onFilterChange(immediate = false) {
+ if (filterDebounceTimer) {
+ clearTimeout(filterDebounceTimer);
+ filterDebounceTimer = null;
+ }
+ if (immediate) {
+ applyFilterReload();
+ } else {
+ filterDebounceTimer = setTimeout(() => {
+ filterDebounceTimer = null;
+ applyFilterReload();
+ }, 200);
+ }
+}
+
+function applyFilterReload() {
+ queryEpoch++;
+ clearChunkDebounce();
+
+ const currentKey = getFilterKey(automat.state);
+ const cached = filterCaches.get(currentKey);
+
+ if (cached && cached.items !== null) {
+ cached.lastAccessed = Date.now();
+ for (const c of cached.loadedChunks) {
+ touchChunkLRU(currentKey, c);
+ }
+
+ automat.setState({
+ items: cached.items,
+ total: cached.total,
+ unfilteredTotal: cached.unfilteredTotal ?? automat.state.unfilteredTotal ?? 0,
+ status: cached.status || 'ready',
+ error: cached.error || null,
+ });
+
+ if (!cached.loadedChunks.has(0)) {
+ void actions.loadChunk(0, queryEpoch, currentKey);
+ }
+ } else {
+ const entry = getOrCreateFilterCache(currentKey, automat.state);
+ entry.lastAccessed = Date.now();
+
+ automat.setState({
+ items: null,
+ total: 0,
+ unfilteredTotal: automat.state.unfilteredTotal ?? 0,
+ status: 'loading',
+ error: null,
+ });
+
+ void actions.loadChunk(0, queryEpoch, currentKey);
+ }
+}
+
+const actions = {
+ getItems() {
+ if (automat.state.items === null) {
+ void actions.requireChunk(0, 0, { prefetch: false, immediate: true });
+ return null;
+ }
+ return automat.state.items;
+ },
+
+ requireChunk(chunkIndex, endOrOptions = chunkIndex, maybeOptions = {}) {
+ let start;
+ let end;
+ let options = {};
+
+ if (Array.isArray(chunkIndex)) {
+ start = chunkIndex[0] ?? 0;
+ end = chunkIndex[chunkIndex.length - 1] ?? start;
+ if (typeof endOrOptions === 'object' && endOrOptions !== null) {
+ options = endOrOptions;
+ }
+ } else {
+ start = Number(chunkIndex) || 0;
+ if (typeof endOrOptions === 'object' && endOrOptions !== null) {
+ end = start;
+ options = endOrOptions;
+ } else {
+ end = Number(endOrOptions ?? start) || start;
+ if (typeof maybeOptions === 'object' && maybeOptions !== null) {
+ options = maybeOptions;
+ }
+ }
+ }
+
+ start = Math.max(0, Math.floor(start));
+ end = Math.max(start, Math.floor(end));
+ const prefetch = options.prefetch !== false;
+ const currentKey = getFilterKey(automat.state);
+
+ if (options.immediate || automat.state.items === null) {
+ clearChunkDebounce();
+ const chunks = new Set();
+ for (let c = start; c <= end; c++) chunks.add(c);
+ return _executeRequiredChunks(chunks, queryEpoch, prefetch, currentKey);
+ }
+
+ if (lastRequireChunkTick !== currentEventTick) {
+ pendingTargetChunks.clear();
+ lastRequireChunkTick = currentEventTick;
+ if (typeof queueMicrotask === 'function') {
+ queueMicrotask(() => { currentEventTick++; });
+ } else {
+ Promise.resolve().then(() => { currentEventTick++; });
+ }
+ }
+
+ for (let c = start; c <= end; c++) {
+ pendingTargetChunks.add(c);
+ }
+
+ if (chunkDebounceTimer) {
+ clearTimeout(chunkDebounceTimer);
+ chunkDebounceTimer = null;
+ }
+
+ if (!pendingChunkPromise) {
+ pendingChunkPromise = new Promise((resolve, reject) => {
+ pendingChunkResolve = resolve;
+ pendingChunkReject = reject;
+ });
+ }
+
+ const captureEpoch = queryEpoch;
+ chunkDebounceTimer = setTimeout(() => {
+ chunkDebounceTimer = null;
+ const chunksToLoad = new Set(pendingTargetChunks);
+ pendingTargetChunks.clear();
+ notifyChunkStats();
+
+ const resolve = pendingChunkResolve;
+ const reject = pendingChunkReject;
+ pendingChunkPromise = null;
+ pendingChunkResolve = null;
+ pendingChunkReject = null;
+
+ _executeRequiredChunks(chunksToLoad, captureEpoch, true, currentKey)
+ .then((res) => resolve?.(res))
+ .catch((err) => reject?.(err));
+ }, CHUNK_DEBOUNCE_MS);
+
+ return pendingChunkPromise;
+ },
+
+ loadChunk(chunkIndex, targetEpoch = queryEpoch, targetFilterKey = getFilterKey(automat.state)) {
+ const index = Math.max(0, Number(chunkIndex) || 0);
+ const entry = getOrCreateFilterCache(targetFilterKey, automat.state);
+
+ if (targetEpoch !== queryEpoch && targetFilterKey !== getFilterKey(automat.state)) {
+ return Promise.resolve(entry.items);
+ }
+
+ if (entry.loadedChunks.has(index)) {
+ touchChunkLRU(targetFilterKey, index);
+ return Promise.resolve(targetFilterKey === getFilterKey(automat.state) ? automat.state.items : entry.items);
+ }
+
+ const inflightKey = makeLruKey(targetFilterKey, index);
+ if (inflightChunks.has(inflightKey)) {
+ return inflightChunks.get(inflightKey);
+ }
+
+ const offset = index * CHUNK_SIZE;
+ const limit = CHUNK_SIZE;
+ const isActiveFilter = targetFilterKey === getFilterKey(automat.state);
+
+ if (index === 0 && (entry.items === null || (isActiveFilter && automat.state.items === null))) {
+ entry.status = 'loading';
+ entry.error = null;
+ if (isActiveFilter) {
+ automat.setState({ status: 'loading', error: null });
+ }
+ }
+
+ const filterParams = entry.params || getFilterParams(automat.state);
+ const promise = fetchRetouresPageFor(filterParams, offset, limit)
+ .then(({ items, total, unfilteredTotal }) => {
+ const currentTotal = total > 0 ? total : 0;
+ entry.total = currentTotal;
+ entry.unfilteredTotal = unfilteredTotal;
+
+ if (entry.items === null || entry.items.length !== currentTotal) {
+ entry.items = new Array(currentTotal).fill(null);
+ } else {
+ entry.items = [...entry.items];
+ }
+
+ for (let i = 0; i < items.length; i++) {
+ const idx = offset + i;
+ if (idx < entry.items.length) {
+ entry.items[idx] = items[i];
+ }
+ }
+
+ entry.loadedChunks.add(index);
+ entry.status = 'ready';
+ entry.error = null;
+ entry.lastAccessed = Date.now();
+
+ registerChunkLRU(targetFilterKey, index, items.length);
+
+ const currentActive = targetFilterKey === getFilterKey(automat.state);
+ if (currentActive) {
+ automat.setState({
+ items: entry.items,
+ total: currentTotal,
+ unfilteredTotal,
+ status: 'ready',
+ error: null,
+ });
+ }
+
+ notifyChunkStats();
+ return entry.items;
+ })
+ .catch((err) => {
+ entry.status = 'failed';
+ entry.error = err;
+ if (targetFilterKey === getFilterKey(automat.state)) {
+ automat.setState({ status: 'failed', error: err });
+ }
+ notifyChunkStats();
+ throw err;
+ })
+ .finally(() => {
+ inflightChunks.delete(inflightKey);
+ });
+
+ inflightChunks.set(inflightKey, promise);
+ return promise;
+ },
+
+ async reload() {
+ filterCaches.clear();
+ lruChunks.clear();
+ inflightChunks.clear();
+ clearChunkDebounce();
+ queryEpoch++;
+
+ const currentKey = getFilterKey(automat.state);
+ const entry = getOrCreateFilterCache(currentKey, automat.state);
+ entry.lastAccessed = Date.now();
+
+ try {
+ await actions.loadChunk(0, queryEpoch, currentKey);
+ return automat.state.items;
+ } catch (err) {
+ return automat.state.items;
+ }
+ },
+
+ async ensureLoaded() {
+ if (automat.state.status === 'ready' && automat.state.items !== null) {
+ return automat.state.items;
+ }
+ return actions.load();
+ },
+
+ async load() {
+ return actions.requireChunk(0, 0, { immediate: true });
+ },
+
+ filter(query) {
+ actions.setFilter(query);
+ },
+
+ setFilter(value) {
+ const next = String(value ?? '');
+ if (automat.state.filter === next) return;
+ automat.setState({ filter: next });
+ onFilterChange(false);
+ },
+
+ setOnlyUnpaid(value) {
+ const next = Boolean(value);
+ if (automat.state.onlyUnpaid === next) return;
+ automat.setState({ onlyUnpaid: next });
+ onFilterChange(true);
+ },
+
+ setOnlyUndelivered(value) {
+ const next = Boolean(value);
+ if (automat.state.onlyUndelivered === next) return;
+ automat.setState({ onlyUndelivered: next });
+ onFilterChange(true);
+ },
+
+ setEntityId(entityId) {
+ const next = entityId != null && entityId !== '' ? Number(entityId) : null;
+ if (automat.state.entityId === next) return;
+ automat.setState({ entityId: next });
+ onFilterChange(true);
+ },
+
+ addSeekMonth() {
+ const seekMonths = (automat.state.seekMonths ?? 0) + 1;
+ const seekYears = automat.state.seekYears ?? 0;
+ const totalMonths = -(seekMonths + seekYears * 12);
+ automat.setState({ seekMonths, negativeSeekMonths: totalMonths });
+ onFilterChange(true);
+ },
+
+ addSeekYear() {
+ const seekMonths = automat.state.seekMonths ?? 0;
+ const seekYears = (automat.state.seekYears ?? 0) + 1;
+ const totalMonths = -(seekMonths + seekYears * 12);
+ automat.setState({ seekYears, negativeSeekMonths: totalMonths });
+ onFilterChange(true);
+ },
+
+ addNegativeSeek(months) {
+ if (months === -12) {
+ actions.addSeekYear();
+ } else if (months === -1) {
+ actions.addSeekMonth();
+ } else {
+ const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0));
+ const next = current + months;
+ automat.setState({ negativeSeekMonths: Math.min(0, next) });
+ onFilterChange(true);
+ }
+ },
+
+ resetNegativeSeek() {
+ automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 });
+ onFilterChange(true);
+ },
+
+ resetFilters() {
+ automat.setState({
+ filter: '',
+ onlyUnpaid: false,
+ onlyUndelivered: false,
+ seekMonths: 0,
+ seekYears: 0,
+ negativeSeekMonths: 0,
+ });
+ onFilterChange(true);
+ },
+
+ patchRetoure(retoureId, patch) {
+ if (!retoureId || !patch) return;
+ for (const entry of filterCaches.values()) {
+ if (Array.isArray(entry.items)) {
+ let changed = false;
+ for (let i = 0; i < entry.items.length; i++) {
+ const it = entry.items[i];
+ if (it && (it.id === retoureId || it.manifest_id === retoureId)) {
+ entry.items[i] = { ...it, ...patch };
+ changed = true;
+ }
+ }
+ if (changed && entry.key === getFilterKey(automat.state)) {
+ automat.setState({ items: [...entry.items] });
+ }
+ }
+ }
+ },
+
+ // MARK: evicts a specific chunk from cache (optional filterKey, defaults to active)
+ evictChunk(chunkIndex, filterKey = getFilterKey(automat.state)) {
+ const index = Math.max(0, Number(chunkIndex) || 0);
+ const key = makeLruKey(filterKey, index);
+ if (lruChunks.has(key)) {
+ lruChunks.delete(key);
+ }
+ evictChunkFromMemory(filterKey, index, 'manual');
+ return automat.state.items;
+ },
+
+ // MARK: clears a specific filter cache and removes its chunks from LRU
+ clearFilterCache(filterKey = getFilterKey(automat.state)) {
+ for (const [lruKey, info] of Array.from(lruChunks.entries())) {
+ if (info.filterKey === filterKey) {
+ lruChunks.delete(lruKey);
+ }
+ }
+
+ const entry = filterCaches.get(filterKey);
+ if (entry) {
+ entry.loadedChunks.clear();
+ entry.items = null;
+ entry.total = 0;
+ entry.status = 'idle';
+ entry.error = null;
+ }
+
+ if (filterKey === getFilterKey(automat.state)) {
+ automat.setState({
+ items: null,
+ total: 0,
+ status: 'idle',
+ error: null,
+ });
+ }
+
+ recordChunkEvent({
+ type: 'clear_filter',
+ filterKey,
+ message: `Filter-Cache für "${formatFilterLabel(filterKey)}" geleert`,
+ });
+ notifyChunkStats();
+ return automat.state.items;
+ },
+
+ // MARK: clears all loaded chunks and filter caches from RAM
+ clearCache() {
+ clearChunkDebounce();
+ lruChunks.clear();
+ inflightChunks.clear();
+
+ for (const entry of filterCaches.values()) {
+ entry.loadedChunks.clear();
+ entry.items = null;
+ entry.total = 0;
+ entry.status = 'idle';
+ entry.error = null;
+ }
+
+ automat.setState({
+ items: null,
+ total: 0,
+ status: 'idle',
+ error: null,
+ });
+
+ recordChunkEvent({
+ type: 'clear',
+ message: 'Gesamter LRU-Cache und alle Filter-Caches geleert',
+ });
+ notifyChunkStats();
+ return automat.state.items;
+ },
+
+ // MARK: prefetches all remaining chunks for a filter (bounded by MAX_LRU_CHUNKS)
+ prefetchAllChunks(filterKey = getFilterKey(automat.state)) {
+ const entry = getOrCreateFilterCache(filterKey, automat.state);
+ const total = entry.total || automat.state.total || 0;
+ const chunkCount = Math.ceil(total / CHUNK_SIZE);
+
+ recordChunkEvent({
+ type: 'prefetch_all',
+ filterKey,
+ message: `Starte Prefetch aller ${chunkCount} Chunks für "${entry.label}"`,
+ });
+
+ for (let c = 0; c < chunkCount; c++) {
+ const inflightKey = makeLruKey(filterKey, c);
+ if (!entry.loadedChunks.has(c) && !inflightChunks.has(inflightKey)) {
+ void actions.loadChunk(c, queryEpoch, filterKey);
+ }
+ }
+ notifyChunkStats();
+ },
+
+ reset() {
+ queryEpoch++;
+ if (filterDebounceTimer) {
+ clearTimeout(filterDebounceTimer);
+ filterDebounceTimer = null;
+ }
+ clearChunkDebounce();
+ filterCaches.clear();
+ lruChunks.clear();
+ inflightChunks.clear();
+
+ recordChunkEvent({
+ type: 'reset',
+ message: 'Retoure-Automat und sämtliche Caches zurückgesetzt',
+ });
+ notifyChunkStats();
+ automat.setState(initialState);
+ },
+};
+
+export const retoureAutomat = new Automat(initialState, actions, { name: 'retoure' });
+automat = retoureAutomat;
+
+// MARK: exported methods for direct invocation
+export function getItems() {
+ return actions.getItems();
+}
+
+export function requireChunk(chunkIndex, endOrOptions, maybeOptions) {
+ return actions.requireChunk(chunkIndex, endOrOptions, maybeOptions);
+}
+
+retoureAutomat.getItems = getItems;
+retoureAutomat.requireChunk = requireChunk;
+retoureAutomat.CHUNK_SIZE = CHUNK_SIZE;
+retoureAutomat.MAX_LRU_CHUNKS = MAX_LRU_CHUNKS;
+retoureAutomat.getChunkStats = getChunkStats;
+retoureAutomat.subscribeChunkStats = subscribeChunkStats;
+retoureAutomat.evictChunk = actions.evictChunk;
+retoureAutomat.clearFilterCache = actions.clearFilterCache;
+retoureAutomat.clearCache = actions.clearCache;
+retoureAutomat.prefetchAllChunks = actions.prefetchAllChunks;
+retoureAutomat.getFilterKey = getFilterKey;
+retoureAutomat.formatFilterLabel = formatFilterLabel;
+
+// Reset on logout or company change
+let lastCompanyId = null;
+authAutomat.subscribe((auth) => {
+ const companyId = auth.user?.selectedCompanyId ?? null;
+ if (auth.status === 'anon') {
+ actions.reset();
+ lastCompanyId = null;
+ return;
+ }
+ if (lastCompanyId !== null && companyId !== lastCompanyId) {
+ actions.reset();
+ }
+ if (auth.status === 'authed' && companyId != null) {
+ lastCompanyId = companyId;
+ void actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
+ }
+});
+
+export default retoureAutomat;
+
+export function selectRetoures(state = automat.state) {
+ return state.items;
+}
+
+export function selectRetoureTotal(state = automat.state) {
+ return state.total;
+}
+
+export function selectRetoureUnfilteredTotal(state = automat.state) {
+ return state.unfilteredTotal;
+}
+
diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js
index f6a6149..7127952 100644
--- a/src/automats/sseAutomat.js
+++ b/src/automats/sseAutomat.js
@@ -35,6 +35,7 @@ import supplierAutomat from './supplierAutomat';
import deliveryAutomat from './deliveryAutomat';
import orderAutomat from './orderAutomat';
import quoteAutomat from './quoteAutomat';
+import retoureAutomat from './retoureAutomat';
import shipmentAutomat from './shipmentAutomat';
import carrierAutomat from './carrierAutomat';
@@ -282,6 +283,15 @@ function connect() {
});
});
+ addTrackedListener(es, 'retoure', () => {
+ void retoureAutomat.actions.reload().catch((err) => {
+ console.error('[sse] retoure refresh failed', err);
+ });
+ void shipmentAutomat.actions.reload().catch((err) => {
+ console.error('[sse] shipment refresh failed', err);
+ });
+ });
+
diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx
index 77a44ac..442c178 100644
--- a/src/components/manifest/ManifestForm.jsx
+++ b/src/components/manifest/ManifestForm.jsx
@@ -20,6 +20,7 @@ import entityAutomat from '../../automats/entityAutomat';
import customergroupAutomat from '../../automats/customergroupAutomat';
import supplierAutomat from '../../automats/supplierAutomat';
import orderAutomat from '../../automats/orderAutomat';
+import retoureAutomat from '../../automats/retoureAutomat';
import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat';
import warehouseAutomat from '../../automats/warehouseAutomat';
import sseAutomat from '../../automats/sseAutomat';
@@ -46,6 +47,22 @@ import {
getManifestEditAutomat,
} from '../../automats/manifestDraftAutomat';
+export function computeDocGrossTotal(lines) {
+ if (!Array.isArray(lines)) return 0;
+ return lines.reduce((acc, line) => {
+ const amount = Number(line.amount ?? line.count) || 0;
+ if (line.gross != null && line.gross !== '') {
+ const g = Number(line.gross) || 0;
+ return acc + amount * g;
+ }
+ const net = Number(line.net ?? line.netprice ?? line.netPrice) || 0;
+ const tax = Number(line.tax ?? line.taxrate ?? line.taxRate) || 0;
+ const rebate = Number(line.rebate) || 0;
+ const lineNet = amount * net * (1 - rebate / 100);
+ return acc + lineNet * (1 + tax / 100);
+ }, 0);
+}
+
class ManifestForm extends PureComponent {
constructor(props) {
super(props);
@@ -481,7 +498,7 @@ class ManifestForm extends PureComponent {
let shipments = doc.shipments;
let payments = doc.payments;
- if (kind === 'order') {
+ if (kind === 'order' || kind === 'retoure') {
if (!shipments) {
try {
const shRes = await shipmentApi.listByOrder(docId);
@@ -512,6 +529,10 @@ class ManifestForm extends PureComponent {
}
}
+ const linesGross = computeDocGrossTotal(lines);
+ const computedOrderTotal = doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? (linesGross !== 0 ? linesGross : null);
+ const computedPaidTotal = doc.paidTotal ?? doc.paid_total ?? (Array.isArray(payments) && payments.length > 0 ? payments.reduce((acc, p) => acc + (Number(p.amount) || 0), 0) : null);
+
const loadedDoc = {
date: dateInputValue(doc.date),
createdAt: doc.createdAt ?? doc.created_at ?? null,
@@ -524,11 +545,11 @@ class ManifestForm extends PureComponent {
shipments: shipments || [],
payments: payments || [],
shippings: doc.shippings || [],
- orderTotal: doc.orderTotal ?? null,
- paidTotal: doc.paidTotal ?? null,
- paid: Boolean(doc.paid),
- shipped: Boolean(doc.shipped),
- isShipped: Boolean(doc.isShipped ?? doc.shipped),
+ orderTotal: computedOrderTotal,
+ paidTotal: computedPaidTotal,
+ paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid),
+ shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_shipped),
+ isShipped: Boolean(doc.isShipped ?? doc.shipped ?? doc.is_shipped),
delivered: Boolean(doc.delivered),
};
@@ -1211,7 +1232,11 @@ class ManifestForm extends PureComponent {
async handleShipmentCreated() {
await this.loadEdit();
- void orderAutomat.actions.reload();
+ if (this.props.kind === 'retoure') {
+ void retoureAutomat.actions.reload();
+ } else {
+ void orderAutomat.actions.reload();
+ }
}
async handleAttachmentDelete(attachment) {
@@ -1315,12 +1340,21 @@ class ManifestForm extends PureComponent {
attachments: this.state.attachments,
};
+ const linesGross = computeDocGrossTotal(savedDoc.lines);
+ const savedDocWithTotals = {
+ ...savedDoc,
+ orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? this.state.orderTotal ?? (linesGross !== 0 ? linesGross : null),
+ paidTotal: doc.paidTotal ?? doc.paid_total ?? this.state.paidTotal ?? null,
+ shipments: this.state.shipments,
+ payments: this.state.payments,
+ };
+
if (this.editAutomat && String(this.editAutomat.state.id) === String(docId)) {
- this.editAutomat.actions.commitSaved(savedDoc);
+ this.editAutomat.actions.commitSaved(savedDocWithTotals);
}
this.setState({
- ...savedDoc,
+ ...savedDocWithTotals,
isDirty: false,
dirtyFields: null,
dirtyLineKeys: [],
@@ -1430,8 +1464,8 @@ class ManifestForm extends PureComponent {
onPdfDelete={this.handleAttachmentDelete}
onPdfCreate={kind === 'quote' || kind === 'order' ? this.handleCreatePdf : undefined}
onCreateOrder={kind === 'quote' && this.props.mode === 'edit' ? this.handleCreateOrder : undefined}
- onOpenCreateShipment={kind === 'order' && this.props.mode === 'edit' ? this.handleOpenCreateShipment : undefined}
- deliverDisabled={kind === 'order' && this.isFullyDelivered()}
+ onOpenCreateShipment={(kind === 'order' || kind === 'retoure') && this.props.mode === 'edit' ? this.handleOpenCreateShipment : undefined}
+ deliverDisabled={(kind === 'order' || kind === 'retoure') && this.isFullyDelivered()}
onSave={this.handleSave}
onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange}
@@ -1439,13 +1473,24 @@ class ManifestForm extends PureComponent {
onExternalNumberChange={this.handleExternalNumberChange}
/>
{status === 'ready' ? this.renderArticles() : null}
- {status === 'ready' && kind === 'order' && this.props.mode === 'edit' ? (
+ {status === 'ready' && (kind === 'order' || kind === 'retoure') && this.props.mode === 'edit' ? (
0
+ ? this.state.payments.reduce((acc, p) => acc + (Number(p.amount) || 0), 0)
+ : 0)
+ }
paid={this.state.paid}
shipped={this.state.shipped}
delivered={this.state.delivered}
@@ -1482,6 +1527,7 @@ class ManifestForm extends PureComponent {
);
})()}
- {(totalNet > 0 || totalGross > 0) && (
+ {(totalNet !== 0 || totalGross !== 0) && (
Netto:{' '}
diff --git a/src/components/manifest/OrderDocumentsTiles.jsx b/src/components/manifest/OrderDocumentsTiles.jsx
index 3e6c1a7..1a63529 100644
--- a/src/components/manifest/OrderDocumentsTiles.jsx
+++ b/src/components/manifest/OrderDocumentsTiles.jsx
@@ -11,10 +11,19 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import AddIcon from '@mui/icons-material/Add';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+import DialogContent from '@mui/material/DialogContent';
+import DialogActions from '@mui/material/DialogActions';
+import DialogTitle from '@mui/material/DialogTitle';
+import TextField from '@mui/material/TextField';
+import MenuItem from '@mui/material/MenuItem';
+import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels';
-import { formatDate, formatDateTime } from './manifestConfig';
+import AppDialog from '../AppDialog';
+import { payment as paymentApi } from '../../api';
+import { notifyError } from '../../utils/notify';
+import { formatDate, formatDateTime, todayInputValue } from './manifestConfig';
import { formatPrice } from '../../utils/price';
-import { typeLabel, typeIcon } from './OrderPaymentList';
+import { typeLabel, typeIcon, PAYMENT_TYPES } from './OrderPaymentList';
import ShipmentDetailDialog from './ShipmentDetailDialog';
import {
GREEN_PANEL,
@@ -23,19 +32,81 @@ import {
} from '../../colors';
/**
- * Renders 1-wide associated document tiles for an order in edit mode:
+ * Renders 1-wide associated document tiles for an order or retoure in edit mode:
* 1. Lieferscheine / Auslieferungen (Shipments)
- * 2. Zahlungen (Payments)
+ * 2. Zahlungen / Erstattungen (Payments)
*/
export class OrderDocumentsTiles extends PureComponent {
constructor(props) {
super(props);
this.state = {
selectedShipment: null,
+ paymentDialogOpen: false,
+ paymentAmount: '',
+ paymentType: 'cash',
+ paymentDate: todayInputValue(),
+ paymentSaving: false,
};
this.handleOpenShipment = this.handleOpenShipment.bind(this);
this.handleCloseShipment = this.handleCloseShipment.bind(this);
this.handleOpenPayments = this.handleOpenPayments.bind(this);
+ this.handleOpenPaymentDialog = this.handleOpenPaymentDialog.bind(this);
+ this.handleClosePaymentDialog = this.handleClosePaymentDialog.bind(this);
+ this.handleSubmitPayment = this.handleSubmitPayment.bind(this);
+ }
+
+ handleOpenPaymentDialog() {
+ const { orderTotal, paidTotal, kind } = this.props;
+ const tot = Number(orderTotal) || 0;
+ const pd = Number(paidTotal) || 0;
+ const absTot = Math.abs(tot);
+ const absPd = Math.abs(pd);
+ const isPaid = Boolean(this.props.paid || (absTot > 0 && absPd >= absTot - 0.005));
+ const remaining = isPaid ? 0 : Math.max(0, absTot - absPd);
+ const defaultAmount = remaining > 0
+ ? (kind === 'retoure' || tot < 0 ? -remaining : remaining)
+ : '';
+ this.setState({
+ paymentDialogOpen: true,
+ paymentAmount: defaultAmount !== '' ? String(Math.round(defaultAmount * 100) / 100) : '',
+ paymentType: 'cash',
+ paymentDate: todayInputValue(),
+ });
+ }
+
+ handleClosePaymentDialog() {
+ this.setState({ paymentDialogOpen: false, paymentSaving: false });
+ }
+
+ async handleSubmitPayment(e) {
+ e?.preventDefault?.();
+ const { orderId, onShipmentChanged } = this.props;
+ const { paymentAmount, paymentType, paymentDate, paymentSaving } = this.state;
+ if (paymentSaving || !orderId) return;
+
+ const amt = Number(String(paymentAmount).replace(',', '.'));
+ if (Number.isNaN(amt) || amt === 0) {
+ enqueueSnackbar('Bitte einen gültigen Betrag ungleich 0 eingeben.', { variant: 'warning' });
+ return;
+ }
+
+ this.setState({ paymentSaving: true });
+ try {
+ await paymentApi.create({
+ manifestId: orderId,
+ amount: amt,
+ type: paymentType,
+ date: paymentDate || undefined,
+ });
+ enqueueSnackbar(amt < 0 ? 'Erstattung erfolgreich gebucht!' : 'Zahlung erfolgreich erfasst!', {
+ variant: 'success',
+ });
+ this.handleClosePaymentDialog();
+ onShipmentChanged?.();
+ } catch (err) {
+ notifyError(err);
+ this.setState({ paymentSaving: false });
+ }
}
handleOpenShipment(sh) {
@@ -163,31 +234,53 @@ export class OrderDocumentsTiles extends PureComponent {
}
renderPaymentsTile() {
- const { payments = [], paid, paidTotal, orderTotal } = this.props;
+ const { payments = [], paid, paidTotal, orderTotal, kind } = this.props;
+ const isRetoure = kind === 'retoure';
const tot = Number(orderTotal) || 0;
const pd = Number(paidTotal) || 0;
- const openAmount = Math.max(0, tot - pd);
+ const absTot = Math.abs(tot);
+ const absPd = Math.abs(pd);
+ const isPaid = Boolean(paid || (absTot > 0 && absPd >= absTot - 0.005));
- const isPaid = Boolean(paid || tot <= 0 || (tot > 0 && pd >= tot));
- const statusLabel = isPaid ? 'Bezahlt' : pd > 0 ? 'Teilzahlung' : 'Offen';
- const statusColor = isPaid ? 'success' : pd > 0 ? 'warning' : 'error';
+ let openAmount = 0;
+ if (!isPaid) {
+ const remaining = Math.max(0, absTot - absPd);
+ openAmount = tot < 0 ? -remaining : remaining;
+ }
+
+ const statusLabel = isPaid
+ ? (isRetoure ? 'Erstattet' : 'Bezahlt')
+ : Math.abs(pd) > 0
+ ? 'Teilzahlung'
+ : 'Offen';
+ const statusColor = isPaid ? 'success' : Math.abs(pd) > 0 ? 'warning' : 'error';
return (
-
+
-
+
+
+
+
{/* Financial KPI Card */}
@@ -203,7 +296,7 @@ export class OrderDocumentsTiles extends PureComponent {
>
- Summe:
+ {isRetoure ? 'Gutschrift / Summe:' : 'Summe:'}
{formatPrice(tot)}
@@ -211,7 +304,7 @@ export class OrderDocumentsTiles extends PureComponent {
- Bezahlt:
+ {isRetoure ? 'Erstattet:' : 'Bezahlt:'}
{formatPrice(pd)}
@@ -222,7 +315,7 @@ export class OrderDocumentsTiles extends PureComponent {
Offen:
- 0 ? 'error.main' : 'text.secondary' }}>
+
{formatPrice(openAmount)}
@@ -233,6 +326,8 @@ export class OrderDocumentsTiles extends PureComponent {
{payments.map((pay) => {
const pType = pay.type || pay.paymentType || pay.payment_type;
const payDate = pay.date || pay.createdAt || pay.created_at || pay.timestamp;
+ const pAmt = Number(pay.amount) || 0;
+ const isRefund = pAmt < 0;
return (
- +{formatPrice(pay.amount)}
+ {isRefund ? formatPrice(pAmt) : `+${formatPrice(pAmt)}`}
@@ -289,6 +390,9 @@ export class OrderDocumentsTiles extends PureComponent {
}
render() {
+ const { kind } = this.props;
+ const isRetoure = kind === 'retoure';
+
return (
<>
{this.renderShipmentsTile()}
@@ -300,6 +404,56 @@ export class OrderDocumentsTiles extends PureComponent {
onNavigate={this.props.navigate}
onChanged={this.props.onShipmentChanged}
/>
+
+
+ this.setState({ paymentAmount: e.target.value })}
+ helperText={isRetoure ? 'Negative Beträge für Gutschrift/Erstattung (z.B. -50.00)' : 'Zahlungsbetrag'}
+ fullWidth
+ required
+ />
+ this.setState({ paymentType: e.target.value })}
+ fullWidth
+ >
+ {PAYMENT_TYPES.map((t) => (
+
+ ))}
+
+ this.setState({ paymentDate: e.target.value })}
+ slotProps={{ inputLabel: { shrink: true } }}
+ fullWidth
+ />
+
+
+
+
+
+
>
);
}
diff --git a/src/components/manifest/OrderShipmentDialog.jsx b/src/components/manifest/OrderShipmentDialog.jsx
index dec27e2..08bd444 100644
--- a/src/components/manifest/OrderShipmentDialog.jsx
+++ b/src/components/manifest/OrderShipmentDialog.jsx
@@ -23,7 +23,7 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import { enqueueSnackbar } from 'notistack';
import AppDialog from '../AppDialog';
-import { order as orderApi } from '../../api';
+import { order as orderApi, retoure as retoureApi } from '../../api';
import { notifyError } from '../../utils/notify';
import { formatStock } from '../article/searchFormat';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
@@ -364,7 +364,9 @@ export default class OrderShipmentDialog extends PureComponent {
lines: linesToShip,
};
- const res = await orderApi.createShipment(orderId, payload);
+ const res = this.props.kind === 'retoure'
+ ? await retoureApi.createShipment(orderId, payload)
+ : await orderApi.createShipment(orderId, payload);
const shipment = res?.shipment;
const shNum = shipment?.number || '';
@@ -380,7 +382,7 @@ export default class OrderShipmentDialog extends PureComponent {
this.setState({ saving: false, error: err?.message || String(err) });
}
}
- // // MARK: calls orderApi.createShipment to ship all remaining lines
+ // // MARK: calls orderApi/retoureApi.createShipment to ship all remaining lines
// #region POST /api/order/:id/shipment — ship all remaining
async handleShipAll() {
const { orderId, onClose, onSuccess } = this.props;
@@ -394,7 +396,9 @@ export default class OrderShipmentDialog extends PureComponent {
externalNumber: externalNumber.trim() || undefined,
};
- const res = await orderApi.createShipment(orderId, payload);
+ const res = this.props.kind === 'retoure'
+ ? await retoureApi.createShipment(orderId, payload)
+ : await orderApi.createShipment(orderId, payload);
const shipment = res?.shipment;
const shNum = shipment?.number || '';
diff --git a/src/components/manifest/RetoureList.jsx b/src/components/manifest/RetoureList.jsx
new file mode 100644
index 0000000..4ec0bc2
--- /dev/null
+++ b/src/components/manifest/RetoureList.jsx
@@ -0,0 +1,852 @@
+import { PureComponent, createRef } from 'react';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Chip from '@mui/material/Chip';
+import CircularProgress from '@mui/material/CircularProgress';
+import Divider from '@mui/material/Divider';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import IconButton from '@mui/material/IconButton';
+import ListItemButton from '@mui/material/ListItemButton';
+import Typography from '@mui/material/Typography';
+import Checkbox from '@mui/material/Checkbox';
+import Switch from '@mui/material/Switch';
+import Skeleton from '@mui/material/Skeleton';
+import Tooltip from '@mui/material/Tooltip';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+import ViewModuleIcon from '@mui/icons-material/ViewModule';
+import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
+import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
+import { enqueueSnackbar } from 'notistack';
+import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
+import FillSpace from 'dashgui/FillSpace';
+import { PanelTile, PanelTileGrid } from 'dashgui/panels';
+import BackendError from '../BackendError';
+import retoureAutomat, {
+ CHUNK_SIZE,
+ isRetourePaid,
+ isRetoureShipped,
+} from '../../automats/retoureAutomat';
+import numberAutomat from '../../automats/numberAutomat';
+import { retoure as retoureApi } from '../../api';
+import { backendErrorFrom } from '../../utils/backendError';
+import { notifyError } from '../../utils/notify';
+import { formatPrice } from '../../utils/price';
+import { getMaxDocumentNumber } from '../../utils/documentNumber';
+import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
+import withNavigate from '../withNavigate';
+import {
+ formatDate,
+ loadParties,
+ manifestConfig,
+ numberDisplay,
+ partyDisplayName,
+} from './manifestConfig';
+
+// MARK: pure helpers
+function retoureGrossTotal(lines) {
+ if (!Array.isArray(lines)) return 0;
+ return lines.reduce((acc, line) => {
+ const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100);
+ return acc + net * (1 + (Number(line.taxrate) || 0) / 100);
+ }, 0);
+}
+
+const cfg = manifestConfig('retoure');
+
+// Dummy retoure generator using the active sequence scheme with max digits for layout testing
+export function createDummyTestRetoure() {
+ const maxNumber = getMaxDocumentNumber('retoure');
+ return {
+ id: 999999,
+ number: maxNumber,
+ externalNumber: 'REF-999999',
+ date: '2030-10-30T12:00:00Z',
+ customerName: 'Kunde',
+ positions: 999,
+ grossTotal: -30000.90,
+ };
+}
+
+const rowGridSx = {
+ display: 'grid',
+ gridTemplateColumns: 'max-content max-content 1fr max-content max-content max-content max-content auto',
+ gridTemplateRows: '0px 1fr',
+ rowGap: 0,
+ columnGap: 1.5,
+ alignItems: 'center',
+ width: '100%',
+ boxSizing: 'border-box',
+};
+
+const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
+
+const measuringCellSx = {
+ visibility: 'hidden',
+ height: 0,
+ boxSizing: 'border-box',
+ pointerEvents: 'none',
+ textTransform: 'none',
+ letterSpacing: 'normal',
+};
+
+const headerCellSx = {
+ color: 'text.secondary',
+ typography: 'caption',
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.04em',
+ userSelect: 'none',
+};
+
+function renderInvisibleDummyRow(dummy) {
+ const d = dummy || createDummyTestRetoure();
+ return (
+ <>
+
+ {d.number}
+ {d.externalNumber || 'Nr. / Ref.'}
+
+
+ {d.date ? formatDate(d.date) : '–'}
+
+
+ {d.customerName}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatPrice(d.grossTotal)}
+
+
+
+
+
+ >
+ );
+}
+
+/**
+ * Ambient context for `RetoureRow`: FillSpace renders rows as bare elements
+ * (`
`), so the list publishes callbacks and the party map
+ * here on every render.
+ */
+const rowContext = {
+ partiesById: {},
+ busyId: null,
+ onOpen: null,
+ onDelete: null,
+ onTogglePaid: null,
+ onToggleShipped: null,
+ headerItem: null,
+};
+
+// MARK:
+class RetoureRow extends PureComponent {
+ partyName(partyId, fallbackName) {
+ if (partyId == null) return fallbackName || '–';
+ const party = rowContext.partiesById[partyId];
+ return partyDisplayName('retoure', party) || fallbackName || `Kunde #${partyId}`;
+ }
+
+ render() {
+ const { item: retoure, disabled } = this.props;
+ const dummy = this.props.headerItem || rowContext.headerItem;
+
+ // Data-free probe (FillSpace measures the row height with `item` undefined).
+ if (!retoure) {
+ if (disabled) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {renderInvisibleDummyRow(dummy)}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const { busyId, onOpen, onDelete, onTogglePaid, onToggleShipped } = rowContext;
+ const lines = retoure.lines ?? [];
+ const positions = retoure.positions ?? lines.length;
+ const gross = retoure.gross_total ?? retoure.grossTotal ?? (lines.length > 0 ? retoureGrossTotal(lines) : 0);
+ const paid = isRetourePaid(retoure);
+ const shipped = isRetoureShipped(retoure);
+ const num = retoure.number ?? retoure.retoure_number ?? `RET-${retoure.id}`;
+ const dt = retoure.date ?? retoure.retoure_date;
+ const entityId = retoure.entityId ?? retoure.entity_id;
+ const customer = this.partyName(entityId, retoure.customerName ?? retoure.customer_name);
+
+ return (
+ onOpen?.(retoure)}
+ disabled={disabled}
+ >
+ {renderInvisibleDummyRow(dummy)}
+
+
+
+ {num}
+
+
+ {numberDisplay(retoure.externalNumber)}
+
+
+
+
+
+ {dt ? formatDate(dt) : '–'}
+
+
+
+
+
+ {customer}
+
+
+
+
+ e.stopPropagation()}
+ onChange={(e) => onTogglePaid?.(retoure, e.target.checked)}
+ slotProps={{ input: { 'aria-label': `Retoure ${num} bezahlt` } }}
+ sx={{ p: 0.5 }}
+ />
+
+
+
+ e.stopPropagation()}
+ onChange={(e) => onToggleShipped?.(retoure, e.target.checked)}
+ slotProps={{ input: { 'aria-label': `Retoure ${num} geliefert` } }}
+ sx={{ p: 0.5 }}
+ />
+
+
+
+ 0 ? 'text.primary' : 'text.disabled',
+ '& .MuiChip-label': { px: 0.5 },
+ }}
+ />
+
+
+
+
+ {gross !== 0 ? formatPrice(gross) : '—'}
+
+
+
+
+ {
+ e.stopPropagation();
+ onDelete?.(retoure);
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ sx={{ width: 40, height: 40 }}
+ >
+
+
+
+
+ );
+ }
+}
+
+// MARK:
+class RetoureSearchField extends PureComponent {
+ constructor(props) {
+ super(props);
+ const s = retoureAutomat.state;
+ const stats = retoureAutomat.getChunkStats?.();
+ this.state = {
+ input: s.filter ?? '',
+ onlyUnpaid: s.onlyUnpaid ?? false,
+ onlyUndelivered: s.onlyUndelivered ?? false,
+ seekMonths: s.seekMonths ?? 0,
+ seekYears: s.seekYears ?? 0,
+ negativeSeekMonths: s.negativeSeekMonths ?? 0,
+ lruChunksCount: stats?.lruChunksCount || 0,
+ maxLruChunks: stats?.maxLruChunks || 50,
+ filterCachesCount: stats?.filterCaches?.length || 1,
+ };
+ }
+
+ componentDidMount() {
+ this.unsub = retoureAutomat.subscribe(this, (state) => ({
+ input: state.filter ?? '',
+ onlyUnpaid: state.onlyUnpaid ?? false,
+ onlyUndelivered: state.onlyUndelivered ?? false,
+ seekMonths: state.seekMonths ?? 0,
+ seekYears: state.seekYears ?? 0,
+ negativeSeekMonths: state.negativeSeekMonths ?? 0,
+ }));
+ if (import.meta.env.DEV) {
+ this.unsubStats = retoureAutomat.subscribeChunkStats?.((stats) => {
+ this.setState({
+ lruChunksCount: stats.lruChunksCount,
+ maxLruChunks: stats.maxLruChunks,
+ filterCachesCount: stats.filterCaches?.length || 1,
+ });
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.unsub?.();
+ this.unsubStats?.();
+ }
+
+ render() {
+ const {
+ inputRef,
+ onCreate,
+ customerFilterOpen,
+ onToggleCustomerFilter,
+ selectedCustomer,
+ onClearCustomerFilter,
+ } = this.props;
+ const { input, onlyUnpaid, onlyUndelivered, seekMonths, seekYears } = this.state;
+ const monthActive = seekMonths > 0;
+ const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
+ const yearActive = seekYears > 0;
+ const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
+
+ return (
+
+
+ {/* Top row: search field, customer filter button, active chip & create button */}
+
+ retoureAutomat.actions.filter(next)}
+ />
+
+ {selectedCustomer && (
+
+ )}
+
+
+
+ {/* Filter Bar: "Nur ungezahlte", "Nur ungelieferte", Negativeseek (-1 Month, -1 Year, Reset) */}
+
+ {/* Left: Unpaid & Undelivered toggles */}
+
+ retoureAutomat.actions.setOnlyUnpaid(e.target.checked)}
+ />
+ }
+ label={
+
+ Nur unerstattete
+
+ }
+ sx={{ m: 0 }}
+ />
+
+
+
+ retoureAutomat.actions.setOnlyUndelivered(e.target.checked)}
+ />
+ }
+ label={
+
+ Nur offene
+
+ }
+ sx={{ m: 0 }}
+ />
+
+
+ {/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}
+
+
+
+
+
+
+
+
+
+ {import.meta.env.DEV && (
+
+ }
+ label={this.state.lruChunksCount > 0 ? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})` : 'Chunk-Cache'}
+ onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
+ sx={{
+ cursor: 'pointer',
+ height: 28,
+ fontWeight: 600,
+ fontSize: '0.75rem',
+ bgcolor: 'background.paper',
+ boxShadow: 1,
+ '&:hover': { bgcolor: 'action.hover' },
+ }}
+ />
+
+ )}
+
+
+
+
+ );
+ }
+}
+
+// MARK:
+export class RetoureList extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.headerItem = createDummyTestRetoure();
+ // getItems() returns current items or null and triggers loading of the first chunk
+ this.state = {
+ items: retoureAutomat.getItems(),
+ unfilteredTotal: retoureAutomat.state.unfilteredTotal ?? 0,
+ filterKey: retoureAutomat.getFilterKey?.() ?? '',
+ entityId: retoureAutomat.state.entityId ?? null,
+ customerFilterOpen: false,
+ partiesById: {},
+ partiesList: [],
+ busyId: null,
+ };
+ this.searchRef = createRef();
+ this._cancelled = false;
+ this.handleRetry = this.handleRetry.bind(this);
+ this.handleCreate = this.handleCreate.bind(this);
+ this.handleOpen = this.handleOpen.bind(this);
+ this.handleDelete = this.handleDelete.bind(this);
+ this.handleTogglePaid = this.handleTogglePaid.bind(this);
+ this.handleToggleShipped = this.handleToggleShipped.bind(this);
+ this.handlePageChange = this.handlePageChange.bind(this);
+ this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this);
+ this.handleSelectCustomer = this.handleSelectCustomer.bind(this);
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ this.unsub = retoureAutomat.subscribe(this, (state) => ({
+ items: state.items,
+ unfilteredTotal: state.unfilteredTotal ?? 0,
+ filterKey: retoureAutomat.getFilterKey(state),
+ entityId: state.entityId ?? null,
+ }));
+ this.unsubNumber = numberAutomat.subscribe(this, () => {
+ this.headerItem = createDummyTestRetoure();
+ this.forceUpdate();
+ });
+ void this.loadParties();
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ this.unsub?.();
+ this.unsubNumber?.();
+ }
+
+ handleToggleCustomerFilter() {
+ this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen }));
+ }
+
+ handleSelectCustomer(entityId) {
+ retoureAutomat.actions.setEntityId(entityId);
+ }
+
+ async loadParties() {
+ try {
+ const parties = await loadParties('retoure');
+ if (!this._cancelled) {
+ this.setState({
+ partiesById: cfg.partiesByIdFromList(parties),
+ partiesList: parties || [],
+ });
+ }
+ } catch {
+ // Non-fatal: names fall back to ids.
+ }
+ }
+
+ handlePageChange(page, meta) {
+ const pageSize = meta?.pageSize ?? 10;
+ const startIndex = (page - 1) * pageSize;
+ const items = this.state.items;
+ const maxIndex = items ? items.length - 1 : startIndex;
+ const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
+
+ const startChunk = Math.floor(startIndex / CHUNK_SIZE);
+ const endChunk = Math.floor(endIndex / CHUNK_SIZE);
+
+ retoureAutomat.requireChunk(startChunk, endChunk);
+ }
+
+ handleRetry() {
+ void retoureAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
+ }
+
+ handleCreate() {
+ this.props.navigate(cfg.createPath);
+ }
+
+ handleOpen(retoure) {
+ this.props.navigate(cfg.editPath(retoure.id));
+ }
+
+ async handleDelete(retoure) {
+ if (this.state.busyId != null) return;
+ const num = retoure.number ?? retoure.retoure_number ?? `RET-${retoure.id}`;
+ if (!window.confirm(cfg.confirmDelete(num))) return;
+ this.setState({ busyId: retoure.id });
+ try {
+ await retoureApi.delete(retoure.id);
+ await retoureAutomat.actions.reload();
+ enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
+ } catch (err) {
+ notifyError(err);
+ } finally {
+ if (!this._cancelled) this.setState({ busyId: null });
+ }
+ }
+
+ async handleTogglePaid(retoure, checked) {
+ if (this.state.busyId != null) return;
+ const id = retoure.id ?? retoure.manifest_id;
+ const patch = { isPaid: checked, is_paid: checked, paid: checked };
+ retoureAutomat.actions.patchRetoure(id, patch);
+ try {
+ await retoureApi.update(id, { isPaid: checked });
+ enqueueSnackbar(checked ? 'Retoure als erstattet/bezahlt markiert' : 'Retoure als offen markiert', {
+ variant: 'info',
+ });
+ } catch (err) {
+ console.warn('[RetoureList] update paid on backend failed:', err);
+ }
+ }
+
+ async handleToggleShipped(retoure, checked) {
+ if (this.state.busyId != null) return;
+ const id = retoure.id ?? retoure.manifest_id;
+ const patch = { isShipped: checked, is_shipped: checked, delivered: checked };
+ retoureAutomat.actions.patchRetoure(id, patch);
+ try {
+ await retoureApi.update(id, { isShipped: checked });
+ enqueueSnackbar(checked ? 'Retoure als geliefert/eingegangen markiert' : 'Retoure als offen markiert', {
+ variant: 'info',
+ });
+ } catch (err) {
+ console.warn('[RetoureList] update shipped on backend failed:', err);
+ }
+ }
+
+ render() {
+ const {
+ items,
+ unfilteredTotal,
+ filterKey,
+ partiesById,
+ partiesList,
+ entityId,
+ customerFilterOpen,
+ busyId,
+ } = this.state;
+
+ rowContext.partiesById = partiesById;
+ rowContext.busyId = busyId;
+ rowContext.onOpen = this.handleOpen;
+ rowContext.onDelete = this.handleDelete;
+ rowContext.onTogglePaid = this.handleTogglePaid;
+ rowContext.onToggleShipped = this.handleToggleShipped;
+ rowContext.headerItem = this.headerItem;
+
+ const customerCounts = {};
+ if (Array.isArray(items)) {
+ for (const ret of items) {
+ const eId = ret?.entityId ?? ret?.entity_id;
+ if (eId) {
+ customerCounts[eId] = (customerCounts[eId] || 0) + 1;
+ }
+ }
+ }
+ const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null;
+
+ const searchField = (
+ this.handleSelectCustomer(null)}
+ />
+ );
+
+ if (items === null && retoureAutomat.state.status === 'failed') {
+ return (
+ <>
+ {searchField}
+
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {searchField}
+
+ {customerFilterOpen && (
+
+ )}
+
+
+
+
+ {renderInvisibleDummyRow(this.headerItem)}
+ Nr. / Ref.
+ Datum
+ Kunde
+
+
+
+
+
+
+
+
+
+
+ Pos.
+ Gesamt
+
+
+ }
+ onPageChange={this.handlePageChange}
+ />
+ {items === null && (
+
+
+
+ )}
+
+
+
+
+ >
+ );
+ }
+}
+
+export default withNavigate(RetoureList);
diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js
index 7f3cded..4e2318a 100644
--- a/src/components/manifest/index.js
+++ b/src/components/manifest/index.js
@@ -2,6 +2,7 @@ export { default as ManifestList } from './ManifestList';
export { default as DeliveryList } from './DeliveryList';
export { default as OrderList } from './OrderList';
export { default as QuoteList } from './QuoteList';
+export { default as RetoureList } from './RetoureList';
export { default as ShipmentList } from './ShipmentList';
export { default as OrderPaymentList } from './OrderPaymentList';
diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js
index b227299..acf6520 100644
--- a/src/components/manifest/manifestConfig.js
+++ b/src/components/manifest/manifestConfig.js
@@ -2,6 +2,7 @@ import {
delivery as deliveryApi,
quote as quoteApi,
order as orderApi,
+ retoure as retoureApi,
} from '../../api';
import supplierAutomat from '../../automats/supplierAutomat';
import entityAutomat from '../../automats/entityAutomat';
@@ -9,6 +10,7 @@ import customergroupAutomat from '../../automats/customergroupAutomat';
import deliveryAutomat from '../../automats/deliveryAutomat';
import quoteAutomat from '../../automats/quoteAutomat';
import orderAutomat from '../../automats/orderAutomat';
+import retoureAutomat from '../../automats/retoureAutomat';
import { getArticle } from '../../automats/artikelAutomat';
import { selectCompanyTaxrate } from '../../automats/companyAutomat';
import { netFromGross } from '../../utils/price';
@@ -18,6 +20,7 @@ import {
ORANGE_PANEL,
PURPLE_PANEL,
YELLOW_PANEL,
+ RED_PANEL,
} from '../../colors';
/** Groups that are not sales customers (filter client-side). */
@@ -632,6 +635,7 @@ export function apiFor(cfgOrKind) {
if (kind === 'delivery') return deliveryApi;
if (kind === 'quote') return quoteApi;
if (kind === 'order') return orderApi;
+ if (kind === 'retoure') return retoureApi;
throw new Error(`Unknown manifest kind: ${kind}`);
}
@@ -776,6 +780,40 @@ export function manifestConfig(cfgOrKind) {
columns: ORDER_LINE_COLUMNS,
};
}
+ if (kind === 'retoure') {
+ return {
+ kind: 'retoure',
+ api: 'retoure',
+ listField: 'retoures',
+ docField: 'retoure',
+ partyField: 'entityId',
+ listPath: '/auftrag/retoure',
+ createPath: '/auftrag/retoure/neu',
+ editPath: (id) => `/auftrag/retoure/${id}`,
+ dateLabel: 'Retourendatum',
+ partyLabel: 'Kunde',
+ emptyList: 'Noch keine Retouren.',
+ createButton: 'Neue Retoure',
+ createdMsg: 'Retoure angelegt.',
+ savedMsg: 'Retoure gespeichert.',
+ deletedMsg: 'Retoure gelöscht.',
+ confirmDelete: (number) => `Retoure „${number}“ wirklich löschen?`,
+ invalidId: 'Ungültige Retouren-ID.',
+ partyGateMessage: null,
+ getPathLabel: 'GET /api/retoure/:id',
+ postPathLabel: 'POST /api/retoure',
+ patchPathLabel: 'PUT /api/retoure/:id',
+ listColor: RED_PANEL,
+ headerColor: RED_PANEL,
+ linesColor: GREEN_PANEL,
+ requirePartyBeforeLines: false,
+ listAutomat: retoureAutomat,
+ partiesByIdFromList: (parties) =>
+ Object.fromEntries(parties.map((row) => [row.id, row])),
+ externalNumber: true,
+ columns: ORDER_LINE_COLUMNS,
+ };
+ }
throw new Error(`Unknown manifest kind: ${kind}`);
}
diff --git a/src/devtools/ChunkCacheMonitor.jsx b/src/devtools/ChunkCacheMonitor.jsx
index 5ed2c21..5aefe54 100644
--- a/src/devtools/ChunkCacheMonitor.jsx
+++ b/src/devtools/ChunkCacheMonitor.jsx
@@ -39,12 +39,14 @@ import orderAutomat, {
isOrderDelivered,
} from '../automats/orderAutomat';
import quoteAutomat from '../automats/quoteAutomat';
+import retoureAutomat from '../automats/retoureAutomat';
import shipmentAutomat from '../automats/shipmentAutomat';
import deliveryAutomat from '../automats/deliveryAutomat';
import { formatPrice } from '../utils/price';
function getAutomatFor(target) {
if (target === 'quote') return quoteAutomat;
+ if (target === 'retoure') return retoureAutomat;
if (target === 'shipment') return shipmentAutomat;
if (target === 'delivery') return deliveryAutomat;
return orderAutomat;
@@ -55,6 +57,7 @@ function getInitialTarget() {
const p = window.location.pathname;
if (p.startsWith('/lieferung')) return 'delivery';
if (p.startsWith('/auftrag/ausliefern')) return 'shipment';
+ if (p.startsWith('/auftrag/retoure')) return 'retoure';
if (p.startsWith('/auftrag/angebot')) return 'quote';
if (p.startsWith('/auftrag')) return 'order';
}
@@ -987,13 +990,13 @@ class ChunkCacheMonitor extends PureComponent {
render() {
const { open, position, tab, stats, pulseCounter } = this.state;
- const { lruChunksCount, maxLruChunks, inflightChunks } = stats;
+ const { lruChunksCount = 0, maxLruChunks = MAX_LRU_CHUNKS, inflightChunks = [] } = stats || {};
const maxLru = maxLruChunks || MAX_LRU_CHUNKS;
const lruCount = lruChunksCount || 0;
const lruPercent = Math.min(100, Math.round((lruCount / maxLru) * 100));
- const hasInflight = inflightChunks.length > 0;
- const filterCachesCount = stats.filterCaches?.length || 1;
+ const hasInflight = Array.isArray(inflightChunks) && inflightChunks.length > 0;
+ const filterCachesCount = stats?.filterCaches?.length || 1;
return (
<>
diff --git a/src/nav.js b/src/nav.js
index f6285f8..3321c87 100644
--- a/src/nav.js
+++ b/src/nav.js
@@ -18,6 +18,7 @@ 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 AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn';
import LabelIcon from '@mui/icons-material/Label';
import HistoryIcon from '@mui/icons-material/History';
@@ -221,6 +222,13 @@ export const SUB_NAV = {
icon: LocalShippingIcon,
help: 'Auslieferungen und Lieferscheine (Shipments) verwalten und versenden',
},
+ {
+ segment: 'retoure',
+ label: 'Retoure',
+ title: 'Retoure',
+ icon: AssignmentReturnIcon,
+ help: 'Kundenretouren, Rücksendungen und Erstattungen verwalten',
+ },
{
segment: 'zahlungen',
label: 'Zahlungen',
diff --git a/src/routes/auftrag/retoure.jsx b/src/routes/auftrag/retoure.jsx
new file mode 100644
index 0000000..c409adb
--- /dev/null
+++ b/src/routes/auftrag/retoure.jsx
@@ -0,0 +1,10 @@
+import { PureComponent } from 'react';
+import RetoureList from '../../components/manifest/RetoureList';
+
+class AuftragRetourePage extends PureComponent {
+ render() {
+ return ;
+ }
+}
+
+export default AuftragRetourePage;
diff --git a/src/routes/auftrag/retoure/_retoureId.jsx b/src/routes/auftrag/retoure/_retoureId.jsx
new file mode 100644
index 0000000..a4003a2
--- /dev/null
+++ b/src/routes/auftrag/retoure/_retoureId.jsx
@@ -0,0 +1,16 @@
+import { PureComponent } from 'react';
+import { ManifestForm, withManifestId } from '../../../components/manifest';
+
+class AuftragRetoureEditPage extends PureComponent {
+ render() {
+ return (
+
+ );
+ }
+}
+
+export default withManifestId('retoureId', AuftragRetoureEditPage);
diff --git a/src/routes/auftrag/retoure/neu.jsx b/src/routes/auftrag/retoure/neu.jsx
new file mode 100644
index 0000000..0098c12
--- /dev/null
+++ b/src/routes/auftrag/retoure/neu.jsx
@@ -0,0 +1,10 @@
+import { PureComponent } from 'react';
+import { ManifestForm } from '../../../components/manifest';
+
+class AuftragRetoureNeuPage extends PureComponent {
+ render() {
+ return ;
+ }
+}
+
+export default AuftragRetoureNeuPage;
diff --git a/src/utils/documentNumber.js b/src/utils/documentNumber.js
index 615ad90..a005977 100644
--- a/src/utils/documentNumber.js
+++ b/src/utils/documentNumber.js
@@ -2,7 +2,8 @@ import numberAutomat from '../automats/numberAutomat';
import quoteAutomat from '../automats/quoteAutomat';
import orderAutomat from '../automats/orderAutomat';
import deliveryAutomat from '../automats/deliveryAutomat';
-import { order as orderApi, quote as quoteApi, delivery as deliveryApi } from '../api';
+import retoureAutomat from '../automats/retoureAutomat';
+import { order as orderApi, quote as quoteApi, delivery as deliveryApi, retoure as retoureApi } from '../api';
/** Standard cross-platform monospace font stack for numbers from a number scheme. */
export const MONO_FONT_FAMILY =
@@ -61,7 +62,11 @@ export function getNextDocumentNumber(kind) {
pad = refSeq ? Math.max(0, Number(refSeq.padding) || 0) : 6;
const prefixStyle = refSeq?.prefix ?? '';
- if (kind === 'delivery') {
+ if (kind === 'retoure') {
+ prefix = 'RET-';
+ pad = 5;
+ startNum = 1;
+ } else if (kind === 'delivery') {
prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-');
} else if (
prefixStyle.startsWith('ART-') ||
@@ -91,8 +96,8 @@ export function getNextDocumentNumber(kind) {
}
}
- // Check existing docs from quoteAutomat / orderAutomat / deliveryAutomat to avoid collisions
- const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : (kind === 'delivery' ? deliveryAutomat : null));
+ // Check existing docs from quoteAutomat / orderAutomat / deliveryAutomat / retoureAutomat to avoid collisions
+ const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : (kind === 'delivery' ? deliveryAutomat : (kind === 'retoure' ? retoureAutomat : null)));
const existingDocs = Array.isArray(listAutomat?.state?.items) ? listAutomat.state.items : [];
let maxFound = Math.max(startNum - 1, maxKnownNumberByKind.get(kind) ?? 0);
@@ -128,7 +133,7 @@ export function getNextDocumentNumber(kind) {
* @returns {Promise}
*/
export async function ensureDocumentNumber(kind) {
- const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : (kind === 'delivery' ? deliveryAutomat : null));
+ const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : (kind === 'delivery' ? deliveryAutomat : (kind === 'retoure' ? retoureAutomat : null)));
await Promise.all([
numberAutomat.actions.ensureLoaded().catch(() => {}),
listAutomat?.actions?.ensureLoaded ? listAutomat.actions.ensureLoaded().catch(() => {}) : Promise.resolve(),
@@ -145,6 +150,9 @@ export async function ensureDocumentNumber(kind) {
} else if (kind === 'delivery') {
const res = await deliveryApi.list({ offset: 0, limit: 10 });
recentDocs = Array.isArray(res?.deliveries) ? res.deliveries : [];
+ } else if (kind === 'retoure') {
+ const res = await retoureApi.list({ offset: 0, limit: 10, pageSize: 10 });
+ recentDocs = Array.isArray(res?.retoures) ? res.retoures : (Array.isArray(res?.items) ? res.items : []);
}
if (recentDocs.length > 0) {
@@ -152,7 +160,7 @@ export async function ensureDocumentNumber(kind) {
const seq = numbers.find((r) => r.type === kind);
let prefix = seq?.prefix;
if (prefix == null) {
- prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : 'LS-');
+ prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : (kind === 'retoure' ? 'RET-' : 'LS-'));
}
let maxFound = maxKnownNumberByKind.get(kind) ?? 0;
@@ -206,7 +214,9 @@ export function getMaxDocumentNumber(kind = 'quote') {
const prefixStyle = refSeq?.prefix ?? '';
let prefix = '';
- if (kind === 'delivery') {
+ if (kind === 'retoure') {
+ return `RET-${'9'.repeat(5)}`;
+ } else if (kind === 'delivery') {
prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-');
} else if (
prefixStyle.startsWith('ART-') ||