document.addEventListener('DOMContentLoaded', () => { const config = window.tvChannelApp || {}; const state = { filters: { limit: 50, page: 1 }, advancedFilters: [], sort: { field: 'upper_ch_ref', direction: 'ASC' }, selectedIds: new Set(), currentRows: [], currentDetail: null, options: {}, lastListPayload: null, auditFilters: { page: 1, limit: 20 }, analyticsLoaded: false, auditLoaded: false, historyLoaded: false, currentHistoryId: null, }; const elements = { filtersForm: document.getElementById('filtersForm'), resetFiltersBtn: document.getElementById('resetFiltersBtn'), channelsTableBody: document.getElementById('channelsTableBody'), resultCounter: document.getElementById('resultCounter'), pageMeta: document.getElementById('pageMeta'), prevPageBtn: document.getElementById('prevPageBtn'), nextPageBtn: document.getElementById('nextPageBtn'), topSearchForm: document.getElementById('topSearchForm'), topSearchInput: document.getElementById('topSearchInput'), clearSearchBtn: document.getElementById('clearSearchBtn'), advancedFiltersBtn: document.getElementById('advancedFiltersBtn'), advancedFilterCount: document.getElementById('advancedFilterCount'), advancedFiltersContainer: document.getElementById('advancedFiltersContainer'), addAdvancedFilterBtn: document.getElementById('addAdvancedFilterBtn'), clearAdvancedFiltersBtn: document.getElementById('clearAdvancedFiltersBtn'), applyAdvancedFiltersBtn: document.getElementById('applyAdvancedFiltersBtn'), selectPageBtn: document.getElementById('selectPageBtn'), saveCurrentViewBtn: document.getElementById('saveCurrentViewBtn'), selectAllPageCheckbox: document.getElementById('selectAllPageCheckbox'), bulkEditBtn: document.getElementById('bulkEditBtn'), selectionBar: document.getElementById('selectionBar'), selectionCount: document.getElementById('selectionCount'), selectionBulkBtn: document.getElementById('selectionBulkBtn'), clearSelectionBtn: document.getElementById('clearSelectionBtn'), activeFilterChips: document.getElementById('activeFilterChips'), detailPanel: document.getElementById('detailPanel'), detailForm: document.getElementById('detailForm'), detailLoadingState: document.getElementById('detailLoadingState'), detailChannelId: document.getElementById('detailChannelId'), detailEditableSection: document.getElementById('detailEditableSection'), editableFieldsContainer: document.getElementById('editableFieldsContainer'), detailLastChangeNotice: document.getElementById('detailLastChangeNotice'), lockedFieldsContainer: document.getElementById('lockedFieldsContainer'), bulkEditForm: document.getElementById('bulkEditForm'), bulkEditFieldsContainer: document.getElementById('bulkEditFieldsContainer'), bulkSelectionSummary: document.getElementById('bulkSelectionSummary'), analyticsOverview: document.getElementById('analyticsOverview'), analyticsBreakdowns: document.getElementById('analyticsBreakdowns'), historyLead: document.getElementById('historyLead'), historySummary: document.getElementById('historySummary'), historyTimeline: document.getElementById('historyTimeline'), refreshHistoryBtn: document.getElementById('refreshHistoryBtn'), auditUserFilter: document.getElementById('auditUserFilter'), auditFieldFilter: document.getElementById('auditFieldFilter'), auditDateFrom: document.getElementById('auditDateFrom'), auditDateTo: document.getElementById('auditDateTo'), applyAuditFiltersBtn: document.getElementById('applyAuditFiltersBtn'), auditTableBody: document.getElementById('auditTableBody'), toastContainer: document.getElementById('toastContainer'), }; const detailOffcanvas = elements.detailPanel ? bootstrap.Offcanvas.getOrCreateInstance(elements.detailPanel) : null; const bulkModal = document.getElementById('bulkEditModal') ? bootstrap.Modal.getOrCreateInstance(document.getElementById('bulkEditModal')) : null; const advancedFilterModal = document.getElementById('advancedFilterModal') ? bootstrap.Modal.getOrCreateInstance(document.getElementById('advancedFilterModal')) : null; const labels = { upper_ch_ref: 'Upper channel ref', country_iso: 'Country ISO', sat_ref: 'Satellite ref', genre: 'Genre', type: 'Type', groupe: 'Group', region: 'Region', langue: 'Language', resolution: 'Resolution', active: 'Active', sat_in: 'Sat in', sat_out: 'Sat out', sat_update: 'Sat update', country_client: 'Country client', sat_tpinfo: 'Transponder', idtype: 'Id type', six_id: 'six_id', sid: 'sid', onid: 'onid', tid: 'tid', sat_ch_client_upper: 'Client channel', sat_client: 'Client bouquet', counting: 'Counting', lastview: 'Last view', manually_edited: 'Manual edit', manually_edited_at: 'Manual edit date' }; const editableFields = config.detailFields?.editable || []; const lockedFields = config.detailFields?.locked || []; const selectOptionsFields = ['sat_ref', 'country_iso', 'genre', 'resolution', 'langue', 'region', 'groupe']; const filterSelectFields = ['sat_ref', 'country_iso', 'country_client', 'genre', 'resolution', 'langue', 'region', 'groupe', 'sat_tpinfo', 'sat_ch_client_upper', 'sat_client']; const advancedFieldOptions = [ { value: 'upper_ch_ref', label: 'Channel ref' }, { value: 'country_iso', label: 'Country ISO' }, { value: 'country_client', label: 'Country client' }, { value: 'sat_ref', label: 'Satellite ref' }, { value: 'sat_tpinfo', label: 'Frequency / Transponder' }, { value: 'genre', label: 'Genre' }, { value: 'type', label: 'Type' }, { value: 'resolution', label: 'Resolution' }, { value: 'langue', label: 'Language' }, { value: 'region', label: 'Region' }, { value: 'groupe', label: 'Group' }, { value: 'active', label: 'Active' }, { value: 'idtype', label: 'Id type' }, { value: 'six_id', label: 'six_id' }, { value: 'sid', label: 'sid' }, { value: 'onid', label: 'onid' }, { value: 'tid', label: 'tid' }, { value: 'sat_in', label: 'Sat in' }, { value: 'sat_out', label: 'Sat out' }, { value: 'sat_update', label: 'Sat update' }, { value: 'sat_ch_client_upper', label: 'Client channel' }, { value: 'sat_client', label: 'Client bouquet' }, { value: 'counting', label: 'Counting' }, { value: 'lastview', label: 'Last view' }, { value: 'manually_edited', label: 'Manual edit' }, { value: 'manually_edited_at', label: 'Manual edit date' } ]; const advancedOperatorLabels = { contains: 'contains', equals: 'equals', not_contains: "doesn't contain", not_equals: 'is not equal to', starts_with: 'starts with', ends_with: 'ends with', is_empty: 'is empty', is_not_empty: 'is not empty' }; const exportFields = [ 'upper_ch_ref', 'country_iso', 'country_client', 'sat_ref', 'sat_tpinfo', 'genre', 'type', 'resolution', 'langue', 'region', 'groupe', 'active', 'idtype', 'six_id', 'sid', 'onid', 'tid', 'sat_in', 'sat_out', 'sat_update', 'sat_ch_client_upper', 'sat_client', 'counting', 'lastview', 'manually_edited_at' ]; const formatValue = (value) => { if (value === null || value === undefined || value === '') return '—'; return String(value); }; const escapeHtml = (value) => String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); const notify = (message, tone = 'dark') => { const wrapper = document.createElement('div'); wrapper.className = 'toast align-items-center text-bg-light border-0'; wrapper.setAttribute('role', 'alert'); wrapper.setAttribute('aria-live', 'assertive'); wrapper.setAttribute('aria-atomic', 'true'); wrapper.innerHTML = `
${escapeHtml(message)}
`; elements.toastContainer.appendChild(wrapper); const toast = bootstrap.Toast.getOrCreateInstance(wrapper, { delay: 2600 }); wrapper.addEventListener('hidden.bs.toast', () => wrapper.remove()); toast.show(); }; const buildQueryString = (params) => { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value === undefined || value === null || value === '') return; query.set(key, value); }); return query.toString(); }; const advancedFilterNeedsValue = (operator) => !['is_empty', 'is_not_empty'].includes(operator); const advancedFieldLabel = (field) => advancedFieldOptions.find((item) => item.value === field)?.label || labels[field] || field; const describeAdvancedFilter = (rule) => { const operatorLabel = advancedOperatorLabels[rule.operator] || rule.operator; const suffix = advancedFilterNeedsValue(rule.operator) ? ` ${rule.value}` : ''; return `${advancedFieldLabel(rule.field)} ${operatorLabel}${suffix}`; }; const syncAdvancedFilterBadge = () => { const count = state.advancedFilters.length; if (elements.advancedFilterCount) { elements.advancedFilterCount.textContent = String(count); elements.advancedFilterCount.classList.toggle('d-none', count === 0); } if (elements.advancedFiltersBtn) { elements.advancedFiltersBtn.classList.toggle('btn-dark', count > 0); elements.advancedFiltersBtn.classList.toggle('btn-outline-secondary', count === 0); } }; const renderAdvancedFilters = (rules = state.advancedFilters) => { if (!elements.advancedFiltersContainer) return; if (!rules.length) { elements.advancedFiltersContainer.innerHTML = `
No advanced rules yet.
Example: sid contains 10 or Frequency / Transponder equals 11001.
`; return; } const fieldOptions = advancedFieldOptions .map((item) => ``) .join(''); const operatorOptions = Object.entries(advancedOperatorLabels) .map(([value, label]) => ``) .join(''); elements.advancedFiltersContainer.innerHTML = rules.map((rule, index) => `
`).join(''); [...elements.advancedFiltersContainer.querySelectorAll('.advanced-filter-row')].forEach((row, index) => { const rule = rules[index] || {}; const fieldSelect = row.querySelector('[data-advanced-field]'); const operatorSelect = row.querySelector('[data-advanced-operator]'); const valueInput = row.querySelector('[data-advanced-value]'); if (fieldSelect) fieldSelect.value = rule.field || 'sid'; if (operatorSelect) operatorSelect.value = rule.operator || 'contains'; if (valueInput) { valueInput.value = rule.value || ''; valueInput.disabled = !advancedFilterNeedsValue(operatorSelect?.value || 'contains'); } }); }; const collectAdvancedFilters = () => { const rows = [...(elements.advancedFiltersContainer?.querySelectorAll('.advanced-filter-row') || [])]; const rules = []; for (const row of rows) { const field = row.querySelector('[data-advanced-field]')?.value?.trim() || ''; const operator = row.querySelector('[data-advanced-operator]')?.value?.trim() || 'contains'; const value = row.querySelector('[data-advanced-value]')?.value?.trim() || ''; if (!field) continue; if (advancedFilterNeedsValue(operator) && value === '') { notify('Complete every advanced rule or remove the empty one.', 'danger'); return null; } rules.push({ field, operator, value }); } return rules; }; const syncFilterForm = () => { const searchValue = state.filters.search || ''; if (elements.topSearchInput) elements.topSearchInput.value = searchValue; if (elements.filtersForm) { elements.filtersForm.reset(); Object.entries(state.filters).forEach(([key, value]) => { const input = elements.filtersForm.elements.namedItem(key); if (input) input.value = value; }); } syncAdvancedFilterBadge(); }; const renderFilterOptions = (options) => { state.options = options || {}; filterSelectFields.forEach((field) => { const select = elements.filtersForm?.elements.namedItem(field); if (!(select instanceof HTMLSelectElement)) return; const currentValue = state.filters[field] || ''; const list = state.options[field] || []; const defaultLabel = select.dataset.defaultOption || select.options[0]?.textContent || 'All'; select.innerHTML = `` + list.map(item => ``).join(''); select.value = currentValue; }); }; const renderFilterChips = () => { const ignored = new Set(['page', 'limit']); const basicChips = Object.entries(state.filters) .filter(([key, value]) => !ignored.has(key) && value !== undefined && value !== null && value !== '') .map(([key, value]) => { const label = key === 'search' ? 'Search' : (labels[key] || key); return `${escapeHtml(label)}: ${escapeHtml(value)}`; }); const advancedChips = state.advancedFilters .map((rule) => `Rule: ${escapeHtml(describeAdvancedFilter(rule))}`); elements.activeFilterChips.innerHTML = [...basicChips, ...advancedChips].join(''); }; const updateSelectionUI = () => { const count = state.selectedIds.size; elements.selectionCount.textContent = String(count); elements.selectionBar.classList.toggle('d-none', count === 0); elements.bulkEditBtn.disabled = count === 0; elements.selectionBulkBtn.disabled = count === 0; elements.bulkEditBtn.textContent = `Bulk edit (${count})`; elements.bulkSelectionSummary.textContent = String(count); const currentPageIds = state.currentRows.map(row => String(row.id)); const allSelected = currentPageIds.length > 0 && currentPageIds.every(id => state.selectedIds.has(id)); elements.selectAllPageCheckbox.checked = allSelected; }; const csvCell = (value) => { const normalized = value === null || value === undefined ? '' : String(value); return `"${normalized.replaceAll('"', '""')}"`; }; const timestampForFilename = () => { const now = new Date(); const pad = (part) => String(part).padStart(2, '0'); return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`; }; const updateSaveViewButton = () => { if (!elements.saveCurrentViewBtn) return; const count = state.currentRows.length; elements.saveCurrentViewBtn.disabled = count === 0; elements.saveCurrentViewBtn.textContent = count > 0 ? `Save visible page (${count})` : 'Save visible page'; }; const exportCurrentView = () => { if (!state.currentRows.length) { notify('There is no visible row to save on this page yet.', 'danger'); return; } const headers = exportFields.map((field) => labels[field] || field); const rows = state.currentRows.map((row) => exportFields.map((field) => csvCell(row[field]))); const csvLines = [headers.map(csvCell).join(','), ...rows.map((cells) => cells.join(','))]; const csv = `${csvLines.join('\n')}`; const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const downloadUrl = URL.createObjectURL(blob); const link = document.createElement('a'); const page = Number(state.filters.page || 1); link.href = downloadUrl; link.download = `channels-visible-page-${page}-${timestampForFilename()}.csv`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(downloadUrl); notify(`Saved ${state.currentRows.length} visible row(s) from page ${page} as CSV.`); }; const rowClasses = (row) => { const classes = ['channel-row']; if (Number(row.manually_edited) === 1) classes.push('row-manual'); if (state.selectedIds.has(String(row.id))) classes.push('row-selected'); if (row.last_changed_field) classes.push('row-has-last-change'); return classes.join(' '); }; const cellClasses = (row, field, extra = '') => { const classes = []; if (extra) classes.push(extra); if (row.last_changed_field && row.last_changed_field === field) classes.push('cell-last-change'); return classes.join(' '); }; const renderChannels = (payload) => { const { data, pagination } = payload; state.lastListPayload = payload; state.currentRows = data; renderFilterOptions(payload.options || {}); renderFilterChips(); if (!data.length) { elements.channelsTableBody.innerHTML = 'No channels match the current filters.'; } else { elements.channelsTableBody.innerHTML = data.map(row => ` ${escapeHtml(formatValue(row.upper_ch_ref))} ${escapeHtml(formatValue(row.country_iso))} ${escapeHtml(formatValue(row.country_client))} ${escapeHtml(formatValue(row.sat_ref))} ${escapeHtml(formatValue(row.sat_tpinfo))} ${escapeHtml(formatValue(row.genre))} ${escapeHtml(formatValue(row.type))} ${escapeHtml(formatValue(row.resolution))} ${escapeHtml(formatValue(row.langue))} ${escapeHtml(formatValue(row.region))} ${escapeHtml(formatValue(row.groupe))} ${escapeHtml(formatValue(row.active))} ${escapeHtml(formatValue(row.idtype))} ${escapeHtml(formatValue(row.six_id))} ${escapeHtml(formatValue(row.sid))} ${escapeHtml(formatValue(row.onid))} ${escapeHtml(formatValue(row.tid))} ${escapeHtml(formatValue(row.sat_in))} ${escapeHtml(formatValue(row.sat_out))} ${escapeHtml(formatValue(row.sat_update))} ${escapeHtml(formatValue(row.sat_ch_client_upper))} ${escapeHtml(formatValue(row.sat_client))} ${escapeHtml(formatValue(row.counting))} ${escapeHtml(formatValue(row.lastview))} ${escapeHtml(formatValue(row.manually_edited_at))} `).join(''); } const start = pagination.total === 0 ? 0 : ((pagination.page - 1) * pagination.limit) + 1; const end = Math.min(pagination.total, pagination.page * pagination.limit); elements.resultCounter.textContent = `${pagination.total} result(s) · showing ${start}-${end}`; elements.pageMeta.textContent = `Page ${pagination.page} of ${pagination.pages}`; elements.prevPageBtn.disabled = pagination.page <= 1; elements.nextPageBtn.disabled = pagination.page >= pagination.pages; updateSelectionUI(); updateSaveViewButton(); }; const fetchChannels = async () => { elements.resultCounter.textContent = 'Loading channels…'; const query = buildQueryString({ ...state.filters, sort: state.sort.field, direction: state.sort.direction, advanced_filters: state.advancedFilters.length ? JSON.stringify(state.advancedFilters) : '' }); const response = await fetch(`${config.apiBase}?${query}`, { headers: { 'Accept': 'application/json' } }); const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Unable to fetch channels.'); renderChannels(result.data); }; const renderDetailFieldHighlights = (transition) => { const lastChangedField = transition?.last_changed_field || null; elements.editableFieldsContainer.querySelectorAll('[data-editable-field]').forEach((fieldNode) => { fieldNode.classList.remove('is-last-changed'); }); if (!elements.detailLastChangeNotice) { return; } if (!lastChangedField) { elements.detailLastChangeNotice.classList.add('d-none'); elements.detailLastChangeNotice.innerHTML = ''; return; } const fieldNode = elements.editableFieldsContainer.querySelector(`[data-editable-field="${lastChangedField}"]`); if (fieldNode) fieldNode.classList.add('is-last-changed'); elements.detailLastChangeNotice.classList.remove('d-none'); elements.detailLastChangeNotice.innerHTML = `
Last edited field
Only the newest changed field is highlighted.
${escapeHtml(labels[lastChangedField] || lastChangedField)}
`; }; const renderDetailForm = (channel) => { state.currentDetail = channel; elements.detailChannelId.value = channel.id; elements.detailLoadingState.classList.add('d-none'); elements.detailEditableSection.classList.remove('d-none'); renderDetailFieldHighlights(null); const buildInput = (field, value) => { const label = labels[field] || field; if (field === 'active') { return `
`; } if (field === 'type') { return `
`; } if (selectOptionsFields.includes(field)) { const opts = state.options[field] || []; return `
`; } if (['sat_in', 'sat_out', 'sat_update'].includes(field)) { return `
`; } return `
`; }; elements.editableFieldsContainer.innerHTML = editableFields.map(field => buildInput(field, channel[field])).join(''); elements.lockedFieldsContainer.innerHTML = lockedFields.map(field => `
${escapeHtml(labels[field] || field)}
🔒
${escapeHtml(formatValue(channel[field]))}
`).join(''); }; const fetchDetail = async (id) => { elements.detailLoadingState.textContent = 'Loading channel detail…'; elements.detailLoadingState.classList.remove('d-none'); elements.detailEditableSection.classList.add('d-none'); detailOffcanvas.show(); const response = await fetch(`${config.apiBase}?id=${encodeURIComponent(id)}`); const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Unable to load detail.'); state.options = { ...state.options, ...(result.meta?.options || {}) }; renderDetailForm(result.data); state.currentHistoryId = Number(result.data.id); state.historyLoaded = false; try { await fetchHistory(result.data.id, { silent: true }); } catch (error) { renderHistoryEmpty(error.message); } }; const payloadFromForm = (form, fields) => { const payload = {}; fields.forEach(field => { const input = form.elements.namedItem(field); if (!input) return; payload[field] = input.value; }); return payload; }; const renderBulkForm = () => { elements.bulkEditFieldsContainer.innerHTML = editableFields.map(field => { const label = labels[field] || field; let control = ''; if (field === 'active') { control = ` `; } else if (field === 'type') { control = ` `; } else if (selectOptionsFields.includes(field)) { const opts = state.options[field] || []; control = ` `; } else if (['sat_in', 'sat_out', 'sat_update'].includes(field)) { control = ``; } else { control = ``; } return `
${control}
`; }).join(''); }; const fetchAnalytics = async () => { const response = await fetch(`${config.apiBase}?stats=1`); const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Unable to load analytics.'); const stats = result.data; const overview = [ ['Total channels', stats.overview.total_channels], ['Active', stats.overview.active_channels], ['Manually edited', stats.overview.manually_edited_channels], ['Total hits', Number(stats.overview.total_hits).toLocaleString()], ]; elements.analyticsOverview.innerHTML = overview.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join(''); elements.analyticsBreakdowns.innerHTML = Object.entries(stats.breakdowns).map(([dimension, rows]) => { const max = Math.max(...rows.map(item => Number(item.total)), 1); const bars = rows.map(item => `
${escapeHtml(item.label)}${escapeHtml(item.total)}
`).join(''); return `

${escapeHtml(dimension.replaceAll('_', ' '))}

Top distribution
${bars}
`; }).join(''); }; const renderHistoryEmpty = (message) => { elements.historyLead.textContent = message; elements.historySummary.innerHTML = '
No channel selected yet.
'; elements.historyTimeline.innerHTML = `
${escapeHtml(message)}
`; renderDetailFieldHighlights(null); }; const renderHistory = (payload) => { const versions = payload.versions || []; if (!versions.length) { renderHistoryEmpty('No history entries were found for this channel.'); renderDetailFieldHighlights(null); return; } const latestTransition = payload.latest_transition || {}; const lastChangedField = latestTransition.last_changed_field || null; const currentVersion = versions.find(version => Number(version.is_current) === 1 || version.is_current === true) || versions[versions.length - 1]; const firstVersion = versions[0]; elements.historyLead.textContent = `Showing ${versions.length} version(s) for ${currentVersion.upper_ch_ref || 'selected channel'} (current row #${currentVersion.id}).`; const summaryCards = [ ['Selected row', `#${payload.selected_id}`], ['First version', firstVersion.date_in || `#${firstVersion.id}`], ['Latest version', `#${payload.latest_id}`], ['Last edited field', lastChangedField ? (labels[lastChangedField] || lastChangedField) : '—'], ]; elements.historySummary.innerHTML = summaryCards.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join(''); elements.historyTimeline.innerHTML = versions.map(version => { const statusClass = version.is_current ? 'history-status-current' : 'history-status-closed'; const statusLabel = version.is_current ? 'Current' : 'Closed'; const changeList = (version.changes || []).length ? version.changes.map(change => { const isLastChange = version.is_current && lastChangedField === change.field_name; return `
${escapeHtml(labels[change.field_name] || change.field_name)} ${escapeHtml(formatValue(change.old_value))} ${escapeHtml(formatValue(change.new_value))} ${escapeHtml(formatValue(change.user_id))} · ${escapeHtml(formatValue(change.changed_at))}
`; }).join('') : '
Initial imported/current row snapshot with no manual field changes recorded for this version.
'; const latestBadges = version.is_current && lastChangedField ? `
Last edited field:${escapeHtml(labels[lastChangedField] || lastChangedField)}
` : ''; return `
Version ${escapeHtml(version.version_number)} ${statusLabel} Row #${escapeHtml(version.id)}

${escapeHtml(formatValue(version.upper_ch_ref))}

${escapeHtml(formatValue(version.sat_ref))} · ${escapeHtml(formatValue(version.country_iso))} · ${escapeHtml(formatValue(version.genre))}
Date in: ${escapeHtml(formatValue(version.date_in))}
Date out: ${escapeHtml(formatValue(version.date_out))}
${latestBadges}
Type${escapeHtml(formatValue(version.type))}
Resolution${escapeHtml(formatValue(version.resolution))}
Active${escapeHtml(formatValue(version.active))}
sat_out${escapeHtml(formatValue(version.sat_out))}
Field changes for this version
${changeList}
`; }).join(''); renderDetailFieldHighlights(latestTransition); }; const fetchHistory = async (id, options = {}) => { const { silent = false } = options; if (!id) { state.historyLoaded = false; state.currentHistoryId = null; renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.'); return; } if (!silent) { elements.historyLead.textContent = `Loading history for row #${id}…`; elements.historyTimeline.innerHTML = '
Loading history…
'; } const response = await fetch(`${config.apiBase}?history_for=${encodeURIComponent(id)}`); const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Unable to load history.'); state.currentHistoryId = Number(id); state.historyLoaded = true; renderHistory(result.data); }; const fetchAuditLog = async () => { elements.auditTableBody.innerHTML = 'Loading audit log…'; const params = { page: state.auditFilters.page, limit: state.auditFilters.limit, user_id: elements.auditUserFilter.value, field_name: elements.auditFieldFilter.value, date_from: elements.auditDateFrom.value, date_to: elements.auditDateTo.value, }; const response = await fetch(`${config.auditApi}?${buildQueryString(params)}`); const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Unable to load audit log.'); const { data, filters } = result.data; const fillSelect = (select, items, currentValue) => { if (!select.dataset.loaded) { const first = select.options[0]?.outerHTML || ''; select.innerHTML = first + items.map(item => ``).join(''); select.dataset.loaded = 'true'; } select.value = currentValue || ''; }; fillSelect(elements.auditUserFilter, filters.users || [], params.user_id); fillSelect(elements.auditFieldFilter, filters.fields || [], params.field_name); if (!data.length) { elements.auditTableBody.innerHTML = 'No audit entries for the current filters.'; return; } elements.auditTableBody.innerHTML = data.map(item => ` ${escapeHtml(formatValue(item.changed_at))} ${escapeHtml(formatValue(item.user_id))}
${escapeHtml(formatValue(item.upper_ch_ref))}
#${escapeHtml(item.channel_id)} · ${escapeHtml(formatValue(item.sat_ref))}
${escapeHtml(formatValue(item.field_name))}
${escapeHtml(formatValue(item.old_value))} ${escapeHtml(formatValue(item.new_value))}
`).join(''); }; const refreshAll = async () => { try { await fetchChannels(); if (!state.analyticsLoaded) { await fetchAnalytics(); state.analyticsLoaded = true; } } catch (error) { elements.channelsTableBody.innerHTML = `${escapeHtml(error.message)}`; notify(error.message, 'danger'); } }; const collectFilters = () => { const nextFilters = { limit: 50, page: 1 }; const formData = new FormData(elements.filtersForm); for (const [key, value] of formData.entries()) { if (String(value).trim() !== '') nextFilters[key] = String(value).trim(); } const searchValue = elements.topSearchInput?.value.trim() || ''; if (searchValue) nextFilters.search = searchValue; return nextFilters; }; elements.filtersForm?.addEventListener('submit', async (event) => { event.preventDefault(); state.filters = collectFilters(); syncFilterForm(); await refreshAll(); }); elements.topSearchForm?.addEventListener('submit', async (event) => { event.preventDefault(); state.filters = collectFilters(); syncFilterForm(); await refreshAll(); }); elements.clearSearchBtn?.addEventListener('click', async () => { if (elements.topSearchInput) elements.topSearchInput.value = ''; const hiddenSearch = elements.filtersForm?.elements.namedItem('search'); if (hiddenSearch) hiddenSearch.value = ''; delete state.filters.search; state.filters = { ...state.filters, limit: 50, page: 1 }; syncFilterForm(); await refreshAll(); }); elements.resetFiltersBtn?.addEventListener('click', async () => { state.filters = { limit: 50, page: 1 }; state.advancedFilters = []; elements.filtersForm.reset(); renderAdvancedFilters(); syncFilterForm(); await refreshAll(); notify('Filters reset to default active view.'); }); elements.prevPageBtn?.addEventListener('click', async () => { if ((state.filters.page || 1) <= 1) return; state.filters.page = (state.filters.page || 1) - 1; await refreshAll(); }); elements.nextPageBtn?.addEventListener('click', async () => { state.filters.page = (state.filters.page || 1) + 1; await refreshAll(); }); elements.selectAllPageCheckbox?.addEventListener('change', (event) => { const checked = event.target.checked; state.currentRows.forEach(row => { const id = String(row.id); if (checked) state.selectedIds.add(id); else state.selectedIds.delete(id); }); if (state.lastListPayload) renderChannels(state.lastListPayload); }); elements.selectPageBtn?.addEventListener('click', () => { state.currentRows.forEach(row => state.selectedIds.add(String(row.id))); document.querySelectorAll('.row-checkbox').forEach(box => { box.checked = true; }); updateSelectionUI(); document.querySelectorAll('tr[data-row-id]').forEach(row => row.classList.add('row-selected')); notify('Current page selected.'); }); elements.saveCurrentViewBtn?.addEventListener('click', () => { exportCurrentView(); }); elements.clearSelectionBtn?.addEventListener('click', () => { state.selectedIds.clear(); document.querySelectorAll('.row-checkbox').forEach(box => { box.checked = false; }); document.querySelectorAll('tr[data-row-id]').forEach(row => row.classList.remove('row-selected')); updateSelectionUI(); }); document.querySelectorAll('.channel-table thead th[data-sort]').forEach(th => { th.addEventListener('click', async () => { const field = th.dataset.sort; if (state.sort.field === field) { state.sort.direction = state.sort.direction === 'ASC' ? 'DESC' : 'ASC'; } else { state.sort.field = field; state.sort.direction = 'ASC'; } await refreshAll(); }); }); elements.channelsTableBody?.addEventListener('click', async (event) => { const checkbox = event.target.closest('.row-checkbox'); if (checkbox) { const id = String(checkbox.dataset.rowId); if (checkbox.checked) state.selectedIds.add(id); else state.selectedIds.delete(id); const rowEl = checkbox.closest('tr'); rowEl?.classList.toggle('row-selected', checkbox.checked); updateSelectionUI(); return; } const row = event.target.closest('tr[data-row-id]'); if (!row) return; try { await fetchDetail(row.dataset.rowId); } catch (error) { notify(error.message, 'danger'); } }); elements.detailForm?.addEventListener('submit', async (event) => { event.preventDefault(); const id = elements.detailChannelId.value; const fields = payloadFromForm(elements.detailForm, editableFields); try { const response = await fetch(`${config.apiBase}?id=${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ fields }) }); const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Unable to save changes.'); detailOffcanvas.hide(); notify(`Saved ${result.data.changed_fields.length || 0} field(s).`); state.auditLoaded = false; state.historyLoaded = false; state.currentHistoryId = Number(result.data?.channel?.id || id); try { await fetchHistory(state.currentHistoryId, { silent: true }); } catch (historyError) { renderHistoryEmpty(historyError.message); } await refreshAll(); } catch (error) { notify(error.message, 'danger'); } }); document.getElementById('bulkEditModal')?.addEventListener('show.bs.modal', () => { renderBulkForm(); elements.bulkSelectionSummary.textContent = String(state.selectedIds.size); }); document.getElementById('advancedFilterModal')?.addEventListener('show.bs.modal', () => { renderAdvancedFilters(state.advancedFilters.length ? state.advancedFilters : [{ field: 'sid', operator: 'contains', value: '' }]); }); elements.addAdvancedFilterBtn?.addEventListener('click', () => { const rawRows = [...(elements.advancedFiltersContainer?.querySelectorAll('.advanced-filter-row') || [])].map((row) => ({ field: row.querySelector('[data-advanced-field]')?.value?.trim() || 'sid', operator: row.querySelector('[data-advanced-operator]')?.value?.trim() || 'contains', value: row.querySelector('[data-advanced-value]')?.value?.trim() || '' })); renderAdvancedFilters([...(rawRows.length ? rawRows : []), { field: 'sid', operator: 'contains', value: '' }]); }); elements.clearAdvancedFiltersBtn?.addEventListener('click', () => { renderAdvancedFilters([]); notify('Rules cleared in the builder. Click Apply advanced filters to confirm.'); }); elements.advancedFiltersContainer?.addEventListener('change', (event) => { const operatorSelect = event.target.closest('[data-advanced-operator]'); if (!operatorSelect) return; const row = operatorSelect.closest('.advanced-filter-row'); const valueInput = row?.querySelector('[data-advanced-value]'); if (!valueInput) return; const needsValue = advancedFilterNeedsValue(operatorSelect.value); valueInput.disabled = !needsValue; if (!needsValue) valueInput.value = ''; }); elements.advancedFiltersContainer?.addEventListener('click', (event) => { const removeBtn = event.target.closest('[data-remove-advanced-rule]'); if (!removeBtn) return; removeBtn.closest('.advanced-filter-row')?.remove(); if (!elements.advancedFiltersContainer?.querySelector('.advanced-filter-row')) { renderAdvancedFilters(); } }); elements.applyAdvancedFiltersBtn?.addEventListener('click', async () => { const rules = collectAdvancedFilters(); if (rules === null) return; state.advancedFilters = rules; state.filters = { ...state.filters, page: 1 }; syncAdvancedFilterBadge(); renderFilterChips(); advancedFilterModal?.hide(); await refreshAll(); notify(rules.length ? `Applied ${rules.length} advanced rule(s).` : 'Advanced filters cleared.'); }); elements.bulkEditFieldsContainer?.addEventListener('change', (event) => { const toggle = event.target.closest('.bulk-enable'); if (!toggle) return; const card = document.querySelector(`[data-field-card="${toggle.value}"]`); const control = card?.querySelector('.bulk-control'); if (control) control.disabled = !toggle.checked; card?.classList.toggle('is-enabled', toggle.checked); }); elements.bulkEditForm?.addEventListener('submit', async (event) => { event.preventDefault(); if (state.selectedIds.size === 0) { notify('Select at least one row before bulk editing.', 'danger'); return; } const enabledFields = [...document.querySelectorAll('.bulk-enable:checked')]; if (enabledFields.length === 0) { notify('Check at least one field to apply.', 'danger'); return; } const fields = {}; enabledFields.forEach(toggle => { const control = document.querySelector(`[data-field-card="${toggle.value}"] .bulk-control`); fields[toggle.value] = control ? control.value : ''; }); try { const response = await fetch(`${config.apiBase}?bulk=1`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ ids: [...state.selectedIds].map(Number), fields }) }); const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Bulk update failed.'); bulkModal.hide(); state.selectedIds.clear(); notify(`Bulk update applied to ${result.data.updated_count} row(s).`); state.auditLoaded = false; state.historyLoaded = false; state.currentHistoryId = null; renderHistoryEmpty('Bulk edit finished. Select one row in Dataset to inspect its version chain.'); await refreshAll(); } catch (error) { notify(error.message, 'danger'); } }); document.querySelector('[data-bs-target="#analytics-panel"]')?.addEventListener('shown.bs.tab', async () => { try { await fetchAnalytics(); state.analyticsLoaded = true; } catch (error) { notify(error.message, 'danger'); } }); document.querySelector('[data-bs-target="#history-panel"]')?.addEventListener('shown.bs.tab', async () => { if (!state.currentHistoryId) { renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.'); return; } if (state.historyLoaded) return; try { await fetchHistory(state.currentHistoryId); } catch (error) { renderHistoryEmpty(error.message); notify(error.message, 'danger'); } }); document.querySelector('[data-bs-target="#audit-panel"]')?.addEventListener('shown.bs.tab', async () => { if (state.auditLoaded) return; try { await fetchAuditLog(); state.auditLoaded = true; } catch (error) { notify(error.message, 'danger'); } }); elements.refreshHistoryBtn?.addEventListener('click', async () => { if (!state.currentHistoryId) { renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.'); return; } try { await fetchHistory(state.currentHistoryId); } catch (error) { renderHistoryEmpty(error.message); notify(error.message, 'danger'); } }); elements.applyAuditFiltersBtn?.addEventListener('click', async () => { try { await fetchAuditLog(); state.auditLoaded = true; } catch (error) { notify(error.message, 'danger'); } }); syncFilterForm(); renderAdvancedFilters(); updateSaveViewButton(); renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.'); refreshAll(); });