feat: Implement campaign synchronization feature with dropdown UI

- Updated SCSS styles for new campaign sync buttons and dropdowns.
- Refactored main_view.php to replace the single select for campaigns with a multi-select dropdown.
- Added JavaScript functions to handle dropdown interactions and sync status updates.
- Introduced sync status bars for clients in main_view.php.
- Created new database migrations for client sync flags and cron sync status tracking.
This commit is contained in:
2026-02-19 12:33:14 +01:00
parent bfbcb1c871
commit 38082c5bac
13 changed files with 1039 additions and 838 deletions

View File

@@ -14,7 +14,8 @@
"Bash(cd:*)",
"Bash(git push:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"Bash(php:*)"
]
}
}

View File

@@ -69,4 +69,56 @@ class Clients
echo json_encode( $client ?: [] );
exit;
}
static public function sync_status()
{
global $mdb;
// Kampanie: 1 work unit per row (pending=0, done=1)
$campaigns_raw = $mdb->query(
"SELECT client_id, COUNT(*) as total,
SUM(CASE WHEN phase='done' THEN 1 ELSE 0 END) as done
FROM cron_sync_status WHERE pipeline='campaigns' GROUP BY client_id"
)->fetchAll( \PDO::FETCH_ASSOC );
// Produkty: 3 work units per row (pending=0, fetch=1, aggregate_30=2, done=3)
$products_raw = $mdb->query(
"SELECT client_id, COUNT(*) * 3 as total,
SUM(CASE phase WHEN 'fetch' THEN 1 WHEN 'aggregate_30' THEN 2 WHEN 'done' THEN 3 ELSE 0 END) as done
FROM cron_sync_status WHERE pipeline='products' GROUP BY client_id"
)->fetchAll( \PDO::FETCH_ASSOC );
$data = [];
foreach ( $campaigns_raw as $row )
{
$data[ $row['client_id'] ]['campaigns'] = [ (int) $row['done'], (int) $row['total'] ];
}
foreach ( $products_raw as $row )
{
$data[ $row['client_id'] ]['products'] = [ (int) $row['done'], (int) $row['total'] ];
}
echo json_encode( [ 'status' => 'ok', 'data' => $data ] );
exit;
}
static public function force_sync()
{
global $mdb;
$id = (int) \S::get( 'id' );
if ( !$id )
{
echo json_encode( [ 'success' => false, 'message' => 'Brak ID klienta.' ] );
exit;
}
$mdb -> delete( 'cron_sync_status', [ 'client_id' => $id ] );
echo json_encode( [ 'success' => true ] );
exit;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -156,24 +156,20 @@ class Users
$base_url = self::get_base_url();
$clients_total = (int) $mdb -> query( "SELECT COUNT(*) FROM clients WHERE deleted = 0 AND google_ads_customer_id IS NOT NULL AND google_ads_customer_id <> ''" ) -> fetchColumn();
$campaign_window_state = self::get_setting_json( 'cron_campaigns_window_state' );
$campaign_daily_state = self::get_setting_json( 'cron_campaigns_state' );
$campaign_dates = self::normalize_dates( $campaign_window_state['sync_dates'] ?? [] );
$campaign_dates_count = count( $campaign_dates );
if ( $campaign_dates_count < 1 )
{
$campaign_dates = [ date( 'Y-m-d' ) ];
$campaign_dates_count = 1;
}
// --- Kampanie ---
$campaign_stats = $mdb -> query(
"SELECT COUNT(*) as total,
SUM(CASE WHEN phase = 'done' THEN 1 ELSE 0 END) as done,
COUNT(DISTINCT sync_date) as dates_count,
MIN(CASE WHEN phase != 'done' THEN sync_date END) as active_date
FROM cron_sync_status WHERE pipeline = 'campaigns'"
) -> fetch( \PDO::FETCH_ASSOC );
$campaign_current_date_index = (int) ( $campaign_window_state['current_date_index'] ?? 0 );
$campaign_current_date_index = max( 0, min( $campaign_dates_count - 1, $campaign_current_date_index ) );
$campaign_processed_today = count( self::normalize_ids( $campaign_daily_state['processed_ids'] ?? [] ) );
$campaign_processed_today = min( $clients_total, $campaign_processed_today );
$campaign_total = $clients_total * $campaign_dates_count;
$campaign_processed = min( $campaign_total, ( $campaign_current_date_index * $clients_total ) + $campaign_processed_today );
$campaign_total = (int) ( $campaign_stats['total'] ?? 0 );
$campaign_processed = (int) ( $campaign_stats['done'] ?? 0 );
$campaign_dates_count = max( 1, (int) ( $campaign_stats['dates_count'] ?? 1 ) );
$campaign_active_date = $campaign_stats['active_date'] ?? '';
$campaign_remaining = max( 0, $campaign_total - $campaign_processed );
$campaign_active_date = $campaign_window_state['sync_date'] ?? ( $campaign_dates[ $campaign_current_date_index ] ?? '' );
$campaign_meta = 'Aktywny dzień: ' . ( $campaign_active_date ?: '-' ) . ', okno dni: ' . $campaign_dates_count;
$campaign_eta_meta = self::build_eta_meta( 'cron_campaigns', $campaign_remaining );
if ( $campaign_eta_meta !== '' )
@@ -181,64 +177,44 @@ class Users
$campaign_meta .= ', ' . $campaign_eta_meta;
}
$products_state = self::get_setting_json( 'cron_products_pipeline_state' );
$products_dates = self::normalize_dates( $products_state['import_dates'] ?? [] );
$products_dates_count = count( $products_dates );
if ( $products_dates_count < 1 )
{
$products_dates = [ date( 'Y-m-d' ) ];
$products_dates_count = 1;
}
// --- Produkty (3 work units per row: fetch, aggregate_30, aggregate_temp) ---
$products_stats = $mdb -> query(
"SELECT COUNT(*) as total,
SUM(CASE phase
WHEN 'fetch' THEN 1
WHEN 'aggregate_30' THEN 2
WHEN 'done' THEN 3
ELSE 0
END) as work_done,
COUNT(DISTINCT sync_date) as dates_count,
MIN(CASE WHEN phase != 'done' THEN sync_date END) as active_date
FROM cron_sync_status WHERE pipeline = 'products'"
) -> fetch( \PDO::FETCH_ASSOC );
$products_current_date_index = (int) ( $products_state['current_date_index'] ?? 0 );
$products_current_date_index = max( 0, min( $products_dates_count - 1, $products_current_date_index ) );
$products_phase = (string) ( $products_state['phase'] ?? 'fetch' );
$products_fetch_done = count( self::normalize_ids( $products_state['fetch_done_ids'] ?? [] ) );
$products_aggregate_30_done = count( self::normalize_ids( $products_state['aggregate_30_done_ids'] ?? [] ) );
$products_aggregate_temp_done = count( self::normalize_ids( $products_state['aggregate_temp_done_ids'] ?? [] ) );
$products_fetch_done = min( $clients_total, $products_fetch_done );
$products_aggregate_30_done = min( $clients_total, $products_aggregate_30_done );
$products_aggregate_temp_done = min( $clients_total, $products_aggregate_temp_done );
$products_per_day_total = $clients_total * 3;
$products_total = $products_per_day_total * $products_dates_count;
$products_done_in_day = 0;
if ( $products_phase === 'aggregate_30' )
{
$products_done_in_day = $clients_total + $products_aggregate_30_done;
}
else if ( $products_phase === 'aggregate_temp' )
{
$products_done_in_day = ( $clients_total * 2 ) + $products_aggregate_temp_done;
}
else if ( $products_phase === 'done' )
{
$products_done_in_day = $products_per_day_total;
}
else
{
$products_done_in_day = $products_fetch_done;
}
$products_done_in_day = min( $products_per_day_total, $products_done_in_day );
$products_processed = min( $products_total, ( $products_current_date_index * $products_per_day_total ) + $products_done_in_day );
if ( $products_phase === 'done' )
{
$products_processed = $products_total;
}
$products_row_count = (int) ( $products_stats['total'] ?? 0 );
$products_work_done = (int) ( $products_stats['work_done'] ?? 0 );
$products_total = $products_row_count * 3;
$products_processed = min( $products_total, $products_work_done );
$products_dates_count = max( 1, (int) ( $products_stats['dates_count'] ?? 1 ) );
$products_active_date = $products_stats['active_date'] ?? '';
$products_remaining = max( 0, $products_total - $products_processed );
$products_phase_labels = [
'fetch' => 'Pobieranie',
'aggregate_30' => 'Agregacja 30 dni',
'aggregate_temp' => 'Agregacja temp',
'done' => 'Zakończono'
];
$products_phase_label = $products_phase_labels[ $products_phase ] ?? $products_phase;
$products_active_date = $products_state['import_date'] ?? ( $products_dates[ $products_current_date_index ] ?? '' );
$products_phase_label = 'Zakończono';
if ( $products_active_date )
{
$current_phase = $mdb -> query(
"SELECT phase FROM cron_sync_status cs INNER JOIN clients c ON cs.client_id = c.id AND c.deleted = 0 WHERE cs.pipeline = 'products' AND cs.sync_date = :sync_date AND cs.phase != 'done' ORDER BY FIELD(cs.phase, 'pending', 'fetch', 'aggregate_30') ASC LIMIT 1",
[ ':sync_date' => $products_active_date ]
) -> fetchColumn();
$phase_labels = [
'pending' => 'Pobieranie',
'fetch' => 'Agregacja 30 dni',
'aggregate_30' => 'Agregacja temp'
];
$products_phase_label = $phase_labels[ $current_phase ] ?? 'Zakończono';
}
$products_meta = 'Faza: ' . $products_phase_label . ', aktywny dzień: ' . ( $products_active_date ?: '-' ) . ', okno dni: ' . $products_dates_count;
$products_eta_meta = self::build_eta_meta( 'cron_products', $products_remaining );
if ( $products_eta_meta !== '' )
@@ -246,6 +222,7 @@ class Users
$products_meta .= ', ' . $products_eta_meta;
}
// --- Endpointy CRON ---
$cron_endpoints = [
[ 'name' => 'Legacy CRON', 'path' => '/cron.php', 'action' => 'cron_legacy' ],
[ 'name' => 'Cron kampanii', 'path' => '/cron/cron_campaigns', 'action' => 'cron_campaigns' ],
@@ -291,49 +268,6 @@ class Users
];
}
private static function get_setting_json( $setting_key )
{
$raw = \services\GoogleAdsApi::get_setting( $setting_key );
if ( !$raw )
{
return [];
}
$decoded = json_decode( (string) $raw, true );
return is_array( $decoded ) ? $decoded : [];
}
private static function normalize_ids( $items )
{
$result = [];
foreach ( (array) $items as $item )
{
$id = (int) $item;
if ( $id > 0 )
{
$result[] = $id;
}
}
return array_values( array_unique( $result ) );
}
private static function normalize_dates( $items )
{
$result = [];
foreach ( (array) $items as $item )
{
$timestamp = strtotime( (string) $item );
if ( !$timestamp )
{
continue;
}
$result[] = date( 'Y-m-d', $timestamp );
}
$result = array_values( array_unique( $result ) );
sort( $result );
return $result;
}
private static function progress_percent( $processed, $total )
{
$processed = (int) $processed;

View File

@@ -13,3 +13,4 @@ $settings['email_password'] = 'ProjectPro2025!';
$settings['cron_products_clients_per_run'] = 1;
$settings['cron_campaigns_clients_per_run'] = 1;
$settings['cron_products_urls_limit_per_client'] = 10;
$settings['google_ads_conversion_window_days'] = 7;

View File

@@ -38,10 +38,15 @@ Ten plik sluzy jako trwala pamiec dla Claude Code. Zapisuj tu wzorce, decyzje i
- Klucze API przechowywane w tabeli `settings` (key-value)
- Frazy z Google Ads Keyword Planner dla URL produktu sa cachowane w `products_keyword_planner_terms` i ponownie uzywane przy generowaniu tytulu AI
- Zmiany produktowe (`title`, `description`, `google_product_category`, `custom_label_4`) sa synchronizowane bezposrednio do Merchant API i logowane per pole w `products_merchant_sync_log`
- `cron_products` dziala batchowo po klientach (`clients_per_run`), domyslnie `10` (max `100`), aby ograniczyc liczbe wywolan; odpowiedz zawiera `estimated_calls_remaining_in_phase`
- `cron_campaigns` dziala batchowo po klientach (`clients_per_run`), domyslnie `2` (max `20`), a odpowiedz zawiera `estimated_calls_remaining_today`
- Zmiana listy klientow (np. reaktywacja klienta) nie powinna resetowac postepu `cron_products`; pipeline zachowuje przetworzonych i dopina nowych klientow
- W `cron_campaigns` stan `processed_ids` jest normalizowany do aktualnej listy aktywnych klientow, aby uniknac rozjazdow postepu po zmianach aktywnosci
- CRON dziala w trybie **klient po kliencie** (client-first): konczy WSZYSTKIE daty jednego klienta, potem przechodzi do nastepnego. Dzieki temu paski postepu na `/clients` roznia sie miedzy klientami.
- `cron_products` iteruje po datach per klient (`dates_per_run` z parametru `clients_per_run`), domyslnie `10` (max `100`); faza `aggregate_30` wywoluje `rebuild_products_temp` RAZ per klient
- `cron_campaigns` iteruje po datach per klient (`dates_per_run` z parametru `clients_per_run`), domyslnie `2` (max `20`)
- Helpery: `get_active_client($pipeline)` -> pierwszy klient z niezakonczona praca; `get_pending_dates_for_client()` -> daty do przetworzenia; `determine_client_products_phase()` -> faza per klient
- Stan CRON przechowywany w tabeli `cron_sync_status` (wiersz = klient + pipeline + data + phase), zamiast JSON w `settings` (migracja 012)
- Fazy produktow w `cron_sync_status`: pending -> fetch -> aggregate_30 -> done; kampanie: pending -> done
- Force sync klienta = DELETE z `cron_sync_status` (wiersze odtwarzane przez `ensure_sync_rows` w nastepnym cyklu CRON)
- Nowy klient/usuniety klient obslugiwany naturalnie: `ensure_sync_rows` dodaje brakujace, JOIN z `clients` pomija usunietych
- `cleanup_old_sync_rows(30)` czysci zakonczone wiersze starsze niz 30 dni i wiersze usunietych klientow
## Preferencje uzytkownika

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -38,7 +38,7 @@ body {
color: $cText;
background: $cContentBg;
max-width: 100vw;
overflow: hidden;
overflow-x: hidden;
}
.hide {
@@ -1168,6 +1168,78 @@ table {
color: $cWhite;
}
}
&.btn-icon-sync {
background: #F0FDF4;
color: #16a34a;
&:hover {
background: #16a34a;
color: $cWhite;
}
&:disabled {
opacity: 0.7;
cursor: wait;
}
&.is-queued {
background: #FEF3C7;
color: #D97706;
}
}
}
.client-sync-bars {
display: flex;
flex-direction: column;
gap: 4px;
}
.client-sync-row {
display: flex;
align-items: center;
gap: 4px;
}
.client-sync-label {
font-size: 11px;
font-weight: 600;
color: #8899A6;
width: 18px;
flex-shrink: 0;
}
.client-sync-track {
flex: 1;
height: 6px;
border-radius: 999px;
background: #E9EEF5;
overflow: hidden;
}
.client-sync-fill {
height: 100%;
border-radius: 999px;
background: #CBD5E0;
transition: width 0.4s ease;
&.is-active {
background: linear-gradient(90deg, #5A9BFF 0%, #2E6BDF 100%);
}
&.is-done {
background: $cSuccess;
}
}
.client-sync-pct {
font-size: 11px;
font-weight: 600;
color: #8899A6;
width: 32px;
text-align: right;
flex-shrink: 0;
}
.empty-state {
@@ -1279,6 +1351,7 @@ table {
.filter-with-action {
display: flex;
align-items: center;
gap: 8px;
.form-control {
@@ -1310,6 +1383,121 @@ table {
}
}
}
.filter-group-campaign-multi {
flex: 2 !important;
}
.campaign-dropdown {
flex: 1;
min-width: 0;
position: relative;
}
.campaign-dropdown-trigger {
width: 100%;
padding: 10px 14px;
padding-right: 32px;
border: 1px solid $cBorder;
border-radius: 8px;
font-size: 14px;
color: $cTextDark;
background: $cWhite;
cursor: pointer;
display: flex;
align-items: center;
transition: border-color 0.2s;
position: relative;
min-height: 42px;
box-sizing: border-box;
.campaign-dropdown-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-placeholder {
color: #8899A6;
}
}
.campaign-dropdown-arrow {
position: absolute;
right: 12px;
font-size: 10px;
color: #8899A6;
transition: transform 0.2s;
}
}
.campaign-dropdown.is-open {
.campaign-dropdown-trigger {
border-color: $cPrimary;
box-shadow: 0 0 0 3px rgba($cPrimary, 0.1);
}
.campaign-dropdown-arrow {
transform: rotate(180deg);
}
.campaign-dropdown-menu {
display: block;
}
}
.campaign-dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 100;
max-height: 280px;
overflow-y: auto;
background: $cWhite;
border: 1px solid $cBorder;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 4px 0;
}
.campaign-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
color: $cTextDark;
margin: 0;
transition: background 0.15s;
&:hover {
background: #F8FAFC;
}
&.is-checked {
background: #EEF2FF;
}
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
accent-color: $cPrimary;
}
span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.campaigns-list-panel {

View File

@@ -0,0 +1,2 @@
ALTER TABLE `clients` ADD COLUMN `force_sync_campaigns` TINYINT(1) NOT NULL DEFAULT 0 AFTER `deleted`;
ALTER TABLE `clients` ADD COLUMN `force_sync_products` TINYINT(1) NOT NULL DEFAULT 0 AFTER `force_sync_campaigns`;

View File

@@ -0,0 +1,57 @@
-- Migracja: tabela cron_sync_status zamiast JSON w settings
-- Data: 2026-02-19
-- Opis: dedykowana tabela do sledzenia postepu pipeline CRON (kampanie, produkty)
CREATE TABLE IF NOT EXISTS `cron_sync_status` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`client_id` INT NOT NULL,
`pipeline` ENUM('campaigns','products') NOT NULL,
`sync_date` DATE NOT NULL,
`phase` ENUM('pending','fetch','aggregate_30','aggregate_temp','done') NOT NULL DEFAULT 'pending',
`started_at` DATETIME NULL,
`completed_at` DATETIME NULL,
`error_message` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_client_pipeline_date` (`client_id`, `pipeline`, `sync_date`),
KEY `idx_pipeline_phase` (`pipeline`, `phase`),
KEY `idx_client_id` (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Usuniecie kolumny force_sync_campaigns z clients
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'clients'
AND COLUMN_NAME = 'force_sync_campaigns'
),
'ALTER TABLE `clients` DROP COLUMN `force_sync_campaigns`',
'DO 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Usuniecie kolumny force_sync_products z clients
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'clients'
AND COLUMN_NAME = 'force_sync_products'
),
'ALTER TABLE `clients` DROP COLUMN `force_sync_products`',
'DO 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Usuniecie starych kluczy JSON z settings
DELETE FROM `settings` WHERE `setting_key` IN (
'cron_campaigns_window_state',
'cron_campaigns_state',
'cron_products_pipeline_state'
);

View File

@@ -13,35 +13,24 @@
<?php endforeach; ?>
</select>
</div>
<div class="filter-group">
<label for="campaign_id"><i class="fa-solid fa-bullhorn"></i> Kampania</label>
<div class="filter-group filter-group-campaign-multi">
<label><i class="fa-solid fa-bullhorn"></i> Kampania</label>
<div class="filter-with-action">
<select id="campaign_id" name="campaign_id" class="form-control">
<option value="">- wybierz kampanie -</option>
</select>
<button type="button" id="delete_campaign" class="btn-icon btn-icon-delete" title="Usun kampanie">
<select id="campaign_id" name="campaign_id[]" multiple="multiple" style="display:none"></select>
<div class="campaign-dropdown" id="campaign_dropdown">
<div class="campaign-dropdown-trigger" id="campaign_dropdown_trigger">
<span class="campaign-dropdown-text">- wybierz kampanie -</span>
<i class="fa-solid fa-chevron-down campaign-dropdown-arrow"></i>
</div>
<div class="campaign-dropdown-menu" id="campaign_dropdown_menu"></div>
</div>
<button type="button" id="delete_campaign" class="btn-icon btn-icon-delete" title="Usun zaznaczone kampanie">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
<div class="campaigns-list-panel" id="campaigns_list_panel" style="display: none;">
<div class="campaigns-list-toolbar">
<div class="campaigns-list-toolbar-left">
<input type="checkbox" id="campaigns_select_all" title="Zaznacz wszystkie">
<label for="campaigns_select_all">Zaznacz wszystkie</label>
<span class="campaigns-selected-count">Zaznaczone: <strong id="campaigns_selected_count">0</strong></span>
</div>
<div class="campaigns-list-toolbar-right">
<button type="button" id="campaigns_bulk_delete" class="campaigns-bulk-delete-btn" disabled>
<i class="fa-solid fa-trash"></i> Usun zaznaczone
</button>
</div>
</div>
<div class="campaigns-list-items" id="campaigns_list_items"></div>
</div>
<div class="campaigns-chart-wrap">
<div id="container"></div>
</div>
@@ -95,10 +84,72 @@ function storage_get( key )
}
}
function rebuildCampaignDropdown()
{
var menu = $( '#campaign_dropdown_menu' );
menu.empty();
$( '#campaign_id option' ).each( function()
{
var val = $( this ).val();
var text = $( this ).text();
var item = $( '<div class="campaign-dropdown-item"></div>' );
var checkbox = $( '<input type="checkbox">' ).val( val );
item.append( checkbox ).append( $( '<span></span>' ).text( text ) );
menu.append( item );
});
updateCheckboxState();
updateDropdownDisplay();
}
function syncDropdownToSelect()
{
var selected = [];
$( '#campaign_dropdown_menu input:checked' ).each( function() {
selected.push( $( this ).val() );
});
$( '#campaign_id' ).val( selected ).trigger( 'change' );
updateDropdownDisplay();
}
function updateCheckboxState()
{
var selected = $( '#campaign_id' ).val() || [];
$( '#campaign_dropdown_menu input[type="checkbox"]' ).each( function()
{
var checked = selected.indexOf( $( this ).val() ) !== -1;
$( this ).prop( 'checked', checked );
$( this ).closest( '.campaign-dropdown-item' ).toggleClass( 'is-checked', checked );
});
}
function updateDropdownDisplay()
{
var selected = $( '#campaign_id' ).val() || [];
var textEl = $( '#campaign_dropdown .campaign-dropdown-text' );
if ( selected.length === 0 )
{
textEl.text( '- wybierz kampanie -' ).addClass( 'is-placeholder' );
}
else if ( selected.length === 1 )
{
var name = $( '#campaign_id option[value="' + selected[0] + '"]' ).text();
textEl.text( name ).removeClass( 'is-placeholder' );
}
else
{
var first = $( '#campaign_id option[value="' + selected[0] + '"]' ).text();
textEl.text( first + ' (+' + ( selected.length - 1 ) + ')' ).removeClass( 'is-placeholder' );
}
}
function reloadChart()
{
var campaign_id = $( '#campaign_id' ).val();
if ( !campaign_id ) return;
var vals = $( '#campaign_id' ).val() || [];
if ( vals.length !== 1 ) return;
var campaign_id = vals[0];
$.ajax({
url: '/campaigns/get_campaign_history_data_table_chart/',
@@ -178,111 +229,36 @@ function reloadChart()
$( function()
{
function updateCampaignsSelectedCount()
$( 'body' ).on( 'click', '#campaign_dropdown_trigger', function( e )
{
var count = $( '.campaigns-list-item-cb:checked' ).length;
$( '#campaigns_selected_count' ).text( count );
$( '#campaigns_bulk_delete' ).prop( 'disabled', count === 0 );
$( '#campaigns_select_all' ).prop( 'checked', count > 0 && count === $( '.campaigns-list-item-cb' ).length );
}
function buildCampaignsList( campaigns )
{
var panel = $( '#campaigns_list_panel' );
var container = $( '#campaigns_list_items' );
container.empty();
$( '#campaigns_select_all' ).prop( 'checked', false );
updateCampaignsSelectedCount();
if ( !campaigns.length )
{
panel.hide();
return;
}
campaigns.forEach( function( pair ) {
var c = pair[1];
var item = $( '<label class="campaigns-list-item">' +
'<input type="checkbox" class="campaigns-list-item-cb" value="' + c.id + '" data-name="' + $( '<span>' ).text( c.campaign_name ).html() + '"> ' +
'<span class="campaigns-list-item-name">' + $( '<span>' ).text( c.campaign_name ).html() + '</span>' +
'</label>' );
container.append( item );
});
panel.show();
}
$( 'body' ).on( 'change', '.campaigns-list-item-cb', updateCampaignsSelectedCount );
$( 'body' ).on( 'change', '#campaigns_select_all', function()
{
$( '.campaigns-list-item-cb' ).prop( 'checked', $( this ).is( ':checked' ) );
updateCampaignsSelectedCount();
e.stopPropagation();
$( '#campaign_dropdown' ).toggleClass( 'is-open' );
});
$( 'body' ).on( 'click', '#campaigns_bulk_delete', function()
$( 'body' ).on( 'change', '#campaign_dropdown_menu input[type="checkbox"]', function()
{
var checked = $( '.campaigns-list-item-cb:checked' );
var count = checked.length;
$( this ).closest( '.campaign-dropdown-item' ).toggleClass( 'is-checked', this.checked );
syncDropdownToSelect();
});
if ( count === 0 ) return;
$( 'body' ).on( 'click', '#campaign_dropdown_menu .campaign-dropdown-item span', function( e )
{
e.preventDefault();
e.stopPropagation();
var val = $( this ).siblings( 'input[type="checkbox"]' ).val();
$( '#campaign_dropdown_menu input[type="checkbox"]' ).prop( 'checked', false );
$( '#campaign_dropdown_menu .campaign-dropdown-item' ).removeClass( 'is-checked' );
$( this ).siblings( 'input[type="checkbox"]' ).prop( 'checked', true );
$( this ).closest( '.campaign-dropdown-item' ).addClass( 'is-checked' );
$( '#campaign_id' ).val( [ val ] ).trigger( 'change' );
updateDropdownDisplay();
$( '#campaign_dropdown' ).removeClass( 'is-open' );
});
var names = [];
checked.each( function() { names.push( $( this ).data( 'name' ) ); } );
var namesList = '<ul style="text-align:left; max-height:200px; overflow:auto; margin-top:10px;">';
names.forEach( function( n ) { namesList += '<li>' + n + '</li>'; } );
namesList += '</ul>';
$.confirm({
title: 'Potwierdzenie usuniecia',
content: 'Czy na pewno chcesz usunac <strong>' + count + '</strong> kampanii?' + namesList + '<br>Ta operacja jest nieodwracalna i usunie rowniez cala historie tych kampanii.',
type: 'red',
buttons: {
confirm: {
text: 'Usun (' + count + ')',
btnClass: 'btn-red',
action: function()
{
var ids = [];
checked.each( function() { ids.push( $( this ).val() ); } );
$.ajax({
url: '/campaigns/delete_campaigns/',
type: 'POST',
data: { ids: ids },
success: function( response )
{
var data = JSON.parse( response );
if ( data.success )
{
$.alert({
title: 'Sukces',
content: 'Usunieto ' + data.deleted + ' kampanii.',
type: 'green',
autoClose: 'ok|2000'
});
var current_campaign = $( '#campaign_id' ).val();
if ( ids.indexOf( current_campaign ) !== -1 )
storage_set( STORAGE_CAMPAIGN_KEY, '' );
$( '#client_id' ).trigger( 'change' );
}
else
{
$.alert({
title: 'Blad',
content: data.message || 'Nie udalo sie usunac kampanii.',
type: 'red'
});
}
}
});
}
},
cancel: { text: 'Anuluj' }
}
});
$( document ).on( 'click', function( e )
{
if ( !$( e.target ).closest( '#campaign_dropdown' ).length )
$( '#campaign_dropdown' ).removeClass( 'is-open' );
});
$( 'body' ).on( 'change', '#client_id', function()
@@ -295,14 +271,11 @@ $( function()
if ( !campaign_to_restore )
storage_set( STORAGE_CAMPAIGN_KEY, '' );
campaigns_select.empty();
campaigns_select.append( '<option value="">- wybierz kampanie -</option>' );
campaigns_select.empty().trigger( 'change' );
rebuildCampaignDropdown();
if ( !client_id )
{
$( '#campaigns_list_panel' ).hide();
return;
}
$.ajax({
url: '/campaigns/get_campaigns_list/client_id=' + client_id,
@@ -324,14 +297,14 @@ $( function()
campaigns.forEach( function( pair ) {
var value = pair[1];
campaigns_select.append( '<option value="' + value.id + '">' + value.campaign_name + '</option>' );
campaigns_select.append( new Option( value.campaign_name, value.id, false, false ) );
});
buildCampaignsList( campaigns );
rebuildCampaignDropdown();
if ( campaign_to_restore && campaigns_select.find( 'option[value="' + campaign_to_restore + '"]' ).length )
if ( campaign_to_restore )
{
campaigns_select.val( campaign_to_restore ).trigger( 'change' );
campaigns_select.val( [ campaign_to_restore ] ).trigger( 'change' );
}
else
{
@@ -340,9 +313,11 @@ $( function()
} ).first();
if ( account_option.length )
campaigns_select.val( account_option.val() ).trigger( 'change' );
campaigns_select.val( [ account_option.val() ] ).trigger( 'change' );
}
updateCheckboxState();
updateDropdownDisplay();
restore_campaign_after_client_load = '';
}
});
@@ -350,10 +325,9 @@ $( function()
$( 'body' ).on( 'click', '#delete_campaign', function()
{
var campaign_id = $( '#campaign_id' ).val();
var campaign_name = $( '#campaign_id option:selected' ).text();
var selected = $( '#campaign_id' ).val() || [];
if ( !campaign_id )
if ( !selected.length )
{
$.alert({
title: 'Uwaga',
@@ -363,31 +337,54 @@ $( function()
return;
}
var names = [];
selected.forEach( function( id ) {
var text = $( '#campaign_id option[value="' + id + '"]' ).text();
names.push( text );
});
var count = selected.length;
var msg = '';
if ( count === 1 )
{
msg = 'Czy na pewno chcesz usunac kampanie <strong>' + names[0] + '</strong>?';
}
else
{
var namesList = '<ul style="text-align:left; max-height:200px; overflow:auto; margin-top:10px;">';
names.forEach( function( n ) { namesList += '<li>' + n + '</li>'; } );
namesList += '</ul>';
msg = 'Czy na pewno chcesz usunac <strong>' + count + '</strong> kampanii?' + namesList;
}
msg += '<br>Ta operacja jest nieodwracalna i usunie rowniez cala historie kampanii.';
$.confirm({
title: 'Potwierdzenie usuniecia',
content: 'Czy na pewno chcesz usunac kampanie <strong>' + campaign_name + '</strong>?<br><br>Ta operacja jest nieodwracalna i usunie rowniez cala historie kampanii.',
content: msg,
type: 'red',
buttons: {
confirm: {
text: 'Usun',
text: count === 1 ? 'Usun' : 'Usun (' + count + ')',
btnClass: 'btn-red',
keys: ['enter'],
action: function()
{
$.ajax({
url: '/campaigns/delete_campaign/campaign_id=' + campaign_id,
url: '/campaigns/delete_campaigns/',
type: 'POST',
data: { ids: selected },
success: function( response )
{
var data = JSON.parse( response );
if ( data.success )
{
if ( storage_get( STORAGE_CAMPAIGN_KEY ) === String( campaign_id ) )
storage_set( STORAGE_CAMPAIGN_KEY, '' );
storage_set( STORAGE_CAMPAIGN_KEY, '' );
$.alert({
title: 'Sukces',
content: 'Kampania zostala usunieta.',
content: data.deleted === 1 ? 'Kampania zostala usunieta.' : 'Usunieto ' + data.deleted + ' kampanii.',
type: 'green',
autoClose: 'ok|2000'
});
@@ -464,8 +461,7 @@ $( function()
$( 'body' ).on( 'change', '#campaign_id', function()
{
var campaign_id = $( this ).val();
storage_set( STORAGE_CAMPAIGN_KEY, campaign_id );
var vals = $( this ).val() || [];
if ( $.fn.DataTable.isDataTable( '#products' ) )
{
@@ -473,46 +469,53 @@ $( function()
$( '#products tbody' ).empty();
}
if ( !campaign_id )
return;
if ( vals.length === 1 )
{
var campaign_id = vals[0];
storage_set( STORAGE_CAMPAIGN_KEY, campaign_id );
new DataTable( '#products', {
ajax: {
type: 'POST',
url: '/campaigns/get_campaign_history_data_table/campaign_id=' + campaign_id,
},
processing: true,
serverSide: true,
searching: false,
lengthChange: false,
pageLength: 15,
columns: [
{ width: '130px', name: 'date', orderable: false, className: "nowrap" },
{ width: '120px', name: 'roas30', orderable: false, className: "dt-type-numeric" },
{ width: '120px', name: 'roas_all_time', orderable: false, className: "dt-type-numeric" },
{ width: '180px', name: 'conversion_value', orderable: false, className: "dt-type-numeric" },
{ width: '140px', name: 'spend30', orderable: false, className: "dt-type-numeric" },
{ width: 'auto', name: 'comment', orderable: false },
{ width: 'auto', name: 'bidding_strategy', orderable: false },
{ width: '100px', name: 'budget', orderable: false, className: "dt-type-numeric" },
{ width: '60px', name: 'actions', orderable: false, className: "dt-center" }
],
language: {
processing: 'Ladowanie...',
emptyTable: 'Brak danych do wyswietlenia',
info: 'Wpisy _START_ - _END_ z _TOTAL_',
infoEmpty: '',
lengthMenu: 'Pokaz _MENU_ wpisow',
paginate: {
first: 'Pierwsza',
last: 'Ostatnia',
next: 'Dalej',
previous: 'Wstecz'
new DataTable( '#products', {
ajax: {
type: 'POST',
url: '/campaigns/get_campaign_history_data_table/campaign_id=' + campaign_id,
},
processing: true,
serverSide: true,
searching: false,
lengthChange: false,
pageLength: 15,
columns: [
{ width: '130px', name: 'date', orderable: false, className: "nowrap" },
{ width: '120px', name: 'roas30', orderable: false, className: "dt-type-numeric" },
{ width: '120px', name: 'roas_all_time', orderable: false, className: "dt-type-numeric" },
{ width: '180px', name: 'conversion_value', orderable: false, className: "dt-type-numeric" },
{ width: '140px', name: 'spend30', orderable: false, className: "dt-type-numeric" },
{ width: 'auto', name: 'comment', orderable: false },
{ width: 'auto', name: 'bidding_strategy', orderable: false },
{ width: '100px', name: 'budget', orderable: false, className: "dt-type-numeric" },
{ width: '60px', name: 'actions', orderable: false, className: "dt-center" }
],
language: {
processing: 'Ladowanie...',
emptyTable: 'Brak danych do wyswietlenia',
info: 'Wpisy _START_ - _END_ z _TOTAL_',
infoEmpty: '',
lengthMenu: 'Pokaz _MENU_ wpisow',
paginate: {
first: 'Pierwsza',
last: 'Ostatnia',
next: 'Dalej',
previous: 'Wstecz'
}
}
}
});
});
reloadChart();
reloadChart();
}
else
{
storage_set( STORAGE_CAMPAIGN_KEY, '' );
}
});
var saved_client_id = storage_get( STORAGE_CLIENT_KEY );
@@ -525,4 +528,3 @@ $( function()
}
});
</script>

View File

@@ -15,6 +15,7 @@
<th>Google Ads Customer ID</th>
<th>Merchant Account ID</th>
<th>Dane od</th>
<th style="width: 160px;">Sync</th>
<th style="width: 120px; text-align: center;">Akcje</th>
</tr>
</thead>
@@ -45,7 +46,13 @@
<span class="text-muted">— brak —</span>
<?php endif; ?>
</td>
<td class="client-sync" data-sync-id="<?= $client['id']; ?>"><span class="text-muted">—</span></td>
<td class="actions-cell">
<?php if ( $client['google_ads_customer_id'] ): ?>
<button type="button" class="btn-icon btn-icon-sync" onclick="syncClient(<?= $client['id']; ?>, this)" title="Pobierz dane z Google Ads">
<i class="fa-solid fa-rotate"></i>
</button>
<?php endif; ?>
<button type="button" class="btn-icon btn-icon-edit" onclick="editClient(<?= $client['id']; ?>)" title="Edytuj">
<i class="fa-solid fa-pen"></i>
</button>
@@ -57,7 +64,7 @@
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="6" class="empty-state">
<td colspan="7" class="empty-state">
<i class="fa-solid fa-building"></i>
<p>Brak klientów. Dodaj pierwszego klienta.</p>
</td>
@@ -135,6 +142,43 @@ function editClient( id )
} );
}
function syncClient( id, btn )
{
var $btn = $( btn );
var $icon = $btn.find( 'i' );
$btn.prop( 'disabled', true );
$icon.removeClass( 'fa-rotate' ).addClass( 'fa-spinner fa-spin' );
$.post( '/clients/force_sync', { id: id }, function( response )
{
var data = JSON.parse( response );
$btn.prop( 'disabled', false );
$icon.removeClass( 'fa-spinner fa-spin' ).addClass( 'fa-rotate' );
if ( data.success )
{
$btn.addClass( 'is-queued' );
$.alert({
title: 'Zakolejkowano',
content: 'Klient zostal oznaczony do ponownej synchronizacji. Przy najblizszym uruchomieniu CRON dane kampanii i produktow zostana pobrane od nowa dla calego okna konwersji.',
type: 'green',
autoClose: 'ok|3000'
});
}
else
{
$.alert({
title: 'Blad',
content: data.message || 'Nie udalo sie oznaczyc klienta.',
type: 'red'
});
}
});
}
function deleteClient( id, name )
{
$.confirm( {
@@ -177,4 +221,50 @@ $( document ).on( 'keydown', function( e ) {
$( '#client-modal' ).on( 'click', function( e ) {
if ( e.target === this ) closeClientForm();
} );
// --- Sync status bars ---
function renderSyncBar( label, done, total )
{
var pct = total > 0 ? Math.round( done / total * 100 ) : 0;
var cls = pct >= 100 ? 'is-done' : ( pct > 0 ? 'is-active' : '' );
return '<div class="client-sync-row">' +
'<span class="client-sync-label">' + label + '</span>' +
'<div class="client-sync-track"><div class="client-sync-fill ' + cls + '" style="width:' + pct + '%"></div></div>' +
'<span class="client-sync-pct">' + pct + '%</span>' +
'</div>';
}
function loadSyncStatus()
{
$.getJSON( '/clients/sync_status', function( resp )
{
if ( resp.status !== 'ok' ) return;
$( '.client-sync' ).each( function()
{
var $cell = $( this );
var id = $cell.data( 'sync-id' );
var info = resp.data[ id ];
if ( !info )
{
$cell.html( '<span class="text-muted">—</span>' );
return;
}
var html = '<div class="client-sync-bars">';
if ( info.campaigns ) html += renderSyncBar( 'K:', info.campaigns[0], info.campaigns[1] );
if ( info.products ) html += renderSyncBar( 'P:', info.products[0], info.products[1] );
html += '</div>';
$cell.html( html );
} );
} );
}
$( document ).ready( function() {
loadSyncStatus();
setInterval( loadSyncStatus, 15000 );
} );
</script>