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) => `${escapeHtml(item.label)} `)
+ .join('');
+ const operatorOptions = Object.entries(advancedOperatorLabels)
+ .map(([value, label]) => `${escapeHtml(label)} `)
+ .join('');
+
+ elements.advancedFiltersContainer.innerHTML = rules.map((rule, index) => `
+
+
+
+ Field
+ ${fieldOptions}
+
+
+ Operator
+ ${operatorOptions}
+
+
+ Value
+
+
+
+ Remove
+
+
+
`).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 = `${escapeHtml(select.options[0]?.textContent || 'All')} ` +
+ const defaultLabel = select.dataset.defaultOption || select.options[0]?.textContent || 'All';
+ select.innerHTML = `${escapeHtml(defaultLabel)} ` +
list.map(item => `${escapeHtml(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 `
-
-
${escapeHtml(label)}
-
- 1
- 0
-
+
+
+ ${escapeHtml(label)}
+
+ 1
+ 0
+
+
`;
}
if (field === 'type') {
return `
-
-
${escapeHtml(label)}
-
- free
- payed
-
+
+
+ ${escapeHtml(label)}
+
+ free
+ payed
+
+
`;
}
if (selectOptionsFields.includes(field)) {
const opts = state.options[field] || [];
return `
-
-
${escapeHtml(label)}
-
- Select
- ${opts.map(item => `${escapeHtml(item)} `).join('')}
-
+
+
+ ${escapeHtml(label)}
+
+ Select
+ ${opts.map(item => `${escapeHtml(item)} `).join('')}
+
+
`;
}
if (['sat_in', 'sat_out', 'sat_update'].includes(field)) {
return `
-
-
${escapeHtml(label)}
-
+
`;
}
return `
-
-
${escapeHtml(label)}
-
+
`;
};
@@ -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 = '
';
elements.historyTimeline.innerHTML = `
`;
+ 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
-
= (int) ($stats['overview']['total_channels'] ?? 0) ?>
-
-
-
-
-
Active now
-
= (int) ($stats['overview']['active_channels'] ?? 0) ?>
-
-
-
-
-
Manual edits
-
= (int) ($stats['overview']['manually_edited_channels'] ?? 0) ?>
-
-
-
-
-
Total hits
-
= number_format((int) ($stats['overview']['total_hits'] ?? 0)) ?>
-
-
-
-
-
-
-
-
-
-
-
Operating rules
- UTC = htmlspecialchars($now) ?>
-
-
-
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.
Reset
-