This commit is contained in:
Flatlogic Bot 2026-04-05 11:27:52 +00:00
parent 8be8405504
commit cc09913585
5 changed files with 1309 additions and 217 deletions

View File

@ -34,7 +34,7 @@ try {
respond(200, ['success' => true, 'data' => $channel, 'meta' => [
'editable_fields' => CHANNEL_EDITABLE_FIELDS,
'locked_fields' => CHANNEL_LOCKED_FIELDS,
'options' => channel_distinct_options(['sat_ref', 'country_iso', 'genre', 'type', 'resolution', 'langue', 'region', 'groupe']),
'options' => channel_distinct_options(CHANNEL_FILTER_OPTION_FIELDS),
]]);
}

View File

@ -48,6 +48,336 @@ const CHANNEL_RECORD_COLUMNS = [
'date_in', 'date_out'
];
const CHANNEL_FILTER_OPTION_FIELDS = [
'sat_ref', 'country_iso', 'country_client', 'genre', 'type', 'resolution', 'langue', 'region', 'groupe',
'sat_tpinfo', 'sat_ch_client_upper', 'sat_client'
];
const CHANNEL_ADVANCED_FILTER_FIELDS = [
'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', 'manually_edited_at'
];
function channel_workspace_csv_candidates(): array
{
$root = dirname(__DIR__);
$preferred = glob($root . '/data-*.csv') ?: [];
$all = glob($root . '/*.csv') ?: [];
$files = array_values(array_unique(array_merge($preferred, $all)));
usort($files, static function (string $a, string $b): int {
$aPriority = basename($a) === 'data-1775387350803.csv' ? 0 : (str_starts_with(basename($a), 'data-') ? 1 : 2);
$bPriority = basename($b) === 'data-1775387350803.csv' ? 0 : (str_starts_with(basename($b), 'data-') ? 1 : 2);
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
$mtimeCompare = @filemtime($b) <=> @filemtime($a);
if ($mtimeCompare !== 0) {
return $mtimeCompare;
}
return strcmp(basename($a), basename($b));
});
return $files;
}
function channel_detect_workspace_csv(): ?array
{
$files = channel_workspace_csv_candidates();
if (!$files) {
return null;
}
$path = $files[0];
return [
'path' => $path,
'basename' => basename($path),
'sha1' => is_file($path) ? sha1_file($path) : null,
'modified_at' => is_file($path) ? gmdate('Y-m-d H:i:s', (int) filemtime($path)) : null,
];
}
function channel_normalize_csv_header(string $header): string
{
$header = preg_replace('/^\xEF\xBB\xBF/', '', $header) ?? $header;
$header = strtolower(trim($header));
$header = preg_replace('/[^a-z0-9]+/', '_', $header) ?? $header;
return trim($header, '_');
}
function channel_csv_header_map(array $headers): array
{
$aliases = [
'upper_ch_ref' => ['upper_ch_ref', 'upper ch ref', 'upper channel ref', 'channel', 'channel_name', 'channel name', 'service_name', 'service name', 'name'],
'country_iso' => ['country_iso', 'country iso', 'country_code', 'country code', 'iso', 'iso2', 'country'],
'country_client' => ['country_client', 'country client', 'client_country', 'client country'],
'sat_ref' => ['sat_ref', 'sat ref', 'satellite', 'satellite_ref', 'satellite ref', 'satellite_reference'],
'sat_tpinfo' => ['sat_tpinfo', 'sat tpinfo', 'tpinfo', 'tp_info', 'transponder', 'frequency', 'freq'],
'genre' => ['genre', 'category'],
'type' => ['type', 'channel_type', 'channel type', 'free_pay', 'free/pay', 'fta_type'],
'resolution' => ['resolution', 'quality', 'video_quality', 'video quality', 'format'],
'langue' => ['langue', 'language', 'lang'],
'region' => ['region', 'market'],
'groupe' => ['groupe', 'group', 'bouquet'],
'active' => ['active', 'is_active', 'is active', 'enabled', 'status'],
'idtype' => ['idtype', 'id_type', 'id type', 'identifier_type', 'identifier type'],
'six_id' => ['six_id', 'six id', 'sixid'],
'sid' => ['sid', 'service_id', 'service id'],
'onid' => ['onid', 'original_network_id', 'original network id'],
'tid' => ['tid', 'transport_stream_id', 'transport stream id'],
'sat_in' => ['sat_in', 'sat in', 'start_date', 'start date'],
'sat_out' => ['sat_out', 'sat out', 'end_date', 'end date'],
'sat_update' => ['sat_update', 'sat update', 'update_date', 'update date', 'updated_at_source', 'updated at source', 'last_update', 'last update'],
'sat_ch_client_upper' => ['sat_ch_client_upper', 'sat ch client upper', 'client_channel_name', 'client channel name'],
'sat_client' => ['sat_client', 'sat client', 'client', 'platform', 'operator'],
'counting' => ['counting', 'hits', 'views', 'count'],
'lastview' => ['lastview', 'last view', 'last_seen', 'last seen', 'last_viewed_at', 'last viewed at'],
'manually_edited' => ['manually_edited', 'manually edited', 'manual_edit', 'manual edit'],
'manually_edited_at' => ['manually_edited_at', 'manually edited at', 'manual_edit_date', 'manual edit date'],
'date_in' => ['date_in', 'date in', 'record_date_in', 'record date in'],
'date_out' => ['date_out', 'date out', 'record_date_out', 'record date out'],
];
$normalizedAliases = [];
foreach ($aliases as $canonical => $variants) {
$normalizedAliases[channel_normalize_csv_header($canonical)] = $canonical;
foreach ($variants as $variant) {
$normalizedAliases[channel_normalize_csv_header($variant)] = $canonical;
}
}
$mapped = [];
foreach ($headers as $index => $header) {
$normalized = channel_normalize_csv_header((string) $header);
if ($normalized === '') {
continue;
}
$canonical = $normalizedAliases[$normalized] ?? null;
if ($canonical === null && in_array($normalized, CHANNEL_RECORD_COLUMNS, true)) {
$canonical = $normalized;
}
if ($canonical !== null && !isset($mapped[$canonical])) {
$mapped[$canonical] = (int) $index;
}
}
return $mapped;
}
function channel_csv_nullish(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = trim($value);
if ($value === '') {
return null;
}
$lower = strtolower($value);
if (in_array($lower, ['null', 'n/a', 'na', 'none', '-'], true)) {
return null;
}
return $value;
}
function channel_csv_boolish(?string $value, int $default = 1): int
{
$value = channel_csv_nullish($value);
if ($value === null) {
return $default;
}
$lower = strtolower($value);
if (in_array($lower, ['1', 'true', 'yes', 'y', 'active', 'enabled'], true)) {
return 1;
}
if (in_array($lower, ['0', 'false', 'no', 'n', 'inactive', 'disabled'], true)) {
return 0;
}
return is_numeric($value) ? ((int) $value > 0 ? 1 : 0) : $default;
}
function channel_csv_intish(?string $value, int $default = 0): int
{
$value = channel_csv_nullish($value);
if ($value === null) {
return $default;
}
return is_numeric($value) ? (int) $value : $default;
}
function channel_csv_date(?string $value): ?string
{
$value = channel_csv_nullish($value);
if ($value === null || $value === '0000-00-00') {
return null;
}
$formats = ['Y-m-d', 'd/m/Y', 'm/d/Y', 'd-m-Y', 'm-d-Y', 'd.m.Y', 'Y/m/d'];
foreach ($formats as $format) {
$date = DateTimeImmutable::createFromFormat($format, $value);
if ($date instanceof DateTimeImmutable) {
return $date->format('Y-m-d');
}
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return null;
}
return gmdate('Y-m-d', $timestamp);
}
function channel_csv_datetime(?string $value): ?string
{
$value = channel_csv_nullish($value);
if ($value === null || $value === '0000-00-00 00:00:00') {
return null;
}
$formats = ['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'd/m/Y H:i:s', 'm/d/Y H:i:s', 'd-m-Y H:i:s', 'Y-m-d'];
foreach ($formats as $format) {
$date = DateTimeImmutable::createFromFormat($format, $value);
if ($date instanceof DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return null;
}
return gmdate('Y-m-d H:i:s', $timestamp);
}
function channel_csv_type(?string $value): ?string
{
$value = channel_csv_nullish($value);
if ($value === null) {
return null;
}
$lower = strtolower($value);
if (str_contains($lower, 'free') || str_contains($lower, 'fta') || str_contains($lower, 'open')) {
return 'free';
}
if (str_contains($lower, 'pay')) {
return 'payed';
}
return $value;
}
function channel_import_workspace_csv(PDO $pdo, string $path): int
{
if (!is_file($path) || !is_readable($path)) {
return 0;
}
$csv = new SplFileObject($path, 'r');
$csv->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
$headers = null;
$headerMap = [];
$imported = 0;
foreach ($csv as $row) {
if (!is_array($row)) {
continue;
}
$hasContent = false;
foreach ($row as $cell) {
if (trim((string) $cell) !== '') {
$hasContent = true;
break;
}
}
if (!$hasContent) {
continue;
}
if ($headers === null) {
$headers = array_map(static fn($value): string => (string) $value, $row);
$headerMap = channel_csv_header_map($headers);
if (!isset($headerMap['upper_ch_ref'])) {
throw new RuntimeException('CSV import needs a channel name column (for example: upper_ch_ref or channel_name).');
}
continue;
}
$record = array_fill_keys(CHANNEL_RECORD_COLUMNS, null);
$record['active'] = 1;
$record['idtype'] = 1;
$record['counting'] = 0;
$record['manually_edited'] = 0;
foreach ($headerMap as $column => $index) {
$raw = isset($row[$index]) ? (string) $row[$index] : null;
switch ($column) {
case 'active':
$record[$column] = channel_csv_boolish($raw, 1);
break;
case 'idtype':
$record[$column] = channel_csv_intish($raw, 1);
break;
case 'counting':
$record[$column] = channel_csv_intish($raw, 0);
break;
case 'manually_edited':
$record[$column] = channel_csv_boolish($raw, 0);
break;
case 'sat_in':
case 'sat_out':
case 'sat_update':
case 'manually_edited_at':
case 'date_in':
case 'date_out':
$record[$column] = channel_csv_date($raw);
break;
case 'lastview':
$record[$column] = channel_csv_datetime($raw);
break;
case 'type':
$record[$column] = channel_csv_type($raw);
break;
default:
$record[$column] = channel_csv_nullish($raw);
break;
}
}
$record['upper_ch_ref'] = trim((string) ($record['upper_ch_ref'] ?? ''));
if ($record['upper_ch_ref'] === '') {
continue;
}
if ($record['country_iso'] !== null) {
$record['country_iso'] = strtoupper(substr((string) $record['country_iso'], 0, 3));
}
if ($record['date_in'] === null) {
$record['date_in'] = $record['sat_in'] ?? gmdate('Y-m-d');
}
channel_insert($record, $pdo);
$imported++;
}
return $imported;
}
function channel_app_bootstrap(): void
{
static $booted = false;
@ -117,6 +447,19 @@ function channel_app_bootstrap(): void
$count = (int) $pdo->query("SELECT COUNT(*) FROM channels")->fetchColumn();
if ($count === 0) {
$csvSource = channel_detect_workspace_csv();
$imported = 0;
if ($csvSource !== null) {
try {
$imported = channel_import_workspace_csv($pdo, $csvSource['path']);
} catch (Throwable $e) {
error_log('CSV import failed for ' . ($csvSource['basename'] ?? 'workspace csv') . ': ' . $e->getMessage());
$imported = 0;
}
}
if ($imported === 0) {
$rows = [
['DISCOVERY SPORT', 'FR', 'France', 'EUTELSAT-5W', '11554/V/29950', 'Sport', 'free', 'HD', 'FR', 'West Europe', 'Warner Sports', 1, 1, '101001', '1201', '1', '301', '2024-01-04', null, '2026-03-15', 'DISCOVERY SPORT FR', 'Canal France', 1842, '2026-04-04 14:20:00', 0, null, '2024-01-04', null],
['EURO NEWS WORLD', 'GB', 'United Kingdom', 'ASTRA-28E', '11778/H/27500', 'News', 'free', 'HD', 'EN', 'UK & Ireland', 'Global News', 1, 1, '101002', '1202', '1', '302', '2024-02-12', null, '2026-03-20', 'EURONEWS UK', 'Sky UK', 2215, '2026-04-04 13:08:00', 1, '2026-04-03', '2024-02-12', null],
@ -209,6 +552,7 @@ function channel_app_bootstrap(): void
':new_value' => '1',
':changed_at' => '2026-03-28 12:03:00',
]);
}
}
$booted = true;
@ -252,18 +596,151 @@ function channel_parse_csv_param(string $key): array
return array_values(array_unique($clean));
}
function channel_parse_advanced_filters(mixed $rawFilters): array
{
if (is_string($rawFilters)) {
$decoded = json_decode($rawFilters, true);
$rawFilters = is_array($decoded) ? $decoded : [];
}
if (!is_array($rawFilters)) {
return [];
}
$allowedFields = CHANNEL_ADVANCED_FILTER_FIELDS;
$allowedOperators = ['contains', 'equals', 'not_contains', 'not_equals', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty'];
$normalized = [];
foreach ($rawFilters as $rule) {
if (!is_array($rule)) {
continue;
}
$field = trim((string) ($rule['field'] ?? ''));
$operator = trim((string) ($rule['operator'] ?? 'contains'));
$value = trim((string) ($rule['value'] ?? ''));
if (!in_array($field, $allowedFields, true) || !in_array($operator, $allowedOperators, true)) {
continue;
}
if (!in_array($operator, ['is_empty', 'is_not_empty'], true) && $value === '') {
continue;
}
$normalized[] = [
'field' => $field,
'operator' => $operator,
'value' => $value,
];
if (count($normalized) >= 10) {
break;
}
}
return $normalized;
}
function channel_apply_advanced_filters(array $rules, array &$where, array &$params): bool
{
$applied = false;
foreach ($rules as $index => $rule) {
$field = $rule['field'];
$operator = $rule['operator'];
$value = $rule['value'] ?? '';
$placeholder = ':adv_' . $index;
switch ($operator) {
case 'contains':
$where[] = "{$field} LIKE {$placeholder}";
$params[$placeholder] = '%' . $value . '%';
$applied = true;
break;
case 'equals':
$where[] = "{$field} = {$placeholder}";
$params[$placeholder] = $value;
$applied = true;
break;
case 'not_contains':
$where[] = "({$field} IS NULL OR {$field} NOT LIKE {$placeholder})";
$params[$placeholder] = '%' . $value . '%';
$applied = true;
break;
case 'not_equals':
$where[] = "({$field} IS NULL OR {$field} <> {$placeholder})";
$params[$placeholder] = $value;
$applied = true;
break;
case 'starts_with':
$where[] = "{$field} LIKE {$placeholder}";
$params[$placeholder] = $value . '%';
$applied = true;
break;
case 'ends_with':
$where[] = "{$field} LIKE {$placeholder}";
$params[$placeholder] = '%' . $value;
$applied = true;
break;
case 'is_empty':
$where[] = "({$field} IS NULL OR {$field} = '')";
$applied = true;
break;
case 'is_not_empty':
$where[] = "({$field} IS NOT NULL AND {$field} <> '')";
$applied = true;
break;
}
}
return $applied;
}
function channel_distinct_options(array $fields): array
{
channel_app_bootstrap();
$pdo = db();
$allowed = array_values(array_intersect($fields, CHANNEL_FILTER_OPTION_FIELDS));
$out = [];
foreach ($fields as $field) {
foreach ($allowed as $field) {
$stmt = $pdo->query("SELECT DISTINCT {$field} AS value FROM channels WHERE date_out IS NULL AND {$field} IS NOT NULL AND {$field} <> '' ORDER BY {$field} ASC");
$out[$field] = array_values(array_map(static fn(array $row): string => (string) $row['value'], $stmt->fetchAll()));
}
return $out;
}
function channel_last_changed_field_map(array $channelIds): array
{
$channelIds = array_values(array_unique(array_map('intval', array_filter($channelIds, static fn($id): bool => (int) $id > 0))));
if (!$channelIds) {
return [];
}
$pdo = db();
$placeholders = implode(',', array_fill(0, count($channelIds), '?'));
$sql = "SELECT a.channel_id, a.field_name
FROM audit_log a
INNER JOIN (
SELECT channel_id, MAX(id) AS max_id
FROM audit_log
WHERE field_name <> 'versioned_from' AND channel_id IN ({$placeholders})
GROUP BY channel_id
) latest ON latest.max_id = a.id";
$stmt = $pdo->prepare($sql);
foreach ($channelIds as $index => $channelId) {
$stmt->bindValue($index + 1, $channelId, PDO::PARAM_INT);
}
$stmt->execute();
$map = [];
foreach ($stmt->fetchAll() as $row) {
$map[(int) $row['channel_id']] = (string) $row['field_name'];
}
return $map;
}
function channel_list(array $query): array
{
channel_app_bootstrap();
@ -280,6 +757,7 @@ function channel_list(array $query): array
$filterMap = [
'sat_ref' => 'sat_ref',
'country_iso' => 'country_iso',
'country_client' => 'country_client',
'genre' => 'genre',
'type' => 'type',
'resolution' => 'resolution',
@ -289,6 +767,15 @@ function channel_list(array $query): array
'active' => 'active',
'idtype' => 'idtype',
'manually_edited' => 'manually_edited',
'sat_tpinfo' => 'sat_tpinfo',
'sat_ch_client_upper' => 'sat_ch_client_upper',
'sat_client' => 'sat_client',
'counting' => 'counting',
'lastview' => 'lastview',
'sat_in' => 'sat_in',
'sat_out' => 'sat_out',
'sat_update' => 'sat_update',
'manually_edited_at' => 'manually_edited_at',
];
$where = [];
@ -301,11 +788,17 @@ function channel_list(array $query): array
upper_ch_ref LIKE :search OR country_iso LIKE :search OR country_client LIKE :search OR sat_ref LIKE :search OR
sat_tpinfo LIKE :search OR genre LIKE :search OR type LIKE :search OR resolution LIKE :search OR langue LIKE :search OR
region LIKE :search OR groupe LIKE :search OR six_id LIKE :search OR sid LIKE :search OR onid LIKE :search OR tid LIKE :search OR
sat_client LIKE :search OR sat_ch_client_upper LIKE :search
sat_client LIKE :search OR sat_ch_client_upper LIKE :search OR counting LIKE :search OR lastview LIKE :search OR
sat_in LIKE :search OR sat_out LIKE :search OR sat_update LIKE :search OR manually_edited_at LIKE :search
)";
$params[':search'] = '%' . $query['search'] . '%';
}
$advancedFilters = channel_parse_advanced_filters($query['advanced_filters'] ?? []);
if ($advancedFilters) {
$hasExplicitFilter = channel_apply_advanced_filters($advancedFilters, $where, $params) || $hasExplicitFilter;
}
foreach ($filterMap as $param => $column) {
$values = [];
if (isset($query[$param])) {
@ -367,8 +860,15 @@ function channel_list(array $query): array
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
$lastChangedFields = channel_last_changed_field_map(array_map(static fn(array $row): int => (int) $row['id'], $rows));
foreach ($rows as &$row) {
$row['last_changed_field'] = $lastChangedFields[(int) ($row['id'] ?? 0)] ?? null;
}
unset($row);
return [
'data' => $stmt->fetchAll(),
'data' => $rows,
'pagination' => [
'page' => $page,
'limit' => $limit,
@ -380,7 +880,7 @@ function channel_list(array $query): array
'direction' => $direction,
'filters_applied' => $hasExplicitFilter,
],
'options' => channel_distinct_options(['sat_ref', 'country_iso', 'genre', 'type', 'resolution', 'langue', 'region', 'groupe']),
'options' => channel_distinct_options(CHANNEL_FILTER_OPTION_FIELDS),
];
}
@ -600,6 +1100,87 @@ function channel_bulk_patch(array $ids, array $fields, string $userId): array
];
}
function channel_build_latest_transition(array $versions): array
{
if (!$versions) {
return [
'current_id' => null,
'previous_id' => null,
'changed_fields' => [],
'changes' => [],
'last_changed_field' => null,
'last_change' => null,
];
}
$currentIndex = null;
foreach ($versions as $index => $version) {
if (!empty($version['is_current'])) {
$currentIndex = $index;
break;
}
}
if ($currentIndex === null) {
$currentIndex = count($versions) - 1;
}
$currentVersion = $versions[$currentIndex] ?? null;
$previousVersion = $currentIndex > 0 ? ($versions[$currentIndex - 1] ?? null) : null;
if (!$currentVersion || !$previousVersion) {
return [
'current_id' => $currentVersion['id'] ?? null,
'previous_id' => $previousVersion['id'] ?? null,
'changed_fields' => [],
'changes' => [],
'last_changed_field' => null,
'last_change' => null,
];
}
$changes = [];
foreach (CHANNEL_EDITABLE_FIELDS as $field) {
$oldValue = $previousVersion[$field] ?? null;
$newValue = $currentVersion[$field] ?? null;
if ((string) ($oldValue ?? '') === (string) ($newValue ?? '')) {
continue;
}
$changes[] = [
'field_name' => $field,
'old_value' => $oldValue,
'new_value' => $newValue,
];
}
$lastChange = null;
$currentChanges = $currentVersion['changes'] ?? [];
if ($currentChanges) {
$lastAuditChange = $currentChanges[count($currentChanges) - 1];
$lastField = (string) ($lastAuditChange['field_name'] ?? '');
foreach ($changes as $change) {
if (($change['field_name'] ?? '') === $lastField) {
$lastChange = $change;
break;
}
}
}
if ($lastChange === null && $changes) {
$lastChange = $changes[count($changes) - 1];
}
return [
'current_id' => $currentVersion['id'] ?? null,
'previous_id' => $previousVersion['id'] ?? null,
'changed_fields' => array_values(array_map(static fn(array $change): string => $change['field_name'], $changes)),
'changes' => $changes,
'last_changed_field' => $lastChange['field_name'] ?? null,
'last_change' => $lastChange,
];
}
function channel_history(int $id): array
{
channel_app_bootstrap();
@ -691,10 +1272,13 @@ function channel_history(int $id): array
'is_current' => $row['date_out'] === null,
'previous_id' => $previousMap[$versionId] ?? null,
'upper_ch_ref' => $row['upper_ch_ref'],
'sat_ref' => $row['sat_ref'],
'country_iso' => $row['country_iso'],
'sat_ref' => $row['sat_ref'],
'genre' => $row['genre'],
'type' => $row['type'],
'groupe' => $row['groupe'],
'region' => $row['region'],
'langue' => $row['langue'],
'resolution' => $row['resolution'],
'active' => $row['active'],
'sat_in' => $row['sat_in'],
@ -714,6 +1298,7 @@ function channel_history(int $id): array
'root_id' => $orderedIds[0] ?? $id,
'latest_id' => $orderedIds ? $orderedIds[count($orderedIds) - 1] : $id,
'total_versions' => count($versions),
'latest_transition' => channel_build_latest_transition($versions),
'versions' => $versions,
];
}

View File

@ -163,6 +163,70 @@ code {
}
.btn-dark:hover,
.btn-dark:focus { background: #111827; border-color: #111827; }
.dataset-search-bar {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(248,250,252,0.96));
padding: 1rem;
}
.dataset-search-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 0.75rem;
align-items: center;
}
.dataset-search-input {
min-height: 48px;
font-size: 0.98rem;
}
.advanced-filter-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 48px;
white-space: nowrap;
}
.advanced-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
min-height: 1.5rem;
padding: 0 0.4rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(148, 163, 184, 0.45);
font-size: 0.76rem;
font-weight: 700;
}
.advanced-filters-stack {
display: grid;
gap: 0.9rem;
}
.advanced-filter-row {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-muted);
padding: 1rem;
}
.advanced-filter-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) minmax(0, 1.25fr) auto;
gap: 0.75rem;
align-items: end;
}
.advanced-filter-actions {
display: flex;
justify-content: flex-end;
}
.advanced-filter-empty {
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
background: #fff;
padding: 1rem;
}
.toolbar-strip,
.selection-bar {
border: 1px solid var(--border);
@ -196,6 +260,11 @@ code {
font-size: 0.78rem;
color: var(--text-muted);
}
.filter-chip-advanced {
background: #fff7e6;
border-color: var(--warning-border);
color: #9a3412;
}
.legend {
display: inline-flex;
align-items: center;
@ -214,6 +283,10 @@ code {
background: var(--selection-soft);
border-color: var(--selection-border);
}
.legend-last-change .legend-swatch {
background: rgba(245, 158, 11, 0.16);
border-color: rgba(245, 158, 11, 0.45);
}
.section-tabs .nav-link {
color: var(--text-muted);
border: none;
@ -290,6 +363,17 @@ code {
.channel-table tbody tr.row-manual:hover td.sticky-col { background: #fff2d6; }
.channel-table tbody tr.row-selected:hover td,
.channel-table tbody tr.row-selected:hover td.sticky-col { background: #dbeafe; }
.channel-table tbody td.cell-last-change,
.channel-table tbody td.cell-last-change.sticky-col {
background: rgba(245, 158, 11, 0.16) !important;
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.35);
font-weight: 700;
color: #7c2d12;
}
.channel-table tbody td.cell-last-change .channel-pill {
background: rgba(255, 255, 255, 0.78);
border-color: rgba(245, 158, 11, 0.45);
}
.channel-row { cursor: pointer; }
.sys-col,
.sys-field { color: #98a2b3; }
@ -348,6 +432,53 @@ code {
color: #98a2b3;
}
.detail-section { background: #fff; }
.detail-last-change-note {
border: 1px solid #f7d8a8;
background: #fff7e6;
border-radius: var(--radius-sm);
padding: 0.85rem 1rem;
margin-bottom: 1rem;
}
.detail-last-change-title {
font-weight: 700;
color: #b54708;
margin-bottom: 0.15rem;
}
.detail-last-change-badges,
.history-last-change-note {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.detail-last-change-badge {
display: inline-flex;
align-items: center;
padding: 0.28rem 0.6rem;
border-radius: 999px;
background: #fff;
border: 1px solid #f7d8a8;
color: #b54708;
font-size: 0.78rem;
font-weight: 600;
}
.editable-field-card {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff;
padding: 0.85rem;
height: 100%;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
[data-editable-field].is-last-changed .editable-field-card {
border-color: #f7b955;
background: #fff7e6;
box-shadow: 0 0 0 3px rgba(247, 185, 85, 0.14);
}
[data-editable-field].is-last-changed .form-label {
color: #b54708;
font-weight: 700;
}
.section-subtitle {
font-size: 0.75rem;
text-transform: uppercase;
@ -472,12 +603,17 @@ code {
gap: 0.75rem;
margin-bottom: 1rem;
}
.history-meta-grid > div {
.history-meta-grid > div,
.history-meta-item {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.75rem;
background: var(--surface-muted);
}
.history-meta-item.is-last-change {
border-color: #f7b955;
background: #fff7e6;
}
.history-meta-label,
.history-change-meta {
display: block;
@ -500,6 +636,25 @@ code {
border-top: none;
padding-top: 0;
}
.history-change.is-last-change {
border: 1px solid #f7d8a8;
border-radius: var(--radius-sm);
background: #fffaf0;
padding: 0.75rem;
}
.history-change.is-last-change:first-child {
border-top: 1px solid #f7d8a8;
}
.history-last-change-note {
margin-bottom: 1rem;
}
.history-last-change-label {
font-size: 0.76rem;
font-weight: 700;
color: #b54708;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.history-change-field {
font-weight: 600;
}
@ -511,4 +666,8 @@ code {
.hero-title { max-width: none; }
.toolbar-strip,
.selection-bar { align-items: flex-start; }
.dataset-search-grid,
.advanced-filter-grid { grid-template-columns: 1fr; }
.advanced-filter-actions { justify-content: stretch; }
.advanced-filter-actions .btn { width: 100%; }
}

View File

@ -2,6 +2,7 @@ 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: [],
@ -23,6 +24,15 @@ document.addEventListener('DOMContentLoaded', () => {
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'),
selectAllPageCheckbox: document.getElementById('selectAllPageCheckbox'),
bulkEditBtn: document.getElementById('bulkEditBtn'),
@ -37,6 +47,7 @@ document.addEventListener('DOMContentLoaded', () => {
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'),
@ -53,15 +64,12 @@ document.addEventListener('DOMContentLoaded', () => {
auditDateTo: document.getElementById('auditDateTo'),
applyAuditFiltersBtn: document.getElementById('applyAuditFiltersBtn'),
auditTableBody: document.getElementById('auditTableBody'),
heroTotalChannels: document.getElementById('heroTotalChannels'),
heroActiveChannels: document.getElementById('heroActiveChannels'),
heroManualChannels: document.getElementById('heroManualChannels'),
heroTotalHits: document.getElementById('heroTotalHits'),
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',
@ -95,6 +103,45 @@ document.addEventListener('DOMContentLoaded', () => {
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 formatValue = (value) => {
if (value === null || value === undefined || value === '') return '—';
@ -134,27 +181,128 @@ document.addEventListener('DOMContentLoaded', () => {
return query.toString();
};
const syncFilterForm = () => {
Object.entries(state.filters).forEach(([key, value]) => {
const input = elements.filtersForm?.elements.namedItem(key);
if (input) input.value = value;
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 || {};
const mappings = {
sat_ref: document.getElementById('satRefFilter'),
country_iso: document.getElementById('countryFilter'),
genre: document.getElementById('genreFilter'),
resolution: document.getElementById('resolutionFilter'),
};
Object.entries(mappings).forEach(([field, select]) => {
if (!select) return;
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] || [];
select.innerHTML = `<option value="">${escapeHtml(select.options[0]?.textContent || 'All')}</option>` +
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;
});
@ -162,10 +310,15 @@ document.addEventListener('DOMContentLoaded', () => {
const renderFilterChips = () => {
const ignored = new Set(['page', 'limit']);
const chips = Object.entries(state.filters)
const basicChips = Object.entries(state.filters)
.filter(([key, value]) => !ignored.has(key) && value !== undefined && value !== null && value !== '')
.map(([key, value]) => `<span class="filter-chip">${escapeHtml(key)}: ${escapeHtml(value)}</span>`);
elements.activeFilterChips.innerHTML = chips.join('');
.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 = () => {
@ -186,6 +339,14 @@ document.addEventListener('DOMContentLoaded', () => {
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(' ');
};
@ -201,32 +362,32 @@ document.addEventListener('DOMContentLoaded', () => {
} else {
elements.channelsTableBody.innerHTML = data.map(row => `
<tr class="${rowClasses(row)}" data-row-id="${row.id}">
<td class="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><strong>${escapeHtml(formatValue(row.upper_ch_ref))}</strong></td>
<td>${escapeHtml(formatValue(row.country_iso))}</td>
<td>${escapeHtml(formatValue(row.country_client))}</td>
<td>${escapeHtml(formatValue(row.sat_ref))}</td>
<td>${escapeHtml(formatValue(row.sat_tpinfo))}</td>
<td>${escapeHtml(formatValue(row.genre))}</td>
<td><span class="channel-pill">${escapeHtml(formatValue(row.type))}</span></td>
<td>${escapeHtml(formatValue(row.resolution))}</td>
<td>${escapeHtml(formatValue(row.langue))}</td>
<td>${escapeHtml(formatValue(row.region))}</td>
<td>${escapeHtml(formatValue(row.groupe))}</td>
<td>${escapeHtml(formatValue(row.active))}</td>
<td>${escapeHtml(formatValue(row.idtype))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.six_id))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.sid))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.onid))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.tid))}</td>
<td>${escapeHtml(formatValue(row.sat_in))}</td>
<td>${escapeHtml(formatValue(row.sat_out))}</td>
<td>${escapeHtml(formatValue(row.sat_update))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.sat_ch_client_upper))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.sat_client))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.counting))}</td>
<td class="sys-col">${escapeHtml(formatValue(row.lastview))}</td>
<td>${escapeHtml(formatValue(row.manually_edited_at))}</td>
<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('');
}
@ -241,63 +402,106 @@ document.addEventListener('DOMContentLoaded', () => {
const fetchChannels = async () => {
elements.resultCounter.textContent = 'Loading channels…';
const query = buildQueryString({ ...state.filters, sort: state.sort.field, direction: state.sort.direction });
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">
<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 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">
<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 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">
<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 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">
<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 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">
<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 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>`;
};
@ -398,11 +602,6 @@ document.addEventListener('DOMContentLoaded', () => {
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.heroTotalChannels.textContent = stats.overview.total_channels;
elements.heroActiveChannels.textContent = stats.overview.active_channels;
elements.heroManualChannels.textContent = stats.overview.manually_edited_channels;
elements.heroTotalHits.textContent = Number(stats.overview.total_hits).toLocaleString();
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 => `
@ -427,15 +626,19 @@ document.addEventListener('DOMContentLoaded', () => {
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}).`;
@ -444,7 +647,7 @@ document.addEventListener('DOMContentLoaded', () => {
['Selected row', `#${payload.selected_id}`],
['First version', firstVersion.date_in || `#${firstVersion.id}`],
['Latest version', `#${payload.latest_id}`],
['Total versions', String(payload.total_versions || versions.length)],
['Last edited field', lastChangedField ? (labels[lastChangedField] || lastChangedField) : '—'],
];
elements.historySummary.innerHTML = summaryCards.map(([label, value]) => `
@ -459,16 +662,23 @@ document.addEventListener('DOMContentLoaded', () => {
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 => `
<div class="history-change">
? 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>`;
}).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">
@ -487,11 +697,13 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
</div>
${latestBadges}
<div class="history-meta-grid">
<div><span class="history-meta-label">Type</span><strong>${escapeHtml(formatValue(version.type))}</strong></div>
<div><span class="history-meta-label">Resolution</span><strong>${escapeHtml(formatValue(version.resolution))}</strong></div>
<div><span class="history-meta-label">Active</span><strong>${escapeHtml(formatValue(version.active))}</strong></div>
<div><span class="history-meta-label">sat_out</span><strong>${escapeHtml(formatValue(version.sat_out))}</strong></div>
<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">
@ -500,6 +712,8 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
</article>`;
}).join('');
renderDetailFieldHighlights(latestTransition);
};
const fetchHistory = async (id, options = {}) => {
@ -589,19 +803,46 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
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();
const formData = new FormData(elements.filtersForm);
state.filters = { limit: 50, page: 1 };
for (const [key, value] of formData.entries()) {
if (String(value).trim() !== '') state.filters[key] = String(value).trim();
}
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.');
@ -709,6 +950,56 @@ document.addEventListener('DOMContentLoaded', () => {
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;
@ -814,6 +1105,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
syncFilterForm();
renderAdvancedFilters();
renderHistoryEmpty('Click a row in Dataset to load its previous and current versions.');
refreshAll();
});

298
index.php
View File

@ -7,8 +7,6 @@ channel_app_bootstrap();
$projectName = $_SERVER['PROJECT_NAME'] ?? 'SignalDesk TV';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'TV channels data management workspace for controlled edits, analytics, and auditability.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$now = gmdate('Y-m-d H:i:s');
$stats = channel_stats();
?>
<!doctype html>
<html lang="fr">
@ -61,64 +59,14 @@ $stats = channel_stats();
<main class="container-fluid px-3 px-lg-4 py-4">
<section class="hero-panel mb-4" id="workspace">
<div class="row g-3 align-items-stretch">
<div class="col-12 col-xxl-7">
<div class="card panel-card h-100">
<div class="card-body p-4">
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<span class="eyebrow">Initial MVP slice</span>
<span class="status-chip">Read-only source data · whitelisted PATCH only</span>
</div>
<h1 class="hero-title">TV channels workspace with strict edits, bulk operations, analytics, and full audit trace.</h1>
<p class="hero-copy mb-4">Built as an internal operator console: browse active and historical channels, filter by business dimensions, update only allowed metadata fields, and review every field-level change in the audit stream.</p>
<div class="row g-3 metrics-inline">
<div class="col-sm-6 col-lg-3">
<div class="metric-tile">
<div class="metric-label">Total channels</div>
<div class="metric-value" id="heroTotalChannels"><?= (int) ($stats['overview']['total_channels'] ?? 0) ?></div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="metric-tile">
<div class="metric-label">Active now</div>
<div class="metric-value" id="heroActiveChannels"><?= (int) ($stats['overview']['active_channels'] ?? 0) ?></div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="metric-tile metric-tile-warm">
<div class="metric-label">Manual edits</div>
<div class="metric-value" id="heroManualChannels"><?= (int) ($stats['overview']['manually_edited_channels'] ?? 0) ?></div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="metric-tile">
<div class="metric-label">Total hits</div>
<div class="metric-value" id="heroTotalHits"><?= number_format((int) ($stats['overview']['total_hits'] ?? 0)) ?></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xxl-5">
<div class="card panel-card h-100">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="section-title mb-0">Operating rules</h2>
<span class="text-muted small">UTC <?= htmlspecialchars($now) ?></span>
</div>
<div class="rule-list">
<div class="rule-item"><span class="rule-dot"></span> No UI insertion path source rows remain pushimatic-owned.</div>
<div class="rule-item"><span class="rule-dot"></span> System identifiers stay locked; forbidden edits return HTTP 403.</div>
<div class="rule-item"><span class="rule-dot"></span> Single-row and bulk edits both flag <code>manually_edited</code>.</div>
<div class="rule-item"><span class="rule-dot"></span> Every changed field creates an <code>audit_log</code> entry.</div>
</div>
<div class="surface-note mt-4">
<div class="surface-note-title">Default query behavior</div>
<p class="mb-0">If no filter is applied, the list loads only current active rows with <code>date_out IS NULL</code> and <code>sat_out IS NULL</code>.</p>
</div>
</div>
<div class="card panel-card">
<div class="card-body p-4 p-lg-5">
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<span class="eyebrow">Channel workspace</span>
<span class="status-chip">Fast search · Technical filters · Version history</span>
</div>
<h1 class="hero-title">Find channels faster, filter every technical field, and spot the latest edit instantly.</h1>
<p class="hero-copy mb-0">Use the quick search bar on top for anything specific, then narrow results with the detailed filters on the left.</p>
</div>
</div>
</section>
@ -130,73 +78,138 @@ $stats = channel_stats();
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="section-title mb-1">Filters</h2>
<p class="text-muted small mb-0">All filters combine with AND logic.</p>
<p class="text-muted small mb-0">Use quick search on top for free text, then apply exact filters here.</p>
</div>
<button class="btn btn-sm btn-outline-secondary" id="resetFiltersBtn">Reset</button>
</div>
<form id="filtersForm" class="vstack gap-3" autocomplete="off">
<form id="filtersForm" class="vstack gap-4" autocomplete="off">
<input id="searchInput" name="search" type="hidden" value="">
<div>
<label class="form-label" for="searchInput">Global search</label>
<input id="searchInput" name="search" class="form-control" type="search" placeholder="Channel, satellite, system id…">
</div>
<div class="row g-3">
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="satRefFilter">Satellite</label>
<select id="satRefFilter" name="sat_ref" class="form-select filter-select"><option value="">All satellites</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="countryFilter">Country ISO</label>
<select id="countryFilter" name="country_iso" class="form-select filter-select"><option value="">All countries</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="genreFilter">Genre</label>
<select id="genreFilter" name="genre" class="form-select filter-select"><option value="">All genres</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="typeFilter">Type</label>
<select id="typeFilter" name="type" class="form-select filter-select">
<option value="">All types</option>
<option value="free">free</option>
<option value="payed">payed</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="resolutionFilter">Resolution</label>
<select id="resolutionFilter" name="resolution" class="form-select filter-select"><option value="">All resolutions</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="activeFilter">Active</label>
<select id="activeFilter" name="active" class="form-select">
<option value="">Default logic</option>
<option value="1">1</option>
<option value="0">0</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="manualFilter">Manual flag</label>
<select id="manualFilter" name="manually_edited" class="form-select">
<option value="">All</option>
<option value="1">Edited</option>
<option value="0">Not edited</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="idtypeFilter">Id type</label>
<select id="idtypeFilter" name="idtype" class="form-select">
<option value="">All</option>
<option value="1">Known (1)</option>
<option value="2">Unknown (2)</option>
</select>
<div class="section-subtitle mb-2">Main filters</div>
<div class="row g-3">
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="satRefFilter">Satellite</label>
<select id="satRefFilter" name="sat_ref" class="form-select filter-select" data-default-option="All satellites"><option value="">All satellites</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="countryFilter">Country ISO</label>
<select id="countryFilter" name="country_iso" class="form-select filter-select" data-default-option="All countries"><option value="">All countries</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="countryClientFilter">Country client</label>
<select id="countryClientFilter" name="country_client" class="form-select filter-select" data-default-option="All client countries"><option value="">All client countries</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="genreFilter">Genre</label>
<select id="genreFilter" name="genre" class="form-select filter-select" data-default-option="All genres"><option value="">All genres</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="typeFilter">Type</label>
<select id="typeFilter" name="type" class="form-select">
<option value="">All types</option>
<option value="free">free</option>
<option value="payed">payed</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="resolutionFilter">Resolution</label>
<select id="resolutionFilter" name="resolution" class="form-select filter-select" data-default-option="All resolutions"><option value="">All resolutions</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="languageFilter">Language</label>
<select id="languageFilter" name="langue" class="form-select filter-select" data-default-option="All languages"><option value="">All languages</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="regionFilter">Region</label>
<select id="regionFilter" name="region" class="form-select filter-select" data-default-option="All regions"><option value="">All regions</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="groupFilter">Group</label>
<select id="groupFilter" name="groupe" class="form-select filter-select" data-default-option="All groups"><option value="">All groups</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="activeFilter">Active</label>
<select id="activeFilter" name="active" class="form-select">
<option value="">Default logic</option>
<option value="1">1</option>
<option value="0">0</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="manualFilter">Manual flag</label>
<select id="manualFilter" name="manually_edited" class="form-select">
<option value="">All</option>
<option value="1">Edited</option>
<option value="0">Not edited</option>
</select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="idtypeFilter">Id type</label>
<select id="idtypeFilter" name="idtype" class="form-select">
<option value="">All</option>
<option value="1">Known (1)</option>
<option value="2">Unknown (2)</option>
</select>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="sixIdFilter">six_id</label>
<input id="sixIdFilter" name="six_id" class="form-control" type="text" placeholder="Exact match">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="sidFilter">sid</label>
<input id="sidFilter" name="sid" class="form-control" type="text" placeholder="Exact match">
<div>
<div class="section-subtitle mb-2">Technical filters</div>
<div class="row g-3">
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="tpInfoFilter">sat_tpinfo</label>
<select id="tpInfoFilter" name="sat_tpinfo" class="form-select filter-select" data-default-option="All transponders"><option value="">All transponders</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="sixIdFilter">six_id</label>
<input id="sixIdFilter" name="six_id" class="form-control" type="text" placeholder="Exact match">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="sidFilter">sid</label>
<input id="sidFilter" name="sid" class="form-control" type="text" placeholder="Exact match">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="onidFilter">onid</label>
<input id="onidFilter" name="onid" class="form-control" type="text" placeholder="Exact match">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="tidFilter">tid</label>
<input id="tidFilter" name="tid" class="form-control" type="text" placeholder="Exact match">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="clientChannelFilter">sat_ch_client_upper</label>
<select id="clientChannelFilter" name="sat_ch_client_upper" class="form-select filter-select" data-default-option="All client channels"><option value="">All client channels</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="clientBouquetFilter">sat_client</label>
<select id="clientBouquetFilter" name="sat_client" class="form-select filter-select" data-default-option="All bouquets"><option value="">All bouquets</option></select>
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="countingFilter">counting</label>
<input id="countingFilter" name="counting" class="form-control" type="text" placeholder="Exact match">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="lastviewFilter">lastview</label>
<input id="lastviewFilter" name="lastview" class="form-control" type="text" placeholder="Exact match">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="satInFilter">sat_in</label>
<input id="satInFilter" name="sat_in" class="form-control" type="date">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="satOutFilter">sat_out</label>
<input id="satOutFilter" name="sat_out" class="form-control" type="date">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="satUpdateFilter">sat_update</label>
<input id="satUpdateFilter" name="sat_update" class="form-control" type="date">
</div>
<div class="col-sm-6 col-xl-12">
<label class="form-label" for="manualEditedAtFilter">manually_edited_at</label>
<input id="manualEditedAtFilter" name="manually_edited_at" class="form-control" type="date">
</div>
</div>
</div>
<button class="btn btn-dark" type="submit">Apply filters</button>
@ -212,7 +225,7 @@ $stats = channel_stats();
<div class="d-flex flex-column flex-lg-row align-items-lg-center justify-content-between gap-3">
<div>
<h2 class="section-title mb-1">Channels</h2>
<p class="text-muted small mb-0">Full dataset browser with strict editable fields and protected system metadata.</p>
<p class="text-muted small mb-0">Search on top, then narrow the dataset with detailed business and technical filters.</p>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-outline-secondary btn-sm" id="selectPageBtn">Select page</button>
@ -220,9 +233,25 @@ $stats = channel_stats();
</div>
</div>
<form id="topSearchForm" class="dataset-search-bar" autocomplete="off">
<div class="dataset-search-grid">
<div class="dataset-search-input-wrap">
<label class="visually-hidden" for="topSearchInput">Quick search</label>
<input id="topSearchInput" class="form-control dataset-search-input" type="search" placeholder="Search channel, satellite, transponder, client, or system id…">
</div>
<button class="btn btn-outline-secondary advanced-filter-trigger" type="button" id="advancedFiltersBtn" data-bs-toggle="modal" data-bs-target="#advancedFilterModal">
Advanced filter
<span class="advanced-filter-count d-none" id="advancedFilterCount">0</span>
</button>
<button class="btn btn-dark" type="submit">Search</button>
<button class="btn btn-outline-secondary" type="button" id="clearSearchBtn">Clear</button>
</div>
</form>
<div class="toolbar-strip">
<div class="toolbar-left">
<span class="legend legend-manual"><span class="legend-swatch"></span> Manually edited</span>
<span class="legend legend-last-change"><span class="legend-swatch"></span> Last edited field</span>
<span class="legend legend-selected"><span class="legend-swatch"></span> Selected row</span>
</div>
<div class="toolbar-right small text-muted">
@ -370,6 +399,7 @@ $stats = channel_stats();
<div id="detailEditableSection" class="d-none">
<div class="detail-section px-4 py-4 border-bottom">
<div class="section-subtitle">Editable fields</div>
<div id="detailLastChangeNotice" class="detail-last-change-note d-none"></div>
<div class="row g-3" id="editableFieldsContainer"></div>
</div>
<div class="detail-section px-4 py-4 border-bottom bg-light-subtle">
@ -412,6 +442,32 @@ $stats = channel_stats();
</div>
</div>
<div class="modal fade" id="advancedFilterModal" tabindex="-1" aria-labelledby="advancedFilterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header border-bottom">
<div>
<h2 class="modal-title h5 mb-1" id="advancedFilterModalLabel">Advanced filter builder</h2>
<p class="small text-muted mb-0">Build a deeper search like <code>sid contains 123</code> or <code>Frequency / Transponder equals 11001</code>. Every rule is combined together.</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-light border small mb-4">Use this when the normal dropdown filters are too strict. You can mix contains, equals, does not contain, and not equal rules.</div>
<div id="advancedFiltersContainer" class="advanced-filters-stack"></div>
<button type="button" class="btn btn-outline-secondary mt-3" id="addAdvancedFilterBtn">Add rule</button>
</div>
<div class="modal-footer border-top d-flex justify-content-between gap-2 flex-wrap">
<button type="button" class="btn btn-outline-secondary" id="clearAdvancedFiltersBtn">Clear rules</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-dark" id="applyAdvancedFiltersBtn">Apply advanced filters</button>
</div>
</div>
</div>
</div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer"></div>
<script>