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
+
+
+
+