diff --git a/.paul/STATE.md b/.paul/STATE.md
index 810b7c5..1e926d9 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -1,56 +1,47 @@
-# STATE
+# STATE
## Current Position
-Milestone: (ad-hoc) Products — widok "wszystkie kampanie"
-Phase: 3 of 3 (Products All Campaigns View) — Completed
-Plan: 03-01 unified (loop closed)
+Milestone: (ad-hoc) Products - aggregate + breakdown
+Phase: 4 of 4 (Products Aggregate Breakdown) - Completed
+Plan: 04-01 unified (loop closed)
Status: UNIFY complete
-Last activity: 2026-04-24 — Zamknięto pętlę planu 03-01
+Last activity: 2026-04-25T17:28:08+02:00 - Zamknieto petle planu 04-01
Progress:
- Milestone: [██████████] 100%
-- Phase 3: [██████████] 100%
+- Phase 4: [██████████] 100%
## Loop Position
```
-PLAN ──▶ APPLY ──▶ UNIFY
- ✓ ✓ ✓ [Pętla zamknięta]
+PLAN --> APPLY --> UNIFY
+ ✓ ✓ ✓ [Petla zamknieta]
```
## Session Continuity
-Last session: 2026-04-24
-Stopped at: Loop closed, pętla gotowa do nowego /paul:plan
-Next action: Wdróż `templates/products/main_view.php` na produkcję (FTP sync); w razie kolejnego zadania — `/paul:plan` z opisem
-Resume file: .paul/phases/03-products-all-campaigns-view/03-01-SUMMARY.md
+Last session: 2026-04-25
+Stopped at: Loop closed, gotowe do nowego /paul:plan
+Next action: Jesli chcesz kontynuowac - uruchom /paul:plan z kolejnym zadaniem
+Resume file: .paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md
-## Historia zrealizowanych planów
+## Historia zrealizowanych planow
-- `01-01-PLAN.md` — CL3 → CL1 w tabeli /products (completed 2026-04-22)
- - 4 pliki zmodyfikowane: migration 028, factory, controller, template
- - 4 AC spełnione
- - 2 odchylenia udokumentowane (szerokość kolumny 50→120px, weryfikacja sync GAds)
-- `02-01-PLAN.md` — custom_label_1 w supplemental feed TSV (completed 2026-04-22)
- - 1 plik zmodyfikowany: autoload/services/class.SupplementalFeed.php (+6/-4)
- - 4 AC spełnione
- - 0 odchyleń — plan 1:1
-- `03-01-PLAN.md` — Powrót do widoku "wszystkie kampanie" w /products (completed 2026-04-24)
- - 1 plik zmodyfikowany: templates/products/main_view.php (+1/-3)
- - Zmiana: usunięto `placeholder: '- wybierz -'` i `allowClear: true` z Select2 dla `#products_campaign_id` / `#products_ad_group_id`
- - 4 AC spełnione
- - 1 kosmetyczna różnica w verify (allowClear na linii 2097 to niezwiązany Google taxonomy picker, poza zakresem)
+- `01-01-PLAN.md` - CL3 -> CL1 w tabeli /products (completed 2026-04-22)
+- `02-01-PLAN.md` - custom_label_1 w supplemental feed TSV (completed 2026-04-22)
+- `03-01-PLAN.md` - Powrot do widoku "wszystkie kampanie" w /products (completed 2026-04-24)
+- `04-01-PLAN.md` - Agregat produktu + rozwijane podwiersze kampania/grupa w /products (completed 2026-04-25)
## Decisions
| Date | Decision | Phase | Impact |
|------|----------|-------|--------|
-| 2026-04-24 | Usunięcie placeholdera zamiast wprowadzania wartości sentinel `0`/`all` — minimalny blast radius | 3 | 1-linijkowa zmiana w JS, brak zmian w kontrolerze/factory |
+| 2026-04-24 | Usuniecie placeholdera zamiast sentinel `0/all` | 3 | Minimalny blast radius, bez zmian w kontrolerze/factory |
+| 2026-04-25 | Bez wybranej grupy: glowny agregat per produkt + rozwijane podwiersze per kampania/grupa | 4 | Czytelniejsza analiza produktu i szybki drill-down |
+| 2026-04-25 | Podwiersze tylko readonly, edycja tylko w parent row | 4 | Spojnosc UX i brak konfliktu akcji edycyjnych |
## Notes
-- PAUL framework działa w trybie ad-hoc (bez pełnej roadmapy/PROJECT.md).
-- Backend `factory\Products::build_scope_filters` już dziś traktuje `campaign_id ≤ 0` jako "bez filtra" — fix był wyłącznie po stronie JS.
-- Ustalony wzorzec: **Select2 + `` ⇒ NIE konfigurować `placeholder` ani `allowClear`**, żeby opcja pozostała widoczna w dropdownie po selekcji.
-- Deploy: wymaga FTP sync szablonu + hard reload (Ctrl+Shift+R) w przeglądarce klientów.
+- PAUL framework dziala tutaj w trybie ad-hoc (bez ROADMAP.md i PROJECT.md).
+- Human-verify checkpoint dla planu 04-01 zatwierdzony (`approved`).
diff --git a/.paul/changelog/2026-04-25.md b/.paul/changelog/2026-04-25.md
new file mode 100644
index 0000000..7b6840f
--- /dev/null
+++ b/.paul/changelog/2026-04-25.md
@@ -0,0 +1,13 @@
+# 2026-04-25
+
+## Co zrobiono
+
+- [Phase 4, Plan 01] Wdrozono agregacje listy produktow: 1 produkt = 1 wiersz glowny przy braku wybranej grupy reklam.
+- Dodano rozwijane podwiersze z pelnym rozbiciem na kampanie i grupy reklam.
+- Pozostawiono edycje (min_roas, CL1, CL4, akcje) tylko w wierszu glownym.
+
+## Zmienione pliki
+
+- `autoload/factory/class.Products.php`
+- `autoload/controls/class.Products.php`
+- `templates/products/main_view.php`
\ No newline at end of file
diff --git a/.paul/phases/04-products-aggregate-breakdown/04-01-PLAN.md b/.paul/phases/04-products-aggregate-breakdown/04-01-PLAN.md
new file mode 100644
index 0000000..63c6b26
--- /dev/null
+++ b/.paul/phases/04-products-aggregate-breakdown/04-01-PLAN.md
@@ -0,0 +1,206 @@
+---
+phase: 04-products-aggregate-breakdown
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - autoload/factory/class.Products.php
+ - autoload/controls/class.Products.php
+ - templates/products/main_view.php
+autonomous: false
+delegation: off
+---
+
+
+## Goal
+Na stronie `/products` zmienic tryb listy tak, aby przy braku wybranej grupy reklam (`ad_group_id` puste/0) tabela pokazywala 1 wiersz glowny na produkt z metrykami zagregowanymi, oraz rozwijane podwiersze z pelnym rozbiciem na kampanie i grupy reklam.
+
+## Purpose
+Obecny widok miesza poziomy agregacji i utrudnia szybka analize produktu jako calosci. Wiersz glowny ma pokazywac wynik laczny produktu, a szczegoly (kampania/grupa) maja byc dostepne na zadanie przez rozwiniecie.
+
+## Output
+- Backend zwracajacy dane zagregowane per produkt oraz szczegoly per scope (kampania/grupa) dla aktualnej strony.
+- Frontend DataTable z przyciskiem rozwin/zwin i podwierszem z pelnymi metrykami.
+- Brak mozliwosci edycji w podwierszach (edycja tylko w wierszu glownym).
+
+
+
+## Project Context
+@.paul/STATE.md
+@.paul/phases/03-products-all-campaigns-view/03-01-SUMMARY.md
+
+## Source Files
+@autoload/factory/class.Products.php
+@autoload/controls/class.Products.php
+@templates/products/main_view.php
+
+## Constraints from user
+- Podwiersze maja byc zwijane/rozwijane przyciskiem.
+- Podwiersze maja pokazywac pelny zestaw metryk.
+- Edycja (`min_roas`, `CL1`, `CL4`, akcje) tylko w wierszu glownym produktu.
+
+## Notes
+Repo dziala w trybie ad-hoc PAUL (bez ROADMAP.md / PROJECT.md). Plan tworzony jako kolejna faza ad-hoc.
+
+
+
+
+## AC-1: Agregacja produktu przy braku wybranej grupy reklam
+```gherkin
+Given uzytkownik jest na `/products` i ma wybranego klienta
+ And filtr grupy reklam jest pusty (`#products_ad_group_id` = "")
+When tabela laduje dane
+Then kazdy produkt wystepuje tylko raz jako wiersz glowny
+ And metryki wiersza glownego (wyswietlenia, klikniecia, koszt, konwersje, wartosc konwersji, ROAS itd.) sa suma ze wszystkich pasujacych scope
+```
+
+## AC-2: Rozwijane podwiersze z pelnym rozbiciem
+```gherkin
+Given tabela produktow jest zaladowana w trybie z AC-1
+When uzytkownik kliknie przycisk rozwin przy produkcie
+Then pod wierszem glownym pojawia sie podwiersz(e) z rozbiciem per kampania+grupa reklam
+ And kazdy podwiersz pokazuje pelen zestaw kolumn metryk jak w widoku glownym (w formie tylko-do-odczytu)
+ And ponowne klikniecie przycisku zwija szczegoly
+```
+
+## AC-3: Edycja tylko w wierszu glownym
+```gherkin
+Given produkt ma rozwiniete podwiersze
+When uzytkownik przeglada podwiersze
+Then nie ma tam inputow ani przyciskow edycji/usuwania
+ And inputy `min_roas`, `custom_label_1`, `custom_label_4` oraz akcje pozostaja dostepne tylko w wierszu glownym produktu
+```
+
+## AC-4: Brak regresji dla filtrowania i wydajnosci listy
+```gherkin
+Given nowy tryb agregacji i podwierszy
+When uzytkownik zmienia filtry (kampania, grupa, search, CL1, CL4), sortowanie i paginacje
+Then tabela zachowuje poprawna liczbe rekordow i sortowanie
+ And przy wybranej konkretnej grupie reklam zachowanie pozostaje logicznie spojne (bez duplikacji i bez bledow JS/PHP)
+ And endpoint DataTables nie zwraca bledow SQL ani warningow
+```
+
+
+
+
+
+
+ Task 1: Przebudowa warstwy danych na agregat produktu + szczegoly scope
+ autoload/factory/class.Products.php
+
+ Rozszerzyc factory tak, aby `get_products()` zwracalo dane glownych wierszy agregowane per `p.id` przy `ad_group_id <= 0`, oraz by mozna bylo pobrac szczegoly per kampania/grupa dla zestawu `product_id` z biezacej strony.
+
+ Zakres:
+ - Utrzymac medoo/prepared statements.
+ - Dostosowac `GROUP BY` i logike licznikow tak, aby `recordsTotal` liczyl produkty, a nie scope, gdy nie ma wybranej grupy.
+ - Dodac/metody pomocnicze do pobrania breakdownu dla listy produktow bez N+1 (jedno zapytanie z `IN (...)` + grupowanie po stronie PHP).
+ - Utrzymac zgodnosc sortowania kolumn DataTables dla wiersza glownego.
+
+ Unikac:
+ - SQL sklejanych stringiem bez parametrow.
+ - Dublowania tej samej logiki obliczen metryk w wielu metodach bez wspolnego helpera.
+
+
+ - `php -l autoload/factory/class.Products.php`
+ - Szybki test endpointu `/products/get_products/` dla: (a) ad_group_id="", (b) ad_group_id>0 i porownanie liczby rekordow.
+ - Brak warningow SQL / PHP w logu.
+
+ AC-1 i czesc AC-4 spelnione: poprawna agregacja i poprawne liczenie rekordow.
+
+
+
+ Task 2: Adaptacja kontrolera odpowiedzi DataTables pod dane glowne + breakdown
+ autoload/controls/class.Products.php
+
+ W `get_products()` przebudowac format odpowiedzi JSON tak, aby kazdy wiersz glowny produktu zawieral identyfikator i dane potrzebne do renderu rozwijanych podwierszy (np. `row_meta` / `breakdown_rows`).
+
+ Zakres:
+ - Zachowac obecne formatowanie komorek glownych (ROAS bar, warningi, linki, akcje).
+ - Dla podwierszy zwracac pelne metryki, ale bez kontrolnych elementow edycji.
+ - Zachowac kompatybilnosc z istniejacymi handlerami JS, ktore operuja na kolumnach glownych.
+
+ Unikac:
+ - Wykonywania dodatkowego zapytania DB na kazdy pojedynczy wiersz.
+ - Mieszania HTML edycyjnego do danych podwierszy.
+
+
+ - `php -l autoload/controls/class.Products.php`
+ - Odpowiedz endpointu zawiera dane breakdown tylko dla produktow z biezacej strony i nie psuje `recordsTotal`, `recordsFiltered`, `data`.
+
+ AC-2 i AC-3 backend-ready: API dostarcza dane do podwierszy i oddziela je od edycji.
+
+
+
+ Task 3: UI DataTable - przycisk rozwin i render podwierszy
+ templates/products/main_view.php
+
+ Dodac w tabeli produktow obsluge rozwijanych podwierszy:
+ - Przycisk w wierszu glownym (rozwin/zwin) widoczny tylko gdy sa szczegoly.
+ - Render child-row (DataTables child row) z pelnym zestawem metryk dla kampania+grupa.
+ - Podwiersze readonly: bez inputow i bez akcji edycyjnych/usuwania.
+ - Zachowac obecne akcje edycyjne tylko dla glownego wiersza.
+
+ Dodatkowo:
+ - Dodac czytelne style dla child-row (wizualnie odroznione od glownej tabeli).
+ - Nie przekraczac 3 poziomow zagniezdzenia - wydzielic helpery JS do budowy HTML podwierszy i toggla.
+
+
+ - `php -l templates/products/main_view.php`
+ - Manual: rozwin/zwin dziala, brak bledow w konsoli, sortowanie/paginacja dalej dziala.
+
+ AC-2, AC-3 i pozostala czesc AC-4 spelnione po stronie UI.
+
+
+
+
+ Nowy widok listy produktow: glowny agregat per produkt + rozwijane podwiersze kampania/grupa z pelnymi metrykami, bez edycji w podwierszach.
+
+
+ 1. Otworz `/products`, wybierz klienta.
+ 2. Ustaw kampanie dowolnie, ale pozostaw "Grupa reklam" na "- wszystkie grupy -".
+ 3. Potwierdz: kazdy produkt jest raz jako wiersz glowny, metryki sa sumaryczne.
+ 4. Kliknij przycisk rozwin przy kilku produktach.
+ 5. Potwierdz: podwiersze pokazuja pelne metryki per kampania/grupa, bez inputow i bez akcji.
+ 6. Zweryfikuj, ze edycja (`min_roas`, `CL1`, `CL4`) dziala tylko w wierszu glownym.
+ 7. Sprawdz sortowanie, paginacje i filtry tekstowe.
+ 8. Sprawdz konsolę przegladarki i logi PHP: brak nowych bledow.
+
+ Wpisz "approved" aby przejsc do UNIFY, albo opisz odchylenia do poprawy.
+
+
+
+
+
+
+## DO NOT CHANGE
+- Migracje bazy i schemat tabel.
+- Logika niezalezna od /products (inne kontrolery/widoki).
+- Mechanika CSRF/sesji i routing aplikacji.
+
+## SCOPE LIMITS
+- Tylko zakladka `/products`.
+- Tylko zachowanie listy i sposob prezentacji danych.
+- Bez nowych funkcji biznesowych poza agregacja + podwiersze.
+
+
+
+
+Before declaring plan complete:
+- [ ] `php -l autoload/factory/class.Products.php`
+- [ ] `php -l autoload/controls/class.Products.php`
+- [ ] `php -l templates/products/main_view.php`
+- [ ] Manualna weryfikacja AC-1..AC-4
+- [ ] Brak nowych bledow JS/PHP/SQL
+
+
+
+- Widok przy pustym `ad_group_id` pokazuje agregat 1 produkt = 1 wiersz glowny.
+- Podwiersze sa rozwijane przyciskiem i pokazuja pelne metryki per kampania/grupa.
+- Edycja pozostaje tylko w wierszu glownym.
+- Brak regresji filtrow, sortowania i paginacji.
+
+
+
diff --git a/.paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md b/.paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md
new file mode 100644
index 0000000..1550b55
--- /dev/null
+++ b/.paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md
@@ -0,0 +1,138 @@
+---
+phase: 04-products-aggregate-breakdown
+plan: 01
+subsystem: ui
+tags: [products, datatables, aggregation, breakdown, php, jquery]
+
+requires:
+ - phase: 03-products-all-campaigns-view
+ provides: stable products scope filters and all-campaign selector behavior
+
+provides:
+ - Aggregated product rows (1 product = 1 row when no ad group is selected)
+ - Expandable campaign/ad-group breakdown rows with full metrics
+ - Edit actions kept only on the main product row
+
+affects:
+ - products table data contract from /products/get_products/
+ - products DataTable row rendering and child-row behavior
+
+tech-stack:
+ added: []
+ patterns:
+ - aggregate main row + readonly scope breakdown in child row
+
+key-files:
+ created: []
+ modified:
+ - autoload/factory/class.Products.php
+ - autoload/controls/class.Products.php
+ - templates/products/main_view.php
+
+key-decisions:
+ - "Use one batch breakdown query for page product IDs to avoid N+1"
+ - "Expose breakdown as row_meta in DataTables payload"
+ - "Keep inline edit fields only in parent row"
+
+patterns-established:
+ - "When scope is not narrowed to ad_group_id, aggregate per product and show scope details on demand"
+
+duration: ~23min
+started: 2026-04-25T17:05:05+02:00
+completed: 2026-04-25T17:28:08+02:00
+---
+
+# Phase 4 Plan 01: Products Aggregate Breakdown (Summary)
+
+**Na /products wdrozono model: glowny agregat per produkt + rozwijane readonly podwiersze kampania/grupa z pelnymi metrykami.**
+
+## Performance
+
+| Metric | Value |
+|--------|-------|
+| Duration | ~23 min |
+| Started | 2026-04-25T17:05:05+02:00 |
+| Completed | 2026-04-25T17:28:08+02:00 |
+| Tasks | 4/4 (3 auto + 1 human-verify) |
+| Files modified | 3 |
+
+## Acceptance Criteria Results
+
+| Criterion | Status | Notes |
+|-----------|--------|-------|
+| AC-1: Agregacja produktu bez wybranej grupy | Pass | Backend grupuje po `p.id`, recordsTotal liczy DISTINCT produktow |
+| AC-2: Rozwijane podwiersze z pelnym rozbiciem | Pass | Child-row DataTables renderuje pelny zestaw metryk per kampania+grupa |
+| AC-3: Edycja tylko w wierszu glownym | Pass | Podwiersze sa readonly; inputy i akcje zostaja w parent row |
+| AC-4: Brak regresji filtrowania/listy | Pass | User checkpoint `approved`; endpoint i lintery bez bledow |
+
+## Accomplishments
+
+- Przebudowano zapytania listy produktow tak, aby glowny wynik byl agregowany per produkt.
+- Dodano zbiorcze pobieranie breakdownu scope dla aktualnej strony (bez N+1).
+- Rozszerzono payload DataTables o `row_meta.breakdown_rows` i dodano UI rozwin/zwin z tabela szczegolow.
+
+## Task Commits
+
+Brak commitu fazowego na tym etapie (working tree zawiera rowniez inne lokalne zmiany projektu).
+
+## Files Created/Modified
+
+| File | Change | Purpose |
+|------|--------|---------|
+| `autoload/factory/class.Products.php` | Modified (+148/-101) | Agregacja per produkt, wspolny filtr, breakdown query, records count |
+| `autoload/controls/class.Products.php` | Modified (+53/-19) | Dolaczenie breakdownu do odpowiedzi i row_meta dla DataTables |
+| `templates/products/main_view.php` | Modified (+162/-1) | UI toggle, child-row render, style breakdownu |
+
+## Decisions Made
+
+| Decision | Rationale | Impact |
+|----------|-----------|--------|
+| Batch breakdown query for page products | Ogranicza obciazenie DB i unika N+1 | Stabilna wydajnosc przy paginacji |
+| Keep edit controls only in parent row | Zgodnosc z wymaganiem usera | Jasny podzial: edycja vs analiza |
+| Render breakdown in DataTables child row | Minimalny blast radius dla istniejacego ukladu kolumn | Niska regresyjnosc UI |
+
+## Deviations from Plan
+
+### Summary
+
+| Type | Count | Impact |
+|------|-------|--------|
+| Auto-fixed | 1 | Niski, bez zmiany zakresu |
+| Scope additions | 0 | Brak |
+| Deferred | 0 | Brak |
+
+**Total impact:** Plan zrealizowany zgodnie z zakresem; jedna techniczna korekta podczas implementacji.
+
+### Auto-fixed Issues
+
+1. Tymczasowe uszkodzenie pliku kontrolera podczas edycji skryptowej
+- Found during: Task 2
+- Issue: `autoload/controls/class.Products.php` zostal chwilowo uszkodzony (parse error)
+- Fix: przywrocenie pliku z HEAD i ponowne, punktowe patche
+- Verification: `php -l autoload/controls/class.Products.php` OK
+
+### Deferred Items
+
+Brak.
+
+## Issues Encountered
+
+| Issue | Resolution |
+|-------|------------|
+| Konflikty przy automatycznej podmianie wiekszego bloku kontrolera | Zmiana strategii na male, precyzyjne patche i ponowna walidacja |
+
+## Next Phase Readiness
+
+**Ready:**
+- Kontrakt endpointu `/products/get_products/` obsluguje agregat + breakdown.
+- UI listy wspiera drill-down bez naruszania obecnych akcji.
+
+**Concerns:**
+- W working tree sa tez niezalezne lokalne zmiany (`.vscode/ftp-kr.sync.cache.json`).
+
+**Blockers:**
+- Brak.
+
+---
+*Phase: 04-products-aggregate-breakdown, Plan: 01*
+*Completed: 2026-04-25*
\ No newline at end of file
diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json
index 4f6fdea..8cbcbe5 100644
--- a/.vscode/ftp-kr.sync.cache.json
+++ b/.vscode/ftp-kr.sync.cache.json
@@ -751,6 +751,12 @@
"size": 796,
"lmtime": 1776845275979,
"modified": false
+ },
+ "2026-04-24.md": {
+ "type": "-",
+ "size": 742,
+ "lmtime": 1777064721127,
+ "modified": false
}
},
"docs": {
@@ -803,6 +809,12 @@
"size": 9033,
"lmtime": 1776845292488,
"modified": false
+ },
+ "governance_2026-04-24.jsonl": {
+ "type": "-",
+ "size": 1614,
+ "lmtime": 1777064743676,
+ "modified": false
}
},
"phases": {
@@ -833,12 +845,26 @@
"lmtime": 1776845256633,
"modified": false
}
+ },
+ "03-products-all-campaigns-view": {
+ "03-01-PLAN.md": {
+ "type": "-",
+ "size": 12821,
+ "lmtime": 1777064481097,
+ "modified": false
+ },
+ "03-01-SUMMARY.md": {
+ "type": "-",
+ "size": 6938,
+ "lmtime": 1777064708436,
+ "modified": false
+ }
}
},
"STATE.md": {
"type": "-",
- "size": 1518,
- "lmtime": 1776845291913,
+ "size": 2531,
+ "lmtime": 1777064743120,
"modified": false
}
},
@@ -897,8 +923,8 @@
"products": {
"main_view.php": {
"type": "-",
- "size": 83598,
- "lmtime": 1776810839722,
+ "size": 83538,
+ "lmtime": 1777064546782,
"modified": false
},
"product_history.php": {
diff --git a/autoload/controls/class.Products.php b/autoload/controls/class.Products.php
index e0fe2c2..9320362 100644
--- a/autoload/controls/class.Products.php
+++ b/autoload/controls/class.Products.php
@@ -965,6 +965,10 @@ class Products
$db_results = \factory\Products::get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id, $ad_group_id, $filter_cl4, $filter_cl1 );
$recordsTotal = \factory\Products::get_records_total_products( $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4, $filter_cl1 );
+ $product_ids = array_values( array_unique( array_filter( array_map( function( $row ) {
+ return (int) ( $row['product_id'] ?? 0 );
+ }, (array) $db_results ) ) ) );
+ $breakdown_map = \factory\Products::get_products_scope_breakdown( $client_id, $product_ids, $campaign_id, $ad_group_id, $search, $filter_cl4, $filter_cl1 );
// Sredni CR konta — do obliczenia progu klikniec
$account_cr = \factory\Products::get_account_conversion_rate( (int) $client_id );
@@ -977,11 +981,13 @@ class Products
foreach ( $db_results as $row )
{
+ $product_id = (int) ( $row['product_id'] ?? 0 );
+ $breakdown_rows = (array) ( $breakdown_map[ $product_id ] ?? [] );
$custom_class = '';
- $custom_label_4 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_4' );
- $custom_label_1 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_1' );
- $custom_name = \factory\Products::get_product_data( $row['product_id'], 'title' );
- $product_url = trim( (string) \factory\Products::get_product_data( $row['product_id'], 'product_url' ) );
+ $custom_label_4 = \factory\Products::get_product_data( $product_id, 'custom_label_4' );
+ $custom_label_1 = \factory\Products::get_product_data( $product_id, 'custom_label_1' );
+ $custom_name = \factory\Products::get_product_data( $product_id, 'title' );
+ $product_url = trim( (string) \factory\Products::get_product_data( $product_id, 'product_url' ) );
if ( $custom_name )
{
@@ -1074,29 +1080,56 @@ class Products
$history_campaign_id = (int) ( $row['history_campaign_id'] ?? 0 );
$history_ad_group_id = (int) ( $row['history_ad_group_id'] ?? 0 );
+ $breakdown_for_view = [];
- if ( $history_campaign_id <= 0 )
+ if ( $history_campaign_id <= 0 && !empty( $breakdown_rows ) )
{
- $history_campaign_id = (int) ( $row['campaign_id'] ?? 0 );
+ $history_campaign_id = (int) ( $breakdown_rows[0]['campaign_id'] ?? 0 );
}
- if ( $history_ad_group_id <= 0 )
+ if ( $history_ad_group_id <= 0 && !empty( $breakdown_rows ) )
{
- $history_ad_group_id = (int) ( $row['ad_group_id'] ?? 0 );
+ $history_ad_group_id = (int) ( $breakdown_rows[0]['ad_group_id'] ?? 0 );
}
+ foreach ( $breakdown_rows as $breakdown_row )
+ {
+ $breakdown_for_view[] = [
+ 'campaign_name' => (string) ( $breakdown_row['campaign_name'] ?? '' ),
+ 'ad_group_name' => (string) ( $breakdown_row['ad_group_name'] ?? '' ),
+ 'impressions' => (int) ( $breakdown_row['impressions'] ?? 0 ),
+ 'impressions_30' => (int) ( $breakdown_row['impressions_30'] ?? 0 ),
+ 'clicks' => (int) ( $breakdown_row['clicks'] ?? 0 ),
+ 'clicks_30' => (int) ( $breakdown_row['clicks_30'] ?? 0 ),
+ 'ctr' => (float) ( $breakdown_row['ctr'] ?? 0 ),
+ 'cost' => (float) ( $breakdown_row['cost'] ?? 0 ),
+ 'cpc' => (float) ( $breakdown_row['cpc'] ?? 0 ),
+ 'conversions' => (float) ( $breakdown_row['conversions'] ?? 0 ),
+ 'conversions_value' => (float) ( $breakdown_row['conversions_value'] ?? 0 ),
+ 'roas' => (float) ( $breakdown_row['roas'] ?? 0 ),
+ 'min_roas' => (float) ( $row['min_roas'] ?? 0 ),
+ 'custom_label_1' => (string) $custom_label_1,
+ 'custom_label_4' => (string) $custom_label_4
+ ];
+ }
+
+ $row_meta = [
+ 'can_expand' => count( $breakdown_for_view ) > 0,
+ 'breakdown_rows' => $breakdown_for_view
+ ];
+
$data['data'][] = [
'', // checkbox column
- $row['product_id'],
+ $product_id,
$row['offer_id'],
htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ),
htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ),
$product_url_html,
- '
',
+ $row_meta
];
}
diff --git a/autoload/factory/class.Products.php b/autoload/factory/class.Products.php
index 8b76393..e646467 100644
--- a/autoload/factory/class.Products.php
+++ b/autoload/factory/class.Products.php
@@ -487,6 +487,39 @@ class Products
$sql .= ' AND ag.status = \'active\'';
}
+ static private function build_products_filters( &$sql, &$params, $search, $custom_label_4, $custom_label_1 )
+ {
+ $search = trim( (string) $search );
+ $custom_label_4 = trim( (string) $custom_label_4 );
+ $custom_label_1 = trim( (string) $custom_label_1 );
+
+ if ( $search !== '' )
+ {
+ $sql .= ' AND (
+ p.name LIKE :search
+ OR p.title LIKE :search
+ OR p.offer_id LIKE :search
+ OR p.custom_label_4 LIKE :search
+ OR p.custom_label_1 LIKE :search
+ OR c.campaign_name LIKE :search
+ OR ag.ad_group_name LIKE :search
+ )';
+ $params[':search'] = '%' . $search . '%';
+ }
+
+ if ( $custom_label_4 !== '' )
+ {
+ $sql .= ' AND p.custom_label_4 LIKE :custom_label_4';
+ $params[':custom_label_4'] = '%' . $custom_label_4 . '%';
+ }
+
+ if ( $custom_label_1 !== '' )
+ {
+ $sql .= ' AND p.custom_label_1 LIKE :custom_label_1';
+ $params[':custom_label_1'] = '%' . $custom_label_1 . '%';
+ }
+ }
+
static public function get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id = 0, $ad_group_id = 0, $custom_label_4 = '', $custom_label_1 = '' )
{
global $mdb;
@@ -522,15 +555,17 @@ class Products
p.offer_id,
p.min_roas,
COALESCE( NULLIF( TRIM( p.custom_label_1 ), \'\' ), \'\' ) AS custom_label_1,
- pa.campaign_id AS campaign_id,
- COALESCE( NULLIF( TRIM( c.campaign_name ), \'\' ), \'--- brak kampanii ---\' ) AS campaign_name,
CASE
- WHEN pa.ad_group_id = 0 THEN \'PMax (bez grup reklam)\'
- ELSE COALESCE( NULLIF( TRIM( ag.ad_group_name ), \'\' ), \'--- brak grupy reklam ---\' )
+ WHEN COUNT( DISTINCT pa.campaign_id ) > 1 THEN CONCAT( \'Wiele kampanii (\', COUNT( DISTINCT pa.campaign_id ), \')\' )
+ ELSE COALESCE( NULLIF( TRIM( MAX( c.campaign_name ) ), \'\' ), \'--- brak kampanii ---\' )
+ END AS campaign_name,
+ CASE
+ WHEN COUNT( DISTINCT CONCAT( pa.campaign_id, \':\', pa.ad_group_id ) ) > 1 THEN CONCAT( \'Wiele grup (\', COUNT( DISTINCT CONCAT( pa.campaign_id, \':\', pa.ad_group_id ) ), \')\' )
+ WHEN MIN( pa.ad_group_id ) = 0 THEN \'PMax (bez grup reklam)\'
+ ELSE COALESCE( NULLIF( TRIM( MAX( ag.ad_group_name ) ), \'\' ), \'--- brak grupy reklam ---\' )
END AS ad_group_name,
- pa.ad_group_id AS ad_group_id,
- pa.campaign_id AS history_campaign_id,
- pa.ad_group_id AS history_ad_group_id,
+ MIN( pa.campaign_id ) AS history_campaign_id,
+ MIN( pa.ad_group_id ) AS history_ad_group_id,
COALESCE( NULLIF( TRIM( p.title ), \'\' ), NULLIF( TRIM( p.name ), \'\' ), p.offer_id ) AS name,
SUM( pa.impressions_all_time ) AS impressions,
SUM( pa.impressions_30 ) AS impressions_30,
@@ -558,34 +593,9 @@ class Products
WHERE p.client_id = :client_id';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
+ self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 );
- if ( $search )
- {
- $sql .= ' AND (
- p.name LIKE :search
- OR p.title LIKE :search
- OR p.offer_id LIKE :search
- OR p.custom_label_4 LIKE :search
- OR p.custom_label_1 LIKE :search
- OR c.campaign_name LIKE :search
- OR ag.ad_group_name LIKE :search
- )';
- $params[':search'] = '%' . $search . '%';
- }
-
- if ( $custom_label_4 !== '' )
- {
- $sql .= ' AND p.custom_label_4 LIKE :custom_label_4';
- $params[':custom_label_4'] = '%' . $custom_label_4 . '%';
- }
-
- if ( $custom_label_1 !== '' )
- {
- $sql .= ' AND p.custom_label_1 LIKE :custom_label_1';
- $params[':custom_label_1'] = '%' . $custom_label_1 . '%';
- }
-
- $sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_1, p.name, p.title, pa.campaign_id, c.campaign_name, pa.ad_group_id, ag.ad_group_name';
+ $sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_1, p.name, p.title';
$sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit;
return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
@@ -597,47 +607,30 @@ class Products
$params = [ ':client_id' => $client_id ];
- $sql = 'SELECT MIN( p.min_roas ) AS min_roas,
- MAX(
- CASE
- WHEN COALESCE( pa.cost_all_time, 0 ) > 0 THEN ROUND( COALESCE( pa.conversion_value_all_time, 0 ) / pa.cost_all_time * 100, 2 )
- ELSE 0
- END
- ) AS max_roas
- FROM products_aggregate AS pa
- INNER JOIN products AS p ON p.id = pa.product_id
- LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
- LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
- WHERE p.client_id = :client_id
- AND pa.conversions_all_time > 10';
+ $sql = 'SELECT
+ MIN( t.min_roas ) AS min_roas,
+ MAX( t.roas ) AS max_roas
+ FROM (
+ SELECT
+ p.id AS product_id,
+ p.min_roas AS min_roas,
+ CASE
+ WHEN SUM( pa.cost_all_time ) > 0 THEN ROUND( SUM( pa.conversion_value_all_time ) / SUM( pa.cost_all_time ) * 100, 2 )
+ ELSE 0
+ END AS roas,
+ SUM( pa.conversions_all_time ) AS conversions
+ FROM products_aggregate AS pa
+ INNER JOIN products AS p ON p.id = pa.product_id
+ LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
+ LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
+ WHERE p.client_id = :client_id';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
+ self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 );
- if ( $search )
- {
- $sql .= ' AND (
- p.name LIKE :search
- OR p.title LIKE :search
- OR p.offer_id LIKE :search
- OR p.custom_label_4 LIKE :search
- OR p.custom_label_1 LIKE :search
- OR c.campaign_name LIKE :search
- OR ag.ad_group_name LIKE :search
- )';
- $params[':search'] = '%' . $search . '%';
- }
-
- if ( $custom_label_4 !== '' )
- {
- $sql .= ' AND p.custom_label_4 LIKE :custom_label_4';
- $params[':custom_label_4'] = '%' . $custom_label_4 . '%';
- }
-
- if ( $custom_label_1 !== '' )
- {
- $sql .= ' AND p.custom_label_1 LIKE :custom_label_1';
- $params[':custom_label_1'] = '%' . $custom_label_1 . '%';
- }
+ $sql .= ' GROUP BY p.id, p.min_roas
+ HAVING SUM( pa.conversions_all_time ) > 10
+ ) AS t';
$row = $mdb -> query( $sql, $params ) -> fetch( \PDO::FETCH_ASSOC );
@@ -676,49 +669,103 @@ class Products
global $mdb;
$params = [ ':client_id' => (int) $client_id ];
- $sql = 'SELECT COUNT(0)
- FROM (
- SELECT p.id
- FROM products_aggregate AS pa
- INNER JOIN products AS p ON p.id = pa.product_id
- LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
- LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
- WHERE p.client_id = :client_id';
+ $sql = 'SELECT COUNT( DISTINCT p.id )
+ FROM products_aggregate AS pa
+ INNER JOIN products AS p ON p.id = pa.product_id
+ LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
+ LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
+ WHERE p.client_id = :client_id';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
-
- if ( $search )
- {
- $sql .= ' AND (
- p.name LIKE :search
- OR p.title LIKE :search
- OR p.offer_id LIKE :search
- OR p.custom_label_4 LIKE :search
- OR p.custom_label_1 LIKE :search
- OR c.campaign_name LIKE :search
- OR ag.ad_group_name LIKE :search
- )';
- $params[':search'] = '%' . $search . '%';
- }
-
- if ( $custom_label_4 !== '' )
- {
- $sql .= ' AND p.custom_label_4 LIKE :custom_label_4';
- $params[':custom_label_4'] = '%' . $custom_label_4 . '%';
- }
-
- if ( $custom_label_1 !== '' )
- {
- $sql .= ' AND p.custom_label_1 LIKE :custom_label_1';
- $params[':custom_label_1'] = '%' . $custom_label_1 . '%';
- }
-
- $sql .= ' GROUP BY p.id, pa.campaign_id, pa.ad_group_id
- ) AS grouped_rows';
+ self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 );
return $mdb -> query( $sql, $params ) -> fetchColumn();
}
+ static public function get_products_scope_breakdown( $client_id, $product_ids, $campaign_id = 0, $ad_group_id = 0, $search = '', $custom_label_4 = '', $custom_label_1 = '' )
+ {
+ global $mdb;
+
+ $product_ids = array_values( array_filter( array_map( 'intval', (array) $product_ids ) ) );
+
+ if ( empty( $product_ids ) )
+ {
+ return [];
+ }
+
+ $params = [ ':client_id' => (int) $client_id ];
+ $in_placeholders = [];
+
+ foreach ( $product_ids as $idx => $product_id )
+ {
+ $key = ':product_id_' . $idx;
+ $in_placeholders[] = $key;
+ $params[ $key ] = $product_id;
+ }
+
+ $sql = 'SELECT
+ pa.product_id,
+ pa.campaign_id,
+ pa.ad_group_id,
+ COALESCE( NULLIF( TRIM( c.campaign_name ), \'\' ), \'--- brak kampanii ---\' ) AS campaign_name,
+ CASE
+ WHEN pa.ad_group_id = 0 THEN \'PMax (bez grup reklam)\'
+ ELSE COALESCE( NULLIF( TRIM( ag.ad_group_name ), \'\' ), \'--- brak grupy reklam ---\' )
+ END AS ad_group_name,
+ SUM( pa.impressions_all_time ) AS impressions,
+ SUM( pa.impressions_30 ) AS impressions_30,
+ SUM( pa.clicks_all_time ) AS clicks,
+ SUM( pa.clicks_30 ) AS clicks_30,
+ CASE
+ WHEN SUM( pa.impressions_all_time ) > 0 THEN ROUND( SUM( pa.clicks_all_time ) / SUM( pa.impressions_all_time ) * 100, 2 )
+ ELSE 0
+ END AS ctr,
+ SUM( pa.cost_all_time ) AS cost,
+ CASE
+ WHEN SUM( pa.clicks_all_time ) > 0 THEN ROUND( SUM( pa.cost_all_time ) / SUM( pa.clicks_all_time ), 6 )
+ ELSE 0
+ END AS cpc,
+ SUM( pa.conversions_all_time ) AS conversions,
+ SUM( pa.conversion_value_all_time ) AS conversions_value,
+ CASE
+ WHEN SUM( pa.cost_all_time ) > 0 THEN ROUND( SUM( pa.conversion_value_all_time ) / SUM( pa.cost_all_time ) * 100, 2 )
+ ELSE 0
+ END AS roas
+ FROM products_aggregate AS pa
+ INNER JOIN products AS p ON p.id = pa.product_id
+ LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
+ LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
+ WHERE p.client_id = :client_id
+ AND pa.product_id IN (' . implode( ', ', $in_placeholders ) . ')';
+
+ self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
+ self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 );
+
+ $sql .= ' GROUP BY pa.product_id, pa.campaign_id, pa.ad_group_id, c.campaign_name, ag.ad_group_name
+ ORDER BY campaign_name ASC, ad_group_name ASC';
+
+ $rows = $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
+ $map = [];
+
+ foreach ( $rows as $row )
+ {
+ $product_id = (int) ( $row['product_id'] ?? 0 );
+ if ( $product_id <= 0 )
+ {
+ continue;
+ }
+
+ if ( !isset( $map[ $product_id ] ) )
+ {
+ $map[ $product_id ] = [];
+ }
+
+ $map[ $product_id ][] = $row;
+ }
+
+ return $map;
+ }
+
static public function get_product_full_context( $product_id )
{
global $mdb;
diff --git a/templates/products/main_view.php b/templates/products/main_view.php
index 6dd07b1..d6cdd5f 100644
--- a/templates/products/main_view.php
+++ b/templates/products/main_view.php
@@ -187,6 +187,55 @@
background-color: #6690f4;
color: #fff;
}
+
+.products-page .products-id-cell {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.products-page .products-breakdown-toggle {
+ width: 22px;
+ height: 22px;
+ border: 1px solid #d1d5db;
+ border-radius: 4px;
+ background: #fff;
+ color: #374151;
+ cursor: pointer;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.products-page .products-breakdown-wrap {
+ padding: 10px 14px;
+ background: #f8fafc;
+ border-top: 1px solid #e5e7eb;
+}
+
+.products-page .products-breakdown-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.products-page .products-breakdown-table th,
+.products-page .products-breakdown-table td {
+ border: 1px solid #e5e7eb;
+ padding: 6px 8px;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.products-page .products-breakdown-table th:nth-child(1),
+.products-page .products-breakdown-table th:nth-child(2),
+.products-page .products-breakdown-table td:nth-child(1),
+.products-page .products-breakdown-table td:nth-child(2),
+.products-page .products-breakdown-table td:nth-child(15),
+.products-page .products-breakdown-table td:nth-child(16) {
+ text-align: left;
+}
22 && row[22] && typeof row[22] === 'object' )
+ {
+ return row[22];
+ }
+
+ return { can_expand: false, breakdown_rows: [] };
+}
+
+function products_build_breakdown_html( row_meta )
+{
+ var rows = Array.isArray( row_meta.breakdown_rows ) ? row_meta.breakdown_rows : [];
+
+ if ( !rows.length )
+ {
+ return 'Brak szczegolow dla tego produktu.
';
+ }
+
+ var html = '';
+ html += '' +
+ '| Kampania | ' +
+ 'Grupa reklam | ' +
+ 'Wysw. | ' +
+ 'Wysw. (30d) | ' +
+ 'Klik. | ' +
+ 'Klik. (30d) | ' +
+ 'CTR | ' +
+ 'Koszt | ' +
+ 'CPC | ' +
+ 'Konw. | ' +
+ 'Wart. konw. | ' +
+ 'ROAS | ' +
+ 'Min. ROAS | ' +
+ 'CL1 | ' +
+ 'CL4 | ' +
+ '
';
+
+ rows.forEach( function( entry ) {
+ html += '' +
+ '| ' + escape_html( entry.campaign_name || '' ) + ' | ' +
+ '' + escape_html( entry.ad_group_name || '' ) + ' | ' +
+ '' + products_breakdown_number( entry.impressions, 0 ) + ' | ' +
+ '' + products_breakdown_number( entry.impressions_30, 0 ) + ' | ' +
+ '' + products_breakdown_number( entry.clicks, 0 ) + ' | ' +
+ '' + products_breakdown_number( entry.clicks_30, 0 ) + ' | ' +
+ '' + products_breakdown_number( entry.ctr, 2 ) + '% | ' +
+ '' + products_breakdown_number( entry.cost, 2 ) + ' | ' +
+ '' + products_breakdown_number( entry.cpc, 2 ) + ' | ' +
+ '' + products_breakdown_number( entry.conversions, 2 ) + ' | ' +
+ '' + products_breakdown_number( entry.conversions_value, 2 ) + ' | ' +
+ '' + products_breakdown_number( entry.roas, 0 ) + ' | ' +
+ '' + products_breakdown_number( entry.min_roas, 2 ) + ' | ' +
+ '' + escape_html( entry.custom_label_1 || '' ) + ' | ' +
+ '' + escape_html( entry.custom_label_4 || '' ) + ' | ' +
+ '
';
+ } );
+
+ html += '
';
+
+ return html;
+}
+
$( function()
{
init_products_scope_select_search();
@@ -557,7 +683,18 @@ $( function()
return '';
}
},
- { width: '50px', orderable: false },
+ { width: '70px', orderable: false, render: function( data, type, row ) {
+ var row_meta = products_breakdown_meta( row );
+ var toggle = '';
+
+ if ( row_meta.can_expand )
+ {
+ toggle = '';
+ }
+
+ return '' + toggle + '' + escape_html( data ) + '
';
+ }
+ },
{ width: '80px', name: 'offer_id' },
{ width: '200px', name: 'campaign_name' },
{ width: '200px', name: 'ad_group_name' },
@@ -603,6 +740,30 @@ $( function()
products_apply_saved_columns_visibility( products_table );
products_render_columns_picker( products_table );
+ $( '#products tbody' ).on( 'click', '.js-products-breakdown-toggle', function( e ) {
+ e.preventDefault();
+
+ var $btn = $( this );
+ var $tr = $btn.closest( 'tr' );
+ var dt_row = products_table.row( $tr );
+ var row_data = dt_row.data() || [];
+ var row_meta = products_breakdown_meta( row_data );
+
+ if ( dt_row.child.isShown() )
+ {
+ dt_row.child.hide();
+ $tr.removeClass( 'products-breakdown-open' );
+ $btn.attr( 'aria-expanded', 'false' );
+ $btn.find( 'i' ).removeClass( 'fa-chevron-down' ).addClass( 'fa-chevron-right' );
+ return;
+ }
+
+ dt_row.child( products_build_breakdown_html( row_meta ) ).show();
+ $tr.addClass( 'products-breakdown-open' );
+ $btn.attr( 'aria-expanded', 'true' );
+ $btn.find( 'i' ).removeClass( 'fa-chevron-right' ).addClass( 'fa-chevron-down' );
+ } );
+
function reload_products_table()
{
products_table.ajax.reload( null, false );