1168 lines
52 KiB
JavaScript
1168 lines
52 KiB
JavaScript
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 = `
|
||
<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();
|
||
});
|