From cc0991358505a1f350550aeacb5609d776bade2a Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 5 Apr 2026 11:27:52 +0000 Subject: [PATCH] v2 --- api/channels.php | 2 +- app/channel_data.php | 595 +++++++++++++++++++++++++++++++++++++++++- assets/css/custom.css | 161 +++++++++++- assets/js/main.js | 470 ++++++++++++++++++++++++++------- index.php | 298 ++++++++++++--------- 5 files changed, 1309 insertions(+), 217 deletions(-) diff --git a/api/channels.php b/api/channels.php index f666d20..fdc61a0 100644 --- a/api/channels.php +++ b/api/channels.php @@ -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), ]]); } diff --git a/app/channel_data.php b/app/channel_data.php index 5d8dad7..bcfc3a1 100644 --- a/app/channel_data.php +++ b/app/channel_data.php @@ -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, ]; } diff --git a/assets/css/custom.css b/assets/css/custom.css index ccf6601..1e3ba5a 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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%; } } diff --git a/assets/js/main.js b/assets/js/main.js index a7f05b1..a908d51 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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 = ` +
+ No advanced rules yet. +
Example: sid contains 10 or Frequency / Transponder equals 11001.
+
`; + return; + } + + const fieldOptions = advancedFieldOptions + .map((item) => ``) + .join(''); + const operatorOptions = Object.entries(advancedOperatorLabels) + .map(([value, label]) => ``) + .join(''); + + elements.advancedFiltersContainer.innerHTML = rules.map((rule, index) => ` +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
`).join(''); + + [...elements.advancedFiltersContainer.querySelectorAll('.advanced-filter-row')].forEach((row, index) => { + const rule = rules[index] || {}; + const fieldSelect = row.querySelector('[data-advanced-field]'); + const operatorSelect = row.querySelector('[data-advanced-operator]'); + const valueInput = row.querySelector('[data-advanced-value]'); + if (fieldSelect) fieldSelect.value = rule.field || 'sid'; + if (operatorSelect) operatorSelect.value = rule.operator || 'contains'; + if (valueInput) { + valueInput.value = rule.value || ''; + valueInput.disabled = !advancedFilterNeedsValue(operatorSelect?.value || 'contains'); + } }); }; + const collectAdvancedFilters = () => { + const rows = [...(elements.advancedFiltersContainer?.querySelectorAll('.advanced-filter-row') || [])]; + const rules = []; + + for (const row of rows) { + const field = row.querySelector('[data-advanced-field]')?.value?.trim() || ''; + const operator = row.querySelector('[data-advanced-operator]')?.value?.trim() || 'contains'; + const value = row.querySelector('[data-advanced-value]')?.value?.trim() || ''; + + if (!field) continue; + if (advancedFilterNeedsValue(operator) && value === '') { + notify('Complete every advanced rule or remove the empty one.', 'danger'); + return null; + } + + rules.push({ field, operator, value }); + } + + return rules; + }; + + const syncFilterForm = () => { + const searchValue = state.filters.search || ''; + if (elements.topSearchInput) elements.topSearchInput.value = searchValue; + + if (elements.filtersForm) { + elements.filtersForm.reset(); + Object.entries(state.filters).forEach(([key, value]) => { + const input = elements.filtersForm.elements.namedItem(key); + if (input) input.value = value; + }); + } + + syncAdvancedFilterBadge(); + }; + const renderFilterOptions = (options) => { state.options = options || {}; - 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 = `` + + const defaultLabel = select.dataset.defaultOption || select.options[0]?.textContent || 'All'; + select.innerHTML = `` + list.map(item => ``).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]) => `${escapeHtml(key)}: ${escapeHtml(value)}`); - elements.activeFilterChips.innerHTML = chips.join(''); + .map(([key, value]) => { + const label = key === 'search' ? 'Search' : (labels[key] || key); + return `${escapeHtml(label)}: ${escapeHtml(value)}`; + }); + const advancedChips = state.advancedFilters + .map((rule) => `Rule: ${escapeHtml(describeAdvancedFilter(rule))}`); + elements.activeFilterChips.innerHTML = [...basicChips, ...advancedChips].join(''); }; const updateSelectionUI = () => { @@ -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 => ` - - ${escapeHtml(formatValue(row.upper_ch_ref))} - ${escapeHtml(formatValue(row.country_iso))} - ${escapeHtml(formatValue(row.country_client))} - ${escapeHtml(formatValue(row.sat_ref))} - ${escapeHtml(formatValue(row.sat_tpinfo))} - ${escapeHtml(formatValue(row.genre))} - ${escapeHtml(formatValue(row.type))} - ${escapeHtml(formatValue(row.resolution))} - ${escapeHtml(formatValue(row.langue))} - ${escapeHtml(formatValue(row.region))} - ${escapeHtml(formatValue(row.groupe))} - ${escapeHtml(formatValue(row.active))} - ${escapeHtml(formatValue(row.idtype))} - ${escapeHtml(formatValue(row.six_id))} - ${escapeHtml(formatValue(row.sid))} - ${escapeHtml(formatValue(row.onid))} - ${escapeHtml(formatValue(row.tid))} - ${escapeHtml(formatValue(row.sat_in))} - ${escapeHtml(formatValue(row.sat_out))} - ${escapeHtml(formatValue(row.sat_update))} - ${escapeHtml(formatValue(row.sat_ch_client_upper))} - ${escapeHtml(formatValue(row.sat_client))} - ${escapeHtml(formatValue(row.counting))} - ${escapeHtml(formatValue(row.lastview))} - ${escapeHtml(formatValue(row.manually_edited_at))} + + ${escapeHtml(formatValue(row.upper_ch_ref))} + ${escapeHtml(formatValue(row.country_iso))} + ${escapeHtml(formatValue(row.country_client))} + ${escapeHtml(formatValue(row.sat_ref))} + ${escapeHtml(formatValue(row.sat_tpinfo))} + ${escapeHtml(formatValue(row.genre))} + ${escapeHtml(formatValue(row.type))} + ${escapeHtml(formatValue(row.resolution))} + ${escapeHtml(formatValue(row.langue))} + ${escapeHtml(formatValue(row.region))} + ${escapeHtml(formatValue(row.groupe))} + ${escapeHtml(formatValue(row.active))} + ${escapeHtml(formatValue(row.idtype))} + ${escapeHtml(formatValue(row.six_id))} + ${escapeHtml(formatValue(row.sid))} + ${escapeHtml(formatValue(row.onid))} + ${escapeHtml(formatValue(row.tid))} + ${escapeHtml(formatValue(row.sat_in))} + ${escapeHtml(formatValue(row.sat_out))} + ${escapeHtml(formatValue(row.sat_update))} + ${escapeHtml(formatValue(row.sat_ch_client_upper))} + ${escapeHtml(formatValue(row.sat_client))} + ${escapeHtml(formatValue(row.counting))} + ${escapeHtml(formatValue(row.lastview))} + ${escapeHtml(formatValue(row.manually_edited_at))} `).join(''); } @@ -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 = ` +
Last edited field
+
Only the newest changed field is highlighted.
+
${escapeHtml(labels[lastChangedField] || lastChangedField)}
`; + }; + const renderDetailForm = (channel) => { state.currentDetail = channel; elements.detailChannelId.value = channel.id; elements.detailLoadingState.classList.add('d-none'); elements.detailEditableSection.classList.remove('d-none'); + renderDetailFieldHighlights(null); const buildInput = (field, value) => { const label = labels[field] || field; if (field === 'active') { return ` -
- - +
+
+ + +
`; } if (field === 'type') { return ` -
- - +
+
+ + +
`; } if (selectOptionsFields.includes(field)) { const opts = state.options[field] || []; return ` -
- - +
+
+ + +
`; } if (['sat_in', 'sat_out', 'sat_update'].includes(field)) { return ` -
- - +
+
+ + +
`; } return ` -
- - +
+
+ + +
`; }; @@ -398,11 +602,6 @@ document.addEventListener('DOMContentLoaded', () => { elements.analyticsOverview.innerHTML = overview.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).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 = '
No channel selected yet.
'; elements.historyTimeline.innerHTML = `
${escapeHtml(message)}
`; + renderDetailFieldHighlights(null); }; const renderHistory = (payload) => { const versions = payload.versions || []; if (!versions.length) { renderHistoryEmpty('No history entries were found for this channel.'); + renderDetailFieldHighlights(null); return; } + const latestTransition = payload.latest_transition || {}; + const lastChangedField = latestTransition.last_changed_field || null; const currentVersion = versions.find(version => Number(version.is_current) === 1 || version.is_current === true) || versions[versions.length - 1]; const firstVersion = versions[0]; elements.historyLead.textContent = `Showing ${versions.length} version(s) for ${currentVersion.upper_ch_ref || 'selected channel'} (current row #${currentVersion.id}).`; @@ -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 => ` -
+ ? version.changes.map(change => { + const isLastChange = version.is_current && lastChangedField === change.field_name; + return ` +
${escapeHtml(labels[change.field_name] || change.field_name)} ${escapeHtml(formatValue(change.old_value))} ${escapeHtml(formatValue(change.new_value))} ${escapeHtml(formatValue(change.user_id))} · ${escapeHtml(formatValue(change.changed_at))} -
`).join('') +
`; + }).join('') : '
Initial imported/current row snapshot with no manual field changes recorded for this version.
'; + const latestBadges = version.is_current && lastChangedField + ? `
Last edited field:${escapeHtml(labels[lastChangedField] || lastChangedField)}
` + : ''; + return `
@@ -487,11 +697,13 @@ document.addEventListener('DOMContentLoaded', () => {
+ ${latestBadges} +
-
Type${escapeHtml(formatValue(version.type))}
-
Resolution${escapeHtml(formatValue(version.resolution))}
-
Active${escapeHtml(formatValue(version.active))}
-
sat_out${escapeHtml(formatValue(version.sat_out))}
+
Type${escapeHtml(formatValue(version.type))}
+
Resolution${escapeHtml(formatValue(version.resolution))}
+
Active${escapeHtml(formatValue(version.active))}
+
sat_out${escapeHtml(formatValue(version.sat_out))}
@@ -500,6 +712,8 @@ document.addEventListener('DOMContentLoaded', () => {
`; }).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(); }); diff --git a/index.php b/index.php index 74196a5..055611f 100644 --- a/index.php +++ b/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(); ?> @@ -61,64 +59,14 @@ $stats = channel_stats();
-
-
-
-
-
- Initial MVP slice - Read-only source data · whitelisted PATCH only -
-

TV channels workspace with strict edits, bulk operations, analytics, and full audit trace.

-

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.

-
-
-
-
Total channels
-
-
-
-
-
-
Active now
-
-
-
-
-
-
Manual edits
-
-
-
-
-
-
Total hits
-
-
-
-
-
-
-
-
-
-
-
-

Operating rules

- UTC -
-
-
No UI insertion path — source rows remain pushimatic-owned.
-
System identifiers stay locked; forbidden edits return HTTP 403.
-
Single-row and bulk edits both flag manually_edited.
-
Every changed field creates an audit_log entry.
-
-
-
Default query behavior
-

If no filter is applied, the list loads only current active rows with date_out IS NULL and sat_out IS NULL.

-
-
+
+
+
+ Channel workspace + Fast search · Technical filters · Version history
+

Find channels faster, filter every technical field, and spot the latest edit instantly.

+

Use the quick search bar on top for anything specific, then narrow results with the detailed filters on the left.

@@ -130,73 +78,138 @@ $stats = channel_stats();

Filters

-

All filters combine with AND logic.

+

Use quick search on top for free text, then apply exact filters here.

-
+ + +
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - +
Main filters
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-
- - -
-
- - + +
+
Technical filters
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -212,7 +225,7 @@ $stats = channel_stats();

Channels

-

Full dataset browser with strict editable fields and protected system metadata.

+

Search on top, then narrow the dataset with detailed business and technical filters.

@@ -220,9 +233,25 @@ $stats = channel_stats();
+ +
+
+ + +
+ + + +
+ +
Manually edited + Last edited field Selected row
@@ -370,6 +399,7 @@ $stats = channel_stats();
Editable fields
+
@@ -412,6 +442,32 @@ $stats = channel_stats();
+ +