From 75691e6afcf18a5ed470df810edfa6ace7ba6fa6 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 27 Oct 2026 00:28:06 +0100 Subject: [PATCH] u --- src/api/index.js | 1 + src/api/payment.js | 13 +- src/api/retoure.js | 187 +++ src/appRouter.jsx | 6 + src/automats/index.js | 14 + src/automats/manifestDraftAutomat.js | 26 +- src/automats/retoureAutomat.js | 1005 +++++++++++++++++ src/automats/sseAutomat.js | 10 + src/components/manifest/ManifestForm.jsx | 74 +- src/components/manifest/ManifestLines.jsx | 2 +- .../manifest/OrderDocumentsTiles.jsx | 200 +++- .../manifest/OrderShipmentDialog.jsx | 12 +- src/components/manifest/RetoureList.jsx | 852 ++++++++++++++ src/components/manifest/index.js | 1 + src/components/manifest/manifestConfig.js | 38 + src/devtools/ChunkCacheMonitor.jsx | 9 +- src/nav.js | 8 + src/routes/auftrag/retoure.jsx | 10 + src/routes/auftrag/retoure/_retoureId.jsx | 16 + src/routes/auftrag/retoure/neu.jsx | 10 + src/utils/documentNumber.js | 24 +- 21 files changed, 2458 insertions(+), 60 deletions(-) create mode 100644 src/api/retoure.js create mode 100644 src/automats/retoureAutomat.js create mode 100644 src/components/manifest/RetoureList.jsx create mode 100644 src/routes/auftrag/retoure.jsx create mode 100644 src/routes/auftrag/retoure/_retoureId.jsx create mode 100644 src/routes/auftrag/retoure/neu.jsx 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) => ( + + + {typeIcon(t.value, { fontSize: 18 })} + {t.label} + + + ))} + + 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-') ||