(function () { function t(key, params) { return window.CrashI18n ? window.CrashI18n.t(key, params) : key; } const STORAGE = { theme: 'crash_console_theme', perPage: 'crash_console_per_page', lastVisit: 'crash_console_last_visit_ms', viewed: 'crash_console_viewed_ids', sort: 'crash_console_sort', skipStamp: 'crash_skip_visit_stamp', colWidths: 'crash_console_col_widths', colOrder: 'crash_console_col_order', }; const DEFAULT_COL_WIDTHS = { expand: 40, device_model: 128, app_version: 80, os_name: 88, os_version: 120, received_at_ms: 152, rating: 92, tags: 128, }; function tagNew() { return { label: t('tag.new'), bg: '#3d8bfd' }; } const LIST_COLUMNS = [ { key: 'device_model', labelKey: 'col.device' }, { key: 'app_version', labelKey: 'col.app' }, { key: 'os_name', labelKey: 'col.os' }, { key: 'os_version', labelKey: 'col.os_ver' }, { key: 'received_at_ms', labelKey: 'col.received', fmt: 'time' }, { key: 'rating', labelKey: 'col.rating', fmt: 'stars' }, ]; const SORT_API_MAP = { priority: 'priority', rating: 'fingerprint_cnt', os_name: 'device_model', os_version: 'app_version', }; const GROUP_COLUMNS = [ { key: 'cnt', labelKey: 'col.count' }, { key: 'rating', labelKey: 'col.rating', fmt: 'stars' }, { key: 'fingerprint', labelKey: 'col.fingerprint', fmt: 'fp' }, { key: 'crash_type', labelKey: 'col.type' }, { key: 'last_generated_ms', labelKey: 'col.last_generated', fmt: 'time' }, { key: 'last_received_ms', labelKey: 'col.last_received', fmt: 'time' }, ]; function colLabel(col) { if (!col) return ''; return t(col.labelKey || 'col.' + col.key); } function basePath() { return document.body.getAttribute('data-base-path') || ''; } function lsGet(key, fallback) { try { const v = localStorage.getItem(key); return v === null ? fallback : v; } catch { return fallback; } } function lsSet(key, value) { try { localStorage.setItem(key, value); } catch { /* ignore */ } } function getViewedIds() { try { const raw = lsGet(STORAGE.viewed, '[]'); const arr = JSON.parse(raw); return new Set(Array.isArray(arr) ? arr.map(Number).filter(Boolean) : []); } catch { return new Set(); } } function addViewedId(id) { const set = getViewedIds(); set.add(Number(id)); lsSet(STORAGE.viewed, JSON.stringify([...set])); } function lastVisitMs() { return Number(lsGet(STORAGE.lastVisit, '0')) || 0; } function stampLastVisit() { lsSet(STORAGE.lastVisit, String(Date.now())); } function applyTheme(theme) { const t = theme === 'light' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', t); lsSet(STORAGE.theme, t); const sel = document.getElementById('theme-select'); if (sel) sel.value = t; } function initTheme() { applyTheme(lsGet(STORAGE.theme, 'dark')); const sel = document.getElementById('theme-select'); if (sel) { sel.addEventListener('change', () => applyTheme(sel.value)); } } function initNav() { /* nav_shell.js handles toggle + drag on #nav-pane / #nav-handle */ } function markReportViewed(id) { if (!id) return; addViewedId(id); const xhr = new XMLHttpRequest(); xhr.open('POST', basePath() + '/api/report_viewed.php', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(JSON.stringify({ id: Number(id) })); } function bindDetailTree(root) { root.querySelectorAll('[data-tree-toggle]').forEach((row) => { const panelId = row.getAttribute('data-controls'); const panel = panelId ? document.getElementById(panelId) : null; const btn = row.querySelector('.report-tree-toggle'); if (!panel) return; const toggle = (e) => { if (e && e.target.closest('.report-tree-toggle') && e.type === 'click') { e.stopPropagation(); } const open = panel.hidden; panel.hidden = !open; if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false'); row.classList.toggle('report-row--open', open); }; if (btn) { btn.addEventListener('click', (e) => { e.stopPropagation(); toggle(e); }); } row.addEventListener('click', () => toggle()); row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }); }); } function initReportDetail() { const id = document.body.getAttribute('data-report-id'); if (!id) return; markReportViewed(id); const tree = document.getElementById('detail-tree'); if (tree) bindDetailTree(tree); } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function formatTime(ms) { if (!ms) return '—'; const d = new Date(Number(ms)); return d.toISOString().replace('T', ' ').slice(0, 19); } function formatCell(col, row) { const v = row[col.key]; if (col.fmt === 'time') return formatTime(v); if (col.fmt === 'stars') return formatStars(Number(row.rating ?? 0)); if (col.fmt === 'fp') { const s = String(v || ''); return '' + escapeHtml(s.slice(0, 12)) + (s.length > 12 ? '…' : '') + ''; } return escapeHtml(v ?? '') || ''; } function formatStars(n) { const score = Math.max(0, Math.min(5, Number(n) || 0)); let html = ''; for (let i = 1; i <= 5; i++) { html += ''; } return html + ''; } function crashKindTag(crashType) { const kind = String(crashType || '').toLowerCase(); const map = { native: { labelKey: 'tag.ndk', bg: '#b45309' }, java: { labelKey: 'tag.java', bg: '#6d28d9' }, android: { labelKey: 'tag.android', bg: '#047857' }, }; const hit = map[kind]; if (hit) return { label: t(hit.labelKey), bg: hit.bg }; return { label: kind || '?', bg: '#5c6b82' }; } function renderTagPills(tags, filterable) { if (!tags.length) return ''; return tags .map((t) => { const label = escapeHtml(t.label || t.id || ''); const bg = escapeHtml(t.bg || '#5c6b82'); const id = t.id ? String(t.id).trim() : ''; if (filterable && id) { return ( '' ); } return '' + label + ''; }) .join(''); } function buildTags(row, grouped) { const tags = []; const kind = crashKindTag(row.crash_type); tags.push(kind); if (!grouped && row.id) { const viewed = row.viewed || getViewedIds().has(Number(row.id)); const sinceVisit = Number(row.received_at_ms) > lastVisitMs(); if (!viewed && (lastVisitMs() === 0 || sinceVisit)) { tags.push(tagNew()); } } if (Array.isArray(row.tags)) { row.tags.forEach((t) => { if (!t || !t.id) return; const id = String(t.id).toLowerCase(); if (id === 'new' || id === 'java' || id === 'ndk' || id === 'android') return; tags.push({ id, label: t.label || t.id, bg: t.bg || '#5c6b82', }); }); } return renderTagPills(tags, !grouped); } function apiSortKey(key) { return SORT_API_MAP[key] || key; } function loadSavedSort(isGrouped) { try { const s = JSON.parse(lsGet(STORAGE.sort, '')); if (!s || !s.sort) { return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; } if (!!s.grouped !== !!isGrouped) { return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; } return { sort: s.sort, dir: s.dir || 'desc' }; } catch { return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; } } function getColWidths() { try { const w = JSON.parse(lsGet(STORAGE.colWidths, '{}')); return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) }; } catch { return { ...DEFAULT_COL_WIDTHS }; } } function saveColWidths(widths) { lsSet(STORAGE.colWidths, JSON.stringify(widths)); } function getOrderedColumnKeys() { const defaults = LIST_COLUMNS.map((c) => c.key); try { const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]')); if (Array.isArray(saved) && saved.length) { const out = saved.filter((k) => defaults.includes(k)); defaults.forEach((k) => { if (!out.includes(k)) out.push(k); }); return out; } } catch { /* ignore */ } return defaults; } function saveColumnOrder(keys) { lsSet(STORAGE.colOrder, JSON.stringify(keys)); } function getDisplayColumns(isGrouped) { if (isGrouped) return GROUP_COLUMNS; return getOrderedColumnKeys() .map((k) => LIST_COLUMNS.find((c) => c.key === k)) .filter(Boolean); } function applyColgroup(isGrouped) { const cg = document.getElementById('reports-colgroup'); if (!cg || isGrouped) { if (cg) cg.innerHTML = ''; return; } const widths = getColWidths(); const order = getOrderedColumnKeys(); let html = ''; order.forEach((k) => { html += ''; }); html += ''; cg.innerHTML = html; } function columnLabelByKey(key) { const c = LIST_COLUMNS.find((col) => col.key === key); if (c) return colLabel(c); const g = GROUP_COLUMNS.find((col) => col.key === key); if (g) return colLabel(g); if (key === 'tags') return t('col.tags'); return key; } function clearColumnDropMarkers(thead) { thead.querySelectorAll('.th-drop-target').forEach((el) => { el.classList.remove('th-drop-target'); }); thead.querySelectorAll('.th-dragging').forEach((el) => { el.classList.remove('th-dragging'); }); } function bindReportsTableLayout(table, thead, ctx) { if (!table || !thead || table.dataset.layoutBound === '1') return; table.dataset.layoutBound = '1'; thead.addEventListener('dragstart', (e) => { if (ctx.getGrouped()) return; if (e.target.closest('.col-resizer')) { e.preventDefault(); return; } const th = e.target.closest('th.th-draggable[data-col-key]'); if (!th) return; const sourceKey = th.getAttribute('data-col-key'); if (!sourceKey) return; table.dataset.dragColKey = sourceKey; th.classList.add('th-dragging'); const label = columnLabelByKey(sourceKey); const ghost = document.createElement('div'); ghost.className = 'col-drag-ghost'; ghost.textContent = label; ghost.setAttribute('aria-hidden', 'true'); document.body.appendChild(ghost); table._dragGhost = ghost; if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', sourceKey); try { e.dataTransfer.setDragImage(ghost, 16, 14); } catch { /* ignore */ } } }); thead.addEventListener('dragend', () => { clearColumnDropMarkers(thead); delete table.dataset.dragColKey; if (table._dragGhost) { table._dragGhost.remove(); table._dragGhost = null; } }); thead.addEventListener('dragenter', (e) => { if (ctx.getGrouped() || !table.dataset.dragColKey) return; e.preventDefault(); }); thead.addEventListener('dragover', (e) => { if (ctx.getGrouped() || !table.dataset.dragColKey) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; const th = e.target.closest('th[data-col-key]'); if (!th) return; const active = thead.querySelector('.th-drop-target'); if (active && active !== th) active.classList.remove('th-drop-target'); th.classList.add('th-drop-target'); }); thead.addEventListener('dragleave', (e) => { const th = e.target.closest('th[data-col-key]'); if (!th) return; const rel = e.relatedTarget; if (rel && th.contains(rel)) return; th.classList.remove('th-drop-target'); }); thead.addEventListener('drop', (e) => { if (ctx.getGrouped()) return; const th = e.target.closest('th[data-col-key]'); if (!th) return; e.preventDefault(); e.stopPropagation(); const sourceKey = table.dataset.dragColKey || (e.dataTransfer && e.dataTransfer.getData('text/plain')) || ''; const targetKey = th.getAttribute('data-col-key'); clearColumnDropMarkers(thead); delete table.dataset.dragColKey; if (table._dragGhost) { table._dragGhost.remove(); table._dragGhost = null; } if (!sourceKey || !targetKey || sourceKey === targetKey) return; const order = getOrderedColumnKeys(); const from = order.indexOf(sourceKey); const to = order.indexOf(targetKey); if (from < 0 || to < 0) return; order.splice(from, 1); order.splice(to, 0, sourceKey); saveColumnOrder(order); ctx.refreshLayout(); }); table.addEventListener('mousedown', (e) => { if (ctx.getGrouped()) return; const handle = e.target.closest('.col-resizer'); if (!handle) return; e.preventDefault(); e.stopPropagation(); const colKey = handle.getAttribute('data-resize-col'); const th = handle.closest('th'); if (!colKey || !th) return; const widths = getColWidths(); const startX = e.clientX; const startW = th.getBoundingClientRect().width; const onMove = (ev) => { const w = Math.max(48, Math.round(startW + (ev.clientX - startX))); widths[colKey] = w; th.style.width = w + 'px'; th.style.minWidth = w + 'px'; th.style.maxWidth = w + 'px'; const cg = document.getElementById('reports-colgroup'); const col = cg && cg.querySelector('col[data-col="' + colKey + '"]'); if (col) col.style.width = w + 'px'; else if (colKey === 'tags') { const tagCol = cg && cg.querySelector('col.col-tags'); if (tagCol) tagCol.style.width = w + 'px'; } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); saveColWidths(widths); applyColgroup(false); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } function parseListContext() { const p = new URLSearchParams(window.location.search); const filterTags = []; p.getAll('tag').forEach((t) => { const id = String(t).trim().toLowerCase(); if (id && !filterTags.includes(id)) filterTags.push(id); }); return { similarTo: Number(p.get('similar_to')) || 0, filterDevice: p.get('filter_device') || '', filterAbi: p.get('filter_abi') || '', filterOsSdk: Number(p.get('filter_os_sdk')) || 0, filterOsRelease: p.get('filter_os_release') || '', filterAppVersion: p.get('filter_app_version') || '', filterBuild: p.get('filter_build') || '', filterTags, tagMode: p.get('tag_mode') === 'or' ? 'or' : 'and', searchQuery: p.get('q') || '', }; } function listContextActive(ctx) { return ( ctx.similarTo > 0 || !!ctx.filterDevice || !!ctx.filterAbi || ctx.filterOsSdk > 0 || !!ctx.filterOsRelease || !!ctx.filterAppVersion || !!ctx.filterBuild || (ctx.filterTags && ctx.filterTags.length > 0) || !!ctx.searchQuery ); } function reportsUrlWithTagToggle(tagId, tagMode) { const p = new URLSearchParams(window.location.search); p.set('view', 'reports'); const tags = p.getAll('tag'); const idx = tags.indexOf(tagId); if (idx >= 0) { tags.splice(idx, 1); } else { tags.push(tagId); } p.delete('tag'); tags.forEach((t) => p.append('tag', t)); if (tags.length) { p.set('tag_mode', tagMode === 'or' ? 'or' : 'and'); } else { p.delete('tag_mode'); } p.delete('group'); p.delete('page'); return basePath() + '/?' + p.toString(); } function updateFilterBanner(ctx, bannerEl) { if (!bannerEl) return; const parts = []; if (ctx.similarTo > 0) { parts.push('Similar reports for #' + ctx.similarTo); } if (ctx.filterDevice) { parts.push('Device: “' + ctx.filterDevice + '”'); } if (ctx.filterAbi) { parts.push('ABI: ' + ctx.filterAbi); } if (ctx.filterOsSdk > 0) { parts.push('SDK ' + ctx.filterOsSdk); } if (ctx.filterOsRelease) { parts.push('Android ' + ctx.filterOsRelease); } if (ctx.filterAppVersion) { parts.push('App ' + ctx.filterAppVersion); } if (ctx.filterBuild) { parts.push('Build ' + ctx.filterBuild); } if (ctx.filterTags && ctx.filterTags.length) { const modeLabel = ctx.tagMode === 'or' ? 'OR' : 'AND'; parts.push('Tags (' + modeLabel + '): ' + ctx.filterTags.join(', ')); } if (ctx.searchQuery) { parts.push('Search: “' + ctx.searchQuery + '”'); } if (!parts.length) { bannerEl.hidden = true; bannerEl.textContent = ''; return; } bannerEl.hidden = false; bannerEl.innerHTML = parts.join(' · ') + ' — Clear filters'; } function navigateTo(href) { try { sessionStorage.setItem(STORAGE.skipStamp, '1'); } catch { /* ignore */ } window.location.href = href; } function bindTreeInteractions(root) { root.querySelectorAll('.report-tree-toggle').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); const id = btn.getAttribute('aria-controls'); const briefRow = id ? document.getElementById(id) : null; const row = btn.closest('.report-row'); if (!briefRow || !row) return; const open = briefRow.hidden; briefRow.hidden = !open; btn.setAttribute('aria-expanded', open ? 'true' : 'false'); row.classList.toggle('report-row--open', open); }); }); root.querySelectorAll('[data-href]').forEach((el) => { const href = el.getAttribute('data-href'); if (!href) return; const go = () => navigateTo(href); const onActivate = (e) => { if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return; go(); }; el.addEventListener('click', onActivate); el.addEventListener('keydown', (e) => { if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }); }); } function bindReportTagFilters(root, onTag) { if (!root || typeof onTag !== 'function') return; root.querySelectorAll('.report-tag--filter').forEach((btn) => { btn.addEventListener('click', (e) => { const tagId = btn.getAttribute('data-tag-id') || ''; if (tagId) onTag(tagId, e); }); }); } function initReportsApp() { const app = document.getElementById('reports-app'); if (!app) return; const thead = document.getElementById('reports-thead'); const tbody = document.getElementById('reports-tbody'); const pagination = document.getElementById('reports-pagination'); const statusEl = document.getElementById('reports-status'); const perPageSel = document.getElementById('per-page-select'); const table = document.getElementById('reports-tree'); const filterBanner = document.getElementById('reports-filter-banner'); const listContext = parseListContext(); let grouped = listContext.filterTags && listContext.filterTags.length > 0 ? false : listContextActive(listContext) ? false : app.getAttribute('data-grouped') === '1'; let page = 1; let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50; let listTotal = 0; const saved = loadSavedSort(grouped); let sort = saved.sort; let dir = saved.dir; let loading = false; let lastItems = []; function saveSortState() { lsSet( STORAGE.sort, JSON.stringify({ sort, dir, grouped: !!grouped }) ); } function sortActive(colKey) { if (sort === 'priority' && colKey === 'rating') return true; if (sort === colKey) return true; return apiSortKey(sort) === apiSortKey(colKey); } const layoutCtx = { getGrouped: () => grouped, refreshLayout: () => { applyColgroup(grouped); renderHead(); if (lastItems.length) renderRows(lastItems); }, }; function refreshTableChrome() { layoutCtx.refreshLayout(); } bindReportsTableLayout(table, thead, layoutCtx); updateFilterBanner(listContext, filterBanner); initTagFilterBar(listContext); if (listContextActive(listContext)) { app.setAttribute('data-grouped', '0'); app.querySelectorAll('.reports-mode-btn').forEach((b) => { b.classList.toggle('active', b.getAttribute('data-grouped') === '0'); b.disabled = true; b.title = 'Disabled while a filter is active'; }); } if (perPageSel) { perPageSel.value = String(perPage); perPageSel.addEventListener('change', () => { perPage = Number(perPageSel.value) || 50; lsSet(STORAGE.perPage, String(perPage)); page = 1; load(); }); } app.querySelectorAll('.reports-mode-btn').forEach((btn) => { btn.addEventListener('click', () => { grouped = btn.getAttribute('data-grouped') === '1'; app.setAttribute('data-grouped', grouped ? '1' : '0'); app.querySelectorAll('.reports-mode-btn').forEach((b) => { b.classList.toggle('active', b === btn); }); const next = loadSavedSort(grouped); sort = next.sort; dir = next.dir; page = 1; load(); }); }); const maybeStampVisit = () => { try { if (sessionStorage.getItem(STORAGE.skipStamp)) { sessionStorage.removeItem(STORAGE.skipStamp); return; } } catch { /* ignore */ } stampLastVisit(); }; window.addEventListener('pagehide', maybeStampVisit); function columns() { return getDisplayColumns(grouped); } function colspan() { return columns().length + 2; } function renderHead() { const cols = columns(); const widths = getColWidths(); let html = ''; cols.forEach((c) => { const active = sortActive(c.key); const arrow = active ? (dir === 'asc' ? '▲' : '▼') : ''; const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : ''; let thClass = 'sortable' + (active ? ' sortable--active' : ''); let thDragAttrs = ''; if (!grouped) { thClass += ' th-draggable'; thDragAttrs = ' draggable="true" data-col-key="' + escapeHtml(c.key) + '" title="' + escapeHtml(t('col.drag_reorder')) + '"'; } html += '' + (grouped ? '' : '') + '' + escapeHtml(colLabel(c)) + ' ' + (grouped ? '' : '') + ''; }); html += 'Tags'; thead.innerHTML = html; thead.querySelectorAll('.sortable').forEach((th) => { th.addEventListener('click', (e) => { if (e.target.closest('.col-resizer')) return; const key = th.getAttribute('data-sort'); if (!key) return; if (sort === key) { dir = dir === 'asc' ? 'desc' : 'asc'; } else { sort = key; dir = 'desc'; } saveSortState(); page = 1; load(); }); }); } function pageRangeEnd(targetPage, totalCount) { return Math.min(targetPage * perPage, totalCount); } function renderPagination(total) { listTotal = total; const pages = Math.max(1, Math.ceil(total / perPage)); if (page > pages) page = pages; const prev = page > 1 ? page - 1 : null; const next = page < pages ? page + 1 : null; let prevLabel = '← Previous'; let nextLabel = 'Next →'; if (prev) { const t = page - 1; const before = (t - 1) * perPage; const through = pageRangeEnd(t, total); prevLabel = '← Previous (' + before + '/' + through + ')'; } if (next) { const start = page * perPage + 1; const end = pageRangeEnd(page + 1, total); nextLabel = 'Next (' + start + '/' + end + ') →'; } let html = '
'; html += ''; html += 'Page ' + page + ' / ' + pages + ' · ' + total + ' reports'; html += ''; html += '
'; pagination.innerHTML = html; pagination.querySelectorAll('.page-btn').forEach((btn) => { btn.addEventListener('click', () => { const p = Number(btn.getAttribute('data-page')); if (!p) return; page = p; load(); }); }); } function renderRows(items) { const cols = columns(); if (!items.length) { tbody.innerHTML = '' + escapeHtml(t('reports.empty')) + ''; return; } let html = ''; items.forEach((row, i) => { const rowKey = grouped ? 'g' + i + '-' + page : 'r' + row.id; const briefId = 'brief-' + rowKey; const openUrl = !grouped && row.id ? basePath() + '/?view=report&id=' + row.id : ''; const navClass = openUrl ? ' report-row--nav' : ''; const hrefAttr = openUrl ? ' data-href="' + escapeHtml(openUrl) + '"' : ''; const tabAttr = openUrl ? ' tabindex="0" role="link"' : ''; html += ''; html += ''; cols.forEach((c) => { if (c.key === 'cnt') { html += '' + escapeHtml(row.cnt) + ''; } else { html += '' + formatCell(c, row) + ''; } }); const tagEditBtn = !grouped && row.id && canTagEdit() ? '' : ''; html += '
' + buildTags(row, grouped) + tagEditBtn + '
'; html += ''; const briefLines = row.brief || []; let panelOpen = '
'; if (openUrl) { panelOpen = '
'; } html += '' + panelOpen + '
'; briefLines.forEach((line) => { html += '

' + escapeHtml(line) + '

'; }); if (openUrl) { html += '

Click to open full report

'; } html += '
'; }); tbody.innerHTML = html; bindTreeInteractions(tbody); bindReportTagFilters(tbody, handleReportTagClick); } function applyReportTagFilter(tagId) { listContext.filterTags = [tagId]; listContext.tagMode = listContext.tagMode || 'and'; grouped = false; app.setAttribute('data-grouped', '0'); page = 1; updateFilterBanner(listContext, filterBanner); const p = new URLSearchParams(window.location.search); p.set('view', 'reports'); p.delete('tag'); p.append('tag', tagId); p.set('tag_mode', listContext.tagMode); p.delete('group'); p.delete('page'); history.replaceState(null, '', basePath() + '/?' + p.toString()); const modeWrap = document.getElementById('tag-mode-wrap'); if (modeWrap) modeWrap.hidden = false; const modeSel = document.getElementById('tag-mode-select'); if (modeSel) modeSel.value = listContext.tagMode; load(); } function handleReportTagClick(tagId, ev) { ev.stopPropagation(); ev.preventDefault(); if (grouped) { applyReportTagFilter(tagId); return; } const xhr = new XMLHttpRequest(); xhr.open( 'GET', basePath() + '/api/reports.php?page=1&per_page=2&tag=' + encodeURIComponent(tagId) + '&tag_mode=and&sort=received_at_ms&dir=desc&since_ms=0', true ); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { return; } if (!data.ok) return; const total = Number(data.total || 0); if (total === 1 && data.items && data.items[0] && data.items[0].id) { navigateTo(basePath() + '/?view=report&id=' + data.items[0].id); return; } applyReportTagFilter(tagId); }; xhr.send(); } function load() { if (loading) return; loading = true; statusEl.textContent = t('reports.loading'); let qs = 'page=' + page + '&per_page=' + perPage + '&sort=' + encodeURIComponent(apiSortKey(sort)) + '&dir=' + encodeURIComponent(dir) + '&since_ms=' + encodeURIComponent(String(lastVisitMs())); if (listContext.searchQuery) { qs += '&q=' + encodeURIComponent(listContext.searchQuery); } else if (listContext.similarTo > 0) { qs += '&similar_to=' + encodeURIComponent(String(listContext.similarTo)); } else { if (grouped) qs += '&group=1'; if (listContext.filterDevice) { qs += '&filter_device=' + encodeURIComponent(listContext.filterDevice); } if (listContext.filterAbi) { qs += '&filter_abi=' + encodeURIComponent(listContext.filterAbi); } if (listContext.filterOsSdk > 0) { qs += '&filter_os_sdk=' + encodeURIComponent(String(listContext.filterOsSdk)); } if (listContext.filterOsRelease) { qs += '&filter_os_release=' + encodeURIComponent(listContext.filterOsRelease); } if (listContext.filterAppVersion) { qs += '&filter_app_version=' + encodeURIComponent(listContext.filterAppVersion); } if (listContext.filterBuild) { qs += '&filter_build=' + encodeURIComponent(listContext.filterBuild); } if (listContext.filterTags && listContext.filterTags.length) { listContext.filterTags.forEach((tagId) => { qs += '&tag=' + encodeURIComponent(tagId); }); qs += '&tag_mode=' + encodeURIComponent(listContext.tagMode || 'and'); } } const xhr = new XMLHttpRequest(); xhr.open('GET', basePath() + '/api/reports.php?' + qs, true); xhr.onload = function () { loading = false; let data; try { data = JSON.parse(xhr.responseText); } catch { statusEl.textContent = 'Invalid response'; return; } if (!data.ok) { statusEl.textContent = data.error || 'Load failed'; return; } lastItems = data.items || []; refreshTableChrome(); renderPagination(Number(data.total) || 0); statusEl.textContent = 'Showing ' + (data.items?.length || 0) + ' of ' + (data.total || 0) + (grouped ? ' (grouped)' : ''); }; xhr.onerror = function () { loading = false; statusEl.textContent = 'Network error'; }; xhr.send(); } initTagEditButtons(tbody, load); const searchInput = document.getElementById('reports-search-input'); const searchSuggest = document.getElementById('reports-search-suggest'); const searchClear = document.getElementById('reports-search-clear'); let suggestTimer = null; function hideSuggest() { if (searchSuggest) { searchSuggest.hidden = true; searchSuggest.innerHTML = ''; } } function applySearchQuery(q, pushUrl) { listContext.similarTo = 0; listContext.filterDevice = ''; listContext.filterAbi = ''; listContext.filterOsSdk = 0; listContext.filterOsRelease = ''; listContext.filterAppVersion = ''; listContext.filterBuild = ''; listContext.searchQuery = (q || '').trim(); grouped = false; app.setAttribute('data-grouped', '0'); page = 1; updateFilterBanner(listContext, filterBanner); if (searchInput) searchInput.value = listContext.searchQuery; if (searchClear) searchClear.hidden = !listContext.searchQuery; if (pushUrl) { const url = basePath() + '/?view=reports' + (listContext.searchQuery ? '&q=' + encodeURIComponent(listContext.searchQuery) : ''); history.replaceState(null, '', url); } load(); } function fetchSuggest(q) { if (!searchSuggest || q.length < 2) { hideSuggest(); return; } const xhr = new XMLHttpRequest(); xhr.open( 'GET', basePath() + '/api/reports.php?suggest=1&q=' + encodeURIComponent(q) + '&per_page=10', true ); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { hideSuggest(); return; } if (!data.ok || !data.suggestions || !data.suggestions.length) { hideSuggest(); return; } searchSuggest.innerHTML = data.suggestions .map( (s) => '
  • ' ) .join(''); searchSuggest.hidden = false; }; xhr.send(); } if (searchInput) { if (listContext.searchQuery) { searchInput.value = listContext.searchQuery; if (searchClear) searchClear.hidden = false; } searchInput.addEventListener('input', () => { const q = searchInput.value; if (searchClear) searchClear.hidden = !q.trim(); hideSuggest(); clearTimeout(suggestTimer); const trimmed = q.trim(); if (trimmed.length < 2) return; suggestTimer = setTimeout(() => fetchSuggest(trimmed), 1000); }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(suggestTimer); hideSuggest(); applySearchQuery(searchInput.value.trim(), true); } if (e.key === 'Escape') { hideSuggest(); } }); } function selectSearchSuggestion(term) { if (!searchInput) return; const t = (term || '').trim(); if (!t) return; const raw = searchInput.value; const withoutTrailing = raw.replace(/\s+$/, ''); const lastSpace = withoutTrailing.lastIndexOf(' '); searchInput.value = lastSpace < 0 ? t : withoutTrailing.slice(0, lastSpace + 1) + t; if (searchClear) searchClear.hidden = !searchInput.value.trim(); searchInput.focus(); } if (searchSuggest) { searchSuggest.addEventListener('click', (e) => { const btn = e.target.closest('[data-suggest]'); if (!btn) return; const term = btn.getAttribute('data-suggest') || ''; selectSearchSuggestion(term); hideSuggest(); }); } if (searchClear) { searchClear.addEventListener('click', () => { if (searchInput) searchInput.value = ''; searchClear.hidden = true; hideSuggest(); applySearchQuery('', true); }); } applyColgroup(grouped); load(); } const TAG_QUICK_COLORS = [ '#5c6b82', '#3d8bfd', '#6d28d9', '#b45309', '#047857', '#dc2626', '#d97706', '#0891b2', '#be185d', '#4f46e5', ]; function canTagEdit() { return document.body.getAttribute('data-can-tag-edit') === '1'; } function slugTagId(label) { return String(label) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 32); } function renderTagPill(tag, removable) { let html = '' + escapeHtml(tag.label || tag.id); if (removable) { html += ''; } return html + ''; } function tagsApiUrl(entityType) { return basePath() + (entityType === 'ticket' ? '/api/ticket_tags.php' : '/api/report_tags.php'); } const TICKET_META_IDS = ['ticket', 'issue']; function isTicketMetaId(id) { return TICKET_META_IDS.indexOf(id) >= 0; } function stripTicketMetaTags(tags) { return (tags || []).filter((t) => !isTicketMetaId(t.id)); } function tagsJson(tags) { return JSON.stringify(tags || []); } function createTagEditor(cfg) { const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report'; const state = { tags: [...(cfg.initialTags || [])], workflow: [], presets: [], entityId: cfg.entityId ?? cfg.reportId ?? cfg.ticketId ?? 0, entityType, }; const els = cfg.elements; function renderList() { if (!els.list) return; if (!state.tags.length) { els.list.innerHTML = '
  • No custom tags yet.
  • '; return; } els.list.innerHTML = state.tags .map( (t, i) => '
  • ' + renderTagPill(t, true) + '
  • ' ) .join(''); els.list.querySelectorAll('.tag-remove').forEach((btn) => { btn.addEventListener('click', () => { const id = btn.getAttribute('data-remove-id'); state.tags = state.tags.filter((t) => t.id !== id); renderAll(); }); }); els.list.querySelectorAll('input[data-color-idx]').forEach((inp) => { inp.addEventListener('input', () => { const idx = Number(inp.getAttribute('data-color-idx')); if (state.tags[idx]) state.tags[idx].bg = inp.value; renderPreview(); }); }); } function renderPreview() { if (!els.preview) return; els.preview.innerHTML = state.tags.length ? state.tags.map((t) => renderTagPill(t, false)).join('') : 'No custom tags'; } function presetChipHtml(p) { return ( '' ); } function wirePresetChips(root) { if (!root) return; root.querySelectorAll('.tag-preset-chip').forEach((btn) => { btn.addEventListener('click', () => { const id = btn.getAttribute('data-preset-id'); const p = state.workflow.find((x) => x.id === id) || state.presets.find((x) => x.id === id); if (!p || state.tags.some((t) => t.id === p.id)) return; if (entityType === 'ticket' && isTicketMetaId(p.id)) { state.tags = stripTicketMetaTags(state.tags); } state.tags.push({ ...p }); renderAll(); }); }); } function renderPresets() { if (!els.presetBar || !els.presetChips) return; const wf = state.workflow || []; const hist = (state.presets || []).filter( (p) => !wf.some((w) => w.id === p.id) ); if (!wf.length && !hist.length) { els.presetBar.hidden = true; return; } els.presetBar.hidden = false; let html = ''; if (wf.length) { html += '
    ' + escapeHtml(t('tag.workflow')) + '
    ' + wf.map(presetChipHtml).join('') + '
    '; } if (hist.length) { html += '
    ' + escapeHtml(t('tag.suggestions')) + '
    ' + hist.map(presetChipHtml).join('') + '
    '; } els.presetChips.innerHTML = html; wirePresetChips(els.presetChips); } function renderAll() { renderList(); renderPreview(); } function setStatus(msg, ok) { if (!els.status) return; els.status.textContent = msg || ''; els.status.classList.toggle('tag-editor-status--err', ok === false); } function addFromForm(form) { const label = (form.label?.value || '').trim(); const bg = form.bg?.value || '#5c6b82'; if (!label) return; const id = slugTagId(label); if (!id) return; const reserved = ['new', 'java', 'ndk', 'android']; if (reserved.includes(id)) { setStatus('Reserved tag id: ' + id, false); return; } const existing = state.tags.findIndex((t) => t.id === id); const entry = { id, label, bg }; if (existing >= 0) state.tags[existing] = entry; else state.tags.push(entry); form.label.value = ''; renderAll(); setStatus('', true); } if (els.form) { els.form.addEventListener('submit', (e) => { e.preventDefault(); addFromForm(els.form); }); } if (els.saveBtn) { els.saveBtn.addEventListener('click', () => { setStatus(t('tag.saving'), true); const sentTags = state.tags.map((t) => ({ ...t })); const xhr = new XMLHttpRequest(); xhr.open('POST', tagsApiUrl(state.entityType), true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { setStatus('Invalid response', false); return; } if (!data.ok) { setStatus(data.hint || data.error || 'Save failed', false); return; } state.tags = data.tags || []; renderAll(); const adjusted = tagsJson(sentTags) !== tagsJson(state.tags); setStatus(adjusted ? 'Saved (type/status tags normalized)' : 'Saved', true); if (cfg.onSaved) cfg.onSaved(state.tags, state.entityId); }; xhr.onerror = function () { setStatus(t('reports.network_error'), false); }; xhr.send(JSON.stringify({ id: state.entityId, tags: state.tags })); }); } function loadFromApi() { const xhr = new XMLHttpRequest(); xhr.open('GET', tagsApiUrl(state.entityType) + '?id=' + state.entityId, true); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { return; } if (data.ok) { state.tags = data.tags || []; state.workflow = data.workflow || []; state.presets = data.presets || []; renderAll(); renderPresets(); } }; xhr.send(); } renderAll(); if (cfg.fetchPresets) loadFromApi(); return { setTags(tags) { state.tags = tags || []; renderAll(); }, getTags() { return state.tags; }, reload() { loadFromApi(); }, }; } let tagModalEditor = null; function openTagModal(entityId, entityType) { const modal = document.getElementById('tag-modal'); const editorEl = document.getElementById('tag-editor-modal'); if (!modal || !editorEl || !canTagEdit()) return; const et = entityType === 'ticket' ? 'ticket' : 'report'; editorEl.setAttribute('data-report-id', et === 'report' ? String(entityId) : '0'); editorEl.setAttribute('data-ticket-id', et === 'ticket' ? String(entityId) : '0'); editorEl.setAttribute('data-entity-type', et); modal.hidden = false; modal.setAttribute('aria-hidden', 'false'); tagModalEditor = createTagEditor({ entityId: Number(entityId), entityType: et, initialTags: [], fetchPresets: true, elements: { list: document.getElementById('tag-modal-list'), preview: document.getElementById('tag-modal-preview'), form: document.getElementById('tag-modal-add'), presetBar: document.getElementById('tag-modal-presets'), presetChips: document.getElementById('tag-modal-preset-chips'), saveBtn: document.getElementById('tag-modal-save'), status: document.getElementById('tag-modal-status'), }, onSaved() { if (typeof reportsReloadRef === 'function') reportsReloadRef(); if (typeof window.ticketsReloadRef === 'function') window.ticketsReloadRef(); }, }); } window.openTagModal = openTagModal; function closeTagModal() { const modal = document.getElementById('tag-modal'); if (!modal) return; modal.hidden = true; modal.setAttribute('aria-hidden', 'true'); tagModalEditor = null; } let reportsReloadRef = null; function initTagModal() { document.querySelectorAll('[data-tag-modal-close]').forEach((el) => { el.addEventListener('click', closeTagModal); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeTagModal(); }); } function initTagEditorReadOnly() { const preview = document.getElementById('tag-editor-preview'); const dataEl = document.getElementById('ticket-custom-tags-data') || document.getElementById('report-custom-tags-data'); if (!preview || !dataEl || canTagEdit()) return; try { const tags = JSON.parse(dataEl.textContent || '[]'); preview.innerHTML = tags.length ? tags.map((t) => renderTagPill(t, false)).join('') : 'No custom tags'; } catch { preview.innerHTML = ''; } } function initTagEditorDetail() { const root = document.getElementById('tag-editor'); if (!root) return; const entityType = root.getAttribute('data-entity-type') === 'ticket' ? 'ticket' : 'report'; const entityId = Number( root.getAttribute(entityType === 'ticket' ? 'data-ticket-id' : 'data-report-id') ); const canEdit = entityType === 'ticket' ? !!root.querySelector('#tag-editor-list') : canTagEdit(); if (!canEdit) { initTagEditorReadOnly(); return; } let initial = []; const dataEl = document.getElementById('ticket-custom-tags-data') || document.getElementById('report-custom-tags-data'); if (dataEl) { try { initial = JSON.parse(dataEl.textContent || '[]'); } catch { /* ignore */ } } createTagEditor({ entityId, entityType, initialTags: initial, fetchPresets: true, elements: { list: document.getElementById('tag-editor-list'), preview: document.getElementById('tag-editor-preview'), form: document.getElementById('tag-editor-add'), presetBar: document.getElementById('tag-preset-bar'), presetChips: document.getElementById('tag-preset-chips'), saveBtn: document.getElementById('tag-editor-save'), status: document.getElementById('tag-editor-status'), }, }); } function initTicketDetail() { const id = document.body.getAttribute('data-ticket-id'); if (!id) return; const tree = document.getElementById('detail-tree'); if (tree) bindDetailTree(tree); let assignees = []; const assigneeDataEl = document.getElementById('ticket-assignees-data'); if (assigneeDataEl) { try { assignees = JSON.parse(assigneeDataEl.textContent || '[]'); if (!Array.isArray(assignees)) assignees = []; } catch { assignees = []; } } const assigneeEditor = document.getElementById('assignee-editor'); const assigneePick = document.getElementById('assignee-user-pick'); const assigneeAddBtn = document.getElementById('assignee-add-btn'); function renderAssignees() { if (!assigneeEditor) return; if (!assignees.length) { assigneeEditor.innerHTML = ''; return; } assigneeEditor.innerHTML = assignees .map(function (a, idx) { const role = a.role ? ' (' + escapeHtml(a.role) + ')' : ''; return ( '' + escapeHtml(a.username) + role + ' ' ); }) .join(''); assigneeEditor.querySelectorAll('.assignee-remove').forEach(function (btn) { btn.addEventListener('click', function () { const i = Number(btn.getAttribute('data-idx')); assignees.splice(i, 1); renderAssignees(); }); }); } function loadUsersForAssignees() { if (!assigneePick) return; const xhr = new XMLHttpRequest(); xhr.open('GET', basePath() + '/api/users.php', true); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { return; } if (!data.ok || !Array.isArray(data.users)) return; data.users.forEach(function (u) { const opt = document.createElement('option'); opt.value = u.username; opt.textContent = u.username + (u.role ? ' (' + u.role + ')' : ''); opt.dataset.role = u.role || ''; assigneePick.appendChild(opt); }); }; xhr.send(); } if (assigneeEditor) { renderAssignees(); loadUsersForAssignees(); if (assigneeAddBtn && assigneePick) { assigneeAddBtn.addEventListener('click', function () { const username = assigneePick.value; if (!username) return; const role = assigneePick.selectedOptions[0]?.dataset.role || ''; if (assignees.some(function (a) { return a.username === username; })) return; assignees.push({ username: username, role: role }); renderAssignees(); }); } } const attachmentList = document.getElementById('ticket-attachment-list'); const linkForm = document.getElementById('ticket-link-form'); const fileForm = document.getElementById('ticket-file-form'); const linkStatus = document.getElementById('ticket-link-status'); const fileStatus = document.getElementById('ticket-file-status'); function renderAttachmentItem(att) { const li = document.createElement('li'); li.className = 'ticket-attachment ticket-attachment--' + (att.kind || 'file'); li.dataset.attachmentId = String(att.id); let main = ''; const label = escapeHtml(att.provider_label || 'File'); if (att.kind === 'link' && att.external_url) { main = '' + escapeHtml(att.title || att.external_url) + ''; } else if (att.download_url) { const size = att.size_bytes > 0 ? ' (' + (att.size_bytes / 1024).toFixed(1) + ' KB)' : ''; main = '' + escapeHtml(att.title || att.original_name || 'file') + '' + size; } else { main = '' + escapeHtml(att.title || '') + ''; } li.innerHTML = '' + label + ' ' + main + ' ' + escapeHtml(att.created_by || '') + '' + ' '; li.querySelector('.attachment-remove').addEventListener('click', function () { if (!confirm('Remove attachment?')) return; const xhr = new XMLHttpRequest(); xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + att.id, true); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { return; } if (data.ok) li.remove(); }; xhr.send(); }); return li; } if (linkForm) { linkForm.addEventListener('submit', function (e) { e.preventDefault(); const fd = new FormData(linkForm); const url = String(fd.get('url') || '').trim(); if (!url) return; if (linkStatus) linkStatus.textContent = 'Saving…'; const xhr = new XMLHttpRequest(); xhr.open('POST', basePath() + '/api/ticket_attachments.php', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { if (linkStatus) linkStatus.textContent = 'Invalid response'; return; } if (!data.ok) { if (linkStatus) linkStatus.textContent = data.error || 'Failed'; return; } if (attachmentList && data.attachment) { attachmentList.appendChild(renderAttachmentItem(data.attachment)); } linkForm.reset(); if (linkStatus) linkStatus.textContent = 'Attached'; }; xhr.onerror = function () { if (linkStatus) linkStatus.textContent = t('reports.network_error'); }; xhr.send( JSON.stringify({ ticket_id: Number(id), title: fd.get('title'), url: url, }) ); }); } if (fileForm) { fileForm.addEventListener('submit', function (e) { e.preventDefault(); const fd = new FormData(fileForm); fd.append('ticket_id', id); if (fileStatus) fileStatus.textContent = 'Uploading…'; const xhr = new XMLHttpRequest(); xhr.open('POST', basePath() + '/api/ticket_attachments.php', true); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { if (fileStatus) fileStatus.textContent = 'Invalid response'; return; } if (!data.ok) { if (fileStatus) fileStatus.textContent = data.error || 'Failed'; return; } if (attachmentList && data.attachment) { attachmentList.appendChild(renderAttachmentItem(data.attachment)); } fileForm.reset(); if (fileStatus) fileStatus.textContent = 'Uploaded'; }; xhr.onerror = function () { if (fileStatus) fileStatus.textContent = t('reports.network_error'); }; xhr.send(fd); }); } if (attachmentList) { attachmentList.querySelectorAll('.attachment-remove').forEach(function (btn) { btn.addEventListener('click', function () { const attId = btn.getAttribute('data-id'); if (!confirm('Remove attachment?')) return; const xhr = new XMLHttpRequest(); xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + attId, true); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { return; } if (data.ok) btn.closest('.ticket-attachment')?.remove(); }; xhr.send(); }); }); } const commentForm = document.getElementById('ticket-comment-form'); const commentStatus = document.getElementById('ticket-comment-status'); const commentList = document.getElementById('ticket-comment-list'); if (commentForm) { commentForm.addEventListener('submit', function (e) { e.preventDefault(); const fd = new FormData(commentForm); const bodyText = String(fd.get('body') || '').trim(); if (!bodyText) return; if (commentStatus) commentStatus.textContent = 'Posting…'; const xhr = new XMLHttpRequest(); xhr.open('POST', basePath() + '/api/ticket_comments.php', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = function () { let data; try { data = JSON.parse(xhr.responseText); } catch { if (commentStatus) commentStatus.textContent = 'Invalid response'; return; } if (!data.ok) { if (commentStatus) commentStatus.textContent = data.error || 'Failed'; return; } const c = data.comment; if (commentList && c) { const li = document.createElement('li'); li.className = 'ticket-comment'; const when = c.created_at_ms ? new Date(Number(c.created_at_ms)).toISOString().replace('T', ' ').slice(0, 19) : ''; li.innerHTML = '
    ' + escapeHtml(c.author_username) + ' ' + escapeHtml(when) + '
    ';
                li.querySelector('.ticket-comment-body').textContent = c.body;
                commentList.appendChild(li);
              }
              commentForm.reset();
              if (commentStatus) commentStatus.textContent = 'Posted';
            };
            xhr.onerror = function () {
              if (commentStatus) commentStatus.textContent = t('reports.network_error');
            };
            xhr.send(JSON.stringify({ ticket_id: Number(id), body: bodyText }));
          });
        }
    
        const form = document.getElementById('ticket-edit-form');
        if (!form) return;
        const statusEl = document.getElementById('ticket-save-status');
        const ratingSel = form.querySelector('select[name="rating"]');
        const starsPreview = form.querySelector('.ticket-stars-preview .star-rating');
        if (ratingSel && starsPreview) {
          ratingSel.addEventListener('change', () => {
            const n = Math.max(0, Math.min(5, Number(ratingSel.value) || 0));
            let html = '';
            for (let i = 1; i <= 5; i++) {
              html += '';
            }
            starsPreview.innerHTML = html;
          });
        }
        form.addEventListener('submit', (e) => {
          e.preventDefault();
          if (statusEl) statusEl.textContent = 'Saving…';
          const fd = new FormData(form);
          const body = {
            id: Number(form.getAttribute('data-ticket-id')),
            title: fd.get('title'),
            brief: fd.get('brief'),
            body: fd.get('body'),
            owner_username: fd.get('owner_username'),
            verified_by_username: fd.get('verified_by_username'),
            rating: Number(fd.get('rating')),
            workflow_state: fd.get('workflow_state'),
            assignees: assignees,
          };
          const xhr = new XMLHttpRequest();
          xhr.open('PUT', basePath() + '/api/ticket_update.php', true);
          xhr.setRequestHeader('Content-Type', 'application/json');
          xhr.onload = function () {
            let data;
            try {
              data = JSON.parse(xhr.responseText);
            } catch {
              if (statusEl) statusEl.textContent = 'Invalid response';
              return;
            }
            if (!data.ok) {
              if (statusEl) statusEl.textContent = data.error || 'Save failed';
              return;
            }
            if (statusEl) statusEl.textContent = 'Saved';
          };
          xhr.onerror = function () {
            if (statusEl) statusEl.textContent = t('reports.network_error');
          };
          xhr.send(JSON.stringify(body));
        });
      }
    
      function initTagFilterBar(listContext) {
        const chipsEl = document.getElementById('tag-filter-chips');
        const modeWrap = document.getElementById('tag-mode-wrap');
        const modeSel = document.getElementById('tag-mode-select');
        if (!chipsEl) return;
    
        function renderFilterChips(workflow) {
          const active = listContext.filterTags || [];
          chipsEl.innerHTML = (workflow || [])
            .map((tag) => {
              const on = active.includes(tag.id);
              return (
                '' +
                escapeHtml(tag.label) +
                ''
              );
            })
            .join('');
        }
    
        if (modeSel) {
          modeSel.value = listContext.tagMode === 'or' ? 'or' : 'and';
          modeSel.addEventListener('change', () => {
            if (!(listContext.filterTags && listContext.filterTags.length)) return;
            const p = new URLSearchParams(window.location.search);
            p.set('view', 'reports');
            p.set('tag_mode', modeSel.value === 'or' ? 'or' : 'and');
            p.delete('page');
            window.location.href = basePath() + '/?' + p.toString();
          });
        }
    
        const xhr = new XMLHttpRequest();
        xhr.open('GET', basePath() + '/api/tag_catalog.php', true);
        xhr.onload = function () {
          let data;
          try {
            data = JSON.parse(xhr.responseText);
          } catch {
            return;
          }
          if (!data.ok) return;
          renderFilterChips(data.workflow || []);
          if (modeWrap) {
            modeWrap.hidden = !(listContext.filterTags && listContext.filterTags.length);
          }
        };
        xhr.send();
      }
    
      function initTagEditButtons(tbody, reloadFn) {
        reportsReloadRef = reloadFn;
        if (!tbody) return;
        tbody.addEventListener('click', (e) => {
          const btn = e.target.closest('.tag-edit-btn');
          if (!btn) return;
          e.stopPropagation();
          e.preventDefault();
          openTagModal(Number(btn.getAttribute('data-report-id')), 'report');
        });
      }
    
      function boot() {
        initTheme();
        initNav();
        initTagModal();
        initReportDetail();
        initTicketDetail();
        initTagEditorDetail();
        initReportsApp();
      }
    
      function onReady(fn) {
        const run = () => {
          if (window.CrashI18n && window.CrashI18n.ready) {
            window.CrashI18n.ready.then(fn);
          } else {
            fn();
          }
        };
        if (document.readyState === 'loading') {
          document.addEventListener('DOMContentLoaded', run);
        } else {
          run();
        }
      }
    
      onReady(boot);
    })();