diff --git a/admin/templates/shop-product-sets/product-set-edit-custom-script.php b/admin/templates/shop-product-sets/product-set-edit-custom-script.php
new file mode 100644
index 0000000..23d8702
--- /dev/null
+++ b/admin/templates/shop-product-sets/product-set-edit-custom-script.php
@@ -0,0 +1,64 @@
+
+
+
+
diff --git a/admin/templates/shop-product-sets/product-set-edit.php b/admin/templates/shop-product-sets/product-set-edit.php
new file mode 100644
index 0000000..557b0ac
--- /dev/null
+++ b/admin/templates/shop-product-sets/product-set-edit.php
@@ -0,0 +1,2 @@
+= \Tpl::view('components/form-edit', ['form' => $this->form]); ?>
+= \Tpl::view('shop-product-sets/product-set-edit-custom-script'); ?>
diff --git a/admin/templates/shop-product-sets/product-sets-list.php b/admin/templates/shop-product-sets/product-sets-list.php
new file mode 100644
index 0000000..0f89c5b
--- /dev/null
+++ b/admin/templates/shop-product-sets/product-sets-list.php
@@ -0,0 +1 @@
+= \Tpl::view('components/table-list', ['list' => $this->viewModel]); ?>
diff --git a/admin/templates/shop-product-sets/set-edit.php b/admin/templates/shop-product-sets/set-edit.php
deleted file mode 100644
index 4dafbe4..0000000
--- a/admin/templates/shop-product-sets/set-edit.php
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-ob_start();
-?>
-
-
-
-
- = \Html::input( [
- 'label' => 'Nazwa',
- 'name' => 'name',
- 'id' => 'name',
- 'value' => $this -> set[ 'name' ],
- ] );
- ?>
- =
- \Html::input_switch( [
- 'label' => 'Aktywny',
- 'name' => 'status',
- 'checked' => $this -> set[ 'status' ] == 1 ? true : false
- ] );
- ?>
-
-
-
-
-
-$out = ob_get_clean();
-
-$grid = new \gridEdit;
-$grid -> id = 'set-edit';
-$grid -> gdb_opt = $gdb;
-$grid -> include_plugins = true;
-$grid -> title = 'Edycja kompletu produktów: ' . '' . '';
-$grid -> fields = [
- [
- 'db' => 'id',
- 'type' => 'hidden',
- 'value' => $this -> set[ 'id' ]
- ]
-];
-$grid -> actions = [
- 'save' => [ 'url' => '/admin/shop_product_sets/save/', 'back_url' => '/admin/shop_product_sets/view_list/' ],
- 'cancel' => [ 'url' => '/admin/shop_product_sets/view_list/' ]
-];
-$grid -> external_code = $out;
-$grid -> persist_edit = true;
-$grid -> id_param = 'id';
-
-echo $grid -> draw();
-?>
-
-
-
-
\ No newline at end of file
diff --git a/admin/templates/shop-product-sets/view-list.php b/admin/templates/shop-product-sets/view-list.php
deleted file mode 100644
index f86fb81..0000000
--- a/admin/templates/shop-product-sets/view-list.php
+++ /dev/null
@@ -1,76 +0,0 @@
- gdb_opt = $gdb;
-$grid -> limit = 10;
-$grid -> sql = 'SELECT *'
- . 'FROM ( '
- . 'SELECT '
- . 'id, name, status '
- . 'FROM '
- . 'pp_shop_product_sets AS psps '
- . ') AS q1 '
- . 'WHERE '
- . '1=1 [where] '
- . 'ORDER BY '
- . '[order_p1] [order_p2]';
-$grid -> sql_count = 'SELECT '
- . 'COUNT(0) FROM ( '
- . 'SELECT '
- . 'id, name, status '
- . 'FROM '
- . 'pp_shop_product_sets AS psps '
- . ') AS q1 '
- . 'WHERE '
- . '1=1 [where] ';
-$grid -> debug = true;
-$grid -> order = [ 'column' => 'name', 'type' => 'ASC' ];
-$grid -> search = [
- [ 'name' => 'Nazwa', 'db' => 'name', 'type' => 'text' ],
- [ 'name' => 'Aktywny', 'db' => 'status', 'type' => 'select', 'replace' => [ 'array' => [ 0 => 'nie', 1 => 'tak' ] ] ]
- ];
-$grid -> columns_view = [
- [
- 'name' => 'Lp.',
- 'th' => [ 'class' => 'g-lp' ],
- 'td' => [ 'class' => 'g-center' ],
- 'autoincrement' => true
- ],
- [
- 'name' => 'Nazwa',
- 'db' => 'name',
- 'sort' => true,
- 'php' => 'echo "" . htmlspecialchars( \'[name]\' ) . "";'
- ],
- [
- 'name' => 'Aktywny',
- 'db' => 'status',
- 'replace' => [ 'array' => [ 0 => 'nie', 1 => 'tak' ] ],
- 'td' => [ 'class' => 'g-center' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 150px;' ],
- 'sort' => true
- ],
- [
- 'name' => 'Edytuj',
- 'action' => [ 'type' => 'edit', 'url' => '/admin/shop_product_sets/set_edit/id=[id]' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 70px;' ],
- 'td' => [ 'class' => 'g-center' ]
- ],
- [
- 'name' => 'Usuń',
- 'action' => [ 'type' => 'delete', 'url' => '/admin/shop_product_sets/set_delete/id=[id]' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 70px;' ],
- 'td' => [ 'class' => 'g-center' ]
- ]
- ];
-$grid -> buttons = [
- [
- 'label' => 'Dodaj komplet produktów',
- 'url' => '/admin/shop_product_sets/set_edit/',
- 'icon' => 'fa-plus-circle',
- 'class' => 'btn-success'
- ]
- ];
-echo $grid -> draw();
-?>
\ No newline at end of file
diff --git a/admin/templates/site/main-layout.php b/admin/templates/site/main-layout.php
index 09abc89..ef7f00f 100644
--- a/admin/templates/site/main-layout.php
+++ b/admin/templates/site/main-layout.php
@@ -65,7 +65,7 @@
-
+
Komplety Produktów
diff --git a/autoload/Domain/ProductSet/ProductSetRepository.php b/autoload/Domain/ProductSet/ProductSetRepository.php
new file mode 100644
index 0000000..0290430
--- /dev/null
+++ b/autoload/Domain/ProductSet/ProductSetRepository.php
@@ -0,0 +1,250 @@
+db = $db;
+ }
+
+ /**
+ * @return array{items: array>, total: int}
+ */
+ public function listForAdmin(
+ array $filters,
+ string $sortColumn = 'name',
+ string $sortDir = 'ASC',
+ int $page = 1,
+ int $perPage = 15
+ ): array {
+ $allowedSortColumns = [
+ 'id' => 'ps.id',
+ 'name' => 'ps.name',
+ 'status' => 'ps.status',
+ ];
+
+ $sortSql = $allowedSortColumns[$sortColumn] ?? 'ps.name';
+ $sortDir = strtoupper(trim($sortDir)) === 'DESC' ? 'DESC' : 'ASC';
+ $page = max(1, $page);
+ $perPage = min(self::MAX_PER_PAGE, max(1, $perPage));
+ $offset = ($page - 1) * $perPage;
+
+ $where = ['1 = 1'];
+ $params = [];
+
+ $name = trim((string)($filters['name'] ?? ''));
+ if ($name !== '') {
+ if (strlen($name) > 255) {
+ $name = substr($name, 0, 255);
+ }
+ $where[] = 'ps.name LIKE :name';
+ $params[':name'] = '%' . $name . '%';
+ }
+
+ $status = trim((string)($filters['status'] ?? ''));
+ if ($status === '0' || $status === '1') {
+ $where[] = 'ps.status = :status';
+ $params[':status'] = (int)$status;
+ }
+
+ $whereSql = implode(' AND ', $where);
+
+ $sqlCount = "
+ SELECT COUNT(0)
+ FROM pp_shop_product_sets AS ps
+ WHERE {$whereSql}
+ ";
+
+ $stmtCount = $this->db->query($sqlCount, $params);
+ $countRows = $stmtCount ? $stmtCount->fetchAll() : [];
+ $total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0;
+
+ $sql = "
+ SELECT
+ ps.id,
+ ps.name,
+ ps.status
+ FROM pp_shop_product_sets AS ps
+ WHERE {$whereSql}
+ ORDER BY {$sortSql} {$sortDir}, ps.id DESC
+ LIMIT {$perPage} OFFSET {$offset}
+ ";
+
+ $stmt = $this->db->query($sql, $params);
+ $items = $stmt ? $stmt->fetchAll() : [];
+
+ if (!is_array($items)) {
+ $items = [];
+ }
+
+ foreach ($items as &$item) {
+ $item['id'] = (int)($item['id'] ?? 0);
+ $item['status'] = $this->toSwitchValue($item['status'] ?? 0);
+ }
+ unset($item);
+
+ return [
+ 'items' => $items,
+ 'total' => $total,
+ ];
+ }
+
+ public function find(int $id): array
+ {
+ if ($id <= 0) {
+ return $this->defaultSet();
+ }
+
+ $set = $this->db->get('pp_shop_product_sets', '*', ['id' => $id]);
+ if (!is_array($set)) {
+ return $this->defaultSet();
+ }
+
+ $set['id'] = (int)($set['id'] ?? 0);
+ $set['status'] = $this->toSwitchValue($set['status'] ?? 0);
+
+ $products = $this->db->select('pp_shop_product_sets_products', 'product_id', ['set_id' => $id]);
+ $set['products'] = is_array($products) ? array_map('intval', $products) : [];
+
+ return $set;
+ }
+
+ public function save(int $id, string $name, int $status, array $productIds): ?int
+ {
+ $row = [
+ 'name' => trim($name),
+ 'status' => $status === 1 ? 1 : 0,
+ ];
+
+ if ($id <= 0) {
+ $this->db->insert('pp_shop_product_sets', $row);
+ $id = (int)$this->db->id();
+ if ($id <= 0) {
+ return null;
+ }
+ } else {
+ $this->db->update('pp_shop_product_sets', $row, ['id' => $id]);
+ }
+
+ $this->syncProducts($id, $productIds);
+ $this->clearTempAndCache();
+
+ return $id;
+ }
+
+ public function delete(int $id): bool
+ {
+ if ($id <= 0) {
+ return false;
+ }
+
+ $this->db->delete('pp_shop_product_sets_products', ['set_id' => $id]);
+ $result = (bool)$this->db->delete('pp_shop_product_sets', ['id' => $id]);
+
+ if ($result) {
+ $this->clearTempAndCache();
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array
+ */
+ public function allSets(): array
+ {
+ $rows = $this->db->select('pp_shop_product_sets', ['id', 'name'], ['ORDER' => ['name' => 'ASC']]);
+ if (!is_array($rows)) {
+ return [];
+ }
+
+ $sets = [];
+ foreach ($rows as $row) {
+ $sets[] = [
+ 'id' => (int)($row['id'] ?? 0),
+ 'name' => (string)($row['name'] ?? ''),
+ ];
+ }
+
+ return $sets;
+ }
+
+ /**
+ * @return array
+ */
+ public function allProductsMap(): array
+ {
+ $rows = $this->db->select('pp_shop_products', 'id', ['parent_id' => null]);
+ if (!is_array($rows)) {
+ return [];
+ }
+
+ $products = [];
+ foreach ($rows as $productId) {
+ $name = $this->db->get('pp_shop_products_langs', 'name', [
+ 'AND' => ['product_id' => $productId, 'lang_id' => 'pl'],
+ ]);
+ if ($name) {
+ $products[(int)$productId] = (string)$name;
+ }
+ }
+
+ return $products;
+ }
+
+ private function syncProducts(int $setId, array $productIds): void
+ {
+ $this->db->delete('pp_shop_product_sets_products', ['set_id' => $setId]);
+
+ $seen = [];
+ foreach ($productIds as $productId) {
+ $pid = (int)$productId;
+ if ($pid > 0 && !isset($seen[$pid])) {
+ $this->db->insert('pp_shop_product_sets_products', [
+ 'set_id' => $setId,
+ 'product_id' => $pid,
+ ]);
+ $seen[$pid] = true;
+ }
+ }
+ }
+
+ private function defaultSet(): array
+ {
+ return [
+ 'id' => 0,
+ 'name' => '',
+ 'status' => 1,
+ 'products' => [],
+ ];
+ }
+
+ private function toSwitchValue($value): int
+ {
+ if (is_bool($value)) {
+ return $value ? 1 : 0;
+ }
+
+ if (is_numeric($value)) {
+ return ((int)$value) === 1 ? 1 : 0;
+ }
+
+ if (is_string($value)) {
+ $normalized = strtolower(trim($value));
+ return in_array($normalized, ['1', 'on', 'true', 'yes'], true) ? 1 : 0;
+ }
+
+ return 0;
+ }
+
+ private function clearTempAndCache(): void
+ {
+ \S::delete_dir('../temp/');
+ \S::delete_dir('../thumbs/');
+ }
+}
diff --git a/autoload/admin/Controllers/ShopProductSetsController.php b/autoload/admin/Controllers/ShopProductSetsController.php
new file mode 100644
index 0000000..b963a09
--- /dev/null
+++ b/autoload/admin/Controllers/ShopProductSetsController.php
@@ -0,0 +1,328 @@
+repository = $repository;
+ }
+
+ public function list(): string
+ {
+ $sortableColumns = ['id', 'name', 'status'];
+ $filterDefinitions = [
+ [
+ 'key' => 'name',
+ 'label' => 'Nazwa',
+ 'type' => 'text',
+ ],
+ [
+ 'key' => 'status',
+ 'label' => 'Aktywny',
+ 'type' => 'select',
+ 'options' => [
+ '' => '- aktywny -',
+ '1' => 'tak',
+ '0' => 'nie',
+ ],
+ ],
+ ];
+
+ $listRequest = \admin\Support\TableListRequestFactory::fromRequest(
+ $filterDefinitions,
+ $sortableColumns,
+ 'name'
+ );
+
+ $sortDir = $listRequest['sortDir'];
+ if (trim((string)\S::get('sort')) === '') {
+ $sortDir = 'ASC';
+ }
+
+ $result = $this->repository->listForAdmin(
+ $listRequest['filters'],
+ $listRequest['sortColumn'],
+ $sortDir,
+ $listRequest['page'],
+ $listRequest['perPage']
+ );
+
+ $rows = [];
+ $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1;
+ foreach ($result['items'] as $item) {
+ $id = (int)($item['id'] ?? 0);
+ $name = trim((string)($item['name'] ?? ''));
+ $status = (int)($item['status'] ?? 0);
+
+ $rows[] = [
+ 'lp' => $lp++ . '.',
+ 'name' => '' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '',
+ 'status' => $status === 1 ? 'tak' : 'nie',
+ '_actions' => [
+ [
+ 'label' => 'Edytuj',
+ 'url' => '/admin/shop_product_sets/edit/id=' . $id,
+ 'class' => 'btn btn-xs btn-primary',
+ ],
+ [
+ 'label' => 'Usun',
+ 'url' => '/admin/shop_product_sets/delete/id=' . $id,
+ 'class' => 'btn btn-xs btn-danger',
+ 'confirm' => 'Na pewno chcesz usunac wybrany komplet produktow?',
+ 'confirm_ok' => 'Usun',
+ 'confirm_cancel' => 'Anuluj',
+ ],
+ ],
+ ];
+ }
+
+ $total = (int)$result['total'];
+ $totalPages = max(1, (int)ceil($total / $listRequest['perPage']));
+
+ $viewModel = new PaginatedTableViewModel(
+ [
+ ['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false],
+ ['key' => 'name', 'sort_key' => 'name', 'label' => 'Nazwa', 'sortable' => true, 'raw' => true],
+ ['key' => 'status', 'sort_key' => 'status', 'label' => 'Aktywny', 'class' => 'text-center', 'sortable' => true, 'raw' => true],
+ ],
+ $rows,
+ $listRequest['viewFilters'],
+ [
+ 'column' => $listRequest['sortColumn'],
+ 'dir' => $sortDir,
+ ],
+ [
+ 'page' => $listRequest['page'],
+ 'per_page' => $listRequest['perPage'],
+ 'total' => $total,
+ 'total_pages' => $totalPages,
+ ],
+ array_merge($listRequest['queryFilters'], [
+ 'sort' => $listRequest['sortColumn'],
+ 'dir' => $sortDir,
+ 'per_page' => $listRequest['perPage'],
+ ]),
+ $listRequest['perPageOptions'],
+ $sortableColumns,
+ '/admin/shop_product_sets/list/',
+ 'Brak danych w tabeli.',
+ '/admin/shop_product_sets/edit/',
+ 'Dodaj komplet produktow'
+ );
+
+ return \Tpl::view('shop-product-sets/product-sets-list', [
+ 'viewModel' => $viewModel,
+ ]);
+ }
+
+ public function view_list(): string
+ {
+ return $this->list();
+ }
+
+ public function edit(): string
+ {
+ $set = $this->repository->find((int)\S::get('id'));
+ $products = $this->repository->allProductsMap();
+
+ return \Tpl::view('shop-product-sets/product-set-edit', [
+ 'form' => $this->buildFormViewModel($set, $products),
+ ]);
+ }
+
+ public function set_edit(): string
+ {
+ return $this->edit();
+ }
+
+ public function save(): void
+ {
+ $legacyValues = \S::get('values');
+
+ if ($legacyValues) {
+ $values = json_decode((string)$legacyValues, true);
+ $response = [
+ 'status' => 'error',
+ 'msg' => 'Podczas zapisywania kompletu produktow wystapil blad. Prosze sprobowac ponownie.',
+ ];
+
+ if (is_array($values)) {
+ $productIds = $values['set_products_id'] ?? [];
+ if (!is_array($productIds)) {
+ $productIds = $productIds ? [$productIds] : [];
+ }
+
+ $id = $this->repository->save(
+ (int)($values['id'] ?? 0),
+ (string)($values['name'] ?? ''),
+ $this->toSwitchValue($values['status'] ?? 0),
+ $productIds
+ );
+
+ if (!empty($id)) {
+ $response = [
+ 'status' => 'ok',
+ 'msg' => 'Komplet produktow zostal zapisany.',
+ 'id' => (int)$id,
+ ];
+ }
+ }
+
+ echo json_encode($response);
+ exit;
+ }
+
+ $payload = $_POST;
+ if (empty($payload['id'])) {
+ $routeId = (int)\S::get('id');
+ if ($routeId > 0) {
+ $payload['id'] = $routeId;
+ }
+ }
+
+ $productIds = $payload['set_products_id'] ?? [];
+ if (!is_array($productIds)) {
+ $productIds = $productIds ? [$productIds] : [];
+ }
+
+ $id = $this->repository->save(
+ (int)($payload['id'] ?? 0),
+ (string)($payload['name'] ?? ''),
+ $this->toSwitchValue($payload['status'] ?? 0),
+ $productIds
+ );
+
+ if (!empty($id)) {
+ echo json_encode([
+ 'success' => true,
+ 'id' => (int)$id,
+ 'message' => 'Komplet produktow zostal zapisany.',
+ ]);
+ exit;
+ }
+
+ echo json_encode([
+ 'success' => false,
+ 'errors' => ['general' => 'Podczas zapisywania kompletu produktow wystapil blad.'],
+ ]);
+ exit;
+ }
+
+ public function delete(): void
+ {
+ if ($this->repository->delete((int)\S::get('id'))) {
+ \S::alert('Komplet produktow zostal usuniety.');
+ }
+
+ header('Location: /admin/shop_product_sets/list/');
+ exit;
+ }
+
+ public function set_delete(): void
+ {
+ $this->delete();
+ }
+
+ private function buildFormViewModel(array $set, array $products = []): FormEditViewModel
+ {
+ $id = (int)($set['id'] ?? 0);
+ $isNew = $id <= 0;
+ $selectedProducts = $set['products'] ?? [];
+
+ $data = [
+ 'id' => $id,
+ 'name' => (string)($set['name'] ?? ''),
+ 'status' => (int)($set['status'] ?? 1),
+ ];
+
+ $productsSelectHtml = $this->renderProductsSelect($products, $selectedProducts);
+
+ $fields = [
+ FormField::hidden('id', $id),
+ FormField::text('name', [
+ 'label' => 'Nazwa',
+ 'tab' => 'settings',
+ 'required' => true,
+ ]),
+ FormField::switch('status', [
+ 'label' => 'Aktywny',
+ 'tab' => 'settings',
+ 'value' => true,
+ ]),
+ FormField::custom('set_products', $productsSelectHtml, [
+ 'tab' => 'settings',
+ ]),
+ ];
+
+ $tabs = [
+ new FormTab('settings', 'Ustawienia', 'fa-wrench'),
+ ];
+
+ $actionUrl = '/admin/shop_product_sets/save/' . ($isNew ? '' : ('id=' . $id));
+ $actions = [
+ FormAction::save($actionUrl, '/admin/shop_product_sets/list/'),
+ FormAction::cancel('/admin/shop_product_sets/list/'),
+ ];
+
+ return new FormEditViewModel(
+ 'shop-product-set-edit',
+ $isNew ? 'Nowy komplet produktow' : ('Edycja kompletu produktow: ' . (string)($set['name'] ?? '')),
+ $data,
+ $fields,
+ $tabs,
+ $actions,
+ 'POST',
+ $actionUrl,
+ '/admin/shop_product_sets/list/',
+ true,
+ ['id' => $id]
+ );
+ }
+
+ private function toSwitchValue($value): int
+ {
+ if (is_numeric($value)) {
+ return ((int)$value) === 1 ? 1 : 0;
+ }
+
+ if (is_string($value)) {
+ $normalized = strtolower(trim($value));
+ return in_array($normalized, ['1', 'on', 'true', 'yes'], true) ? 1 : 0;
+ }
+
+ return 0;
+ }
+
+ private function renderProductsSelect(array $products, array $selectedProducts): string
+ {
+ $html = '';
+
+ return $html;
+ }
+}
diff --git a/autoload/admin/class.Site.php b/autoload/admin/class.Site.php
index 2f47fd5..72e2da8 100644
--- a/autoload/admin/class.Site.php
+++ b/autoload/admin/class.Site.php
@@ -362,6 +362,13 @@ class Site
new \Domain\ShopStatus\ShopStatusRepository( $mdb )
);
},
+ 'ShopProductSets' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopProductSetsController(
+ new \Domain\ProductSet\ProductSetRepository( $mdb )
+ );
+ },
];
return self::$newControllers;
diff --git a/autoload/admin/controls/class.ShopProductSets.php b/autoload/admin/controls/class.ShopProductSets.php
deleted file mode 100644
index 757a2d5..0000000
--- a/autoload/admin/controls/class.ShopProductSets.php
+++ /dev/null
@@ -1,43 +0,0 @@
- 'error', 'msg' => 'Podczas zapisywania kompletu produktów wystąpił błąd. Proszę spróbować ponownie.' ];
- $values = json_decode( \S::get( 'values' ), true );
-
- if ( $id = \admin\factory\ShopProductSet::save(
- (int)$values['id'], $values['name'], (string) $values['status'], $values['set_products_id']
- ) ) {
- $response = [ 'status' => 'ok', 'msg' => 'Komplet produktów został zapisany.', 'id' => $id ];
- }
-
- echo json_encode( $response );
- exit;
- }
-
- static public function set_edit()
- {
- return \Tpl::view( 'shop-product-sets/set-edit', [
- 'set' => new \shop\ProductSet( (int) \S::get( 'id' ) ),
- 'products' => \admin\factory\ShopProduct::products_list()
- ] );
- }
-
- static public function view_list()
- {
- return \Tpl::view( 'shop-product-sets/view-list' );
- }
-}
\ No newline at end of file
diff --git a/autoload/admin/factory/class.ShopProductSet.php b/autoload/admin/factory/class.ShopProductSet.php
deleted file mode 100644
index d3276cf..0000000
--- a/autoload/admin/factory/class.ShopProductSet.php
+++ /dev/null
@@ -1,68 +0,0 @@
-
-namespace admin\factory;
-
-class ShopProductSet
-{
- // zapisywanie kompletu produktów
- static public function save( int $set_id, string $name, string $status, $set_products_id )
- {
- global $mdb;
-
- if ( !$set_id )
- {
- $mdb -> insert('pp_shop_product_sets', [
- 'name' => $name,
- 'status' => 'on' === $status ? 1 : 0
- ] );
-
- $id = $mdb -> id();
-
- if ( $set_products_id == null )
- $not_in = [ 0 ];
- elseif ( !is_array( $set_products_id ) )
- $not_in = [ 0, $set_products_id ];
- elseif ( is_array( $set_products_id ) )
- $not_in = $set_products_id;
-
- foreach ( $not_in as $product_id )
- {
- if ( $product_id != 0 )
- $mdb -> insert( 'pp_shop_product_sets_products', [ 'set_id' => $id, 'product_id' => $product_id ] );
- }
-
- \S::delete_dir('../temp/');
- \S::delete_dir('../thumbs/');
-
- return $id;
- }
- else
- {
- $mdb -> update('pp_shop_product_sets', [
- 'name' => $name,
- 'status' => 'on' === $status ? 1 : 0
- ], [
- 'id' => (int)$set_id,
- ] );
-
- $mdb -> delete( 'pp_shop_product_sets_products', [ 'set_id' => $set_id ] );
-
- if ( $set_products_id == null )
- $not_in = [ 0 ];
- elseif ( !is_array( $set_products_id ) )
- $not_in = [ 0, $set_products_id ];
- elseif ( is_array( $set_products_id ) )
- $not_in = $set_products_id;
-
- foreach ( $not_in as $product_id )
- {
- if ( $product_id != 0 )
- $mdb -> insert( 'pp_shop_product_sets_products', [ 'set_id' => $set_id, 'product_id' => $product_id ] );
- }
-
- \S::delete_dir('../temp/');
- \S::delete_dir('../thumbs/');
-
- return $set_id;
- }
- }
-}
\ No newline at end of file
diff --git a/autoload/shop/class.ProductSet.php b/autoload/shop/class.ProductSet.php
index 78dda25..cca00cb 100644
--- a/autoload/shop/class.ProductSet.php
+++ b/autoload/shop/class.ProductSet.php
@@ -23,32 +23,26 @@ class ProductSet implements \ArrayAccess
{
global $mdb;
- $result = $mdb -> get( 'pp_shop_product_sets', '*', [ 'id' => $set_id ] );
- if ( \S::is_array_fix( $result ) ) foreach ( $result as $key => $val )
- $this -> $key = $val;
+ $repo = new \Domain\ProductSet\ProductSetRepository( $mdb );
+ $data = $repo->find( $set_id );
- $this -> products = $mdb -> select( 'pp_shop_product_sets_products', 'product_id', [ 'set_id' => $set_id ] );
+ foreach ( $data as $key => $val )
+ $this->$key = $val;
}
- //lista dostępnych kompletów
+ //lista dostepnych kompletow (fasada do repozytorium)
static public function sets_list()
{
global $mdb;
- return $mdb -> select( 'pp_shop_product_sets', [ 'id', 'name' ], [ 'ORDER' => [ 'name' => 'ASC' ] ] );
+ $repo = new \Domain\ProductSet\ProductSetRepository( $mdb );
+ return $repo->allSets();
}
- // usuwanie kompletu produktów
+ // usuwanie kompletu produktow (fasada do repozytorium)
static public function set_delete( int $set_id )
{
global $mdb;
-
- if (
- $mdb -> delete( 'pp_shop_product_sets_products', [ 'set_id' => $set_id ] )
- and
- $mdb -> delete( 'pp_shop_product_sets', [ 'id' => $set_id ] )
- )
- return true;
-
- return false;
+ $repo = new \Domain\ProductSet\ProductSetRepository( $mdb );
+ return $repo->delete( $set_id );
}
-}
\ No newline at end of file
+}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a71517a..354840a 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -4,6 +4,20 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
---
+## ver. 0.272 (2026-02-15) - ShopProductSets
+
+- **ShopProductSets** - migracja `/admin/shop_product_sets` na Domain + DI + nowe widoki
+ - NOWE: `Domain\ProductSet\ProductSetRepository` (`listForAdmin`, `find`, `save`, `delete`, `allSets`, `allProductsMap`)
+ - NOWE: `admin\Controllers\ShopProductSetsController` (DI) z akcjami `list`, `edit`, `save`, `delete`
+ - UPDATE: modul `/admin/shop_product_sets/*` dziala na `components/table-list` i `components/form-edit` + Selectize multi-select produktow
+ - UPDATE: routing i menu admin na kanoniczny URL `/admin/shop_product_sets/list/`
+ - UPDATE: `shop\ProductSet` przepiety na fasade do `Domain\ProductSet\ProductSetRepository`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopProductSets.php`, `autoload/admin/factory/class.ShopProductSet.php`, `admin/templates/shop-product-sets/view-list.php`, `admin/templates/shop-product-sets/set-edit.php`
+ - TEST: dodane `tests/Unit/Domain/ProductSet/ProductSetRepositoryTest.php` i `tests/Unit/admin/Controllers/ShopProductSetsControllerTest.php`
+- Testy: **OK (324 tests, 1000 assertions)**
+
+---
+
## ver. 0.271 (2026-02-14) - ShopAttribute
- **ShopAttribute** - migracja `/admin/shop_attribute` na Domain + DI + nowe widoki
diff --git a/docs/DATABASE_STRUCTURE.md b/docs/DATABASE_STRUCTURE.md
index 6a5853e..5ca1d44 100644
--- a/docs/DATABASE_STRUCTURE.md
+++ b/docs/DATABASE_STRUCTURE.md
@@ -516,3 +516,27 @@ Statusy zamowien sklepu (modul `/admin/shop_statuses`). Statusy sa predefiniowan
**Uzywane w:** `Domain\ShopStatus\ShopStatusRepository`, `admin\Controllers\ShopStatusesController`, `front\factory\ShopStatuses`, `shop\Order`, `cron.php`
**Aktualizacja 2026-02-14 (ver. 0.267):** modul `/admin/shop_statuses` korzysta z `Domain\ShopStatus\ShopStatusRepository` przez `admin\Controllers\ShopStatusesController`. Usunieto legacy klasy `admin\controls\ShopStatuses` i `admin\factory\ShopStatuses`. `front\factory\ShopStatuses` dziala jako fasada do repozytorium.
+
+## pp_shop_product_sets
+Komplety produktow (modul `/admin/shop_product_sets`).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Nazwa kompletu |
+| status | Status: 1 = aktywny, 0 = nieaktywny |
+
+**Uzywane w:** `Domain\ProductSet\ProductSetRepository`, `admin\Controllers\ShopProductSetsController`, `shop\ProductSet`, `shop\Product`
+
+## pp_shop_product_sets_products
+Powiazanie kompletow z produktami (tabela lacznikowa).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| set_id | FK do pp_shop_product_sets |
+| product_id | FK do pp_shop_products |
+
+**Uzywane w:** `Domain\ProductSet\ProductSetRepository`, `shop\Product`, `front\factory\ShopProduct`, `admin\factory\ShopProduct`
+
+**Aktualizacja 2026-02-15 (ver. 0.272):** modul `/admin/shop_product_sets` korzysta z `Domain\ProductSet\ProductSetRepository` przez `admin\Controllers\ShopProductSetsController`. Usunieto legacy klasy `admin\controls\ShopProductSets` i `admin\factory\ShopProductSet`. `shop\ProductSet` dziala jako fasada do repozytorium.
diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md
index c02b230..915fe3c 100644
--- a/docs/PROJECT_STRUCTURE.md
+++ b/docs/PROJECT_STRUCTURE.md
@@ -220,6 +220,8 @@ autoload/
│ │ └── ShopStatusRepository.php
│ ├── Transport/
│ │ └── TransportRepository.php
+│ ├── ProductSet/
+│ │ └── ProductSetRepository.php
│ └── ...
├── admin/
│ ├── Controllers/ # Nowe kontrolery (namespace \admin\Controllers\)
@@ -285,5 +287,12 @@ Pelna dokumentacja testow: `TESTING.md`
- Przepieto zaleznosci kombinacji produktu na `Domain\Attribute\AttributeRepository` i `shop\ProductAttribute`.
- Dla `ShopAttribute` routing celowo nie wykonuje fallbacku akcji do legacy kontrolera.
+## Dodatkowa aktualizacja 2026-02-15 (ver. 0.272)
+- Dodano modul domenowy `Domain/ProductSet/ProductSetRepository.php`.
+- Dodano kontroler DI `admin/Controllers/ShopProductSetsController.php`.
+- Modul `/admin/shop_product_sets/*` dziala na nowych widokach (`product-sets-list`, `product-set-edit`).
+- Usunieto legacy: `autoload/admin/controls/class.ShopProductSets.php`, `autoload/admin/factory/class.ShopProductSet.php`, `admin/templates/shop-product-sets/view-list.php`, `admin/templates/shop-product-sets/set-edit.php`.
+- `shop\ProductSet` przepiety na fasade do `Domain\ProductSet\ProductSetRepository`.
+
---
-*Dokument aktualizowany: 2026-02-14*
+*Dokument aktualizowany: 2026-02-15*
diff --git a/docs/REFACTORING_PLAN.md b/docs/REFACTORING_PLAN.md
index 449864a..564c71b 100644
--- a/docs/REFACTORING_PLAN.md
+++ b/docs/REFACTORING_PLAN.md
@@ -151,6 +151,7 @@ grep -r "Product::getQuantity" .
| 20 | ShopPaymentMethod | 0.268 | listForAdmin, find, save, allActive, mapowanie Apilo, DI kontroler |
| 21 | ShopTransport | 0.269 | listForAdmin, find, save, allActive, allForAdmin, findActiveById, getTransportCost, lowestTransportPrice, getApiloCarrierAccountId, powiazanie z PaymentMethod, DI kontroler |
| 22 | ShopAttribute | 0.271 | list/edit/save/delete/values, nowy edytor wartosci, cleanup legacy, przepiecie zaleznosci kombinacji |
+| 23 | ShopProductSets | 0.272 | listForAdmin, find, save, delete, allSets, allProductsMap, multi-select Selectize, DI kontroler |
### Product - szczegolowy status
- ✅ getQuantity (ver. 0.238)
@@ -168,11 +169,11 @@ grep -r "Product::getQuantity" .
## Kolejność refaktoryzacji (priorytet)
-1-22: ✅ Cache, Product, Banner, Settings, Dictionaries, ProductArchive, Filemanager, Users, Pages, Integrations, ShopPromotion, ShopCoupon, ShopStatuses, ShopPaymentMethod, ShopTransport, ShopAttribute
+1-23: ✅ Cache, Product, Banner, Settings, Dictionaries, ProductArchive, Filemanager, Users, Pages, Integrations, ShopPromotion, ShopCoupon, ShopStatuses, ShopPaymentMethod, ShopTransport, ShopAttribute, ShopProductSets
Nastepne:
-23. **Order**
-24. **Category**
+24. **Order**
+25. **Category**
## Form Edit System
@@ -279,5 +280,5 @@ Pelna dokumentacja testow: `TESTING.md`
---
*Rozpoczęto: 2025-02-05*
-*Ostatnia aktualizacja: 2026-02-14*
+*Ostatnia aktualizacja: 2026-02-15*
*Changelog zmian: `docs/CHANGELOG.md`*
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 743dd1a..5489e5e 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -33,10 +33,10 @@ Alternatywnie (Git Bash):
## Aktualny stan suite
-Ostatnio zweryfikowano: 2026-02-14
+Ostatnio zweryfikowano: 2026-02-15
```text
-OK (312 tests, 948 assertions)
+OK (324 tests, 1000 assertions)
```
## Struktura testow
@@ -55,6 +55,7 @@ tests/
| | |-- Integrations/IntegrationsRepositoryTest.php
| | |-- PaymentMethod/PaymentMethodRepositoryTest.php
| | |-- Product/ProductRepositoryTest.php
+| | |-- ProductSet/ProductSetRepositoryTest.php
| | |-- Promotion/PromotionRepositoryTest.php
| | |-- Settings/SettingsRepositoryTest.php
| | |-- ShopStatus/ShopStatusRepositoryTest.php
@@ -70,6 +71,7 @@ tests/
| |-- ShopAttributeControllerTest.php
| |-- ShopCouponControllerTest.php
| |-- ShopPaymentMethodControllerTest.php
+| |-- ShopProductSetsControllerTest.php
| |-- ShopPromotionControllerTest.php
| |-- ShopStatusesControllerTest.php
| |-- ShopTransportControllerTest.php
@@ -398,3 +400,14 @@ OK (312 tests, 948 assertions)
Nowe testy dodane 2026-02-14:
- `tests/Unit/Domain/Attribute/AttributeRepositoryTest.php` (5 testow: domyslne dane cechy, whitelist sortowania/paginacji, zapis wartosci i domyslnej, usuwanie pustych tlumaczen, jezyk domyslny)
- `tests/Unit/admin/Controllers/ShopAttributeControllerTest.php` (7 testow: kontrakty metod, brak aliasow legacy, return types, DI konstruktora, walidacja `validateValuesRows`)
+
+## Aktualizacja suite (ShopProductSets refactor, ver. 0.272)
+Ostatnio zweryfikowano: 2026-02-15
+
+```text
+OK (324 tests, 1000 assertions)
+```
+
+Nowe testy dodane 2026-02-15:
+- `tests/Unit/Domain/ProductSet/ProductSetRepositoryTest.php` (7 testow: find default/normalize, save insert/update, delete invalid, whitelist sortowania/paginacji, allSets)
+- `tests/Unit/admin/Controllers/ShopProductSetsControllerTest.php` (5 testow: kontrakty metod, aliasy legacy, return types, DI konstruktora)
diff --git a/tests/Unit/Domain/ProductSet/ProductSetRepositoryTest.php b/tests/Unit/Domain/ProductSet/ProductSetRepositoryTest.php
new file mode 100644
index 0000000..2998676
--- /dev/null
+++ b/tests/Unit/Domain/ProductSet/ProductSetRepositoryTest.php
@@ -0,0 +1,185 @@
+createMock(\medoo::class);
+ $repository = new ProductSetRepository($mockDb);
+
+ $result = $repository->find(0);
+
+ $this->assertIsArray($result);
+ $this->assertSame(0, $result['id']);
+ $this->assertSame('', $result['name']);
+ $this->assertSame(1, $result['status']);
+ $this->assertSame([], $result['products']);
+ }
+
+ public function testFindNormalizesSetData(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->expects($this->once())
+ ->method('get')
+ ->with('pp_shop_product_sets', '*', ['id' => 5])
+ ->willReturn([
+ 'id' => '5',
+ 'name' => 'Komplet A',
+ 'status' => '1',
+ ]);
+
+ $mockDb->expects($this->once())
+ ->method('select')
+ ->with('pp_shop_product_sets_products', 'product_id', ['set_id' => 5])
+ ->willReturn(['10', '20', '30']);
+
+ $repository = new ProductSetRepository($mockDb);
+ $result = $repository->find(5);
+
+ $this->assertSame(5, $result['id']);
+ $this->assertSame('Komplet A', $result['name']);
+ $this->assertSame(1, $result['status']);
+ $this->assertSame([10, 20, 30], $result['products']);
+ }
+
+ public function testSaveInsertsNewSetAndSyncsProducts(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $insertCalls = [];
+ $deleteCalls = [];
+
+ $mockDb->method('insert')
+ ->willReturnCallback(function ($table, $row) use (&$insertCalls) {
+ $insertCalls[] = ['table' => $table, 'row' => $row];
+ });
+
+ $mockDb->expects($this->once())
+ ->method('id')
+ ->willReturn(42);
+
+ $mockDb->method('delete')
+ ->willReturnCallback(function ($table, $where) use (&$deleteCalls) {
+ $deleteCalls[] = ['table' => $table, 'where' => $where];
+ return true;
+ });
+
+ $repository = new ProductSetRepository($mockDb);
+ $id = $repository->save(0, 'Nowy komplet', 1, [10, 20, 20]);
+
+ $this->assertSame(42, $id);
+
+ $this->assertSame('pp_shop_product_sets', $insertCalls[0]['table']);
+ $this->assertSame('Nowy komplet', $insertCalls[0]['row']['name']);
+ $this->assertSame(1, $insertCalls[0]['row']['status']);
+
+ // Sync: delete old + insert 2 unique products (10, 20)
+ $this->assertSame('pp_shop_product_sets_products', $deleteCalls[0]['table']);
+ $this->assertSame(['set_id' => 42], $deleteCalls[0]['where']);
+
+ $productInserts = array_filter($insertCalls, fn($c) => $c['table'] === 'pp_shop_product_sets_products');
+ $this->assertCount(2, $productInserts);
+ }
+
+ public function testSaveUpdatesExistingSet(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $updateRow = null;
+
+ $mockDb->expects($this->once())
+ ->method('update')
+ ->willReturnCallback(function ($table, $row, $where) use (&$updateRow) {
+ $this->assertSame('pp_shop_product_sets', $table);
+ $this->assertSame(['id' => 7], $where);
+ $updateRow = $row;
+ return true;
+ });
+
+ $mockDb->expects($this->never())->method('id');
+
+ $mockDb->method('delete')->willReturn(true);
+ $mockDb->method('insert')->willReturn(null);
+
+ $repository = new ProductSetRepository($mockDb);
+ $id = $repository->save(7, 'Zaktualizowany', 0, [15]);
+
+ $this->assertSame(7, $id);
+ $this->assertSame('Zaktualizowany', $updateRow['name']);
+ $this->assertSame(0, $updateRow['status']);
+ }
+
+ public function testDeleteReturnsFalseForInvalidId(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->expects($this->never())->method('delete');
+
+ $repository = new ProductSetRepository($mockDb);
+ $this->assertFalse($repository->delete(0));
+ }
+
+ public function testListForAdminWhitelistsSortAndPagination(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $queries = [];
+
+ $mockDb->method('query')
+ ->willReturnCallback(function ($sql, $params = []) use (&$queries) {
+ $queries[] = ['sql' => $sql, 'params' => $params];
+
+ if (strpos($sql, 'COUNT(0)') !== false) {
+ return new class {
+ public function fetchAll() { return [[1]]; }
+ };
+ }
+
+ return new class {
+ public function fetchAll() {
+ return [[
+ 'id' => 1,
+ 'name' => 'Test',
+ 'status' => 1,
+ ]];
+ }
+ };
+ });
+
+ $repository = new ProductSetRepository($mockDb);
+ $result = $repository->listForAdmin(
+ [],
+ 'name DESC; DROP TABLE pp_shop_product_sets; --',
+ 'DESC; DELETE FROM pp_users; --',
+ 1,
+ 999
+ );
+
+ $this->assertCount(2, $queries);
+ $dataSql = $queries[1]['sql'];
+
+ $this->assertMatchesRegularExpression('/ORDER BY\s+ps\.name\s+ASC,\s+ps\.id\s+DESC/i', $dataSql);
+ $this->assertStringNotContainsString('DROP TABLE', $dataSql);
+ $this->assertMatchesRegularExpression('/LIMIT\s+100\s+OFFSET\s+0/i', $dataSql);
+ }
+
+ public function testAllSetsReturnsFormattedList(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->expects($this->once())
+ ->method('select')
+ ->with('pp_shop_product_sets', ['id', 'name'], ['ORDER' => ['name' => 'ASC']])
+ ->willReturn([
+ ['id' => '1', 'name' => 'Komplet A'],
+ ['id' => '2', 'name' => 'Komplet B'],
+ ]);
+
+ $repository = new ProductSetRepository($mockDb);
+ $result = $repository->allSets();
+
+ $this->assertCount(2, $result);
+ $this->assertSame(1, $result[0]['id']);
+ $this->assertSame('Komplet A', $result[0]['name']);
+ $this->assertSame(2, $result[1]['id']);
+ }
+}
diff --git a/tests/Unit/admin/Controllers/ShopProductSetsControllerTest.php b/tests/Unit/admin/Controllers/ShopProductSetsControllerTest.php
new file mode 100644
index 0000000..9f8a4ac
--- /dev/null
+++ b/tests/Unit/admin/Controllers/ShopProductSetsControllerTest.php
@@ -0,0 +1,62 @@
+repository = $this->createMock(ProductSetRepository::class);
+ $this->controller = new ShopProductSetsController($this->repository);
+ }
+
+ public function testConstructorAcceptsRepository(): void
+ {
+ $controller = new ShopProductSetsController($this->repository);
+ $this->assertInstanceOf(ShopProductSetsController::class, $controller);
+ }
+
+ public function testHasMainActionMethods(): void
+ {
+ $this->assertTrue(method_exists($this->controller, 'list'));
+ $this->assertTrue(method_exists($this->controller, 'edit'));
+ $this->assertTrue(method_exists($this->controller, 'save'));
+ $this->assertTrue(method_exists($this->controller, 'delete'));
+ }
+
+ public function testHasLegacyAliasMethods(): void
+ {
+ $this->assertTrue(method_exists($this->controller, 'view_list'));
+ $this->assertTrue(method_exists($this->controller, 'set_edit'));
+ $this->assertTrue(method_exists($this->controller, 'set_delete'));
+ }
+
+ public function testActionMethodReturnTypes(): void
+ {
+ $reflection = new \ReflectionClass($this->controller);
+
+ $this->assertEquals('string', (string)$reflection->getMethod('list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('view_list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('edit')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('set_edit')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('save')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('delete')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('set_delete')->getReturnType());
+ }
+
+ public function testConstructorRequiresProductSetRepository(): void
+ {
+ $reflection = new \ReflectionClass(ShopProductSetsController::class);
+ $constructor = $reflection->getConstructor();
+ $params = $constructor->getParameters();
+
+ $this->assertCount(1, $params);
+ $this->assertEquals('Domain\ProductSet\ProductSetRepository', $params[0]->getType()->getName());
+ }
+}
diff --git a/updates/0.20/ver_0.272.zip b/updates/0.20/ver_0.272.zip
new file mode 100644
index 0000000..852a9c8
Binary files /dev/null and b/updates/0.20/ver_0.272.zip differ
diff --git a/updates/0.20/ver_0.272_files.txt b/updates/0.20/ver_0.272_files.txt
new file mode 100644
index 0000000..008f9fc
--- /dev/null
+++ b/updates/0.20/ver_0.272_files.txt
@@ -0,0 +1,4 @@
+F: ../admin/templates/shop-product-sets/set-edit.php
+F: ../admin/templates/shop-product-sets/view-list.php
+F: ../autoload/admin/controls/class.ShopProductSets.php
+F: ../autoload/admin/factory/class.ShopProductSet.php
diff --git a/updates/changelog.php b/updates/changelog.php
index a2c7dec..1413388 100644
--- a/updates/changelog.php
+++ b/updates/changelog.php
@@ -1,3 +1,12 @@
+ver. 0.272 - 15.02.2026
+- NEW - migracja modulu `ShopProductSets` do architektury Domain + DI (`Domain\ProductSet\ProductSetRepository`, `admin\Controllers\ShopProductSetsController`)
+- UPDATE - modul `/admin/shop_product_sets/*` przepiety z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit` + multi-select Selectize
+- UPDATE - routing i menu admin przepiete na kanoniczny URL `/admin/shop_product_sets/list/`
+- UPDATE - `shop\ProductSet` przepiety na fasade do `Domain\ProductSet\ProductSetRepository`
+- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopProductSets.php`, `autoload/admin/factory/class.ShopProductSet.php`, `admin/templates/shop-product-sets/view-list.php`, `admin/templates/shop-product-sets/set-edit.php`
+- UPDATE - testy: `OK (324 tests, 1000 assertions)` + nowe pliki testowe `ProductSetRepositoryTest`, `ShopProductSetsControllerTest`
+- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.272.zip`, `ver_0.272_files.txt`
+
ver. 0.271 - 14.02.2026
- NEW - migracja modulu `ShopAttribute` do architektury Domain + DI (`Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`)
- UPDATE - modul `/admin/shop_attribute/*` przepiety z legacy `grid/gridEdit` na `components/table-list`, `components/form-edit` oraz nowy edytor wartosci (`values-edit`)
diff --git a/updates/versions.php b/updates/versions.php
index f9a47b1..3c5a3bd 100644
--- a/updates/versions.php
+++ b/updates/versions.php
@@ -1,5 +1,5 @@
-$current_ver = 271;
+$current_ver = 272;
for ($i = 1; $i <= $current_ver; $i++)
{