diff --git a/src/automats/deliveryAutomat.js b/src/automats/deliveryAutomat.js index 146c66f..1fe96ee 100644 --- a/src/automats/deliveryAutomat.js +++ b/src/automats/deliveryAutomat.js @@ -1,43 +1,1192 @@ /** * @file deliveryAutomat.js - * @description Manages cached inbound goods receipts / deliveries (`GET /api/delivery`) - * for the active company. Built via `createListAutomat`. + * @description Manages cached and chunk-paginated company inbound goods receipts / deliveries (`GET /api/delivery`). + * + * Implements a multi-filter chunk cache identical to `orderAutomat`: + * - Each filter combination has its own isolated cache entry (total, unfilteredTotal, items array, loadedChunks). + * - Total chunk capacity across all filter combinations is bounded by a global LRU 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. + * - Real-time stats and events can be observed via `subscribeChunkStats` or the dev ChunkCacheMonitor. * * Upstream Triggers: - * - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with - * a `selectedCompanyId`, and `reset()` on `'anon'`. - * - UI / Routes: Manual calls to `load()` or `reload()`. + * - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes. + * - `sseAutomat`: Triggers `actions.reload()` on `'delivery'` SSE events. + * - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`. */ +import { Automat } from 'react-automat'; import { delivery as deliveryApi } from '../api'; -import { createListAutomat } from './createListAutomat'; +import authAutomat from './authAutomat'; + +/** + * @typedef {Object} DeliveryLedgerStatus + * @property {number} totalPositions + * @property {number} completelyOnLedgerPositions + * @property {number} bookedPositions + * @property {number} unbookedPositions + * @property {number} totalQuantity + * @property {number} bookedQuantity + * @property {number} unbookedQuantity + * @property {boolean} isBooked + */ + +/** + * @typedef {Object} DeliveryAttachment + * @property {number} id + * @property {number} [fileId] + * @property {number} [manifestId] + * @property {string} name + * @property {string} mimetype + * @property {number} size + * @property {string} [thumbavif] + * @property {string} [skeledash] + */ /** * @typedef {Object} DeliverySummary - * @property {string|number} id Delivery id. - * @property {string} number Server-allocated manifest number. - * @property {string|null} externalNumber User-entered reference. - * @property {string} type Always `'delivery'`. - * @property {string|number|null} supplierId Supplier id. + * @property {number} id Delivery id. + * @property {string} number Manifest number. + * @property {'delivery'} type Always `'delivery'`. + * @property {number|null} supplierId Supplier id. + * @property {string|null} [supplierName] Supplier name. + * @property {string|null} externalNumber External reference / delivery note number. * @property {string} date Delivery date (ISO). + * @property {string|null} createdAt Creation timestamp (ISO). + * @property {number} [positions] Total line positions count. + * @property {DeliveryLedgerStatus} [ledger] Ledger booking status. + * @property {DeliveryAttachment[]} [files] Attached files / PDFs. */ /** - * Company goods receipts (`GET /api/delivery`). - * - * Backs the delivery list page and the quote/order manifest lists share the - * same pattern through their own automats. - * - * @type {import('react-automat').Automat} + * @typedef {Object} DeliveryAutomatState + * @property {DeliverySummary[]|null} items Pre-filled array of deliveries/nulls, initially null. + * @property {number} total Total unpaged count for pagination calculation. + * @property {number} unfilteredTotal Unfiltered total count. + * @property {string} filter Search term. + * @property {boolean} onlyUnbooked Only unbooked deliveries. + * @property {number|null} supplierId Filter by supplier id. + * @property {number} seekMonths Seek month count. + * @property {number} seekYears Seek year count. + * @property {number} negativeSeekMonths Negative seek months offset. + * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. + * @property {Error|string|null} error Last load error. */ -export const deliveryAutomat = createListAutomat({ - name: 'delivery', - fetch: async () => { - const data = await deliveryApi.list(); - return data.deliveries ?? []; + +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: '', + onlyUnbooked: false, + supplierId: 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 onlyUnbooked = Boolean(state.onlyUnbooked); + const supplierId = state.supplierId != null && state.supplierId !== '' ? Number(state.supplierId) : null; + const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0) + ? Number(state.negativeSeekMonths) + : 0; + return { filter, onlyUnbooked, supplierId, negativeSeekMonths }; +} + +export function getFilterKey(state = automat?.state || initialState) { + const { filter, onlyUnbooked, supplierId, negativeSeekMonths } = getFilterParams(state); + return JSON.stringify({ f: filter, u: onlyUnbooked, s: supplierId, m: negativeSeekMonths }); +} + +export function formatFilterLabel(filterKeyOrState) { + let f, u, s, m; + if (typeof filterKeyOrState === 'string') { + try { + const parsed = JSON.parse(filterKeyOrState); + f = parsed.f; + u = parsed.u; + s = parsed.s; + m = parsed.m; + } catch { + return filterKeyOrState; + } + } else if (filterKeyOrState && typeof filterKeyOrState === 'object') { + if ('f' in filterKeyOrState) { + f = filterKeyOrState.f; + u = filterKeyOrState.u; + s = filterKeyOrState.s; + m = filterKeyOrState.m; + } else { + const params = getFilterParams(filterKeyOrState); + f = params.filter; + u = params.onlyUnbooked; + s = params.supplierId; + m = params.negativeSeekMonths; + } + } + + const parts = []; + if (f) parts.push(`Suche: „${f}“`); + if (u) parts.push('Nur ungebucht'); + if (s) parts.push(`Lieferant #${s}`); + if (m < 0) parts.push(`Seek: ${formatNegativeSeek(m)}`); + if (parts.length === 0) return 'Standard (Alle Lieferungen)'; + 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; +} + +// Touch an existing chunk in the global LRU cache to mark it as Most Recently Used +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() }); + } +} + +// Register a newly loaded chunk into the LRU cache and evict the oldest if limit exceeded +function registerChunkLRU(filterKey, chunkIndex, deliveryCount = 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, + deliveryCount, + }); + + // Enforce global MAX_LRU_CHUNKS limit across all filter caches + 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'); + } + } +} + +// Evicts a chunk from memory: sets items back to null in the filter cache and removes from loadedChunks +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] }); + } + + const eventType = reason === 'lru' ? 'lru_evict' : 'evict'; + const msg = reason === 'lru' + ? `LRU-Eviction: Chunk #${chunkIndex} von Filter "${entry.label}" verworfen (${lruChunks.size}/${MAX_LRU_CHUNKS} im Cache)` + : `Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`; + + recordChunkEvent({ + type: eventType, + chunkIndex, + filterKey, + message: msg, + }); + 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('[deliveryAutomat] 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, + deliveryCount: info.deliveryCount, + 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: 'delivery', + 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 ?? '', + onlyUnbooked: automat?.state?.onlyUnbooked ?? false, + supplierId: automat?.state?.supplierId ?? null, + negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0, + 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); + }; +} + +// Clears pending debounced chunk requests +function clearChunkDebounce() { + if (chunkDebounceTimer) { + clearTimeout(chunkDebounceTimer); + chunkDebounceTimer = null; + } + if (pendingTargetChunks.size > 0) { + pendingTargetChunks.clear(); + notifyChunkStats(); + } + pendingChunkPromise = null; + pendingChunkResolve = null; + pendingChunkReject = null; +} + +// Executes chunk loading for the standstill target and prefetches lookahead chunks +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); + } + + recordChunkEvent({ + type: 'execute', + chunks: chunkList, + filterKey: targetFilterKey, + message: `Lade Chunks [${chunkList.join(', ')}] für "${formatFilterLabel(targetFilterKey)}" (Epoch ${targetEpoch})`, + }); + + 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; + } + recordChunkEvent({ + type: 'prefetch', + chunkIndex: c, + filterKey: targetFilterKey, + message: `Prefetch Chunk #${c} für "${formatFilterLabel(targetFilterKey)}"`, + }); + void actions.loadChunk(c, targetEpoch, targetFilterKey); + } + } + return automat.state.items; + }); +} + +// MARK: pure delivery helpers +export function isDeliveryBooked(delivery) { + if (!delivery) return false; + const ledger = delivery.ledger; + if (!ledger) return false; + if (ledger.isBooked !== undefined) return Boolean(ledger.isBooked); + const total = Number(ledger.totalPositions ?? ledger.total ?? delivery.positions ?? 0); + const booked = Number(ledger.completelyOnLedgerPositions ?? ledger.bookedPositions ?? 0); + return total > 0 && booked >= total; +} + +export function getDeliveryUnbookedCount(delivery) { + if (!delivery) return 0; + const ledger = delivery.ledger; + if (!ledger) return 0; + if (ledger.unbookedPositions !== undefined) return Number(ledger.unbookedPositions); + const total = Number(ledger.totalPositions ?? ledger.total ?? delivery.positions ?? 0); + const booked = Number(ledger.completelyOnLedgerPositions ?? ledger.bookedPositions ?? 0); + return Math.max(0, total - booked); +} + +export function formatNegativeSeek(months) { + if (!months || months >= 0) return ''; + const total = Math.abs(months); + const years = Math.floor(total / 12); + const remMonths = total % 12; + const parts = []; + if (years > 0) { + parts.push(`${years} ${years === 1 ? 'Jahr' : 'Jahre'}`); + } + if (remMonths > 0) { + parts.push(`${remMonths} ${remMonths === 1 ? 'Monat' : 'Monate'}`); + } + return `-${parts.join(' ')}`; +} + +// Builds server query parameters from filter params +function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) { + const params = { offset, limit }; + const trimmed = String(filterParams.filter ?? '').trim(); + if (trimmed) params.search = trimmed; + if (filterParams.onlyUnbooked) params.onlyUnbooked = true; + if (filterParams.supplierId != null) params.supplierId = filterParams.supplierId; + if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) { + params.seekMonths = Number(filterParams.negativeSeekMonths); + } + return params; +} + +// Fetches a single chunk from GET /api/delivery with server-side filters +async function fetchDeliveriesPageFor(filterParams, offset = 0, limit = CHUNK_SIZE) { + const params = buildQueryParamsFor(filterParams, offset, limit); + const res = await deliveryApi.list(params); + const deliveries = Array.isArray(res?.deliveries) ? res.deliveries : (Array.isArray(res) ? res : []); + const total = Number(res?.total ?? deliveries.length); + const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total; + return { deliveries, total, unfilteredTotal }; +} + +function onFilterChange(immediate = false) { + if (filterDebounceTimer) { + clearTimeout(filterDebounceTimer); + filterDebounceTimer = null; + } + if (immediate) { + applyFilterReload(); + } else { + filterDebounceTimer = setTimeout(() => { + filterDebounceTimer = null; + applyFilterReload(); + }, 200); + } +} + +// Invoked when active filters change: switches to the matching filter cache without wiping! +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, + }); + + recordChunkEvent({ + type: 'filter_hit', + epoch: queryEpoch, + filterKey: currentKey, + message: `Filter gewechselt: "${cached.label}" (Cache-Hit: ${cached.loadedChunks.size} Chunks / ${countNonNullItems(cached.items)} Lieferungen im RAM)`, + }); + notifyChunkStats(); + + 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, + }); + + recordChunkEvent({ + type: 'filter_miss', + epoch: queryEpoch, + filterKey: currentKey, + message: `Filter gewechselt: "${entry.label}" (Cache-Miss: Lade Chunk #0)`, + }); + notifyChunkStats(); + + void actions.loadChunk(0, queryEpoch, currentKey); + } +} + +const actions = { + // MARK: returns current items and triggers chunk 0 if not loaded + getItems() { + if (automat.state.items === null) { + void actions.requireChunk(0, 0, { prefetch: false, immediate: true }); + return null; + } + return automat.state.items; }, - sort: (items) => - [...items].sort((a, b) => String(b.date).localeCompare(String(a.date))), + + // MARK: ensures chunkIndex and lookahead chunks are loaded/loading (debounced) + 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); + recordChunkEvent({ + type: 'require_immediate', + chunks: Array.from(chunks), + filterKey: currentKey, + message: `Sofortige Anforderung: Chunks ${start}..${end} (${formatFilterLabel(currentKey)})`, + }); + 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); + } + recordChunkEvent({ + type: 'pending', + chunks: Array.from(pendingTargetChunks), + filterKey: currentKey, + message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`, + }); + notifyChunkStats(); + + 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; + + recordChunkEvent({ + type: 'debounce_fire', + chunks: Array.from(chunksToLoad), + filterKey: currentKey, + message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`, + }); + + _executeRequiredChunks(chunksToLoad, captureEpoch, true, currentKey) + .then((res) => resolve?.(res)) + .catch((err) => reject?.(err)); + }, CHUNK_DEBOUNCE_MS); + + return pendingChunkPromise; + }, + + // MARK: fetches a single chunk and populates the filter cache and prefilled array + 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); + recordChunkEvent({ + type: 'hit', + chunkIndex: index, + filterKey: targetFilterKey, + message: `Cache-Hit: Chunk #${index} für Filter "${entry.label}" bereits im Speicher`, + }); + return Promise.resolve(targetFilterKey === getFilterKey(automat.state) ? automat.state.items : entry.items); + } + + const inflightKey = makeLruKey(targetFilterKey, index); + if (inflightChunks.has(inflightKey)) { + recordChunkEvent({ + type: 'inflight_hit', + chunkIndex: index, + filterKey: targetFilterKey, + message: `Inflight-Hit: Chunk #${index} für Filter "${entry.label}" lädt bereits`, + }); + 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 startTime = Date.now(); + recordChunkEvent({ + type: 'start', + chunkIndex: index, + filterKey: targetFilterKey, + message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1}) für "${entry.label}"`, + }); + + const filterParams = entry.params || getFilterParams(automat.state); + const promise = fetchDeliveriesPageFor(filterParams, offset, limit) + .then(({ deliveries, 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 < deliveries.length; i++) { + const idx = offset + i; + if (idx < entry.items.length) { + entry.items[idx] = deliveries[i]; + } + } + + entry.loadedChunks.add(index); + entry.status = 'ready'; + entry.error = null; + entry.lastAccessed = Date.now(); + + registerChunkLRU(targetFilterKey, index, deliveries.length); + + const currentActive = targetFilterKey === getFilterKey(automat.state); + if (currentActive) { + automat.setState({ + items: entry.items, + total: currentTotal, + unfilteredTotal, + status: 'ready', + error: null, + }); + } + + const duration = Date.now() - startTime; + recordChunkEvent({ + type: 'loaded', + chunkIndex: index, + filterKey: targetFilterKey, + count: deliveries.length, + duration, + message: `Chunk #${index} geladen (${deliveries.length} Lieferungen in ${duration}ms) für "${entry.label}"`, + }); + + return currentActive ? automat.state.items : entry.items; + }) + .catch((err) => { + entry.status = 'failed'; + entry.error = err; + const currentActive = targetFilterKey === getFilterKey(automat.state); + if (currentActive) { + if (index === 0 && automat.state.items === null) { + automat.setState({ status: 'failed', error: err }); + } + } + const duration = Date.now() - startTime; + console.error(`[deliveryAutomat] loadChunk(${index}, ${targetFilterKey}) error:`, err); + recordChunkEvent({ + type: 'error', + chunkIndex: index, + filterKey: targetFilterKey, + duration, + error: err?.message || String(err), + message: `Fehler bei Chunk #${index} ("${entry.label}"): ${err?.message || err}`, + }); + throw err; + }) + .finally(() => { + inflightChunks.delete(inflightKey); + notifyChunkStats(); + }); + + inflightChunks.set(inflightKey, promise); + notifyChunkStats(); + return promise; + }, + + // MARK: server-side filter actions + filter(query) { + automat.setState({ filter: String(query ?? '') }); + onFilterChange(false); + }, + + setOnlyUnbooked(onlyUnbooked) { + automat.setState({ onlyUnbooked: Boolean(onlyUnbooked) }); + onFilterChange(true); + }, + + setSupplierId(supplierId) { + const parsed = supplierId != null && supplierId !== '' ? Number(supplierId) : null; + automat.setState({ supplierId: parsed }); + 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: '', + onlyUnbooked: false, + supplierId: null, + seekMonths: 0, + seekYears: 0, + negativeSeekMonths: 0, + }); + onFilterChange(true); + }, + + // MARK: initial load + load() { + return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); + }, + + // MARK: ensure loaded + ensureLoaded() { + if (automat.state.items !== null) { + return Promise.resolve(automat.state.items); + } + return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); + }, + + // MARK: reload active filter or invalidate all + async reload(onlyCurrent = false) { + queryEpoch++; + clearChunkDebounce(); + + if (onlyCurrent) { + const currentKey = getFilterKey(automat.state); + actions.clearFilterCache(currentKey); + } else { + actions.clearCache(); + } + + await actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); + return automat.state.items; + }, + + // MARK: replace items directly + replace(items) { + clearChunkDebounce(); + const currentKey = getFilterKey(automat.state); + actions.clearFilterCache(currentKey); + + const count = Array.isArray(items) ? items.length : 0; + const entry = getOrCreateFilterCache(currentKey, automat.state); + entry.items = items; + entry.total = count; + entry.status = 'ready'; + entry.error = null; + + recordChunkEvent({ + type: 'replace', + filterKey: currentKey, + message: `Items ersetzt (${count} Einträge)`, + }); + notifyChunkStats(); + automat.setState({ + items, + total: count, + status: 'ready', + error: null, + }); + return items; + }, + + // MARK: evicts a specific chunk from cache + 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(); + }, + + // MARK: patches a delivery locally across all filter caches + patchDelivery(id, patch) { + let changed = false; + for (const entry of filterCaches.values()) { + if (Array.isArray(entry.items)) { + entry.items = entry.items.map((d) => { + if (d && d.id === id) { + changed = true; + return { ...d, ...patch }; + } + return d; + }); + } + } + + if (Array.isArray(automat.state.items)) { + const items = automat.state.items.map((d) => { + if (d && d.id === id) { + changed = true; + return { ...d, ...patch }; + } + return d; + }); + if (changed) { + automat.setState({ items }); + } + } + return automat.state.items; + }, + + // MARK: resets state and all caches completely (e.g. on logout / company switch) + reset() { + queryEpoch++; + if (filterDebounceTimer) { + clearTimeout(filterDebounceTimer); + filterDebounceTimer = null; + } + clearChunkDebounce(); + filterCaches.clear(); + lruChunks.clear(); + inflightChunks.clear(); + + recordChunkEvent({ + type: 'reset', + message: 'Delivery-Automat und sämtliche Caches zurückgesetzt', + }); + notifyChunkStats(); + automat.setState(initialState); + }, +}; + +export const deliveryAutomat = new Automat(initialState, actions, { name: 'delivery' }); +automat = deliveryAutomat; + +// MARK: exported methods for direct invocation +export function getItems() { + return actions.getItems(); +} + +export function requireChunk(chunkIndex, endOrOptions, maybeOptions) { + return actions.requireChunk(chunkIndex, endOrOptions, maybeOptions); +} + +deliveryAutomat.getItems = getItems; +deliveryAutomat.requireChunk = requireChunk; +deliveryAutomat.CHUNK_SIZE = CHUNK_SIZE; +deliveryAutomat.MAX_LRU_CHUNKS = MAX_LRU_CHUNKS; +deliveryAutomat.getChunkStats = getChunkStats; +deliveryAutomat.subscribeChunkStats = subscribeChunkStats; +deliveryAutomat.evictChunk = actions.evictChunk; +deliveryAutomat.clearFilterCache = actions.clearFilterCache; +deliveryAutomat.clearCache = actions.clearCache; +deliveryAutomat.prefetchAllChunks = actions.prefetchAllChunks; +deliveryAutomat.getFilterKey = getFilterKey; +deliveryAutomat.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; + } }); +/** Selects current deliveries */ +export function selectDeliveries(state = automat.state) { + return state.items; +} + +/** Selects total unpaged count */ +export function selectDeliveryTotal(state = automat.state) { + return state.total; +} + +/** Selects unfiltered total count */ +export function selectDeliveryUnfilteredTotal(state = automat.state) { + return state.unfilteredTotal ?? 0; +} + +/** Selects onlyUnbooked filter */ +export function selectOnlyUnbooked(state = automat.state) { + return state.onlyUnbooked; +} + +/** Selects negative seek offset in months */ +export function selectNegativeSeekMonths(state = automat.state) { + return state.negativeSeekMonths; +} + +/** Selects seek month count */ +export function selectSeekMonths(state = automat.state) { + return state.seekMonths ?? 0; +} + +/** Selects seek year count */ +export function selectSeekYears(state = automat.state) { + return state.seekYears ?? 0; +} + +/** + * Calculates client pagination metadata. + */ +export function selectDeliveryPagination(state = automat.state) { + const total = state.total ?? 0; + return { + total, + offset: 0, + limit: CHUNK_SIZE, + currentPage: 1, + pageCount: Math.max(1, Math.ceil(total / CHUNK_SIZE)), + }; +} + export default deliveryAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index 5bf03f0..26d8ac5 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -64,7 +64,20 @@ export { buildArticleIndex, selectArticleIndexItems, } from './articleIndexAutomat'; -export { default as deliveryAutomat } from './deliveryAutomat'; +export { + default as deliveryAutomat, + CHUNK_SIZE as DELIVERY_CHUNK_SIZE, + MAX_LRU_CHUNKS as DELIVERY_MAX_LRU_CHUNKS, + selectDeliveries, + selectDeliveryTotal, + selectDeliveryUnfilteredTotal, + selectDeliveryPagination, + selectOnlyUnbooked, + isDeliveryBooked, + getDeliveryUnbookedCount, + getChunkStats as getDeliveryChunkStats, + subscribeChunkStats as subscribeDeliveryChunkStats, +} from './deliveryAutomat'; export { default as quoteAutomat } from './quoteAutomat'; export { default as orderAutomat, diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 5bdbe2d..386f6b7 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -32,6 +32,7 @@ import warehouseAutomat from './warehouseAutomat'; import attributeAutomat from './attributeAutomat'; import propertyAutomat from './propertyAutomat'; import supplierAutomat from './supplierAutomat'; +import deliveryAutomat from './deliveryAutomat'; import orderAutomat from './orderAutomat'; import quoteAutomat from './quoteAutomat'; import shipmentAutomat from './shipmentAutomat'; @@ -264,6 +265,12 @@ function connect() { }); }); + addTrackedListener(es, 'delivery', () => { + void deliveryAutomat.actions.reload().catch((err) => { + console.error('[sse] delivery refresh failed', err); + }); + }); + diff --git a/src/components/manifest/BookDeliveryDialog.jsx b/src/components/manifest/BookDeliveryDialog.jsx index a1d0e1e..1623b4f 100644 --- a/src/components/manifest/BookDeliveryDialog.jsx +++ b/src/components/manifest/BookDeliveryDialog.jsx @@ -47,7 +47,6 @@ export default class BookDeliveryDialog extends PureComponent { let initialAmounts = {}; if (Array.isArray(props.summary?.lines)) { for (const line of props.summary.lines) { - if (!line.trackStock) continue; if (props.prefillLineId != null) { initialAmounts[line.id] = line.id === props.prefillLineId @@ -175,7 +174,6 @@ export default class BookDeliveryDialog extends PureComponent { const amounts = {}; if (Array.isArray(summary?.lines)) { for (const line of summary.lines) { - if (!line.trackStock) continue; if (prefillLineId != null) { amounts[line.id] = line.id === prefillLineId @@ -210,7 +208,6 @@ export default class BookDeliveryDialog extends PureComponent { if (!summary?.lines) return; const amounts = {}; for (const line of summary.lines) { - if (!line.trackStock) continue; amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0; } this.setState({ amountsToBook: amounts }); @@ -221,7 +218,6 @@ export default class BookDeliveryDialog extends PureComponent { if (!summary?.lines) return; const amounts = {}; for (const line of summary.lines) { - if (!line.trackStock) continue; amounts[line.id] = 0; } this.setState({ amountsToBook: amounts }); @@ -229,16 +225,16 @@ export default class BookDeliveryDialog extends PureComponent { async handleConfirmBook() { const { delivery, onBookSuccess, onClose } = this.props; - const { summary, amountsToBook, transactionText } = this.state; + const { summary, amountsToBook, transactionText, selectedWarehouseId } = this.state; if (!summary?.lines) return; const linesToBook = summary.lines - .filter((line) => line.trackStock) .map((line) => ({ manifestarticleId: line.id, sku: line.sku, name: line.name, amount: Number(amountsToBook[line.id] || 0), + trackStock: Boolean(line.trackStock), })) .filter((l) => l.amount > 0); @@ -247,8 +243,8 @@ export default class BookDeliveryDialog extends PureComponent { return; } - const { selectedWarehouseId } = this.state; - if (!selectedWarehouseId) { + const hasTracked = linesToBook.some((l) => l.trackStock); + if (hasTracked && !selectedWarehouseId) { this.setState({ warehouseError: 'Bitte wählen Sie ein Ziellager aus.' }); enqueueSnackbar('Bitte wählen Sie ein Ziellager aus.', { variant: 'warning' }); return; @@ -265,7 +261,7 @@ export default class BookDeliveryDialog extends PureComponent { const totalBookedQty = linesToBook.reduce((sum, l) => sum + l.amount, 0); enqueueSnackbar( - `${linesToBook.length} Position${linesToBook.length === 1 ? '' : 'en'} (${totalBookedQty} Stk.) erfolgreich ins Lager gebucht.`, + `${linesToBook.length} Position${linesToBook.length === 1 ? '' : 'en'} (${totalBookedQty} Stk.) erfolgreich gebucht.`, { variant: 'success' } ); @@ -387,12 +383,12 @@ export default class BookDeliveryDialog extends PureComponent { renderLinesTable() { const { summary, amountsToBook, isBooking } = this.state; - const lines = (summary?.lines || []).filter((l) => l.trackStock); + const lines = summary?.lines || []; if (lines.length === 0) { return ( - Diese Lieferung enthält keine lagergeführten Artikel zum Einbuchen. + Diese Lieferung enthält keine Positionen zum Verbuchen. ); } @@ -406,7 +402,7 @@ export default class BookDeliveryDialog extends PureComponent { Artikel Liefermenge Bereits gebucht - Jetzt einbuchen + Jetzt verbuchen @@ -420,9 +416,19 @@ export default class BookDeliveryDialog extends PureComponent { {line.sku || '–'} - - {line.name || '–'} - + + + {line.name || '–'} + + {!line.trackStock && ( + + )} + @@ -493,8 +499,14 @@ export default class BookDeliveryDialog extends PureComponent { warehouseError, } = this.state; - const lines = (summary?.lines || []).filter((l) => l.trackStock); - const hasTrackStock = lines.length > 0; + const lines = summary?.lines || []; + const hasLines = lines.length > 0; + const hasTrackStock = lines.some((l) => l.trackStock); + const linesWithAmountsToBook = lines.filter( + (l) => Number(amountsToBook[l.id]) > 0 + ); + const hasTrackedLinesToBook = linesWithAmountsToBook.some((l) => l.trackStock); + const prefillLine = this.props.prefillLineId != null ? lines.find((l) => l.id === this.props.prefillLineId) @@ -503,12 +515,10 @@ export default class BookDeliveryDialog extends PureComponent { (sum, l) => sum + (Number(amountsToBook[l.id]) || 0), 0 ); - const countLinesToBook = lines.filter( - (l) => Number(amountsToBook[l.id]) > 0 - ).length; - const isFullyBooked = hasTrackStock && summary?.status === 'booked'; + const countLinesToBook = linesWithAmountsToBook.length; + const isFullyBooked = hasLines && summary?.status === 'booked'; const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId); - const isWarehouseMissing = !selectedWarehouseId; + const isWarehouseMissing = hasTrackedLinesToBook && !selectedWarehouseId; return ( - Lieferung {delivery?.number || ''} ins Lager buchen + Lieferung {delivery?.number || ''} verbuchen @@ -552,13 +562,13 @@ export default class BookDeliveryDialog extends PureComponent { <> {prefillLine && ( - Vorausgewählt zum Einbuchen: Position{' '} + Vorausgewählt zum Verbuchen: Position{' '} {prefillLine.sku ? `SKU ${prefillLine.sku}` : prefillLine.name} ({amountsToBook[prefillLine.id] || 0} Stk.). Andere Positionen stehen auf 0. )} {this.renderLinesTable()} - {!isFullyBooked && hasTrackStock && ( + {!isFullyBooked && hasLines && ( - {!isFullyBooked && lines.length > 0 && ( + {!isFullyBooked && hasLines && ( )} diff --git a/src/components/manifest/DeliveryList.jsx b/src/components/manifest/DeliveryList.jsx index ce201d1..660ce24 100644 --- a/src/components/manifest/DeliveryList.jsx +++ b/src/components/manifest/DeliveryList.jsx @@ -1,38 +1,44 @@ -import { PureComponent } from 'react'; +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 FormControlLabel from '@mui/material/FormControlLabel'; import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; +import ListItemButton from '@mui/material/ListItemButton'; import Typography from '@mui/material/Typography'; -import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; +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 Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined'; import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; 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 { RecordList } from 'dashgui/lists'; -import deliveryAutomat from '../../automats/deliveryAutomat'; -import { delivery } from '../../api'; -import { supplierAutomat } from '../../automats'; +import deliveryAutomat, { CHUNK_SIZE } from '../../automats/deliveryAutomat'; +import numberAutomat from '../../automats/numberAutomat'; +import { delivery as deliveryApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; +import { getMaxDocumentNumber } from '../../utils/documentNumber'; import BookDeliveryDialog from './BookDeliveryDialog'; import withNavigate from '../withNavigate'; -import { formatDate, numberDisplay } from './manifestConfig'; import { - ORANGE_PANEL, -} from '../../colors'; + formatDate, + loadParties, + manifestConfig, + numberDisplay, + partyDisplayName, +} from './manifestConfig'; -function sortDeliveries(docs) { - if (!Array.isArray(docs)) return []; - return [...docs].sort((a, b) => { - const cmp = String(b.date ?? '').localeCompare(String(a.date ?? '')); - if (cmp !== 0) return cmp; - return Number(b.id ?? 0) - Number(a.id ?? 0); - }); -} +const cfg = manifestConfig('delivery'); function formatFileSize(bytes) { if (bytes == null || Number.isNaN(bytes)) return ''; @@ -41,226 +47,241 @@ function formatFileSize(bytes) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -export class DeliveryList extends PureComponent { - constructor(props) { - super(props); - this.state = { - status: 'loading', - error: null, - docs: [], - suppliers: supplierAutomat.state?.suppliers || [], - busyId: null, - bookingDoc: null, - bookingOpen: false, - }; - this._cancelled = false; - this.handleRetry = this.handleRetry.bind(this); - this.handleCreate = this.handleCreate.bind(this); - this.handleEdit = this.handleEdit.bind(this); - this.handleDelete = this.handleDelete.bind(this); - this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this); - this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this); - this.handleBookSuccess = this.handleBookSuccess.bind(this); - } +// Dummy delivery generator using the active sequence scheme with max digits for layout testing +export function createDummyTestDelivery() { + const maxNumber = getMaxDocumentNumber('delivery'); + return { + id: 999999, + number: maxNumber, + externalNumber: 'REF-99999999', + date: '2030-10-30T12:00:00Z', + supplierName: 'Lieferant mit sehr langem Firmennamen GmbH & Co. KG', + positions: 999, + ledger: { + totalPositions: 999, + completelyOnLedgerPositions: 0, + bookedPositions: 0, + unbookedPositions: 999, + isBooked: false, + }, + files: [ + { + id: 99991, + manifestId: 999999, + fileId: 99991, + name: 'lieferschein.pdf', + mimetype: 'application/pdf', + size: 15420, + }, + { + id: 99992, + manifestId: 999999, + fileId: 99992, + name: 'lieferschein.txt', + mimetype: 'text/plain', + size: 2048, + }, + ], + }; +} - componentDidMount() { - this._cancelled = false; - this.unsubSupplier = supplierAutomat.subscribe(this, (state) => ({ - suppliers: state.suppliers || [], - })); +const rowGridSx = { + display: 'grid', + gridTemplateColumns: 'max-content max-content 1fr max-content max-content max-content auto', + gridTemplateRows: '0px 1fr', + rowGap: 0, + columnGap: 1.5, + alignItems: 'center', + width: '100%', + boxSizing: 'border-box', +}; - void this.load(); - } +const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; - componentWillUnmount() { - this._cancelled = true; - this.unsubSupplier?.(); - } +const measuringCellSx = { + visibility: 'hidden', + height: 0, + boxSizing: 'border-box', + pointerEvents: 'none', + textTransform: 'none', + letterSpacing: 'normal', +}; - async fetchDeliveries() { - return deliveryAutomat.actions.ensureLoaded(); - } +const headerCellSx = { + color: 'text.secondary', + typography: 'caption', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.04em', + userSelect: 'none', +}; - async load() { - this.setState({ status: 'loading', error: null }); - try { - const docs = await this.fetchDeliveries(); - if (this._cancelled) return; - this.setState({ - status: 'ready', - docs: sortDeliveries(docs), - }); - } catch (err) { - if (this._cancelled) return; - this.setState({ status: 'failed', error: backendErrorFrom(err) }); - } - } +function renderInvisibleDummyRow(dummy) { + const d = dummy || createDummyTestDelivery(); + return ( + <> + + {d.number} + {d.externalNumber || 'Nr. / Ref.'} + + + {d.date ? formatDate(d.date) : '–'} + + + {d.supplierName || 'Lieferant'} + + + } + label="999" + sx={{ height: 24, '& .MuiChip-label': { px: 0.5 } }} + /> + + + + + + + + + + + + + + + + + + ); +} - async handleRetry() { - try { - await deliveryAutomat.actions.reload(); - } catch { - // Will be caught in load() - } - void this.load(); - } +/** + * Ambient context for `DeliveryRow`: FillSpace renders rows as bare elements + * (``), so the list publishes callbacks and the party map + * here on every render. + */ +const rowContext = { + suppliersById: {}, + busyId: null, + onOpen: null, + onDelete: null, + onOpenBookDialog: null, + headerItem: null, +}; - go(to) { - const { navigate } = this.props; - if (typeof navigate === 'function') { - navigate(to); - return; - } - window.location.assign(to); - } - - handleCreate() { - this.go('/lieferung/neu'); - } - - handleEdit(doc) { - this.go(`/lieferung/${doc.id}`); - } - - handleOpenBookDialog(doc) { - this.setState({ bookingDoc: doc, bookingOpen: true }); - } - - handleCloseBookDialog() { - this.setState({ bookingDoc: null, bookingOpen: false }); - } - - async handleBookSuccess() { - this.handleCloseBookDialog(); - try { - const updated = await deliveryAutomat.actions.reload(); - if (this._cancelled) return; - this.setState({ docs: sortDeliveries(updated) }); - return; - } catch { - // Fall back to load() - } - void this.load(); - } - - async handleDelete(doc) { - if (this.state.busyId != null) return; - if (!window.confirm(`Lieferung „${doc.number}“ wirklich löschen?`)) return; - - this.setState({ busyId: doc.id }); - try { - await delivery.delete(doc.id); - await deliveryAutomat.actions.reload(); - if (this._cancelled) return; - await this.load(); - enqueueSnackbar('Lieferung gelöscht.', { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - this.setState({ busyId: null }); - } - } - - getSupplierName(supplierId) { - if (supplierId == null) return null; - const supplier = this.state.suppliers?.find((s) => s.id === supplierId); - return supplier?.name || `Lieferant #${supplierId}`; +// MARK: +class DeliveryRow extends PureComponent { + supplierName(supplierId) { + if (supplierId == null) return '–'; + const supplier = rowContext.suppliersById[supplierId]; + return partyDisplayName('delivery', supplier) || `Lieferant #${supplierId}`; } render() { - const { status, error, docs, busyId, bookingDoc, bookingOpen, suppliers } = this.state; + const { item: delivery, disabled } = this.props; + const dummy = this.props.headerItem || rowContext.headerItem; - const columns = [ - { - key: 'number', - label: 'Nummer', - render: (row) => ( - - {numberDisplay(row.number)} + // Data-free probe (FillSpace measures the row height with `item` undefined). + if (!delivery) { + if (disabled) { + return ( + +   + + ); + } + return ( + + {renderInvisibleDummyRow(dummy)} + + + + + + + + + + + + + + + ); + } + + const { busyId, onOpen, onDelete, onOpenBookDialog } = rowContext; + const files = Array.isArray(delivery.files) ? delivery.files : []; + const totalPositions = delivery.ledger?.totalPositions ?? delivery.ledger?.total ?? delivery.positions ?? 0; + const completelyBooked = delivery.ledger?.completelyOnLedgerPositions ?? delivery.ledger?.bookedPositions ?? 0; + const unbooked = delivery.ledger?.unbookedPositions ?? Math.max(0, totalPositions - completelyBooked); + + return ( + onOpen?.(delivery)} + disabled={disabled} + > + {renderInvisibleDummyRow(dummy)} + + + + {delivery.number} - ), - }, - { - key: 'createdAt', - label: 'Erstellt', - render: (row) => ( - - {row.createdAt ? formatDate(row.createdAt) : '–'} + + {numberDisplay(delivery.externalNumber)} - ), - }, - { - key: 'date', - label: 'Lieferung', - render: (row) => ( - - {row.date ? formatDate(row.date) : '–'} + + + + + {delivery.date ? formatDate(delivery.date) : '–'} - ), - }, - { - key: 'totalPositions', - label: 'Positionen', - render: (row) => { - const total = row.ledger?.totalPositions ?? row.ledger?.total ?? 0; - return ( - - {total} - - ); - }, - }, - { - key: 'unbooked', - label: 'Ungebucht', - render: (row) => { - const total = row.ledger?.totalPositions ?? row.ledger?.total ?? 0; - const completely = row.ledger?.completelyOnLedgerPositions ?? row.ledger?.booked ?? 0; - const unbooked = Math.max(0, total - completely); + - if (total === 0) { - return ( - - 0 - - ); - } + + + {this.supplierName(delivery.supplierId)} + + - if (unbooked === 0) { - return ( - - } - label="0" - color="success" - variant="outlined" - onClick={(e) => { - e.stopPropagation(); - this.handleOpenBookDialog(row); - }} - sx={{ fontWeight: 600, cursor: 'pointer' }} - /> - - ); - } - - return ( - e.stopPropagation()} - > - + e.stopPropagation()} + > + {totalPositions === 0 ? ( + 0 + ) : unbooked === 0 ? ( + + } + label="Gebucht" + color="success" + variant="outlined" + onClick={() => onOpenBookDialog?.(delivery)} + sx={{ fontWeight: 600, cursor: 'pointer' }} + /> + + ) : ( + <> + } label={String(unbooked)} color="warning" variant="outlined" - onClick={() => this.handleOpenBookDialog(row)} + onClick={() => onOpenBookDialog?.(delivery)} sx={{ fontWeight: 600, cursor: 'pointer' }} /> @@ -268,7 +289,7 @@ export class DeliveryList extends PureComponent { size="small" variant="contained" color="warning" - onClick={() => this.handleOpenBookDialog(row)} + onClick={() => onOpenBookDialog?.(delivery)} sx={{ py: 0.2, px: 1, @@ -280,124 +301,539 @@ export class DeliveryList extends PureComponent { > Buchen - - ); - }, - }, - { - key: 'supplierId', - label: 'Lieferant', - render: (row) => { - const supplier = suppliers?.find((s) => s.id === row.supplierId); - const name = supplier?.name || (row.supplierId != null ? `Lieferant #${row.supplierId}` : '–'); - return ( - - {name} - - ); - }, - }, - { - key: 'externalNumber', - label: 'Referenz', - render: (row) => ( - - {numberDisplay(row.externalNumber)} - - ), - }, - { - key: 'files', - label: 'Attachments', - render: (row) => { - const files = Array.isArray(row.files) ? row.files : []; - if (files.length === 0) { - return ( - - – - - ); - } + + )} + - return ( + + 0 ? 'text.primary' : 'text.disabled', + '& .MuiChip-label': { px: 0.5 }, + }} + /> + + + e.stopPropagation()} + > + {files.length === 0 ? ( + – + ) : ( + files.map((file, idx) => { + const docId = file.manifestId ?? delivery.id; + const fileId = file.fileId ?? file.id; + const isPdf = + file.mimetype === 'application/pdf' || + file.name?.toLowerCase().endsWith('.pdf'); + const isTxt = + file.mimetype?.startsWith('text/') || + file.name?.toLowerCase().endsWith('.txt') || + file.name?.toLowerCase().endsWith('.csv'); + + const icon = isPdf ? ( + + ) : isTxt ? ( + + ) : ( + + ); + + const title = `${file.name}${file.size ? ` (${formatFileSize(file.size)})` : ''}`; + const fileUrl = `/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`; + + return ( + + window.open(fileUrl, '_blank')} + sx={{ + p: 0.5, + border: '1px solid', + borderColor: 'divider', + bgcolor: 'background.paper', + borderRadius: 1, + '&:hover': { + bgcolor: 'action.hover', + borderColor: 'primary.main', + }, + }} + > + {icon} + + + ); + }) + )} + + + + { + e.stopPropagation(); + onDelete?.(delivery); + }} + onMouseDown={(e) => e.stopPropagation()} + sx={{ width: 40, height: 40 }} + > + + + + + ); + } +} + +// MARK: +class DeliverySearchField extends PureComponent { + constructor(props) { + super(props); + const s = deliveryAutomat.state; + const stats = deliveryAutomat.getChunkStats?.(); + this.state = { + input: s.filter ?? '', + onlyUnbooked: s.onlyUnbooked ?? 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 = deliveryAutomat.subscribe(this, (state) => ({ + input: state.filter ?? '', + onlyUnbooked: state.onlyUnbooked ?? false, + seekMonths: state.seekMonths ?? 0, + seekYears: state.seekYears ?? 0, + negativeSeekMonths: state.negativeSeekMonths ?? 0, + })); + if (import.meta.env.DEV) { + this.unsubStats = deliveryAutomat.subscribeChunkStats?.((stats) => { + this.setState({ + lruChunksCount: stats.lruChunksCount, + maxLruChunks: stats.maxLruChunks, + filterCachesCount: stats.filterCaches?.length || 1, + }); + }); + } + } + + componentWillUnmount() { + this.unsub?.(); + this.unsubStats?.(); + } + + render() { + const { inputRef, onCreate } = this.props; + const { input, onlyUnbooked, 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 & create button */} + + deliveryAutomat.actions.filter(next)} + /> + + + + {/* Filter Bar: "Nur ungebuchte", Negativeseek (-1 Month, -1 Year, Reset) */} + + {/* Left: Unbooked toggle */} e.stopPropagation()} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 1.5, + px: 1.5, + py: 0.25, + borderRadius: 2, + bgcolor: 'background.paper', + boxShadow: 1, + }} > - {files.map((file, idx) => { - const docId = file.manifestId ?? row.id; - const fileId = file.fileId ?? file.id; - const isPdf = - file.mimetype === 'application/pdf' || - file.name?.toLowerCase().endsWith('.pdf'); - const isTxt = - file.mimetype?.startsWith('text/') || - file.name?.toLowerCase().endsWith('.txt') || - file.name?.toLowerCase().endsWith('.csv'); - - const icon = isPdf ? ( - - ) : isTxt ? ( - - ) : ( - - ); - - const title = `${file.name}${file.size ? ` (${formatFileSize(file.size)})` : ''}`; - const fileUrl = `/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`; - - return ( - - window.open(fileUrl, '_blank')} - sx={{ - p: 0.5, - border: '1px solid', - borderColor: 'divider', - bgcolor: 'background.paper', - borderRadius: 1, - '&:hover': { - bgcolor: 'action.hover', - borderColor: 'primary.main', - }, - }} - > - {icon} - - - ); - })} + deliveryAutomat.actions.setOnlyUnbooked(e.target.checked)} + /> + } + label={ + + Nur ungebuchte + + } + 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', { detail: { target: 'delivery' } }))} + sx={{ + cursor: 'pointer', + height: 28, + fontWeight: 600, + fontSize: '0.75rem', + bgcolor: 'background.paper', + boxShadow: 1, + '&:hover': { bgcolor: 'action.hover' }, + }} + /> + + )} + + + + + ); + } +} + +// MARK: +export class DeliveryList extends PureComponent { + constructor(props) { + super(props); + this.headerItem = createDummyTestDelivery(); + this.state = { + items: deliveryAutomat.getItems(), + unfilteredTotal: deliveryAutomat.state.unfilteredTotal ?? 0, + filterKey: deliveryAutomat.getFilterKey?.() ?? '', + suppliersById: {}, + busyId: null, + bookingDoc: null, + bookingOpen: false, + }; + 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.handlePageChange = this.handlePageChange.bind(this); + this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this); + this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this); + this.handleBookSuccess = this.handleBookSuccess.bind(this); + } + + componentDidMount() { + this._cancelled = false; + this.unsub = deliveryAutomat.subscribe(this, (state) => ({ + items: state.items, + unfilteredTotal: state.unfilteredTotal ?? 0, + filterKey: deliveryAutomat.getFilterKey(state), + })); + this.unsubNumber = numberAutomat.subscribe(this, () => { + this.headerItem = createDummyTestDelivery(); + this.forceUpdate(); + }); + void numberAutomat.actions.ensureLoaded().catch(() => {}); + void this.loadSuppliers(); + } + + componentWillUnmount() { + this._cancelled = true; + this.unsub?.(); + this.unsubNumber?.(); + } + + async loadSuppliers() { + try { + const parties = await loadParties('delivery'); + if (!this._cancelled) { + this.setState({ suppliersById: cfg.partiesByIdFromList(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); + + deliveryAutomat.requireChunk(startChunk, endChunk); + } + + handleRetry() { + void deliveryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {}); + } + + handleCreate() { + this.props.navigate(cfg.createPath); + } + + handleOpen(delivery) { + this.props.navigate(cfg.editPath(delivery.id)); + } + + handleOpenBookDialog(delivery) { + this.setState({ bookingDoc: delivery, bookingOpen: true }); + } + + handleCloseBookDialog() { + this.setState({ bookingDoc: null, bookingOpen: false }); + } + + async handleBookSuccess() { + this.handleCloseBookDialog(); + try { + await deliveryAutomat.actions.reload(); + enqueueSnackbar('Lieferung erfolgreich verbucht.', { variant: 'success' }); + } catch (err) { + notifyError(err); + } + } + + async handleDelete(delivery) { + if (this.state.busyId != null) return; + if (!window.confirm(cfg.confirmDelete(delivery.number))) return; + this.setState({ busyId: delivery.id }); + try { + await deliveryApi.delete(delivery.id); + await deliveryAutomat.actions.reload(); + enqueueSnackbar(cfg.deletedMsg, { variant: 'success' }); + } catch (err) { + notifyError(err); + } finally { + if (!this._cancelled) this.setState({ busyId: null }); + } + } + + render() { + const { items, unfilteredTotal, filterKey, suppliersById, busyId, bookingDoc, bookingOpen } = this.state; + + rowContext.suppliersById = suppliersById; + rowContext.busyId = busyId; + rowContext.onOpen = this.handleOpen; + rowContext.onDelete = this.handleDelete; + rowContext.onOpenBookDialog = this.handleOpenBookDialog; + rowContext.headerItem = this.headerItem; + + if (items === null && deliveryAutomat.state.status === 'failed') { + return ( + <> + + + + + + + + ); + } + + const bookingPartyName = bookingDoc + ? suppliersById[bookingDoc.supplierId]?.name || (bookingDoc.supplierId ? `Lieferant #${bookingDoc.supplierId}` : null) + : null; return ( <> - } - rows={docs} - columns={columns} - createLabel="Neue Lieferung" - onCreate={this.handleCreate} - onEdit={this.handleEdit} - onDelete={this.handleDelete} - deleteDisabled={busyId != null} - emptyMessage="Noch keine Lieferungen." - color={ORANGE_PANEL} - getEditLabel={(row) => `„${row.number}“ bearbeiten`} - getDeleteLabel={(row) => `„${row.number}“ löschen`} - /> + + + + + {renderInvisibleDummyRow(this.headerItem)} + Nr. / Ref. + Lieferung + Lieferant + Lagerbuchung + Pos. + Anhänge + + + } + onPageChange={this.handlePageChange} + /> + {items === null && ( + + + + )} + + diff --git a/src/devtools/ChunkCacheMonitor.jsx b/src/devtools/ChunkCacheMonitor.jsx index cecc5c6..ac2cad0 100644 --- a/src/devtools/ChunkCacheMonitor.jsx +++ b/src/devtools/ChunkCacheMonitor.jsx @@ -40,17 +40,20 @@ import orderAutomat, { } from '../automats/orderAutomat'; import quoteAutomat from '../automats/quoteAutomat'; 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 === 'shipment') return shipmentAutomat; + if (target === 'delivery') return deliveryAutomat; return orderAutomat; } function getInitialTarget() { if (typeof window !== 'undefined') { const p = window.location.pathname; + if (p.startsWith('/lieferung')) return 'delivery'; if (p.startsWith('/auftrag/ausliefern')) return 'shipment'; if (p.startsWith('/auftrag/bestellung')) return 'order'; if (p.startsWith('/auftrag')) return 'quote'; @@ -296,6 +299,10 @@ class ChunkCacheMonitor extends PureComponent { if (p.onlyUnpaid) automat.actions.setOnlyUnpaid(true); if (p.onlyUndelivered) automat.actions.setOnlyUndelivered(true); } + if (this.state.target === 'delivery') { + if (p.onlyUnbooked) automat.actions.setOnlyUnbooked(true); + if (p.supplierId) automat.actions.setSupplierId(p.supplierId); + } if (this.state.target === 'shipment') { if (p.stateFilter) automat.actions.setStateFilter(p.stateFilter); } @@ -723,11 +730,15 @@ class ChunkCacheMonitor extends PureComponent { {chunkOrders.map(({ item: order, index }) => { const isOrd = this.state.target === 'order'; const isShip = this.state.target === 'shipment'; + const isDeliv = this.state.target === 'delivery'; const paid = isOrd ? isOrderPaid(order) : false; const delivered = isOrd ? isOrderDelivered(order) : false; const gross = order.grossTotal != null ? Number(order.grossTotal) : (order.lines ? orderGrossTotal(order.lines) : 0); - const customer = order.customerName || (order.entityId ? `Kunde #${order.entityId}` : '–'); + const customer = isDeliv + ? (order.supplierName || (order.supplierId ? `Lieferant #${order.supplierId}` : '–')) + : (order.customerName || (order.entityId ? `Kunde #${order.entityId}` : '–')); const posCount = order.positions != null ? order.positions : (order.lines?.length ?? 0); + const unbooked = order.ledger?.unbookedPositions ?? 0; return ( )} + {isDeliv && ( + + )} {isShip && ( <> this.switchTarget('order')} sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'order' ? 700 : 500 }} /> + this.switchTarget('delivery')} + sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'delivery' ? 700 : 500 }} + /> sum + Number(e.amount || 0), 0); const targetAmount = Number(line.amount || 0); - const remainingAmount = trackStock ? Math.max(0, targetAmount - bookedAmount) : 0; - const isBooked = trackStock ? (targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0) : true; + const remainingAmount = Math.max(0, targetAmount - bookedAmount); + const isBooked = targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0; return { ...line, @@ -134,21 +134,20 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def remainingAmount, isBooked, trackStock, - isBookable: trackStock, + isBookable: true, ledgerEntries: deliveryEntries, }; }) ); - const stockLines = linesWithLedger.filter((l) => l.trackStock); - const totalPositions = stockLines.length; - const bookedPositions = stockLines.filter((l) => l.isBooked).length; - const totalQuantity = stockLines.reduce((sum, l) => sum + l.targetAmount, 0); - const bookedQuantity = stockLines.reduce((sum, l) => sum + l.bookedAmount, 0); - const hasTrackStock = stockLines.length > 0; + const totalPositions = linesWithLedger.length; + const bookedPositions = linesWithLedger.filter((l) => l.isBooked).length; + const totalQuantity = linesWithLedger.reduce((sum, l) => sum + l.targetAmount, 0); + const bookedQuantity = linesWithLedger.reduce((sum, l) => sum + l.bookedAmount, 0); + const hasTrackStock = linesWithLedger.some((l) => l.trackStock); let status; - if (!hasTrackStock) { + if (totalPositions === 0) { status = 'empty'; } else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) { status = 'booked'; @@ -176,8 +175,8 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def * * @param {Object} params * @param {Object} params.delivery - The delivery object (used for transaction memo and fallback) - * @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook - * @param {number|string} params.warehouseId - Mandatory warehouse ID + * @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string, trackStock?: boolean }>} params.linesToBook + * @param {number|string} [params.warehouseId] - Warehouse ID (mandatory only when at least one line tracks stock) * @param {Object} [params.stockApi] * @param {string} [params.transactionText] * @returns {Promise>} @@ -189,15 +188,17 @@ export async function bookDeliveryLines({ stockApi = defaultStockApi, transactionText, }) { - const numericWarehouseId = Number(warehouseId); - if (!numericWarehouseId || numericWarehouseId <= 0) { - throw new Error('Bitte wählen Sie ein Lager aus.'); - } - if (!Array.isArray(linesToBook) || linesToBook.length === 0) { return []; } + const numericWarehouseId = Number(warehouseId) || null; + const hasTrackedLines = linesToBook.some((l) => l.trackStock); + + if (hasTrackedLines && (!numericWarehouseId || numericWarehouseId <= 0)) { + throw new Error('Bitte wählen Sie ein Lager aus.'); + } + const memo = String( transactionText != null && transactionText !== '' ? transactionText diff --git a/src/utils/documentNumber.js b/src/utils/documentNumber.js index be6d062..3d99d72 100644 --- a/src/utils/documentNumber.js +++ b/src/utils/documentNumber.js @@ -136,7 +136,9 @@ export function getMaxDocumentNumber(kind = 'quote') { const prefixStyle = refSeq?.prefix ?? ''; let prefix = ''; - if ( + if (kind === 'delivery') { + prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-'); + } else if ( prefixStyle.startsWith('ART-') || prefixStyle.startsWith('LS-') || prefixStyle.startsWith('KD-') ||