diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 5b76de2..592038e 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -120,11 +120,17 @@ ".claude": { "settings.local.json": { "type": "-", - "size": 381, - "lmtime": 1771198236725, + "size": 528, + "lmtime": 1771368558172, "modified": false } }, + "CLAUDE.md": { + "type": "-", + "size": 4139, + "lmtime": 1771368527045, + "modified": false + }, "config.php": { "type": "-", "size": 357, @@ -137,7 +143,38 @@ "lmtime": 0, "modified": false }, - "docs": {}, + "docs": { + "database.sql": { + "type": "-", + "size": 15919, + "lmtime": 0, + "modified": false + }, + "google_ads_api_design_doc.doc": { + "type": "-", + "size": 6924, + "lmtime": 0, + "modified": false + }, + "google_ads_api_design_doc.md": { + "type": "-", + "size": 5067, + "lmtime": 0, + "modified": false + }, + "PLAN.md": { + "type": "-", + "size": 11544, + "lmtime": 0, + "modified": false + }, + "memory.md": { + "type": "-", + "size": 1697, + "lmtime": 1771368520970, + "modified": false + } + }, ".htaccess": { "type": "-", "size": 601, @@ -165,14 +202,14 @@ }, "style.css": { "type": "-", - "size": 34772, - "lmtime": 1771198851660, + "size": 35676, + "lmtime": 1771367686453, "modified": false }, "style.css.map": { "type": "-", - "size": 42515, - "lmtime": 1771169108538, + "size": 8131, + "lmtime": 1771367686452, "modified": false }, "style-old.css": { @@ -189,12 +226,36 @@ }, "style.scss": { "type": "-", - "size": 37864, - "lmtime": 1771198844499, + "size": 38739, + "lmtime": 1771367615645, "modified": false } }, "libraries": { + "adspro-dialog.js": { + "type": "-", + "size": 9505, + "lmtime": 1771367545298, + "modified": false + }, + "bootstrap": {}, + "bootstrap-4.1.3": {}, + "ckeditor": {}, + "countdown": {}, + "datepicker": {}, + "daterange": {}, + "filemanager-9.14.1": {}, + "font-awesome-4.7.0": {}, + "framework": {}, + "functions.js": { + "type": "-", + "size": 3400, + "lmtime": 0, + "modified": false + }, + "grid": {}, + "icheck-1.0.2": {}, + "jquery": {}, "jquery-confirm": { "jquery-confirm.min.css": { "type": "-", @@ -208,6 +269,37 @@ "lmtime": 0, "modified": false } + }, + "jquery.contextMenu": {}, + "jquery-gantt": {}, + "medoo": {}, + "moment": {}, + "phpmailer": {}, + "rb.php": { + "type": "-", + "size": 546666, + "lmtime": 0, + "modified": false + }, + "select2": {}, + "tagsinput": {}, + "typeahead.bundle.js": { + "type": "-", + "size": 96186, + "lmtime": 0, + "modified": false + }, + "xlsxwriter.class.php": { + "type": "-", + "size": 48385, + "lmtime": 0, + "modified": false + }, + "adspro-dialog.css": { + "type": "-", + "size": 6801, + "lmtime": 1771367581706, + "modified": false } }, "migrations": { @@ -254,21 +346,21 @@ "site": { "layout-cron.php": { "type": "-", - "size": 5804, - "lmtime": 0, + "size": 5764, + "lmtime": 1771367592957, "modified": false }, "layout-logged.php": { "type": "-", - "size": 4944, - "lmtime": 1763678430048, + "size": 7746, + "lmtime": 1771367592216, "modified": false }, "layout-unlogged.php": { "type": "-", - "size": 1056, + "size": 2024, "lmtime": 0, - "modified": false + "modified": true } }, "campaigns": { @@ -292,12 +384,26 @@ "lmtime": 1771198773079, "modified": false } + }, + "campaign_terms": { + "main_view.php": { + "type": "-", + "size": 25469, + "lmtime": 1771367807171, + "modified": false + } } }, "tmp": {}, "tools": {}, "upload": {}, - "xml": {} + "xml": {}, + ".gitignore": { + "type": "-", + "size": 16, + "lmtime": 1771368579889, + "modified": false + } } }, "$version": 1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9a85c4c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# Repository Guidelines + +## Project Structure & Module Organization +adsPRO is a custom PHP MVC app. Keep business logic in: +- `autoload/controls/` (`class.*.php`): request handling (`\controls\*`) +- `autoload/factory/` (`class.*.php`): DB access via Medoo (`\factory\*`) +- `autoload/view/` (`class.*.php`): view composition (`\view\*`) +- `autoload/services/`: external integrations (Google Ads, OpenAI, Claude) +- `templates/`: PHP templates rendered with `\Tpl::view()` +- `migrations/`: SQL migrations (`NNN_description.sql` + optional `demo_data.sql`) +- `layout/`: SCSS/CSS assets (`style.scss` -> `style.css`) + +Main entry points are `index.php`, `ajax.php`, `api.php`, `cron.php`, and `install.php`. + +## Build, Test, and Development Commands +- `php -S 127.0.0.1:8000` - run a local PHP server from repo root. +- `php install.php` - apply pending DB migrations. +- `php install.php --with_demo` - apply migrations and demo data. +- `php install.php --force` - re-apply tracked migrations. +- `php -l autoload/controls/class.Campaigns.php` - lint a changed PHP file. + +There is no dedicated build pipeline; frontend dependencies are committed in `libraries/`. SCSS is typically compiled via VS Code Live Sass Compiler. + +## Coding Style & Naming Conventions +- Follow existing PHP style: spaces inside parentheses (`if ( $x )`), braces on new lines. +- Use `PascalCase` class names, lowercase namespaces (`\controls`, `\factory`), and `snake_case` for methods/variables/DB columns. +- Controllers/factories are conventionally `static public function ...`. +- Keep route/module naming aligned with URL pattern `/module/action/...`. + +## Testing Guidelines +No first-party automated test suite is maintained in this repo. Validate changes with: +- PHP lint on edited files. +- Manual UI checks for affected `templates/` views. +- Endpoint checks for `ajax.php`, `api.php`, or `cron.php` paths you touched. +- Migration dry run on a non-production database when schema is changed. + +Document manual test steps and outcomes in each PR. + +## Commit & Pull Request Guidelines +- Use concise Polish commit messages with prefixes seen in history: `feat:`, `fix:`, `update:`. +- Keep commits focused (feature, refactor, migration, UI tweak). +- PRs should include: scope summary, linked issue/task, migration notes, manual test checklist, and screenshots for UI changes. + +## Security & Configuration Tips +`config.php` contains environment credentials. Do not introduce new secrets in commits or PR discussions, and treat any credential change as a coordinated ops task. diff --git a/autoload/controls/class.Api.php b/autoload/controls/class.Api.php index 25c8f31..d45112e 100644 --- a/autoload/controls/class.Api.php +++ b/autoload/controls/class.Api.php @@ -295,7 +295,7 @@ class Api $processed = 0; $skipped = 0; - $touched_product_ids = []; + $touched_scopes = []; foreach ( $data['data'] as $offer ) { @@ -338,6 +338,23 @@ class Api continue; } + $campaign_external_id = (int) self::normalize_number( $offer['CampaignId'] ?? ( $offer['campaign_id'] ?? 0 ) ); + $campaign_name = trim( (string) ( $offer['CampaignName'] ?? ( $offer['campaign_name'] ?? '' ) ) ); + $ad_group_external_id = (int) self::normalize_number( $offer['AdGroupId'] ?? ( $offer['ad_group_id'] ?? 0 ) ); + $ad_group_name = trim( (string) ( $offer['AdGroupName'] ?? ( $offer['ad_group_name'] ?? '' ) ) ); + + $scope = \controls\Cron::resolve_products_scope_ids( + $client_id, + $campaign_external_id, + $campaign_name, + $ad_group_external_id, + $ad_group_name, + $date + ); + + $db_campaign_id = (int) ( $scope['campaign_id'] ?? 0 ); + $db_ad_group_id = (int) ( $scope['ad_group_id'] ?? 0 ); + $impressions = (int) round( self::normalize_number( $offer['Impressions'] ?? 0 ) ); $clicks = (int) round( self::normalize_number( $offer['Clicks'] ?? 0 ) ); $cost = self::normalize_number( $offer['Cost'] ?? 0 ); @@ -352,12 +369,24 @@ class Api 'cost' => $cost, 'conversions' => $conversions, 'conversions_value' => $conversion_value, - 'updated' => 1 + 'updated' => 1, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id ]; - if ( $mdb -> count( 'products_history', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ) ) + if ( $mdb -> count( 'products_history', [ 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'date_add' => $date + ] ] ) ) { - $offer_data_old = $mdb -> get( 'products_history', '*', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ); + $offer_data_old = $mdb -> get( 'products_history', '*', [ 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'date_add' => $date + ] ] ); if ( $offer_data_old['impressions'] == $offer_data['impressions'] @@ -367,13 +396,23 @@ class Api and number_format( (float) str_replace( ',', '.', $offer_data_old['conversions_value'] ), 5 ) == number_format( (float) $offer_data['conversions_value'], 5 ) ) { - $touched_product_ids[ $product_id ] = true; + $scope_key = (int) $product_id . '|' . $db_campaign_id . '|' . $db_ad_group_id; + $touched_scopes[ $scope_key ] = [ + 'product_id' => (int) $product_id, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id + ]; $processed++; continue; } $mdb -> update( 'products_history', $offer_data, [ - 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] + 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'date_add' => $date + ] ] ); } else @@ -383,19 +422,33 @@ class Api $mdb -> insert( 'products_history', $offer_data ); } - $touched_product_ids[ $product_id ] = true; + $scope_key = (int) $product_id . '|' . $db_campaign_id . '|' . $db_ad_group_id; + $touched_scopes[ $scope_key ] = [ + 'product_id' => (int) $product_id, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id + ]; $processed++; } $history_30_rows = 0; - foreach ( array_keys( $touched_product_ids ) as $product_id ) + foreach ( $touched_scopes as $scope ) { - \controls\Cron::cron_product_history_30_save( (int) $product_id, $date ); - $mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'AND' => [ 'product_id' => (int) $product_id, 'date_add' => $date ] ] ); + $product_id = (int) ( $scope['product_id'] ?? 0 ); + $campaign_id = (int) ( $scope['campaign_id'] ?? 0 ); + $ad_group_id = (int) ( $scope['ad_group_id'] ?? 0 ); + + \controls\Cron::cron_product_history_30_save( $product_id, $date, $campaign_id, $ad_group_id ); + $mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, + 'date_add' => $date + ] ] ); $history_30_rows++; } - $temp_rows = self::rebuild_products_temp_for_client( $client_id ); + $temp_rows = \controls\Cron::rebuild_products_temp_for_client( $client_id ); echo json_encode( [ 'status' => 'ok', @@ -411,67 +464,7 @@ class Api static private function rebuild_products_temp_for_client( $client_id ) { - global $mdb; - - $client_id = (int) $client_id; - if ( $client_id <= 0 ) - { - return 0; - } - - $product_ids = $mdb -> select( 'products', 'id', [ 'client_id' => $client_id ] ); - if ( empty( $product_ids ) ) - { - return 0; - } - - $mdb -> delete( 'products_temp', [ 'product_id' => $product_ids ] ); - - $rows = $mdb -> query( - 'SELECT p.id AS product_id, p.name, - COALESCE( SUM( ph.impressions ), 0 ) AS impressions, - COALESCE( SUM( ph.clicks ), 0 ) AS clicks, - COALESCE( SUM( ph.cost ), 0 ) AS cost, - COALESCE( SUM( ph.conversions ), 0 ) AS conversions, - COALESCE( SUM( ph.conversions_value ), 0 ) AS conversions_value - FROM products AS p - LEFT JOIN products_history AS ph ON p.id = ph.product_id - WHERE p.client_id = :client_id - GROUP BY p.id, p.name', - [ ':client_id' => $client_id ] - ) -> fetchAll( \PDO::FETCH_ASSOC ); - - $inserted = 0; - foreach ( $rows as $row ) - { - $impressions = (int) $row['impressions']; - $clicks = (int) $row['clicks']; - $cost = (float) $row['cost']; - $conversions = (float) $row['conversions']; - $conversion_value = (float) $row['conversions_value']; - $ctr = ( $impressions > 0 ) ? round( $clicks / $impressions, 4 ) * 100 : 0; - $cpc = ( $clicks > 0 ) ? round( $cost / $clicks, 6 ) : 0; - $roas = ( $cost > 0 ) ? round( $conversion_value / $cost, 2 ) * 100 : 0; - - $mdb -> insert( 'products_temp', [ - 'product_id' => (int) $row['product_id'], - 'name' => $row['name'], - 'impressions' => $impressions, - 'impressions_30' => (int) \factory\Products::get_impressions_30( (int) $row['product_id'] ), - 'clicks' => $clicks, - 'clicks_30' => (int) \factory\Products::get_clicks_30( (int) $row['product_id'] ), - 'ctr' => $ctr, - 'cost' => $cost, - 'conversions' => $conversions, - 'conversions_value' => $conversion_value, - 'cpc' => $cpc, - 'roas' => $roas - ] ); - - $inserted++; - } - - return $inserted; + return \controls\Cron::rebuild_products_temp_for_client( $client_id ); } static private function normalize_number( $value ) diff --git a/autoload/controls/class.CampaignTerms.php b/autoload/controls/class.CampaignTerms.php index c891eef..14a07ec 100644 --- a/autoload/controls/class.CampaignTerms.php +++ b/autoload/controls/class.CampaignTerms.php @@ -13,7 +13,7 @@ class CampaignTerms static public function get_campaigns_list() { $client_id = (int) \S::get( 'client_id' ); - echo json_encode( [ 'campaigns' => \factory\Campaigns::get_campaigns_list( $client_id ) ] ); + echo json_encode( [ 'campaigns' => \factory\Campaigns::get_campaigns_list( $client_id, true ) ] ); exit; } @@ -54,6 +54,7 @@ class CampaignTerms $search_term_id = (int) \S::get( 'search_term_id' ); $match_type = strtoupper( trim( (string) \S::get( 'match_type' ) ) ); $scope = strtolower( trim( (string) \S::get( 'scope' ) ) ); + $manual_keyword_text = trim( (string) \S::get( 'keyword_text' ) ); if ( $search_term_id <= 0 ) { @@ -80,7 +81,9 @@ class CampaignTerms $customer_id = trim( (string) ( $context['google_ads_customer_id'] ?? '' ) ); $campaign_external_id = trim( (string) ( $context['external_campaign_id'] ?? '' ) ); $ad_group_external_id = trim( (string) ( $context['external_ad_group_id'] ?? '' ) ); - $keyword_text = trim( (string) ( $context['search_term'] ?? '' ) ); + $context_keyword_text = trim( (string) ( $context['search_term'] ?? '' ) ); + $keyword_text = $manual_keyword_text !== '' ? $manual_keyword_text : $context_keyword_text; + $keyword_source = $manual_keyword_text !== '' ? 'manual' : 'search_term'; $missing_data = ( $customer_id === '' || $keyword_text === '' ); if ( $scope === 'campaign' && $campaign_external_id === '' ) @@ -102,6 +105,7 @@ class CampaignTerms 'campaign_external_id' => $campaign_external_id, 'ad_group_external_id' => $ad_group_external_id, 'keyword_text' => $keyword_text, + 'keyword_source' => $keyword_source, 'scope' => $scope, 'context' => $context ] @@ -137,6 +141,7 @@ class CampaignTerms 'campaign_external_id' => $campaign_external_id, 'ad_group_external_id' => $ad_group_external_id, 'keyword_text' => $keyword_text, + 'keyword_source' => $keyword_source, 'match_type' => $match_type, 'scope' => $scope, 'api_result' => $api_result @@ -166,6 +171,7 @@ class CampaignTerms 'campaign_external_id' => $campaign_external_id, 'ad_group_external_id' => $ad_group_external_id, 'keyword_text' => $keyword_text, + 'keyword_source' => $keyword_source, 'scope' => $scope, 'api_response' => $api_result['response'] ?? null, 'sent_operation' => $api_result['sent_operation'] ?? null, diff --git a/autoload/controls/class.Cron.php b/autoload/controls/class.Cron.php index 919ac1a..200e94a 100644 --- a/autoload/controls/class.Cron.php +++ b/autoload/controls/class.Cron.php @@ -293,6 +293,23 @@ class Cron continue; } + $campaign_external_id = (int) ( $offer['CampaignId'] ?? 0 ); + $campaign_name = trim( (string) ( $offer['CampaignName'] ?? '' ) ); + $ad_group_external_id = (int) ( $offer['AdGroupId'] ?? 0 ); + $ad_group_name = trim( (string) ( $offer['AdGroupName'] ?? '' ) ); + + $scope = self::resolve_products_scope_ids( + $client_id, + $campaign_external_id, + $campaign_name, + $ad_group_external_id, + $ad_group_name, + $date + ); + + $db_campaign_id = (int) ( $scope['campaign_id'] ?? 0 ); + $db_ad_group_id = (int) ( $scope['ad_group_id'] ?? 0 ); + $impressions = (int) round( (float) ( $offer['Impressions'] ?? 0 ) ); $clicks = (int) round( (float) ( $offer['Clicks'] ?? 0 ) ); $cost = (float) ( $offer['Cost'] ?? 0 ); @@ -307,12 +324,24 @@ class Cron 'cost' => $cost, 'conversions' => $conversions, 'conversions_value' => $conversion_value, - 'updated' => 1 + 'updated' => 1, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id ]; - if ( $mdb -> count( 'products_history', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ) ) + if ( $mdb -> count( 'products_history', [ 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'date_add' => $date + ] ] ) ) { - $offer_data_old = $mdb -> get( 'products_history', '*', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ); + $offer_data_old = $mdb -> get( 'products_history', '*', [ 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'date_add' => $date + ] ] ); if ( $offer_data_old['impressions'] == $offer_data['impressions'] @@ -328,7 +357,12 @@ class Cron } $mdb -> update( 'products_history', $offer_data, [ - 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] + 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'date_add' => $date + ] ] ); } else @@ -351,6 +385,163 @@ class Cron ]; } + static public function resolve_products_scope_ids( $client_id, $campaign_external_id, $campaign_name, $ad_group_external_id, $ad_group_name, $date_sync ) + { + $client_id = (int) $client_id; + $campaign_external_id = (int) $campaign_external_id; + $ad_group_external_id = (int) $ad_group_external_id; + + $db_campaign_id = self::ensure_products_campaign( + $client_id, + $campaign_external_id, + $campaign_name, + $date_sync + ); + + if ( $db_campaign_id <= 0 ) + { + $db_campaign_id = self::ensure_products_campaign( + $client_id, + 0, + '--- konto ---', + $date_sync + ); + } + + $db_ad_group_id = self::ensure_products_ad_group( + $db_campaign_id, + $ad_group_external_id, + $ad_group_name, + $date_sync + ); + + return [ + 'campaign_id' => (int) $db_campaign_id, + 'ad_group_id' => (int) $db_ad_group_id + ]; + } + + static private function ensure_products_campaign( $client_id, $campaign_external_id, $campaign_name, $date_sync ) + { + global $mdb; + + $client_id = (int) $client_id; + $campaign_external_id = (int) $campaign_external_id; + $campaign_name = trim( (string) $campaign_name ); + + if ( $client_id <= 0 ) + { + return 0; + } + + $db_campaign_id = (int) $mdb -> get( 'campaigns', 'id', [ 'AND' => [ + 'client_id' => $client_id, + 'campaign_id' => $campaign_external_id + ] ] ); + + if ( $db_campaign_id > 0 ) + { + if ( $campaign_name !== '' ) + { + $mdb -> update( 'campaigns', [ 'campaign_name' => $campaign_name ], [ 'id' => $db_campaign_id ] ); + } + + return $db_campaign_id; + } + + if ( $campaign_name === '' ) + { + $campaign_name = $campaign_external_id > 0 ? 'Kampania #' . $campaign_external_id : '--- konto ---'; + } + + $mdb -> insert( 'campaigns', [ + 'client_id' => $client_id, + 'campaign_id' => $campaign_external_id, + 'campaign_name' => $campaign_name + ] ); + + $db_campaign_id = (int) $mdb -> id(); + + if ( $db_campaign_id > 0 && $date_sync ) + { + if ( !$mdb -> count( 'campaigns_history', [ 'AND' => [ 'campaign_id' => $db_campaign_id, 'date_add' => $date_sync ] ] ) ) + { + $mdb -> insert( 'campaigns_history', [ + 'campaign_id' => $db_campaign_id, + 'roas_30_days' => 0, + 'roas_all_time' => 0, + 'budget' => 0, + 'money_spent' => 0, + 'conversion_value' => 0, + 'bidding_strategy' => '', + 'date_add' => $date_sync + ] ); + } + } + + return $db_campaign_id; + } + + static private function ensure_products_ad_group( $db_campaign_id, $ad_group_external_id, $ad_group_name, $date_sync ) + { + global $mdb; + + $db_campaign_id = (int) $db_campaign_id; + $ad_group_external_id = (int) $ad_group_external_id; + $ad_group_name = trim( (string) $ad_group_name ); + + if ( $db_campaign_id <= 0 ) + { + return 0; + } + + if ( $ad_group_external_id <= 0 ) + { + return (int) self::ensure_campaign_level_ad_group( $db_campaign_id, $date_sync ); + } + + $db_ad_group_id = (int) $mdb -> get( 'campaign_ad_groups', 'id', [ 'AND' => [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $ad_group_external_id + ] ] ); + + if ( $db_ad_group_id > 0 ) + { + if ( $ad_group_name !== '' ) + { + $mdb -> update( 'campaign_ad_groups', [ 'ad_group_name' => $ad_group_name ], [ 'id' => $db_ad_group_id ] ); + } + + return $db_ad_group_id; + } + + if ( $ad_group_name === '' ) + { + $ad_group_name = 'Ad group #' . $ad_group_external_id; + } + + $mdb -> insert( 'campaign_ad_groups', [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $ad_group_external_id, + 'ad_group_name' => $ad_group_name, + 'impressions_30' => 0, + 'clicks_30' => 0, + 'cost_30' => 0, + 'conversions_30' => 0, + 'conversion_value_30' => 0, + 'roas_30' => 0, + 'impressions_all_time' => 0, + 'clicks_all_time' => 0, + 'cost_all_time' => 0, + 'conversions_all_time' => 0, + 'conversion_value_all_time' => 0, + 'roas_all_time' => 0, + 'date_sync' => $date_sync + ] ); + + return (int) $mdb -> id(); + } + static private function aggregate_products_history_30_for_client( $client_id, $date ) { global $mdb; @@ -359,156 +550,200 @@ class Cron $date = date( 'Y-m-d', strtotime( $date ) ); $rows = $mdb -> query( - 'SELECT ph.id, ph.product_id, ph.date_add + 'SELECT DISTINCT ph.product_id, ph.campaign_id, ph.ad_group_id, ph.date_add FROM products_history AS ph INNER JOIN products AS p ON p.id = ph.product_id WHERE p.client_id = ' . $client_id . ' AND ph.updated = 1 AND ph.date_add = \'' . $date . '\' - ORDER BY ph.product_id ASC' + ORDER BY ph.product_id ASC, ph.campaign_id ASC, ph.ad_group_id ASC' ) -> fetchAll( \PDO::FETCH_ASSOC ); $processed = 0; foreach ( $rows as $row ) { $product_id = (int) $row['product_id']; - self::cron_product_history_30_save( $product_id, $row['date_add'] ); - $mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'id' => (int) $row['id'] ] ); + $campaign_id = (int) ( $row['campaign_id'] ?? 0 ); + $ad_group_id = (int) ( $row['ad_group_id'] ?? 0 ); + + self::cron_product_history_30_save( $product_id, $row['date_add'], $campaign_id, $ad_group_id ); + $mdb -> query( + 'UPDATE products_history AS ph + INNER JOIN products AS p ON p.id = ph.product_id + SET ph.updated = 0 + WHERE ph.product_id = :product_id + AND ph.campaign_id = :campaign_id + AND ph.ad_group_id = :ad_group_id + AND ph.date_add = :date_add + AND p.client_id = :client_id', + [ + ':product_id' => $product_id, + ':campaign_id' => $campaign_id, + ':ad_group_id' => $ad_group_id, + ':date_add' => $row['date_add'], + ':client_id' => $client_id + ] + ); $processed++; } return $processed; } - static private function rebuild_products_temp_for_client( $client_id ) + static public function rebuild_products_temp_for_client( $client_id ) { global $mdb; - $client_bestseller_min_roas = \factory\Products::get_client_bestseller_min_roas( $client_id ); - - $db_result = $mdb -> query( 'SELECT * FROM products AS p INNER JOIN products_history AS ph ON p.id = ph.product_id WHERE p.client_id = ' . (int) $client_id ) -> fetchAll( \PDO::FETCH_ASSOC ); - - $aggregated_data = []; - - foreach ( $db_result as $row ) + $client_id = (int) $client_id; + if ( $client_id <= 0 ) { - $product_id = (int) $row['product_id']; - - if ( !isset( $aggregated_data[$client_id] ) ) - { - $aggregated_data[$client_id] = []; - } - - if ( !isset( $aggregated_data[$client_id][$product_id] ) ) - { - $aggregated_data[$client_id][$product_id] = [ - 'product_id' => $product_id, - 'name' => $row['name'], - 'impressions' => 0, - 'clicks' => 0, - 'cost' => 0.0, - 'conversions' => 0, - 'conversions_value' => 0.0 - ]; - } - - $aggregated_data[$client_id][$product_id]['impressions'] += (int) $row['impressions']; - $aggregated_data[$client_id][$product_id]['clicks'] += (int) $row['clicks']; - $aggregated_data[$client_id][$product_id]['cost'] += (float) $row['cost']; - $aggregated_data[$client_id][$product_id]['conversions'] += (float) $row['conversions']; - $aggregated_data[$client_id][$product_id]['conversions_value'] += (float) $row['conversions_value']; + return 0; } - $products_ids = $mdb -> select( 'products', 'id', [ 'client_id' => (int) $client_id ] ); - $products_ids_array = []; - foreach ( $products_ids as $product_id ) + $client_bestseller_min_roas = (int) \factory\Products::get_client_bestseller_min_roas( $client_id ); + + $rows = $mdb -> query( + 'SELECT + p.id AS product_id, + p.name, + ph.campaign_id, + ph.ad_group_id, + COALESCE( SUM( ph.impressions ), 0 ) AS impressions, + COALESCE( SUM( ph.clicks ), 0 ) AS clicks, + COALESCE( SUM( ph.cost ), 0 ) AS cost, + COALESCE( SUM( ph.conversions ), 0 ) AS conversions, + COALESCE( SUM( ph.conversions_value ), 0 ) AS conversions_value + FROM products AS p + LEFT JOIN products_history AS ph ON p.id = ph.product_id + WHERE p.client_id = :client_id + GROUP BY p.id, p.name, ph.campaign_id, ph.ad_group_id', + [ ':client_id' => $client_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $product_ids = $mdb -> select( 'products', 'id', [ 'client_id' => $client_id ] ); + $product_ids = array_values( array_unique( array_map( 'intval', (array) $product_ids ) ) ); + + if ( !empty( $product_ids ) ) { - $products_ids_array[] = (int) $product_id; + $mdb -> delete( 'products_temp', [ 'product_id' => $product_ids ] ); } - if ( !empty( $products_ids_array ) ) + // products_data jest globalne per product_id, wiec klasyfikacje liczymy globalnie. + $global_totals = $mdb -> query( + 'SELECT + p.id AS product_id, + COALESCE( SUM( ph.impressions ), 0 ) AS impressions, + COALESCE( SUM( ph.clicks ), 0 ) AS clicks, + COALESCE( SUM( ph.cost ), 0 ) AS cost, + COALESCE( SUM( ph.conversions ), 0 ) AS conversions, + COALESCE( SUM( ph.conversions_value ), 0 ) AS conversions_value + FROM products AS p + LEFT JOIN products_history AS ph ON p.id = ph.product_id + WHERE p.client_id = :client_id + GROUP BY p.id', + [ ':client_id' => $client_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + foreach ( $global_totals as $total ) { - $mdb -> delete( 'products_temp', [ 'product_id' => $products_ids_array ] ); + $product_id = (int) ( $total['product_id'] ?? 0 ); + if ( $product_id <= 0 ) + { + continue; + } + + $total_cost = (float) ( $total['cost'] ?? 0 ); + $total_conversions = (float) ( $total['conversions'] ?? 0 ); + $total_conversion_value = (float) ( $total['conversions_value'] ?? 0 ); + $total_roas = ( $total_conversions > 0 && $total_cost > 0 ) ? round( $total_conversion_value / $total_cost, 2 ) * 100 : 0; + + $custom_label_4 = \factory\Products::get_product_data( $product_id, 'custom_label_4' ); + if ( $custom_label_4 == null || ( $custom_label_4 == 'bestseller' && $client_bestseller_min_roas > 0 ) ) + { + $new_custom_label_4 = ( $total_roas > $client_bestseller_min_roas && $total_conversions > 10 ) ? 'bestseller' : null; + + $offers_data_tmp = $mdb -> get( 'products_data', '*', [ 'product_id' => $product_id ] ); + if ( isset( $offers_data_tmp['id'] ) ) + { + if ( $new_custom_label_4 != $offers_data_tmp['custom_label_4'] ) + { + $mdb -> insert( 'products_comments', [ + 'product_id' => $product_id, + 'comment' => 'Zmiana pola "custom_label_4" na: ' . $new_custom_label_4, + 'type' => 1, + 'date_add' => date( 'Y-m-d' ) + ] ); + } + + $mdb -> update( 'products_data', [ 'custom_label_4' => $new_custom_label_4 ], [ 'id' => $offers_data_tmp['id'] ] ); + } + else + { + $mdb -> insert( 'products_data', [ + 'product_id' => $product_id, + 'custom_label_4' => $new_custom_label_4 + ] ); + + if ( $new_custom_label_4 == 'bestseller' ) + { + $mdb -> insert( 'products_comments', [ + 'product_id' => $product_id, + 'comment' => 'Zmiana pola "custom_label_4" na: bestseller', + 'type' => 1, + 'date_add' => date( 'Y-m-d' ) + ] ); + } + } + } } $processed_rows = 0; - foreach ( $aggregated_data as $client_offers ) + foreach ( $rows as $row ) { - foreach ( $client_offers as $offer_data ) + $product_id = (int) ( $row['product_id'] ?? 0 ); + if ( $product_id <= 0 ) { - // Obliczamy wartoci CPC oraz ROAS - $cpc = $offer_data['clicks'] > 0 ? round( $offer_data['cost'] / $offer_data['clicks'], 6 ) : 0; - $roas = ( $offer_data['conversions'] > 0 and $offer_data['cost'] ) ? round( $offer_data['conversions_value'] / $offer_data['cost'], 2 ) * 100 : 0; - - $impressions_30 = \factory\Products::get_impressions_30( $offer_data['product_id'] ); - - // update custom_label_4 only current is empty or is bestseller - $custom_label_4 = \factory\Products::get_product_data( $offer_data['product_id'], 'custom_label_4' ); - if ( $custom_label_4 == null || ( $custom_label_4 == 'bestseller' and (int)$client_bestseller_min_roas > 0 ) ) - { - if ( $roas > $client_bestseller_min_roas and $offer_data['conversions'] > 10 ) - { - $new_custom_label_4 = 'bestseller'; - } - else - { - $new_custom_label_4 = null; - } - - $offers_data_tmp = $mdb -> get( 'products_data', '*', [ 'product_id' => $offer_data['product_id'] ] ); - if ( isset( $offers_data_tmp['id'] ) ) - { - if ( $new_custom_label_4 != $offers_data_tmp['custom_label_4'] ) - $mdb -> insert( 'products_comments', [ - 'product_id' => $offer_data['product_id'], - 'comment' => 'Zmiana pola "custom_label_4" na: ' . $new_custom_label_4, - 'type' => 1, - 'date_add' => date( 'Y-m-d' ) - ] ); - - $mdb -> update( 'products_data', [ - 'custom_label_4' => $new_custom_label_4 - ], [ 'id' => $offers_data_tmp['id'] ] ); - } - else - { - $mdb -> insert( 'products_data', [ - 'product_id' => $offer_data['product_id'], - 'custom_label_4' => $new_custom_label_4 - ] ); - - if ( $new_custom_label_4 == 'bestseller' ) - { - $mdb -> insert( 'products_comments', [ - 'product_id' => $offer_data['product_id'], - 'comment' => 'Zmiana pola "custom_label_4" na: bestseller', - 'type' => 1, - 'date_add' => date( 'Y-m-d' ) - ] ); - } - } - } - - $clicks_30 = \factory\Products::get_clicks_30( $offer_data['product_id'] ); - - $mdb -> insert( 'products_temp', [ - 'product_id' => $offer_data['product_id'], - 'name' => $offer_data['name'], - 'impressions' => $offer_data['impressions'], - 'impressions_30' => $impressions_30, - 'clicks' => $offer_data['clicks'], - 'clicks_30' => $clicks_30, - 'ctr' => ( $offer_data['impressions'] > 0 ) ? round( $offer_data['clicks'] / $offer_data['impressions'], 4 ) * 100 : 0, - 'cost' => $offer_data['cost'], - 'conversions' => $offer_data['conversions'], - 'conversions_value' => $offer_data['conversions_value'], - 'cpc' => $cpc, - 'roas' => $roas, - ] ); - - $processed_rows++; + continue; } + + $campaign_id = (int) ( $row['campaign_id'] ?? 0 ); + $ad_group_id = (int) ( $row['ad_group_id'] ?? 0 ); + $impressions = (int) ( $row['impressions'] ?? 0 ); + $clicks = (int) ( $row['clicks'] ?? 0 ); + $cost = (float) ( $row['cost'] ?? 0 ); + $conversions = (float) ( $row['conversions'] ?? 0 ); + $conversions_value = (float) ( $row['conversions_value'] ?? 0 ); + + // Pomijamy puste scope bez danych. + if ( $impressions <= 0 && $clicks <= 0 && $cost <= 0 && $conversions <= 0 && $conversions_value <= 0 ) + { + continue; + } + + $cpc = $clicks > 0 ? round( $cost / $clicks, 6 ) : 0; + $roas = ( $conversions > 0 && $cost > 0 ) ? round( $conversions_value / $cost, 2 ) * 100 : 0; + $impressions_30 = (int) \factory\Products::get_impressions_30( $product_id, $campaign_id, $ad_group_id ); + $clicks_30 = (int) \factory\Products::get_clicks_30( $product_id, $campaign_id, $ad_group_id ); + + $mdb -> insert( 'products_temp', [ + 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, + 'name' => $row['name'], + 'impressions' => $impressions, + 'impressions_30' => $impressions_30, + 'clicks' => $clicks, + 'clicks_30' => $clicks_30, + 'ctr' => ( $impressions > 0 ) ? round( $clicks / $impressions, 4 ) * 100 : 0, + 'cost' => $cost, + 'conversions' => $conversions, + 'conversions_value' => $conversions_value, + 'cpc' => $cpc, + 'roas' => $roas, + ] ); + + $processed_rows++; } return $processed_rows; @@ -537,11 +772,20 @@ class Cron $products = $mdb -> select( 'products', 'id', [ 'client_id' => $client_id ] ); foreach ( $products as $product ) { - $dates = $mdb -> query( 'SELECT id, date_add FROM products_history WHERE product_id = ' . $product . ' AND updated = 1 ORDER BY date_add DESC' ) -> fetchAll( \PDO::FETCH_ASSOC ); - foreach ( $dates as $date ) + $scopes = $mdb -> query( 'SELECT DISTINCT campaign_id, ad_group_id, date_add FROM products_history WHERE product_id = ' . $product . ' AND updated = 1 ORDER BY date_add DESC' ) -> fetchAll( \PDO::FETCH_ASSOC ); + foreach ( $scopes as $scope ) { - self::cron_product_history_30_save( $product, $date['date_add'] ); - $mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'id' => $date['id'] ] ); + $campaign_id = (int) ( $scope['campaign_id'] ?? 0 ); + $ad_group_id = (int) ( $scope['ad_group_id'] ?? 0 ); + $date_add = $scope['date_add'] ?? ''; + + self::cron_product_history_30_save( $product, $date_add, $campaign_id, $ad_group_id ); + $mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'AND' => [ + 'product_id' => $product, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, + 'date_add' => $date_add + ] ] ); } } @@ -551,19 +795,61 @@ class Cron exit; } - static public function get_roas_all_time( $product_id, $date_to ) + static public function get_roas_all_time( $product_id, $date_to, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; - $roas_all_time = $mdb -> query( 'SELECT SUM(conversions_value) / SUM(cost) * 100 AS roas_all_time FROM products_history WHERE product_id = ' . $product_id . ' AND date_add <= \'' . $date_to . '\'' ) -> fetchColumn(); + $product_id = (int) $product_id; + $campaign_id = (int) $campaign_id; + $ad_group_id = (int) $ad_group_id; + + $sql = 'SELECT SUM(conversions_value) / SUM(cost) * 100 AS roas_all_time + FROM products_history + WHERE product_id = :product_id + AND date_add <= :date_to + AND campaign_id = :campaign_id + AND ad_group_id = :ad_group_id'; + + $roas_all_time = $mdb -> query( $sql, [ + ':product_id' => $product_id, + ':date_to' => $date_to, + ':campaign_id' => $campaign_id, + ':ad_group_id' => $ad_group_id + ] ) -> fetchColumn(); return round( $roas_all_time, 2 ); } - static public function cron_product_history_30_save( $product_id, $date_to ) + static public function cron_product_history_30_save( $product_id, $date_to, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; - $data = $mdb -> query( 'SELECT * FROM products_history WHERE product_id = ' . $product_id . ' AND date_add <= \'' . $date_to . '\' ORDER BY date_add DESC LIMIT 30' ) -> fetchAll( \PDO::FETCH_ASSOC ); + $product_id = (int) $product_id; + $campaign_id = (int) $campaign_id; + $ad_group_id = (int) $ad_group_id; + + $data = $mdb -> query( + 'SELECT + date_add, + SUM( impressions ) AS impressions, + SUM( clicks ) AS clicks, + SUM( cost ) AS cost, + SUM( conversions ) AS conversions, + SUM( conversions_value ) AS conversions_value + FROM products_history + WHERE product_id = :product_id + AND campaign_id = :campaign_id + AND ad_group_id = :ad_group_id + AND date_add <= :date_to + GROUP BY date_add + ORDER BY date_add DESC + LIMIT 30', + [ + ':product_id' => $product_id, + ':campaign_id' => $campaign_id, + ':ad_group_id' => $ad_group_id, + ':date_to' => $date_to + ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); // Inicjalizacja tablic do przechowywania danych $offers_data = []; @@ -605,9 +891,29 @@ class Cron $conversions_value = $offer['conversions_value']; $roas = ( $conversions_value > 0 and $cost ) ? round( $conversions_value / $cost, 2 ) * 100 : 0; - if ( $mdb -> count( 'products_history', [ 'AND' => [ 'product_id' => $product_id, 'date_add[<=]' => $date_to ] ] ) >= 14 ) + $days_count_for_product = (int) $mdb -> query( + 'SELECT COUNT( DISTINCT date_add ) + FROM products_history + WHERE product_id = :product_id + AND campaign_id = :campaign_id + AND ad_group_id = :ad_group_id + AND date_add <= :date_to', + [ + ':product_id' => $product_id, + ':campaign_id' => $campaign_id, + ':ad_group_id' => $ad_group_id, + ':date_to' => $date_to + ] + ) -> fetchColumn(); + + if ( $days_count_for_product >= 14 ) { - if ( $mdb -> count( 'products_history_30', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date_to ] ] ) > 0 ) + if ( $mdb -> count( 'products_history_30', [ 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, + 'date_add' => $date_to + ] ] ) > 0 ) { $mdb -> update( 'products_history_30', [ 'impressions' => $impressions, @@ -617,13 +923,20 @@ class Cron 'conversions' => $conversions, 'conversions_value' => $conversions_value, 'roas' => $roas, - 'roas_all_time' => self::get_roas_all_time( $product_id, $date_to ) - ], [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date_to ] ] ); + 'roas_all_time' => self::get_roas_all_time( $product_id, $date_to, $campaign_id, $ad_group_id ) + ], [ 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, + 'date_add' => $date_to + ] ] ); } else { $mdb -> insert( 'products_history_30', [ 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, 'impressions' => $impressions, 'clicks' => $clicks, 'ctr' => $ctr, @@ -631,7 +944,7 @@ class Cron 'conversions' => $conversions, 'conversions_value' => $conversions_value, 'roas' => $roas, - 'roas_all_time' => self::get_roas_all_time( $product_id, $date_to ), + 'roas_all_time' => self::get_roas_all_time( $product_id, $date_to, $campaign_id, $ad_group_id ), 'date_add' => $date_to ] ); } @@ -1032,6 +1345,8 @@ class Cron continue; } + $advertising_channel_type = strtoupper( trim( (string) ( $campaign['advertising_channel_type'] ?? '' ) ) ); + $account_30_totals['budget'] += (float) ( $campaign['budget'] ?? 0 ); $account_30_totals['money_spent'] += (float) ( $campaign['money_spent'] ?? 0 ); $account_30_totals['conversion_value'] += (float) ( $campaign['conversion_value'] ?? 0 ); @@ -1044,7 +1359,8 @@ class Cron $mdb -> insert( 'campaigns', [ 'client_id' => $client['id'], 'campaign_id' => $external_campaign_id, - 'campaign_name' => $campaign['campaign_name'] + 'campaign_name' => $campaign['campaign_name'], + 'advertising_channel_type' => $advertising_channel_type !== '' ? $advertising_channel_type : null ] ); $db_campaign_id = $mdb -> id(); } @@ -1056,7 +1372,8 @@ class Cron ] ] ); $mdb -> update( 'campaigns', [ - 'campaign_name' => $campaign['campaign_name'] + 'campaign_name' => $campaign['campaign_name'], + 'advertising_channel_type' => $advertising_channel_type !== '' ? $advertising_channel_type : null ], [ 'id' => $db_campaign_id ] ); } @@ -1111,7 +1428,8 @@ class Cron $mdb -> insert( 'campaigns', [ 'client_id' => $client['id'], 'campaign_id' => 0, - 'campaign_name' => '--- konto ---' + 'campaign_name' => '--- konto ---', + 'advertising_channel_type' => null ] ); $db_account_campaign_id = $mdb -> id(); } @@ -1123,7 +1441,8 @@ class Cron ] ] ); $mdb -> update( 'campaigns', [ - 'campaign_name' => '--- konto ---' + 'campaign_name' => '--- konto ---', + 'advertising_channel_type' => null ], [ 'id' => $db_account_campaign_id ] ); } @@ -1358,6 +1677,15 @@ class Cron $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); $db_ad_group_id = (int) ( $ad_group_db_map[ $campaign_external_id . '|' . $ad_group_external_id ] ?? 0 ); + if ( $db_campaign_id > 0 && $db_ad_group_id <= 0 && $ad_group_external_id === '0' ) + { + $db_ad_group_id = self::ensure_campaign_level_ad_group( $db_campaign_id, $date_sync ); + if ( $db_ad_group_id > 0 ) + { + $ad_group_db_map[ $campaign_external_id . '|0' ] = $db_ad_group_id; + } + } + if ( $db_campaign_id <= 0 || $db_ad_group_id <= 0 ) { continue; @@ -1404,6 +1732,50 @@ class Cron return [ 'count' => $count, 'errors' => [] ]; } + static private function ensure_campaign_level_ad_group( $db_campaign_id, $date_sync ) + { + global $mdb; + + $db_campaign_id = (int) $db_campaign_id; + if ( $db_campaign_id <= 0 ) + { + return 0; + } + + $existing_id = (int) $mdb -> get( 'campaign_ad_groups', 'id', [ + 'AND' => [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => 0 + ] + ] ); + + if ( $existing_id > 0 ) + { + return $existing_id; + } + + $mdb -> insert( 'campaign_ad_groups', [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => 0, + 'ad_group_name' => 'PMax (bez grup reklam)', + 'impressions_30' => 0, + 'clicks_30' => 0, + 'cost_30' => 0, + 'conversions_30' => 0, + 'conversion_value_30' => 0, + 'roas_30' => 0, + 'impressions_all_time' => 0, + 'clicks_all_time' => 0, + 'cost_all_time' => 0, + 'conversions_all_time' => 0, + 'conversion_value_all_time' => 0, + 'roas_all_time' => 0, + 'date_sync' => $date_sync + ] ); + + return (int) $mdb -> id(); + } + static private function sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ) { global $mdb; diff --git a/autoload/controls/class.Products.php b/autoload/controls/class.Products.php index 820f8ee..f63fb4a 100644 --- a/autoload/controls/class.Products.php +++ b/autoload/controls/class.Products.php @@ -36,6 +36,27 @@ class Products ] ); } + static public function get_campaigns_list() + { + $client_id = (int) \S::get( 'client_id' ); + echo json_encode( [ 'campaigns' => \factory\Campaigns::get_campaigns_list( $client_id, true ) ] ); + exit; + } + + static public function get_campaign_ad_groups() + { + $campaign_id = (int) \S::get( 'campaign_id' ); + + if ( $campaign_id <= 0 ) + { + echo json_encode( [ 'ad_groups' => [] ] ); + exit; + } + + echo json_encode( [ 'ad_groups' => \factory\Campaigns::get_campaign_ad_groups( $campaign_id ) ] ); + exit; + } + static public function comment_add() { $product_id = \S::get( 'product_id' ); @@ -178,6 +199,8 @@ class Products static public function get_products() { $client_id = \S::get( 'client_id' ); + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); $limit = \S::get( 'length' ) ? \S::get( 'length' ) : 10; $start = \S::get( 'start' ) ? \S::get( 'start' ) : 0; @@ -186,7 +209,7 @@ class Products $search = $_POST['search']['value']; // ➊ MIN/MAX ROAS dla kontekstu klienta (opcjonalnie z filtrem search) - $bounds = \factory\Products::get_roas_bounds( $client_id, $search ); + $bounds = \factory\Products::get_roas_bounds( (int) $client_id, $search, $campaign_id, $ad_group_id ); $roas_min = (float)$bounds['min']; $roas_max = (float)$bounds['max']; // zabezpieczenie przed dzieleniem przez 0 @@ -221,12 +244,13 @@ class Products '; }; - $db_results = \factory\Products::get_products( $client_id, $search, $limit, $start, $order_name, $order_dir ); - $recordsTotal = \factory\Products::get_records_total_products( $client_id, $search ); + $db_results = \factory\Products::get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id, $ad_group_id ); + $recordsTotal = \factory\Products::get_records_total_products( $client_id, $search, $campaign_id, $ad_group_id ); $data['draw'] = \S::get( 'draw' ); $data['recordsTotal'] = $recordsTotal; $data['recordsFiltered'] = $recordsTotal; + $data['data'] = []; foreach ( $db_results as $row ) { @@ -270,8 +294,10 @@ class Products '', // checkbox column $row['product_id'], $row['offer_id'], + htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ), + htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ), '
- + ' . $row['name'] . ' @@ -357,10 +383,14 @@ class Products { $client_id = \S::get( 'client_id' ); $product_id = \S::get( 'product_id' ); + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); return \Tpl::view( 'products/product_history', [ 'client_id' => $client_id, 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, 'min_roas' => \factory\Products::get_min_roas( $product_id ) ] ); } @@ -369,15 +399,18 @@ class Products { $client_id= \S::get( 'client_id' ); $product_id = \S::get( 'product_id' ); + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); $start = \S::get( 'start' ) ? \S::get( 'start' ) : 0; $limit = \S::get( 'length' ) ? \S::get( 'length' ) : 10; - $db_results = \factory\Products::get_product_history( $client_id, $product_id, $start, $limit ); - $recordsTotal = \factory\Products::get_records_total_product_history( $client_id, $product_id ); + $db_results = \factory\Products::get_product_history( $client_id, $product_id, $start, $limit, $campaign_id, $ad_group_id ); + $recordsTotal = \factory\Products::get_records_total_product_history( $client_id, $product_id, $campaign_id, $ad_group_id ); $data['draw'] = \S::get( 'draw' ); $data['recordsTotal'] = $recordsTotal; $data['recordsFiltered'] = $recordsTotal; + $data['data'] = []; foreach ( $db_results as $row ) { @@ -416,13 +449,16 @@ class Products { $client_id = \S::get( 'client_id' ); $product_id = \S::get( 'product_id' ); + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); $limit = \S::get( 'length' ) ? \S::get( 'length' ) : 360; $start = \S::get( 'start' ) ? \S::get( 'start' ) : 0; - $db_results = \factory\Products::get_product_history_30( $client_id, $product_id, $start, $limit ); + $db_results = \factory\Products::get_product_history_30( $client_id, $product_id, $start, $limit, $campaign_id, $ad_group_id ); $impressions = []; $clicks = []; + $ctr = []; $cost = []; $conversions = []; $conversions_value = []; @@ -507,4 +543,4 @@ class Products echo json_encode( [ 'status' => 'ok' ] ); exit; } -} \ No newline at end of file +} diff --git a/autoload/factory/class.Campaigns.php b/autoload/factory/class.Campaigns.php index 24b1282..37d716a 100644 --- a/autoload/factory/class.Campaigns.php +++ b/autoload/factory/class.Campaigns.php @@ -8,10 +8,45 @@ class Campaigns return $mdb -> select( 'clients', '*', [ 'ORDER' => [ 'name' => 'ASC' ] ] ); } - static public function get_campaigns_list( $client_id ) + static public function get_campaigns_list( $client_id, $only_active = false ) { global $mdb; - return $mdb -> select( 'campaigns', '*', [ 'client_id' => $client_id, 'ORDER' => [ 'campaign_name' => 'ASC' ] ] ); + + $client_id = (int) $client_id; + + if ( !$only_active ) + { + return $mdb -> select( 'campaigns', '*', [ 'client_id' => $client_id, 'ORDER' => [ 'campaign_name' => 'ASC' ] ] ); + } + + $latest_date = $mdb -> query( + 'SELECT MAX( ch.date_add ) + FROM campaigns_history AS ch + INNER JOIN campaigns AS c ON c.id = ch.campaign_id + WHERE c.client_id = :client_id + AND c.campaign_id <> 0', + [ ':client_id' => $client_id ] + ) -> fetchColumn(); + + if ( !$latest_date ) + { + return $mdb -> select( 'campaigns', '*', [ 'client_id' => $client_id, 'ORDER' => [ 'campaign_name' => 'ASC' ] ] ); + } + + return $mdb -> query( + 'SELECT c.* + FROM campaigns AS c + LEFT JOIN campaigns_history AS ch + ON ch.campaign_id = c.id + AND ch.date_add = :latest_date + WHERE c.client_id = :client_id + AND ( c.campaign_id = 0 OR ch.id IS NOT NULL ) + ORDER BY c.campaign_name ASC', + [ + ':client_id' => $client_id, + ':latest_date' => $latest_date + ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); } static public function get_campaign_history_data( $campaign_id, $start, $length, $revert = false ) @@ -144,6 +179,7 @@ class Campaigns st.ad_group_id AS db_ad_group_id, c.client_id, c.campaign_id AS external_campaign_id, + c.advertising_channel_type, ag.ad_group_id AS external_ad_group_id, cl.google_ads_customer_id FROM campaign_search_terms AS st diff --git a/autoload/factory/class.Products.php b/autoload/factory/class.Products.php index 2c2cbb2..2a525a3 100644 --- a/autoload/factory/class.Products.php +++ b/autoload/factory/class.Products.php @@ -61,63 +61,181 @@ class Products return $mdb -> update( 'products', [ 'min_roas' => $min_roas ], [ 'id' => $product_id ] ); } - static public function get_products( $client_id, $search, $limit, $start, $order_name, $order_dir ) + static private function build_scope_filters( &$sql, &$params, $campaign_id, $ad_group_id ) { - global $mdb; + $campaign_id = (int) $campaign_id; + $ad_group_id = (int) $ad_group_id; - if ( $search ) - return $mdb -> query( 'SELECT pt.*, p.offer_id, p.min_roas FROM products_temp AS pt INNER JOIN products AS p ON p.id = pt.product_id WHERE client_id = \'' . $client_id . '\' AND ( pt.name LIKE \'%' . $search . '%\' OR offer_id LIKE \'%' . $search . '%\' ) ORDER BY ' . $order_name . ' ' . $order_dir . ', id DESC LIMIT ' . $start . ', ' . $limit ) -> fetchAll(); - else - return $mdb -> query( 'SELECT pt.*, p.offer_id, p.min_roas FROM products_temp AS pt INNER JOIN products AS p ON p.id = pt.product_id WHERE client_id = \'' . $client_id . '\' ORDER BY ' . $order_name . ' ' . $order_dir . ', id DESC LIMIT ' . $start . ', ' . $limit ) -> fetchAll(); + if ( $campaign_id > 0 ) + { + $sql .= ' AND pt.campaign_id = :campaign_id'; + $params[':campaign_id'] = $campaign_id; + } + + if ( $ad_group_id > 0 ) + { + $sql .= ' AND pt.ad_group_id = :ad_group_id'; + $params[':ad_group_id'] = $ad_group_id; + } } - - - // \factory\Products.php - public static function get_roas_bounds(int $client_id, ?string $search = null): array + static public function get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; - $params = [':client_id' => $client_id]; + $limit = max( 1, (int) $limit ); + $start = max( 0, (int) $start ); + $order_dir = strtoupper( (string) $order_dir ) === 'ASC' ? 'ASC' : 'DESC'; - $sql = 'SELECT MIN(p.min_roas) AS min_roas, MAX(pt.roas) AS max_roas + $order_map = [ + 'offer_id' => 'p.offer_id', + 'campaign_name' => 'c.campaign_name', + 'ad_group_name' => 'ag.ad_group_name', + 'name' => 'pt.name', + 'impressions' => 'pt.impressions', + 'impressions_30' => 'pt.impressions_30', + 'clicks' => 'pt.clicks', + 'clicks_30' => 'pt.clicks_30', + 'ctr' => 'pt.ctr', + 'cost' => 'pt.cost', + 'cpc' => 'pt.cpc', + 'conversions' => 'pt.conversions', + 'conversions_value' => 'pt.conversions_value', + 'roas' => 'pt.roas', + 'min_roas' => 'p.min_roas' + ]; + + $order_sql = $order_map[ $order_name ] ?? 'pt.clicks'; + + $params = [ ':client_id' => (int) $client_id ]; + $sql = 'SELECT pt.*, p.offer_id, p.min_roas, + COALESCE( c.campaign_name, \'--- brak kampanii ---\' ) AS campaign_name, + CASE + WHEN pt.ad_group_id = 0 THEN \'PMax (bez grup reklam)\' + ELSE COALESCE( ag.ad_group_name, \'--- brak grupy reklam ---\' ) + END AS ad_group_name FROM products_temp AS pt INNER JOIN products AS p ON p.id = pt.product_id - WHERE p.client_id = :client_id AND conversions > 10'; + LEFT JOIN campaigns AS c ON c.id = pt.campaign_id + LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id + WHERE p.client_id = :client_id'; - if ($search) { - $sql .= ' AND (pt.name LIKE :search OR p.offer_id LIKE :search)'; + self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id ); + + if ( $search ) + { + $sql .= ' AND ( + pt.name LIKE :search + OR p.offer_id LIKE :search + OR c.campaign_name LIKE :search + OR ag.ad_group_name LIKE :search + )'; $params[':search'] = '%' . $search . '%'; } - $row = $mdb->query($sql, $params)->fetch(\PDO::FETCH_ASSOC); + $sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', pt.id DESC LIMIT ' . $start . ', ' . $limit; - return [ - 'min' => isset($row['min_roas']) ? (float)$row['min_roas'] : 0.0, - 'max' => isset($row['max_roas']) ? (float)$row['max_roas'] : 0.0, - ]; + return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); } - static public function get_records_total_products( $client_id, $search ) + public static function get_roas_bounds( int $client_id, ?string $search = null, int $campaign_id = 0, int $ad_group_id = 0 ): array { global $mdb; + $params = [ ':client_id' => $client_id ]; + + $sql = 'SELECT MIN( p.min_roas ) AS min_roas, MAX( pt.roas ) AS max_roas + FROM products_temp AS pt + INNER JOIN products AS p ON p.id = pt.product_id + LEFT JOIN campaigns AS c ON c.id = pt.campaign_id + LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id + WHERE p.client_id = :client_id + AND pt.conversions > 10'; + + self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id ); + if ( $search ) - return $mdb -> query( 'SELECT COUNT(0) FROM products_temp AS pt INNER JOIN products AS p ON p.id = pt.product_id WHERE client_id = \'' . $client_id . '\' AND ( pt.name LIKE \'%' . $search . '%\' OR offer_id LIKE \'%' . $search . '%\' )' ) -> fetchColumn(); - else - return $mdb -> query( 'SELECT COUNT(0) FROM products_temp AS pt INNER JOIN products AS p ON p.id = pt.product_id WHERE client_id = \'' . $client_id . '\'' ) -> fetchColumn(); + { + $sql .= ' AND ( + pt.name LIKE :search + OR p.offer_id LIKE :search + OR c.campaign_name LIKE :search + OR ag.ad_group_name LIKE :search + )'; + $params[':search'] = '%' . $search . '%'; + } + + $row = $mdb -> query( $sql, $params ) -> fetch( \PDO::FETCH_ASSOC ); + + return [ + 'min' => isset( $row['min_roas'] ) ? (float) $row['min_roas'] : 0.0, + 'max' => isset( $row['max_roas'] ) ? (float) $row['max_roas'] : 0.0, + ]; + } + + static public function get_records_total_products( $client_id, $search, $campaign_id = 0, $ad_group_id = 0 ) + { + global $mdb; + + $params = [ ':client_id' => (int) $client_id ]; + $sql = 'SELECT COUNT(0) + FROM products_temp AS pt + INNER JOIN products AS p ON p.id = pt.product_id + LEFT JOIN campaigns AS c ON c.id = pt.campaign_id + LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id + WHERE p.client_id = :client_id'; + + self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id ); + + if ( $search ) + { + $sql .= ' AND ( + pt.name LIKE :search + OR p.offer_id LIKE :search + OR c.campaign_name LIKE :search + OR ag.ad_group_name LIKE :search + )'; + $params[':search'] = '%' . $search . '%'; + } + + return $mdb -> query( $sql, $params ) -> fetchColumn(); } static public function get_product_full_context( $product_id ) { global $mdb; return $mdb -> query( - 'SELECT p.id, p.offer_id, p.name, p.min_roas, - pt.impressions, pt.impressions_30, pt.clicks, pt.clicks_30, - pt.ctr, pt.cost, pt.cpc, pt.conversions, pt.conversions_value, pt.roas + 'SELECT + p.id, + p.offer_id, + p.name, + p.min_roas, + COALESCE( SUM( pt.impressions ), 0 ) AS impressions, + COALESCE( SUM( pt.impressions_30 ), 0 ) AS impressions_30, + COALESCE( SUM( pt.clicks ), 0 ) AS clicks, + COALESCE( SUM( pt.clicks_30 ), 0 ) AS clicks_30, + CASE + WHEN COALESCE( SUM( pt.impressions ), 0 ) > 0 + THEN ROUND( COALESCE( SUM( pt.clicks ), 0 ) / COALESCE( SUM( pt.impressions ), 0 ) * 100, 2 ) + ELSE 0 + END AS ctr, + COALESCE( SUM( pt.cost ), 0 ) AS cost, + CASE + WHEN COALESCE( SUM( pt.clicks ), 0 ) > 0 + THEN ROUND( COALESCE( SUM( pt.cost ), 0 ) / COALESCE( SUM( pt.clicks ), 0 ), 6 ) + ELSE 0 + END AS cpc, + COALESCE( SUM( pt.conversions ), 0 ) AS conversions, + COALESCE( SUM( pt.conversions_value ), 0 ) AS conversions_value, + CASE + WHEN COALESCE( SUM( pt.cost ), 0 ) > 0 + THEN ROUND( COALESCE( SUM( pt.conversions_value ), 0 ) / COALESCE( SUM( pt.cost ), 0 ) * 100, 2 ) + ELSE 0 + END AS roas FROM products AS p LEFT JOIN products_temp AS pt ON pt.product_id = p.id - WHERE p.id = :pid', + WHERE p.id = :pid + GROUP BY p.id, p.offer_id, p.name, p.min_roas', [ ':pid' => $product_id ] ) -> fetch( \PDO::FETCH_ASSOC ); } @@ -139,34 +257,139 @@ class Products return $result; } - static public function get_product_history( $client_id, $product_id, $start, $limit ) + static public function get_product_history( $client_id, $product_id, $start, $limit, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; - return $mdb -> query( 'SELECT * FROM products_history AS ph WHERE ph.product_id = \'' . $product_id . '\' ORDER BY ph.date_add DESC LIMIT ' . $start . ', ' . $limit ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $limit = max( 1, (int) $limit ); + $start = max( 0, (int) $start ); + + return $mdb -> query( + 'SELECT + MAX( ph.id ) AS id, + SUM( ph.impressions ) AS impressions, + SUM( ph.clicks ) AS clicks, + CASE WHEN SUM( ph.impressions ) > 0 THEN ROUND( SUM( ph.clicks ) / SUM( ph.impressions ) * 100, 2 ) ELSE 0 END AS ctr, + SUM( ph.cost ) AS cost, + SUM( ph.conversions ) AS conversions, + SUM( ph.conversions_value ) AS conversions_value, + ph.date_add + FROM products_history AS ph + INNER JOIN products AS p ON p.id = ph.product_id + WHERE ph.product_id = :product_id + AND p.client_id = :client_id + AND ph.campaign_id = :campaign_id + AND ph.ad_group_id = :ad_group_id + GROUP BY ph.date_add + ORDER BY ph.date_add DESC + LIMIT ' . $start . ', ' . $limit, + [ + ':product_id' => (int) $product_id, + ':client_id' => (int) $client_id, + ':campaign_id' => (int) $campaign_id, + ':ad_group_id' => (int) $ad_group_id + ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); } - static public function get_records_total_product_history( $client_id, $product_id ) + static public function get_records_total_product_history( $client_id, $product_id, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; - return $mdb -> query( 'SELECT COUNT(0) FROM products_history AS ph WHERE ph.product_id = \'' . $product_id . '\'' ) -> fetchColumn(); + return $mdb -> query( + 'SELECT COUNT( DISTINCT ph.date_add ) + FROM products_history AS ph + INNER JOIN products AS p ON p.id = ph.product_id + WHERE ph.product_id = :product_id + AND p.client_id = :client_id + AND ph.campaign_id = :campaign_id + AND ph.ad_group_id = :ad_group_id', + [ + ':product_id' => (int) $product_id, + ':client_id' => (int) $client_id, + ':campaign_id' => (int) $campaign_id, + ':ad_group_id' => (int) $ad_group_id + ] + ) -> fetchColumn(); } - static public function get_product_history_30( $client_id, $product_id, $start, $limit ) + static public function get_product_history_30( $client_id, $product_id, $start, $limit, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; - return $mdb -> query( 'SELECT * FROM products_history_30 AS ph3 WHERE ph3.product_id = \'' . $product_id . '\' ORDER BY ph3.date_add ASC LIMIT ' . $start . ', ' . $limit ) -> fetchAll( \PDO::FETCH_ASSOC ); + return $mdb -> query( + 'SELECT ph3.* + FROM products_history_30 AS ph3 + INNER JOIN products AS p ON p.id = ph3.product_id + WHERE ph3.product_id = :product_id + AND p.client_id = :client_id + AND ph3.campaign_id = :campaign_id + AND ph3.ad_group_id = :ad_group_id + ORDER BY ph3.date_add ASC + LIMIT ' . (int) $start . ', ' . (int) $limit, + [ + ':product_id' => (int) $product_id, + ':client_id' => (int) $client_id, + ':campaign_id' => (int) $campaign_id, + ':ad_group_id' => (int) $ad_group_id + ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); } - static public function get_impressions_30( $product_id ) + static public function get_impressions_30( $product_id, $campaign_id = null, $ad_group_id = null ) { global $mdb; - return $mdb -> query( 'SELECT SUM(impressions) FROM products_history WHERE product_id = \'' . $product_id . '\' AND date_add >= \'' . date( 'Y-m-d', strtotime( '-30 days', time() ) ) . '\'' ) -> fetchColumn(); + + $sql = 'SELECT COALESCE( SUM( impressions ), 0 ) AS total + FROM products_history + WHERE product_id = :product_id + AND date_add >= :date_from'; + + $params = [ + ':product_id' => (int) $product_id, + ':date_from' => date( 'Y-m-d', strtotime( '-30 days', time() ) ) + ]; + + if ( $campaign_id !== null ) + { + $sql .= ' AND campaign_id = :campaign_id'; + $params[':campaign_id'] = (int) $campaign_id; + } + + if ( $ad_group_id !== null ) + { + $sql .= ' AND ad_group_id = :ad_group_id'; + $params[':ad_group_id'] = (int) $ad_group_id; + } + + return $mdb -> query( $sql, $params ) -> fetchColumn(); } - static public function get_clicks_30( $product_id ) + static public function get_clicks_30( $product_id, $campaign_id = null, $ad_group_id = null ) { global $mdb; - return $mdb -> query( 'SELECT SUM(clicks) FROM products_history WHERE product_id = \'' . $product_id . '\' AND date_add >= \'' . date( 'Y-m-d', strtotime( '-30 days', time() ) ) . '\'' ) -> fetchColumn(); + + $sql = 'SELECT COALESCE( SUM( clicks ), 0 ) AS total + FROM products_history + WHERE product_id = :product_id + AND date_add >= :date_from'; + + $params = [ + ':product_id' => (int) $product_id, + ':date_from' => date( 'Y-m-d', strtotime( '-30 days', time() ) ) + ]; + + if ( $campaign_id !== null ) + { + $sql .= ' AND campaign_id = :campaign_id'; + $params[':campaign_id'] = (int) $campaign_id; + } + + if ( $ad_group_id !== null ) + { + $sql .= ' AND ad_group_id = :ad_group_id'; + $params[':ad_group_id'] = (int) $ad_group_id; + } + + return $mdb -> query( $sql, $params ) -> fetchColumn(); } static public function add_product_comment( $product_id, $comment, $date = null ) @@ -183,4 +406,4 @@ class Products else return $mdb -> insert( 'products_comments', [ 'product_id' => $product_id, 'comment' => $comment, 'date_add' => $date ] ); } -} \ No newline at end of file +} diff --git a/autoload/services/class.GoogleAdsApi.php b/autoload/services/class.GoogleAdsApi.php index 3e47dde..ceb2e67 100644 --- a/autoload/services/class.GoogleAdsApi.php +++ b/autoload/services/class.GoogleAdsApi.php @@ -449,20 +449,49 @@ class GoogleAdsApi { $date = date( 'Y-m-d', strtotime( $date ) ); - $gaql = "SELECT " - . "segments.date, " - . "segments.product_item_id, " - . "segments.product_title, " - . "metrics.impressions, " - . "metrics.clicks, " - . "metrics.cost_micros, " - . "metrics.conversions, " - . "metrics.conversions_value " - . "FROM shopping_performance_view " - . "WHERE segments.date = '" . $date . "'"; + $gaql_with_ad_group = "SELECT " + . "segments.date, " + . "segments.product_item_id, " + . "segments.product_title, " + . "campaign.id, " + . "campaign.name, " + . "ad_group.id, " + . "ad_group.name, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM shopping_performance_view " + . "WHERE segments.date = '" . $date . "'"; - $results = $this -> search_stream( $customer_id, $gaql ); - if ( $results === false ) return false; + $results = $this -> search_stream( $customer_id, $gaql_with_ad_group ); + $fallback_without_ad_group = false; + + if ( $results === false ) + { + $gaql_without_ad_group = "SELECT " + . "segments.date, " + . "segments.product_item_id, " + . "segments.product_title, " + . "campaign.id, " + . "campaign.name, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM shopping_performance_view " + . "WHERE segments.date = '" . $date . "'"; + + $results = $this -> search_stream( $customer_id, $gaql_without_ad_group ); + if ( $results === false ) + { + return false; + } + + $fallback_without_ad_group = true; + } $products = []; @@ -474,11 +503,42 @@ class GoogleAdsApi continue; } - if ( !isset( $products[ $offer_id ] ) ) + $campaign_id = (int) ( $row['campaign']['id'] ?? 0 ); + $campaign_name = trim( (string) ( $row['campaign']['name'] ?? '' ) ); + if ( $campaign_name === '' && $campaign_id > 0 ) { - $products[ $offer_id ] = [ + $campaign_name = 'Kampania #' . $campaign_id; + } + + $ad_group_id = 0; + $ad_group_name = 'PMax (bez grup reklam)'; + + if ( !$fallback_without_ad_group ) + { + $ad_group_id = (int) ( $row['adGroup']['id'] ?? 0 ); + $ad_group_name = trim( (string) ( $row['adGroup']['name'] ?? '' ) ); + + if ( $ad_group_id > 0 && $ad_group_name === '' ) + { + $ad_group_name = 'Ad group #' . $ad_group_id; + } + else if ( $ad_group_id <= 0 ) + { + $ad_group_name = 'PMax (bez grup reklam)'; + } + } + + $scope_key = $offer_id . '|' . $campaign_id . '|' . $ad_group_id; + + if ( !isset( $products[ $scope_key ] ) ) + { + $products[ $scope_key ] = [ 'OfferId' => $offer_id, 'ProductTitle' => (string) ( $row['segments']['productTitle'] ?? $offer_id ), + 'CampaignId' => $campaign_id, + 'CampaignName' => $campaign_name, + 'AdGroupId' => $ad_group_id, + 'AdGroupName' => $ad_group_name, 'Impressions' => 0, 'Clicks' => 0, 'Cost' => 0.0, @@ -487,11 +547,11 @@ class GoogleAdsApi ]; } - $products[ $offer_id ]['Impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); - $products[ $offer_id ]['Clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); - $products[ $offer_id ]['Cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; - $products[ $offer_id ]['Conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); - $products[ $offer_id ]['ConversionValue'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); + $products[ $scope_key ]['Impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); + $products[ $scope_key ]['Clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); + $products[ $scope_key ]['Cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; + $products[ $scope_key ]['Conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); + $products[ $scope_key ]['ConversionValue'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); } return array_values( $products ); @@ -502,6 +562,7 @@ class GoogleAdsApi $gaql = "SELECT " . "campaign.id, " . "campaign.name, " + . "campaign.advertising_channel_type, " . "campaign.bidding_strategy_type, " . "campaign.target_roas.target_roas, " . "campaign_budget.amount_micros, " @@ -526,6 +587,7 @@ class GoogleAdsApi $campaigns[ $cid ] = [ 'campaign_id' => $cid, 'campaign_name' => $row['campaign']['name'] ?? '', + 'advertising_channel_type' => (string) ( $row['campaign']['advertisingChannelType'] ?? '' ), 'bidding_strategy' => $row['campaign']['biddingStrategyType'] ?? 'UNKNOWN', 'target_roas' => isset( $row['campaign']['targetRoas']['targetRoas'] ) ? (float) $row['campaign']['targetRoas']['targetRoas'] @@ -601,8 +663,8 @@ class GoogleAdsApi . "metrics.conversions, " . "metrics.conversions_value " . "FROM ad_group " - . "WHERE campaign.status != 'REMOVED' " - . "AND ad_group.status != 'REMOVED' " + . "WHERE campaign.status = 'ENABLED' " + . "AND ad_group.status = 'ENABLED' " . "AND segments.date DURING LAST_30_DAYS"; $results = $this -> search_stream( $customer_id, $gaql ); @@ -623,8 +685,8 @@ class GoogleAdsApi . "metrics.conversions, " . "metrics.conversions_value " . "FROM ad_group " - . "WHERE campaign.status != 'REMOVED' " - . "AND ad_group.status != 'REMOVED'"; + . "WHERE campaign.status = 'ENABLED' " + . "AND ad_group.status = 'ENABLED'"; $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; @@ -653,7 +715,15 @@ class GoogleAdsApi $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; - return $this -> aggregate_search_terms( $results ); + $terms = $this -> aggregate_search_terms( $results ); + + $pmax_terms = $this -> get_pmax_search_terms_30_days( $customer_id ); + if ( $pmax_terms !== false && is_array( $pmax_terms ) && !empty( $pmax_terms ) ) + { + $terms = array_merge( $terms, $pmax_terms ); + } + + return $terms; } public function get_search_terms_all_time( $customer_id ) @@ -676,7 +746,58 @@ class GoogleAdsApi $results = $this -> search_stream( $customer_id, $gaql ); if ( $results === false ) return false; - return $this -> aggregate_search_terms( $results ); + $terms = $this -> aggregate_search_terms( $results ); + + $pmax_terms = $this -> get_pmax_search_terms_all_time( $customer_id ); + if ( $pmax_terms !== false && is_array( $pmax_terms ) && !empty( $pmax_terms ) ) + { + $terms = array_merge( $terms, $pmax_terms ); + } + + return $terms; + } + + private function get_pmax_search_terms_30_days( $customer_id ) + { + $gaql = "SELECT " + . "campaign.id, " + . "campaign_search_term_view.search_term, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM campaign_search_term_view " + . "WHERE campaign.status != 'REMOVED' " + . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX' " + . "AND metrics.clicks > 0 " + . "AND segments.date DURING LAST_30_DAYS"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + return $this -> aggregate_campaign_search_terms( $results ); + } + + private function get_pmax_search_terms_all_time( $customer_id ) + { + $gaql = "SELECT " + . "campaign.id, " + . "campaign_search_term_view.search_term, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM campaign_search_term_view " + . "WHERE campaign.status != 'REMOVED' " + . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX' " + . "AND metrics.clicks > 0"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + return $this -> aggregate_campaign_search_terms( $results ); } public function get_negative_keywords( $customer_id ) @@ -871,4 +992,59 @@ class GoogleAdsApi return array_values( $terms ); } + + private function aggregate_campaign_search_terms( $results ) + { + $terms = []; + + foreach ( $results as $row ) + { + $campaign_id = $row['campaign']['id'] ?? null; + $search_term = trim( (string) ( $row['campaignSearchTermView']['searchTerm'] ?? '' ) ); + + if ( !$campaign_id || $search_term === '' ) + { + continue; + } + + $key = $campaign_id . '|0|' . strtolower( $search_term ); + + if ( !isset( $terms[ $key ] ) ) + { + $terms[ $key ] = [ + 'campaign_id' => (int) $campaign_id, + 'ad_group_id' => 0, + 'ad_group_name' => 'PMax (bez grup reklam)', + 'search_term' => $search_term, + 'impressions' => 0, + 'clicks' => 0, + 'cost' => 0.0, + 'conversions' => 0.0, + 'conversion_value' => 0.0, + 'roas' => 0.0 + ]; + } + + $terms[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); + $terms[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); + $terms[ $key ]['cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; + $terms[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); + $terms[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); + } + + foreach ( $terms as $key => &$term ) + { + if ( (int) $term['clicks'] <= 0 ) + { + unset( $terms[ $key ] ); + continue; + } + + $term['roas'] = ( $term['cost'] > 0 ) + ? round( ( $term['conversion_value'] / $term['cost'] ) * 100, 2 ) + : 0; + } + + return array_values( $terms ); + } } diff --git a/layout/style-old.css b/layout/style-old.css index 54d1bcb..07fb3c2 100644 --- a/layout/style-old.css +++ b/layout/style-old.css @@ -1 +1 @@ -.animate{animation:mymove 3s infinite}.text-right{text-align:right}.text-bold{font-weight:900 !important}.nowrap{white-space:nowrap}table{border-collapse:collapse}small{font-size:.75em}table{font-size:13px}@keyframes mymove{50%{opacity:.33}}@keyframes gradient-animation{0%{background-position:15% 0%}50%{background-position:85% 100%}100%{background-position:15% 0%}}@keyframes frame-enter{0%{clip-path:polygon(0% 100%, 3px 100%, 3px 3px, calc(100% - 3px) 3px, calc(100% - 3px) calc(100% - 3px), 3px calc(100% - 3px), 3px 100%, 100% 100%, 100% 0%, 0% 0%)}25%{clip-path:polygon(0% 100%, 3px 100%, 3px 3px, calc(100% - 3px) 3px, calc(100% - 3px) calc(100% - 3px), calc(100% - 3px) calc(100% - 3px), calc(100% - 3px) 100%, 100% 100%, 100% 0%, 0% 0%)}50%{clip-path:polygon(0% 100%, 3px 100%, 3px 3px, calc(100% - 3px) 3px, calc(100% - 3px) 3px, calc(100% - 3px) 3px, calc(100% - 3px) 3px, calc(100% - 3px) 3px, 100% 0%, 0% 0%)}75%{-webkit-clip-path:polygon(0% 100%, 3px 100%, 3px 3px, 3px 3px, 3px 3px, 3px 3px, 3px 3px, 3px 3px, 3px 0%, 0% 0%)}100%{-webkit-clip-path:polygon(0% 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 0% 100%)}}*{box-sizing:border-box}body{font-family:"Open Sans",sans-serif;margin:0;padding:0;font-size:15px;color:#4e5e6a}.btn{padding:12px 25px;transition:all .3s ease;color:#fff;border:0;border-radius:6px;cursor:pointer;display:inline-flex;text-decoration:none;gap:5px;justify-content:center;align-items:center}.btn.btn_small,.btn.btn-xs{padding:5px 7px;font-size:13px}.btn.btn_small i,.btn.btn-xs i{font-size:12px}.btn.btn-success{background:#57b951}.btn.btn-success:hover{background:#4a9c3b}.btn.btn-primary{background:#6690f4}.btn.btn-primary:hover{background:#3164db}.btn.btn-danger{background:#c00}.btn.btn-danger:hover{background:#b30000}.hide{display:none}.form-error{color:#c00;font-size:13px}.input-group{margin-bottom:10px}input[type=checkbox]{border:1px solid #eee}.form-control{border:1px solid #eee;border-radius:3px;height:35px;width:100%;padding:5px;font-family:"Open Sans",sans-serif}.form-control option{padding:5px}.form-control:focus{border:1px solid #6690f4;outline:none}.unlogged{background:#eef1f9;display:flex;align-items:center;justify-content:center;height:100vh}.unlogged .box-login{background:#fff;padding:25px;border-radius:6px;width:400px}.unlogged .box-login .title{text-align:center;padding:10px 10px 25px;border-bottom:1px solid #eee;font-size:20px;margin-bottom:25px}body>.top{background:#fff;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center}body>.top .logo a{display:inline-flex;color:#6690f4;padding:10px;text-decoration:none}body>.top .logo a span{font-weight:600}body>.top .user-nav{position:relative;font-size:14px;padding:10px}body>.top .user-nav .trigger{cursor:pointer}body>.top .user-nav .trigger i{color:#6690f4}body>.top .user-nav ul{position:absolute;top:100%;left:0;background:#fff;margin:0;padding:0;width:100%;list-style-type:none;border:1px solid #eee;display:none}body>.top .user-nav ul li{cursor:pointer}body>.top .user-nav ul li:hover{background:#f8f9fa}body>.top .user-nav ul li a{color:#333;display:block;text-decoration:none;padding:10px}body>.top .user-nav:hover ul{display:block}.main-menu{display:flex;box-shadow:0 .125rem .25rem rgba(0,0,0,.075) !important}.main-menu ul{display:flex;list-style-type:none;margin:0;padding:0;font-size:14px}.main-menu ul li{cursor:pointer}.main-menu ul li a{color:#333;text-decoration:none;display:inline-flex;padding:10px 15px}.main-menu ul li:hover a{background:#6690f4;color:#fff}.main-menu ul li ul{display:none}.main{padding:25px;background:#d9dee2;min-height:calc(100vh - 80px)}.tasks_container{display:flex;flex-wrap:wrap;gap:20px}.tasks_container .column{width:350px}.tasks_container .column h2{display:flex;padding:10px;background:#fff;margin-bottom:10px;font-size:15px;font-weight:300;border-radius:3px 3px 0 0;justify-content:space-between;align-items:center}.tasks_container .column h2 i{cursor:pointer}.tasks_container .column.tasks_suspended h2{border-bottom:5px solid #c00}.tasks_container .column.tasks_new h2{border-bottom:5px solid #ccc}.tasks_container .column.tasks_bulk h2{border-bottom:5px solid #ff8c00}.tasks_container .column.tasks_to_do h2{border-bottom:5px solid #2aaf47}.tasks_container .column.tasks_to_review h2{border-bottom:5px solid #2535c9}.tasks_container .column.tasks_closed h2{border-bottom:5px solid #000}.tasks_container .column ul{list-style-type:none;margin:0;padding:0}.tasks_container .column ul .task{margin-bottom:5px;background:#fff;padding:10px;border-radius:0;display:flex;position:relative}.tasks_container .column ul .task.notopened{border:2px solid #c00}.tasks_container .column ul .task .left{width:30px}.tasks_container .column ul .task .left .users{display:flex;gap:5px;flex-wrap:wrap}.tasks_container .column ul .task .left .users .user{display:flex;width:20px;height:20px;border-radius:50%;justify-content:center;align-items:center;color:#fff;font-size:13px}.tasks_container .column ul .task .middle{width:calc(100% - 60px)}.tasks_container .column ul .task .right{width:30px;display:flex;flex-wrap:wrap;justify-content:flex-end}.tasks_container .column ul .task .right .recursively{color:#ccc;border-radius:3px;cursor:pointer;margin-bottom:5px;width:22px;height:22px;text-align:center}.tasks_container .column ul .task .right .recursively i{font-size:15px}.tasks_container .column ul .task .right .task_start{background:#57b951;color:#fff;border-radius:3px;cursor:pointer;margin-bottom:5px;width:22px;height:22px;text-align:center}.tasks_container .column ul .task .right .task_start.hidden{display:none}.tasks_container .column ul .task .right .task_start i{font-size:12px}.tasks_container .column ul .task .right .task_end{background:#c00;color:#fff;border-radius:3px;cursor:pointer;margin-bottom:5px;width:22px;height:22px;text-align:center}.tasks_container .column ul .task .right .task_end.hidden{display:none}.tasks_container .column ul .task .right .task_end i{font-size:12px}.tasks_container .column ul .task .name{font-size:14px;color:#333;text-decoration:none;display:block;margin-bottom:5px}.tasks_container .column ul .task .bottom{display:flex;justify-content:space-between}.tasks_container .column ul .task .client_info,.tasks_container .column ul .task .current_status{font-size:12px;font-weight:400}.tasks_container .column ul .task .client_info strong,.tasks_container .column ul .task .current_status strong{font-weight:600}.tasks_container .column ul .task .current_status{position:relative;cursor:pointer}.tasks_container .column ul .task .current_status .status_change{position:absolute;left:0;top:20px;background:#fff;padding:15px;border:1px solid #dfdfdf;border-radius:3px;cursor:pointer;box-shadow:0 0 15px rgba(0,0,0,.1);z-index:99;display:none}.tasks_container .column ul .task .current_status .status_change select{width:250px;padding:10px;border:1px solid #eee;border-radius:3px}.tasks_container .column ul .task .current_status .status_change select option{font-size:15px;padding:3px}.tasks_container .column ul .task .dates{margin-bottom:5px;display:flex;justify-content:space-between;font-size:12px}.tasks_container .column ul .task .dates .danger{color:#c00;font-weight:600}.tasks_container .column ul .task .dates .warning{color:#ff8c00}.tasks_container .column ul .task .dates i{font-size:12px;color:#c9ced4;margin-right:5px}.action_menu{display:flex;margin-bottom:25px;gap:20px}.action_menu .btn{display:inline-flex;padding:7px 15px;color:#fff;border-radius:3px;text-decoration:none;align-items:center;justify-content:center;gap:5px}.action_menu .btn.btn_add{background:#57b951}.action_menu .btn.btn_add:hover{background:#4a9c3b}.action_menu .btn.btn_cancel{background:#c00}.action_menu .btn.btn_cancel:hover{background:#b30000}.action_menu .btn.disabled{opacity:.5}.form_container{background:#fff;padding:25px;max-width:1300px}.form_container.full{max-width:100%}.form_container .form_group{margin-bottom:10px;display:flex}.form_container .form_group>.label{width:300px;display:inline-flex;align-items:flex-start;justify-content:right;padding-right:10px}.form_container .form_group .input{width:calc(100% - 300px)}.task_popup{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);display:none}.task_popup .task_details{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);background:#fff;padding:25px;border-radius:6px;max-width:1140px;width:100%}.task_popup .task_details .title{font-size:20px;margin-bottom:25px}.task_popup .task_details .title a{color:#333;text-decoration:none;margin-right:10px}.task_popup .task_details .title a.task-delete{color:#c00}.task_popup .task_details .close{position:absolute;top:10px;right:10px;cursor:pointer}.task_popup .task_details .content{display:flex;font-size:14px}.task_popup .task_details .content h3{width:100%;margin-top:0;margin-bottom:5px;font-weight:500;color:#000;font-size:17px}.task_popup .task_details .content .left{width:70%;max-height:700px;overflow-y:auto}.task_popup .task_details .content .left .users{display:flex;gap:20px}.task_popup .task_details .content .left .users .user{display:flex;gap:10px;align-items:center;margin-bottom:10px}.task_popup .task_details .content .left .users .user .avatar{height:30px;width:30px;border-radius:50%;background:#ccc;display:flex;justify-content:center;align-items:center;color:#fff}.task_popup .task_details .content .left .comments{border-radius:3px;padding:0 15px 15px 0;margin-bottom:15px;border-bottom:1px solid #eee}.task_popup .task_details .content .left .comments .new_comment{margin-bottom:15px}.task_popup .task_details .content .left .comments .new_comment textarea{height:75px}.task_popup .task_details .content .left .comments .new_comment .add_comment{background:#57b951;color:#fff;padding:10px;border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;width:200px;text-decoration:none}.task_popup .task_details .content .left .comments .new_comment .add_comment:hover{background:#4a9c3b}.task_popup .task_details .content .left .comments ul{margin:0;padding:0;list-style-type:none}.task_popup .task_details .content .left .comments ul li{background:#eee;margin-bottom:5px;padding:15px;border-radius:6px;position:relative}.task_popup .task_details .content .left .comments ul li .delete_comment{position:absolute;top:10px;right:10px;cursor:pointer;color:#c00}.task_popup .task_details .content .left .comments ul li .author{font-weight:600;margin-bottom:5px;display:inline-flex;margin-right:10px}.task_popup .task_details .content .left .comments ul li .date{font-size:12px;margin-bottom:5px;display:inline-flex}.task_popup .task_details .content .left .comments ul li .text{margin-bottom:15px;font-size:13px}.task_popup .task_details .content .left .checklist{border-radius:3px;padding:0 15px 15px 0;margin-bottom:15px;border-bottom:1px solid #eee}.task_popup .task_details .content .left .checklist .new_element{display:flex;margin-bottom:15px}.task_popup .task_details .content .left .checklist .new_element a{display:flex;align-items:center;justify-content:center;padding:10px;border-radius:0 6px 6px 0;text-decoration:none;width:35px;background:#57b951;color:#fff}.task_popup .task_details .content .left .checklist ul{margin:0;padding:0;list-style-type:none}.task_popup .task_details .content .left .checklist ul li{display:flex;gap:10px;margin-bottom:5px;background:#fff;border-radius:3px;padding:5px;border:1px solid #eee;font-size:13px;align-items:center}.task_popup .task_details .content .left .checklist ul li i{margin-left:auto;margin-right:0;cursor:pointer;color:#c00}.task_popup .task_details .content .left .description{padding:15px;border-radius:3px;background:#f6f8f9;margin-bottom:15px}.task_popup .task_details .content .right{width:30%;padding:0 15px 15px}.task_popup .task_details .content .right .box{margin-bottom:15px}.task_popup .task_details .content .right .time a{display:block;padding:10px;border-radius:6px;margin-top:10px;text-decoration:none;text-align:center;width:100%}.task_popup .task_details .content .right .time a.task_start{background:#57b951;color:#fff}.task_popup .task_details .content .right .time a.task_end{background:#c00;color:#fff}.task_popup .task_details .content .right .time a.hidden{display:none}.task_popup .task_details .content .right .dates{display:flex;justify-content:space-between;flex-wrap:wrap}.task_popup .task_details .content .right .dates .danger{color:#c00;font-weight:600}.task_popup .task_details .content .right .dates .warning{color:#ff8c00}.task_popup .task_details .content .right .dates i{color:#c9ced4;margin-right:5px}.table{width:100%}.table th,.table td{border:1px solid #eee;padding:5px}.table td.center{text-align:center}.table td.left{text-align:left}.table.table-sm td{padding:5px !important}.table input.form-control{font-size:13px}.projects_container{background:#fff;padding:15px;border-radius:6px;display:flex;gap:20px}.projects_container .left{display:flex;gap:20px;flex-wrap:wrap;width:calc(100% - 250px)}.projects_container .right{width:250px;display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:center;gap:20px;border-left:1px solid #eee;padding-left:15px}.projects_container .right select{width:200px}.default_popup{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);display:none}.default_popup .popup_content{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);background:#fff;padding:25px;border-radius:6px;max-width:1140px;width:100%}.default_popup .popup_content .close{position:absolute;top:10px;right:10px;cursor:pointer}#fg-cron{margin:10px 0}#fg-cron .countdown{background:#57b951;color:#fff;padding:10px}#fg-cron #cron-container{max-height:300px;overflow-x:hidden;overflow-y:auto}#fg-cron #cron-container .msg{font-size:13px;padding:5px;border-bottom:1px solid #e8e8e8}.card{background:#fff;padding:25px;border-radius:6px;color:#000;font-size:15px;max-width:1280px}.card.mb25{margin-bottom:25px}.card .card-header{font-weight:600}.card .card-body{padding-top:10px}.card .card-body table{border-collapse:collapse}.card .card-body table th,.card .card-body table td{font-size:14px}.card .card-body table th.bold,.card .card-body table td.bold{font-weight:600}.card .card-body table th.text-right,.card .card-body table td.text-right{text-align:right}.card .card-body table th.text-center,.card .card-body table td.text-center{text-align:center}.card .card-body table th .close-task,.card .card-body table td .close-task{text-decoration:none;color:#6690f4}.card .card-body table th .close-task:hover,.card .card-body table td .close-task:hover{color:#c00}.finance-summary{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:10px}.finance-summary .panel{background:#fff;border-radius:6px;padding:15px}.finance-summary .panel h1{font-size:20px;margin:0}.finance-summary .panel span{font-size:.85em}.finance-manager{display:grid;gap:10px;grid-template-columns:200px 1fr 500px;padding-top:25px}.finance-manager .manage-menu{display:inline-block;margin-right:10px}.finance-manager .manage-menu a{color:#333;text-decoration:none;font-weight:300;display:block}.finance-manager .actions{width:100px;text-align:center}.finance-manager .actions a{display:inline-flex;margin:0 2px;height:25px;width:25px;align-items:center;justify-content:center;text-decoration:none;color:#000;border:1px solid #e7e7e7;font-size:11px;border-radius:3px;transition:all .3s ease}.finance-manager .actions a:hover{border:1px solid #6690f4}.bootstrap-tagsinput .tag{background:#6690f4;font-size:13px;padding:5px 10px;border-radius:12px}.bootstrap-tagsinput .tag [data-role=remove]{color:#fff !important}.bootstrap-tagsinput .tag [data-role=remove]:hover{box-shadow:none !important;color:#000 !important}.finance-tags{display:flex;flex-wrap:wrap;gap:5px}.finance-tags a:not(.btn){display:flex;width:100%;text-decoration:none;color:#4e5e6a}.finance-tags a:not(.btn).zoom-100{font-size:130%}.finance-tags a:not(.btn).zoom-90{font-size:120%}.finance-tags a:not(.btn).zoom-80{font-size:110%}.finance-tags a:not(.btn).zoom-70{font-size:100%}.finance-tags a:not(.btn).zoom-60{font-size:95%}.finance-tags a:not(.btn).zoom-50{font-size:85%}.finance-tags a:not(.btn).zoom-40{font-size:80%}.finance-tags a:not(.btn).zoom-30{font-size:75%}.finance-tags a:not(.btn).zoom-20{font-size:75%}.finance-tags a:not(.btn).zoom-10{font-size:70%}.finance-tags a:not(.btn).zoom-0{font-size:70%}.manage-menu{position:relative}.manage-menu .context-menu{border-left:4px dotted #000;width:1px;height:100%}.manage-menu .context-menu-container{position:absolute;display:none;background:#fff;box-shadow:5px 5px 15px rgba(0,0,0,.1);top:2px;left:2px}.manage-menu .context-menu-container ul{list-style-type:none;margin:0;padding:0}.manage-menu .context-menu-container ul li a{display:block;padding:7px 15px;white-space:nowrap}.manage-menu .context-menu-container ul li a:hover{background:#f8f8f8}.manage-menu:hover .context-menu-container{display:block}.dt-layout-table{margin-bottom:25px}.pagination button{border:1px solid #eee;background:#fff;display:inline-flex;height:30px;width:30px;align-items:center;justify-content:center;margin:0 2px;transition:all .3s ease}.pagination button:hover{background:#eee}table#products .table-product-title{display:flex;justify-content:space-between}table#products .edit-product-title{display:flex;height:25px;align-items:center;justify-content:center;width:25px;cursor:pointer;background:#fff;border:1px solid #9b9b9b;color:#9b9b9b}table#products .edit-product-title:hover{background:#9b9b9b;color:#fff}table#products a.custom_name{color:#57b951 !important}.chart-with-form{display:flex;gap:20px;align-items:flex-start}.chart-area{flex:1 1 auto;min-width:0}.comment-form{width:360px;flex:0 0 360px}.comment-form .form-group{margin-bottom:12px}.comment-form label{display:block;font-weight:600;margin-bottom:6px}.comment-form input[type=date],.comment-form textarea{width:100%;border:1px solid #ccc;border-radius:4px;padding:0 8px;font-size:14px}.comment-form textarea{min-height:120px;resize:vertical}.comment-form .btn{display:inline-block;padding:8px 14px;border-radius:4px;border:0;background:#337ab7;color:#fff;font-weight:600;cursor:pointer}.comment-form .btn[disabled]{opacity:.6;cursor:not-allowed}.comment-form .hint{font-size:12px;color:#666}.jconfirm-box .form-group .select2-container{width:100% !important;margin-top:8px}.jconfirm-box .select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #ced4da;border-radius:3px;min-height:42px;display:flex;align-items:center;padding:4px 12px;box-shadow:none;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;font-size:14px}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered{padding-left:0;line-height:1.4;color:#495057}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder{color:#adb5bd}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow{height:100%;right:8px}.jconfirm-box .select2-container--default.select2-container--focus .select2-selection--single,.jconfirm-box .select2-container--default .select2-selection--single:hover{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25);outline:0}.jconfirm-box .select2-container .select2-dropdown{border-color:#ced4da;border-radius:0 0 3px 3px;font-size:14px}.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field{padding:6px 10px;border-radius:3px;border:1px solid #ced4da;font-size:14px}.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#007bff;color:#fff}/*# sourceMappingURL=style.css.map */ \ No newline at end of file +.animate{animation:mymove 3s infinite}.text-right{text-align:right}.text-bold{font-weight:900 !important}.nowrap{white-space:nowrap}table{border-collapse:collapse}small{font-size:.75em}table{font-size:13px}@keyframes mymove{50%{opacity:.33}}@keyframes gradient-animation{0%{background-position:15% 0%}50%{background-position:85% 100%}100%{background-position:15% 0%}}@keyframes frame-enter{0%{clip-path:polygon(0% 100%, 3px 100%, 3px 3px, calc(100% - 3px) 3px, calc(100% - 3px) calc(100% - 3px), 3px calc(100% - 3px), 3px 100%, 100% 100%, 100% 0%, 0% 0%)}25%{clip-path:polygon(0% 100%, 3px 100%, 3px 3px, calc(100% - 3px) 3px, calc(100% - 3px) calc(100% - 3px), calc(100% - 3px) calc(100% - 3px), calc(100% - 3px) 100%, 100% 100%, 100% 0%, 0% 0%)}50%{clip-path:polygon(0% 100%, 3px 100%, 3px 3px, calc(100% - 3px) 3px, calc(100% - 3px) 3px, calc(100% - 3px) 3px, calc(100% - 3px) 3px, calc(100% - 3px) 3px, 100% 0%, 0% 0%)}75%{-webkit-clip-path:polygon(0% 100%, 3px 100%, 3px 3px, 3px 3px, 3px 3px, 3px 3px, 3px 3px, 3px 3px, 3px 0%, 0% 0%)}100%{-webkit-clip-path:polygon(0% 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 3px 100%, 0% 100%)}}*{box-sizing:border-box}body{font-family:"Roboto",sans-serif;margin:0;padding:0;font-size:15px;color:#4e5e6a}.btn{padding:12px 25px;transition:all .3s ease;color:#fff;border:0;border-radius:6px;cursor:pointer;display:inline-flex;text-decoration:none;gap:5px;justify-content:center;align-items:center}.btn.btn_small,.btn.btn-xs{padding:5px 7px;font-size:13px}.btn.btn_small i,.btn.btn-xs i{font-size:12px}.btn.btn-success{background:#57b951}.btn.btn-success:hover{background:#4a9c3b}.btn.btn-primary{background:#6690f4}.btn.btn-primary:hover{background:#3164db}.btn.btn-danger{background:#c00}.btn.btn-danger:hover{background:#b30000}.hide{display:none}.form-error{color:#c00;font-size:13px}.input-group{margin-bottom:10px}input[type=checkbox]{border:1px solid #eee}.form-control{border:1px solid #eee;border-radius:3px;height:35px;width:100%;padding:5px;font-family:"Roboto",sans-serif}.form-control option{padding:5px}.form-control:focus{border:1px solid #6690f4;outline:none}.unlogged{background:#eef1f9;display:flex;align-items:center;justify-content:center;height:100vh}.unlogged .box-login{background:#fff;padding:25px;border-radius:6px;width:400px}.unlogged .box-login .title{text-align:center;padding:10px 10px 25px;border-bottom:1px solid #eee;font-size:20px;margin-bottom:25px}body>.top{background:#fff;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center}body>.top .logo a{display:inline-flex;color:#6690f4;padding:10px;text-decoration:none}body>.top .logo a span{font-weight:600}body>.top .user-nav{position:relative;font-size:14px;padding:10px}body>.top .user-nav .trigger{cursor:pointer}body>.top .user-nav .trigger i{color:#6690f4}body>.top .user-nav ul{position:absolute;top:100%;left:0;background:#fff;margin:0;padding:0;width:100%;list-style-type:none;border:1px solid #eee;display:none}body>.top .user-nav ul li{cursor:pointer}body>.top .user-nav ul li:hover{background:#f8f9fa}body>.top .user-nav ul li a{color:#333;display:block;text-decoration:none;padding:10px}body>.top .user-nav:hover ul{display:block}.main-menu{display:flex;box-shadow:0 .125rem .25rem rgba(0,0,0,.075) !important}.main-menu ul{display:flex;list-style-type:none;margin:0;padding:0;font-size:14px}.main-menu ul li{cursor:pointer}.main-menu ul li a{color:#333;text-decoration:none;display:inline-flex;padding:10px 15px}.main-menu ul li:hover a{background:#6690f4;color:#fff}.main-menu ul li ul{display:none}.main{padding:25px;background:#d9dee2;min-height:calc(100vh - 80px)}.tasks_container{display:flex;flex-wrap:wrap;gap:20px}.tasks_container .column{width:350px}.tasks_container .column h2{display:flex;padding:10px;background:#fff;margin-bottom:10px;font-size:15px;font-weight:300;border-radius:3px 3px 0 0;justify-content:space-between;align-items:center}.tasks_container .column h2 i{cursor:pointer}.tasks_container .column.tasks_suspended h2{border-bottom:5px solid #c00}.tasks_container .column.tasks_new h2{border-bottom:5px solid #ccc}.tasks_container .column.tasks_bulk h2{border-bottom:5px solid #ff8c00}.tasks_container .column.tasks_to_do h2{border-bottom:5px solid #2aaf47}.tasks_container .column.tasks_to_review h2{border-bottom:5px solid #2535c9}.tasks_container .column.tasks_closed h2{border-bottom:5px solid #000}.tasks_container .column ul{list-style-type:none;margin:0;padding:0}.tasks_container .column ul .task{margin-bottom:5px;background:#fff;padding:10px;border-radius:0;display:flex;position:relative}.tasks_container .column ul .task.notopened{border:2px solid #c00}.tasks_container .column ul .task .left{width:30px}.tasks_container .column ul .task .left .users{display:flex;gap:5px;flex-wrap:wrap}.tasks_container .column ul .task .left .users .user{display:flex;width:20px;height:20px;border-radius:50%;justify-content:center;align-items:center;color:#fff;font-size:13px}.tasks_container .column ul .task .middle{width:calc(100% - 60px)}.tasks_container .column ul .task .right{width:30px;display:flex;flex-wrap:wrap;justify-content:flex-end}.tasks_container .column ul .task .right .recursively{color:#ccc;border-radius:3px;cursor:pointer;margin-bottom:5px;width:22px;height:22px;text-align:center}.tasks_container .column ul .task .right .recursively i{font-size:15px}.tasks_container .column ul .task .right .task_start{background:#57b951;color:#fff;border-radius:3px;cursor:pointer;margin-bottom:5px;width:22px;height:22px;text-align:center}.tasks_container .column ul .task .right .task_start.hidden{display:none}.tasks_container .column ul .task .right .task_start i{font-size:12px}.tasks_container .column ul .task .right .task_end{background:#c00;color:#fff;border-radius:3px;cursor:pointer;margin-bottom:5px;width:22px;height:22px;text-align:center}.tasks_container .column ul .task .right .task_end.hidden{display:none}.tasks_container .column ul .task .right .task_end i{font-size:12px}.tasks_container .column ul .task .name{font-size:14px;color:#333;text-decoration:none;display:block;margin-bottom:5px}.tasks_container .column ul .task .bottom{display:flex;justify-content:space-between}.tasks_container .column ul .task .client_info,.tasks_container .column ul .task .current_status{font-size:12px;font-weight:400}.tasks_container .column ul .task .client_info strong,.tasks_container .column ul .task .current_status strong{font-weight:600}.tasks_container .column ul .task .current_status{position:relative;cursor:pointer}.tasks_container .column ul .task .current_status .status_change{position:absolute;left:0;top:20px;background:#fff;padding:15px;border:1px solid #dfdfdf;border-radius:3px;cursor:pointer;box-shadow:0 0 15px rgba(0,0,0,.1);z-index:99;display:none}.tasks_container .column ul .task .current_status .status_change select{width:250px;padding:10px;border:1px solid #eee;border-radius:3px}.tasks_container .column ul .task .current_status .status_change select option{font-size:15px;padding:3px}.tasks_container .column ul .task .dates{margin-bottom:5px;display:flex;justify-content:space-between;font-size:12px}.tasks_container .column ul .task .dates .danger{color:#c00;font-weight:600}.tasks_container .column ul .task .dates .warning{color:#ff8c00}.tasks_container .column ul .task .dates i{font-size:12px;color:#c9ced4;margin-right:5px}.action_menu{display:flex;margin-bottom:25px;gap:20px}.action_menu .btn{display:inline-flex;padding:7px 15px;color:#fff;border-radius:3px;text-decoration:none;align-items:center;justify-content:center;gap:5px}.action_menu .btn.btn_add{background:#57b951}.action_menu .btn.btn_add:hover{background:#4a9c3b}.action_menu .btn.btn_cancel{background:#c00}.action_menu .btn.btn_cancel:hover{background:#b30000}.action_menu .btn.disabled{opacity:.5}.form_container{background:#fff;padding:25px;max-width:1300px}.form_container.full{max-width:100%}.form_container .form_group{margin-bottom:10px;display:flex}.form_container .form_group>.label{width:300px;display:inline-flex;align-items:flex-start;justify-content:right;padding-right:10px}.form_container .form_group .input{width:calc(100% - 300px)}.task_popup{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);display:none}.task_popup .task_details{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);background:#fff;padding:25px;border-radius:6px;max-width:1140px;width:100%}.task_popup .task_details .title{font-size:20px;margin-bottom:25px}.task_popup .task_details .title a{color:#333;text-decoration:none;margin-right:10px}.task_popup .task_details .title a.task-delete{color:#c00}.task_popup .task_details .close{position:absolute;top:10px;right:10px;cursor:pointer}.task_popup .task_details .content{display:flex;font-size:14px}.task_popup .task_details .content h3{width:100%;margin-top:0;margin-bottom:5px;font-weight:500;color:#000;font-size:17px}.task_popup .task_details .content .left{width:70%;max-height:700px;overflow-y:auto}.task_popup .task_details .content .left .users{display:flex;gap:20px}.task_popup .task_details .content .left .users .user{display:flex;gap:10px;align-items:center;margin-bottom:10px}.task_popup .task_details .content .left .users .user .avatar{height:30px;width:30px;border-radius:50%;background:#ccc;display:flex;justify-content:center;align-items:center;color:#fff}.task_popup .task_details .content .left .comments{border-radius:3px;padding:0 15px 15px 0;margin-bottom:15px;border-bottom:1px solid #eee}.task_popup .task_details .content .left .comments .new_comment{margin-bottom:15px}.task_popup .task_details .content .left .comments .new_comment textarea{height:75px}.task_popup .task_details .content .left .comments .new_comment .add_comment{background:#57b951;color:#fff;padding:10px;border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;width:200px;text-decoration:none}.task_popup .task_details .content .left .comments .new_comment .add_comment:hover{background:#4a9c3b}.task_popup .task_details .content .left .comments ul{margin:0;padding:0;list-style-type:none}.task_popup .task_details .content .left .comments ul li{background:#eee;margin-bottom:5px;padding:15px;border-radius:6px;position:relative}.task_popup .task_details .content .left .comments ul li .delete_comment{position:absolute;top:10px;right:10px;cursor:pointer;color:#c00}.task_popup .task_details .content .left .comments ul li .author{font-weight:600;margin-bottom:5px;display:inline-flex;margin-right:10px}.task_popup .task_details .content .left .comments ul li .date{font-size:12px;margin-bottom:5px;display:inline-flex}.task_popup .task_details .content .left .comments ul li .text{margin-bottom:15px;font-size:13px}.task_popup .task_details .content .left .checklist{border-radius:3px;padding:0 15px 15px 0;margin-bottom:15px;border-bottom:1px solid #eee}.task_popup .task_details .content .left .checklist .new_element{display:flex;margin-bottom:15px}.task_popup .task_details .content .left .checklist .new_element a{display:flex;align-items:center;justify-content:center;padding:10px;border-radius:0 6px 6px 0;text-decoration:none;width:35px;background:#57b951;color:#fff}.task_popup .task_details .content .left .checklist ul{margin:0;padding:0;list-style-type:none}.task_popup .task_details .content .left .checklist ul li{display:flex;gap:10px;margin-bottom:5px;background:#fff;border-radius:3px;padding:5px;border:1px solid #eee;font-size:13px;align-items:center}.task_popup .task_details .content .left .checklist ul li i{margin-left:auto;margin-right:0;cursor:pointer;color:#c00}.task_popup .task_details .content .left .description{padding:15px;border-radius:3px;background:#f6f8f9;margin-bottom:15px}.task_popup .task_details .content .right{width:30%;padding:0 15px 15px}.task_popup .task_details .content .right .box{margin-bottom:15px}.task_popup .task_details .content .right .time a{display:block;padding:10px;border-radius:6px;margin-top:10px;text-decoration:none;text-align:center;width:100%}.task_popup .task_details .content .right .time a.task_start{background:#57b951;color:#fff}.task_popup .task_details .content .right .time a.task_end{background:#c00;color:#fff}.task_popup .task_details .content .right .time a.hidden{display:none}.task_popup .task_details .content .right .dates{display:flex;justify-content:space-between;flex-wrap:wrap}.task_popup .task_details .content .right .dates .danger{color:#c00;font-weight:600}.task_popup .task_details .content .right .dates .warning{color:#ff8c00}.task_popup .task_details .content .right .dates i{color:#c9ced4;margin-right:5px}.table{width:100%}.table th,.table td{border:1px solid #eee;padding:5px}.table td.center{text-align:center}.table td.left{text-align:left}.table.table-sm td{padding:5px !important}.table input.form-control{font-size:13px}.projects_container{background:#fff;padding:15px;border-radius:6px;display:flex;gap:20px}.projects_container .left{display:flex;gap:20px;flex-wrap:wrap;width:calc(100% - 250px)}.projects_container .right{width:250px;display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:center;gap:20px;border-left:1px solid #eee;padding-left:15px}.projects_container .right select{width:200px}.default_popup{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);display:none}.default_popup .popup_content{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);background:#fff;padding:25px;border-radius:6px;max-width:1140px;width:100%}.default_popup .popup_content .close{position:absolute;top:10px;right:10px;cursor:pointer}#fg-cron{margin:10px 0}#fg-cron .countdown{background:#57b951;color:#fff;padding:10px}#fg-cron #cron-container{max-height:300px;overflow-x:hidden;overflow-y:auto}#fg-cron #cron-container .msg{font-size:13px;padding:5px;border-bottom:1px solid #e8e8e8}.card{background:#fff;padding:25px;border-radius:6px;color:#000;font-size:15px;max-width:1280px}.card.mb25{margin-bottom:25px}.card .card-header{font-weight:600}.card .card-body{padding-top:10px}.card .card-body table{border-collapse:collapse}.card .card-body table th,.card .card-body table td{font-size:14px}.card .card-body table th.bold,.card .card-body table td.bold{font-weight:600}.card .card-body table th.text-right,.card .card-body table td.text-right{text-align:right}.card .card-body table th.text-center,.card .card-body table td.text-center{text-align:center}.card .card-body table th .close-task,.card .card-body table td .close-task{text-decoration:none;color:#6690f4}.card .card-body table th .close-task:hover,.card .card-body table td .close-task:hover{color:#c00}.finance-summary{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:10px}.finance-summary .panel{background:#fff;border-radius:6px;padding:15px}.finance-summary .panel h1{font-size:20px;margin:0}.finance-summary .panel span{font-size:.85em}.finance-manager{display:grid;gap:10px;grid-template-columns:200px 1fr 500px;padding-top:25px}.finance-manager .manage-menu{display:inline-block;margin-right:10px}.finance-manager .manage-menu a{color:#333;text-decoration:none;font-weight:300;display:block}.finance-manager .actions{width:100px;text-align:center}.finance-manager .actions a{display:inline-flex;margin:0 2px;height:25px;width:25px;align-items:center;justify-content:center;text-decoration:none;color:#000;border:1px solid #e7e7e7;font-size:11px;border-radius:3px;transition:all .3s ease}.finance-manager .actions a:hover{border:1px solid #6690f4}.bootstrap-tagsinput .tag{background:#6690f4;font-size:13px;padding:5px 10px;border-radius:12px}.bootstrap-tagsinput .tag [data-role=remove]{color:#fff !important}.bootstrap-tagsinput .tag [data-role=remove]:hover{box-shadow:none !important;color:#000 !important}.finance-tags{display:flex;flex-wrap:wrap;gap:5px}.finance-tags a:not(.btn){display:flex;width:100%;text-decoration:none;color:#4e5e6a}.finance-tags a:not(.btn).zoom-100{font-size:130%}.finance-tags a:not(.btn).zoom-90{font-size:120%}.finance-tags a:not(.btn).zoom-80{font-size:110%}.finance-tags a:not(.btn).zoom-70{font-size:100%}.finance-tags a:not(.btn).zoom-60{font-size:95%}.finance-tags a:not(.btn).zoom-50{font-size:85%}.finance-tags a:not(.btn).zoom-40{font-size:80%}.finance-tags a:not(.btn).zoom-30{font-size:75%}.finance-tags a:not(.btn).zoom-20{font-size:75%}.finance-tags a:not(.btn).zoom-10{font-size:70%}.finance-tags a:not(.btn).zoom-0{font-size:70%}.manage-menu{position:relative}.manage-menu .context-menu{border-left:4px dotted #000;width:1px;height:100%}.manage-menu .context-menu-container{position:absolute;display:none;background:#fff;box-shadow:5px 5px 15px rgba(0,0,0,.1);top:2px;left:2px}.manage-menu .context-menu-container ul{list-style-type:none;margin:0;padding:0}.manage-menu .context-menu-container ul li a{display:block;padding:7px 15px;white-space:nowrap}.manage-menu .context-menu-container ul li a:hover{background:#f8f8f8}.manage-menu:hover .context-menu-container{display:block}.dt-layout-table{margin-bottom:25px}.pagination button{border:1px solid #eee;background:#fff;display:inline-flex;height:30px;width:30px;align-items:center;justify-content:center;margin:0 2px;transition:all .3s ease}.pagination button:hover{background:#eee}table#products .table-product-title{display:flex;justify-content:space-between}table#products .edit-product-title{display:flex;height:25px;align-items:center;justify-content:center;width:25px;cursor:pointer;background:#fff;border:1px solid #9b9b9b;color:#9b9b9b}table#products .edit-product-title:hover{background:#9b9b9b;color:#fff}table#products a.custom_name{color:#57b951 !important}.chart-with-form{display:flex;gap:20px;align-items:flex-start}.chart-area{flex:1 1 auto;min-width:0}.comment-form{width:360px;flex:0 0 360px}.comment-form .form-group{margin-bottom:12px}.comment-form label{display:block;font-weight:600;margin-bottom:6px}.comment-form input[type=date],.comment-form textarea{width:100%;border:1px solid #ccc;border-radius:4px;padding:0 8px;font-size:14px}.comment-form textarea{min-height:120px;resize:vertical}.comment-form .btn{display:inline-block;padding:8px 14px;border-radius:4px;border:0;background:#337ab7;color:#fff;font-weight:600;cursor:pointer}.comment-form .btn[disabled]{opacity:.6;cursor:not-allowed}.comment-form .hint{font-size:12px;color:#666}.jconfirm-box .form-group .select2-container{width:100% !important;margin-top:8px}.jconfirm-box .select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #ced4da;border-radius:3px;min-height:42px;display:flex;align-items:center;padding:4px 12px;box-shadow:none;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;font-size:14px}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered{padding-left:0;line-height:1.4;color:#495057}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder{color:#adb5bd}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow{height:100%;right:8px}.jconfirm-box .select2-container--default.select2-container--focus .select2-selection--single,.jconfirm-box .select2-container--default .select2-selection--single:hover{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25);outline:0}.jconfirm-box .select2-container .select2-dropdown{border-color:#ced4da;border-radius:0 0 3px 3px;font-size:14px}.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field{padding:6px 10px;border-radius:3px;border:1px solid #ced4da;font-size:14px}.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#007bff;color:#fff}/*# sourceMappingURL=style.css.map */ diff --git a/layout/style-old.scss b/layout/style-old.scss index eddb6a8..307f5a7 100644 --- a/layout/style-old.scss +++ b/layout/style-old.scss @@ -80,7 +80,7 @@ table { } body { - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; margin: 0; padding: 0; font-size: 15px; @@ -158,7 +158,7 @@ input[type="checkbox"] { height: 35px; width: 100%; padding: 5px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; option { padding: 5px; @@ -1406,4 +1406,4 @@ table { .jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected] { background-color: #007bff; color: #fff; -} \ No newline at end of file +} diff --git a/layout/style.css b/layout/style.css index 99cf33f..8bc73a1 100644 --- a/layout/style.css +++ b/layout/style.css @@ -3,7 +3,7 @@ } body { - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; margin: 0; padding: 0; font-size: 14px; @@ -162,7 +162,7 @@ body.unlogged { border-radius: 8px; padding: 0 14px; font-size: 14px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; color: #2D3748; transition: border-color 0.3s, box-shadow 0.3s; } @@ -493,7 +493,7 @@ body.logged { justify-content: center; align-items: center; font-size: 14px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; font-weight: 500; } .btn.btn_small, .btn.btn-xs, .btn.btn-sm { @@ -532,7 +532,7 @@ body.logged { height: 38px; width: 100%; padding: 6px 12px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; font-size: 14px; color: #2D3748; transition: border-color 0.2s, box-shadow 0.2s; @@ -1577,7 +1577,7 @@ table#products a.custom_name { border-radius: 6px; padding: 8px 12px; font-size: 14px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; } .comment-form textarea { min-height: 120px; @@ -1676,3 +1676,4 @@ table#products a.custom_name { } /*# sourceMappingURL=style.css.map */ + diff --git a/layout/style.scss b/layout/style.scss index d134a16..4dc348c 100644 --- a/layout/style.scss +++ b/layout/style.scss @@ -31,7 +31,7 @@ $transitionSpeed: 0.3s; } body { - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; margin: 0; padding: 0; font-size: 14px; @@ -212,7 +212,7 @@ body.unlogged { border-radius: 8px; padding: 0 14px; font-size: 14px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; color: $cTextDark; transition: border-color $transitionSpeed, box-shadow $transitionSpeed; @@ -610,7 +610,7 @@ body.logged { justify-content: center; align-items: center; font-size: 14px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; font-weight: 500; &.btn_small, @@ -661,7 +661,7 @@ body.logged { height: 38px; width: 100%; padding: 6px 12px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; font-size: 14px; color: $cTextDark; transition: border-color 0.2s, box-shadow 0.2s; @@ -1882,7 +1882,7 @@ table#products { border-radius: 6px; padding: 8px 12px; font-size: 14px; - font-family: "Open Sans", sans-serif; + font-family: "Roboto", sans-serif; } textarea { @@ -1989,4 +1989,4 @@ table#products { .main-wrapper { margin-left: 0 !important; } -} \ No newline at end of file +} diff --git a/migrations/004_campaigns_performance_max_flag.sql b/migrations/004_campaigns_performance_max_flag.sql new file mode 100644 index 0000000..b4e9860 --- /dev/null +++ b/migrations/004_campaigns_performance_max_flag.sql @@ -0,0 +1,18 @@ +-- Migracja: typ kampanii Google Ads +-- Data: 2026-02-17 +-- Opis: dodaje pole z dokladnym typem kampanii (SEARCH, DISPLAY, PERFORMANCE_MAX, itp.) + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'campaigns' + AND COLUMN_NAME = 'advertising_channel_type' + ), + 'DO 1', + 'ALTER TABLE `campaigns` ADD COLUMN `advertising_channel_type` VARCHAR(40) NULL DEFAULT NULL AFTER `campaign_name`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/005_drop_is_performance_max_column.sql b/migrations/005_drop_is_performance_max_column.sql new file mode 100644 index 0000000..d837ec3 --- /dev/null +++ b/migrations/005_drop_is_performance_max_column.sql @@ -0,0 +1,18 @@ +-- Migracja: usuniecie przestarzalej flagi is_performance_max +-- Data: 2026-02-17 +-- Opis: pozostawiamy tylko advertising_channel_type jako zrodlo prawdy o typie kampanii + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'campaigns' + AND COLUMN_NAME = 'is_performance_max' + ), + 'ALTER TABLE `campaigns` DROP COLUMN `is_performance_max`', + 'DO 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/006_products_scope_dimensions.sql b/migrations/006_products_scope_dimensions.sql new file mode 100644 index 0000000..2ee2f58 --- /dev/null +++ b/migrations/006_products_scope_dimensions.sql @@ -0,0 +1,236 @@ +-- Migracja: rozbicie statystyk produktow per kampania / grupa reklam +-- Data: 2026-02-18 +-- Uwaga: products_data pozostaje globalne per product_id (bez podzialu na scope) + +-- products_history.campaign_id +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history' + AND COLUMN_NAME = 'campaign_id' + ), + 'DO 1', + 'ALTER TABLE `products_history` ADD COLUMN `campaign_id` INT(11) NOT NULL DEFAULT 0 AFTER `product_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- products_history.ad_group_id +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history' + AND COLUMN_NAME = 'ad_group_id' + ), + 'DO 1', + 'ALTER TABLE `products_history` ADD COLUMN `ad_group_id` INT(11) NOT NULL DEFAULT 0 AFTER `campaign_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- products_temp.campaign_id +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_temp' + AND COLUMN_NAME = 'campaign_id' + ), + 'DO 1', + 'ALTER TABLE `products_temp` ADD COLUMN `campaign_id` INT(11) NOT NULL DEFAULT 0 AFTER `product_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- products_temp.ad_group_id +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_temp' + AND COLUMN_NAME = 'ad_group_id' + ), + 'DO 1', + 'ALTER TABLE `products_temp` ADD COLUMN `ad_group_id` INT(11) NOT NULL DEFAULT 0 AFTER `campaign_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- products_history_30.campaign_id +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history_30' + AND COLUMN_NAME = 'campaign_id' + ), + 'DO 1', + 'ALTER TABLE `products_history_30` ADD COLUMN `campaign_id` INT(11) NOT NULL DEFAULT 0 AFTER `product_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- products_history_30.ad_group_id +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history_30' + AND COLUMN_NAME = 'ad_group_id' + ), + 'DO 1', + 'ALTER TABLE `products_history_30` ADD COLUMN `ad_group_id` INT(11) NOT NULL DEFAULT 0 AFTER `campaign_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- products_history: indeksy scope + dzien +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history' + AND INDEX_NAME = 'idx_products_history_campaign_id' + ), + 'DO 1', + 'ALTER TABLE `products_history` ADD INDEX `idx_products_history_campaign_id` (`campaign_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history' + AND INDEX_NAME = 'idx_products_history_ad_group_id' + ), + 'DO 1', + 'ALTER TABLE `products_history` ADD INDEX `idx_products_history_ad_group_id` (`ad_group_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history' + AND INDEX_NAME = 'uk_products_history_scope_day' + ), + 'DO 1', + 'ALTER TABLE `products_history` ADD UNIQUE INDEX `uk_products_history_scope_day` (`product_id`, `campaign_id`, `ad_group_id`, `date_add`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history_30' + AND INDEX_NAME = 'idx_products_history_30_campaign_id' + ), + 'DO 1', + 'ALTER TABLE `products_history_30` ADD INDEX `idx_products_history_30_campaign_id` (`campaign_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history_30' + AND INDEX_NAME = 'idx_products_history_30_ad_group_id' + ), + 'DO 1', + 'ALTER TABLE `products_history_30` ADD INDEX `idx_products_history_30_ad_group_id` (`ad_group_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_history_30' + AND INDEX_NAME = 'uk_products_history_30_scope_day' + ), + 'DO 1', + 'ALTER TABLE `products_history_30` ADD UNIQUE INDEX `uk_products_history_30_scope_day` (`product_id`, `campaign_id`, `ad_group_id`, `date_add`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- products_temp: indeksy scope +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_temp' + AND INDEX_NAME = 'idx_products_temp_campaign_id' + ), + 'DO 1', + 'ALTER TABLE `products_temp` ADD INDEX `idx_products_temp_campaign_id` (`campaign_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_temp' + AND INDEX_NAME = 'idx_products_temp_ad_group_id' + ), + 'DO 1', + 'ALTER TABLE `products_temp` ADD INDEX `idx_products_temp_ad_group_id` (`ad_group_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_temp' + AND INDEX_NAME = 'uk_products_temp_scope' + ), + 'DO 1', + 'ALTER TABLE `products_temp` ADD UNIQUE INDEX `uk_products_temp_scope` (`product_id`, `campaign_id`, `ad_group_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/templates/campaign_terms/main_view.php b/templates/campaign_terms/main_view.php index 2747020..57e6997 100644 --- a/templates/campaign_terms/main_view.php +++ b/templates/campaign_terms/main_view.php @@ -29,12 +29,32 @@
+
+ +
+
+ Grupy reklam +
+
+
+ Frazy wyszukiwane + +
+
+ Frazy wykluczajace +
+
+
+
- Grupy reklam + Grupy reklam +
@@ -62,20 +82,25 @@
Frazy wyszukiwane (klikniecia >= 1)
+
+ + + +
- - - - + + + + @@ -121,6 +146,61 @@ .campaign-terms-page .campaigns-filters .filter-group { min-width: 220px; } +.campaign-terms-page .campaigns-filters .filter-group.terms-columns-group { + min-width: 280px; +} +.terms-columns-box { + display: flex; + flex-direction: column; + gap: 6px; +} +.terms-columns-control { + border: 1px solid #E2E8F0; + border-radius: 6px; + background: #FFFFFF; + overflow: hidden; +} +.terms-columns-control summary { + cursor: pointer; + padding: 8px 10px; + font-size: 12px; + font-weight: 600; + color: #334155; + list-style: none; +} +.terms-columns-control summary::-webkit-details-marker { + display: none; +} +.terms-columns-control summary::after { + content: '\25BC'; + float: right; + font-size: 10px; + color: #64748B; + margin-top: 2px; +} +.terms-columns-control[open] summary::after { + content: '\25B2'; +} +.terms-columns-list { + border-top: 1px solid #EEF2F7; + padding: 8px 10px; + max-height: 180px; + overflow-y: auto; +} +.terms-columns-list .terms-col-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #334155; + margin-bottom: 6px; +} +.terms-columns-list .terms-col-item:last-child { + margin-bottom: 0; +} +.terms-columns-list .terms-col-item input[type=checkbox] { + margin: 0; +} .campaign-terms-page { max-width: 100%; overflow: hidden; @@ -141,6 +221,58 @@ align-items: center; gap: 8px; } +.campaigns-extra-card-title .terms-card-title-label { + display: inline-flex; + align-items: center; + gap: 8px; +} +.campaign-terms-page .terms-card-toggle { + margin-left: auto; + width: 28px; + height: 28px; + border: 1px solid #E2E8F0; + border-radius: 6px; + background: #FFFFFF; + color: #475569; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} +.campaign-terms-page .terms-card-toggle:hover { + background: #F8FAFC; + border-color: #CBD5E1; +} +.campaign-terms-page .terms-adgroups-card.is-collapsed .campaigns-extra-table-wrap { + display: none; +} +.campaign-terms-page .terms-search-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid #EEF2F7; + background: #FFFFFF; +} +.campaign-terms-page .terms-search-toolbar label { + font-size: 12px; + font-weight: 600; + color: #475569; + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + white-space: nowrap; +} +.campaign-terms-page .terms-search-toolbar .terms-search-toolbar-label { + min-width: 86px; +} +.campaign-terms-page .terms-search-toolbar #terms_min_clicks_all, +.campaign-terms-page .terms-search-toolbar #terms_max_clicks_all { + width: 160px; + height: 32px; +} .campaigns-extra-table-wrap { overflow: auto; } @@ -149,20 +281,74 @@ width: 100%; table-layout: fixed; } -.campaigns-extra-table thead th { +.campaign-terms-page table.campaigns-extra-table > thead > tr > th { position: sticky; top: 0; z-index: 2; - background: #F8FAFC; - border-bottom: 1px solid #E2E8F0; + background-color: #111827 !important; + color: #E5E7EB !important; + border-bottom: 1px solid #0B1220 !important; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; - color: #64748B; padding: 10px 12px; white-space: nowrap; } +.campaign-terms-page #terms_search_table thead th .dt-column-order, +.campaign-terms-page #terms_negative_table thead th .dt-column-order { + display: none !important; +} +.campaign-terms-page #terms_search_table thead th.dt-orderable-asc, +.campaign-terms-page #terms_search_table thead th.dt-orderable-desc, +.campaign-terms-page #terms_negative_table thead th.dt-orderable-asc, +.campaign-terms-page #terms_negative_table thead th.dt-orderable-desc { + cursor: pointer; + padding-right: 34px; + overflow: hidden; +} +.campaign-terms-page #terms_search_table thead th .dt-column-title, +.campaign-terms-page #terms_negative_table thead th .dt-column-title { + display: block; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 2px; +} +.campaign-terms-page #terms_search_table thead th.dt-orderable-asc::after, +.campaign-terms-page #terms_search_table thead th.dt-orderable-desc::after, +.campaign-terms-page #terms_negative_table thead th.dt-orderable-asc::after, +.campaign-terms-page #terms_negative_table thead th.dt-orderable-desc::after { + content: '\2195'; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-align: center; + color: #E5E7EB; + background: #374151; +} +.campaign-terms-page #terms_search_table thead th.dt-ordering-asc::after, +.campaign-terms-page #terms_negative_table thead th.dt-ordering-asc::after, +.campaign-terms-page #terms_search_table thead th[aria-sort="ascending"]::after, +.campaign-terms-page #terms_negative_table thead th[aria-sort="ascending"]::after { + content: '\25B2'; + color: #FFFFFF; + background: #2563EB; +} +.campaign-terms-page #terms_search_table thead th.dt-ordering-desc::after, +.campaign-terms-page #terms_negative_table thead th.dt-ordering-desc::after, +.campaign-terms-page #terms_search_table thead th[aria-sort="descending"]::after, +.campaign-terms-page #terms_negative_table thead th[aria-sort="descending"]::after { + content: '\25BC'; + color: #FFFFFF; + background: #2563EB; +} .campaigns-extra-table tbody td { padding: 9px 12px; border-bottom: 1px solid #F1F5F9; @@ -288,6 +474,58 @@ var terms_negative_table = null; var TERMS_STORAGE_CLIENT = 'campaign_terms.last_client_id'; var TERMS_STORAGE_CAMPAIGN = 'campaign_terms.last_campaign_id'; var TERMS_STORAGE_AD_GROUP = 'campaign_terms.last_ad_group_id'; +var TERMS_STORAGE_SEARCH_TABLE_PAGE_PREFIX = 'campaign_terms.search_table_page.'; +var TERMS_STORAGE_NEGATIVE_TABLE_PAGE_PREFIX = 'campaign_terms.negative_table_page.'; +var TERMS_STORAGE_COLUMNS_PREFIX = 'campaign_terms.columns.'; +var TERMS_STORAGE_AD_GROUPS_COLLAPSED = 'campaign_terms.ad_groups_collapsed'; +var TERMS_STORAGE_MIN_CLICKS_ALL = 'campaign_terms.min_clicks_all'; +var TERMS_STORAGE_MAX_CLICKS_ALL = 'campaign_terms.max_clicks_all'; + +var terms_search_filter_registered = false; + +var TERMS_COLUMNS_LABELS = { + ad_groups: [ + 'Grupa reklam', + 'Klik. 30d', + 'Koszt 30d', + 'Wartosc 30d', + 'ROAS 30d', + 'Klik. all', + 'Koszt all', + 'Wartosc all', + 'ROAS all' + ], + search: [ + 'Fraza', + 'Grupa reklam', + 'Klik. all', + 'Koszt all', + 'Wartosc all', + 'ROAS all', + 'Klik. 30d', + 'Koszt 30d', + 'Wartosc 30d', + 'ROAS 30d', + 'Akcja' + ], + negative: [ + 'Poziom', + 'Fraza', + 'Match type' + ] +}; + +var TERMS_COLUMNS_LIST_SELECTORS = { + ad_groups: '#terms_columns_list_ad_groups', + search: '#terms_columns_list_search', + negative: '#terms_columns_list_negative' +}; + +var TERMS_COLUMNS_TABLE_SELECTORS = { + ad_groups: '#terms_ad_groups_table', + search: '#terms_search_table', + negative: '#terms_negative_table' +}; function terms_storage_set( key, value ) { @@ -313,6 +551,279 @@ function terms_storage_get( key ) } } +function terms_pagination_context() +{ + var client_id = $( '#terms_client_id' ).val() || '0'; + var campaign_id = $( '#terms_campaign_id' ).val() || '0'; + var ad_group_id = $( '#terms_ad_group_id' ).val() || '0'; + return [ client_id, campaign_id, ad_group_id ].join( ':' ); +} + +function terms_page_storage_key( table_type ) +{ + var prefix = table_type === 'negative' ? TERMS_STORAGE_NEGATIVE_TABLE_PAGE_PREFIX : TERMS_STORAGE_SEARCH_TABLE_PAGE_PREFIX; + return prefix + terms_pagination_context(); +} + +function terms_columns_storage_key( table_key ) +{ + if ( table_key === 'search' ) + return TERMS_STORAGE_COLUMNS_PREFIX + table_key + '.v2'; + + return TERMS_STORAGE_COLUMNS_PREFIX + table_key; +} + +function terms_get_table_instance( table_key ) +{ + if ( table_key === 'ad_groups' ) return terms_ad_groups_table; + if ( table_key === 'search' ) return terms_search_table; + if ( table_key === 'negative' ) return terms_negative_table; + return null; +} + +function terms_get_saved_columns_visibility( table_key, columns_count ) +{ + var raw = terms_storage_get( terms_columns_storage_key( table_key ) ); + if ( !raw ) + return null; + + try + { + var saved = JSON.parse( raw ); + if ( !Array.isArray( saved ) || saved.length !== columns_count ) + return null; + + return saved.map( function( visible ) { return !!visible; } ); + } + catch ( e ) + { + return null; + } +} + +function terms_save_columns_visibility( table_key, table_instance ) +{ + if ( !table_instance || !table_instance.columns ) + return; + + var columns_count = table_instance.columns().count(); + var visible_map = []; + var i = 0; + + for ( i = 0; i < columns_count; i++ ) + { + visible_map.push( table_instance.column( i ).visible() ); + } + + terms_storage_set( terms_columns_storage_key( table_key ), JSON.stringify( visible_map ) ); +} + +function terms_apply_saved_columns_visibility( table_key, table_instance ) +{ + if ( !table_instance || !table_instance.columns ) + return; + + var columns_count = table_instance.columns().count(); + var saved_visibility = terms_get_saved_columns_visibility( table_key, columns_count ); + var i = 0; + + if ( !saved_visibility ) + return; + + for ( i = 0; i < columns_count; i++ ) + { + table_instance.column( i ).visible( saved_visibility[i], false ); + } + + table_instance.columns.adjust().draw( false ); +} + +function terms_render_columns_picker( table_key, table_instance ) +{ + var selector = TERMS_COLUMNS_LIST_SELECTORS[table_key] || ''; + var labels = TERMS_COLUMNS_LABELS[table_key] || []; + var columns_count = ( table_instance && table_instance.columns ) ? table_instance.columns().count() : 0; + var $list = $( selector ); + var i = 0; + + if ( !$list.length ) + return; + + $list.empty(); + + if ( !columns_count ) + { + $list.append( '
Brak kolumn.
' ); + return; + } + + for ( i = 0; i < columns_count; i++ ) + { + var label = labels[i] || ( 'Kolumna ' + ( i + 1 ) ); + var checked = table_instance.column( i ).visible() ? ' checked' : ''; + var id = 'terms_col_' + table_key + '_' + i; + + $list.append( + '' + ); + } +} + +function terms_get_clicks_filter_value( selector ) +{ + var raw = $( selector ).val(); + + if ( raw === '' || raw === null || raw === undefined ) + return null; + + var n = parseInt( raw, 10 ); + + if ( isNaN( n ) ) + return null; + + if ( n < 0 ) + n = 0; + + return n; +} + +function terms_get_min_clicks_all() +{ + return terms_get_clicks_filter_value( '#terms_min_clicks_all' ); +} + +function terms_get_max_clicks_all() +{ + return terms_get_clicks_filter_value( '#terms_max_clicks_all' ); +} + +function terms_register_search_filters() +{ + var ext_search = null; + + if ( terms_search_filter_registered ) + return; + + if ( $.fn.dataTable && $.fn.dataTable.ext && $.fn.dataTable.ext.search ) + ext_search = $.fn.dataTable.ext.search; + else if ( typeof DataTable !== 'undefined' && DataTable.ext && DataTable.ext.search ) + ext_search = DataTable.ext.search; + + if ( !ext_search ) + return; + + ext_search.push( function( settings, data ) + { + var table_id = settings && settings.nTable ? settings.nTable.id : ''; + var min_clicks = terms_get_min_clicks_all(); + var max_clicks = terms_get_max_clicks_all(); + + if ( table_id !== 'terms_search_table' ) + return true; + + if ( min_clicks === null && max_clicks === null ) + return true; + + var clicks_raw = data && data.length > 2 ? String( data[2] ) : '0'; + var clicks = parseInt( clicks_raw.replace( /[^\d-]/g, '' ), 10 ); + + if ( isNaN( clicks ) ) + clicks = 0; + + if ( min_clicks !== null && clicks < min_clicks ) + return false; + + if ( max_clicks !== null && clicks > max_clicks ) + return false; + + return true; + } ); + + terms_search_filter_registered = true; +} + +function terms_set_ad_groups_collapsed( collapsed ) +{ + var $card = $( '.terms-adgroups-card' ); + var $toggle = $( '#terms_adgroups_toggle' ); + + if ( !$card.length || !$toggle.length ) + return; + + if ( collapsed ) + { + $card.addClass( 'is-collapsed' ); + $toggle.attr( 'title', 'Rozwin tabele grup reklam' ); + $toggle.attr( 'aria-label', 'Rozwin tabele grup reklam' ); + $toggle.attr( 'aria-expanded', 'false' ); + $toggle.find( 'i' ).attr( 'class', 'fa-solid fa-chevron-down' ); + } + else + { + $card.removeClass( 'is-collapsed' ); + $toggle.attr( 'title', 'Zwin tabele grup reklam' ); + $toggle.attr( 'aria-label', 'Zwin tabele grup reklam' ); + $toggle.attr( 'aria-expanded', 'true' ); + $toggle.find( 'i' ).attr( 'class', 'fa-solid fa-chevron-up' ); + } + + terms_storage_set( TERMS_STORAGE_AD_GROUPS_COLLAPSED, collapsed ? '1' : '0' ); +} + +function terms_restore_ad_groups_collapsed() +{ + var saved = terms_storage_get( TERMS_STORAGE_AD_GROUPS_COLLAPSED ); + terms_set_ad_groups_collapsed( saved === '1' ); +} + +function terms_get_saved_page( table_type ) +{ + var page_raw = terms_storage_get( terms_page_storage_key( table_type ) ); + var page = parseInt( page_raw, 10 ); + + if ( isNaN( page ) || page < 0 ) + return 0; + + return page; +} + +function terms_save_current_page( table_type, table_instance ) +{ + if ( !table_instance || typeof table_instance.page !== 'function' ) + return; + + var info = table_instance.page.info(); + if ( !info ) + return; + + terms_storage_set( terms_page_storage_key( table_type ), info.page ); +} + +function terms_restore_saved_page( table_type, table_instance ) +{ + if ( !table_instance || typeof table_instance.page !== 'function' ) + return; + + var info = table_instance.page.info(); + if ( !info || !info.pages ) + return; + + var saved_page = terms_get_saved_page( table_type ); + var page_to_set = saved_page; + + if ( page_to_set >= info.pages ) + page_to_set = info.pages - 1; + + if ( page_to_set < 0 ) + page_to_set = 0; + + if ( page_to_set !== info.page ) + table_instance.page( page_to_set ).draw( 'page' ); +} + function format_num( value, digits ) { var n = Number( value || 0 ); @@ -331,6 +842,21 @@ function destroy_table_if_exists( selector ) } } +function apply_header_titles( selector ) +{ + $( selector + ' thead th' ).each( function() + { + var $th = $( this ); + var title = $.trim( $th.find( '.dt-column-title' ).first().text() || $th.text() ); + + if ( title ) + { + $th.attr( 'title', title ); + $th.find( '.dt-column-title' ).attr( 'title', title ); + } + } ); +} + function build_ad_groups_table( rows ) { @@ -370,6 +896,8 @@ function build_ad_groups_table( rows ) } }); + terms_apply_saved_columns_visibility( 'ad_groups', terms_ad_groups_table ); + terms_render_columns_picker( 'ad_groups', terms_ad_groups_table ); } function build_search_terms_table( rows, negative_keywords ) @@ -390,18 +918,18 @@ function build_search_terms_table( rows, negative_keywords ) lengthChange: false, pageLength: 15, pagingType: 'simple_numbers', - order: [], + order: [[ 2, 'desc' ]], columns: [ { data: 'search_term', defaultContent: '', width: '340px' }, { data: 'ad_group_name', defaultContent: '', width: '360px' }, - { data: 'clicks_30', render: function( data, type ){ return type === 'display' ? format_num( data, 0 ) : Number( data || 0 ); } }, - { data: 'cost_30', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) : Number( data || 0 ); } }, - { data: 'conversion_value_30', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) : Number( data || 0 ); } }, - { data: 'roas_30', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) + '%' : Number( data || 0 ); } }, { data: 'clicks_all_time', render: function( data, type ){ return type === 'display' ? format_num( data, 0 ) : Number( data || 0 ); } }, { data: 'cost_all_time', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) : Number( data || 0 ); } }, { data: 'conversion_value_all_time', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) : Number( data || 0 ); } }, { data: 'roas_all_time', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) + '%' : Number( data || 0 ); } }, + { data: 'clicks_30', render: function( data, type ){ return type === 'display' ? format_num( data, 0 ) : Number( data || 0 ); } }, + { data: 'cost_30', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) : Number( data || 0 ); } }, + { data: 'conversion_value_30', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) : Number( data || 0 ); } }, + { data: 'roas_30', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) + '%' : Number( data || 0 ); } }, { data: 'id', orderable: false, searchable: false, render: function( data, type, row ) { if ( type !== 'display' ) return data; return ''; @@ -425,13 +953,25 @@ function build_search_terms_table( rows, negative_keywords ) language: { emptyTable: 'Brak danych do wyswietlenia', info: 'Wpisy _START_ - _END_ z _TOTAL_', + search: 'Szukaj:', paginate: { next: 'Dalej', previous: 'Wstecz' } } }); + terms_apply_saved_columns_visibility( 'search', terms_search_table ); + + $( '#terms_search_table' ) + .off( 'page.dt.termsPagination' ) + .on( 'page.dt.termsPagination', function() + { + terms_save_current_page( 'search', terms_search_table ); + } ); var q = $( '#phrase_search' ).val(); terms_search_table.search( q ).draw(); + terms_restore_saved_page( 'search', terms_search_table ); + apply_header_titles( '#terms_search_table' ); + terms_render_columns_picker( 'search', terms_search_table ); } function build_negative_terms_table( rows ) @@ -466,13 +1006,25 @@ function build_negative_terms_table( rows ) language: { emptyTable: 'Brak danych do wyswietlenia', info: 'Wpisy _START_ - _END_ z _TOTAL_', + search: 'Szukaj:', paginate: { next: 'Dalej', previous: 'Wstecz' } } }); + terms_apply_saved_columns_visibility( 'negative', terms_negative_table ); + + $( '#terms_negative_table' ) + .off( 'page.dt.termsPagination' ) + .on( 'page.dt.termsPagination', function() + { + terms_save_current_page( 'negative', terms_negative_table ); + } ); var q = $( '#phrase_search' ).val(); terms_negative_table.search( q ).draw(); + terms_restore_saved_page( 'negative', terms_negative_table ); + apply_header_titles( '#terms_negative_table' ); + terms_render_columns_picker( 'negative', terms_negative_table ); } function reset_all_tables() @@ -600,6 +1152,15 @@ function load_campaigns_for_client( restore_campaign_id ) $( function() { + terms_register_search_filters(); + + var saved_min_clicks_all = terms_storage_get( TERMS_STORAGE_MIN_CLICKS_ALL ); + if ( saved_min_clicks_all !== '' ) + $( '#terms_min_clicks_all' ).val( saved_min_clicks_all ); + var saved_max_clicks_all = terms_storage_get( TERMS_STORAGE_MAX_CLICKS_ALL ); + if ( saved_max_clicks_all !== '' ) + $( '#terms_max_clicks_all' ).val( saved_max_clicks_all ); + $( 'body' ).on( 'change', '#terms_client_id', function() { var client_id = $( this ).val(); @@ -627,6 +1188,8 @@ $( function() $( 'body' ).on( 'click', '.terms-add-negative-btn', function() { var search_term_id = $( this ).data( 'search-term-id' ); + var row_data = terms_search_table ? terms_search_table.row( $( this ).closest( 'tr' ) ).data() : null; + var selected_search_term = row_data ? String( row_data.search_term || '' ) : ''; $.confirm({ title: 'Dodaj do wykluczajacych', @@ -647,8 +1210,17 @@ $( function() '' + '' + '' + + '' + + '
' + + '' + + '' + + 'Mozesz zmienic fraze przed zapisem, np. usunac jej fragment.' + '
', type: 'blue', + onContentReady: function() + { + this.$content.find( '#negative_keyword_text' ).val( selected_search_term ); + }, buttons: { confirm: { text: 'Zapisz', @@ -657,7 +1229,20 @@ $( function() { var match_type = this.$content.find( '#negative_match_type' ).val() || 'PHRASE'; var scope = this.$content.find( '#negative_scope' ).val() || 'campaign'; + var keyword_text = $.trim( this.$content.find( '#negative_keyword_text' ).val() || '' ); var modal = this; + + if ( keyword_text === '' ) + { + $.alert({ + title: 'Uwaga', + columnClass: 'col-md-4 col-md-offset-4', + content: 'Wpisz fraze, ktora ma zostac dodana do wykluczajacych.', + type: 'orange' + }); + return false; + } + modal.showLoading( true ); $.ajax({ @@ -666,7 +1251,8 @@ $( function() data: { search_term_id: search_term_id, match_type: match_type, - scope: scope + scope: scope, + keyword_text: keyword_text }, success: function( response ) { @@ -679,16 +1265,18 @@ $( function() if ( data.debug ) { debugHtml = '
' + - '
' + - 'Debug:
' + - 'Customer ID: ' + ( data.debug.customer_id || 'brak' ) + '
' + - 'Campaign ID: ' + ( data.debug.campaign_external_id || 'brak' ) + '
' + - 'Ad Group ID: ' + ( data.debug.ad_group_external_id || 'brak' ) + '
' + - 'Scope: ' + ( data.debug.scope || 'brak' ) + '
' + - 'Keyword: ' + ( data.debug.keyword_text || 'brak' ) + '
' + - 'API response:
' + JSON.stringify( data.debug.api_response, null, 2 ) + '
' + - 'Verification:
' + JSON.stringify( data.debug.verification, null, 2 ) + '
' + - '
'; + '
' + + 'Debug' + + '
' + + 'Customer ID: ' + ( data.debug.customer_id || 'brak' ) + '
' + + 'Campaign ID: ' + ( data.debug.campaign_external_id || 'brak' ) + '
' + + 'Ad Group ID: ' + ( data.debug.ad_group_external_id || 'brak' ) + '
' + + 'Scope: ' + ( data.debug.scope || 'brak' ) + '
' + + 'Keyword: ' + ( data.debug.keyword_text || 'brak' ) + '
' + + 'API response:
' + JSON.stringify( data.debug.api_response, null, 2 ) + '
' + + 'Verification:
' + JSON.stringify( data.debug.verification, null, 2 ) + '
' + + '
' + + '
'; } var successDialog = $.alert({ title: 'Sukces', @@ -707,9 +1295,12 @@ $( function() if ( data.debug ) { debugHtml = '
' + - '
' + - 'Debug:
' + JSON.stringify( data.debug, null, 2 ) + '
' + - '
'; + '
' + + 'Debug' + + '
' + + '
' + JSON.stringify( data.debug, null, 2 ) + '
' + + '
' + + '
'; } $.alert({ title: 'Blad', @@ -748,6 +1339,44 @@ $( function() terms_negative_table.search( q ).draw(); }); + $( 'body' ).on( 'input change', '#terms_min_clicks_all, #terms_max_clicks_all', function() + { + terms_storage_set( TERMS_STORAGE_MIN_CLICKS_ALL, $( '#terms_min_clicks_all' ).val() ); + terms_storage_set( TERMS_STORAGE_MAX_CLICKS_ALL, $( '#terms_max_clicks_all' ).val() ); + if ( terms_search_table ) + terms_search_table.draw(); + }); + + $( 'body' ).on( 'change', '.terms-col-toggle', function() + { + var table_key = $( this ).data( 'table-key' ); + var col_index = parseInt( $( this ).data( 'col-index' ), 10 ); + var is_visible = $( this ).is( ':checked' ); + var table = terms_get_table_instance( table_key ); + var selector = TERMS_COLUMNS_TABLE_SELECTORS[table_key] || ''; + + if ( !table || isNaN( col_index ) ) + return; + + table.column( col_index ).visible( is_visible, false ); + table.columns.adjust().draw( false ); + terms_save_columns_visibility( table_key, table ); + + if ( selector ) + apply_header_titles( selector ); + }); + + $( 'body' ).on( 'click', '#terms_adgroups_toggle', function() + { + var was_collapsed = $( '.terms-adgroups-card' ).hasClass( 'is-collapsed' ); + + terms_set_ad_groups_collapsed( !was_collapsed ); + + if ( was_collapsed && terms_ad_groups_table ) + terms_ad_groups_table.columns.adjust().draw( false ); + }); + + terms_restore_ad_groups_collapsed(); reset_all_tables(); var saved_client_id = terms_storage_get( TERMS_STORAGE_CLIENT ); diff --git a/templates/campaigns/main_view.php b/templates/campaigns/main_view.php index d5867e7..a76e70a 100644 --- a/templates/campaigns/main_view.php +++ b/templates/campaigns/main_view.php @@ -110,7 +110,7 @@ function reloadChart() Highcharts.chart( 'container', { chart: { - style: { fontFamily: '"Open Sans", sans-serif' }, + style: { fontFamily: '"Roboto", sans-serif' }, backgroundColor: 'transparent' }, title: { text: '' }, @@ -205,6 +205,15 @@ $( function() { campaigns_select.val( campaign_to_restore ).trigger( 'change' ); } + else + { + var account_option = campaigns_select.find( 'option' ).filter( function() { + return $.trim( $( this ).text() ).toLowerCase() === '--- konto ---'; + } ).first(); + + if ( account_option.length ) + campaigns_select.val( account_option.val() ).trigger( 'change' ); + } restore_campaign_after_client_load = ''; } @@ -388,3 +397,4 @@ $( function() } }); + diff --git a/templates/products/main_view.php b/templates/products/main_view.php index 48d7ff0..ad09d02 100644 --- a/templates/products/main_view.php +++ b/templates/products/main_view.php @@ -14,6 +14,18 @@ +
+ + +
+
+ + +
@@ -35,6 +47,8 @@
+ + @@ -111,82 +125,188 @@ function loadGoogleCategories( callback ) $( function() { - // Załaduj produkty po wyborze klienta - $( 'body' ).on( 'change', '#client_id', function() + var products_table = new DataTable( '#products', { + ajax: { + type: 'POST', + url: '/products/get_products/', + data: function( d ) { + d.client_id = $( '#client_id' ).val() || ''; + d.campaign_id = $( '#products_campaign_id' ).val() || ''; + d.ad_group_id = $( '#products_ad_group_id' ).val() || ''; + } + }, + processing: true, + serverSide: true, + autoWidth: false, + searching: false, + lengthChange: false, + pageLength: 25, + columns: [ + { width: '30px', orderable: false, className: 'select-checkbox', render: function( data, type, row ) { + return ''; + } + }, + { width: '50px', orderable: false }, + { width: '80px', name: 'offer_id' }, + { width: '200px', name: 'campaign_name' }, + { width: '200px', name: 'ad_group_name' }, + { name: 'name' }, + { width: '50px', name: 'impressions' }, + { width: '80px', name: 'impressions_30' }, + { width: '50px', name: 'clicks' }, + { width: '80px', name: 'clicks_30' }, + { width: '50px', name: 'ctr' }, + { width: '80px', name: 'cost', className: "dt-type-numeric" }, + { width: '50px', name: 'cpc', className: "dt-type-numeric" }, + { width: '50px', name: 'conversions' }, + { width: '90px', name: 'conversions_value', className: "dt-type-numeric" }, + { width: '60px', name: 'roas' }, + { width: '70px', name: 'min_roas' }, + { width: '50px', name: 'cl3', orderable: false }, + { width: '120px', orderable: false }, + { width: '50px', orderable: false, className: 'dt-center' } + ], + order: [ [ 8, 'desc' ] ], + language: { + processing: 'adowanie...', + emptyTable: 'Brak produktw do wywietlenia', + info: 'Produkty _START_ - _END_ z _TOTAL_', + infoEmpty: '', + paginate: { + first: 'Pierwsza', + last: 'Ostatnia', + next: 'Dalej', + previous: 'Wstecz' + } + } + }); + + function reload_products_table() { - var client_id = $( this ).val(); - localStorage.setItem( 'products_client_id', client_id ); + products_table.ajax.reload( null, false ); + } - table = $( '#products' ).DataTable(); - table.destroy(); + function load_client_bestseller_min_roas( client_id ) + { + if ( !client_id ) + { + $( '#bestseller_min_roas' ).val( '' ); + return; + } - // Pobierz min ROAS bestsellera $.ajax({ url: '/products/get_client_bestseller_min_roas/', type: 'POST', data: { client_id: client_id }, success: function( response ) { - data = JSON.parse( response ); + var data = JSON.parse( response ); $( '#bestseller_min_roas' ).val( data.status == 'ok' ? data.min_roas : '' ); } }); + } - new DataTable( '#products', { - ajax: { - type: 'POST', - url: '/products/get_products/client_id=' + client_id, - }, - processing: true, - serverSide: true, - autoWidth: false, - searching: false, - lengthChange: false, - pageLength: 25, - columns: [ - { width: '30px', orderable: false, className: 'select-checkbox', render: function( data, type, row ) { - return ''; - } - }, - { width: '50px', orderable: false }, - { width: '80px', name: 'offer_id' }, - { name: 'name' }, - { width: '50px', name: 'impressions' }, - { width: '80px', name: 'impressions_30' }, - { width: '50px', name: 'clicks' }, - { width: '80px', name: 'clicks_30' }, - { width: '50px', name: 'ctr' }, - { width: '80px', name: 'cost', className: "dt-type-numeric" }, - { width: '50px', name: 'cpc', className: "dt-type-numeric" }, - { width: '50px', name: 'conversions' }, - { width: '90px', name: 'conversions_value', className: "dt-type-numeric" }, - { width: '60px', name: 'roas' }, - { width: '70px', name: 'min_roas' }, - { width: '50px', name: 'cl3', orderable: false }, - { width: '120px', orderable: false }, - { width: '50px', orderable: false, className: 'dt-center' } - ], - order: [ [ 6, 'desc' ] ], - language: { - processing: 'Ładowanie...', - emptyTable: 'Brak produktów do wyświetlenia', - info: 'Produkty _START_ - _END_ z _TOTAL_', - infoEmpty: '', - paginate: { - first: 'Pierwsza', - last: 'Ostatnia', - next: 'Dalej', - previous: 'Wstecz' - } + function load_products_campaigns( client_id, selected_campaign_id ) + { + var $campaign = $( '#products_campaign_id' ); + $campaign.empty().append( '' ); + + if ( !client_id ) + { + return $.Deferred().resolve().promise(); + } + + return $.ajax({ + url: '/products/get_campaigns_list/client_id=' + client_id, + type: 'GET', + dataType: 'json' + }).done( function( res ) { + ( res.campaigns || [] ).forEach( function( row ) { + $campaign.append( '' ); + } ); + + if ( selected_campaign_id && $campaign.find( 'option[value="' + selected_campaign_id + '"]' ).length ) + { + $campaign.val( selected_campaign_id ); } - }); + } ); + } + + function load_products_ad_groups( campaign_id, selected_ad_group_id ) + { + var $ad_group = $( '#products_ad_group_id' ); + $ad_group.empty().append( '' ); + + if ( !campaign_id ) + { + return $.Deferred().resolve().promise(); + } + + return $.ajax({ + url: '/products/get_campaign_ad_groups/campaign_id=' + campaign_id, + type: 'GET', + dataType: 'json' + }).done( function( res ) { + ( res.ad_groups || [] ).forEach( function( row ) { + $ad_group.append( '' ); + } ); + + if ( selected_ad_group_id && $ad_group.find( 'option[value="' + selected_ad_group_id + '"]' ).length ) + { + $ad_group.val( selected_ad_group_id ); + } + } ); + } + + $( 'body' ).on( 'change', '#client_id', function() + { + var client_id = $( this ).val() || ''; + localStorage.setItem( 'products_client_id', client_id ); + localStorage.removeItem( 'products_campaign_id' ); + localStorage.removeItem( 'products_ad_group_id' ); + + load_client_bestseller_min_roas( client_id ); + load_products_campaigns( client_id, '' ).done( function() { + load_products_ad_groups( '', '' ).done( function() { + reload_products_table(); + } ); + } ); }); - // Przywróć ostatnio wybranego klienta - var savedClient = localStorage.getItem( 'products_client_id' ); - if ( savedClient && $( '#client_id option[value="' + savedClient + '"]' ).length ) { - $( '#client_id' ).val( savedClient ).trigger( 'change' ); + $( 'body' ).on( 'change', '#products_campaign_id', function() + { + var campaign_id = $( this ).val() || ''; + localStorage.setItem( 'products_campaign_id', campaign_id ); + localStorage.removeItem( 'products_ad_group_id' ); + + load_products_ad_groups( campaign_id, '' ).done( function() { + reload_products_table(); + } ); + }); + + $( 'body' ).on( 'change', '#products_ad_group_id', function() + { + var ad_group_id = $( this ).val() || ''; + localStorage.setItem( 'products_ad_group_id', ad_group_id ); + reload_products_table(); + }); + + var savedClient = localStorage.getItem( 'products_client_id' ) || ''; + var savedCampaign = localStorage.getItem( 'products_campaign_id' ) || ''; + var savedAdGroup = localStorage.getItem( 'products_ad_group_id' ) || ''; + + if ( savedClient && $( '#client_id option[value="' + savedClient + '"]' ).length ) + { + $( '#client_id' ).val( savedClient ); } + load_client_bestseller_min_roas( $( '#client_id' ).val() || '' ); + load_products_campaigns( $( '#client_id' ).val() || '', savedCampaign ).done( function() { + var selected_campaign_id = $( '#products_campaign_id' ).val() || ''; + load_products_ad_groups( selected_campaign_id, savedAdGroup ).done( function() { + reload_products_table(); + } ); + }); + // Usuwanie produktu $( 'body' ).on( 'click', '.delete-product', function( e ) { @@ -575,3 +695,7 @@ $( function() }); }); + + + + diff --git a/templates/products/product_history.php b/templates/products/product_history.php index 11266a8..7bab55a 100644 --- a/templates/products/product_history.php +++ b/templates/products/product_history.php @@ -57,6 +57,8 @@ $(function() { var client_id = client_id;?>; var product_id = product_id;?>; + var campaign_id = campaign_id ?? 0 ); ?>; + var ad_group_id = ad_group_id ?? 0 ); ?>; // Ustaw domyślnie dzisiejszą datę w formularzu (YYYY-MM-DD) (function presetToday() { @@ -75,7 +77,7 @@ new DataTable('#products', { ajax: { type: 'POST', - url: '/products/get_product_history_table/client_id=' + client_id + '&product_id=' + product_id, + url: '/products/get_product_history_table/client_id=' + client_id + '&product_id=' + product_id + '&campaign_id=' + campaign_id + '&ad_group_id=' + ad_group_id, }, pageLength: 30, processing: true, @@ -100,7 +102,7 @@ $.ajax({ url: '/products/get_product_history_table_chart/', method: 'POST', - data: { client_id: client_id, product_id: product_id }, + data: { client_id: client_id, product_id: product_id, campaign_id: campaign_id, ad_group_id: ad_group_id }, success: function(response) { const parsedData = JSON.parse(response); diff --git a/templates/site/layout-cron.php b/templates/site/layout-cron.php index 324f1a0..dd499d7 100644 --- a/templates/site/layout-cron.php +++ b/templates/site/layout-cron.php @@ -10,7 +10,7 @@ - + @@ -130,3 +130,4 @@ + diff --git a/templates/site/layout-logged.php b/templates/site/layout-logged.php index 5a6c7ac..bcda162 100644 --- a/templates/site/layout-logged.php +++ b/templates/site/layout-logged.php @@ -9,7 +9,7 @@ - + @@ -186,3 +186,4 @@ + diff --git a/templates/site/layout-unlogged.php b/templates/site/layout-unlogged.php index 0faee04..2faf47f 100644 --- a/templates/site/layout-unlogged.php +++ b/templates/site/layout-unlogged.php @@ -8,7 +8,7 @@ - + @@ -42,3 +42,4 @@ +
Fraza Grupa reklamKlik. 30dKoszt 30dWartosc 30dROAS 30d Klik. all Koszt all Wartosc all ROAS allKlik. 30dKoszt 30dWartosc 30dROAS 30d Akcja
Id Id ofertyKampaniaGrupa reklam Nazwa produktu Wyśw. Wyśw. (30d)