v2
This commit is contained in:
parent
8be8405504
commit
cc09913585
@ -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),
|
||||
]]);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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%; }
|
||||
}
|
||||
|
||||
@ -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
298
index.php
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user