table optimization (filtering, resizing and settings saved in user settings

This commit is contained in:
2026-08-12 23:14:21 +02:00
parent e0175ab48a
commit 0fb18ae4f7
24 changed files with 991 additions and 189 deletions
-1
View File
@@ -5,7 +5,6 @@
.public-release-local-patterns .public-release-local-patterns
data/config/config.json data/config/config.json
data/config/APPINFO.json data/config/APPINFO.json
docker-compose_old.yml
# Runtime and user data # Runtime and user data
data/postgres/* data/postgres/*
+1 -1
View File
@@ -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. 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.
+1 -1
View File
@@ -1 +1 @@
0.5.5.68 0.5.5.75
+18 -3
View File
@@ -59,8 +59,8 @@ BASE_TRANSLATIONS = {
"profile.last_login": ("Last sign-in", "Letzte Anmeldung"), "profile.change_password": ("Change password", "Passwort ändern"), "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.current_password": ("Current password", "Aktuelles Passwort"), "profile.new_password": ("New password", "Neues Passwort"),
"profile.save_language": ("Save language", "Sprache speichern"), "profile.save_language": ("Save language", "Sprache speichern"),
"profile.browser_storage": ("Browser storage", "Browserspeicherung"), "profile.browser_storage": ("Table views", "Tabellenansichten"),
"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_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.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"), "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"), "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": ("Row number", "Zeilennummer"),
"lists.row_number_resize": ("Drag to change the row-number column width", "Ziehen, um die Breite der Nummernspalte zu ändern"), "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.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, <=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, <=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_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.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"), "appearance.primary_color": ("Primary color", "Primärfarbe"),
+218 -10
View File
@@ -5412,17 +5412,145 @@ def _translated_filter_codes(
return matches return matches
def _software_job_date_filter(column: Any, search_value: str): def _parse_table_filter_number(value: Any) -> float | None:
"""Support ISO/database text and the common German date formats.""" """Parse a simple locale-friendly table-filter number."""
value = str(search_value or "").strip() text = str(value or "").strip().replace(" ", "").replace(",", ".")
conditions = [_sql_text_contains(column, value)] if not re.fullmatch(r"[-+]?\d+(?:\.\d+)?", text):
for date_format in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): return None
try: try:
parsed = datetime.strptime(value, date_format) 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()
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:
return datetime.strptime(text, date_format), date_only
except ValueError: except ValueError:
continue continue
conditions.append(and_(column >= parsed, column < parsed + timedelta(days=1))) return None
break
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) return or_(*conditions)
@@ -5457,7 +5585,10 @@ def _apply_software_job_filters(query: Any, request: Request, filters: dict[str,
if terms: if terms:
conditions = [] conditions = []
for value in terms: 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)) conditions.append(SoftwareJob.id == int(value))
else: else:
conditions.append(_sql_text_contains(SoftwareJob.id, value)) 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: if terms:
conditions = [] conditions = []
for value in terms: 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)) conditions.append(SoftwareJob.priority == int(value))
else: else:
conditions.append(_sql_text_contains(SoftwareJob.priority, value)) 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) 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") @app.post("/profile/browser-state")
def profile_browser_state( def profile_browser_state(
request: Request, request: Request,
+1
View File
@@ -8,6 +8,7 @@ USER_COLUMNS = {
"profile_location": "VARCHAR(150) NULL", "profile_location": "VARCHAR(150) NULL",
"language_code": "VARCHAR(10) DEFAULT 'en'", "language_code": "VARCHAR(10) DEFAULT 'en'",
"persist_browser_state": "BOOLEAN DEFAULT FALSE", "persist_browser_state": "BOOLEAN DEFAULT FALSE",
"ui_state": "JSON DEFAULT '{}'::json",
"toast_position": "VARCHAR(30) DEFAULT 'top-right'", "toast_position": "VARCHAR(30) DEFAULT 'top-right'",
"toast_duration_seconds": "INTEGER DEFAULT 6", "toast_duration_seconds": "INTEGER DEFAULT 6",
"theme_mode": "VARCHAR(20) DEFAULT 'light'", "theme_mode": "VARCHAR(20) DEFAULT 'light'",
+1
View File
@@ -274,6 +274,7 @@ class User(Base):
profile_location: Mapped[str | None] = mapped_column(String(150), nullable=True) profile_location: Mapped[str | None] = mapped_column(String(150), nullable=True)
language_code: Mapped[str] = mapped_column(String(10), default="en") language_code: Mapped[str] = mapped_column(String(10), default="en")
persist_browser_state: Mapped[bool] = mapped_column(Boolean, default=False) 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_position: Mapped[str] = mapped_column(String(30), default="top-right")
toast_duration_seconds: Mapped[int] = mapped_column(Integer, default=6) toast_duration_seconds: Mapped[int] = mapped_column(Integer, default=6)
theme_mode: Mapped[str] = mapped_column(String(20), default="light") theme_mode: Mapped[str] = mapped_column(String(20), default="light")
+25
View File
@@ -3122,3 +3122,28 @@ html[data-theme="dark"] .column-filter-row textarea.filter-input-active{
color:#fff2b3; color:#fff2b3;
border-color:#d4a735; 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}
+1
View File
@@ -207,6 +207,7 @@
} }
window.addEventListener('resize', scheduleScrollbarUpdate, { passive: true }); window.addEventListener('resize', scheduleScrollbarUpdate, { passive: true });
window.addEventListener('asset-table-columns-changed', scheduleScrollbarUpdate, { passive: true });
if ('ResizeObserver' in window && scroll) { if ('ResizeObserver' in window && scroll) {
const resizeObserver = new ResizeObserver(entries => { const resizeObserver = new ResizeObserver(entries => {
+155 -24
View File
@@ -1,24 +1,165 @@
(() => { (() => {
const perf = window.AssetPerf; const perf = window.AssetPerf;
perf?.start('browser-state:gesamtes Modul'); perf?.start('browser-state:gesamtes Modul');
const persistent = document.body?.dataset.persistBrowserState === 'true'; 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:'; 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 = { const api = {
persistent, persistent,
serverBacked: false,
ready: Promise.resolve(),
get(key) { 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) { 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) { 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; 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) { function tableKey(table, index) {
return table.dataset.storageKey || table.id || `table-${location.pathname}-${index}`; return table.dataset.storageKey || table.id || `table-${location.pathname}-${index}`;
} }
@@ -58,11 +199,6 @@
function restoreTable(table, index) { function restoreTable(table, index) {
if (table.dataset.browserStateReady === '1') return; if (table.dataset.browserStateReady === '1') return;
if (table.dataset.serverFilters === '1') {
table.dataset.browserStateReady = '1';
restoreSort(table, index);
return;
}
const restoreStarted = performance.now(); const restoreStarted = performance.now();
const controls = filterControls(table); const controls = filterControls(table);
if (!controls.length) { if (!controls.length) {
@@ -76,10 +212,11 @@
let values = {}; let values = {};
try { values = JSON.parse(api.get(key) || '{}') || {}; } catch (_) {} try { values = JSON.parse(api.get(key) || '{}') || {}; } catch (_) {}
let serverValuesChanged = false;
controls.forEach((control, controlIndex) => { controls.forEach((control, controlIndex) => {
const field = controlKey(control, controlIndex); const field = controlKey(control, controlIndex);
if (Object.prototype.hasOwnProperty.call(values, field)) { 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]; control.value = values[field];
} }
const save = () => { const save = () => {
@@ -93,9 +230,9 @@
control.addEventListener('change', save); 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') { if (table.dataset.serverFilters === '1' && serverValuesChanged && typeof table.assetSubmitServerFilters === 'function') {
table.assetApplyFilters(); table.assetSubmitServerFilters(true);
} }
restoreSort(table, index); restoreSort(table, index);
table.dataset.browserStateRestored = '1'; table.dataset.browserStateRestored = '1';
@@ -109,7 +246,8 @@
}); });
} }
function initializeTables() { async function initializeTables() {
await api.ready;
document.querySelectorAll('table').forEach(restoreTable); document.querySelectorAll('table').forEach(restoreTable);
} }
@@ -119,21 +257,14 @@
initializeTables(); 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); 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 => { window.addEventListener('asset-table-filters-cleared', event => {
const table = event.detail?.table; const table = event.detail?.table;
if (!(table instanceof HTMLTableElement)) return; if (!(table instanceof HTMLTableElement)) return;
const tables = [...document.querySelectorAll('table')]; const tables = [...document.querySelectorAll('table')];
const index = tables.indexOf(table); const index = tables.indexOf(table);
const key = `${tableKey(table, index >= 0 ? index : 0)}:filters`; api.remove(`${tableKey(table, index >= 0 ? index : 0)}:filters`);
api.remove(key);
}); });
window.addEventListener('asset-table-sort-changed', event => { window.addEventListener('asset-table-sort-changed', event => {
@@ -141,8 +272,7 @@
if (!(table instanceof HTMLTableElement)) return; if (!(table instanceof HTMLTableElement)) return;
const tables = [...document.querySelectorAll('table')]; const tables = [...document.querySelectorAll('table')];
const index = tables.indexOf(table); const index = tables.indexOf(table);
const key = `${tableKey(table, index >= 0 ? index : 0)}:sort`; api.set(`${tableKey(table, index >= 0 ? index : 0)}:sort`, JSON.stringify({
api.set(key, JSON.stringify({
field: event.detail?.field || '', field: event.detail?.field || '',
direction: event.detail?.direction || 'asc' direction: event.detail?.direction || 'asc'
})); }));
@@ -156,5 +286,6 @@
restoreSort(table, index >= 0 ? index : 0, true); restoreSort(table, index >= 0 ? index : 0, true);
}); });
window.addEventListener('pagehide', () => { flushPending(); });
perf?.end('browser-state:gesamtes Modul'); perf?.end('browser-state:gesamtes Modul');
})(); })();
+438 -131
View File
@@ -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 = {}) { function normalizeSearchText(value, options = {}) {
const caseSensitive = Boolean(options.caseSensitive); const caseSensitive = Boolean(options.caseSensitive);
const collapseWhitespace = Boolean(options.collapseWhitespace); const collapseWhitespace = Boolean(options.collapseWhitespace);
@@ -179,14 +189,14 @@
const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]'); const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]');
const rowNumberStoragePart = table.dataset.storageKey || table.id || window.location.pathname; 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 defaultRowNumberWidth = 64;
const minimumRowNumberWidth = 48; const minimumRowNumberWidth = 48;
const maximumRowNumberWidth = 180; const maximumRowNumberWidth = 180;
function readStoredRowNumberWidth() { function readStoredRowNumberWidth() {
try { try {
const stored = Number.parseInt(window.localStorage.getItem(rowNumberWidthKey) || '', 10); const stored = Number.parseInt(stateGet(rowNumberWidthKey) || '', 10);
if (Number.isFinite(stored)) { if (Number.isFinite(stored)) {
return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored)); return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored));
} }
@@ -213,7 +223,7 @@
const saveWidth = width => { const saveWidth = width => {
const normalized = applyRowNumberWidth(width); const normalized = applyRowNumberWidth(width);
try { window.localStorage.setItem(rowNumberWidthKey, String(normalized)); } catch (_) {} try { stateSet(rowNumberWidthKey, String(normalized)); } catch (_) {}
}; };
handle.addEventListener('pointerdown', event => { handle.addEventListener('pointerdown', event => {
@@ -254,9 +264,9 @@
// stored per table and per field/index so they survive navigation, // stored per table and per field/index so they survive navigation,
// reloads and container updates. The visual row-number column keeps its // reloads and container updates. The visual row-number column keeps its
// dedicated implementation above. // dedicated implementation above.
const columnWidthStoragePrefix = `assetmanager:table:${rowNumberStoragePart}:column-width:`; const columnWidthStoragePrefix = `table:${rowNumberStoragePart}:column-width:`;
const minimumColumnWidth = 56; const minimumColumnWidth = 56;
const maximumColumnWidth = 1200; // No arbitrary upper limit: widening a column grows the whole table.
function columnStorageId(header, columnIndex) { function columnStorageId(header, columnIndex) {
const field = header?.dataset?.field; const field = header?.dataset?.field;
@@ -265,7 +275,7 @@
} }
function normalizeColumnWidth(width) { function normalizeColumnWidth(width) {
return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, Math.round(width))); return Math.max(minimumColumnWidth, Math.round(width));
} }
function applyColumnWidth(columnIndex, width) { function applyColumnWidth(columnIndex, width) {
@@ -287,7 +297,7 @@
function readStoredColumnWidth(header, columnIndex) { function readStoredColumnWidth(header, columnIndex) {
try { try {
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`; 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); if (Number.isFinite(stored)) return normalizeColumnWidth(stored);
} catch (_) {} } catch (_) {}
return null; return null;
@@ -295,19 +305,22 @@
function saveColumnWidth(header, columnIndex, width) { function saveColumnWidth(header, columnIndex, width) {
const normalized = applyColumnWidth(columnIndex, width); const normalized = applyColumnWidth(columnIndex, width);
if (header?.dataset?.field) header.dataset.columnWidth = String(normalized);
try { try {
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`; const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
window.localStorage.setItem(key, String(normalized)); stateSet(key, String(normalized));
} catch (_) {} } catch (_) {}
return normalized; return normalized;
} }
function freezeTableGeometry() { function freezeTableGeometry() {
if (table.id === 'asset-table') return; const widths = [...headerRow.cells].map(cell => {
const widths = [...headerRow.cells].map(cell => Math.max(minimumColumnWidth, Math.round(cell.getBoundingClientRect().width || minimumColumnWidth))); 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); const total = widths.reduce((sum, width) => sum + width, 0);
widths.forEach((width, columnIndex) => { widths.forEach((width, columnIndex) => {
if (headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return; if (!width || headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return;
applyColumnWidth(columnIndex, width); applyColumnWidth(columnIndex, width);
}); });
table.style.tableLayout = 'fixed'; table.style.tableLayout = 'fixed';
@@ -315,15 +328,25 @@
table.style.minWidth = `${total}px`; table.style.minWidth = `${total}px`;
} }
function synchronizeTableWidth() { function setTableWidth(total) {
if (table.id === 'asset-table') return; const normalized = Math.max(1, Math.round(total || 0));
const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); table.style.width = `${normalized}px`;
if (total > 0) { table.style.minWidth = `${normalized}px`;
table.style.width = `${total}px`; if (table.id === 'asset-table') {
table.style.minWidth = `${total}px`; 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() { function applyStoredColumnWidths() {
let restored = false; let restored = false;
[...headerRow.cells].forEach((header, columnIndex) => { [...headerRow.cells].forEach((header, columnIndex) => {
@@ -366,39 +389,25 @@
const startX = event.clientX; const startX = event.clientX;
freezeTableGeometry(); freezeTableGeometry();
const headers = [...headerRow.cells]; // A header can be moved after the resizer was installed. Always use
const rightmostIndex = headers.length - 1; // its current DOM position instead of the index captured at startup.
const currentColumnIndex = header.cellIndex;
const startWidth = header.getBoundingClientRect().width || minimumColumnWidth; const startWidth = header.getBoundingClientRect().width || minimumColumnWidth;
const rightmostHeader = headers[rightmostIndex]; const startTableWidth = currentTableWidth();
const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth;
const keepTableWidth = columnIndex !== rightmostIndex;
document.documentElement.classList.add('table-column-resizing'); document.documentElement.classList.add('table-column-resizing');
const resizePair = delta => { // Resize only the dragged column. The table width follows the actual
if (!keepTableWidth) { // column delta exactly; all other columns keep their current widths.
applyColumnWidth(columnIndex, startWidth + delta); const resizeColumn = delta => {
synchronizeTableWidth(); const newWidth = applyColumnWidth(currentColumnIndex, startWidth + delta);
return; setTableWidth(startTableWidth + (newWidth - startWidth));
}
// 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);
}; };
const move = moveEvent => resizePair(moveEvent.clientX - startX); const move = moveEvent => resizeColumn(moveEvent.clientX - startX);
const finish = () => { const finish = () => {
saveColumnWidth(header, columnIndex, header.getBoundingClientRect().width || startWidth); saveColumnWidth(header, currentColumnIndex, header.getBoundingClientRect().width || startWidth);
if (keepTableWidth && rightmostHeader) {
saveColumnWidth(rightmostHeader, rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth);
} else {
synchronizeTableWidth(); synchronizeTableWidth();
}
document.documentElement.classList.remove('table-column-resizing'); document.documentElement.classList.remove('table-column-resizing');
window.removeEventListener('pointermove', move, true); window.removeEventListener('pointermove', move, true);
window.removeEventListener('pointerup', finish, true); window.removeEventListener('pointerup', finish, true);
@@ -414,22 +423,11 @@
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
freezeTableGeometry(); freezeTableGeometry();
const headers = [...headerRow.cells]; const currentColumnIndex = header.cellIndex;
const rightmostIndex = headers.length - 1;
const step = event.key === 'ArrowRight' ? 12 : -12; const step = event.key === 'ArrowRight' ? 12 : -12;
const current = header.getBoundingClientRect().width || minimumColumnWidth; const current = header.getBoundingClientRect().width || minimumColumnWidth;
if (columnIndex === rightmostIndex) { saveColumnWidth(header, currentColumnIndex, current + step);
saveColumnWidth(header, columnIndex, current + step);
synchronizeTableWidth(); 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);
}); });
}); });
} }
@@ -592,17 +590,89 @@
}); });
} }
function numericFilterMatches(rawValue, rawTerm) { function comparableCellValue(cell) {
const value = Number(String(rawValue).trim().replace(',', '.')); if (!cell) return '';
if (!Number.isFinite(value)) return false; 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; if (!term) return true;
const normalize = input => input.replace(',', '.').trim();
let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/); let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/);
if (match) { 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; 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; return value === expected;
} }
match = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/); let compactRange = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/);
if (!match) { const range = compactRange ? [compactRange[1], compactRange[2]] : rangeParts(term);
match = term.match(/^(?:zwischen|between)\s+(-?\d+(?:[.,]\d+)?)\s+(?:und|and)\s+(-?\d+(?:[.,]\d+)?)$/); if (range) {
} const first = parseFilterNumber(range[0]);
if (match) { const second = parseFilterNumber(range[1]);
const first = Number(normalize(match[1])); if (first !== null && second !== null) {
const second = Number(normalize(match[2]));
const minimum = Math.min(first, second); const minimum = Math.min(first, second);
const maximum = Math.max(first, second); const maximum = Math.max(first, second);
return value >= minimum && value <= maximum; 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) { function submitServerFilters(immediate = false) {
if (!usesServerFilters) return; if (!usesServerFilters) return;
@@ -743,12 +1070,9 @@
const hidden = active.some(({ definition, terms }) => { const hidden = active.some(({ definition, terms }) => {
const cell = cellForDefinition(row, definition); const cell = cellForDefinition(row, definition);
const value = textValue(cell); const value = textValue(cell);
const matchesAnyTerm = terms.some(term => { const matchesAnyTerm = terms.some(term =>
if (definition.numericFilter) { advancedFilterMatches(cell, value, term, definition)
return numericFilterMatches(value, term); );
}
return searchTextMatches(value, term);
});
return !matchesAnyTerm; return !matchesAnyTerm;
}); });
@@ -849,12 +1173,15 @@
} }
filterCell.appendChild(input); filterCell.appendChild(input);
const advancedFilterType = inferredAdvancedFilterType(header, columnIndex, serverName);
createAdvancedFilterHelper(filterCell, input, advancedFilterType);
filterDefinitions.push({ filterDefinitions.push({
field, field,
columnIndex, columnIndex,
input, input,
serverName, serverName,
numericFilter: header.dataset.numericFilter === '1' numericFilter: header.dataset.numericFilter === '1',
advancedFilterType
}); });
} }
@@ -878,6 +1205,7 @@
}); });
table.assetApplyFilters = applyFilters; table.assetApplyFilters = applyFilters;
table.assetSubmitServerFilters = submitServerFilters;
table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options); table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options);
table.assetUpdateRowNumbers = updateRowNumbers; table.assetUpdateRowNumbers = updateRowNumbers;
table.assetRebuildFilterCache = rebuildMissingCellKeys; table.assetRebuildFilterCache = rebuildMissingCellKeys;
@@ -916,10 +1244,9 @@
const headerRow = thead.rows[0]; const headerRow = thead.rows[0];
const storagePart = table.dataset.storageKey || table.id || `${window.location.pathname}:table-${[...document.querySelectorAll('main table')].indexOf(table)}`; 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 minWidth = 56;
const maxWidth = 1200; const normalize = width => Math.max(minWidth, Math.round(width));
const normalize = width => Math.min(maxWidth, Math.max(minWidth, Math.round(width)));
const columnKey = (header, index) => header.dataset.field || `index-${index}`; const columnKey = (header, index) => header.dataset.field || `index-${index}`;
const applyWidth = (index, width) => { const applyWidth = (index, width) => {
@@ -935,27 +1262,33 @@
}; };
const freezeGeometry = () => { 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); 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.tableLayout = 'fixed';
table.style.width = `${total}px`; table.style.width = `${total}px`;
table.style.minWidth = `${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 syncTableWidth = () => {
const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); const total = currentTableWidth();
if (total > 0) { if (total > 0) setTableWidth(total);
table.style.width = `${total}px`;
table.style.minWidth = `${total}px`;
}
}; };
[...headerRow.cells].forEach((header, index) => { [...headerRow.cells].forEach((header, index) => {
if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return; if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return;
const key = `${storagePrefix}${columnKey(header, index)}`; const key = `${storagePrefix}${columnKey(header, index)}`;
try { try {
const stored = Number.parseInt(localStorage.getItem(key) || '', 10); const stored = Number.parseInt(stateGet(key) || '', 10);
if (Number.isFinite(stored)) applyWidth(index, stored); if (Number.isFinite(stored)) applyWidth(index, stored);
} catch (_) {} } catch (_) {}
@@ -971,44 +1304,28 @@
const save = width => { const save = width => {
const normalized = applyWidth(index, 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('click', event => { event.preventDefault(); event.stopPropagation(); });
handle.addEventListener('pointerdown', event => { handle.addEventListener('pointerdown', event => {
event.preventDefault(); event.stopPropagation(); event.preventDefault(); event.stopPropagation();
const startX = event.clientX; const startX = event.clientX;
freezeGeometry(); freezeGeometry();
const headers = [...headerRow.cells]; const currentIndex = header.cellIndex;
const rightmostIndex = headers.length - 1;
const startWidth = header.getBoundingClientRect().width || minWidth; const startWidth = header.getBoundingClientRect().width || minWidth;
const rightmostHeader = headers[rightmostIndex]; const startTableWidth = currentTableWidth();
const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minWidth;
const keepTableWidth = index !== rightmostIndex;
document.documentElement.classList.add('table-column-resizing'); document.documentElement.classList.add('table-column-resizing');
const resizePair = delta => { const resizeColumn = delta => {
if (!keepTableWidth) { const newWidth = applyWidth(currentIndex, startWidth + delta);
applyWidth(index, startWidth + delta); setTableWidth(startTableWidth + (newWidth - startWidth));
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 move = e => resizePair(e.clientX - startX); const move = e => resizeColumn(e.clientX - startX);
const finish = () => { const finish = () => {
save(header.getBoundingClientRect().width || startWidth); const normalized = applyWidth(currentIndex, header.getBoundingClientRect().width || startWidth);
if (keepTableWidth && rightmostHeader) { try { stateSet(`${storagePrefix}${columnKey(header, currentIndex)}`, String(normalized)); } catch (_) {}
const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`;
const rightWidth = applyWidth(rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth);
try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {}
} else {
syncTableWidth(); syncTableWidth();
}
document.documentElement.classList.remove('table-column-resizing'); document.documentElement.classList.remove('table-column-resizing');
window.removeEventListener('pointermove', move, true); window.removeEventListener('pointermove', move, true);
window.removeEventListener('pointerup', finish, true); window.removeEventListener('pointerup', finish, true);
@@ -1022,33 +1339,23 @@
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault(); event.stopPropagation(); event.preventDefault(); event.stopPropagation();
freezeGeometry(); freezeGeometry();
const headers = [...headerRow.cells]; const currentIndex = header.cellIndex;
const rightmostIndex = headers.length - 1;
const step = event.key === 'ArrowRight' ? 12 : -12; const step = event.key === 'ArrowRight' ? 12 : -12;
const current = header.getBoundingClientRect().width || minWidth; const current = header.getBoundingClientRect().width || minWidth;
if (index === rightmostIndex) { const normalized = applyWidth(currentIndex, current + step);
save(current + step); try { stateSet(`${storagePrefix}${columnKey(header, currentIndex)}`, String(normalized)); } catch (_) {}
syncTableWidth(); 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 (_) {}
}
}); });
}); });
table.dataset.columnResizeReady = '1'; table.dataset.columnResizeReady = '1';
} }
const initializeTableTools = () => {
document.querySelectorAll('table.data-table').forEach(initTable); document.querySelectorAll('table.data-table').forEach(initTable);
document.querySelectorAll('main table:not(#asset-table):not([data-column-resize="off"])').forEach(initUniversalTableResize); 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 => { window.addEventListener('asset-table-content-reloaded', event => {
const table = event.detail?.table; const table = event.detail?.table;
+25 -4
View File
@@ -136,12 +136,12 @@
{% set has_name = fields|selectattr('field_name', 'equalto', 'name')|list|length > 0 %} {% set has_name = fields|selectattr('field_name', 'equalto', 'name')|list|length > 0 %}
{% if not has_name %}<th draggable="true" data-field="name" data-column-width="220" data-label="{{ t('field.name', 'Bezeichnung')|e }}" style="width:220px;min-width:220px;max-width:220px">{{ t('field.name', 'Bezeichnung') }}</th>{% endif %} {% if not has_name %}<th draggable="true" data-field="name" data-column-width="220" data-label="{{ t('field.name', 'Bezeichnung')|e }}" style="width:220px;min-width:220px;max-width:220px">{{ t('field.name', 'Bezeichnung') }}</th>{% endif %}
{% if not selected %}<th draggable="true" data-field="category" data-column-width="180" data-label="{{ t('common.category', 'Kategorie')|e }}" style="width:180px;min-width:180px;max-width:180px">{{ t('common.category', 'Kategorie') }}</th>{% endif %} {% if not selected %}<th draggable="true" data-field="category" data-column-width="180" data-label="{{ t('common.category', 'Kategorie')|e }}" style="width:180px;min-width:180px;max-width:180px">{{ t('common.category', 'Kategorie') }}</th>{% 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) %}<th draggable="true" data-field="{{ f.field_name }}" data-column-width="{{ list_width }}" data-label="{{ f.label|e }}" style="width:{{ list_width }}px;min-width:{{ list_width }}px;max-width:{{ list_width }}px">{{ f.label }}</th>{% 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) %}<th draggable="true" data-field="{{ f.field_name }}" data-column-width="{{ list_width }}" data-label="{{ f.label|e }}" data-filter-type="{{ f.definition.data_type if f.definition else '' }}" style="width:{{ list_width }}px;min-width:{{ list_width }}px;max-width:{{ list_width }}px">{{ f.label }}</th>{% endfor %}
{% if job_filter %} {% if job_filter %}
<th data-field="job_filter_status" data-label="{{ t('assets.job_filter.status', 'Jobstatus')|e }}" style="width:180px;min-width:180px;max-width:180px">{{ t('assets.job_filter.status', 'Jobstatus') }}</th> <th data-field="job_filter_status" data-label="{{ t('assets.job_filter.status', 'Jobstatus')|e }}" style="width:180px;min-width:180px;max-width:180px">{{ t('assets.job_filter.status', 'Jobstatus') }}</th>
<th data-field="job_filter_last_execution" data-label="{{ t('assets.job_filter.last_execution', 'Letzte Ausführung')|e }}" style="width:175px;min-width:175px;max-width:175px">{{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }}</th> <th data-field="job_filter_last_execution" data-filter-type="datetime" data-label="{{ t('assets.job_filter.last_execution', 'Letzte Ausführung')|e }}" style="width:175px;min-width:175px;max-width:175px">{{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }}</th>
<th data-field="job_filter_last_success" data-label="{{ t('assets.job_filter.last_success', 'Letzter Erfolg')|e }}" style="width:175px;min-width:175px;max-width:175px">{{ t('assets.job_filter.last_success', 'Letzter Erfolg') }}</th> <th data-field="job_filter_last_success" data-filter-type="datetime" data-label="{{ t('assets.job_filter.last_success', 'Letzter Erfolg')|e }}" style="width:175px;min-width:175px;max-width:175px">{{ t('assets.job_filter.last_success', 'Letzter Erfolg') }}</th>
<th data-field="job_filter_revision" data-label="{{ t('assets.job_filter.revision', 'Revision')|e }}" style="width:100px;min-width:100px;max-width:100px">{{ t('assets.job_filter.revision', 'Revision') }}</th> <th data-field="job_filter_revision" data-filter-type="integer" data-label="{{ t('assets.job_filter.revision', 'Revision')|e }}" style="width:100px;min-width:100px;max-width:100px">{{ t('assets.job_filter.revision', 'Revision') }}</th>
{% endif %} {% endif %}
<th data-field="mesh_presence" data-label="{{ t('presence.title')|e }}" style="width:125px;min-width:125px;max-width:125px">{{ t('presence.title') }}</th> <th data-field="mesh_presence" data-label="{{ t('presence.title')|e }}" style="width:125px;min-width:125px;max-width:125px">{{ t('presence.title') }}</th>
<th data-no-sort="1" data-no-filter="1" class="actions-column">{{ t('common.actions', 'Aktionen') }}</th> <th data-no-sort="1" data-no-filter="1" class="actions-column">{{ t('common.actions', 'Aktionen') }}</th>
@@ -371,6 +371,23 @@
}); });
headerRow.insertBefore(headerFragment, headerActions); 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 => { [...table.tBodies[0].rows].forEach(row => {
const actions = row.lastElementChild; const actions = row.lastElementChild;
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@@ -397,6 +414,10 @@
order.splice(order.indexOf(dragged), 1); order.splice(order.indexOf(dragged), 1);
order.splice(order.indexOf(target), 0, dragged); order.splice(order.indexOf(target), 0, dragged);
applyOrder(order); 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.AssetBrowserState?.set(key, JSON.stringify(order));
window.dispatchEvent(new Event('asset-table-columns-changed')); window.dispatchEvent(new Event('asset-table-columns-changed'));
}); });
+1 -1
View File
@@ -14,7 +14,7 @@
--am-warning:{{ colors.get('warning_color', '#d97706') }}; --am-warning:{{ colors.get('warning_color', '#d97706') }};
--am-danger:{{ colors.get('danger_color', '#b42318') }}; --am-danger:{{ colors.get('danger_color', '#b42318') }};
} }
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-row-number-label="{{ t('lists.row_number', 'Zeilennummer')|e }}" data-row-number-resize-label="{{ t('lists.row_number_resize', 'Ziehen, um die Breite der Nummernspalte zu ändern')|e }}" data-column-resize-label="{{ t('lists.column_resize', 'Ziehen, um die Spaltenbreite zu ändern')|e }}" data-multi-value-filter-hint="{{ t('lists.multi_value_filter_hint', 'Mehrere Suchwerte können zeilenweise eingefügt werden. Innerhalb einer Spalte gilt ODER.')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}"> </style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-row-number-label="{{ t('lists.row_number', 'Zeilennummer')|e }}" data-row-number-resize-label="{{ t('lists.row_number_resize', 'Ziehen, um die Breite der Nummernspalte zu ändern')|e }}" data-column-resize-label="{{ t('lists.column_resize', 'Ziehen, um die Spaltenbreite zu ändern')|e }}" data-multi-value-filter-hint="{{ t('lists.multi_value_filter_hint', 'Mehrere Suchwerte können zeilenweise eingefügt werden. Innerhalb einer Spalte gilt ODER.')|e }}" data-advanced-filter-title="{{ t('lists.advanced_filter.title')|e }}" data-advanced-filter-operator="{{ t('lists.advanced_filter.operator')|e }}" data-advanced-filter-value="{{ t('lists.advanced_filter.value')|e }}" data-advanced-filter-value-from="{{ t('lists.advanced_filter.value_from')|e }}" data-advanced-filter-value-to="{{ t('lists.advanced_filter.value_to')|e }}" data-advanced-filter-equal="{{ t('lists.advanced_filter.equal')|e }}" data-advanced-filter-gt="{{ t('lists.advanced_filter.gt')|e }}" data-advanced-filter-gte="{{ t('lists.advanced_filter.gte')|e }}" data-advanced-filter-lt="{{ t('lists.advanced_filter.lt')|e }}" data-advanced-filter-lte="{{ t('lists.advanced_filter.lte')|e }}" data-advanced-filter-range="{{ t('lists.advanced_filter.range')|e }}" data-advanced-filter-apply="{{ t('lists.advanced_filter.apply')|e }}" data-advanced-filter-clear="{{ t('lists.advanced_filter.clear')|e }}" data-advanced-filter-empty="{{ t('lists.advanced_filter.empty')|e }}" data-advanced-filter-not-empty="{{ t('lists.advanced_filter.not_empty')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
<header class="main-header"> <header class="main-header">
<a class="brand" href="/">{% if general.get('logo') %}<img src="{{ general.get('logo') }}" alt="Logo">{% endif %}<span>{{ general.get('title', 'AssetManager') }}</span>{% if general.get('company_name') %}<span class="brand-company">– {{ general.get('company_name') }}</span>{% endif %}</a> <a class="brand" href="/">{% if general.get('logo') %}<img src="{{ general.get('logo') }}" alt="Logo">{% endif %}<span>{{ general.get('title', 'AssetManager') }}</span>{% if general.get('company_name') %}<span class="brand-company">– {{ general.get('company_name') }}</span>{% endif %}</a>
+1 -1
View File
@@ -58,7 +58,7 @@
<button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('common.save') }}</button> <button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('common.save') }}</button>
</form> </form>
<form method="post" action="/profile/browser-state" class="auth-card"><h2>{{ t('profile.browser_storage') }}</h2><p class="table-hint">{{ t('profile.browser_storage_help') }}</p><label class="checkbox-label"><input type="checkbox" name="persist_browser_state" {% if user.persist_browser_state %}checked{% endif %}>{{ t('profile.browser_storage_enable') }}</label><button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('profile.save_browser_storage') }}</button></form> <section class="auth-card"><h2>{{ t('profile.browser_storage') }}</h2><p class="table-hint">{{ t('profile.browser_storage_help') }}</p></section>
<form method="post" action="/profile/toast-settings" class="auth-card"> <form method="post" action="/profile/toast-settings" class="auth-card">
<h2>{{ t('profile.toast_settings') }}</h2> <h2>{{ t('profile.toast_settings') }}</h2>
+3 -3
View File
@@ -87,13 +87,13 @@
> >
<thead><tr> <thead><tr>
<th class="selection-column" data-no-sort="1" data-no-filter="1"><input type="checkbox" id="select-all-restartable-jobs" aria-label="{{ t('jobs.select_all_restartable')|e }}"></th> <th class="selection-column" data-no-sort="1" data-no-filter="1"><input type="checkbox" id="select-all-restartable-jobs" aria-label="{{ t('jobs.select_all_restartable')|e }}"></th>
<th data-server-filter-name="job_id" data-server-filter-value="{{ job_filters.job_id|e }}">{{ t('software.job_id') }}</th> <th data-server-filter-name="job_id" data-filter-type="integer" data-server-filter-value="{{ job_filters.job_id|e }}">{{ t('software.job_id') }}</th>
<th data-server-filter-name="created_at" data-server-filter-value="{{ job_filters.created_at|e }}">{{ t('software.time') }}</th> <th data-server-filter-name="created_at" data-filter-type="datetime" data-server-filter-value="{{ job_filters.created_at|e }}">{{ t('software.time') }}</th>
<th data-server-filter-name="asset" data-server-filter-value="{{ job_filters.asset|e }}">{{ t('common.assets') }}</th> <th data-server-filter-name="asset" data-server-filter-value="{{ job_filters.asset|e }}">{{ t('common.assets') }}</th>
<th data-server-filter-name="job_type" data-server-filter-value="{{ job_filters.job_type|e }}">{{ t('jobs.type') }}</th> <th data-server-filter-name="job_type" data-server-filter-value="{{ job_filters.job_type|e }}">{{ t('jobs.type') }}</th>
<th data-server-filter-name="package" data-server-filter-value="{{ job_filters.package|e }}">{{ t('software.package') }}</th> <th data-server-filter-name="package" data-server-filter-value="{{ job_filters.package|e }}">{{ t('software.package') }}</th>
<th data-server-filter-name="action" data-server-filter-value="{{ job_filters.action|e }}">{{ t('jobs.action') }}</th> <th data-server-filter-name="action" data-server-filter-value="{{ job_filters.action|e }}">{{ t('jobs.action') }}</th>
<th data-server-filter-name="priority" data-server-filter-value="{{ job_filters.priority|e }}">{{ t('jobs.priority') }}</th> <th data-server-filter-name="priority" data-filter-type="integer" data-server-filter-value="{{ job_filters.priority|e }}">{{ t('jobs.priority') }}</th>
<th data-server-filter-name="platform" data-server-filter-value="{{ job_filters.platform|e }}">{{ t('software.platform') }}</th> <th data-server-filter-name="platform" data-server-filter-value="{{ job_filters.platform|e }}">{{ t('software.platform') }}</th>
<th data-server-filter-name="status" data-server-filter-value="{{ job_filters.status|e }}">{{ t('software.status') }}</th> <th data-server-filter-name="status" data-server-filter-value="{{ job_filters.status|e }}">{{ t('software.status') }}</th>
<th>{{ t('software.details') }}</th> <th>{{ t('software.details') }}</th>
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.68" APP_VERSION = "0.5.5.75"
__version__ = APP_VERSION __version__ = APP_VERSION
+6
View File
@@ -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.64](UPDATE-0.5.5.64.md)
- [0.5.5.35](UPDATE-0.5.5.35.md) - [0.5.5.35](UPDATE-0.5.5.35.md)
- [0.5.5.34](UPDATE-0.5.5.34.md) - [0.5.5.34](UPDATE-0.5.5.34.md)
+21
View File
@@ -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.
+18
View File
@@ -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.
+7
View File
@@ -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.
+6
View File
@@ -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.
+12
View File
@@ -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.
+10
View File
@@ -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.
+13
View File
@@ -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.