Files
shopPRO/autoload/admin/Controllers/ShopProductController.php
Jacek Pyziak 4f66dbe42c ver. 0.319: usunięcie shopPRO eksportu produktów + rozszerzenie API o custom_fields i security_information
- Usunięto shopproExportProduct() z IntegrationsRepository
- Usunięto shoppro_product_export() z IntegrationsController
- Usunięto przycisk "Eksportuj do shopPRO" z ShopProductController
- ProductRepository: dodano custom_fields i security_information do odpowiedzi API
- Zaktualizowano docs/API.md i testy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 12:29:13 +01:00

1200 lines
50 KiB
PHP

<?php
namespace admin\Controllers;
use Domain\Product\ProductRepository;
use Domain\Category\CategoryRepository;
use Domain\Integrations\IntegrationsRepository;
use Domain\Languages\LanguagesRepository;
use admin\ViewModels\Forms\FormEditViewModel;
use admin\ViewModels\Forms\FormField;
use admin\ViewModels\Forms\FormTab;
use admin\ViewModels\Forms\FormAction;
use admin\ViewModels\Common\PaginatedTableViewModel;
use admin\Support\TableListRequestFactory;
/**
* Kontroler produktów w panelu administratora.
* Obsługuje: listę produktów, edycję, zapis, operacje, kombinacje, zdjęcia, pliki, masową edycję.
*/
class ShopProductController
{
private ProductRepository $repository;
private IntegrationsRepository $integrationsRepository;
private LanguagesRepository $languagesRepository;
public function __construct(ProductRepository $repository, IntegrationsRepository $integrationsRepository, LanguagesRepository $languagesRepository)
{
$this->repository = $repository;
$this->integrationsRepository = $integrationsRepository;
$this->languagesRepository = $languagesRepository;
}
// ─── Krok 6: Lista / widok ───────────────────────────────────────
/**
* Lista produktów.
*/
public function view_list(): string
{
$apiloEnabled = $this->integrationsRepository->getSetting( 'apilo', 'enabled' );
$shopproEnabled = $this->integrationsRepository->getSetting( 'shoppro', 'enabled' );
$dlang = $this->languagesRepository->defaultLanguage();
$sortableColumns = [ 'id', 'name', 'price_brutto', 'status', 'promoted', 'quantity' ];
$filterDefinitions = [
[
'key' => 'search',
'label' => 'Szukaj (nazwa, EAN, SKU)',
'type' => 'text',
],
[
'key' => 'status',
'label' => 'Aktywny',
'type' => 'select',
'options' => [ '' => '- aktywny -', '1' => 'tak', '0' => 'nie' ],
],
[
'key' => 'promoted',
'label' => 'Promowany',
'type' => 'select',
'options' => [ '' => '- promowany -', '1' => 'tak', '0' => 'nie' ],
],
];
$listRequest = TableListRequestFactory::fromRequest(
$filterDefinitions,
$sortableColumns,
'id'
);
$result = $this->repository->listForAdmin(
$listRequest['filters'],
$listRequest['sortColumn'],
$listRequest['sortDir'],
$listRequest['page'],
$listRequest['perPage']
);
$rows = [];
$lp = ( $listRequest['page'] - 1 ) * $listRequest['perPage'] + 1;
foreach ( $result['items'] as $product ) {
$id = (int) $product['id'];
$name = htmlspecialchars( (string) ( $product['languages'][$dlang]['name'] ?? '' ), ENT_QUOTES, 'UTF-8' );
$imgSrc = !empty( $product['images'][0]['src'] )
? $product['images'][0]['src']
: '/admin/layout/images/no-image.png';
$imgAlt = htmlspecialchars( (string) ( $product['images'][0]['alt'] ?? '' ), ENT_QUOTES, 'UTF-8' );
$categories = htmlspecialchars( $this->repository->productCategoriesText( $id ), ENT_QUOTES, 'UTF-8' );
$sku = htmlspecialchars( (string) ( $product['sku'] ?? '' ), ENT_QUOTES, 'UTF-8' );
$ean = htmlspecialchars( (string) ( $product['ean'] ?? '' ), ENT_QUOTES, 'UTF-8' );
$nameHtml = '<div class="product-image"><img src="' . $imgSrc . '" alt="' . $imgAlt . '" class="img-responsive"></div>'
. '<div class="product-name">'
. '<a href="/admin/shop_product/product_edit/id=' . $id . '">' . $name . '</a> '
. '<a href="#" class="text-muted duplicate-product" product-id="' . $id . '">duplikuj</a>'
. '</div>'
. '<small class="text-muted product-categories product-categories--cats" title="' . $categories . '">' . $categories . '</small>'
. '<small class="text-muted product-categories">SKU: ' . $sku . ', EAN: ' . $ean . '</small>';
$priceHtml = '<input type="text" class="product-price form-control text-right" product-id="' . $id . '" value="' . htmlspecialchars( (string) $product['price_brutto'], ENT_QUOTES, 'UTF-8' ) . '" style="width: 75px;">';
$promoHtml = '<input type="text" class="product-price-promo form-control text-right" product-id="' . $id . '" value="' . htmlspecialchars( (string) $product['price_brutto_promo'], ENT_QUOTES, 'UTF-8' ) . '" style="width: 75px;">';
$promotedHtml = $product['promoted'] ? '<span class="text-success text-bold">tak</span>' : 'nie';
$statusHtml = $product['status'] ? 'tak' : '<span class="text-danger text-bold">nie</span>';
$quantity = (int) $this->repository->getQuantity( $id );
$combinationsCount = $this->repository->countCombinations( $id );
$row = [
'lp' => $lp++ . '.',
'name' => $nameHtml,
'price' => $priceHtml,
'price_promo' => $promoHtml,
'promoted' => $promotedHtml,
'status' => $statusHtml,
'quantity' => '<span class="text-muted">' . $quantity . '</span>',
'combinations' => '<a href="/admin/shop_product/product_combination/product_id=' . $id . '">kombinacje (' . $combinationsCount . ')</a>',
'_actions' => [
[
'label' => 'Edytuj',
'url' => '/admin/shop_product/product_edit/id=' . $id,
'class' => 'btn btn-xs btn-primary',
],
[
'label' => 'Archiwizuj',
'url' => '/admin/shop_product/product_archive/product_id=' . $id,
'class' => 'btn btn-xs btn-danger',
'confirm' => 'Na pewno chcesz przenieść wybrany produkt do archiwum?',
],
],
];
if ( $apiloEnabled ) {
if ( !empty( $product['apilo_product_name'] ) ) {
$apiloName = htmlspecialchars( (string) $product['apilo_product_name'], ENT_QUOTES, 'UTF-8' );
$apiloShort = htmlspecialchars( mb_substr( (string) $product['apilo_product_name'], 0, 25, 'UTF-8' ), ENT_QUOTES, 'UTF-8' );
$row['apilo'] = '<span title="' . $apiloName . '">' . $apiloShort . '...</span><br>'
. '<span class="text-danger apilo-delete-linking" product-id="' . $id . '"><i class="fa fa-times"></i>usuń powiązanie</span>';
} else {
$row['apilo'] = '<span class="text-danger apilo-product-search" product-id="' . $id . '">nie przypisano <i class="fa fa-search"></i></span>';
}
}
$rows[] = $row;
}
$total = (int) $result['total'];
$totalPages = max( 1, (int) ceil( $total / $listRequest['perPage'] ) );
$columns = [
[ 'key' => 'lp', 'label' => '#', 'class' => 'text-center', 'sortable' => false ],
[ 'key' => 'name', 'sort_key' => 'name', 'label' => 'Nazwa', 'sortable' => true, 'raw' => true ],
[ 'key' => 'price', 'sort_key' => 'price_brutto', 'label' => 'Cena', 'class' => 'text-center', 'sortable' => true, 'raw' => true ],
[ 'key' => 'price_promo', 'label' => 'Cena promocyjna', 'class' => 'text-center', 'sortable' => false, 'raw' => true ],
[ 'key' => 'promoted', 'sort_key' => 'promoted', 'label' => 'Promowany', 'class' => 'text-center', 'sortable' => true, 'raw' => true ],
[ 'key' => 'status', 'sort_key' => 'status', 'label' => 'Aktywny', 'class' => 'text-center', 'sortable' => true, 'raw' => true ],
[ 'key' => 'quantity', 'sort_key' => 'quantity', 'label' => 'Stan MG', 'class' => 'text-center', 'sortable' => true, 'raw' => true ],
];
if ( $apiloEnabled ) {
$columns[] = [ 'key' => 'apilo', 'label' => 'Apilo', 'class' => 'text-center', 'sortable' => false, 'raw' => true ];
}
$columns[] = [ 'key' => 'combinations', 'label' => 'Kombinacje', 'class' => 'text-center', 'sortable' => false, 'raw' => true ];
$viewModel = new PaginatedTableViewModel(
$columns,
$rows,
$listRequest['viewFilters'],
[
'column' => $listRequest['sortColumn'],
'dir' => $listRequest['sortDir'],
],
[
'page' => $listRequest['page'],
'per_page' => $listRequest['perPage'],
'total' => $total,
'total_pages' => $totalPages,
],
array_merge( $listRequest['queryFilters'], [
'sort' => $listRequest['sortColumn'],
'dir' => $listRequest['sortDir'],
'per_page' => $listRequest['perPage'],
] ),
$listRequest['perPageOptions'],
$sortableColumns,
'/admin/shop_product/view_list/',
'Brak produktów.',
'/admin/shop_product/product_edit/',
'Dodaj produkt',
'shop-product/products-list-custom-script'
);
return \Shared\Tpl\Tpl::view( 'shop-product/products-list', [
'viewModel' => $viewModel,
'apilo_enabled' => $apiloEnabled,
'shoppro_enabled' => $shopproEnabled,
] );
}
// ─── Krok 7: Edycja i zapis ─────────────────────────────────────
/**
* Formularz edycji produktu.
*/
public function product_edit(): string
{
global $user;
if ( !$user ) {
header( 'Location: /admin/' );
exit;
}
$this->repository->deleteNonassignedImages();
$this->repository->deleteNonassignedFiles();
$db = $GLOBALS['mdb'];
$product = $this->repository->findForAdmin( (int) \Shared\Helpers\Helpers::get( 'id' ) ) ?: [];
$languages = $this->languagesRepository->languagesList();
$categories = ( new CategoryRepository( $db ) )->subcategories( null );
$layouts = $this->layoutsForProductEdit( $db );
$products = $this->repository->allProductsList();
$sets = ( new \Domain\ProductSet\ProductSetRepository( $db ) )->allSets();
$producers = ( new \Domain\Producer\ProducerRepository( $db ) )->allProducers();
$units = ( new \Domain\Dictionaries\DictionariesRepository( $db ) )->allUnits();
$dlang = $this->languagesRepository->defaultLanguage();
$viewModel = $this->buildProductFormViewModel(
$product, $languages, $categories, $layouts, $products, $sets, $producers, $units, $dlang
);
return \Shared\Tpl\Tpl::view( 'shop-product/product-edit', [
'form' => $viewModel,
'product' => $product,
'user' => $user,
] );
}
private function layoutsForProductEdit($db): array
{
if ( class_exists( '\Domain\Layouts\LayoutsRepository' ) ) {
$rows = ( new \Domain\Layouts\LayoutsRepository( $db ) )->listAll();
return is_array( $rows ) ? $rows : [];
}
return [];
}
private function buildProductFormViewModel(
array $product,
array $languages,
array $categories,
array $layouts,
array $products,
array $sets,
array $producers,
array $units,
$dlang
): FormEditViewModel {
$productId = (int) ( $product['id'] ?? 0 );
$title = $productId > 0
? 'Edycja produktu: <u>' . $this->escapeHtml( (string) ( $product['languages'][$dlang]['name'] ?? '' ) ) . '</u>'
: 'Edycja produktu';
// Opcje select: copy_from
$copyFromOptions = [ '' => '---- wersja językowa ----' ];
foreach ( $languages as $lg ) {
if ( !empty( $lg['id'] ) ) {
$copyFromOptions[(string) $lg['id']] = (string) $lg['name'];
}
}
// Opcje select: jednostka miary
$unitOptions = [ '' => '--- wybierz jednostkę miary ---' ];
foreach ( $units as $unit ) {
$unitOptions[(string) $unit['id']] = (string) $unit['text'];
}
// Opcje select: layout
$layoutOptions = [ '' => '---- szablon domyślny ----' ];
foreach ( $layouts as $layout ) {
$layoutOptions[(string) $layout['id']] = (string) $layout['name'];
}
// Opcje select: producent
$producerOptions = [ '' => '--- wybierz producenta ---' ];
foreach ( $producers as $producer ) {
$producerOptions[(string) $producer['id']] = (string) $producer['name'];
}
$tabs = [
new FormTab( 'description', 'Opis', 'fa-file' ),
new FormTab( 'tabs', 'Zakładki', 'fa-file' ),
new FormTab( 'price', 'Cena', 'fa-dollar' ),
new FormTab( 'stock', 'Magazyn', 'fa-home' ),
new FormTab( 'settings', 'Ustawienia', 'fa-wrench' ),
new FormTab( 'seo', 'SEO', 'fa-globe' ),
new FormTab( 'display', 'Wyświetlanie', 'fa-share-alt' ),
new FormTab( 'gallery', 'Galeria', 'fa-file-image-o' ),
new FormTab( 'attachments', 'Załączniki', 'fa-file-archive-o' ),
new FormTab( 'related', 'Produkty powiązane', 'fa-exchange' ),
new FormTab( 'xml', 'XML', 'fa-file-excel-o' ),
new FormTab( 'custom_fields', 'Dodatkowe pola', 'fa-file-o' ),
new FormTab( 'gpsr', 'GPSR', 'fa-file-o' ),
];
$fields = [
FormField::hidden( 'id', $productId ),
// Tab: Opis — sekcja językowa
FormField::langSection( 'product_description', 'description', [
FormField::select( 'copy_from', [
'label' => 'Wyświetlaj treść z wersji',
'options' => $copyFromOptions,
] ),
FormField::text( 'name', [
'label' => 'Nazwa',
'attributes' => [ 'id' => 'name' ],
] ),
FormField::text( 'warehouse_message_zero', [
'label' => 'Komunikat gdy stan magazynowy równy 0',
'attributes' => [ 'id' => 'warehouse_message_zero' ],
] ),
FormField::text( 'warehouse_message_nonzero', [
'label' => 'Komunikat gdy stan magazynowy większy niż 0',
'attributes' => [ 'id' => 'warehouse_message_nonzero' ],
] ),
FormField::editor( 'short_description', [
'label' => 'Krótki opis',
'toolbar' => 'MyToolbar',
'height' => 250,
'attributes' => [ 'id' => 'short_description' ],
] ),
FormField::editor( 'description', [
'label' => 'Opis',
'toolbar' => 'MyToolbar',
'height' => 250,
'attributes' => [ 'id' => 'description' ],
] ),
] ),
// Tab: Zakładki — sekcja językowa
FormField::langSection( 'product_tabs', 'tabs', [
FormField::text( 'tab_name_1', [
'label' => 'Nazwa zakładki (1)',
'attributes' => [ 'id' => 'tab_name_1' ],
] ),
FormField::editor( 'tab_description_1', [
'label' => 'Zawartość zakładki (1)',
'toolbar' => 'MyToolbar',
'height' => 250,
'attributes' => [ 'id' => 'tab_description_1' ],
] ),
FormField::text( 'tab_name_2', [
'label' => 'Nazwa zakładki (2)',
'attributes' => [ 'id' => 'tab_name_2' ],
] ),
FormField::editor( 'tab_description_2', [
'label' => 'Zawartość zakładki (2)',
'toolbar' => 'MyToolbar',
'height' => 250,
'attributes' => [ 'id' => 'tab_description_2' ],
] ),
] ),
// Tab: Cena
FormField::text( 'vat', [
'label' => 'VAT (%)',
'tab' => 'price',
'value' => $productId ? $product['vat'] : 23,
'attributes' => [ 'id' => 'vat', 'class' => 'int-format' ],
] ),
FormField::text( 'price_netto', [
'label' => 'Cena netto (PLN)',
'tab' => 'price',
'value' => $product['price_netto'] ?? '',
'attributes' => [ 'id' => 'price_netto', 'class' => 'number-format' ],
] ),
FormField::text( 'price_brutto', [
'label' => 'Cena brutto (PLN)',
'tab' => 'price',
'value' => $product['price_brutto'] ?? '',
'attributes' => [ 'id' => 'price_brutto', 'class' => 'number-format' ],
] ),
FormField::text( 'price_netto_promo', [
'label' => 'Promocyjna cena netto (PLN)',
'tab' => 'price',
'value' => $product['price_netto_promo'] ?? '',
'attributes' => [ 'id' => 'price_netto_promo', 'class' => 'number-format' ],
] ),
FormField::text( 'price_brutto_promo', [
'label' => 'Promocyjna cena brutto (PLN)',
'tab' => 'price',
'value' => $product['price_brutto_promo'] ?? '',
'attributes' => [ 'id' => 'price_brutto_promo', 'class' => 'number-format' ],
] ),
FormField::select( 'product_unit', [
'label' => 'Jednostka miary',
'tab' => 'price',
'options' => $unitOptions,
'value' => $product['product_unit_id'] ?? '',
] ),
FormField::text( 'weight', [
'label' => 'Waga/pojemność',
'tab' => 'price',
'value' => $product['weight'] ?? '',
'attributes' => [ 'id' => 'weight', 'class' => 'number-format' ],
] ),
// Tab: Magazyn
FormField::text( 'quantity', [
'label' => 'Stan magazynowy',
'tab' => 'stock',
'value' => $product['quantity'] ?? '',
'attributes' => [ 'id' => 'quantity', 'class' => 'int-format' ],
] ),
FormField::switch( 'stock_0_buy', [
'label' => 'Pozwól zamawiać gdy stan 0',
'tab' => 'stock',
'value' => (int) ( $product['stock_0_buy'] ?? 0 ) === 1,
] ),
FormField::text( 'wp', [
'label' => 'Współczynnik WP',
'tab' => 'stock',
'value' => $product['wp'] ?? '',
'attributes' => [ 'id' => 'wp', 'class' => 'number-format' ],
] ),
FormField::custom( 'sku_field', $this->renderSkuField( $product ), [ 'tab' => 'stock' ] ),
FormField::text( 'ean', [
'label' => 'EAN',
'tab' => 'stock',
'value' => $product['ean'] ?? '',
'attributes' => [ 'id' => 'ean' ],
] ),
// Tab: Ustawienia
FormField::switch( 'status', [
'label' => 'Widoczny',
'tab' => 'settings',
'value' => ( (int) ( $product['status'] ?? 0 ) === 1 ) || $productId === 0,
] ),
FormField::switch( 'promoted', [
'label' => 'Promowany',
'tab' => 'settings',
'value' => (int) ( $product['promoted'] ?? 0 ) === 1,
] ),
FormField::date( 'new_to_date', [
'label' => 'Nowość do dnia',
'tab' => 'settings',
'value' => $product['new_to_date'] ?? '',
] ),
FormField::switch( 'additional_message', [
'label' => 'Wyświetlaj pole na dodatkową wiadomość',
'tab' => 'settings',
'value' => (int) ( $product['additional_message'] ?? 0 ) === 1,
] ),
FormField::switch( 'additional_message_required', [
'label' => 'Dodatkowa wiadomość jest wymagana',
'tab' => 'settings',
'value' => (int) ( $product['additional_message_required'] ?? 0 ) === 1,
] ),
FormField::text( 'additional_message_text', [
'label' => 'Dodatkowa wiadomość (treść komunikatu)',
'tab' => 'settings',
'value' => $product['additional_message_text'] ?? '',
'attributes' => [ 'id' => 'additional_message_text' ],
] ),
// Tab: SEO — sekcja językowa
FormField::langSection( 'product_seo', 'seo', [
FormField::text( 'seo_link', [
'label' => 'Link SEO',
'attributes' => [
'id' => 'seo_link',
'icon_content' => 'generuj',
'icon_js' => 'generate_seo_links( "{lang}", $( "#name_{lang}" ).val(), ' . $productId . ' );',
],
] ),
FormField::text( 'meta_title', [
'label' => 'Meta title',
'attributes' => [ 'id' => 'meta_title' ],
] ),
FormField::textarea( 'meta_description', [
'label' => 'Meta description',
'attributes' => [ 'id' => 'meta_description' ],
] ),
FormField::textarea( 'meta_keywords', [
'label' => 'Meta keywords',
'attributes' => [ 'id' => 'meta_keywords' ],
] ),
FormField::text( 'canonical', [
'label' => 'Canonical',
'attributes' => [ 'id' => 'canonical' ],
] ),
] ),
// Tab: Wyświetlanie
FormField::select( 'layout_id', [
'label' => 'Szablon',
'tab' => 'display',
'options' => $layoutOptions,
'value' => $product['layout_id'] ?? '',
] ),
FormField::custom( 'categories_tree', $this->renderCategoriesTree( $categories, $product, $dlang ), [ 'tab' => 'display' ] ),
// Tab: Galeria
FormField::custom( 'gallery_box', $this->renderGalleryBox( $product ), [ 'tab' => 'gallery' ] ),
// Tab: Załączniki
FormField::custom( 'files_box', $this->renderFilesBox( $product ), [ 'tab' => 'attachments' ] ),
// Tab: Produkty powiązane
FormField::custom( 'related_products', $this->renderRelatedProducts( $product, $products, $sets ), [ 'tab' => 'related' ] ),
// Tab: XML
FormField::text( 'custom_label_0', [ 'label' => 'Custom label 0', 'tab' => 'xml', 'value' => $product['custom_label_0'] ?? '' ] ),
FormField::text( 'custom_label_1', [ 'label' => 'Custom label 1', 'tab' => 'xml', 'value' => $product['custom_label_1'] ?? '' ] ),
FormField::text( 'custom_label_2', [ 'label' => 'Custom label 2', 'tab' => 'xml', 'value' => $product['custom_label_2'] ?? '' ] ),
FormField::text( 'custom_label_3', [ 'label' => 'Custom label 3', 'tab' => 'xml', 'value' => $product['custom_label_3'] ?? '' ] ),
FormField::text( 'custom_label_4', [ 'label' => 'Custom label 4', 'tab' => 'xml', 'value' => $product['custom_label_4'] ?? '' ] ),
// Tab: Dodatkowe pola
FormField::custom( 'custom_fields_box', $this->renderCustomFieldsBox( $product ), [ 'tab' => 'custom_fields' ] ),
// Tab: GPSR
FormField::select( 'producer_id', [
'label' => 'Producent',
'tab' => 'gpsr',
'options' => $producerOptions,
'value' => $product['producer_id'] ?? '',
] ),
FormField::langSection( 'product_gpsr', 'gpsr', [
FormField::editor( 'security_information', [
'label' => 'Informacje o bezpieczeństwie',
'toolbar' => 'MyToolbar',
'height' => 250,
'attributes' => [ 'id' => 'security_information' ],
] ),
] ),
];
$saveUrl = '/admin/shop_product/save/' . ( $productId > 0 ? 'id=' . $productId : '' );
$backUrl = '/admin/shop_product/view_list/';
$actions = [
FormAction::save( $saveUrl, $backUrl ),
FormAction::cancel( $backUrl ),
];
if ( $productId > 0 ) {
$previewUrl = $this->repository->getProductUrl( $productId );
$actions[] = FormAction::preview( $previewUrl );
}
return new FormEditViewModel(
'product-edit',
$title,
$product,
$fields,
$tabs,
$actions,
'POST',
$saveUrl,
$backUrl,
true,
[ 'id' => $productId ],
$languages
);
}
private function renderSkuField( array $product ): string
{
$productId = (int) ( $product['id'] ?? 0 );
$sku = $this->escapeHtml( (string) ( $product['sku'] ?? '' ) );
return \Shared\Html\Html::input_icon( [
'label' => 'Kod SKU',
'name' => 'sku',
'id' => 'sku',
'value' => $sku,
'icon_content' => 'generuj',
'icon_js' => 'generate_sku_code( ' . $productId . ' );',
] );
}
private function renderCategoriesTree( array $categories, array $product, $dlang ): string
{
$html = '<div class="form-group row">';
$html .= '<label class="col-md-4 control-label">Wyświetlaj w:</label>';
$html .= '<div class="col-md-8">';
$html .= '<div class="menu_sortable">';
$html .= '<ol class="sortable" id="sortable">';
foreach ( $categories as $category ) {
$catId = (int) $category['id'];
$catTitle = $this->escapeHtml( (string) ( $category['languages'][$dlang]['title'] ?? '' ) );
$checked = is_array( $product['categories'] ?? null ) && in_array( $catId, $product['categories'] );
$html .= '<li id="list_' . $catId . '" class="category_' . $catId . '" category="' . $catId . '">';
$html .= '<div class="context_0 content content_menu">';
$html .= '<button type="button" class="disclose layout-tree-toggle" aria-expanded="false" title="Rozwiń / zwiń"><i class="fa fa-caret-right"></i></button>';
if ( empty( $category['status'] ) ) {
$html .= '<i class="fa fa-ban fa-lg text-danger" title="Kategoria nieaktywna"></i>';
}
$html .= '<input type="checkbox" class="g-checkbox" name="categories[]" value="' . $catId . '"' . ( $checked ? ' checked="checked"' : '' ) . ' />';
$html .= '<b>' . $catTitle . '</b>';
$html .= '</div>';
$html .= \Shared\Tpl\Tpl::view( 'shop-product/subcategories-list', [
'categories' => ( new CategoryRepository( $GLOBALS['mdb'] ) )->subcategories( $catId ),
'product_categories' => $product['categories'] ?? [],
'dlang' => $dlang,
'name' => null,
] );
$html .= '</li>';
}
$html .= '</ol></div></div><div class="clear"></div></div>';
return $html;
}
private function renderGalleryBox( array $product ): string
{
$html = '<ul id="images-list">';
$images = is_array( $product['images'] ?? null ) ? $product['images'] : [];
foreach ( $images as $img ) {
$id = (int) ( $img['id'] ?? 0 );
$src = $this->escapeHtml( (string) ( $img['src'] ?? '' ) );
$alt = $this->escapeHtml( (string) ( $img['alt'] ?? '' ) );
$html .= '<li id="image-' . $id . '">';
$html .= '<img class="article-image lozad" data-src="/libraries/thumb.php?img=' . $src . '&w=300&h=300">';
$html .= '<a href="#" class="input-group-addon btn btn-danger article_image_delete" image-id="' . $id . '"><i class="fa fa-trash"></i></a>';
$html .= '<input type="text" class="form-control image-alt" value="' . $alt . '" image-id="' . $id . '" placeholder="atrybut alt...">';
$html .= '</li>';
}
$html .= '</ul>';
$html .= '<div id="images-uploader">Twoja przeglądarka nie obsługuje uploadu plików.</div>';
return $html;
}
private function renderFilesBox( array $product ): string
{
$html = '<ul id="files-list">';
$files = is_array( $product['files'] ?? null ) ? $product['files'] : [];
foreach ( $files as $file ) {
$id = (int) ( $file['id'] ?? 0 );
$src = (string) ( $file['src'] ?? '' );
$name = trim( (string) ( $file['name'] ?? '' ) );
if ( $name === '' ) {
$parts = explode( '/', $src );
$name = (string) end( $parts );
}
$name = $this->escapeHtml( $name );
$html .= '<li id="file-' . $id . '"><div class="input-group">';
$html .= '<input type="text" class="product_file_edit form-control" file_id="' . $id . '" value="' . $name . '" />';
$html .= '<a href="#" class="input-group-addon btn btn-info product_file_delete" file_id="' . $id . '"><i class="fa fa-trash"></i></a>';
$html .= '</div></li>';
}
$html .= '</ul>';
$html .= '<div id="files-uploader">Twoja przeglądarka nie obsługuje uploadu plików.</div>';
return $html;
}
private function renderRelatedProducts( array $product, array $products, array $sets ): string
{
$productId = (int) ( $product['id'] ?? 0 );
$html = '<div class="form-group row">';
$html .= '<label class="col-lg-4 control-label">Wybierz zdefiniowany komplet produktów:</label>';
$html .= '<div class="col-lg-8">';
$html .= '<select id="set" class="form-control" name="set">';
$html .= '<option value="">wybierz komplet...</option>';
foreach ( $sets as $set ) {
$selected = ( ( $product['set_id'] ?? '' ) == $set['id'] ) ? ' selected="selected"' : '';
$html .= '<option value="' . (int) $set['id'] . '"' . $selected . '>' . $this->escapeHtml( $set['name'] ) . '</option>';
}
$html .= '</select></div></div>';
$html .= '<div class="form-group row">';
$html .= '<label class="col-lg-4 control-label">Produkty powiązane:</label>';
$html .= '<div class="col-lg-8">';
$html .= '<select id="products_related" multiple name="products_related[]" placeholder="produkty powiązane">';
$html .= '<option value="">wybierz produkt...</option>';
foreach ( $products as $key => $val ) {
if ( (int) $key !== $productId ) {
$selected = ( is_array( $product['products_related'] ?? null ) && in_array( $key, $product['products_related'] ) ) ? ' selected' : '';
$html .= '<option value="' . (int) $key . '"' . $selected . '>' . $this->escapeHtml( (string) $val ) . '</option>';
}
}
$html .= '</select></div></div>';
return $html;
}
private function renderCustomFieldsBox( array $product ): string
{
$html = '<a href="#" class="btn btn-success" id="add_custom_field"><i class="fa fa-plus"></i> dodaj niestandardowe pole</a>';
$html .= '<div class="additional_fields pt-3">';
$customFields = is_array( $product['custom_fields'] ?? null ) ? $product['custom_fields'] : [];
foreach ( $customFields as $field ) {
$isRequired = !empty( $field['is_required'] );
$fieldName = $this->escapeHtml( (string) ( $field['name'] ?? '' ) );
$fieldType = (string) ( $field['type'] ?? 'text' );
$html .= '<div class="form-group row custom-field-row bg-white p-4">';
$html .= '<div class="form-group row"><label class="col-sm-3 control-label">Nazwa pola:</label>';
$html .= '<div class="col-sm-9"><input type="text" class="form-control" name="custom_field_name[]" value="' . $fieldName . '"></div></div>';
$html .= '<div class="form-group row"><label class="col-sm-3 control-label">Rodzaj pola:</label>';
$html .= '<div class="col-sm-9"><select class="form-control" name="custom_field_type[]">';
$html .= '<option value="text"' . ( $fieldType === 'text' ? ' selected' : '' ) . '>Tekst</option>';
$html .= '<option value="image"' . ( $fieldType === 'image' ? ' selected' : '' ) . '>Obrazek</option>';
$html .= '</select></div></div>';
$html .= '<div class="form-group row"><label class="col-sm-3 control-label">Status pola:</label>';
$html .= '<div class="col-sm-9"><label style="margin:0; font-weight:normal;" class="d-flex align-items-center mt-3">';
$html .= '<input type="checkbox" class="custom-field-required mt-0 mr-3" name="custom_field_required[]"' . ( $isRequired ? ' checked' : '' ) . '> wymagane';
$html .= '</label></div></div>';
$html .= '<div class="form-group row"><div class="col-sm-12 text-right">';
$html .= '<span class="input-group-addon btn btn-info" onclick="remove_custom_filed( $( this ) );">usuń</span>';
$html .= '</div></div></div>';
}
$html .= '</div>';
return $html;
}
private function escapeHtml( string $value ): string
{
return htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' );
}
private function resolveSavePayload(): array
{
$legacyRaw = \Shared\Helpers\Helpers::get( 'values' );
if ( $legacyRaw !== null && $legacyRaw !== '' ) {
$legacy = json_decode( (string) $legacyRaw, true );
if ( is_array( $legacy ) ) {
return $legacy;
}
}
$payload = $_POST;
unset( $payload['_form_id'] );
return is_array( $payload ) ? $payload : [];
}
/**
* AJAX: zapis produktu.
*/
public function save(): void
{
$response = [ 'success' => false, 'status' => 'error', 'msg' => 'Podczas zapisywania produktu wystąpił błąd. Proszę spróbować ponownie.' ];
$values = $this->resolveSavePayload();
if ( $values ) {
$id = $this->repository->saveProduct( $values );
if ( $id ) {
$response = [ 'success' => true, 'status' => 'ok', 'message' => 'Produkt został zapisany.', 'msg' => 'Produkt został zapisany.', 'id' => $id ];
}
}
echo json_encode( $response );
exit;
}
// ─── Krok 8: Operacje na produktach ──────────────────────────────
/**
* Duplikowanie produktu.
*/
public function duplicate_product(): void
{
if ( $this->repository->duplicate( (int) \Shared\Helpers\Helpers::get( 'product-id' ), (bool) (int) \Shared\Helpers\Helpers::get( 'combination' ) ) ) {
\Shared\Helpers\Helpers::set_message( 'Produkt został zduplikowany.' );
} else {
\Shared\Helpers\Helpers::alert( 'Podczas duplikowania produktu wystąpił błąd. Proszę spróbować ponownie' );
}
header( 'Location: /admin/shop_product/view_list/' );
exit;
}
/**
* Archiwizacja produktu.
*/
public function product_archive(): void
{
if ( $this->repository->archive( (int) \Shared\Helpers\Helpers::get( 'product_id' ) ) ) {
\Shared\Helpers\Helpers::alert( 'Produkt został przeniesiony do archiwum.' );
} else {
\Shared\Helpers\Helpers::alert( 'Podczas przenoszenia produktu do archiwum wystąpił błąd. Proszę spróbować ponownie' );
}
header( 'Location: /admin/shop_product/view_list/' );
exit;
}
/**
* Przywrócenie z archiwum.
*/
public function product_unarchive(): void
{
if ( $this->repository->unarchive( (int) \Shared\Helpers\Helpers::get( 'product_id' ) ) ) {
\Shared\Helpers\Helpers::alert( 'Produkt został przywrócony z archiwum.' );
} else {
\Shared\Helpers\Helpers::alert( 'Podczas przywracania produktu z archiwum wystąpił błąd. Proszę spróbować ponownie' );
}
header( 'Location: /admin/product_archive/list/' );
exit;
}
/**
* Usunięcie produktu.
*/
public function product_delete(): void
{
if ( $this->repository->delete( (int) \Shared\Helpers\Helpers::get( 'id' ) ) ) {
\Shared\Helpers\Helpers::set_message( 'Produkt został usunięty.' );
} else {
\Shared\Helpers\Helpers::alert( 'Podczas usuwania produktu wystąpił błąd. Proszę spróbować ponownie' );
}
header( 'Location: /admin/shop_product/view_list/' );
exit;
}
/**
* Zmiana statusu produktu (aktywny/nieaktywny).
*/
public function change_product_status(): void
{
if ( $this->repository->toggleStatus( (int) \Shared\Helpers\Helpers::get( 'product-id' ) ) ) {
\Shared\Helpers\Helpers::set_message( 'Status produktu został zmieniony' );
}
header( 'Location: ' . $_SERVER['HTTP_REFERER'] );
exit;
}
/**
* AJAX: szybka zmiana ceny brutto.
*/
public function product_change_price_brutto(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas zmiany ceny wystąpił błąd. Proszę spróbować ponownie.' ];
if ( $this->repository->updatePriceBrutto( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'price' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: szybka zmiana ceny promocyjnej brutto.
*/
public function product_change_price_brutto_promo(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas zmiany ceny wystąpił błąd. Proszę spróbować ponownie.' ];
if ( $this->repository->updatePriceBruttoPromo( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'price' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: szybka zmiana custom label (Google XML).
*/
public function product_change_custom_label(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas zmiany google xml label wystąpił błąd. Proszę spróbować ponownie.' ];
if ( $this->repository->updateCustomLabel( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'custom_label' ), \Shared\Helpers\Helpers::get( 'value' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: sugestie custom label.
*/
public function product_custom_label_suggestions(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas pobierania sugestii dla custom label wystąpił błąd. Proszę spróbować ponownie.' ];
$suggestions = $this->repository->customLabelSuggestions( \Shared\Helpers\Helpers::get( 'custom_label' ), \Shared\Helpers\Helpers::get( 'label_type' ) );
if ( $suggestions ) {
$response = [ 'status' => 'ok', 'suggestions' => $suggestions ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: zapis custom label produktu.
*/
public function product_custom_label_save(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas zapisywania custom label wystąpił błąd. Proszę spróbować ponownie.' ];
if ( $this->repository->saveCustomLabel( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'custom_label' ), \Shared\Helpers\Helpers::get( 'label_type' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: pobierz bezpośredni URL produktu na frontendzie.
*/
public function ajax_product_url(): void
{
echo json_encode( [ 'url' => $this->repository->getProductUrl( (int) \Shared\Helpers\Helpers::get( 'product_id' ) ) ] );
exit;
}
/**
* AJAX: generowanie kodu SKU.
*/
public function generate_sku_code(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas generowania kodu sku wystąpił błąd. Proszę spróbować ponownie.' ];
$sku = $this->repository->generateSkuCode();
if ( $sku ) {
$response = [ 'status' => 'ok', 'sku' => $sku ];
}
echo json_encode( $response );
exit;
}
// ─── Krok 9: Kombinacje ─────────────────────────────────────────
/**
* Widok kombinacji produktu.
*/
public function product_combination(): string
{
$db = $GLOBALS['mdb'];
return \Shared\Tpl\Tpl::view( 'shop-product/product-combination', [
'product' => $this->repository->findForAdmin( (int) \Shared\Helpers\Helpers::get( 'product_id' ) ),
'attributes' => ( new \Domain\Attribute\AttributeRepository( $db ) )->getAttributesListForCombinations(),
'default_language' => $this->languagesRepository->defaultLanguage(),
'product_permutations' => $this->repository->getCombinationsForTable( (int) \Shared\Helpers\Helpers::get( 'product_id' ) ),
] );
}
/**
* Generowanie kombinacji.
*/
public function generate_combination(): void
{
$attributes = [];
foreach ( $_POST as $key => $val ) {
if ( strpos( $key, 'attribute_' ) !== false ) {
$attribute = explode( 'attribute_', $key );
$attributes[ $attribute[1] ] = $val;
}
}
if ( $this->repository->generateCombinations( (int) \Shared\Helpers\Helpers::get( 'product_id' ), $attributes ) ) {
\Shared\Helpers\Helpers::alert( 'Kombinacje produktu zostały wygenerowane.' );
}
header( 'Location: /admin/shop_product/product_combination/product_id=' . (int) \Shared\Helpers\Helpers::get( 'product_id' ) );
exit;
}
/**
* Usunięcie kombinacji.
*/
public function delete_combination(): void
{
if ( $this->repository->deleteCombination( (int) \Shared\Helpers\Helpers::get( 'combination_id' ) ) ) {
\Shared\Helpers\Helpers::alert( 'Kombinacja produktu została usunięta' );
} else {
\Shared\Helpers\Helpers::alert( 'Podczas usuwania kombinacji produktu wystąpił błąd. Proszę spróbować ponownie' );
}
header( 'Location: /admin/shop_product/product_combination/product_id=' . \Shared\Helpers\Helpers::get( 'product_id' ) );
exit;
}
/**
* AJAX: zapis stock_0_buy kombinacji.
*/
public function product_combination_stock_0_buy_save(): void
{
$this->repository->saveCombinationStock0Buy( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'stock_0_buy' ) );
echo json_encode( [ 'status' => 'ok' ] );
exit;
}
/**
* AJAX: zapis SKU kombinacji.
*/
public function product_combination_sku_save(): void
{
$this->repository->saveCombinationSku( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'sku' ) );
echo json_encode( [ 'status' => 'ok' ] );
exit;
}
/**
* AJAX: zapis ilości kombinacji.
*/
public function product_combination_quantity_save(): void
{
$this->repository->saveCombinationQuantity( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'quantity' ) );
echo json_encode( [ 'status' => 'ok' ] );
exit;
}
/**
* AJAX: zapis ceny kombinacji.
*/
public function product_combination_price_save(): void
{
$this->repository->saveCombinationPrice( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'price' ) );
echo json_encode( [ 'status' => 'ok' ] );
exit;
}
/**
* AJAX: usunięcie kombinacji bez przeładowania strony.
*/
public function delete_combination_ajax(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas usuwania kombinacji wystąpił błąd.' ];
if ( $this->repository->deleteCombination( (int) \Shared\Helpers\Helpers::get( 'combination_id' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
// ─── Krok 10: Zdjęcia i pliki ───────────────────────────────────
/**
* AJAX: usunięcie zdjęcia produktu.
*/
public function image_delete(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas usuwania zdjecia wystąpił błąd. Proszę spróbować ponownie.' ];
if ( $this->repository->deleteImage( (int) \Shared\Helpers\Helpers::get( 'image_id' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: zapis kolejności zdjęć.
*/
public function images_order_save(): void
{
if ( $this->repository->saveImagesOrder( (int) \Shared\Helpers\Helpers::get( 'product_id' ), \Shared\Helpers\Helpers::get( 'order' ) ) ) {
echo json_encode( [ 'status' => 'ok', 'msg' => 'Produkt został zapisany.' ] );
}
exit;
}
/**
* AJAX: zmiana alt zdjęcia.
*/
public function image_alt_change(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas zmiany atrybutu alt zdjęcia wystąpił błąd. Proszę spróbować ponownie.' ];
if ( $this->repository->updateImageAlt( (int) \Shared\Helpers\Helpers::get( 'image_id' ), \Shared\Helpers\Helpers::get( 'image_alt' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: usunięcie pliku produktu (migracja z ajax/shop.php).
*/
public function product_file_delete(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas usuwania pliku wystąpił błąd.' ];
if ( $this->repository->deleteFile( (int) \Shared\Helpers\Helpers::get( 'file_id' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: zmiana nazwy pliku (migracja z ajax/shop.php).
*/
public function product_file_name_change(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas zmiany nazwy pliku wystąpił błąd.' ];
if ( $this->repository->updateFileName( (int) \Shared\Helpers\Helpers::get( 'file_id' ), \Shared\Helpers\Helpers::get( 'file_name' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
/**
* AJAX: usunięcie zdjęcia produktu (migracja z ajax/shop.php).
*/
public function product_image_delete(): void
{
$response = [ 'status' => 'error', 'msg' => 'Podczas usuwania zdjęcia wystąpił błąd.' ];
if ( $this->repository->deleteImage( (int) \Shared\Helpers\Helpers::get( 'image_id' ) ) ) {
$response = [ 'status' => 'ok' ];
}
echo json_encode( $response );
exit;
}
// ─── Masowa edycja (istniejące) ──────────────────────────────────
/**
* Widok masowej edycji produktów.
*/
public function mass_edit(): string
{
$categoryRepository = new CategoryRepository( $GLOBALS['mdb'] );
return \Shared\Tpl\Tpl::view( 'shop-product/mass-edit', [
'products' => $this->repository->allProductsForMassEdit(),
'categories' => $categoryRepository->subcategories( null ),
'dlang' => $this->languagesRepository->defaultLanguage(),
] );
}
/**
* AJAX: zastosowanie rabatu procentowego na zaznaczonych produktach.
*/
public function mass_edit_save(): void
{
$discountPercent = \Shared\Helpers\Helpers::get( 'discount_percent' );
$products = \Shared\Helpers\Helpers::get( 'products' );
if ( $discountPercent != '' && $products && is_array( $products ) && count( $products ) > 0 ) {
$productId = (int) $products[0];
$result = $this->repository->applyDiscountPercent( $productId, (float) $discountPercent );
if ( $result !== null ) {
echo json_encode( [
'status' => 'ok',
'price_brutto_promo' => $result['price_brutto_promo'],
'price_brutto' => $result['price_brutto'],
] );
exit;
}
}
echo json_encode( [ 'status' => 'error' ] );
exit;
}
/**
* AJAX: pobranie ID produktów z danej kategorii.
*/
public function get_products_by_category(): void
{
$categoryId = (int) \Shared\Helpers\Helpers::get( 'category_id' );
$products = $this->repository->getProductsByCategory( $categoryId );
echo json_encode( [ 'status' => 'ok', 'products' => $products ] );
exit;
}
}