39481-vm/assets/js/main.js
Flatlogic Bot 1b3f6ab743 v3
2026-04-05 11:53:02 +00:00

1168 lines
52 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
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 = `
<div class="d-flex">
<div class="toast-body"><strong class="text-${tone}">${escapeHtml(message)}</strong></div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>`;
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 = `
<div class="advanced-filter-empty">
<strong>No advanced rules yet.</strong>
<div class="small text-muted mt-1">Example: <code>sid contains 10</code> or <code>Frequency / Transponder equals 11001</code>.</div>
</div>`;
return;
}
const fieldOptions = advancedFieldOptions
.map((item) => `<option value="${escapeHtml(item.value)}">${escapeHtml(item.label)}</option>`)
.join('');
const operatorOptions = Object.entries(advancedOperatorLabels)
.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`)
.join('');
elements.advancedFiltersContainer.innerHTML = rules.map((rule, index) => `
<div class="advanced-filter-row" data-rule-index="${index}">
<div class="advanced-filter-grid">
<div>
<label class="form-label">Field</label>
<select class="form-select" data-advanced-field>${fieldOptions}</select>
</div>
<div>
<label class="form-label">Operator</label>
<select class="form-select" data-advanced-operator>${operatorOptions}</select>
</div>
<div>
<label class="form-label">Value</label>
<input class="form-control" type="text" data-advanced-value placeholder="Enter a value">
</div>
<div class="advanced-filter-actions">
<button type="button" class="btn btn-outline-danger" data-remove-advanced-rule>Remove</button>
</div>
</div>
</div>`).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 = `<option value="">${escapeHtml(defaultLabel)}</option>` +
list.map(item => `<option value="${escapeHtml(item)}">${escapeHtml(item)}</option>`).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 `<span class="filter-chip">${escapeHtml(label)}: ${escapeHtml(value)}</span>`;
});
const advancedChips = state.advancedFilters
.map((rule) => `<span class="filter-chip filter-chip-advanced">Rule: ${escapeHtml(describeAdvancedFilter(rule))}</span>`);
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 = '<tr><td colspan="26" class="empty-state-cell">No channels match the current filters.</td></tr>';
} else {
elements.channelsTableBody.innerHTML = data.map(row => `
<tr class="${rowClasses(row)}" data-row-id="${row.id}">
<td class="${cellClasses(row, '', 'sticky-col checkbox-col')}"><input class="form-check-input row-checkbox" type="checkbox" data-row-id="${row.id}" ${state.selectedIds.has(String(row.id)) ? 'checked' : ''} aria-label="Select row ${escapeHtml(row.upper_ch_ref)}"></td>
<td class="${cellClasses(row, 'upper_ch_ref')}"><strong>${escapeHtml(formatValue(row.upper_ch_ref))}</strong></td>
<td class="${cellClasses(row, 'country_iso')}">${escapeHtml(formatValue(row.country_iso))}</td>
<td class="${cellClasses(row, 'country_client')}">${escapeHtml(formatValue(row.country_client))}</td>
<td class="${cellClasses(row, 'sat_ref')}">${escapeHtml(formatValue(row.sat_ref))}</td>
<td class="${cellClasses(row, 'sat_tpinfo')}">${escapeHtml(formatValue(row.sat_tpinfo))}</td>
<td class="${cellClasses(row, 'genre')}">${escapeHtml(formatValue(row.genre))}</td>
<td class="${cellClasses(row, 'type')}"><span class="channel-pill">${escapeHtml(formatValue(row.type))}</span></td>
<td class="${cellClasses(row, 'resolution')}">${escapeHtml(formatValue(row.resolution))}</td>
<td class="${cellClasses(row, 'langue')}">${escapeHtml(formatValue(row.langue))}</td>
<td class="${cellClasses(row, 'region')}">${escapeHtml(formatValue(row.region))}</td>
<td class="${cellClasses(row, 'groupe')}">${escapeHtml(formatValue(row.groupe))}</td>
<td class="${cellClasses(row, 'active')}">${escapeHtml(formatValue(row.active))}</td>
<td class="${cellClasses(row, 'idtype')}">${escapeHtml(formatValue(row.idtype))}</td>
<td class="${cellClasses(row, 'six_id', 'sys-col')}">${escapeHtml(formatValue(row.six_id))}</td>
<td class="${cellClasses(row, 'sid', 'sys-col')}">${escapeHtml(formatValue(row.sid))}</td>
<td class="${cellClasses(row, 'onid', 'sys-col')}">${escapeHtml(formatValue(row.onid))}</td>
<td class="${cellClasses(row, 'tid', 'sys-col')}">${escapeHtml(formatValue(row.tid))}</td>
<td class="${cellClasses(row, 'sat_in')}">${escapeHtml(formatValue(row.sat_in))}</td>
<td class="${cellClasses(row, 'sat_out')}">${escapeHtml(formatValue(row.sat_out))}</td>
<td class="${cellClasses(row, 'sat_update')}">${escapeHtml(formatValue(row.sat_update))}</td>
<td class="${cellClasses(row, 'sat_ch_client_upper', 'sys-col')}">${escapeHtml(formatValue(row.sat_ch_client_upper))}</td>
<td class="${cellClasses(row, 'sat_client', 'sys-col')}">${escapeHtml(formatValue(row.sat_client))}</td>
<td class="${cellClasses(row, 'counting', 'sys-col')}">${escapeHtml(formatValue(row.counting))}</td>
<td class="${cellClasses(row, 'lastview', 'sys-col')}">${escapeHtml(formatValue(row.lastview))}</td>
<td class="${cellClasses(row, 'manually_edited_at')}">${escapeHtml(formatValue(row.manually_edited_at))}</td>
</tr>`).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 = `
<div class="detail-last-change-title">Last edited field</div>
<div class="small text-muted mb-2">Only the newest changed field is highlighted.</div>
<div class="detail-last-change-badges"><span class="detail-last-change-badge">${escapeHtml(labels[lastChangedField] || lastChangedField)}</span></div>`;
};
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 `
<div class="col-sm-6" data-editable-field="${field}">
<div class="editable-field-card">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<select class="form-select" id="field_${field}" name="${field}">
<option value="1" ${String(value) === '1' ? 'selected' : ''}>1</option>
<option value="0" ${String(value) === '0' ? 'selected' : ''}>0</option>
</select>
</div>
</div>`;
}
if (field === 'type') {
return `
<div class="col-sm-6" data-editable-field="${field}">
<div class="editable-field-card">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<select class="form-select" id="field_${field}" name="${field}">
<option value="free" ${String(value) === 'free' ? 'selected' : ''}>free</option>
<option value="payed" ${String(value) === 'payed' ? 'selected' : ''}>payed</option>
</select>
</div>
</div>`;
}
if (selectOptionsFields.includes(field)) {
const opts = state.options[field] || [];
return `
<div class="col-sm-6" data-editable-field="${field}">
<div class="editable-field-card">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<select class="form-select" id="field_${field}" name="${field}">
<option value="">Select</option>
${opts.map(item => `<option value="${escapeHtml(item)}" ${String(value || '') === String(item) ? 'selected' : ''}>${escapeHtml(item)}</option>`).join('')}
</select>
</div>
</div>`;
}
if (['sat_in', 'sat_out', 'sat_update'].includes(field)) {
return `
<div class="col-sm-6" data-editable-field="${field}">
<div class="editable-field-card">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<input class="form-control" id="field_${field}" name="${field}" type="date" value="${escapeHtml(value || '')}">
</div>
</div>`;
}
return `
<div class="col-sm-6" data-editable-field="${field}">
<div class="editable-field-card">
<label class="form-label" for="field_${field}">${escapeHtml(label)}</label>
<input class="form-control" id="field_${field}" name="${field}" type="text" value="${escapeHtml(value || '')}">
</div>
</div>`;
};
elements.editableFieldsContainer.innerHTML = editableFields.map(field => buildInput(field, channel[field])).join('');
elements.lockedFieldsContainer.innerHTML = lockedFields.map(field => `
<div class="col-sm-6">
<div class="locked-card sys-field">
<div class="d-flex justify-content-between gap-2">
<div class="locked-label">${escapeHtml(labels[field] || field)}</div>
<span class="lock-icon">🔒</span>
</div>
<div class="locked-value">${escapeHtml(formatValue(channel[field]))}</div>
</div>
</div>`).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 = `
<select class="form-select bulk-control" name="${field}" disabled>
<option value="1">1</option>
<option value="0">0</option>
</select>`;
} else if (field === 'type') {
control = `
<select class="form-select bulk-control" name="${field}" disabled>
<option value="free">free</option>
<option value="payed">payed</option>
</select>`;
} else if (selectOptionsFields.includes(field)) {
const opts = state.options[field] || [];
control = `
<select class="form-select bulk-control" name="${field}" disabled>
<option value="">Select</option>
${opts.map(item => `<option value="${escapeHtml(item)}">${escapeHtml(item)}</option>`).join('')}
</select>`;
} else if (['sat_in', 'sat_out', 'sat_update'].includes(field)) {
control = `<input class="form-control bulk-control" name="${field}" type="date" disabled>`;
} else {
control = `<input class="form-control bulk-control" name="${field}" type="text" disabled>`;
}
return `
<div class="col-md-6">
<div class="bulk-field-card" data-field-card="${field}">
<div class="form-check">
<input class="form-check-input bulk-enable" type="checkbox" value="${field}" id="bulk_enable_${field}">
<label class="form-check-label fw-semibold" for="bulk_enable_${field}">${escapeHtml(label)}</label>
</div>
${control}
</div>
</div>`;
}).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]) => `
<div class="col-sm-6 col-xl-3"><div class="analytics-card"><div class="metric-label">${escapeHtml(label)}</div><div class="metric-value">${escapeHtml(value)}</div></div></div>`).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 => `
<div class="analytics-bar-row">
<div class="analytics-bar-top"><span>${escapeHtml(item.label)}</span><strong>${escapeHtml(item.total)}</strong></div>
<div class="analytics-bar-track"><div class="analytics-bar-fill" style="width:${(Number(item.total) / max) * 100}%"></div></div>
</div>`).join('');
return `
<div class="col-lg-6">
<div class="analytics-card h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="section-title mb-0 text-capitalize">${escapeHtml(dimension.replaceAll('_', ' '))}</h3>
<span class="small text-muted">Top distribution</span>
</div>
<div class="analytics-bars">${bars}</div>
</div>
</div>`;
}).join('');
};
const renderHistoryEmpty = (message) => {
elements.historyLead.textContent = message;
elements.historySummary.innerHTML = '<div class="col-12"><div class="analytics-card"><div class="small text-muted">No channel selected yet.</div></div></div>';
elements.historyTimeline.innerHTML = `<div class="analytics-card"><div class="small text-muted">${escapeHtml(message)}</div></div>`;
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]) => `
<div class="col-sm-6 col-xl-3">
<div class="analytics-card h-100">
<div class="metric-label">${escapeHtml(label)}</div>
<div class="metric-value history-metric-value">${escapeHtml(value)}</div>
</div>
</div>`).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 `
<div class="history-change ${isLastChange ? 'is-last-change' : ''}">
<span class="history-change-field">${escapeHtml(labels[change.field_name] || change.field_name)}</span>
<span class="audit-old">${escapeHtml(formatValue(change.old_value))}</span>
<span aria-hidden="true">→</span>
<span class="audit-new">${escapeHtml(formatValue(change.new_value))}</span>
<span class="history-change-meta">${escapeHtml(formatValue(change.user_id))} · ${escapeHtml(formatValue(change.changed_at))}</span>
</div>`;
}).join('')
: '<div class="small text-muted">Initial imported/current row snapshot with no manual field changes recorded for this version.</div>';
const latestBadges = version.is_current && lastChangedField
? `<div class="history-last-change-note"><span class="history-last-change-label">Last edited field:</span><span class="small text-muted">${escapeHtml(labels[lastChangedField] || lastChangedField)}</span></div>`
: '';
return `
<article class="history-card ${version.is_current ? 'is-current' : ''}">
<div class="history-card-top">
<div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<span class="history-version-pill">Version ${escapeHtml(version.version_number)}</span>
<span class="history-status-pill ${statusClass}">${statusLabel}</span>
<span class="small text-muted">Row #${escapeHtml(version.id)}</span>
</div>
<h3 class="history-card-title">${escapeHtml(formatValue(version.upper_ch_ref))}</h3>
<div class="small text-muted">${escapeHtml(formatValue(version.sat_ref))} · ${escapeHtml(formatValue(version.country_iso))} · ${escapeHtml(formatValue(version.genre))}</div>
</div>
<div class="history-date-block">
<div><strong>Date in:</strong> ${escapeHtml(formatValue(version.date_in))}</div>
<div><strong>Date out:</strong> ${escapeHtml(formatValue(version.date_out))}</div>
</div>
</div>
${latestBadges}
<div class="history-meta-grid">
<div class="history-meta-item"><span class="history-meta-label">Type</span><strong>${escapeHtml(formatValue(version.type))}</strong></div>
<div class="history-meta-item"><span class="history-meta-label">Resolution</span><strong>${escapeHtml(formatValue(version.resolution))}</strong></div>
<div class="history-meta-item"><span class="history-meta-label">Active</span><strong>${escapeHtml(formatValue(version.active))}</strong></div>
<div class="history-meta-item"><span class="history-meta-label">sat_out</span><strong>${escapeHtml(formatValue(version.sat_out))}</strong></div>
</div>
<div class="history-changes-block">
<div class="section-subtitle mb-2">Field changes for this version</div>
<div class="history-changes-list">${changeList}</div>
</div>
</article>`;
}).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 = '<div class="analytics-card"><div class="small text-muted">Loading history…</div></div>';
}
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 = '<tr><td colspan="5" class="empty-state-cell">Loading audit log…</td></tr>';
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 || '<option value="">All</option>';
select.innerHTML = first + items.map(item => `<option value="${escapeHtml(item)}">${escapeHtml(item)}</option>`).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 = '<tr><td colspan="5" class="empty-state-cell">No audit entries for the current filters.</td></tr>';
return;
}
elements.auditTableBody.innerHTML = data.map(item => `
<tr>
<td>${escapeHtml(formatValue(item.changed_at))}</td>
<td>${escapeHtml(formatValue(item.user_id))}</td>
<td>
<div class="fw-semibold">${escapeHtml(formatValue(item.upper_ch_ref))}</div>
<div class="small text-muted">#${escapeHtml(item.channel_id)} · ${escapeHtml(formatValue(item.sat_ref))}</div>
</td>
<td>${escapeHtml(formatValue(item.field_name))}</td>
<td>
<div class="audit-change">
<span class="audit-old">${escapeHtml(formatValue(item.old_value))}</span>
<span aria-hidden="true">→</span>
<span class="audit-new">${escapeHtml(formatValue(item.new_value))}</span>
</div>
</td>
</tr>`).join('');
};
const refreshAll = async () => {
try {
await fetchChannels();
if (!state.analyticsLoaded) {
await fetchAnalytics();
state.analyticsLoaded = true;
}
} catch (error) {
elements.channelsTableBody.innerHTML = `<tr><td colspan="26" class="empty-state-cell">${escapeHtml(error.message)}</td></tr>`;
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();
});