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)}
+ />
+ }
+ onClick={onReload}
+ disabled={loading}
+ sx={{ height: 40, px: 2, whiteSpace: 'nowrap' }}
+ >
+ Aktualisieren
+
+
+
+ {/* 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
-
- )}
- }
- onClick={this.handleReload}
- disabled={loading}
- >
- Aktualisieren
-
-
- );
+ 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)}
+ />
+ }
+ onClick={onReload}
+ disabled={loading}
+ sx={{ height: 40, px: 2, whiteSpace: 'nowrap' }}
+ >
+ Aktualisieren
+
+
+
+ {/* 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
-
- )}
- }
- onClick={this.handleReload}
- disabled={loading}
- >
- Aktualisieren
-
-
- );
+ 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}