diff --git a/src/api/article.js b/src/api/article.js index 9bda351..6c2a86c 100644 --- a/src/api/article.js +++ b/src/api/article.js @@ -29,15 +29,20 @@ export const article = { }, /** - * GET /api/article/history - * History of all articles with paging/sorting/filtering + * GET /api/article/history?offset=0&limit=50&search=...&seekMonths=-1 + * @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, startRow?: number, endRow?: number }} [params] + * → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number } */ - getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) { + getHistory({ offset = 0, limit = 50, search, seekMonths, startRow, endRow, sortModel, filterModel } = {}) { const params = new URLSearchParams(); - if (startRow != null) params.set('startRow', String(startRow)); - if (endRow != null) params.set('endRow', String(endRow)); - if (offset != null) params.set('offset', String(offset)); - if (limit != null) params.set('limit', String(limit)); + const finalOffset = offset != null ? offset : (startRow != null ? startRow : 0); + const finalLimit = limit != null ? limit : (endRow != null && startRow != null ? endRow - startRow : 50); + params.set('offset', String(Math.max(0, Math.floor(Number(finalOffset) || 0)))); + params.set('limit', String(Math.max(1, Math.floor(Number(finalLimit) || 50)))); + if (search && String(search).trim()) params.set('search', String(search).trim()); + if (seekMonths != null && Number(seekMonths) < 0) { + params.set('seekMonths', String(Math.floor(Number(seekMonths)))); + } if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel)); if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel)); const qs = params.toString(); diff --git a/src/api/globalHistory.js b/src/api/globalHistory.js index d1fdf3b..ad5377e 100644 --- a/src/api/globalHistory.js +++ b/src/api/globalHistory.js @@ -2,16 +2,22 @@ import { http } from './http.js'; export const globalHistory = { /** - * GET /api/history - * Global system/entity history with paging/sorting/filtering + * GET /api/history?offset=0&limit=50&search=...&seekMonths=-1 + * Global system/entity history with chunk paging and search/seek filtering + * @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, startRow?: number, endRow?: number }} [params] + * → { history: GlobalHistoryEntry[], total: number, unfilteredTotal?: number } */ // #region GET /api/history — list - list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) { + list({ offset = 0, limit = 50, search, seekMonths, startRow, endRow, sortModel, filterModel } = {}) { const params = new URLSearchParams(); - if (startRow != null) params.set('startRow', String(startRow)); - if (endRow != null) params.set('endRow', String(endRow)); - if (offset != null) params.set('offset', String(offset)); - if (limit != null) params.set('limit', String(limit)); + const finalOffset = offset != null ? offset : (startRow != null ? startRow : 0); + const finalLimit = limit != null ? limit : (endRow != null && startRow != null ? endRow - startRow : 50); + params.set('offset', String(Math.max(0, Math.floor(Number(finalOffset) || 0)))); + params.set('limit', String(Math.max(1, Math.floor(Number(finalLimit) || 50)))); + if (search && String(search).trim()) params.set('search', String(search).trim()); + if (seekMonths != null && Number(seekMonths) < 0) { + params.set('seekMonths', String(Math.floor(Number(seekMonths)))); + } if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel)); if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel)); const qs = params.toString(); diff --git a/src/automats/articleHistoryAutomat.js b/src/automats/articleHistoryAutomat.js index 02c5c17..a65ba10 100644 --- a/src/automats/articleHistoryAutomat.js +++ b/src/automats/articleHistoryAutomat.js @@ -1,45 +1,26 @@ /** * @file articleHistoryAutomat.js - * @description Virtualized grid automat (`articleHistoryGridAutomat`) for - * infinite-scrolling article history via `react-virtual-aggridautomat`. + * @description Double-buffered chunk list automat (`articleHistoryAutomat`) for + * article audit logs and history entries with multi-filter caching and LRU eviction. * * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — calls `reload()` when - * status reaches `'authed'` with a `selectedCompanyId`. - * - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes. + * - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when + * `selectedCompanyId` changes. + * - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`, + * `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`. */ -import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat'; +import { createChunkListAutomat } from './createChunkListAutomat'; import { article } from '../api'; -import authAutomat from './authAutomat'; -export const articleHistoryGridAutomat = createGridAutomat({ +export const articleHistoryAutomat = createChunkListAutomat({ name: 'articleHistory', - blockSize: 50, - fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => { - try { - const res = await article.getHistory({ - startRow, - endRow, - sortModel, - filterModel, - }); - const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []); - const total = res?.total ?? res?.totalRows ?? res?.totalCount; - const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined); - return { rows, lastRow }; - } catch (err) { - console.warn('Failed to fetch article history (backend route pending):', err); - return { rows: [], lastRow: 0 }; - } - }, + chunkSize: 50, + maxLruChunks: 50, + fetchPage: (params) => article.getHistory(params), }); -// Reload on company switch or auth change -authAutomat.subscribe((auth) => { - if (auth.status === 'authed' && auth.user?.selectedCompanyId) { - articleHistoryGridAutomat.actions.reload(); - } -}); +// Alias for backwards compatibility +export const articleHistoryGridAutomat = articleHistoryAutomat; -export default articleHistoryGridAutomat; +export default articleHistoryAutomat; diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js index 5b67e92..524ee87 100644 --- a/src/automats/chatAutomat.js +++ b/src/automats/chatAutomat.js @@ -87,6 +87,8 @@ const initialState = { draftConversation: false, /** Prefilled input text waiting to be consumed by the chat UI. */ pendingInput: null, + /** Staged text extraction snippet for data extraction pill. */ + pendingExtraction: null, conversationState: 'idle', provider: 'openrouter', model: DEFAULT_MODEL, @@ -327,12 +329,49 @@ const actions = { void chatAutomat.actions.loadConversations(); }, + /** + * Stage an extracted text snippet (e.g. from an invoice or delivery document) as a pill. + * @param {{ text: string, size?: number, label?: string }} snippet + */ + insertExtractionSnippet({ text = '', size, label } = {}) { + const trimmed = String(text || '').trim(); + if (!trimmed) return; + const finalSize = Number(size) || new Blob([trimmed]).size; + chatOpenedThisSession = true; + chatAutomat.setState({ + isOpen: true, + sseEstablished: true, + pendingExtraction: { + text: trimmed, + size: finalSize, + label, + }, + }); + ensureCompanyEventsConnected(); + if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) { + chatAutomat.actions.startDraft(); + } + void chatAutomat.actions.loadConversations(); + }, + /** * Open the chat drawer and append text / prompt to the chat composer. + * If the prompt is an extracted text snippet (e.g. `Text aus „...“:`), + * automatically converts it into a staged extraction pill. * @param {string} prompt */ insertPrompt(prompt = '') { chatOpenedThisSession = true; + const trimmedPrompt = String(prompt || '').trim(); + const extractionMatch = trimmedPrompt.match(/^(?:Text for Data Extraction|Text aus „[^“]+“):\s*```(?:\w+)?\n([\s\S]*?)```$/); + if (extractionMatch) { + const content = (extractionMatch[1] || '').trim(); + if (content) { + chatAutomat.actions.insertExtractionSnippet({ text: content }); + return; + } + } + chatAutomat.setState((prev) => ({ isOpen: true, sseEstablished: true, diff --git a/src/automats/createChunkListAutomat.js b/src/automats/createChunkListAutomat.js new file mode 100644 index 0000000..bef5923 --- /dev/null +++ b/src/automats/createChunkListAutomat.js @@ -0,0 +1,926 @@ +/** + * @file createChunkListAutomat.js + * @description Factory function for company-scoped, chunk-cached double-buffered + * list automats with LRU memory caching, debounced prefetching, and multi-filter + * retention (like `orderAutomat` and `quoteAutomat`). + * + * Upstream Triggers: + * - `authAutomat`: Subscribes each created automat to auth state — resets on `'anon'` + * or when `selectedCompanyId` changes. + * - Consumer UI (e.g. `FillSpace`): Calls `getItems()` and `requireChunk(start, end)`. + */ + +import { Automat } from 'react-automat'; +import authAutomat from './authAutomat'; + +export const DEFAULT_CHUNK_SIZE = 50; +export const DEFAULT_MAX_LRU_CHUNKS = 50; +const PREFETCH_CHUNKS = 1; +const CHUNK_DEBOUNCE_MS = 130; +const MAX_CHUNK_EVENTS = 150; + +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(' ')}`; +} + +/** + * Creates a chunk-paginated, double-buffered automat. + * + * @param {object} options + * @param {string} options.name Name for the Automat instance. + * @param {(params: { offset: number, limit: number, search?: string, seekMonths?: number }) => Promise<{ items?: any[], history?: any[], rows?: any[], entries?: any[], total?: number, unfilteredTotal?: number }>} options.fetchPage + * @param {number} [options.chunkSize=50] + * @param {number} [options.maxLruChunks=50] + * @returns {Automat} Configured chunk-list automat. + */ +export function createChunkListAutomat({ + name, + fetchPage, + chunkSize = DEFAULT_CHUNK_SIZE, + maxLruChunks = DEFAULT_MAX_LRU_CHUNKS, +}) { + const initialState = { + items: null, + total: 0, + unfilteredTotal: 0, + filter: '', + 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(); + + // Observability & Stats + let nextEventId = 0; + const chunkEvents = []; + const chunkStatsListeners = new Set(); + + function makeLruKey(filterKey, chunkIndex) { + return `${filterKey}:::${chunkIndex}`; + } + + function parseLruKey(key) { + const [filterKey, chunkStr] = key.split(':::'); + return { filterKey, chunkIndex: Number(chunkStr) }; + } + + function getFilterParams(state = automat?.state || initialState) { + const filter = String(state.filter ?? '').trim(); + const negativeSeekMonths = + state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0 + ? Number(state.negativeSeekMonths) + : 0; + return { filter, negativeSeekMonths }; + } + + function getFilterKey(state = automat?.state || initialState) { + const { filter, negativeSeekMonths } = getFilterParams(state); + return JSON.stringify({ f: filter, s: negativeSeekMonths }); + } + + function formatFilterLabel(filterKeyOrState) { + let f, s; + if (typeof filterKeyOrState === 'string') { + try { + const parsed = JSON.parse(filterKeyOrState); + f = parsed.f; + s = parsed.s; + } catch { + return filterKeyOrState; + } + } else if (filterKeyOrState && typeof filterKeyOrState === 'object') { + if ('f' in filterKeyOrState) { + f = filterKeyOrState.f; + s = filterKeyOrState.s; + } else { + const params = getFilterParams(filterKeyOrState); + f = params.filter; + s = params.negativeSeekMonths; + } + } + + const parts = []; + if (f) parts.push(`Suche: „${f}“`); + if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`); + if (parts.length === 0) return 'Standard (Alle Einträge)'; + 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 > maxLruChunks) { + 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 * chunkSize; + for (let i = offset; i < offset + chunkSize && 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: + reason === 'lru' + ? `LRU-Eviction: Chunk #${chunkIndex} von Filter "${entry.label}" verworfen` + : `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(`[${name}] chunkStatsListener error`, e); + } + } + } + + 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 count = total > 0 ? Math.ceil(total / chunkSize) : 0; + + const activeInflight = []; + for (const key of inflightChunks.keys()) { + const info = parseLruKey(key); + if (info.filterKey === currentKey) { + activeInflight.push(info.chunkIndex); + } + } + + const items = activeEntry?.items ?? automat?.state?.items ?? null; + const loadedItemsCount = countNonNullItems(items); + + return { + chunkSize, + maxLruChunks, + lruChunksCount: lruChunks.size, + total, + unfilteredTotal: activeEntry?.unfilteredTotal ?? automat?.state?.unfilteredTotal ?? total, + chunkCount: count, + 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 ?? '', + negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0, + items, + activeFilterKey: currentKey, + activeFilterLabel: formatFilterLabel(currentKey), + events: [...chunkEvents], + }; + } + + 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); + } + + 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 * chunkSize >= 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; + }); + } + + function buildQueryParamsFor(filterParams, offset = 0, limit = chunkSize) { + const params = { offset, limit }; + const trimmed = String(filterParams.filter ?? '').trim(); + if (trimmed) params.search = trimmed; + if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) { + params.seekMonths = Number(filterParams.negativeSeekMonths); + } + return params; + } + + async function fetchRecordsPageFor(filterParams, offset = 0, limit = chunkSize) { + const params = buildQueryParamsFor(filterParams, offset, limit); + const res = await fetchPage(params); + const records = Array.isArray(res?.items) + ? res.items + : Array.isArray(res?.history) + ? res.history + : Array.isArray(res?.rows) + ? res.rows + : Array.isArray(res?.entries) + ? res.entries + : Array.isArray(res) + ? res + : []; + const total = Number(res?.total ?? res?.totalRows ?? res?.totalCount ?? records.length); + const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total; + return { records, 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, + }); + + recordChunkEvent({ + type: 'filter_hit', + epoch: queryEpoch, + filterKey: currentKey, + message: `Filter gewechselt: "${cached.label}" (Cache-Hit: ${cached.loadedChunks.size} Chunks / ${countNonNullItems(cached.items)} Einträge 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 = { + 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); + 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; + }, + + 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 * chunkSize; + const limit = chunkSize; + 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 = fetchRecordsPageFor(filterParams, offset, limit) + .then(({ records, 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 < records.length; i++) { + const idx = offset + i; + if (idx < entry.items.length) { + entry.items[idx] = records[i]; + } + } + + entry.loadedChunks.add(index); + entry.status = 'ready'; + entry.error = null; + entry.lastAccessed = Date.now(); + + registerChunkLRU(targetFilterKey, index, records.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: records.length, + duration, + message: `Chunk #${index} geladen (${records.length} Einträge 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(`[${name}] 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; + }, + + filter(query) { + automat.setState({ filter: String(query ?? '') }); + onFilterChange(false); + }, + + 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: '', + seekMonths: 0, + seekYears: 0, + negativeSeekMonths: 0, + }); + onFilterChange(true); + }, + + load() { + return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); + }, + + ensureLoaded() { + if (automat.state.items !== null) { + return Promise.resolve(automat.state.items); + } + return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); + }, + + 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; + }, + + 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; + + const chunkCount = count > 0 ? Math.ceil(count / chunkSize) : 0; + for (let c = 0; c < chunkCount; c++) { + entry.loadedChunks.add(c); + registerChunkLRU(currentKey, c, Math.min(chunkSize, count - c * chunkSize)); + } + + automat.setState({ + items, + total: count, + unfilteredTotal: count, + status: 'ready', + error: null, + }); + notifyChunkStats(); + }, + + clearFilterCache(filterKey) { + const entry = filterCaches.get(filterKey); + if (entry) { + for (const c of entry.loadedChunks) { + lruChunks.delete(makeLruKey(filterKey, c)); + } + entry.items = null; + entry.loadedChunks.clear(); + entry.status = 'idle'; + entry.error = null; + + if (filterKey === getFilterKey(automat.state)) { + automat.setState({ items: null, status: 'idle', error: null }); + } + + recordChunkEvent({ + type: 'clear_filter', + filterKey, + message: `Filter-Cache "${entry.label}" geleert`, + }); + notifyChunkStats(); + } + }, + + clearCache() { + clearChunkDebounce(); + lruChunks.clear(); + inflightChunks.clear(); + + for (const entry of filterCaches.values()) { + entry.items = null; + entry.loadedChunks.clear(); + entry.status = 'idle'; + entry.error = null; + } + + recordChunkEvent({ + type: 'clear_all', + message: `Gesamter ${name}-Chunk-Cache geleert`, + }); + notifyChunkStats(); + + automat.setState({ + items: null, + status: 'idle', + error: null, + }); + }, + + reset() { + if (filterDebounceTimer) { + clearTimeout(filterDebounceTimer); + filterDebounceTimer = null; + } + clearChunkDebounce(); + filterCaches.clear(); + lruChunks.clear(); + inflightChunks.clear(); + + recordChunkEvent({ + type: 'reset', + message: `${name}-Automat und sämtliche Caches zurückgesetzt`, + }); + notifyChunkStats(); + automat.setState(initialState); + }, + }; + + automat = new Automat(initialState, actions, { name }); + + automat.getItems = actions.getItems; + automat.requireChunk = actions.requireChunk; + automat.CHUNK_SIZE = chunkSize; + automat.MAX_LRU_CHUNKS = maxLruChunks; + automat.getChunkStats = getChunkStats; + automat.subscribeChunkStats = subscribeChunkStats; + automat.getFilterKey = getFilterKey; + automat.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; + } + }); + + return automat; +} diff --git a/src/automats/globalHistoryAutomat.js b/src/automats/globalHistoryAutomat.js index 38f3981..6a75d28 100644 --- a/src/automats/globalHistoryAutomat.js +++ b/src/automats/globalHistoryAutomat.js @@ -1,45 +1,26 @@ /** * @file globalHistoryAutomat.js - * @description Virtualized grid automat (`globalHistoryGridAutomat`) for - * infinite-scrolling global audit/change history via `react-virtual-aggridautomat`. + * @description Double-buffered chunk list automat (`globalHistoryAutomat`) for + * global system and change history logs with multi-filter caching and LRU eviction. * * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — calls `reload()` when - * status reaches `'authed'` with a `selectedCompanyId`. - * - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes. + * - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when + * `selectedCompanyId` changes. + * - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`, + * `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`. */ -import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat'; +import { createChunkListAutomat } from './createChunkListAutomat'; import { globalHistory } from '../api'; -import authAutomat from './authAutomat'; -export const globalHistoryGridAutomat = createGridAutomat({ +export const globalHistoryAutomat = createChunkListAutomat({ name: 'globalHistory', - blockSize: 50, - fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => { - try { - const res = await globalHistory.list({ - startRow, - endRow, - sortModel, - filterModel, - }); - const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []); - const total = res?.total ?? res?.totalRows ?? res?.totalCount; - const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined); - return { rows, lastRow }; - } catch (err) { - console.warn('Failed to fetch global history (backend route pending):', err); - return { rows: [], lastRow: 0 }; - } - }, + chunkSize: 50, + maxLruChunks: 50, + fetchPage: (params) => globalHistory.list(params), }); -// Reload on company switch or auth change -authAutomat.subscribe((auth) => { - if (auth.status === 'authed' && auth.user?.selectedCompanyId) { - globalHistoryGridAutomat.actions.reload(); - } -}); +// Alias for backwards compatibility +export const globalHistoryGridAutomat = globalHistoryAutomat; -export default globalHistoryGridAutomat; +export default globalHistoryAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index a2cd1a0..e68e1cc 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -176,7 +176,7 @@ export { } from './categoryViewAutomat'; export { default as articleHistoryGridAutomat } from './articleHistoryAutomat'; -export { default as globalHistoryGridAutomat } from './globalHistoryAutomat'; +export { default as globalHistoryAutomat, globalHistoryGridAutomat } from './globalHistoryAutomat'; export { default as chatAutomat } from './chatAutomat'; export { diff --git a/src/components/chat/chatAttachments.js b/src/components/chat/chatAttachments.js index 94b9751..afd97c3 100644 --- a/src/components/chat/chatAttachments.js +++ b/src/components/chat/chatAttachments.js @@ -184,15 +184,46 @@ function getMimeTypeFallback(category, filename) { } /** - * Trigger download of an attachment in the browser. - * @param {object} attachment + * Format bytes into human-readable kb size for data extraction pills (e.g. "2.4 kb" or "12 kb"). + * @param {number} bytes + * @returns {string} */ -export function downloadAttachment(attachment) { - if (!attachment) return; - const link = document.createElement('a'); - link.href = attachment.dataUrl || (attachment.url ? attachment.url : ''); - link.download = attachment.name || 'anhang'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); +export function formatExtractionSize(bytes) { + const b = Number(bytes) || 0; + const kb = b / 1024; + if (kb < 1) { + return `${Math.max(0.1, kb).toFixed(1)} kb`; + } + return `${kb.toFixed(kb >= 10 ? 0 : 1)} kb`; } + +/** + * Parses extraction text blocks (e.g. `Text for Data Extraction:\n```\n...```\n` or `Text aus „...“:\n```\n...```\n`) + * from a message string, returning the extracted snippets as structured pills and the remaining text. + * @param {string} rawText + * @returns {{ extractions: Array<{ text: string, size: number, label: string }>, cleanText: string }} + */ +export function parseExtractionsFromText(rawText) { + if (typeof rawText !== 'string' || !rawText) { + return { extractions: [], cleanText: '' }; + } + + const extractionRegex = /(?:Text for Data Extraction|Text aus „[^“]+“):\s*```(?:\w+)?\n([\s\S]*?)```/g; + const extractions = []; + let match; + while ((match = extractionRegex.exec(rawText)) !== null) { + const content = (match[1] || '').trim(); + if (content) { + const size = new Blob([content]).size; + extractions.push({ + text: content, + size, + label: `Text for Data Extraction - ${formatExtractionSize(size)}`, + }); + } + } + + const cleanText = rawText.replace(extractionRegex, '').trim(); + return { extractions, cleanText }; +} + diff --git a/src/components/manifest/Attachment.jsx b/src/components/manifest/Attachment.jsx index ecbc891..b00f4ad 100644 --- a/src/components/manifest/Attachment.jsx +++ b/src/components/manifest/Attachment.jsx @@ -9,6 +9,7 @@ import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineR import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles'; import SkeleHashImage from '../settings/SkeleHashImage'; +import { enqueueSnackbar } from 'notistack'; import { generateTextThumbnail } from '../../utils/textThumbnail'; import chatAutomat from '../../automats/chatAutomat'; import { @@ -79,20 +80,44 @@ export class Attachment extends PureComponent { let textContent = attachment.text || ''; if (!textContent && (attachment.file instanceof Blob || attachment.file instanceof File)) { - try { - textContent = await attachment.file.text(); - } catch (err) { - console.error('Fehler beim Lesen des Texts:', err); + if (attachment.file.size > 0) { + try { + textContent = await attachment.file.text(); + } catch (err) { + console.error('Fehler beim Lesen des Texts:', err); + } + } else { + const docId = this.props.docId ?? attachment.manifestId; + const fileId = attachment.fileId ?? attachment.id; + const kind = this.props.kind || 'delivery'; + const prefix = kind === 'delivery' ? 'delivery' : kind === 'quote' ? 'quote' : 'order'; + if (docId != null && fileId != null) { + try { + const res = await fetch(`/api/${prefix}/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`, { + credentials: 'include', + }); + if (res.ok) { + textContent = await res.text(); + } + } catch (fetchErr) { + console.error('Fehler beim Laden der Datei:', fetchErr); + } + } } } const trimmed = (textContent || '').trim(); - if (!trimmed) return; + if (!trimmed) { + enqueueSnackbar('Datei enthält keinen Text zum Erwähnen im Chat.', { variant: 'warning' }); + return; + } - const title = attachment.name ? `Text aus „${attachment.name}“:` : 'Text:'; - const quote = `${title}\n\`\`\`\n${trimmed}\n\`\`\``; - - chatAutomat.actions.insertPrompt(quote); + const size = attachment.size || new Blob([trimmed]).size; + chatAutomat.actions.insertExtractionSnippet({ + text: trimmed, + size, + }); + enqueueSnackbar('Text im Chat erwähnt', { variant: 'success' }); }; handleDelete = (e) => { @@ -265,6 +290,7 @@ export class Attachment extends PureComponent { disabled={disabled} onClick={this.handleRescan} aria-label="Zu Text" + title="Zu Text" sx={{ bgcolor: SCRIM_ACTION, color: WHITE, @@ -285,6 +311,7 @@ export class Attachment extends PureComponent { disabled={disabled} onClick={this.handleMentionInChat} aria-label="Im Chat erwähnen" + title="Im Chat erwähnen" sx={{ bgcolor: SCRIM_ACTION, color: WHITE, @@ -304,6 +331,7 @@ export class Attachment extends PureComponent { disabled={disabled} onClick={this.handleDelete} aria-label="Löschen" + title="Löschen" sx={{ bgcolor: SCRIM_ACTION, color: WHITE, diff --git a/src/routes/infos/ArticleHistoryPage.jsx b/src/routes/infos/ArticleHistoryPage.jsx index 14726f3..ed943ef 100644 --- a/src/routes/infos/ArticleHistoryPage.jsx +++ b/src/routes/infos/ArticleHistoryPage.jsx @@ -1,107 +1,448 @@ -import { PureComponent } from 'react'; +import { PureComponent, createRef } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; -import RefreshIcon from '@mui/icons-material/Refresh'; import CircularProgress from '@mui/material/CircularProgress'; -import { PanelTile } from 'dashgui/panels'; -import { AutomatGrid } from 'react-virtual-aggridautomat'; -import { articleHistoryGridAutomat } from '../../automats'; +import ListItemButton from '@mui/material/ListItemButton'; +import Typography from '@mui/material/Typography'; +import Skeleton from '@mui/material/Skeleton'; +import Tooltip from '@mui/material/Tooltip'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import Chip from '@mui/material/Chip'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; +import { PanelTile, PanelTileGrid } from 'dashgui/panels'; +import BackendError from '../../components/BackendError'; +import articleHistoryAutomat from '../../automats/articleHistoryAutomat'; import { router } from '../../appRouter'; -import { - LINK, -} from '../../colors'; +import { backendErrorFrom } from '../../utils/backendError'; +import { LINK } from '../../colors'; -const COLUMN_DEFS = [ - { - headerName: 'ID', - field: 'id', - maxWidth: 90, - sortable: true, - filter: 'agNumberColumnFilter', - cellStyle: { fontVariantNumeric: 'tabular-nums' }, - }, - { - headerName: 'Datum', - field: 'date', - minWidth: 170, - maxWidth: 200, - sortable: true, - valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''), - cellStyle: { fontVariantNumeric: 'tabular-nums' }, - }, - { - headerName: 'Artikel-Nr.', - field: 'sku', - minWidth: 130, - maxWidth: 160, - sortable: true, - filter: 'agTextColumnFilter', - cellRenderer: (p) => { - if (!p.value) return ''; +const CHUNK_SIZE = 50; + +function formatDateTime(val) { + if (!val) return '–'; + try { + const d = new Date(val); + if (isNaN(d.getTime())) return String(val); + return d.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return String(val); + } +} + +export function createDummyTestArticleHistory() { + return { + id: 999999, + date: '2030-10-30T12:00:00Z', + sku: 'SKU-99999999', + name: 'Musterartikel Name Langform', + text: 'Preis geändert von 10,00 € auf 12,50 € durch Benutzer', + user: 'Max Mustermann', + }; +} + +const rowGridSx = { + display: 'grid', + gridTemplateColumns: + 'minmax(60px, max-content) minmax(150px, max-content) minmax(120px, max-content) minmax(160px, 1.2fr) minmax(240px, 2fr) minmax(120px, max-content)', + gridTemplateRows: '0px 1fr', + rowGap: 0, + columnGap: 2, + 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 || createDummyTestArticleHistory(); + return ( + <> + + {d.id} + + + {formatDateTime(d.date)} + + + {d.sku} + + + {d.name} + + + {d.text} + + + {d.user} + + + ); +} + +class ArticleHistoryRow extends PureComponent { + render() { + const { item, disabled, headerItem } = this.props; + const dummy = headerItem || createDummyTestArticleHistory(); + + if (!item) { return ( - { - e.preventDefault(); - void router.navigate(`/artikel/${encodeURIComponent(p.value)}`); - }} - style={{ color: LINK, fontWeight: 600, textDecoration: 'none' }} + - {p.value} - + {renderInvisibleDummyRow(dummy)} + + + + + + + + + + + + + + + + + + + ); - }, - }, - { - headerName: 'Artikelname', - field: 'name', - minWidth: 180, - flex: 1, - sortable: true, - filter: 'agTextColumnFilter', - }, - { - headerName: 'Änderung / Ereignis', - field: 'text', - minWidth: 300, - flex: 2, - sortable: true, - filter: 'agTextColumnFilter', - cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' }, - autoHeight: true, - }, - { - headerName: 'Benutzer', - field: 'user', - maxWidth: 160, - sortable: true, - filter: 'agTextColumnFilter', - valueGetter: (p) => - p.data?.user || - p.data?.employee || - p.data?.companyuser || - (p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'), - }, -]; + } + + const userName = + item.user || + item.employee || + item.companyuser || + (item.companyuserId ? `User #${item.companyuserId}` : '—'); + + return ( + { + void router.navigate(`/artikel/${encodeURIComponent(item.sku)}`); + } + : undefined + } + > + {renderInvisibleDummyRow(dummy)} + + + + {item.id} + + + + + + {formatDateTime(item.date || item.createdAt)} + + + + + {item.sku ? ( + { + e.preventDefault(); + e.stopPropagation(); + void router.navigate(`/artikel/${encodeURIComponent(item.sku)}`); + }} + sx={{ + color: LINK, + fontWeight: 600, + textDecoration: 'none', + ...ellipsisSx, + '&:hover': { textDecoration: 'underline' }, + }} + > + {item.sku} + + ) : ( + + – + + )} + + + + + {item.name || item.articlename || '–'} + + + + + + + {item.text || item.message || '–'} + + + + + + + {userName} + + + + ); + } +} + +class ArticleHistorySearchField extends PureComponent { + constructor(props) { + super(props); + const s = articleHistoryAutomat.state; + const stats = articleHistoryAutomat.getChunkStats?.(); + this.state = { + input: s.filter ?? '', + seekMonths: s.seekMonths ?? 0, + seekYears: s.seekYears ?? 0, + lruChunksCount: stats?.lruChunksCount || 0, + maxLruChunks: stats?.maxLruChunks || 50, + }; + } + + componentDidMount() { + this.unsub = articleHistoryAutomat.subscribe(this, (state) => ({ + input: state.filter ?? '', + seekMonths: state.seekMonths ?? 0, + seekYears: state.seekYears ?? 0, + })); + if (import.meta.env.DEV) { + this.unsubStats = articleHistoryAutomat.subscribeChunkStats?.((stats) => { + this.setState({ + lruChunksCount: stats.lruChunksCount, + maxLruChunks: stats.maxLruChunks, + }); + }); + } + } + + componentWillUnmount() { + this.unsub?.(); + this.unsubStats?.(); + } + + render() { + const { inputRef, onReload, loading } = this.props; + const { input, 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 */} + + articleHistoryAutomat.actions.filter(next)} + /> + + + + {/* Filter Bar: Negativeseek (-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' }, + }} + /> + )} + + + + ); + } +} export default class ArticleHistoryPage extends PureComponent { constructor(props) { super(props); - const s = articleHistoryGridAutomat.state; + this.headerItem = createDummyTestArticleHistory(); this.state = { - loading: s.loading, - error: s.error, - totalRows: s.totalRows, + items: articleHistoryAutomat.getItems(), + unfilteredTotal: articleHistoryAutomat.state.unfilteredTotal ?? 0, + filterKey: articleHistoryAutomat.getFilterKey?.() ?? '', + status: articleHistoryAutomat.state.status, + error: articleHistoryAutomat.state.error, }; + this.searchRef = createRef(); this.handleReload = this.handleReload.bind(this); + this.handleRetry = this.handleRetry.bind(this); + this.handlePageChange = this.handlePageChange.bind(this); } componentDidMount() { - this.unsub = articleHistoryGridAutomat.subscribe(this, (state) => ({ - loading: state.loading, + this.unsub = articleHistoryAutomat.subscribe(this, (state) => ({ + items: state.items, + unfilteredTotal: state.unfilteredTotal ?? 0, + filterKey: articleHistoryAutomat.getFilterKey(state), + status: state.status, error: state.error, - totalRows: state.totalRows, })); } @@ -109,47 +450,112 @@ export default class ArticleHistoryPage extends PureComponent { this.unsub?.(); } + 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); + + articleHistoryAutomat.requireChunk(startChunk, endChunk); + } + handleReload() { - articleHistoryGridAutomat.actions.reload(); + void articleHistoryAutomat.actions.reload().catch(() => {}); + } + + handleRetry() { + void articleHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {}); } render() { - const { loading, totalRows } = this.state; + const { items, unfilteredTotal, filterKey, status, error } = this.state; + const loading = status === 'loading'; - const actions = ( - - {loading && } - {totalRows >= 0 && ( - - {totalRows} Einträge - - )} - - - ); + if (items === null && status === 'failed') { + return ( + <> + + + + + + + + ); + } return ( - - - - - + <> + + + + + {renderInvisibleDummyRow(this.headerItem)} + ID + Datum + Artikel-Nr. + Artikelname + Änderung / Ereignis + Benutzer + + } + onPageChange={this.handlePageChange} + /> + {items === null && ( + + + + )} + + + ); } } diff --git a/src/routes/infos/GlobalHistoryPage.jsx b/src/routes/infos/GlobalHistoryPage.jsx index 2798eea..0ffcd3a 100644 --- a/src/routes/infos/GlobalHistoryPage.jsx +++ b/src/routes/infos/GlobalHistoryPage.jsx @@ -1,89 +1,460 @@ -import { PureComponent } from 'react'; +import { PureComponent, createRef } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; -import RefreshIcon from '@mui/icons-material/Refresh'; import CircularProgress from '@mui/material/CircularProgress'; -import { PanelTile } from 'dashgui/panels'; -import { AutomatGrid } from 'react-virtual-aggridautomat'; -import { globalHistoryGridAutomat } from '../../automats'; +import ListItemButton from '@mui/material/ListItemButton'; +import Typography from '@mui/material/Typography'; +import Skeleton from '@mui/material/Skeleton'; +import Tooltip from '@mui/material/Tooltip'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import Chip from '@mui/material/Chip'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; +import { PanelTile, PanelTileGrid } from 'dashgui/panels'; +import BackendError from '../../components/BackendError'; +import globalHistoryAutomat from '../../automats/globalHistoryAutomat'; +import { router } from '../../appRouter'; +import { backendErrorFrom } from '../../utils/backendError'; +import { LINK } from '../../colors'; -const COLUMN_DEFS = [ - { - headerName: 'ID', - field: 'id', - maxWidth: 90, - sortable: true, - filter: 'agNumberColumnFilter', - cellStyle: { fontVariantNumeric: 'tabular-nums' }, - }, - { - headerName: 'Datum', - field: 'date', - minWidth: 170, - maxWidth: 200, - sortable: true, - valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''), - cellStyle: { fontVariantNumeric: 'tabular-nums' }, - }, - { - headerName: 'Typ', - field: 'type', - minWidth: 120, - maxWidth: 160, - sortable: true, - filter: 'agTextColumnFilter', - }, - { - headerName: 'Referenz / Key', - field: 'reference', - minWidth: 140, - maxWidth: 200, - sortable: true, - filter: 'agTextColumnFilter', - valueGetter: (p) => p.data?.reference || p.data?.p1 || '', - }, - { - headerName: 'Meldung / Beschreibung', - field: 'text', - minWidth: 300, - flex: 2, - sortable: true, - filter: 'agTextColumnFilter', - valueGetter: (p) => p.data?.text || p.data?.value || '', - cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' }, - autoHeight: true, - }, - { - headerName: 'Benutzer', - field: 'user', - maxWidth: 160, - sortable: true, - filter: 'agTextColumnFilter', - valueGetter: (p) => - p.data?.user || - p.data?.employee || - (p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'), - }, -]; +const CHUNK_SIZE = 50; + +function formatDateTime(val) { + if (!val) return '–'; + try { + const d = new Date(val); + if (isNaN(d.getTime())) return String(val); + return d.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return String(val); + } +} + +export function createDummyTestGlobalHistory() { + return { + id: 999999, + date: '2030-10-30T12:00:00Z', + type: 'Bestellung', + reference: 'BEST-999999', + text: 'Status geändert auf abgeschlossen durch Benutzer', + user: 'Max Mustermann', + }; +} + +const rowGridSx = { + display: 'grid', + gridTemplateColumns: + 'minmax(60px, max-content) minmax(150px, max-content) minmax(110px, max-content) minmax(140px, max-content) minmax(240px, 2fr) minmax(120px, max-content)', + gridTemplateRows: '0px 1fr', + rowGap: 0, + columnGap: 2, + 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 || createDummyTestGlobalHistory(); + return ( + <> + + {d.id} + + + {formatDateTime(d.date)} + + + {d.type} + + + {d.reference} + + + {d.text} + + + {d.user} + + + ); +} + +class GlobalHistoryRow extends PureComponent { + render() { + const { item, disabled, headerItem } = this.props; + const dummy = headerItem || createDummyTestGlobalHistory(); + + if (!item) { + return ( + + {renderInvisibleDummyRow(dummy)} + + + + + + + + + + + + + + + + + + + + ); + } + + const userName = + item.user || + item.employee || + (item.companyuserId ? `User #${item.companyuserId}` : '—'); + + const typeStr = item.type || item.entityType || '–'; + const refStr = item.reference || item.p1 || '–'; + + const isArticle = (typeStr === 'article' || typeStr === 'artikel') && refStr && refStr !== '–'; + + return ( + { + void router.navigate(`/artikel/${encodeURIComponent(refStr)}`); + } + : undefined + } + > + {renderInvisibleDummyRow(dummy)} + + + + {item.id} + + + + + + {formatDateTime(item.date || item.createdAt)} + + + + + + + + + {isArticle ? ( + { + e.preventDefault(); + e.stopPropagation(); + void router.navigate(`/artikel/${encodeURIComponent(refStr)}`); + }} + sx={{ + color: LINK, + fontWeight: 600, + textDecoration: 'none', + ...ellipsisSx, + '&:hover': { textDecoration: 'underline' }, + }} + > + {refStr} + + ) : ( + + {refStr} + + )} + + + + + + {item.text || item.value || '–'} + + + + + + + {userName} + + + + ); + } +} + +class GlobalHistorySearchField extends PureComponent { + constructor(props) { + super(props); + const s = globalHistoryAutomat.state; + const stats = globalHistoryAutomat.getChunkStats?.(); + this.state = { + input: s.filter ?? '', + seekMonths: s.seekMonths ?? 0, + seekYears: s.seekYears ?? 0, + lruChunksCount: stats?.lruChunksCount || 0, + maxLruChunks: stats?.maxLruChunks || 50, + }; + } + + componentDidMount() { + this.unsub = globalHistoryAutomat.subscribe(this, (state) => ({ + input: state.filter ?? '', + seekMonths: state.seekMonths ?? 0, + seekYears: state.seekYears ?? 0, + })); + if (import.meta.env.DEV) { + this.unsubStats = globalHistoryAutomat.subscribeChunkStats?.((stats) => { + this.setState({ + lruChunksCount: stats.lruChunksCount, + maxLruChunks: stats.maxLruChunks, + }); + }); + } + } + + componentWillUnmount() { + this.unsub?.(); + this.unsubStats?.(); + } + + render() { + const { inputRef, onReload, loading } = this.props; + const { input, 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 */} + + globalHistoryAutomat.actions.filter(next)} + /> + + + + {/* Filter Bar: Negativeseek (-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' }, + }} + /> + )} + + + + ); + } +} export default class GlobalHistoryPage extends PureComponent { constructor(props) { super(props); - const s = globalHistoryGridAutomat.state; + this.headerItem = createDummyTestGlobalHistory(); this.state = { - loading: s.loading, - error: s.error, - totalRows: s.totalRows, + items: globalHistoryAutomat.getItems(), + unfilteredTotal: globalHistoryAutomat.state.unfilteredTotal ?? 0, + filterKey: globalHistoryAutomat.getFilterKey?.() ?? '', + status: globalHistoryAutomat.state.status, + error: globalHistoryAutomat.state.error, }; + this.searchRef = createRef(); this.handleReload = this.handleReload.bind(this); + this.handleRetry = this.handleRetry.bind(this); + this.handlePageChange = this.handlePageChange.bind(this); } componentDidMount() { - this.unsub = globalHistoryGridAutomat.subscribe(this, (state) => ({ - loading: state.loading, + this.unsub = globalHistoryAutomat.subscribe(this, (state) => ({ + items: state.items, + unfilteredTotal: state.unfilteredTotal ?? 0, + filterKey: globalHistoryAutomat.getFilterKey(state), + status: state.status, error: state.error, - totalRows: state.totalRows, })); } @@ -91,47 +462,112 @@ export default class GlobalHistoryPage extends PureComponent { this.unsub?.(); } + 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); + + globalHistoryAutomat.requireChunk(startChunk, endChunk); + } + handleReload() { - globalHistoryGridAutomat.actions.reload(); + void globalHistoryAutomat.actions.reload().catch(() => {}); + } + + handleRetry() { + void globalHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {}); } render() { - const { loading, totalRows } = this.state; + const { items, unfilteredTotal, filterKey, status, error } = this.state; + const loading = status === 'loading'; - const actions = ( - - {loading && } - {totalRows >= 0 && ( - - {totalRows} Einträge - - )} - - - ); + if (items === null && status === 'failed') { + return ( + <> + + + + + + + + ); + } return ( - - - - - + <> + + + + + {renderInvisibleDummyRow(this.headerItem)} + ID + Datum + Typ + Referenz / Key + Meldung / Beschreibung + Benutzer + + } + onPageChange={this.handlePageChange} + /> + {items === null && ( + + + + )} + + + ); } } diff --git a/src/routes/lieferung/neu.jsx b/src/routes/lieferung/neu.jsx index 0b75fd1..573a3b9 100644 --- a/src/routes/lieferung/neu.jsx +++ b/src/routes/lieferung/neu.jsx @@ -47,6 +47,7 @@ import { generateTextThumbnail } from '../../utils/textThumbnail'; import { extractPdfText } from '../../utils/pdfText'; import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat'; import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats'; +import chatAutomat from '../../automats/chatAutomat'; import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger'; import { @@ -101,6 +102,7 @@ class LieferungNeuPage extends PureComponent { this.handlePdfUpload = this.handlePdfUpload.bind(this); this.handlePdfRescan = this.handlePdfRescan.bind(this); this.handlePdfDelete = this.handlePdfDelete.bind(this); + this.handleMentionInChat = this.handleMentionInChat.bind(this); this.handleAttachmentClick = this.handleAttachmentClick.bind(this); this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this); this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this); @@ -657,17 +659,20 @@ class LieferungNeuPage extends PureComponent { const existingTxt = this.state.attachments?.find( (a) => a.parentPdfId === attachment.id || a.name === txtName, ); + let targetTxt = existingTxt; if (existingTxt) { - this.automat.actions.updateAttachment({ + const updated = { id: existingTxt.id, file: txtFile, size: txtFile.size, text, thumbnail: txtThumbnail || existingTxt.thumbnail, isDirty: true, - }); + }; + this.automat.actions.updateAttachment(updated); + targetTxt = { ...existingTxt, ...updated }; } else { - this.automat.actions.addAttachment({ + targetTxt = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: txtFile, name: txtFile.name, @@ -677,14 +682,52 @@ class LieferungNeuPage extends PureComponent { text, parentPdfId: attachment.id, uploadedAt: new Date().toISOString(), - }); + }; + this.automat.actions.addAttachment(targetTxt); } + this.handleAttachmentClick(targetTxt); + enqueueSnackbar('Text aus PDF extrahiert und geöffnet', { variant: 'success' }); } catch (txtErr) { console.error('Fehler beim Aktualisieren der Textdatei:', txtErr); + enqueueSnackbar('Fehler beim Extrahieren von Text aus der PDF', { variant: 'error' }); } } } + async handleMentionInChat(attachment) { + if (!attachment) return; + let text = attachment.text; + if (!text && (!attachment.file || attachment.file.size === 0)) { + const file = await this.ensureAttachmentFile(attachment); + if (file) { + try { + text = await file.text(); + } catch (e) { + console.error('Fehler beim Lesen des Texts:', e); + } + } + } else if (!text && (attachment.file instanceof Blob || attachment.file instanceof File)) { + try { + text = await attachment.file.text(); + } catch (e) { + console.error('Fehler beim Lesen des Texts:', e); + } + } + + const trimmed = (text || '').trim(); + if (!trimmed) { + enqueueSnackbar('Datei enthält keinen Text zum Erwähnen im Chat.', { variant: 'warning' }); + return; + } + + const size = attachment.size || new Blob([trimmed]).size; + chatAutomat.actions.insertExtractionSnippet({ + text: trimmed, + size, + }); + enqueueSnackbar('Text im Chat erwähnt', { variant: 'success' }); + } + handlePdfDelete(attachment) { if (!attachment?.id) return; this.automat.actions.removeAttachment(attachment.id); @@ -1058,6 +1101,7 @@ class LieferungNeuPage extends PureComponent { onPdfRescan={this.handlePdfRescan} onPdfDelete={this.handlePdfDelete} onAttachmentClick={this.handleAttachmentClick} + onMentionInChat={this.handleMentionInChat} onSave={this.handleSave} onPartyChange={this.handlePartyChange} onDateChange={this.handleDateChange}