diff --git a/.gitignore b/.gitignore index aa8501e..6d39070 100755 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ .public-release-local-patterns data/config/config.json data/config/APPINFO.json -docker-compose_old.yml # Runtime and user data data/postgres/* diff --git a/README.md b/README.md index 370daba..590ce6f 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AssetManager 0.5.5.68 +# AssetManager 0.5.5.75 AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral. diff --git a/VERSION b/VERSION index 5e110a5..cdb6531 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.68 +0.5.5.75 diff --git a/app/i18n.py b/app/i18n.py index 0c01d3c..5bfbae0 100755 --- a/app/i18n.py +++ b/app/i18n.py @@ -59,8 +59,8 @@ BASE_TRANSLATIONS = { "profile.last_login": ("Last sign-in", "Letzte Anmeldung"), "profile.change_password": ("Change password", "Passwort ändern"), "profile.current_password": ("Current password", "Aktuelles Passwort"), "profile.new_password": ("New password", "Neues Passwort"), "profile.save_language": ("Save language", "Sprache speichern"), -"profile.browser_storage": ("Browser storage", "Browserspeicherung"), -"profile.browser_storage_help": ("Store table filters, column order and future interface preferences in this browser, even after it is closed.", "Tabellenfilter, Spaltenreihenfolgen und künftige Oberflächeneinstellungen in diesem Browser dauerhaft speichern."), +"profile.browser_storage": ("Table views", "Tabellenansichten"), +"profile.browser_storage_help": ("Table filters, sorting and column widths are automatically saved to your user account and are available across browsers after sign-in.", "Tabellenfilter, Sortierung und Spaltenbreiten werden automatisch in deinem Benutzerkonto gespeichert und stehen nach der Anmeldung browserübergreifend zur Verfügung."), "profile.browser_storage_enable": ("Enable persistent browser storage", "Dauerhafte Browserspeicherung aktivieren"), "profile.save_browser_storage": ("Save browser storage setting", "Browserspeicherung speichern"), "language.changed": ("Language changed", "Sprache geändert"), "settings.appearance": ("Appearance", "Darstellung"), "settings.application_title": ("Application title", "Anwendungstitel"), @@ -201,7 +201,22 @@ BASE_TRANSLATIONS = { "lists.row_number": ("Row number", "Zeilennummer"), "lists.row_number_resize": ("Drag to change the row-number column width", "Ziehen, um die Breite der Nummernspalte zu ändern"), "lists.column_resize": ("Drag to change the column width", "Ziehen, um die Spaltenbreite zu ändern"), -"lists.multi_value_filter_hint": ("Paste multiple search values one per line. Values within one column are OR-linked.", "Mehrere Suchwerte können zeilenweise eingefügt werden. Innerhalb einer Spalte gilt ODER."), +"lists.multi_value_filter_hint": ("Paste multiple search values one per line. Values within one column are OR-linked. Numeric and date values also support >x, =x, <=x and ranges such as 'from x to y'.", "Mehrere Suchwerte können zeilenweise eingefügt werden. Innerhalb einer Spalte gilt ODER. Zahlen- und Datumswerte unterstützen zusätzlich >x, =x, <=x sowie Bereiche wie ‚von x bis y‘."), +"lists.advanced_filter.title": ("Advanced filter", "Erweiterter Filter"), +"lists.advanced_filter.operator": ("Condition", "Bedingung"), +"lists.advanced_filter.value": ("Value", "Wert"), +"lists.advanced_filter.value_from": ("From", "Von"), +"lists.advanced_filter.value_to": ("To", "Bis"), +"lists.advanced_filter.equal": ("Equals", "Gleich"), +"lists.advanced_filter.gt": ("Greater than", "Größer als"), +"lists.advanced_filter.gte": ("Greater than or equal", "Größer oder gleich"), +"lists.advanced_filter.lt": ("Less than", "Kleiner als"), +"lists.advanced_filter.lte": ("Less than or equal", "Kleiner oder gleich"), +"lists.advanced_filter.range": ("Range", "Bereich"), +"lists.advanced_filter.apply": ("Apply", "Anwenden"), +"lists.advanced_filter.clear": ("Clear", "Leeren"), +"lists.advanced_filter.empty": ("Empty", "Leer"), +"lists.advanced_filter.not_empty": ("Not empty", "Nicht leer"), "appearance.colors_title": ("System colors", "Systemfarben"), "appearance.colors_help": ("Configure the central colors used throughout the application.", "Konfigurieren Sie die zentral im gesamten System verwendeten Farben."), "appearance.primary_color": ("Primary color", "Primärfarbe"), diff --git a/app/main.py b/app/main.py index 47a7ada..f40321a 100755 --- a/app/main.py +++ b/app/main.py @@ -5412,17 +5412,145 @@ def _translated_filter_codes( return matches -def _software_job_date_filter(column: Any, search_value: str): - """Support ISO/database text and the common German date formats.""" +def _parse_table_filter_number(value: Any) -> float | None: + """Parse a simple locale-friendly table-filter number.""" + text = str(value or "").strip().replace(" ", "").replace(",", ".") + if not re.fullmatch(r"[-+]?\d+(?:\.\d+)?", text): + return None + try: + return float(text) + except ValueError: + return None + + +def _table_filter_range_parts(value: Any) -> tuple[str, str] | None: + """Return the two operands of a user-entered inclusive range.""" + text = str(value or "").strip() + patterns = ( + r"^(?:von|from)\s+(.+?)\s+(?:bis|to)\s+(.+)$", + r"^(?:zwischen|between)\s+(.+?)\s+(?:und|and)\s+(.+)$", + r"^(.+?)\s*\.\.\s*(.+)$", + r"^(.+?)\s+-\s+(.+)$", + ) + for pattern in patterns: + match = re.fullmatch(pattern, text, flags=re.IGNORECASE) + if match: + return match.group(1).strip(), match.group(2).strip() + return None + + +def _software_job_numeric_filter(column: Any, search_value: str): + """Build a numeric comparison/range filter, or return None for normal text.""" value = str(search_value or "").strip() - conditions = [_sql_text_contains(column, value)] - for date_format in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): + if value.lower() == "@empty": + return column.is_(None) + if value.lower() == "@notempty": + return column.is_not(None) + match = re.fullmatch(r"(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)", value) + if match: + expected = _parse_table_filter_number(match.group(2)) + if expected is None: + return None + return { + "<": column < expected, + "<=": column <= expected, + ">": column > expected, + ">=": column >= expected, + "=": column == expected, + }[match.group(1)] + + range_values = _table_filter_range_parts(value) + if range_values: + first = _parse_table_filter_number(range_values[0]) + second = _parse_table_filter_number(range_values[1]) + if first is not None and second is not None: + minimum, maximum = sorted((first, second)) + return and_(column >= minimum, column <= maximum) + return None + + +def _parse_table_filter_date(value: Any) -> tuple[datetime, bool] | None: + """Parse supported local/ISO dates and tell whether no time was supplied.""" + text = str(value or "").strip() + formats = ( + ("%d.%m.%Y %H:%M:%S", False), + ("%d.%m.%Y %H:%M", False), + ("%d.%m.%y %H:%M:%S", False), + ("%d.%m.%y %H:%M", False), + ("%Y-%m-%d %H:%M:%S", False), + ("%Y-%m-%d %H:%M", False), + ("%Y-%m-%dT%H:%M:%S", False), + ("%Y-%m-%dT%H:%M", False), + ("%d.%m.%Y", True), + ("%d.%m.%y", True), + ("%Y-%m-%d", True), + ) + for date_format, date_only in formats: try: - parsed = datetime.strptime(value, date_format) + return datetime.strptime(text, date_format), date_only except ValueError: continue - conditions.append(and_(column >= parsed, column < parsed + timedelta(days=1))) - break + return None + + +def _software_job_date_comparison_filter(column: Any, search_value: str): + """Build a date comparison/range filter, or return None for normal text.""" + value = str(search_value or "").strip() + if value.lower() == "@empty": + return column.is_(None) + if value.lower() == "@notempty": + return column.is_not(None) + match = re.fullmatch(r"(<=|>=|<|>|=)\s*(.+)", value) + if match: + parsed = _parse_table_filter_date(match.group(2)) + if not parsed: + return None + expected, date_only = parsed + next_day = expected + timedelta(days=1) + operator = match.group(1) + if operator == "<": + return column < expected + if operator == "<=": + return column < next_day if date_only else column <= expected + if operator == ">": + return column >= next_day if date_only else column > expected + if operator == ">=": + return column >= expected + return and_(column >= expected, column < next_day) if date_only else column == expected + + range_values = _table_filter_range_parts(value) + if range_values: + first = _parse_table_filter_date(range_values[0]) + second = _parse_table_filter_date(range_values[1]) + if first and second: + (first_value, first_date_only), (second_value, second_date_only) = first, second + if first_value <= second_value: + start = first_value + end_value, end_date_only = second_value, second_date_only + else: + start = second_value + end_value, end_date_only = first_value, first_date_only + end = end_value + timedelta(days=1) if end_date_only else end_value + return and_(column >= start, column < end) if end_date_only else and_(column >= start, column <= end) + return None + + +def _software_job_date_filter(column: Any, search_value: str): + """Support comparisons/ranges plus the existing exact/text date search.""" + value = str(search_value or "").strip() + advanced = _software_job_date_comparison_filter(column, value) + if advanced is not None: + return advanced + + # Existing behavior intentionally remains unchanged for ordinary values. + conditions = [_sql_text_contains(column, value)] + parsed = _parse_table_filter_date(value) + if parsed: + date_value, date_only = parsed + if date_only: + conditions.append(and_(column >= date_value, column < date_value + timedelta(days=1))) + else: + conditions.append(column == date_value) return or_(*conditions) @@ -5457,7 +5585,10 @@ def _apply_software_job_filters(query: Any, request: Request, filters: dict[str, if terms: conditions = [] for value in terms: - if value.isdigit(): + advanced = _software_job_numeric_filter(SoftwareJob.id, value) + if advanced is not None: + conditions.append(advanced) + elif value.isdigit(): conditions.append(SoftwareJob.id == int(value)) else: conditions.append(_sql_text_contains(SoftwareJob.id, value)) @@ -5539,7 +5670,10 @@ def _apply_software_job_filters(query: Any, request: Request, filters: dict[str, if terms: conditions = [] for value in terms: - if re.fullmatch(r"[-+]?\d+", value): + advanced = _software_job_numeric_filter(SoftwareJob.priority, value) + if advanced is not None: + conditions.append(advanced) + elif re.fullmatch(r"[-+]?\d+", value): conditions.append(SoftwareJob.priority == int(value)) else: conditions.append(_sql_text_contains(SoftwareJob.priority, value)) @@ -7028,6 +7162,80 @@ def profile_language(request: Request, language_code: str = Form(...), next_url: )) return RedirectResponse(redirect_target, status_code=303) +@app.get("/api/user-ui-state") +def api_user_ui_state(request: Request, db: Session = Depends(get_db)): + session_user = _session_user(request) + if not session_user: + return {"authenticated": False, "state": {}} + user = db.query(User).filter(User.id == session_user.get("id")).first() + if not user: + return {"authenticated": False, "state": {}} + state = user.ui_state if isinstance(getattr(user, "ui_state", None), dict) else {} + return {"authenticated": True, "state": state} + + +@app.post("/api/user-ui-state") +def api_user_ui_state_save( + request: Request, + payload: dict = Body(default={}), + db: Session = Depends(get_db), +): + session_user = _session_user(request) + if not session_user: + raise HTTPException(401, "Anmeldung erforderlich") + + updates = payload.get("updates") or {} + removals = payload.get("remove") or [] + if not isinstance(updates, dict) or not isinstance(removals, list): + raise HTTPException(400, "Ungültiger UI-Zustand") + if len(updates) > 500 or len(removals) > 500: + raise HTTPException(400, "Zu viele UI-Zustände in einer Anfrage") + + try: + request_size = len(json.dumps(payload, ensure_ascii=False)) + except (TypeError, ValueError): + raise HTTPException(400, "Ungültiger UI-Zustand") + if request_size > 524288: + raise HTTPException(413, "UI-Zustand ist zu groß") + + normalized_updates: dict[str, Any] = {} + for key, value in updates.items(): + key = str(key or "").strip() + if not key or len(key) > 512: + raise HTTPException(400, "Ungültiger UI-Zustandsschlüssel") + normalized_updates[key] = value + normalized_removals = [] + for key in removals: + key = str(key or "").strip() + if key and len(key) <= 512: + normalized_removals.append(key) + + user = ( + db.query(User) + .filter(User.id == session_user.get("id")) + .with_for_update() + .first() + ) + if not user: + raise HTTPException(401, "Benutzerkonto wurde nicht gefunden") + + state = dict(user.ui_state or {}) if isinstance(user.ui_state, dict) else {} + for key in normalized_removals: + state.pop(key, None) + state.update(normalized_updates) + + try: + total_size = len(json.dumps(state, ensure_ascii=False)) + except (TypeError, ValueError): + raise HTTPException(400, "Ungültiger UI-Zustand") + if total_size > 1048576: + raise HTTPException(413, "Gespeicherter UI-Zustand ist zu groß") + + user.ui_state = state + db.commit() + return {"ok": True} + + @app.post("/profile/browser-state") def profile_browser_state( request: Request, diff --git a/app/migrations.py b/app/migrations.py index 6d20430..d6048c8 100755 --- a/app/migrations.py +++ b/app/migrations.py @@ -8,6 +8,7 @@ USER_COLUMNS = { "profile_location": "VARCHAR(150) NULL", "language_code": "VARCHAR(10) DEFAULT 'en'", "persist_browser_state": "BOOLEAN DEFAULT FALSE", + "ui_state": "JSON DEFAULT '{}'::json", "toast_position": "VARCHAR(30) DEFAULT 'top-right'", "toast_duration_seconds": "INTEGER DEFAULT 6", "theme_mode": "VARCHAR(20) DEFAULT 'light'", diff --git a/app/models.py b/app/models.py index 059df6e..09f8801 100755 --- a/app/models.py +++ b/app/models.py @@ -274,6 +274,7 @@ class User(Base): profile_location: Mapped[str | None] = mapped_column(String(150), nullable=True) language_code: Mapped[str] = mapped_column(String(10), default="en") persist_browser_state: Mapped[bool] = mapped_column(Boolean, default=False) + ui_state: Mapped[dict] = mapped_column(JSON, default=dict) toast_position: Mapped[str] = mapped_column(String(30), default="top-right") toast_duration_seconds: Mapped[int] = mapped_column(Integer, default=6) theme_mode: Mapped[str] = mapped_column(String(20), default="light") diff --git a/app/static/css/app.css b/app/static/css/app.css index 3942811..f5044b4 100755 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -3122,3 +3122,28 @@ html[data-theme="dark"] .column-filter-row textarea.filter-input-active{ color:#fff2b3; border-color:#d4a735; } + +/* Advanced numeric/date filter helper. The free-form filter remains the source of truth. */ +.column-filter-row th.advanced-filter-cell{position:relative;overflow:visible} +.advanced-filter-popup{position:absolute;z-index:50;top:calc(100% + 4px);left:4px;min-width:250px;padding:10px;border:1px solid var(--border-color,#c7d0d8);border-radius:6px;background:var(--surface-color,#fff);box-shadow:0 8px 24px rgba(0,0,0,.18);text-align:left} +.advanced-filter-popup[hidden]{display:none} +.advanced-filter-title{display:block;margin-bottom:8px} +.advanced-filter-popup label{display:block;margin:6px 0;font-size:12px;font-weight:600} +.advanced-filter-popup select,.advanced-filter-popup input{display:block;width:100%;min-width:0;margin-top:4px;padding:6px;font-size:13px} +.advanced-filter-values{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.advanced-filter-values label:only-child{grid-column:1/-1} +.advanced-filter-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:10px} +.advanced-filter-actions .button{padding:6px 10px;font-size:12px} +html[data-theme="dark"] .advanced-filter-popup{background:#202a34;color:#e8edf2;border-color:#53606d;box-shadow:0 8px 24px rgba(0,0,0,.45)} +html[data-theme="dark"] .advanced-filter-popup select,html[data-theme="dark"] .advanced-filter-popup input{background:#172029;color:#e8edf2;border-color:#596673} + +/* v0.5.5.71: advanced filter popup lives below body so table scrollers cannot clip it. */ +.advanced-filter-popup-portal{ + position:fixed; + z-index:10000; + top:0; + left:0; +} + +/* v0.5.5.72: hidden value fields in advanced filter builder must stay hidden despite label display rules. */ +.advanced-filter-popup label[hidden]{display:none!important} diff --git a/app/static/js/asset-columns.js b/app/static/js/asset-columns.js index e878837..050dbab 100755 --- a/app/static/js/asset-columns.js +++ b/app/static/js/asset-columns.js @@ -207,6 +207,7 @@ } window.addEventListener('resize', scheduleScrollbarUpdate, { passive: true }); + window.addEventListener('asset-table-columns-changed', scheduleScrollbarUpdate, { passive: true }); if ('ResizeObserver' in window && scroll) { const resizeObserver = new ResizeObserver(entries => { diff --git a/app/static/js/browser-state.js b/app/static/js/browser-state.js index cc35b09..bbf56c5 100755 --- a/app/static/js/browser-state.js +++ b/app/static/js/browser-state.js @@ -1,24 +1,165 @@ (() => { const perf = window.AssetPerf; perf?.start('browser-state:gesamtes Modul'); + const persistent = document.body?.dataset.persistBrowserState === 'true'; - const storage = persistent ? window.localStorage : window.sessionStorage; + const fallbackStorage = persistent ? window.localStorage : window.sessionStorage; + const alternateStorage = persistent ? window.sessionStorage : window.localStorage; const prefix = 'assetmanager:'; + let serverBacked = false; + let serverState = {}; + let pendingUpdates = {}; + let pendingRemovals = new Set(); + let saveTimer = null; + let saveChain = Promise.resolve(); + + function browserGet(storage, key) { + try { return storage.getItem(prefix + key); } catch (_) { return null; } + } + + function browserSet(storage, key, value) { + try { storage.setItem(prefix + key, value); } catch (_) {} + } + + function browserRemove(storage, key) { + try { storage.removeItem(prefix + key); } catch (_) {} + } + + function collectBrowserState() { + const result = {}; + const collect = storage => { + try { + for (let index = 0; index < storage.length; index += 1) { + const fullKey = storage.key(index); + if (!fullKey || !fullKey.startsWith(prefix)) continue; + const key = fullKey.slice(prefix.length); + if (!key || Object.prototype.hasOwnProperty.call(result, key)) continue; + const value = storage.getItem(fullKey); + if (value !== null) result[key] = value; + } + } catch (_) {} + }; + // Prefer the storage mode that was active for this user before the update. + collect(fallbackStorage); + collect(alternateStorage); + return result; + } + + async function postState(updates = {}, removals = []) { + const response = await fetch('/api/user-ui-state', { + method: 'POST', + credentials: 'same-origin', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({updates, remove: removals}), + keepalive: true, + }); + if (!response.ok) throw new Error(`UI state save failed (${response.status})`); + } + + function flushPending() { + if (!serverBacked) return Promise.resolve(); + if (saveTimer !== null) { + clearTimeout(saveTimer); + saveTimer = null; + } + const updates = pendingUpdates; + const removals = [...pendingRemovals]; + pendingUpdates = {}; + pendingRemovals = new Set(); + if (!Object.keys(updates).length && !removals.length) return saveChain; + + saveChain = saveChain + .catch(() => {}) + .then(() => postState(updates, removals)) + .catch(error => console.warn('[AssetManager] Benutzerbezogener Tabellenzustand konnte nicht gespeichert werden.', error)); + return saveChain; + } + + function scheduleSave() { + if (!serverBacked) return; + if (saveTimer !== null) clearTimeout(saveTimer); + saveTimer = window.setTimeout(flushPending, 350); + } + const api = { persistent, + serverBacked: false, + ready: Promise.resolve(), get(key) { - try { return storage.getItem(prefix + key); } catch (_) { return null; } + const normalized = String(key || ''); + if (!normalized) return null; + if (serverBacked) { + const value = serverState[normalized]; + return value === undefined || value === null ? null : String(value); + } + return browserGet(fallbackStorage, normalized); }, set(key, value) { - try { storage.setItem(prefix + key, value); } catch (_) {} + const normalized = String(key || ''); + if (!normalized) return; + const serialized = String(value); + if (serverBacked) { + serverState[normalized] = serialized; + pendingUpdates[normalized] = serialized; + pendingRemovals.delete(normalized); + scheduleSave(); + return; + } + browserSet(fallbackStorage, normalized, serialized); }, remove(key) { - try { storage.removeItem(prefix + key); } catch (_) {} - } + const normalized = String(key || ''); + if (!normalized) return; + if (serverBacked) { + delete serverState[normalized]; + delete pendingUpdates[normalized]; + pendingRemovals.add(normalized); + scheduleSave(); + return; + } + browserRemove(fallbackStorage, normalized); + }, + flush: flushPending, }; window.AssetBrowserState = api; + async function loadUserState() { + try { + const response = await fetch('/api/user-ui-state', {credentials: 'same-origin', cache: 'no-store'}); + if (!response.ok) return; + const payload = await response.json(); + if (!payload?.authenticated) return; + + serverBacked = true; + api.serverBacked = true; + serverState = payload.state && typeof payload.state === 'object' && !Array.isArray(payload.state) + ? {...payload.state} + : {}; + + // One-time/non-destructive migration: existing values from this browser are + // copied only when the account does not already have a value for the key. + const browserState = collectBrowserState(); + const migration = {}; + Object.entries(browserState).forEach(([key, value]) => { + if (!Object.prototype.hasOwnProperty.call(serverState, key)) { + serverState[key] = value; + migration[key] = value; + } + }); + if (Object.keys(migration).length) { + try { await postState(migration, []); } + catch (error) { console.warn('[AssetManager] Vorhandener Browserzustand konnte nicht in das Benutzerkonto übernommen werden.', error); } + } + } catch (error) { + // Browser storage remains the safe fallback if the account-state endpoint + // is temporarily unavailable. + console.warn('[AssetManager] Benutzerbezogener Tabellenzustand nicht verfügbar; Browser-Fallback wird verwendet.', error); + } + } + + api.ready = loadUserState(); + function tableKey(table, index) { return table.dataset.storageKey || table.id || `table-${location.pathname}-${index}`; } @@ -58,11 +199,6 @@ function restoreTable(table, index) { if (table.dataset.browserStateReady === '1') return; - if (table.dataset.serverFilters === '1') { - table.dataset.browserStateReady = '1'; - restoreSort(table, index); - return; - } const restoreStarted = performance.now(); const controls = filterControls(table); if (!controls.length) { @@ -76,10 +212,11 @@ let values = {}; try { values = JSON.parse(api.get(key) || '{}') || {}; } catch (_) {} + let serverValuesChanged = false; controls.forEach((control, controlIndex) => { const field = controlKey(control, controlIndex); if (Object.prototype.hasOwnProperty.call(values, field)) { - // Restore without dispatching one input/change pair per column. + if (control.value !== values[field]) serverValuesChanged = true; control.value = values[field]; } const save = () => { @@ -93,9 +230,9 @@ control.addEventListener('change', save); }); - // One filtering pass after every stored value is in place. - if (typeof table.assetApplyFilters === 'function') { - table.assetApplyFilters(); + if (typeof table.assetApplyFilters === 'function') table.assetApplyFilters(); + if (table.dataset.serverFilters === '1' && serverValuesChanged && typeof table.assetSubmitServerFilters === 'function') { + table.assetSubmitServerFilters(true); } restoreSort(table, index); table.dataset.browserStateRestored = '1'; @@ -109,7 +246,8 @@ }); } - function initializeTables() { + async function initializeTables() { + await api.ready; document.querySelectorAll('table').forEach(restoreTable); } @@ -119,21 +257,14 @@ initializeTables(); } - // Filter controls are added by table-tools.js. Initialize only on its explicit - // completion event instead of observing every DOM mutation in the document. window.addEventListener('asset-table-structure-ready', initializeTables); - // A programmatic reset does not emit input/change events. Remove the - // corresponding persisted filter state explicitly so it cannot return after - // a page refresh. window.addEventListener('asset-table-filters-cleared', event => { const table = event.detail?.table; if (!(table instanceof HTMLTableElement)) return; - const tables = [...document.querySelectorAll('table')]; const index = tables.indexOf(table); - const key = `${tableKey(table, index >= 0 ? index : 0)}:filters`; - api.remove(key); + api.remove(`${tableKey(table, index >= 0 ? index : 0)}:filters`); }); window.addEventListener('asset-table-sort-changed', event => { @@ -141,8 +272,7 @@ if (!(table instanceof HTMLTableElement)) return; const tables = [...document.querySelectorAll('table')]; const index = tables.indexOf(table); - const key = `${tableKey(table, index >= 0 ? index : 0)}:sort`; - api.set(key, JSON.stringify({ + api.set(`${tableKey(table, index >= 0 ? index : 0)}:sort`, JSON.stringify({ field: event.detail?.field || '', direction: event.detail?.direction || 'asc' })); @@ -156,5 +286,6 @@ restoreSort(table, index >= 0 ? index : 0, true); }); + window.addEventListener('pagehide', () => { flushPending(); }); perf?.end('browser-state:gesamtes Modul'); })(); diff --git a/app/static/js/table-tools.js b/app/static/js/table-tools.js index e591107..94bd7e2 100755 --- a/app/static/js/table-tools.js +++ b/app/static/js/table-tools.js @@ -1,4 +1,14 @@ (() => { + const uiState = window.AssetBrowserState; + const stateGet = key => { + if (uiState) return uiState.get(key); + try { return window.localStorage.getItem(`assetmanager:${key}`); } catch (_) { return null; } + }; + const stateSet = (key, value) => { + if (uiState) { uiState.set(key, value); return; } + try { window.localStorage.setItem(`assetmanager:${key}`, String(value)); } catch (_) {} + }; + function normalizeSearchText(value, options = {}) { const caseSensitive = Boolean(options.caseSensitive); const collapseWhitespace = Boolean(options.collapseWhitespace); @@ -179,14 +189,14 @@ const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]'); const rowNumberStoragePart = table.dataset.storageKey || table.id || window.location.pathname; - const rowNumberWidthKey = `assetmanager:table:${rowNumberStoragePart}:row-number-width`; + const rowNumberWidthKey = `table:${rowNumberStoragePart}:row-number-width`; const defaultRowNumberWidth = 64; const minimumRowNumberWidth = 48; const maximumRowNumberWidth = 180; function readStoredRowNumberWidth() { try { - const stored = Number.parseInt(window.localStorage.getItem(rowNumberWidthKey) || '', 10); + const stored = Number.parseInt(stateGet(rowNumberWidthKey) || '', 10); if (Number.isFinite(stored)) { return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored)); } @@ -213,7 +223,7 @@ const saveWidth = width => { const normalized = applyRowNumberWidth(width); - try { window.localStorage.setItem(rowNumberWidthKey, String(normalized)); } catch (_) {} + try { stateSet(rowNumberWidthKey, String(normalized)); } catch (_) {} }; handle.addEventListener('pointerdown', event => { @@ -254,9 +264,9 @@ // stored per table and per field/index so they survive navigation, // reloads and container updates. The visual row-number column keeps its // dedicated implementation above. - const columnWidthStoragePrefix = `assetmanager:table:${rowNumberStoragePart}:column-width:`; + const columnWidthStoragePrefix = `table:${rowNumberStoragePart}:column-width:`; const minimumColumnWidth = 56; - const maximumColumnWidth = 1200; + // No arbitrary upper limit: widening a column grows the whole table. function columnStorageId(header, columnIndex) { const field = header?.dataset?.field; @@ -265,7 +275,7 @@ } function normalizeColumnWidth(width) { - return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, Math.round(width))); + return Math.max(minimumColumnWidth, Math.round(width)); } function applyColumnWidth(columnIndex, width) { @@ -287,7 +297,7 @@ function readStoredColumnWidth(header, columnIndex) { try { const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`; - const stored = Number.parseInt(window.localStorage.getItem(key) || '', 10); + const stored = Number.parseInt(stateGet(key) || '', 10); if (Number.isFinite(stored)) return normalizeColumnWidth(stored); } catch (_) {} return null; @@ -295,19 +305,22 @@ function saveColumnWidth(header, columnIndex, width) { const normalized = applyColumnWidth(columnIndex, width); + if (header?.dataset?.field) header.dataset.columnWidth = String(normalized); try { const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`; - window.localStorage.setItem(key, String(normalized)); + stateSet(key, String(normalized)); } catch (_) {} return normalized; } function freezeTableGeometry() { - if (table.id === 'asset-table') return; - const widths = [...headerRow.cells].map(cell => Math.max(minimumColumnWidth, Math.round(cell.getBoundingClientRect().width || minimumColumnWidth))); + const widths = [...headerRow.cells].map(cell => { + const measured = Math.round(cell.getBoundingClientRect().width || 0); + return measured > 0 ? Math.max(minimumColumnWidth, measured) : 0; + }); const total = widths.reduce((sum, width) => sum + width, 0); widths.forEach((width, columnIndex) => { - if (headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return; + if (!width || headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return; applyColumnWidth(columnIndex, width); }); table.style.tableLayout = 'fixed'; @@ -315,15 +328,25 @@ table.style.minWidth = `${total}px`; } - function synchronizeTableWidth() { - if (table.id === 'asset-table') return; - const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); - if (total > 0) { - table.style.width = `${total}px`; - table.style.minWidth = `${total}px`; + function setTableWidth(total) { + const normalized = Math.max(1, Math.round(total || 0)); + table.style.width = `${normalized}px`; + table.style.minWidth = `${normalized}px`; + if (table.id === 'asset-table') { + table.dataset.calculatedWidth = String(normalized); + window.dispatchEvent(new Event('asset-table-columns-changed')); } } + function currentTableWidth() { + return [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); + } + + function synchronizeTableWidth() { + const total = currentTableWidth(); + if (total > 0) setTableWidth(total); + } + function applyStoredColumnWidths() { let restored = false; [...headerRow.cells].forEach((header, columnIndex) => { @@ -366,39 +389,25 @@ const startX = event.clientX; freezeTableGeometry(); - const headers = [...headerRow.cells]; - const rightmostIndex = headers.length - 1; + // A header can be moved after the resizer was installed. Always use + // its current DOM position instead of the index captured at startup. + const currentColumnIndex = header.cellIndex; const startWidth = header.getBoundingClientRect().width || minimumColumnWidth; - const rightmostHeader = headers[rightmostIndex]; - const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth; - const keepTableWidth = columnIndex !== rightmostIndex; + const startTableWidth = currentTableWidth(); document.documentElement.classList.add('table-column-resizing'); - const resizePair = delta => { - if (!keepTableWidth) { - applyColumnWidth(columnIndex, startWidth + delta); - synchronizeTableWidth(); - return; - } - - // All columns between the dragged column and the far-right column - // stay unchanged. The far-right column alone absorbs the delta. - const minDelta = minimumColumnWidth - startWidth; - const maxDelta = rightmostStartWidth - minimumColumnWidth; - const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, delta)); - applyColumnWidth(columnIndex, startWidth + effectiveDelta); - applyColumnWidth(rightmostIndex, rightmostStartWidth - effectiveDelta); + // Resize only the dragged column. The table width follows the actual + // column delta exactly; all other columns keep their current widths. + const resizeColumn = delta => { + const newWidth = applyColumnWidth(currentColumnIndex, startWidth + delta); + setTableWidth(startTableWidth + (newWidth - startWidth)); }; - const move = moveEvent => resizePair(moveEvent.clientX - startX); + const move = moveEvent => resizeColumn(moveEvent.clientX - startX); const finish = () => { - saveColumnWidth(header, columnIndex, header.getBoundingClientRect().width || startWidth); - if (keepTableWidth && rightmostHeader) { - saveColumnWidth(rightmostHeader, rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth); - } else { - synchronizeTableWidth(); - } + saveColumnWidth(header, currentColumnIndex, header.getBoundingClientRect().width || startWidth); + synchronizeTableWidth(); document.documentElement.classList.remove('table-column-resizing'); window.removeEventListener('pointermove', move, true); window.removeEventListener('pointerup', finish, true); @@ -414,22 +423,11 @@ event.preventDefault(); event.stopPropagation(); freezeTableGeometry(); - const headers = [...headerRow.cells]; - const rightmostIndex = headers.length - 1; + const currentColumnIndex = header.cellIndex; const step = event.key === 'ArrowRight' ? 12 : -12; const current = header.getBoundingClientRect().width || minimumColumnWidth; - if (columnIndex === rightmostIndex) { - saveColumnWidth(header, columnIndex, current + step); - synchronizeTableWidth(); - return; - } - const rightmostHeader = headers[rightmostIndex]; - const rightmostWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth; - const minDelta = minimumColumnWidth - current; - const maxDelta = rightmostWidth - minimumColumnWidth; - const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, step)); - saveColumnWidth(header, columnIndex, current + effectiveDelta); - if (rightmostHeader) saveColumnWidth(rightmostHeader, rightmostIndex, rightmostWidth - effectiveDelta); + saveColumnWidth(header, currentColumnIndex, current + step); + synchronizeTableWidth(); }); }); } @@ -592,17 +590,89 @@ }); } - function numericFilterMatches(rawValue, rawTerm) { - const value = Number(String(rawValue).trim().replace(',', '.')); - if (!Number.isFinite(value)) return false; + function comparableCellValue(cell) { + if (!cell) return ''; + if (cell.dataset.sortValue !== undefined && String(cell.dataset.sortValue).trim()) { + return String(cell.dataset.sortValue).trim(); + } + if (cell.dataset.filterValue !== undefined && String(cell.dataset.filterValue).trim()) { + return String(cell.dataset.filterValue).trim(); + } + return textValue(cell).trim(); + } - const term = String(rawTerm || '').trim().toLocaleLowerCase(); + function parseFilterNumber(rawValue) { + const text = String(rawValue ?? '').trim().replace(/\s/g, '').replace(',', '.'); + if (!/^[-+]?\d+(?:\.\d+)?$/.test(text)) return null; + const value = Number(text); + return Number.isFinite(value) ? value : null; + } + + function parseFilterDate(rawValue) { + const text = String(rawValue ?? '').trim(); + if (!text) return null; + + let match = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/); + if (match) { + if (match[4] && (text.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(text))) { + const timestamp = Date.parse(text.replace(' ', 'T')); + if (!Number.isNaN(timestamp)) return {timestamp, dateOnly: false}; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4] || 0); + const minute = Number(match[5] || 0); + const second = Number(match[6] || 0); + const value = new Date(year, month - 1, day, hour, minute, second); + if ( + value.getFullYear() === year && value.getMonth() === month - 1 && + value.getDate() === day && value.getHours() === hour && value.getMinutes() === minute + ) { + return {timestamp: value.getTime(), dateOnly: !match[4]}; + } + return null; + } + + match = text.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/); + if (match) { + let year = Number(match[3]); + if (year < 100) year += year >= 70 ? 1900 : 2000; + const month = Number(match[2]); + const day = Number(match[1]); + const hour = Number(match[4] || 0); + const minute = Number(match[5] || 0); + const second = Number(match[6] || 0); + const value = new Date(year, month - 1, day, hour, minute, second); + if ( + value.getFullYear() === year && value.getMonth() === month - 1 && + value.getDate() === day && value.getHours() === hour && value.getMinutes() === minute + ) { + return {timestamp: value.getTime(), dateOnly: !match[4]}; + } + } + return null; + } + + function rangeParts(term) { + let match = term.match(/^(?:von|from)\s+(.+?)\s+(?:bis|to)\s+(.+)$/i); + if (!match) match = term.match(/^(?:zwischen|between)\s+(.+?)\s+(?:und|and)\s+(.+)$/i); + if (!match) match = term.match(/^(.+?)\s*\.\.\s*(.+)$/); + if (!match) match = term.match(/^(.+?)\s+-\s+(.+)$/); + return match ? [match[1].trim(), match[2].trim()] : null; + } + + function numericFilterMatches(rawValue, rawTerm, allowPlainExact = true) { + const value = parseFilterNumber(rawValue); + if (value === null) return null; + + const term = String(rawTerm || '').trim(); if (!term) return true; - const normalize = input => input.replace(',', '.').trim(); let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/); if (match) { - const expected = Number(normalize(match[2])); + const expected = parseFilterNumber(match[2]); + if (expected === null) return null; if (match[1] === '<') return value < expected; if (match[1] === '<=') return value <= expected; if (match[1] === '>') return value > expected; @@ -610,22 +680,279 @@ return value === expected; } - match = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/); - if (!match) { - match = term.match(/^(?:zwischen|between)\s+(-?\d+(?:[.,]\d+)?)\s+(?:und|and)\s+(-?\d+(?:[.,]\d+)?)$/); - } - if (match) { - const first = Number(normalize(match[1])); - const second = Number(normalize(match[2])); - const minimum = Math.min(first, second); - const maximum = Math.max(first, second); - return value >= minimum && value <= maximum; + let compactRange = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/); + const range = compactRange ? [compactRange[1], compactRange[2]] : rangeParts(term); + if (range) { + const first = parseFilterNumber(range[0]); + const second = parseFilterNumber(range[1]); + if (first !== null && second !== null) { + const minimum = Math.min(first, second); + const maximum = Math.max(first, second); + return value >= minimum && value <= maximum; + } } - const exact = Number(normalize(term)); - return Number.isFinite(exact) ? value === exact : false; + if (!allowPlainExact) return null; + const exact = parseFilterNumber(term); + return exact === null ? null : value === exact; } + function dateFilterMatches(rawValue, rawTerm) { + const value = parseFilterDate(rawValue); + if (!value) return null; + + const term = String(rawTerm || '').trim(); + if (!term) return true; + + const nextDay = parsed => parsed.timestamp + 24 * 60 * 60 * 1000; + let match = term.match(/^(<=|>=|<|>|=)\s*(.+)$/); + if (match) { + const expected = parseFilterDate(match[2]); + if (!expected) return null; + if (match[1] === '<') return value.timestamp < expected.timestamp; + if (match[1] === '<=') return expected.dateOnly ? value.timestamp < nextDay(expected) : value.timestamp <= expected.timestamp; + if (match[1] === '>') return expected.dateOnly ? value.timestamp >= nextDay(expected) : value.timestamp > expected.timestamp; + if (match[1] === '>=') return value.timestamp >= expected.timestamp; + if (expected.dateOnly) return value.timestamp >= expected.timestamp && value.timestamp < nextDay(expected); + return value.timestamp === expected.timestamp; + } + + const range = rangeParts(term); + if (range) { + const first = parseFilterDate(range[0]); + const second = parseFilterDate(range[1]); + if (first && second) { + const start = Math.min(first.timestamp, second.timestamp); + const endSource = first.timestamp <= second.timestamp ? second : first; + const end = endSource.dateOnly ? nextDay(endSource) : endSource.timestamp; + return value.timestamp >= start && (endSource.dateOnly ? value.timestamp < end : value.timestamp <= end); + } + } + + return null; + } + + function advancedFilterMatches(cell, displayValue, term, definition) { + const comparable = comparableCellValue(cell); + const neutralTerm = String(term || '').trim().toLowerCase(); + if (neutralTerm === '@empty') return !String(comparable || '').trim(); + if (neutralTerm === '@notempty') return !!String(comparable || '').trim(); + + // Preserve the pre-existing software-summary numeric behavior exactly: + // plain numeric input is an exact match in columns explicitly marked as numeric. + if (definition.numericFilter) { + const numeric = numericFilterMatches(comparable, term, true); + return numeric === null ? searchTextMatches(displayValue, term) : numeric; + } + + // Everywhere else advanced comparisons are opt-in by syntax. A normal + // filter such as "5" or "2026" therefore keeps the existing substring behavior. + const numeric = numericFilterMatches(comparable, term, false); + if (numeric !== null) return numeric; + + const date = dateFilterMatches(comparable, term); + if (date !== null) return date; + + return searchTextMatches(displayValue, term); + } + + + function inferredAdvancedFilterType(header, columnIndex, serverName) { + const explicit = String(header.dataset.filterType || '').toLowerCase(); + if (['integer', 'decimal', 'number'].includes(explicit)) return 'number'; + if (['date', 'datetime'].includes(explicit)) return 'date'; + if (header.dataset.numericFilter === '1') return 'number'; + if (['job_id', 'priority'].includes(serverName)) return 'number'; + if (serverName === 'created_at') return 'date'; + + // Generic fallback for tables that do not declare a data type. Only + // classify a column when every sampled non-empty value is parseable. + const samples = [...tbody.rows] + .map(row => comparableCellValue(row.cells[columnIndex])) + .filter(value => String(value || '').trim()) + .slice(0, 12); + if (!samples.length) return ''; + if (samples.every(value => parseFilterDate(value))) return 'date'; + if (samples.every(value => parseFilterNumber(value) !== null)) return 'number'; + return ''; + } + + function createAdvancedFilterHelper(filterCell, input, kind) { + if (!kind) return; + + filterCell.classList.add('advanced-filter-cell'); + input.classList.add('advanced-filter-input'); + + // Keep the popup outside the table. Asset tables use sticky headers and + // overflow scrollers, which clip absolutely positioned descendants of TH. + // A body-level fixed popup remains visible in every table layout. + const popup = document.createElement('div'); + popup.className = 'advanced-filter-popup advanced-filter-popup-portal'; + popup.hidden = true; + document.body.appendChild(popup); + + const title = document.createElement('strong'); + title.className = 'advanced-filter-title'; + title.textContent = document.body.dataset.advancedFilterTitle || 'Advanced filter'; + popup.appendChild(title); + + const operatorLabel = document.createElement('label'); + operatorLabel.textContent = document.body.dataset.advancedFilterOperator || 'Condition'; + const operator = document.createElement('select'); + const options = [ + ['=', document.body.dataset.advancedFilterEqual || 'Equals'], + ['>', document.body.dataset.advancedFilterGt || 'Greater than'], + ['>=', document.body.dataset.advancedFilterGte || 'Greater than or equal'], + ['<', document.body.dataset.advancedFilterLt || 'Less than'], + ['<=', document.body.dataset.advancedFilterLte || 'Less than or equal'], + ['range', document.body.dataset.advancedFilterRange || 'Range'], + ['@empty', document.body.dataset.advancedFilterEmpty || 'Empty'], + ['@notempty', document.body.dataset.advancedFilterNotEmpty || 'Not empty'] + ]; + options.forEach(([value, label]) => { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + operator.appendChild(option); + }); + operatorLabel.appendChild(operator); + popup.appendChild(operatorLabel); + + const valueWrap = document.createElement('div'); + valueWrap.className = 'advanced-filter-values'; + const makeValueInput = (labelText, className) => { + const label = document.createElement('label'); + label.textContent = labelText; + const valueInput = document.createElement('input'); + valueInput.className = className; + valueInput.type = kind === 'date' ? 'date' : 'number'; + if (kind === 'number') valueInput.step = 'any'; + label.appendChild(valueInput); + valueWrap.appendChild(label); + return {label, input: valueInput}; + }; + const first = makeValueInput(document.body.dataset.advancedFilterValue || 'Value', 'advanced-filter-value-first'); + const second = makeValueInput(document.body.dataset.advancedFilterValueTo || 'To', 'advanced-filter-value-second'); + second.label.hidden = true; + popup.appendChild(valueWrap); + + const actions = document.createElement('div'); + actions.className = 'advanced-filter-actions'; + const apply = document.createElement('button'); + apply.type = 'button'; + apply.className = 'button button-primary'; + apply.textContent = document.body.dataset.advancedFilterApply || 'Apply'; + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'button button-secondary'; + clear.textContent = document.body.dataset.advancedFilterClear || 'Clear'; + actions.append(apply, clear); + popup.appendChild(actions); + + const syncOperator = () => { + const range = operator.value === 'range'; + const needsValue = !['@empty', '@notempty'].includes(operator.value); + first.label.hidden = !needsValue; + first.label.firstChild.textContent = range + ? (document.body.dataset.advancedFilterValueFrom || 'From') + : (document.body.dataset.advancedFilterValue || 'Value'); + second.label.hidden = !range || !needsValue; + }; + operator.addEventListener('change', syncOperator); + syncOperator(); + + const positionPopup = () => { + if (popup.hidden) return; + const rect = input.getBoundingClientRect(); + const margin = 6; + const minWidth = Math.max(250, Math.round(rect.width)); + popup.style.minWidth = `${minWidth}px`; + popup.style.maxWidth = `min(360px, calc(100vw - ${margin * 2}px))`; + + // Measure after making the popup visible, then choose below/above. + const popupRect = popup.getBoundingClientRect(); + let left = rect.left; + if (left + popupRect.width > window.innerWidth - margin) { + left = Math.max(margin, window.innerWidth - popupRect.width - margin); + } + left = Math.max(margin, left); + + let top = rect.bottom + 4; + if (top + popupRect.height > window.innerHeight - margin && rect.top > popupRect.height + margin) { + top = rect.top - popupRect.height - 4; + } + popup.style.left = `${Math.round(left)}px`; + popup.style.top = `${Math.round(Math.max(margin, top))}px`; + }; + + const openPopup = () => { + popup.hidden = false; + popup.dataset.open = '1'; + positionPopup(); + }; + const closePopup = () => { + popup.hidden = true; + delete popup.dataset.open; + }; + + // Focus/click both open the helper. Click is kept explicitly because a + // textarea can already be focused when the user clicks it again. + input.addEventListener('focus', openPopup); + input.addEventListener('click', openPopup); + popup.addEventListener('click', event => event.stopPropagation()); + popup.addEventListener('pointerdown', event => event.stopPropagation()); + document.addEventListener('click', event => { + if (event.target === input || popup.contains(event.target)) return; + closePopup(); + }); + window.addEventListener('resize', positionPopup); + // Reposition while any surrounding table/page scroller moves. + window.addEventListener('scroll', positionPopup, true); + + const triggerFilter = () => { + input.dispatchEvent(new Event('input', {bubbles: true})); + }; + + apply.addEventListener('click', () => { + if (['@empty', '@notempty'].includes(operator.value)) { + input.value = operator.value; + triggerFilter(); + closePopup(); + return; + } + const value1 = first.input.value.trim(); + if (!value1) return; + if (operator.value === 'range') { + const value2 = second.input.value.trim(); + if (!value2) return; + input.value = `${value1}..${value2}`; + } else { + input.value = `${operator.value}${value1}`; + } + triggerFilter(); + closePopup(); + }); + + clear.addEventListener('click', () => { + first.input.value = ''; + second.input.value = ''; + input.value = ''; + triggerFilter(); + closePopup(); + }); + + [first.input, second.input].forEach(valueInput => { + valueInput.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + apply.click(); + } else if (event.key === 'Escape') { + closePopup(); + input.focus(); + } + }); + }); + } function submitServerFilters(immediate = false) { if (!usesServerFilters) return; @@ -743,12 +1070,9 @@ const hidden = active.some(({ definition, terms }) => { const cell = cellForDefinition(row, definition); const value = textValue(cell); - const matchesAnyTerm = terms.some(term => { - if (definition.numericFilter) { - return numericFilterMatches(value, term); - } - return searchTextMatches(value, term); - }); + const matchesAnyTerm = terms.some(term => + advancedFilterMatches(cell, value, term, definition) + ); return !matchesAnyTerm; }); @@ -849,12 +1173,15 @@ } filterCell.appendChild(input); + const advancedFilterType = inferredAdvancedFilterType(header, columnIndex, serverName); + createAdvancedFilterHelper(filterCell, input, advancedFilterType); filterDefinitions.push({ field, columnIndex, input, serverName, - numericFilter: header.dataset.numericFilter === '1' + numericFilter: header.dataset.numericFilter === '1', + advancedFilterType }); } @@ -878,6 +1205,7 @@ }); table.assetApplyFilters = applyFilters; + table.assetSubmitServerFilters = submitServerFilters; table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options); table.assetUpdateRowNumbers = updateRowNumbers; table.assetRebuildFilterCache = rebuildMissingCellKeys; @@ -916,10 +1244,9 @@ const headerRow = thead.rows[0]; const storagePart = table.dataset.storageKey || table.id || `${window.location.pathname}:table-${[...document.querySelectorAll('main table')].indexOf(table)}`; - const storagePrefix = `assetmanager:table:${storagePart}:column-width:`; + const storagePrefix = `table:${storagePart}:column-width:`; const minWidth = 56; - const maxWidth = 1200; - const normalize = width => Math.min(maxWidth, Math.max(minWidth, Math.round(width))); + const normalize = width => Math.max(minWidth, Math.round(width)); const columnKey = (header, index) => header.dataset.field || `index-${index}`; const applyWidth = (index, width) => { @@ -935,27 +1262,33 @@ }; const freezeGeometry = () => { - const widths = [...headerRow.cells].map(cell => normalize(cell.getBoundingClientRect().width || minWidth)); + const widths = [...headerRow.cells].map(cell => { + const measured = Math.round(cell.getBoundingClientRect().width || 0); + return measured > 0 ? normalize(measured) : 0; + }); const total = widths.reduce((sum, width) => sum + width, 0); - widths.forEach((width, index) => applyWidth(index, width)); + widths.forEach((width, index) => { if (width) applyWidth(index, width); }); table.style.tableLayout = 'fixed'; table.style.width = `${total}px`; table.style.minWidth = `${total}px`; }; + const setTableWidth = total => { + const normalized = Math.max(1, Math.round(total || 0)); + table.style.width = `${normalized}px`; + table.style.minWidth = `${normalized}px`; + }; + const currentTableWidth = () => [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); const syncTableWidth = () => { - const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); - if (total > 0) { - table.style.width = `${total}px`; - table.style.minWidth = `${total}px`; - } + const total = currentTableWidth(); + if (total > 0) setTableWidth(total); }; [...headerRow.cells].forEach((header, index) => { if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return; const key = `${storagePrefix}${columnKey(header, index)}`; try { - const stored = Number.parseInt(localStorage.getItem(key) || '', 10); + const stored = Number.parseInt(stateGet(key) || '', 10); if (Number.isFinite(stored)) applyWidth(index, stored); } catch (_) {} @@ -971,44 +1304,28 @@ const save = width => { const normalized = applyWidth(index, width); - try { localStorage.setItem(key, String(normalized)); } catch (_) {} + try { stateSet(key, String(normalized)); } catch (_) {} }; handle.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); }); handle.addEventListener('pointerdown', event => { event.preventDefault(); event.stopPropagation(); const startX = event.clientX; freezeGeometry(); - const headers = [...headerRow.cells]; - const rightmostIndex = headers.length - 1; + const currentIndex = header.cellIndex; const startWidth = header.getBoundingClientRect().width || minWidth; - const rightmostHeader = headers[rightmostIndex]; - const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minWidth; - const keepTableWidth = index !== rightmostIndex; + const startTableWidth = currentTableWidth(); document.documentElement.classList.add('table-column-resizing'); - const resizePair = delta => { - if (!keepTableWidth) { - applyWidth(index, startWidth + delta); - syncTableWidth(); - return; - } - const minDelta = minWidth - startWidth; - const maxDelta = rightmostStartWidth - minWidth; - const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, delta)); - applyWidth(index, startWidth + effectiveDelta); - applyWidth(rightmostIndex, rightmostStartWidth - effectiveDelta); + const resizeColumn = delta => { + const newWidth = applyWidth(currentIndex, startWidth + delta); + setTableWidth(startTableWidth + (newWidth - startWidth)); }; - const move = e => resizePair(e.clientX - startX); + const move = e => resizeColumn(e.clientX - startX); const finish = () => { - save(header.getBoundingClientRect().width || startWidth); - if (keepTableWidth && rightmostHeader) { - const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`; - const rightWidth = applyWidth(rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth); - try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {} - } else { - syncTableWidth(); - } + const normalized = applyWidth(currentIndex, header.getBoundingClientRect().width || startWidth); + try { stateSet(`${storagePrefix}${columnKey(header, currentIndex)}`, String(normalized)); } catch (_) {} + syncTableWidth(); document.documentElement.classList.remove('table-column-resizing'); window.removeEventListener('pointermove', move, true); window.removeEventListener('pointerup', finish, true); @@ -1022,33 +1339,23 @@ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); event.stopPropagation(); freezeGeometry(); - const headers = [...headerRow.cells]; - const rightmostIndex = headers.length - 1; + const currentIndex = header.cellIndex; const step = event.key === 'ArrowRight' ? 12 : -12; const current = header.getBoundingClientRect().width || minWidth; - if (index === rightmostIndex) { - save(current + step); - syncTableWidth(); - return; - } - const rightmostHeader = headers[rightmostIndex]; - const rightmostWidth = rightmostHeader?.getBoundingClientRect().width || minWidth; - const minDelta = minWidth - current; - const maxDelta = rightmostWidth - minWidth; - const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, step)); - save(current + effectiveDelta); - if (rightmostHeader) { - const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`; - const rightWidth = applyWidth(rightmostIndex, rightmostWidth - effectiveDelta); - try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {} - } + const normalized = applyWidth(currentIndex, current + step); + try { stateSet(`${storagePrefix}${columnKey(header, currentIndex)}`, String(normalized)); } catch (_) {} + syncTableWidth(); }); }); table.dataset.columnResizeReady = '1'; } - document.querySelectorAll('table.data-table').forEach(initTable); - document.querySelectorAll('main table:not(#asset-table):not([data-column-resize="off"])').forEach(initUniversalTableResize); + const initializeTableTools = () => { + document.querySelectorAll('table.data-table').forEach(initTable); + document.querySelectorAll('main table:not(#asset-table):not([data-column-resize="off"])').forEach(initUniversalTableResize); + }; + if (uiState?.ready) uiState.ready.finally(initializeTableTools); + else initializeTableTools(); window.addEventListener('asset-table-content-reloaded', event => { const table = event.detail?.table; diff --git a/app/templates/assets.html b/app/templates/assets.html index 8b90574..1ce023f 100755 --- a/app/templates/assets.html +++ b/app/templates/assets.html @@ -136,12 +136,12 @@ {% set has_name = fields|selectattr('field_name', 'equalto', 'name')|list|length > 0 %} {% if not has_name %}{{ t('field.name', 'Bezeichnung') }}{% endif %} {% if not selected %}{{ t('common.category', 'Kategorie') }}{% endif %} - {% for f in fields %}{% set list_width = f.definition.list_width if f.definition and f.definition.list_width else (220 if f.field_name == 'name' else 180) %}{{ f.label }}{% endfor %} + {% for f in fields %}{% set list_width = f.definition.list_width if f.definition and f.definition.list_width else (220 if f.field_name == 'name' else 180) %}{{ f.label }}{% endfor %} {% if job_filter %} {{ t('assets.job_filter.status', 'Jobstatus') }} - {{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }} - {{ t('assets.job_filter.last_success', 'Letzter Erfolg') }} - {{ t('assets.job_filter.revision', 'Revision') }} + {{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }} + {{ t('assets.job_filter.last_success', 'Letzter Erfolg') }} + {{ t('assets.job_filter.revision', 'Revision') }} {% endif %} {{ t('presence.title') }} {{ t('common.actions', 'Aktionen') }} @@ -371,6 +371,23 @@ }); headerRow.insertBefore(headerFragment, headerActions); + // Keep the generated filter row in exactly the same field order. It is + // created by table-tools.js after the initial page setup, so this matters + // especially when the user reorders a column interactively afterwards. + const filterRow = table.tHead.querySelector('tr[data-table-filter-row="1"]'); + if (filterRow) { + const filterActions = filterRow.lastElementChild; + const filterFragment = document.createDocumentFragment(); + valid.forEach(field => { + const cell = filterRow.querySelector(`th[data-field="${CSS.escape(field)}"]`); + if (cell) filterFragment.appendChild(cell); + }); + if (filterActions) filterRow.insertBefore(filterFragment, filterActions); + [...filterRow.cells].forEach((cell, index) => { + cell.dataset.columnIndex = String(index); + }); + } + [...table.tBodies[0].rows].forEach(row => { const actions = row.lastElementChild; const fragment = document.createDocumentFragment(); @@ -397,6 +414,10 @@ order.splice(order.indexOf(dragged), 1); order.splice(order.indexOf(target), 0, dragged); applyOrder(order); + // Reapply widths by field after the DOM order changed. Resizer handlers + // use the header's current cellIndex, so the visible separator and the + // resized data cells stay aligned after drag & drop. + table.assetApplyStoredColumnWidths?.(); window.AssetBrowserState?.set(key, JSON.stringify(order)); window.dispatchEvent(new Event('asset-table-columns-changed')); }); diff --git a/app/templates/base.html b/app/templates/base.html index b67f931..c82dd82 100755 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,7 +14,7 @@ --am-warning:{{ colors.get('warning_color', '#d97706') }}; --am-danger:{{ colors.get('danger_color', '#b42318') }}; } -{{ title or general.get('title', 'AssetManager') }}{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}{% endif %} +{{ title or general.get('title', 'AssetManager') }}{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}{% endif %}
{% if general.get('logo') %}Logo{% endif %}{{ general.get('title', 'AssetManager') }}{% if general.get('company_name') %}– {{ general.get('company_name') }}{% endif %} diff --git a/app/templates/profile.html b/app/templates/profile.html index a4e9c4d..5a3064b 100755 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -58,7 +58,7 @@ -

{{ t('profile.browser_storage') }}

{{ t('profile.browser_storage_help') }}

+

{{ t('profile.browser_storage') }}

{{ t('profile.browser_storage_help') }}

{{ t('profile.toast_settings') }}

diff --git a/app/templates/software.html b/app/templates/software.html index 98f955e..d119fef 100755 --- a/app/templates/software.html +++ b/app/templates/software.html @@ -87,13 +87,13 @@ > - {{ t('software.job_id') }} - {{ t('software.time') }} + {{ t('software.job_id') }} + {{ t('software.time') }} {{ t('common.assets') }} {{ t('jobs.type') }} {{ t('software.package') }} {{ t('jobs.action') }} - {{ t('jobs.priority') }} + {{ t('jobs.priority') }} {{ t('software.platform') }} {{ t('software.status') }} {{ t('software.details') }} diff --git a/app/version.py b/app/version.py index dccd9c1..6f26d74 100755 --- a/app/version.py +++ b/app/version.py @@ -1,2 +1,2 @@ -APP_VERSION = "0.5.5.68" +APP_VERSION = "0.5.5.75" __version__ = APP_VERSION diff --git a/docs/version-history/README.md b/docs/version-history/README.md index ba73b39..878121d 100755 --- a/docs/version-history/README.md +++ b/docs/version-history/README.md @@ -1,3 +1,9 @@ +- [0.5.5.75](UPDATE-0.5.5.75.md) - resize only the dragged table column while the total table width grows or shrinks by the same amount. +- [0.5.5.74](UPDATE-0.5.5.74.md) - keep column resize handles, filter cells and stored widths aligned after reordering Asset list columns. +- [0.5.5.73](UPDATE-0.5.5.73.md) - save table filters, sorting and column widths in the signed-in user account instead of only in browser storage. +- [0.5.5.72](UPDATE-0.5.5.72.md) - hide the second advanced-filter value field unless a range filter is selected. +- [0.5.5.71](UPDATE-0.5.5.71.md) - fixed advanced filter popup visibility in sticky/scrolling tables. +- [0.5.5.70](UPDATE-0.5.5.70.md) - translated advanced numeric/date filter helper. - [0.5.5.64](UPDATE-0.5.5.64.md) - [0.5.5.35](UPDATE-0.5.5.35.md) - [0.5.5.34](UPDATE-0.5.5.34.md) diff --git a/docs/version-history/UPDATE-0.5.5.69.md b/docs/version-history/UPDATE-0.5.5.69.md new file mode 100755 index 0000000..f3853ff --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.69.md @@ -0,0 +1,21 @@ +# AssetManager 0.5.5.69 + +## Changes + +- Extended the shared table-filter logic with numeric and date comparisons. + - Supported operators: `<`, `<=`, `>`, `>=` and `=`. + - Inclusive ranges can be entered as `from x to y`, `between x and y` or `x..y`; German input such as `von x bis y` and `zwischen x und y` is supported as well. + - Existing numeric range syntax in explicitly numeric software-summary columns, including `1-5` and `1..5`, remains available. + - Date-only upper bounds such as `<=12.08.2026` include the complete selected day. +- Applied the comparison syntax through the existing shared `data-table` filtering component, so normal client-side tables receive the feature without replacing their current text-filter behavior. +- Extended the server-side recent-job filters with the same comparison behavior for job ID, priority and creation date. +- Preserved existing filtering behavior. + - Normal single-value substring searches remain unchanged outside the previously explicit numeric software-summary columns. + - Line-based multi-value filters remain OR-linked within a column. + - Filters across different columns remain AND-linked. + - Existing sorting, persisted filter state and table resizing remain unchanged. +- Updated German and English filter help text. + +## Compatibility + +No database migration is required. Existing table filters and server-side job filtering remain compatible with previously entered filter values. diff --git a/docs/version-history/UPDATE-0.5.5.70.md b/docs/version-history/UPDATE-0.5.5.70.md new file mode 100755 index 0000000..cfef7d7 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.70.md @@ -0,0 +1,18 @@ +# AssetManager 0.5.5.70 + +## Changes + +- Added a translated advanced-filter helper for numeric and date table columns. + - The helper opens when the existing filter field receives focus. + - Operators are shown in the active UI language: equals, greater than, greater than or equal, less than, less than or equal, range, empty and not empty. + - Range mode provides separate start/end inputs. + - Date values use the browser date control; numeric values use a numeric input. +- The helper writes language-neutral filter expressions such as `>=10`, `10..20`, `=2026-08-12`, `@empty` and `@notempty` into the existing filter field. +- Existing free-form filters remain fully available and unchanged, including line-based multi-value filtering and the comparison/range syntax introduced in 0.5.5.69. +- Explicit Asset field data types are used where available; generic tables can additionally detect unambiguous numeric/date columns from their displayed values. +- Added the same helper to the typed server-side recent-job filters (job ID, priority and creation date), including server-side handling of empty/not-empty. +- Added German and English translations and dark-mode styling for the helper. + +## Compatibility + +No database migration is required. The feature is additive and does not replace the existing table filtering, sorting, persisted filter state or multi-value filtering logic. diff --git a/docs/version-history/UPDATE-0.5.5.71.md b/docs/version-history/UPDATE-0.5.5.71.md new file mode 100755 index 0000000..41652d9 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.71.md @@ -0,0 +1,7 @@ +# AssetManager 0.5.5.71 + +## Fixed +- Advanced numeric/date filter helper is now rendered as a body-level popup instead of inside the table header cell. +- This prevents sticky/scrolling Asset list headers from clipping the filter builder. +- Clicking or focusing an eligible numeric/date filter now opens the translated filter builder reliably. +- Existing free-form filtering, multi-value filtering, sorting, column state and advanced filter evaluation remain unchanged. diff --git a/docs/version-history/UPDATE-0.5.5.72.md b/docs/version-history/UPDATE-0.5.5.72.md new file mode 100755 index 0000000..36bd4b0 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.72.md @@ -0,0 +1,6 @@ +# AssetManager 0.5.5.72 + +## Fixed +- Advanced table filter builder now hides the second value field for single-value operators such as equals, greater than, greater than or equal, less than, and less than or equal. +- The second value field remains visible only for range filters. +- Existing filter parsing and table behavior remain unchanged. diff --git a/docs/version-history/UPDATE-0.5.5.73.md b/docs/version-history/UPDATE-0.5.5.73.md new file mode 100755 index 0000000..8ed0282 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.73.md @@ -0,0 +1,12 @@ +# AssetManager 0.5.5.73 + +## Changed +- Table filters, sorting, column widths, and row-number column widths are stored per signed-in user in the database. +- Existing browser-stored table state is migrated non-destructively into the user account when no account value exists yet. +- Browser storage remains the fallback when no user is signed in or the account-state endpoint is unavailable. +- Server-filtered job tables use the same user-specific persistence without replacing their existing server-side filter behavior. +- The profile page now explains that table views are saved automatically to the user account. + +## Compatibility +- Existing table filtering, sorting, advanced filters, multi-value filters, and resize logic remain in place. +- No manual database migration is required; the lightweight migration adds the new user UI-state column automatically. diff --git a/docs/version-history/UPDATE-0.5.5.74.md b/docs/version-history/UPDATE-0.5.5.74.md new file mode 100755 index 0000000..197c7e5 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.74.md @@ -0,0 +1,10 @@ +# AssetManager 0.5.5.74 + +## Fixed + +- Keeps Asset list resize handles aligned with their columns after drag-and-drop column reordering. +- Reorders the generated filter row together with the Asset list headers and data cells. +- Reapplies stored column widths by field after a column move. +- Resize handlers now resolve the current DOM column index at resize time instead of retaining the original startup index. + +No database migration is required. diff --git a/docs/version-history/UPDATE-0.5.5.75.md b/docs/version-history/UPDATE-0.5.5.75.md new file mode 100755 index 0000000..50fb246 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.75.md @@ -0,0 +1,13 @@ +# AssetManager 0.5.5.75 + +## Changed + +- Resizing a table column now changes only the dragged column. +- The complete table grows or shrinks by exactly the same amount; columns to the right keep their current widths. +- Horizontal scrolling is used automatically when the table becomes wider than the available viewport. +- Removed the previous far-right compensation-column behavior and the arbitrary 1200 px column-width ceiling. +- Asset list width and its synchronized top scrollbar are updated after resizing. +- Resized Asset list widths remain compatible with column hide/show, reordering, and per-user stored table state. +- Keyboard resizing follows the same independent-column behavior. + +No database migration is required.