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 %}
{% for product in categoryProducts %}
{% set marge = product.calcMarge() %}
{% set expected = product.getExpectedMargin() %}
{% set hasBenchmark = competitorPriceIndex[product.getId()] is defined %}
{# Product name #}
{{ product.getName() }}
{# Availability periods (badges) #}
{% for period in product.getAvailabilityPeriods() %}
{% set isCurrent = product.getCurrentPeriod() and period.getId() == product.getCurrentPeriod().getId() %}
{{ period.getName() }}
{% endfor %}
{% if expected is not null %}
{{ expected|number_format(1) }}%
{% else %}
---
{% endif %}
{# Actual margin #}
{% if marge is not null %}
{% set margeClass = (expected is not null and marge < expected) ? 'bg-danger' : 'bg-success' %}
{{ marge|number_format(1) }}%
{% else %}
---
{% endif %}
{# Benchmark info #}
{% if hasBenchmark %}
{% set benchmark = competitorPriceIndex[product.getId()] %}
{% else %}
---
{% endif %}
{% endfor %}
{% 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! 🚀