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. + + + +After completion, create `.paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md`. + 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['name'] . ' - +
', @@ -1111,14 +1144,15 @@ class Products round( $row['conversions'], 2 ), \S::number_display( $row['conversions_value'] ), $roasCellHtml, - '', - '', - '', + '', + '', + '', '
' - . '' - . '' - . '' - . '
' + . '' + . '' + . '' + . '
', + $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 += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + rows.forEach( function( entry ) { + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } ); + + html += '
KampaniaGrupa reklamWysw.Wysw. (30d)Klik.Klik. (30d)CTRKosztCPCKonw.Wart. konw.ROASMin. ROASCL1CL4
' + 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 || '' ) + '
'; + + 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 );