PROMPT: Moduł Zarządzania Maksymalną Ceną Sprzedaży (Admin Panel) Kontekst projektu Pracujesz w projekcie admin panel (monorepo PHP MVC z Twig). Twoim zadaniem jest zaimplementować moduł pozwalający administratorowi na: przeglądanie produktów dostępnych do sprzedaży w sklepach franczyzowych ustalanie maksymalnej ceny sprzedaży dla każdego produktu przeglądanie marży (oczekiwanej vs rzeczywistej) filtrowanie po kategoriach i okresach dostępności (availability periods) obserwację cen konkurencji (benchmark) — tylko do wglądu Cel funkcjonalny Admin widzi wszystkie produkty dostępne w systemie (globalne, dla wszystkich sklepów). Może ustawić maksymalną cenę sprzedaży (max_portion_price) dla produktu. Ta cena będzie limitem dla franczyzobiorców — franczyzobiorca może ustawić swoją cenę, ale nie wyższą niż max_portion_price określona przez admina. UI powinien pokazywać: oczekiwaną marżę (expected_margin) — zapisaną w produkcie rzeczywistą marżę — obliczoną na podstawie ceny kosztowej i aktualnej ceny sprzedaży (jeśli została ustawiona przez admina) dane benchmarkowe (ceny konkurencji) — min/max, ale tylko do wglądu, nie do edycji Wzorce z projektu franczyzowego (panel) W projekcie panel (dla franczyzobiorców) istnieje moduł Price Management, który: pozwala franczyzobiorcy ustawić cenę sprzedaży (price_gross) dla swoich produktów pokazuje okresy dostępności produktów (availability periods) jako badge wyświetla dane benchmarkowe (ceny konkurencji) grupuje produkty po kategoriach pokazuje oczekiwaną i rzeczywistą marżę umożliwia edycję ceny inline (AJAX) Zainspiruj się tym modułem, ale dostosuj do kontekstu admina: Admin operuje na maksymalnej cenie (max_portion_price), a nie na cenie właściwej sklepu. Admin widzi produkty globalnie (nie per sklep, tylko jeden globalny zestaw produktów). Admin nie wybiera sklepu — operuje na produkcie jako takim. Wymagania techniczne Stack PHP 8+ (MVC) Twig — widoki Bootstrap 5 / motyw Mazer — UI Repository Pattern — warstwa dostępu do API Service Pattern — logika biznesowa ApiClient — komunikacja z backendem Tłumaczenia — wszystkie teksty w plikach JSON (public/resources/lang/page/{lang}/*.json) AJAX — zapisywanie cen, filtrowanie Routing GET /products/max-price-list → PriceController@maxPriceManage POST /ajax/products/{id}/max-price → PriceController@ajaxUpdateMaxPrice Kontroler controllers/Product/PriceController.php Metoda maxPriceManage(): pobiera produkty (getAvailableToSale — globalne, bez id_shop) pobiera kategorie pobiera okresy dostępności (availability periods) pobiera dane benchmarkowe (opcjonalnie, jeśli admin chce zobaczyć ceny konkurencji) buduje indeks competitorPriceIndex (min/max ceny) buduje statystyki per kategoria (categoryCompetitorStats) renderuje widok product/price/max_price_manage.twig Metoda ajaxUpdateMaxPrice(int $idProduct): przyjmuje JSON z max_portion_price waliduje wywołuje ProductService->updateMaxPrice($idProduct, $data) zwraca JSON response Serwis services/Product/ProductService.php Metoda updateMaxPrice(int $idProduct, array $data): wywołuje ProductRepository->updateMaxPrice($idProduct, $data) Repozytorium repositories/Product/ProductRepository.php Metoda updateMaxPrice(int $idProduct, array $data): wywołuje $this->apiClient->patch("/products/{$idProduct}/max-price", $data) Model models/ProductModel.php — upewnij się, że model ma: getMaxPriceGross() — zwraca max_portion_price (może być null) getExpectedMargin() — zwraca expected_margin (% — float lub null) calcMarge() — oblicza rzeczywistą marżę (na podstawie ceny kosztowej i max_portion_price) Jeśli max_portion_price nie jest ustawiona → marge = null Jeśli jest → marge = ((max_portion_price - purchase_price_gross) / max_portion_price) * 100 getAvailabilityPeriods() — zwraca tablicę obiektów AvailabilityPeriodModel getCurrentPeriod() — zwraca aktywny okres (jeśli produkt jest obecnie w jakimś okresie) Widok Twig views/product/price/max_price_manage.twig Struktura podobna do panel, ale z różnicami: Filtry (sticky top bar): Kategorie (pills/badges, klikalne, filtrowanie w JS) Okresy dostępności (pills/badges, filtrowanie w JS) Search input (wyszukiwanie po nazwie produktu, debounce) Statystyki kategorii (collapsible cards): Nazwa kategorii + ikona expand/collapse Statystyki: Liczba produktów Średnia marża Liczba produktów bez ustawionej ceny Liczba produktów poniżej oczekiwanej marży (Opcjonalnie) liczba produktów z danymi benchmarkowymi Lista produktów (w ramach każdej kategorii): Nazwa produktu Okresy dostępności (badge — visual element, np. kolorowe labele z nazwą okresu) Jeśli produkt ma current_period → podświetl go innym kolorem Cena zakupu (purchase_price_gross) — tylko do wglądu Maksymalna cena sprzedaży (max_portion_price) — edytowalna inline Jeśli nie ustawiona → placeholder "---" lub "Brak" Kliknięcie → pojawia się input + przyciski Save/Cancel Zapis przez AJAX → toast z wynikiem Marża oczekiwana (expected_margin) — % (z obiektu produktu) Marża rzeczywista — obliczona na podstawie max_portion_price Jeśli marża < expected_margin → kolor czerwony/ostrzeżenie Jeśli marża >= expected_margin → kolor zielony Dane benchmarkowe (opcjonalnie): Ikona/trójkąt informujący, że są dane konkurencji Po kliknięciu/najechaniu → tooltip/collapse z min/max cenami konkurencji Tylko do wglądu — admin nie może edytować tych danych Akcje: Zapisz cenę (inline edit → AJAX) Toast po zapisie Automatyczne przeliczenie marży po zmianie ceny Logika UI/UX Availability Period Badge Każdy produkt może mieć przypisane okresy dostępności (np. "Sezon letni", "Święta", "Cały rok"). W widoku, przy nazwie produktu, wyświetl badge dla każdego okresu. Jeśli produkt jest obecnie w danym okresie (current_period), oznacz go wyróżniającym kolorem (np. zielony). Badge powinien być tylko wizualny (nie klikalny w kontekście produktu, ale może być klikalny w filtrze). Przykład:
{% for period in product.getAvailabilityPeriods() %} {{ period.getName() }} {% endfor %}
Inline Edit Max Price Domyślnie: wyświetl cenę lub "---" Kliknięcie → zamień na + przyciski Po zapisie → AJAX → POST /ajax/products/{id}/max-price → zwrotka JSON → toast + odświeżenie wartości Automatycznie przeliczyć marżę i zaktualizować kolor (czerwony/zielony) Benchmark Data Display Jeśli produkt ma dane konkurencji (competitorPriceIndex[product.getId()]): Wyświetl ikonę/trójkąt (np. ) Po najechaniu/kliknięciu → tooltip/collapse z: Min cena (nazwa konkurenta, cena, data ostatniej obserwacji) Max cena (j.w.) Nie edytuj — tylko informacja Filtrowanie Po kategorii: kliknięcie w badge kategorii → pokaż tylko produkty z tej kategorii Po okresie dostępności: kliknięcie w badge okresu → pokaż tylko produkty przypisane do tego okresu Search: wpisanie tekstu → filtruj po nazwie produktu (debounce 300ms) Filtry mogą działać kumulatywnie (kategoria + okres + search). Kolory marży Jeśli calcMarge() >= expected_margin → badge zielony (bg-success) Jeśli calcMarge() < expected_margin → badge czerwony (bg-danger) Jeśli brak ceny → badge szary (bg-secondary) Dane z API Endpoint: GET /products Zwraca wszystkie produkty dostępne w systemie (globalne, bez filtra po sklepie). Parametry query: date (opcjonalnie) — data, na którą sprawdzamy dostępność include — availability_periods,current_period — żeby dostać pełne dane o okresach Response: { "success": true, "data": [ { "id": 123, "name": "Tarta cytrynowa", "id_category": 11500, "category_name": "Tartes", "purchase_price_gross": 8.50, "max_portion_price": 12.00, "expected_margin": 30.0, "availability_periods": [ { "id": 1, "name": "Cały rok", "description": "Produkty dostępne przez cały rok", "start_date": "2026-01-01", "end_date": "2026-12-31", "is_recurring": true, "is_active": true } ], "current_period": { "id": 1, "name": "Cały rok", ... } }, ... ] } Endpoint: PATCH /products/{id}/max-price Aktualizuje maksymalną cenę sprzedaży produktu. Body: { "max_portion_price": 12.50 } Response: { "success": true, "message": "Maksymalna cena została zaktualizowana", "data": { "id": 123, "max_portion_price": 12.50 } } Tłumaczenia Wszystkie klucze w formacie translations.KEY muszą być w plikach JSON: public/resources/lang/page/pl/product.json public/resources/lang/page/en/product.json public/resources/lang/page/fr/product.json (i inne języki, jeśli są w projekcie) Przykładowe klucze: { "max_price_manage_title": "Zarządzanie maksymalnymi cenami produktów", "max_portion_price": "Maksymalna cena brutto", "expected_margin": "Oczekiwana marża", "actual_margin": "Rzeczywista marża", "no_price_set": "Brak ceny", "price_updated_success": "Cena została zaktualizowana", "price_updated_error": "Błąd podczas aktualizacji ceny", "benchmark_info": "Dane konkurencji", "min_competitor_price": "Najniższa cena konkurencji", "max_competitor_price": "Najwyższa cena konkurencji", "last_seen": "Ostatnia obserwacja", "purchase_price": "Cena zakupu", "filter_by_category": "Filtruj po kategorii", "filter_by_period": "Filtruj po okresie dostępności", "search_product": "Szukaj produktu", "all_categories": "Wszystkie kategorie", "all_periods": "Wszystkie okresy", "products_count": "Liczba produktów", "avg_margin": "Średnia marża", "below_expected": "Poniżej oczekiwanej", "without_price": "Bez ceny" } Szczegóły implementacyjne 1. Kontroler PriceController.php namespace App\Admin\Controllers\Product; use App\Admin\Controllers\Controller; use App\Admin\Routing\Route; use App\Admin\Services\Product\ProductService; use App\Admin\Services\Product\CategoryService; use App\Admin\Services\Product\AvailabilityPeriodService; use App\Admin\Services\Benchmark\AnalysisService; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class PriceController extends Controller { public function __construct( private ProductService $productService, private CategoryService $categoryService, private AvailabilityPeriodService $availabilityPeriodService, private AnalysisService $analysisService ) {} #[Route('GET', '/products/max-price-list')] public function maxPriceManage(): void { $dateTo = $_GET['date'] ?? date('Y-m-d'); $dateFrom = date('Y-m-d', strtotime($dateTo . ' -90 days')); $data['dateTo'] = $dateTo; $data['dateFrom'] = $dateFrom; // Pobierz wszystkie produkty (globalne, bez id_shop) $data['products'] = $this->productService->getAvailableToSale([ 'date' => $dateTo, 'include' => ['availability_periods', 'current_period'] ]); // Pobierz kategorie $data['categories'] = $this->categoryService->getUsed(); // Pobierz okresy dostępności $data['periods'] = $this->availabilityPeriodService->getAll(); $data['active_periods'] = $this->getActivePeriodsFromProducts($data['products']); // Benchmark (opcjonalnie) $breakdown = $this->analysisService->getBreakdownByCompetitorAndProduct($dateFrom, $dateTo); $data['breakdown'] = $breakdown; $data['competitorPriceIndex'] = $this->buildCompetitorPriceIndex($breakdown); // Statystyki kategorii $data['categoryStats'] = $this->buildCategoryStats($data['products'], $data['competitorPriceIndex']); $this->view('product/price/max_price_manage', $data); } #[Route('POST', '/ajax/products/{id}/max-price')] public function ajaxUpdateMaxPrice(int $id): Response { $request = Request::createFromGlobals(); $data = $this->getJson($request); if (empty($data) || !isset($data['max_portion_price'])) { return $this->json(['success' => false, 'message' => 'Invalid data'], 400); } $resp = $this->productService->updateMaxPrice($id, $data); return $this->json($resp, $resp['code'] ?? 200); } private function getActivePeriodsFromProducts(array $products): array { $periodsMap = []; foreach ($products as $product) { $availabilityPeriods = $product->getAvailabilityPeriods(); if (empty($availabilityPeriods)) continue; foreach ($availabilityPeriods as $period) { if (!$period->isActive()) continue; $periodId = $period->getId(); if (!isset($periodsMap[$periodId])) { $periodsMap[$periodId] = $period; } } } return array_values($periodsMap); } private function buildCompetitorPriceIndex(array $breakdown): array { $index = []; foreach ($breakdown as $row) { $pid = $row->getMyProductId(); $price = $row->getCompetitorPriceUsed(); $name = $row->getCompetitorName(); $seen = $row->getLastSeen(); if (!isset($index[$pid])) { $index[$pid] = [ 'min' => $price ?? 0.0, 'max' => $price ?? 0.0, 'count' => 1, 'min_entry' => ['name' => $name, 'price' => $price, 'lastSeen' => $seen], 'max_entry' => ['name' => $name, 'price' => $price, 'lastSeen' => $seen], ]; } else { $index[$pid]['count']++; if ($price !== null) { if ($price < $index[$pid]['min']) { $index[$pid]['min'] = $price; $index[$pid]['min_entry'] = ['name' => $name, 'price' => $price, 'lastSeen' => $seen]; } if ($price > $index[$pid]['max']) { $index[$pid]['max'] = $price; $index[$pid]['max_entry'] = ['name' => $name, 'price' => $price, 'lastSeen' => $seen]; } } } } return $index; } private function buildCategoryStats(array $products, array $competitorPriceIndex): array { $stats = []; foreach ($products as $product) { $catId = $product->getIdCategory(); if (!isset($stats[$catId])) { $stats[$catId] = [ 'total' => 0, 'without_price' => 0, 'below_expected' => 0, 'marge_sum' => 0.0, 'marge_count' => 0, 'avg_marge' => null, 'with_benchmark' => 0, ]; } $stats[$catId]['total']++; if (!$product->getMaxPriceGross()) { $stats[$catId]['without_price']++; } $marge = $product->calcMarge(); $expected = $product->getExpectedMargin(); if ($marge !== null) { $stats[$catId]['marge_sum'] += $marge; $stats[$catId]['marge_count']++; } if ($marge !== null && $expected !== null && $marge < (float)$expected) { $stats[$catId]['below_expected']++; } if (isset($competitorPriceIndex[$product->getId()])) { $stats[$catId]['with_benchmark']++; } } foreach ($stats as &$s) { if ($s['marge_count'] > 0) { $s['avg_marge'] = round($s['marge_sum'] / $s['marge_count'], 1); } } unset($s); return $stats; } } 2. Serwis ProductService.php namespace App\Admin\Services\Product; use App\Admin\Repositories\Product\ProductRepository; class ProductService { public function __construct( private ProductRepository $productRepository ) {} public function getAvailableToSale(array $params = []) { return $this->productRepository->getAvailableToSale($params); } public function updateMaxPrice(int $idProduct, array $data) { return $this->productRepository->updateMaxPrice($idProduct, $data); } } 3. Repozytorium ProductRepository.php namespace App\Admin\Repositories\Product; use App\Admin\Core\ApiClient; use App\Admin\Models\ProductModel; class ProductRepository { public function __construct( private ApiClient $apiClient ) {} public function getAvailableToSale(array $params = []): array { $query = http_build_query($params); $response = $this->apiClient->get("/products?{$query}"); $products = []; if (isset($response['data'])) { foreach ($response['data'] as $data) { $products[] = new ProductModel($data); } } return $products; } public function updateMaxPrice(int $idProduct, array $data) { return $this->apiClient->patch("/products/{$idProduct}/max-price", $data); } } 4. Model ProductModel.php Upewnij się, że model ma: namespace App\Admin\Models; class ProductModel { private $id; private $name; private $id_category; private $category_name; private $purchase_price_gross; private $max_portion_price; // Nowe pole private $expected_margin; // float (%) private $availability_periods = []; // array of AvailabilityPeriodModel private $current_period; // AvailabilityPeriodModel | null public function __construct(array $data) { $this->id = $data['id'] ?? null; $this->name = $data['name'] ?? null; $this->id_category = $data['id_category'] ?? null; $this->category_name = $data['category_name'] ?? null; $this->purchase_price_gross = $data['purchase_price_gross'] ?? null; $this->max_portion_price = $data['max_portion_price'] ?? null; $this->expected_margin = $data['expected_margin'] ?? null; if (isset($data['availability_periods']) && is_array($data['availability_periods'])) { foreach ($data['availability_periods'] as $period) { $this->availability_periods[] = new AvailabilityPeriodModel($period); } } if (isset($data['current_period'])) { $this->current_period = new AvailabilityPeriodModel($data['current_period']); } } public function getId() { return $this->id; } public function getName() { return $this->name; } public function getIdCategory() { return $this->id_category; } public function getCategoryName() { return $this->category_name; } public function getPurchasePriceGross() { return $this->purchase_price_gross; } public function getMaxPriceGross() { return $this->max_portion_price; } public function getExpectedMargin() { return $this->expected_margin; } public function getAvailabilityPeriods(): array { return $this->availability_periods; } public function getCurrentPeriod() { return $this->current_period; } /** * Oblicza rzeczywistą marżę (%) na podstawie max_portion_price */ public function calcMarge(): ?float { if ($this->max_portion_price === null || $this->purchase_price_gross === null) { return null; } if ($this->max_portion_price <= 0) { return null; } return (($this->max_portion_price - $this->purchase_price_gross) / $this->max_portion_price) * 100; } } 5. Widok views/product/price/max_price_manage.twig Struktura widoku: {% extends "layout.twig" %} {% block title %}{{ translations.max_price_manage_title|default('Zarządzanie maksymalnymi cenami') }}{% endblock %} {% block content %}

{{ translations.max_price_manage_title|default('Zarządzanie maksymalnymi cenami') }}

{# Sticky filters bar #}
{# Category filter #}
{{ translations.all_categories|default('Wszystkie') }} {% for category in categories %} {{ category.getName() }} {% endfor %}
{# Period filter #}
{{ translations.all_periods|default('Wszystkie') }} {% for period in active_periods %} {{ period.getName() }} {% endfor %}
{# Search #}
{# Products by category #} {% for category in categories %} {% set categoryId = category.getId() %} {% set categoryProducts = products|filter(p => p.getIdCategory() == categoryId) %} {% if categoryProducts|length > 0 %}
{{ category.getName() }}
{% set stats = categoryStats[categoryId] %} {{ stats.total ?? 0 }} {% if stats.avg_marge is not null %} {{ stats.avg_marge }}% {% endif %} {% if stats.without_price > 0 %} {{ stats.without_price }} {% endif %} {% if stats.below_expected > 0 %} {{ stats.below_expected }} {% endif %}
{% for product in categoryProducts %} {% set marge = product.calcMarge() %} {% set expected = product.getExpectedMargin() %} {% set hasBenchmark = competitorPriceIndex[product.getId()] is defined %} {# Product name #} {# Availability periods (badges) #} {# Purchase price #} {# Max price (editable inline) #} {# Expected margin #} {# Actual margin #} {# Benchmark info #} {% endfor %}
{{ translations.product_name|default('Produkt') }} {{ translations.availability_periods|default('Okresy dostępności') }} {{ translations.purchase_price|default('Cena zakupu') }} {{ translations.max_portion_price|default('Maks. cena') }} {{ translations.expected_margin|default('Oczek. marża') }} {{ translations.actual_margin|default('Rzeczyw. marża') }} {{ translations.benchmark_info|default('Konkurencja') }}
{{ product.getName() }}
{% for period in product.getAvailabilityPeriods() %} {% set isCurrent = product.getCurrentPeriod() and period.getId() == product.getCurrentPeriod().getId() %} {{ period.getName() }} {% endfor %}
{% if product.getPurchasePriceGross() %} {{ product.getPurchasePriceGross()|number_format(2, ',', ' ') }} € {% else %} --- {% endif %}
{% if product.getMaxPriceGross() %} {{ product.getMaxPriceGross()|number_format(2, ',', ' ') }} € {% else %} {{ translations.no_price_set|default('Brak') }} {% endif %}
{% if expected is not null %} {{ expected|number_format(1) }}% {% else %} --- {% endif %} {% if marge is not null %} {% set margeClass = (expected is not null and marge < expected) ? 'bg-danger' : 'bg-success' %} {{ marge|number_format(1) }}% {% else %} --- {% endif %} {% if hasBenchmark %} {% set benchmark = competitorPriceIndex[product.getId()] %} {% else %} --- {% endif %}
{% endif %} {% endfor %}
{# JavaScript #} {% endblock %} Checklist implementacji Backend Utworzyć kontroler PriceController.php w controllers/Product/ Dodać metody maxPriceManage() i ajaxUpdateMaxPrice() Utworzyć serwis ProductService.php w services/Product/ (lub rozszerzyć istniejący) Dodać metodę updateMaxPrice() w serwisie Utworzyć repozytorium ProductRepository.php w repositories/Product/ (lub rozszerzyć) Dodać metodę updateMaxPrice() wywołującą API PATCH /products/{id}/max-price Rozszerzyć ProductModel o: max_portion_price expected_margin availability_periods current_period calcMarge() Upewnić się, że AvailabilityPeriodModel istnieje i ma wymagane pola Zarejestrować routing: GET /products/max-price-list POST /ajax/products/{id}/max-price Frontend Utworzyć widok views/product/price/max_price_manage.twig Zaimplementować: Sticky filters bar (kategorie, okresy, search) Cards per kategoria z collapsible Tabela produktów z edytowalną ceną Inline edit max price (input + save/cancel) Availability period badges (kolorowe dla current period) Benchmark tooltip (min/max ceny) Wyświetlanie marży (oczekiwanej i rzeczywistej) z kolorami Dodać JavaScript do: Filtrowania (kategoria, okres, search) Inline edit + AJAX save Recalculate margin po zapisie Toast notifications Initialize Bootstrap tooltips Stylowanie zgodne z motywem Bootstrap 5 / Mazer Tłumaczenia Dodać klucze do public/resources/lang/page/pl/product.json Dodać klucze do public/resources/lang/page/en/product.json Dodać klucze do public/resources/lang/page/fr/product.json (Opcjonalnie) Dodać dla innych języków (nl, it, etc.) API (backend zewnętrzny) Upewnić się, że endpoint GET /products zwraca: max_portion_price expected_margin availability_periods (gdy include=availability_periods) current_period (gdy include=current_period) Upewnić się, że endpoint PATCH /products/{id}/max-price akceptuje: { "max_portion_price": float } Zwraca standardową odpowiedź JSON z success, message, data Dodatkowe uwagi Różnice względem panelu franczyzowego Panel franczyzowy: edytuje price_gross (cenę sprzedaży dla swojego sklepu), ma limit max_portion_price narzucony przez admina. Panel admina: edytuje max_portion_price (maksymalną cenę globalną), nie ma limitu (lub limit może być narzucony przez biznes, ale nie z UI). Kwestie do rozważenia Walidacja max_portion_price: Czy admin może ustawić dowolną cenę? Czy max_portion_price musi być >= purchase_price_gross? Czy walidować marżę minimalną? → Jeśli tak, dodaj walidację w kontrolerze lub API. Historia zmian cen: Czy logować zmiany max_portion_price (audit log)? → Jeśli tak, backend powinien to wspierać. Bulk update: Czy admin ma móc ustawić ceny dla wielu produktów naraz? → MVP: nie, ale możesz zaplanować rozszerzenie. Export cen: Czy admin ma móc wyeksportować listę cen (np. PDF, Excel)? → Podobnie jak w panelu franczyzowym (getPriceList()) — rozważ dodanie akcji. Podsumowanie Masz teraz kompletny prompt do zaimplementowania modułu Max Price Management w panelu admina. Moduł pozwala na: Przeglądanie produktów z availability periods (badge) Edycję maksymalnej ceny sprzedaży (inline edit) Wyświetlanie marży oczekiwanej i rzeczywistej Filtrowanie po kategorii, okresie, search Podgląd cen konkurencji (benchmark) Zainspirowany jest modułem z panelu franczyzowego, ale dostosowany do potrzeb administratora (globalne produkty, maksymalna cena zamiast ceny sklepu). Powodzenia w implementacji! 🚀